feat(k1): stabilize LAB bridge and isolate onboard device integration

This commit is contained in:
DCCONSTRUCTIONS
2026-09-07 10:31:33 +03:00
parent 020a878915
commit 63ea2bed67
115 changed files with 13700 additions and 988 deletions
@@ -762,7 +762,15 @@ export async function probeRecordedPerceptionViewerSource(
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,
onStatusChange,
onSelectionChange,
@@ -772,7 +780,7 @@ export function RerunViewport({
onPerceptionLoadChange,
onPointColorLoadChange,
}: 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 sourceUrl = profile.sourceUrl;
const recordedArtifact = recordedProfile?.artifact ?? null;
@@ -34,7 +34,7 @@ import {
} from "../laboratory/CanonicalRecordedLabReplay";
import type { CanonicalLabReplayDescriptor } from "../../core/laboratory/canonicalLabReplay";
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 type { AIViewerLayer } from "../../core/observatory/aiComposition";
import {
@@ -305,7 +305,8 @@ export function CanonicalResultRerunReplay({
accumulationSeconds: showLocalSlam ? sceneDraft.accumulationSeconds : 0,
}), [sceneDraft, showLocalSlam, showSourcePoints, spatialMode]);
const semanticVisible = showDDRNet || showEoMT;
const profile = launch ? recordedSessionRerunProfile({
const profile = launch ? laboratoryResultRerunProfile({
resultId,
sourceUrl: launch.replay.sourceUrl,
artifact: {
sourceUrl: launch.replay.sourceUrl,
@@ -103,6 +103,10 @@ export interface DevicePluginConnectionProps {
}
export interface DeviceUiPlugin {
sensorUi?: {
contributions: readonly import('../../../../../packages/sensor-ui/src/extensions').SensorUiContribution[];
Enrollment?: ComponentType<import('../../../../../packages/sensor-ui/src/extensions').SensorEnrollmentProps>;
};
manifest: DevicePluginManifest;
RuntimeProvider: ComponentType<{
activeModel: DeviceModelDefinition | null;
@@ -113,13 +113,13 @@ const defaultQuickActions: Record<
EnvironmentSurfaceId,
readonly [string | null, string | null]
> = {
home: ["spatial-scene", "local-device"],
fleet: ["contour-health", "local-device"],
observation: ["spatial-scene", "cameras"],
home: ["spatial-scene", "vehicles"],
fleet: ["vehicles", "contour-health"],
observation: ["cameras", "world-map"],
missions: ["mission-planner", null],
data: ["recordings", "datasets"],
system: ["modules", "integrations"],
polygon: ["lab-archive", null],
polygon: ["lab-archive", "local-device"],
};
export function defaultEnvironmentSettings(): EnvironmentSettings {
@@ -85,6 +85,13 @@ export interface RecordedSessionRerunProfile {
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
* been republished as a native Rerun sidecar. It must never be selected by a
@@ -101,7 +108,8 @@ export interface LaboratoryRecordedEvidenceViewerProfile {
export type RerunViewerProfile =
| LiveAcquisitionRerunProfile
| RecordedSessionRerunProfile;
| RecordedSessionRerunProfile
| LaboratoryResultRerunProfile;
export type ObservationViewerProfile =
| RerunViewerProfile
@@ -119,11 +127,18 @@ export const LABORATORY_RECORDED_EVIDENCE_VIEWER_PROFILE = Object.freeze({
export function liveAcquisitionRerunProfile(
input: Omit<LiveAcquisitionRerunProfile, "kind" | "clock">,
): LiveAcquisitionRerunProfile {
return { kind: "live-acquisition", clock: "stream_time", ...input };
return { ...input, kind: "live-acquisition", clock: "stream_time" };
}
export function recordedSessionRerunProfile(
input: Omit<RecordedSessionRerunProfile, "kind" | "clock">,
): 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" };
}
+9 -9
View File
@@ -94,7 +94,7 @@ export const roots: RootDefinition[] = [
label: "Парк",
title: "Аппараты и устройства",
eyebrow: "ПАРК / УСТРОЙСТВА",
description: "Реестр аппаратов, локальное подключение, сенсоры и конфигурации борта.",
description: "Реестр аппаратов, бортовые компьютеры, сенсоры и конфигурации борта.",
statement: "Одинаково подключать одиночный стенд, наземную платформу и будущий рой.",
accent: "АППАРАТЫ И ПОЛЕЗНАЯ НАГРУЗКА",
},
@@ -203,11 +203,11 @@ export const workspaces: WorkspaceDefinition[] = [
},
{
id: "local-device",
root: "fleet",
label: "Подключение",
title: "Подключение",
eyebrow: "ПАРК / ТЕКУЩИЙ АДАПТЕР",
description: "Выбор модели, сценарий установленного плагина и запуск доступного потока.",
root: "polygon",
label: "Тестовые устройства",
title: "Тестовые устройства",
eyebrow: "LAB / ТЕСТОВЫЕ УСТРОЙСТВА",
description: "Подключение устройств к компьютеру оператора для испытаний и записи.",
icon: "network",
kind: "device",
groups: [],
@@ -258,11 +258,11 @@ export const workspaces: WorkspaceDefinition[] = [
},
{
id: "spatial-scene",
root: "observation",
root: "polygon",
label: "Пространственная сцена",
title: "Пространственная сцена",
eyebrow: "НАБЛЮДЕНИЕ / ЭФИР",
description: "Облако точек, траектория, преобразования и пространственные слои в единой 3D-сцене.",
eyebrow: "LAB / ПРОСТРАНСТВЕННАЯ СЦЕНА",
description: "Облако точек, камеры и траектория тестового устройства в реальном времени.",
icon: "globe",
kind: "spatial",
groups: [
@@ -1,13 +1,24 @@
import {useMemo} from 'react';
import {useDevicePluginHost} from '../../core/device-plugins/DevicePluginHost';
import {createIsolatedRerunHost} from '../../components/rerun/isolatedRerunHost';
import {SensorWorkspace} from '../../../../../packages/sensor-ui/src/SensorWorkspace';
import type {SensorInventory,SensorTransport} from '../../../../../packages/sensor-ui/src/contracts';
import {fleetRequest} from '../../core/fleet/useFleet';
export function VehicleSensors({vehicleID,enabled,onDetailChange}:{vehicleID:string;enabled:boolean;onDetailChange:(open:boolean)=>void}){
const {registry}=useDevicePluginHost();
const sensorContributions=useMemo(()=>registry.plugins.flatMap(plugin=>plugin.sensorUi?.contributions??[]),[registry]);
const enrollmentViews=registry.plugins.flatMap(plugin=>plugin.sensorUi?.Enrollment?[plugin.sensorUi.Enrollment]:[]);
const SensorEnrollmentView=enrollmentViews.length===1?enrollmentViews[0]:undefined;
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'};},
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),
operation:id=>fleetRequest(`/${encodeURIComponent(vehicleID)}/devices/operations/${encodeURIComponent(id)}`),
}),[vehicleID]);
return <SensorWorkspace key={vehicleID} transport={transport} enabled={enabled} onDetailChange={onDetailChange}/>;
return <SensorWorkspace contributions={sensorContributions} EnrollmentView={SensorEnrollmentView} 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/,
);
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, /hasTgs \? 0\.000001 : 0/);
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 = {
connection_policy: {
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, {
reason: "В свежем Bluetooth-поиске не найден K1, связанный с незавершённой записью.",
nextAction: "Дождитесь автоматического восстановления сохранённого подключения K1.",
nextAction: "Нажмите «Переподключиться», чтобы проверить сохранённое подключение K1.",
});
assert.doesNotMatch(
`${durableGuidance.reason} ${durableGuidance.nextAction}`,
@@ -1725,7 +1725,7 @@ test("restart-recovery guidance describes automatic recovery without protocol ce
deniedFresh,
"observe-fresh-device-network",
).nextAction,
"Дождитесь автоматического восстановления связи с тем же K1.",
"Нажмите «Переподключиться», чтобы проверить связь с тем же K1.",
);
});
@@ -3461,7 +3461,7 @@ test("each terminal explicit provisioning click starts a fresh operation identit
assert.match(
connectRecovery,
/failedOperation\?\.status === "succeeded"[\s\S]*?!exactAppliedNetworkIntentCompleted\([\s\S]*?&& !hasExactConnectionReady/,
/observeProvisioningRequest\([\s\S]*?observedState = observation\.state[\s\S]*?const exactNetworkIntentCompleted = exactAppliedNetworkIntentCompleted\(/,
);
assert.match(
connectRecovery,
@@ -4009,7 +4009,7 @@ test("failed network-profile writes close the UI session with a fresh explicit n
assert.equal(
message,
"Bluetooth-периферия завершила сетевую операцию ошибкой. Команда могла быть принята K1; итог текущей попытки не подтверждён. Автоматический повтор команды K1 не отправлялся. Сессия подключения в интерфейсе сброшена. Выполните новый поиск Bluetooth, выберите K1 и запустите подключение ещё раз.",
"Bluetooth-периферия завершила сетевую операцию ошибкой. Команда могла быть принята K1; итог текущей попытки не подтверждён. Автоматический повтор команды K1 не отправлялся. Проверьте состояние K1 через «Переподключиться» или начните новое подключение через поиск Bluetooth.",
);
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, /Автоматический повтор команды K1 не отправлялся/);
assert.match(attMessage, /Сессия подключения в интерфейсе сброшена/);
assert.match(attMessage, /новый поиск Bluetooth, выберите K1/);
assert.match(attMessage, /Проверьте состояние K1 через «Переподключиться»/);
assert.match(attMessage, /новое подключение через поиск Bluetooth/);
const preWriteAttMessage = networkProvisionFailureMessage({
action: "network.provision",
@@ -4059,7 +4059,7 @@ test("post-dispatch ambiguity resets the UI session without an automatic retry",
assert.equal(
message,
"После BLE-команды K1 вернул сетевой статус, неотличимый от исходного; итог текущей попытки подключения не подтверждён. Автоматический повтор команды K1 не отправлялся. Сессия подключения в интерфейсе сброшена. Выполните новый поиск Bluetooth, выберите K1 и запустите подключение ещё раз.",
"После BLE-команды K1 вернул сетевой статус, неотличимый от исходного; итог текущей попытки подключения не подтверждён. Автоматический повтор команды K1 не отправлялся. Проверьте состояние K1 через «Переподключиться» или начните новое подключение через поиск Bluetooth.",
);
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(
operationTimeout,
"Локальная операция подготовки Wi‑Fi не завершилась вовремя; итог текущей попытки подключения не подтверждён. Автоматический повтор команды K1 не отправлялся. Сессия подключения в интерфейсе сброшена. Выполните новый поиск Bluetooth, выберите K1 и запустите подключение ещё раз.",
"Локальная операция подготовки Wi‑Fi не завершилась вовремя; итог текущей попытки подключения не подтверждён. Автоматический повтор команды K1 не отправлялся. Проверьте состояние K1 через «Переподключиться» или начните новое подключение через поиск Bluetooth.",
);
assert.doesNotMatch(operationTimeout, /получите пароль|пароль отсутствует/i);
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, /setInterval\(updateCountdown, 250\)/);
assert.match(provisioning, /Поиск Bluetooth · \{scanSecondsRemaining \?\? 6\} с/);
assert.match(provisioning, /scanWithResult\(\{[^}]*durationSeconds:\s*6/);
assert.match(provisioning, /Поиск Bluetooth · до \{scanSecondsRemaining \?\? BLE_DISCOVERY_TIMEOUT_SECONDS\} с/);
assert.match(provisioning, /scanWithResult\(\{[^}]*durationSeconds:\s*BLE_DISCOVERY_TIMEOUT_SECONDS/);
assert.doesNotMatch(provisioning, /K1 уже доступен|Сетевой адрес K1 доступен/);
assert.doesNotMatch(
provisioning,
@@ -284,7 +284,7 @@ test("K1 click-owned actions are fenced without hidden frontend continuations",
provisioning.indexOf("const verifyAppliedNetwork"),
);
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.doesNotMatch(
apply,
@@ -680,7 +680,7 @@ test("automatic K1 live start opens the selected delivered camera despite an old
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(
join(pluginFrontendRoot, "useXgridsK1Runtime.ts"),
"utf8",
@@ -691,15 +691,17 @@ test("K1 connect errors reset the UI session without exposing reconciliation cer
assert.notEqual(connectEnd, -1);
const connectFlow = runtime.slice(connectStart, connectEnd);
assert.match(connectFlow, /operationByIdempotencyKey\(/);
assert.match(connectFlow, /failedOperation\?\.status === "succeeded"/);
assert.match(connectFlow, /resetConnectSessionMessage\(/);
assert.match(connectFlow, /observeProvisioningRequest\(/);
assert.match(connectFlow, /operation\?\.status !== "succeeded"/);
assert.match(connectFlow, /networkProvisionFailureMessage\(/);
assert.doesNotMatch(
connectFlow,
/требует ручной проверки|измените параметры только после проверки устройства|Сохранён тот же ключ/,
);
assert.match(runtime, /Сессия подключения в интерфейсе сброшена/);
assert.match(runtime, /новый поиск Bluetooth, выберите K1 и запустите подключение ещё раз/);
const networkFlow = readFileSync(join(pluginFrontendRoot, "networkProvisioning.ts"), "utf8");
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 verifyEnd = runtime.indexOf("const probeConfiguredEndpoint = useCallback(", verifyStart);
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.equal((apply.match(/await connect\(/g) ?? []).length, 1);
assert.doesNotMatch(apply, /scanWithResult\(|verifyConnection\(|candidateRefresh/);
assert.match(provisioning, /scanWithResult\(\{ durationSeconds: 6 \}\)/);
assert.match(provisioning, /scanWithResult\(\{ durationSeconds: BLE_DISCOVERY_TIMEOUT_SECONDS \}\)/);
assert.match(
provisioning,
/onChange=\{\(event\) => setPassword\(event\.target\.value\)\}/,
@@ -65,8 +65,8 @@ test("default environment exposes every current product page", () => {
"system",
"polygon",
]);
assert.equal(defaults.pages.fleet.primaryWorkspaceId, "contour-health");
assert.equal(defaults.pages.observation.primaryWorkspaceId, "spatial-scene");
assert.equal(defaults.pages.fleet.primaryWorkspaceId, "vehicles");
assert.equal(defaults.pages.observation.primaryWorkspaceId, "cameras");
});
test("server document decodes and re-encodes the complete page contract", () => {
@@ -250,7 +250,7 @@ test("K1 one-intent source contract makes mode reset explicit and keeps device I
"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.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.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", () => {
@@ -377,7 +377,7 @@ test("exact network-applied REST proof completes frontend Apply before control i
);
assert.match(
connectFlow,
/failedOperation\?\.status === "succeeded"[\s\S]*?!exactAppliedNetworkIntentCompleted\([\s\S]*?&& !hasExactConnectionReady/,
/observeProvisioningRequest\([\s\S]*?observedState = observation\.state[\s\S]*?const exactNetworkIntentCompleted = exactAppliedNetworkIntentCompleted\(/,
);
assert.doesNotMatch(
connectFlow,
@@ -1359,7 +1359,7 @@ function actionByLabel(node, label) {
return actionByLabel(node.props.children, label);
}
function renderProvisioningWithAttempt(props, presentation) {
function renderProvisioningWithAttempt(props, presentation, afterSearch = false) {
function AttemptHarness() {
const internals =
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");
const originalUseState = dispatcher.useState;
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) {
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");
assert.equal(localConnection?.label, "Подключение");
assert.equal(localConnection?.title, "Подключение");
assert.equal(localConnection?.root, "polygon");
assert.equal(localConnection?.label, "Тестовые устройства");
assert.equal(localConnection?.title, "Тестовые устройства");
assert.equal(model().displayName, "XGRIDS LixelKity K1");
assert.deepEqual(
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, /Поиск Bluetooth · 6 с/);
assert.match(markup, /Поиск Bluetooth · до 20 с/);
assert.equal(
(markup.match(/class="nodedc-activity-indicator"/g) ?? []).length,
1,
@@ -7478,7 +7485,7 @@ test("applied-network recovery spends the old intent and admits only explicit se
search,
/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(
search,
/if \(scanResult\.succeeded && recoveryAttemptKey\) \{\s*setEscapedAppliedAttemptKey\(recoveryAttemptKey\)/,
@@ -7708,3 +7715,57 @@ test("contour health never promotes selection or replay metrics to live authorit
});
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, /<strong>Не удалось подключить K1 к WiFi<\/strong>/);
assert.match(markup, /указанная сеть не найдена/);
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,133 @@
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.match(message, /Проверьте название сети и пароль/);
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", () => {
assert.deepEqual(
productModel.workspacesForRoot("polygon").map(({ id }) => id),
["lab-archive", "simulations", "observatory"],
["lab-archive", "simulations", "observatory", "local-device", "spatial-scene"],
);
assert.deepEqual(
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/,
);
assert.equal(sharedReplay.match(/<RerunViewport\b/g)?.length, 1);
assert.match(sharedReplay, /recordedSessionRerunProfile/);
assert.match(sharedReplay, /laboratoryResultRerunProfile/);
assert.match(sharedReplay, /timelineStartSeconds/);
assert.doesNotMatch(sharedReplay, /live-acquisition|lab-recorded-evidence/);
assert.match(
@@ -324,7 +324,7 @@ test("Polygon exposes one dataset surface and keeps legacy links compatible", ()
assert.equal(workspaceById("datasets").kind, "datasets");
assert.deepEqual(
workspacesForRoot("polygon").map(({ id }) => id),
["lab-archive", "simulations", "observatory"],
["lab-archive", "simulations", "observatory", "local-device", "spatial-scene"],
);
assert.equal(
workspacesForRoot("system").some(({ id }) => id === "polygon-run"),
@@ -29,8 +29,10 @@ test("top navigation has no Center and Park owns contour health first", () => {
assert.equal(productModel.workspaceById("contour-health")?.root, "fleet");
assert.deepEqual(
productModel.workspacesForRoot("polygon").map(({ id }) => id),
["lab-archive", "simulations", "observatory"],
["lab-archive", "simulations", "observatory", "local-device", "spatial-scene"],
);
assert.equal(productModel.workspaceById("spatial-scene")?.root, "polygon");
assert.equal(productModel.workspacesForRoot("observation").some(({ id }) => id === "spatial-scene"), false);
assert.equal(productModel.workspaceById("lab-archive")?.kind, "lab-archive");
assert.deepEqual(
{
@@ -0,0 +1,102 @@
import assert from 'node:assert/strict';
import {before,after,test} from 'node:test';
import {readFileSync,readdirSync} from 'node:fs';
import {createServer} from 'vite';
let server,api,resolveContribution;
before(async()=>{
server=await createServer({appType:'custom',logLevel:'silent',server:{middlewareMode:true}});
api=await server.ssrLoadModule('@xgrids-k1/frontend/sensors/enrollment.ts');
({sensorContribution:resolveContribution}=await server.ssrLoadModule('../../packages/sensor-ui/src/extensions.ts'));
});
after(async()=>{await server?.close();});
const initial={node_id:'node-one',available:true,fresh:true,runtime_id:'runtime-one',snapshot_revision:1,runtime_started_at:'2026-09-06T00:00:00Z',selected_device_id:'synthetic-ble'};
function fixture(){
let request,reads=0,posts=0,clock=Date.now();
const accepted=[];
const value=(status='running',revision=2)=>({...initial,snapshot_revision:revision,connected:status==='succeeded',connection_attempt:{schema_version:'missioncore.xgrids-k1-connection-attempt/v1',attempt_id:request.operation_id,connection_mode:'bridge',status,phase:'network_applied',control_state:status==='succeeded'?'ready':'unknown',safe_next_action:status==='succeeded'?'start-acquisition':'wait-for-current-attempt'}});
const transport={
submit:async input=>{posts++;request=input;return {operation_id:request.operation_id,state:'complete',result:value()};},
operation:async id=>({operation_id:id,state:'complete',result:value()}),
state:async()=>{reads++;return value('succeeded',3);},
};
return {transport,value,accepted,parameters:{device_id:'synthetic-ble',ssid:'synthetic-network',password:crypto.randomUUID()},
observer:{onState:s=>accepted.push(s),now:()=>clock,pause:async()=>{clock+=100000;}},
counts:()=>({reads,posts}),request:()=>request};
}
test('network ACK waits for the exact owned control bootstrap with one POST',async()=>{
const f=fixture();const result=await api.enroll(f.transport,initial,'connect',f.parameters,f.observer);
assert.equal(result.connected,true);assert.deepEqual(f.counts(),{reads:1,posts:1});
assert.equal(f.accepted[0].connection_attempt.status,'running');assert.equal(f.accepted.at(-1).connection_attempt.status,'succeeded');
assert.equal('password' in f.parameters,false);
});
test('lost POST response reads the same operation ID and never submits again',async()=>{
const f=fixture();const submit=f.transport.submit;
f.transport.submit=async input=>{await submit(input);throw new Error('response lost');};
let ids=[];const operation=f.transport.operation;
f.transport.operation=async id=>{ids.push(id);return operation(id);};
const result=await api.enroll(f.transport,initial,'connect',f.parameters,f.observer);
assert.equal(result.connected,true);assert.equal(f.counts().posts,1);assert.deepEqual(ids,[f.request().operation_id]);
assert.equal('password' in f.parameters,false);
});
test('late snapshot cannot restore readiness or overwrite newer attempt evidence',()=>{
const current={...initial,snapshot_revision:8,connected:false};
assert.equal(api.mergeEnrollmentState(current,{...initial,snapshot_revision:7,connected:true}),current);
const newer={...current,runtime_id:'runtime-two',runtime_started_at:'2026-09-06T00:01:00Z'};
assert.equal(api.mergeEnrollmentState(newer,{...initial,snapshot_revision:900,connected:true}),newer);
assert.equal(api.mergeEnrollmentState(current,newer).runtime_id,'runtime-two');
assert.equal(api.mergeEnrollmentState(current,{available:false}).fresh,false);
assert.equal(api.mergeEnrollmentState(current,{...initial,node_id:'another-board'}),current);
});
test('a response for another operation is never accepted',async()=>{
const f=fixture();const submit=f.transport.submit;
f.transport.submit=async input=>({...await submit(input),operation_id:'another-operation'});
await assert.rejects(api.enroll(f.transport,initial,'connect',f.parameters,f.observer),/другое действие/);
assert.equal(f.counts().reads,0);assert.equal(f.counts().posts,1);
});
test('foreign historical success cannot settle this attempt',async()=>{
const f=fixture();const submit=f.transport.submit;
f.transport.submit=async input=>({...await submit(input),result:{...initial,connected:true,connection_attempt:{...f.value('succeeded').connection_attempt,attempt_id:'foreign'}}});
f.transport.state=async()=>({...initial,connected:true,connection_attempt:{...f.value('succeeded').connection_attempt,attempt_id:'foreign'}});
await assert.rejects(api.enroll(f.transport,initial,'connect',f.parameters,f.observer),/пока не подтверждён/);
assert.equal(f.counts().posts,1);
});
test('runtime restart interrupts observation without another command',async()=>{
const f=fixture();f.transport.state=async()=>({...initial,runtime_id:'runtime-two',runtime_started_at:'2026-09-06T00:01:00Z'});
await assert.rejects(api.enroll(f.transport,initial,'connect',f.parameters,f.observer),/Сеанс K1 изменился/);
assert.equal(f.counts().posts,1);assert.equal(f.accepted.at(-1).runtime_id,'runtime-two');
});
test('closing the observer stops reads without cancelling/replaying a device command',async()=>{
const f=fixture();const controller=new AbortController();
f.observer.signal=controller.signal;f.observer.pause=async()=>controller.abort();
await assert.rejects(api.enroll(f.transport,initial,'connect',f.parameters,f.observer),/Наблюдение закрыто/);
assert.deepEqual(f.counts(),{reads:0,posts:1});
});
test('station refusal is the same message in onboard and LAB presentations',()=>{
const state={...initial,connection_attempt:{schema_version:'missioncore.xgrids-k1-connection-attempt/v1',attempt_id:'test',status:'failed',phase:'network_outcome_unknown',public_error_code:'k1-wifi-network-not-found'}};
assert.match(api.enrollmentNotice(state),/Проверьте название сети и пароль/);
assert.doesNotMatch(api.enrollmentNotice(state),/Bluetooth.*ошиб/);
});
test('sensor host can resolve zero or unrelated integrations and rejects ambiguity',()=>{
const alpha={kind:'alpha',Detail:()=>null},beta={kind:'beta',Detail:()=>null};
assert.equal(resolveContribution([],{kind:'alpha'}),undefined);
assert.equal(resolveContribution([alpha,beta],{kind:'beta'}),beta);
assert.equal(resolveContribution([alpha,{...alpha}],{kind:'alpha'}),undefined);
for(const name of readdirSync(new URL('../../../packages/sensor-ui/src/',import.meta.url))){
if(!/\.tsx?$/.test(name))continue;
assert.doesNotMatch(readFileSync(new URL('../../../packages/sensor-ui/src/'+name,import.meta.url),'utf8'),/xgrids|lixel|\bk1\b|K1Detail/,'vendor dependency in '+name);
}
});
test('onboard actions use current server policy and never infer authority from readiness',()=>{
const state={...initial,connected:true,allowed_actions:['verify-control-read-only']};
assert.equal(api.enrollmentAllowed(state,'connect'),false);
assert.equal(api.enrollmentAllowed(state,'scan'),false);
assert.equal(api.enrollmentAllowed(state,'verify'),true);
assert.equal(api.enrollmentAllowed({...state,fresh:false},'verify'),false);
});
test('delayed operation result cannot clear an observed board outage',()=>{
const current={...initial,fresh:false,available:false};
const result=api.mergeEnrollmentState(current,{...initial,fresh:undefined,snapshot_revision:2});
assert.equal(result.fresh,false);
assert.equal(api.mergeEnrollmentState(result,{...initial,fresh:true,snapshot_revision:3}).fresh,true);
});
@@ -7,6 +7,7 @@ let server;
let LABORATORY_RECORDED_EVIDENCE_VIEWER_PROFILE;
let liveAcquisitionRerunProfile;
let recordedSessionRerunProfile;
let laboratoryResultRerunProfile;
before(async () => {
server = await createServer({
@@ -18,6 +19,7 @@ before(async () => {
LABORATORY_RECORDED_EVIDENCE_VIEWER_PROFILE,
liveAcquisitionRerunProfile,
recordedSessionRerunProfile,
laboratoryResultRerunProfile,
} = 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);
});
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:" "}));
});
+27 -7
View File
@@ -3,7 +3,11 @@
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"lib": [
"ES2022",
"DOM",
"DOM.Iterable"
],
"allowJs": false,
"skipLibCheck": true,
"esModuleInterop": true,
@@ -14,11 +18,24 @@
"moduleResolution": "Bundler",
"baseUrl": ".",
"paths": {
"@mission-core/plugin-sdk": ["src/core/device-plugins/frontendSdk.ts"],
"@xgrids-k1/frontend/*": ["../../plugins/xgrids-k1/frontend/src/*"],
"react": ["node_modules/@types/react/index.d.ts"],
"react/jsx-runtime": ["node_modules/@types/react/jsx-runtime.d.ts"],
"@nodedc/ui-react": ["node_modules/@nodedc/ui-react/dist/index.d.ts"]
"@mission-core/plugin-sdk": [
"src/core/device-plugins/frontendSdk.ts"
],
"@xgrids-k1/frontend/*": [
"../../plugins/xgrids-k1/frontend/src/*"
],
"react": [
"node_modules/@types/react/index.d.ts"
],
"react/jsx-runtime": [
"node_modules/@types/react/jsx-runtime.d.ts"
],
"@nodedc/ui-react": [
"node_modules/@nodedc/ui-react/dist/index.d.ts"
],
"@mission-core/sensor-sdk": [
"../../packages/sensor-ui/src/pluginSdk.ts"
]
},
"resolveJsonModule": true,
"isolatedModules": true,
@@ -28,5 +45,8 @@
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src", "../../plugins/xgrids-k1/frontend/src"]
"include": [
"src",
"../../plugins/xgrids-k1/frontend/src"
]
}
+1
View File
@@ -74,6 +74,7 @@ export default defineConfig(({ mode }) => {
plugins: [react(), wasm(), cesiumRuntimeAssets()],
resolve: {
alias: {
"@mission-core/sensor-sdk": fileURLToPath(new URL("../../packages/sensor-ui/src/pluginSdk.ts", import.meta.url)),
"@mission-core/plugin-sdk": fileURLToPath(
new URL("./src/core/device-plugins/frontendSdk.ts", import.meta.url),
),
+6
View File
@@ -80,6 +80,12 @@ func run() error {
return err
}
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("/") }}
if err := os.MkdirAll(filepath.Dir(*socket), 0700); err != nil {
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"`
}
type Pairing struct {
Sensors *Sensors
mu sync.Mutex
path string
store *Store
state PairState
now func() time.Time
inventory func() Inventory
version string
lastSeen int64
connection string
listenError string
failureWindow int64
failures int
clients map[string]*http.Client
Sensors *Sensors
DeviceEnrollment *DeviceEnrollment
mu sync.Mutex
path string
store *Store
state PairState
now func() time.Time
inventory func() Inventory
version string
lastSeen int64
connection string
listenError string
failureWindow int64
failures int
clients map[string]*http.Client
}
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_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)
p.mu.Lock()
if p.state.Phase == "paired" && p.state.Binding != nil && p.state.Binding.BindingID == binding.BindingID {
if e == nil && status == 200 {
p.connection = "online"
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 {
var ack []string
if json.Unmarshal(result["sensor_acknowledgements"], &ack) == nil {
+49 -12
View File
@@ -45,19 +45,20 @@ type SensorOperation struct {
Updated int64 `json:"updated_at"`
}
type Sensors struct {
events sensorEvents
mu sync.Mutex
prepareMu sync.Mutex
root string
nodeID string
instance string
client *http.Client
operations map[string]*SensorOperation
names map[string]string
initialized map[string]bool
events sensorEvents
mu sync.Mutex
prepareMu sync.Mutex
root string
nodeID string
instance string
client *http.Client
NetworkDevices *DeviceEnrollment
operations map[string]*SensorOperation
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}$`)
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 {
items := []any{}
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 found, ok := result["items"].([]any); ok {
for _, v := range found {
@@ -259,6 +286,9 @@ func sensorViewAction(action string) bool {
}
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 {
return nil, errors.New("Некорректная команда устройства.")
}
@@ -344,7 +374,14 @@ func (s *Sensors) execute(c SensorCommand) {
}
} else {
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"
if err == nil {
if v["state"] == "complete" {
+18 -14
View File
@@ -13,20 +13,21 @@ import (
)
type Server struct {
Store *Store
Pairing *Pairing
Sensors *Sensors
Assets fs.FS
Origin string
Version string
Inventory func() Inventory
Access *AccessStore
Tailscale func() TailscaleStatus
Environment func() EnvironmentStatus
mu sync.Mutex
logins map[string]time.Time
sessions map[string]time.Time
Now func() time.Time
Store *Store
Pairing *Pairing
Sensors *Sensors
DeviceEnrollment *DeviceEnrollment
Assets fs.FS
Origin string
Version string
Inventory func() Inventory
Access *AccessStore
Tailscale func() TailscaleStatus
Environment func() EnvironmentStatus
mu sync.Mutex
logins map[string]time.Time
sessions map[string]time.Time
Now func() time.Time
}
func token() string {
@@ -82,6 +83,9 @@ func (s *Server) Handler() http.Handler {
if s.Sensors != nil {
s.Sensors.Routes(mux, s)
}
if s.DeviceEnrollment != nil {
s.DeviceEnrollment.Routes(mux, s)
}
if s.Pairing != nil {
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;
}
});
+4 -1
View File
@@ -11,7 +11,7 @@ import sys
from build_deb import build, VERSION, BRAND_SHA256
ROOT = Path(__file__).resolve().parents[1]
DG_COMMIT = "999864e5b0a81555823cfa1ea6e8cf8a417c37f1"
DG_COMMIT = "1bdfc6c24072d38cc1068086ea271c444f2524ad"
def guideline_sources():
@@ -32,6 +32,9 @@ def provenance():
"base_commit": subprocess.check_output(["git", "rev-parse", "HEAD"], cwd=ROOT, text=True).strip(),
"design_guideline_commit": DG_COMMIT,
"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_frontend_files": {str(p.relative_to(ROOT.parents[1])): hashlib.sha256(p.read_bytes()).hexdigest() for p in sorted((ROOT.parents[1] / "plugins/xgrids-k1/frontend/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}
+25 -2
View File
@@ -13,7 +13,7 @@ import tarfile
ROOT = Path(__file__).resolve().parents[1]
VERSION = "0.6.11"
VERSION = "0.7.1"
BRAND_SHA256 = "8bfee8ca9f98e0db48d98aae3af4b32493b8593e18b064a0239d513d824182af"
@@ -65,7 +65,7 @@ Architecture: amd64
Maintainer: NODE.DC local build <noreply@example.invalid>
Section: admin
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
Local graphical setup, host inventory, SSH access and persistent node identity.
""".encode()
@@ -107,6 +107,29 @@ Description: Mission Core onboard computer configuration
files.append(("usr/share/mission-core-node/realsense/" + item["name"], data, 0o644))
for path in (ROOT / "sensors").glob("*.py"):
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"
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))
@@ -1,13 +1,18 @@
"""Engineering build input, never run on an operator board. Exact PyPI hashes only."""
import hashlib
import argparse
import json
import time
from pathlib import Path
from urllib.request import urlopen
from urllib.request import Request, urlopen
root = Path(__file__).resolve().parents[1]
manifest = json.loads((root / "packaging/realsense-bundle.json").read_text())
output = root / "build/realsense-wheels"
parser = argparse.ArgumentParser()
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)
for item in manifest["wheels"]:
target = output / item["name"]
@@ -23,8 +28,24 @@ for item in manifest["wheels"]:
)
if not source["url"].startswith("https://files.pythonhosted.org/"):
raise ValueError("Unexpected package origin")
with urlopen(source["url"], timeout=120) as response:
data = response.read(item["bytes"] + 1)
partial = target.with_suffix(target.suffix + ".partial")
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"]:
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
adduser --system --group --home /var/lib/mission-core-sensors --no-create-home --disabled-login mission-core-sensors
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
# versioned job started by «Настройка окружения → Сконфигурировать».
if [ -d /run/systemd/system ]; then
@@ -15,6 +19,8 @@ case "$1" in
systemctl enable mission-core-node.service
systemctl restart mission-core-node.service
systemctl try-restart mission-core-realsense.service
systemctl enable mission-core-k1.service
systemctl restart mission-core-k1.service
fi
;;
esac
+11
View File
@@ -8,6 +8,12 @@ if [ "$1" = install ] || [ "$1" = upgrade ]; then
exit 1
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)
case "$mc_node_device_job" in
active|activating) echo "Mission Core Node: дождитесь завершения подготовки устройства." >&2; exit 1 ;;
@@ -20,6 +26,11 @@ if [ "$1" = install ] || [ "$1" = upgrade ]; then
exit 1
;;
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
. /etc/os-release
if [ "${ID:-}" != ubuntu ] || [ "${VERSION_ID:-}" != 24.04 ]; then
+8
View File
@@ -7,6 +7,12 @@ if [ -d /run/systemd/system ]; then
exit 1
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)
case "$mc_node_device_job" in
active|activating) echo "Mission Core Node: дождитесь завершения подготовки устройства." >&2; exit 1 ;;
@@ -40,6 +46,8 @@ case "$1" in
/usr/sbin/sshd -t
systemctl try-reload-or-restart ssh.service
fi
systemctl stop mission-core-k1.service
systemctl disable mission-core-k1.service || true
systemctl stop mission-core-realsense.service
systemctl disable mission-core-realsense.service || true
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/ui-core": "file:../../../../NODEDC_DESIGN_GUIDELINE/packages/ui-core",
"@nodedc/ui-react": "file:../../../../NODEDC_DESIGN_GUIDELINE/packages/ui-react",
"@rerun-io/web-viewer": "0.36.3",
"react": "19.1.0",
"react-dom": "19.1.0"
},
@@ -18,7 +19,8 @@
"@types/react": "^19.1.0",
"@types/react-dom": "^19.1.0",
"typescript": "^5.8.3",
"vite": "^7.0.0"
"vite": "^7.0.0",
"vite-plugin-wasm": "^3.6.0"
}
},
"../../../../NODEDC_DESIGN_GUIDELINE/packages/tokens": {
@@ -524,6 +526,12 @@
"resolved": "../../../../NODEDC_DESIGN_GUIDELINE/packages/ui-react",
"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": {
"version": "4.63.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.63.1.tgz",
@@ -1239,6 +1247,16 @@
"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"
},
"dependencies": {
"@rerun-io/web-viewer": "0.36.3",
"@nodedc/ui-react": "file:../../../../NODEDC_DESIGN_GUIDELINE/packages/ui-react",
"@nodedc/ui-core": "file:../../../../NODEDC_DESIGN_GUIDELINE/packages/ui-core",
"@nodedc/tokens": "file:../../../../NODEDC_DESIGN_GUIDELINE/packages/tokens",
@@ -16,6 +17,7 @@
"react-dom": "19.1.0"
},
"devDependencies": {
"vite-plugin-wasm": "^3.6.0",
"@types/react": "^19.1.0",
"@types/react-dom": "^19.1.0",
"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>
+4 -2
View File
@@ -1,5 +1,7 @@
import {xgridsK1SensorUi,K1EnrollmentWindow} from '../../../../plugins/xgrids-k1/frontend/src/sensors/plugin';
import {createIsolatedRerunHost} from '../../../control-station/src/components/rerun/isolatedRerunHost';
import {SensorWorkspace} from '../../../../packages/sensor-ui/src/SensorWorkspace';
import type {SensorTransport} from '../../../../packages/sensor-ui/src/contracts';
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))};
export function NodeSensors(){return <SensorWorkspace transport={transport}/>;}
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 contributions={[xgridsK1SensorUi]} EnrollmentView={K1EnrollmentWindow} createRerunHost={createIsolatedRerunHost} transport={transport}/>;}
+1
View File
@@ -0,0 +1 @@
import "../../../control-station/src/components/rerun/isolatedRerunEntry";
+3
View File
@@ -22,6 +22,9 @@
],
"@nodedc/ui-react": [
"node_modules/@nodedc/ui-react/dist/index.d.ts"
],
"@mission-core/sensor-sdk": [
"../../../packages/sensor-ui/src/pluginSdk.ts"
]
}
},
+9 -1
View File
@@ -1,9 +1,17 @@
import { defineConfig } from "vite";
import { fileURLToPath } from "node:url";
import wasm from "vite-plugin-wasm";
export default defineConfig({
plugins: [wasm()],
// Shared sensor TSX lives outside this app tsconfig; use the same JSX runtime.
esbuild: { jsx: "automatic" },
// Design Guideline packages are linked during development. Their own React
// must never become a second hook dispatcher in the portable production bundle.
resolve: { dedupe: ["react", "react-dom", "@nodedc/ui-react"] },
resolve: { alias: {"@mission-core/sensor-sdk": fileURLToPath(new URL("../../../packages/sensor-ui/src/pluginSdk.ts", import.meta.url))}, 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
### 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
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
@@ -0,0 +1,269 @@
# K1 Bridge: архитектура, эксплуатационные сценарии и граница рефакторинга
Дата среза: 06.09.2026. Основание: просьба владельца полностью восстановить контекст K1 из Ops перед решением о рефакторинге. Это документальный и статический аудит текущей рабочей копии, а не новая аппаратная приёмка.
Исходный commit: `020a878915ea32c64963975447650fd8eee29071`. Рабочее дерево уже содержало изменения Node, K1, viewer и UI. При этом аудите исходники, прошивка, состояние сканера, службы и Ops не изменялись. Созданы только этот отчёт и индексы источников. Тесты, сборки, BLE-поиск, provisioning, START/STOP и отключения сети не запускались.
## 1. Вывод
Опасение о чрезмерной связности подтверждается кодом. `facade.py` — 35 996 строк; `connect()` — 1 971; `_adopt_existing_lan_connection()` — 1 224; `state()` — 1 028; `_reconcile_acquisition()` — 1 587. Экран `K1ProvisioningPipeline.tsx` — 3 732 строки, runtime hook — 2 135. В экран одновременно входят локальный черновик, выбор режима, scan, попытка provisioning, чтение статуса, восстановление, история физической команды и представление ошибок.
Однако это не означает, что все проверки лишние. Существенная часть сложности появилась после доказанных инцидентов: повторные команды после неизвестного результата, поздний ответ старого процесса, потеря сети после START, STOP без READY, восстановление камеры, гонка REST/WebSocket, удержание старого Rerun listener. Удалить эти проверки ради короткой функции подключения означало бы вернуть реальные дефекты.
Нужна декомпозиция владельцев и переходов при сохранении поведения. Уже существуют полезные границы: BLE transport, connection supervisor, network ledger, physical-command coordinator, recovery checkpoint, control transcript. Основной долг находится в их соединении внутри facade и в повторной интерпретации состояния разными UI.
Текущая ошибка подключения и архитектурный долг — связанные, но разные вопросы. Сам размер файла не доказывает причину ATT-ответа K1. Последние изменения улучшили диагноз и выход из ошибки; успешное подключение последней попытки ими не доказано.
## 2. Что найдено в Ops
Через прямой NODE.DC Ops MCP получены все 76 карточек MISSION CORE с описаниями и structured blocks. У 41 карточки есть метка `XGRIDS K1`; значительная часть — потребители уже записанного K1 evidence в LAB. Для 45 карточек получены все страницы активных комментариев: 128 комментариев, без оставшейся пагинации. Полный индекс названий, блоков, дат и comment IDs: [Ops index](2026-09-06-k1-bridge-ops-index.json).
Глубокая смысловая сверка выполнена для сетевого/control/recovery контура, Node и границ viewer. Индексация лабораторных карточек не выдаётся за повторный аудит всех алгоритмов CV.
| Карточка | Роль в этом разборе |
|---|---|
| MISSIONCOR-3 — Mission Core. Lixel K1 / XGRIDS Integration | Основная текущая приёмка K1; packet oracle, 14 операций, Bridge/Quick, сон/сеть, камера, baseline производительности |
| MISSIONCOR-49 — K1 · Проблемы сканирования | История отдельных сетевых, control, producer и Rerun отказов; физические повторные циклы; причины предыдущих регрессий |
| MISSIONCOR-76 — Mission Core Node · Архитектурные границы для бортового ПК | Владение устройствами на борту, связь Core/Node, reboot, журнал команд, Linux-паритет; уточнения владельца в комментариях |
| MISSIONCOR-66 — Mission Core. Канон интеграции Rerun | Границы live, Saved Sessions и LAB; запрет менять общий lifecycle ради локальной лаборатории |
| MISSIONCOR-74 — Additional Core · Переносимая кастомизация Rerun | Связанный реестр кастомизаций; индексирован, актуальные различия поверхностей прочитаны в свежем блоке #66 |
| MISSIONCOR-7 — Mission Core. Milestone — canonical K1 control and durable archive acceptance | Историческая физическая START/live/STOP/READY/archive приёмка |
| MISSIONCOR-10 — Operational Core — Real-time Record Limits | Исторические ограничения записи, durability, consumer backpressure; часть описания recovery устарела |
| MISSIONCOR-51 — Технический долг Mission Core | Отдельные полевые и вычислительные ограничения; не источник разрешения ослабить K1 safety |
| MISSIONCOR-1, -2, -5, -50 | Архитектурный и исторический контекст проекта, SDK и границ компонентов |
| MISSIONCOR-4 — Archive. NDC_xgrids-k1-connector — historical evidence | Раннее физическое evidence; явно архивная архитектура |
| MISSIONCOR-8, -11 и остальные K1 LAB-карточки | Downstream camera/perception/calibration/replay; учитывать как потребителей неизменного source-of-record |
В #76 комментарии 05.09 имеют решающее уточнение: первый бортовой K1 — **только Bridge**; существующий macOS/Quick остаётся тестовым путём. В теле старого baseline ещё написан последующий Linux Quick: это не новая задача. Все действия оператора должны проходить через GUI. Порядок пользовательских уточнений важнее старого шаблона карточки.
## 3. Восстановленная история и достоверность
| Дата / источник | Что действительно было подтверждено | Чего это не доказывает |
|---|---|---|
| 16.07, #3, #4 | BLE/Wi-Fi vertical slice; LixelGO/iPhone IP capture; MQTT/RTSP и packet map | IP capture не содержит BLE HCI; не является дампом самого 7f01 provisioning |
| 19.07, #7 | Canonical START/live/STOP/READY и durable archive на одном K1 | Linux, второй K1, другая FW, многочасовая эксплуатация |
| 28.07, #49 | Повторные Quick/Bridge циклы и вход после очистки cache | Успешное соединение не означало исправный Rerun receiver |
| 06.08, #49 | Bridge физически работал; отдельно диагностирован Quick/CoreWLAN helper regression | Нельзя переносить причину Quick helper на Bridge |
| 2122.08, #49/#3 | Устранены starvation, camera churn, Rerun admission и state-channel проблемы; подтверждён STOP + READY | Число unit tests не заменяет отдельную аппаратную приёмку каждого recovery |
| 23.08, #3 | Bridge → Quick → Bridge → Quick; сон во время запуска; сеть off → сон → wake → сеть on; камера и облако восстановились без повторного START/STOP | Полный reboot Node во время K1 записи, другая ОС/FW, универсальный SLA |
| 0506.09, #76 | Pairing, heartbeat offline/online при остановке Node-службы, сохранение identity, D455 этап | Аппаратный K1/Linux parity ещё не принят |
| 06.09, локальные incident-аудиты | Есть успешный Bridge, затем ATT-отказы; отдельно найден неверный camera evidence-root | Последние подключения и камера после исправления ещё не приняты новым чистым UI-прогоном |
Внутренний быстрый baseline — `eaad9de`, `20260822T105904Z_viewer_live`: START до индикации калибровки <5 с, калибровка 21 с, облако +2 с, правая камера +4 с; callback→publication p50/p95 23,839/41,541 ms. Это один физический run на коротком ledger, не гарантированная задержка любого подключения. Recovery-capable baseline `1001a31` имеет другие задержки и расширенные гарантии. Подробности: [internal baseline](../lab/010_K1_MISSION_CORE_INTERNAL_LIVE_BASELINE_20260822.redacted.md).
### Обнаруженные расхождения источников
1. Manifest содержит 77 сценариев: 51 `software-covered`, 17 `partial`, 9 `planned`. Это значения файла, не результат новых тестов. Например, переключение Bridge/Quick и sleep/wake ещё отмечены как требующие hardware evidence, хотя более поздняя #3 описывает физическую приёмку.
2. #10 пишет, что MQTT reconnect отсутствует; #3 и текущий recovery-код содержат ограниченное восстановление активного потока. Историческое ограничение нельзя применять как описание текущего пути.
3. #49 хранит старый статус незакрытой Rerun-приёмки; более поздняя #3 закрывает конкретный принятый beta-профиль. Приёмку следует связывать с датой, commit, платформой и точным сценарием.
4. В #3 краткий oracle-блок неточно объединяет финальный PCL и teardown около +0,951 с. Явная ERRATA в комментарии `c1ef70e2-d900-4d42-ad78-d460040a42d7` различает последний PCL PUBLISH +36,415 ms, pose +40,787 ms, RTSP media +54,057 ms и teardown около +951 ms. Эталон должен учитывать исправление.
5. Документ supervision описывает фиксированный шестисекундный scan; текущий scanner использует одно непрерывное окно 6→20 с при отсутствии K1. Это документальный drift после сегодняшнего изменения.
6. В документах есть разная формулировка browser close: завершение operator session и сохранение backend-owned capture. Это разные владельцы. Контракт CONN-62 и код не разрешают WebSocket disconnect инициировать scanner-команду. Для рефакторинга нужно явно зафиксировать отдельно draft, UI connection, lease и acquisition.
## 4. Bridge по слоям: нормальная последовательность
```mermaid
flowchart TD
UI[Оператор: найти, выбрать K1, ввести сеть] --> Intent[Один intent: runtime, mode, discovery generation, operation ID]
Intent --> Admission[Проверка владельца, текущего состояния и журналов]
Admission --> BLE[Точный BLE handle: GATT contract и 7f02 baseline]
BLE --> Journal[Сохранить границу dispatch до записи]
Journal --> Write[Одна 99-byte запись в 7f01]
Write --> Status[7f02: подтверждение требуемой сети и адреса]
Status --> Applied[Durable network_applied]
Applied --> Control[Read-only route, TCP, DeviceInfo bootstrap]
Control --> Ready[Текущая identity и готовность управления]
Ready --> Start[Отдельный явный START по каноническому диалогу]
Start --> Raw[Локальная исходная запись]
Start --> Preview[Ограниченный live preview: облако и камера]
```
Для LAB весь runtime принадлежит компьютеру оператора. Для борта путь до `Intent` проходит Core → аутентифицированный Node channel → Node broker → тот же K1 runtime. Радио, проверка маршрута к K1, secret provider и recorder принадлежат БК. Сеть Core может отличаться от локальной сети Node/K1.
### Провода протокола
Общий Bridge profile FW 3.0.2: service `7f00`, write `7f01`, status `7f02`. Frame — ровно 99 bytes: длина SSID, 32-byte slot, длина password, 64-byte slot, конечный zero. Mission Core использует один `with_response`, как в принятом macOS run. Это не Quick AP-enable, где отдельный 100-byte frame. Источники: [reviewed profile](../04_K1_WIFI_PROVISIONING_PROFILE.md), `ble/wifi_provisioning.py:193`, вызов в `facade.py:11747`.
`write_gatt_char` ACK доказывает транспортный факт, но не готовность MQTT. Состояние сети, локальный route, TCP endpoint, DeviceInfo identity, control и свежие sensor data — самостоятельные доказательства. Нельзя заменить их единым `connected` или принимать «точки пришли» за право на новую команду.
Прикладной transcript остаётся непрерывной MQTT-сессией: ordinals 0110 перед START; ordinal 11 — START; 12 — свежий initialized SCANNING; 1314 — завершающее read-only обновление. Подготовка включает согласованную синхронизацию времени и не является целиком read-only. Recovery использует inspection-only путь, который её не повторяет.
Критическая граница сна: после ordinal 12 physical resolve и recovery checkpoint сохраняются до 1314; public SCANNING/STOP удерживаются transition gate до завершения refresh. Потеря связи в этот момент не должна уничтожить уже доказанный START. Реализация: `protocol/application_session.py:1020`, `facade.py:24291`, тест `test_post_start_refresh_timeout_wakes_existing_read_only_recovery`.
## 5. Владельцы и состояния, которые нельзя смешать
| Владелец | Состояние / обязанность | Должен пережить |
|---|---|---|
| UI draft | Выбранный кандидат, SSID/пароль до отправки, локальное ожидание | Ничего, что могло бы восстановить право повторной записи после смены runtime |
| BLE arbiter | Один нативный radio owner, точный handle, cleanup | Отмену coroutine до фактического освобождения нативной операции |
| Network idempotency journal | Один operation ID и неизменность его запроса | Потерю HTTP-ответа и backend restart без повторного write |
| Network mutation ledger | prepared/dispatching/observing/terminal и доказательства 7f02 | Crash на границе отправки |
| Semantic topology store | Последняя подтверждённая конфигурация сети | Restart, но только как configured/offline, без live authority |
| Connection supervisor | Intent, host epoch, route/TCP/DeviceInfo, отдельные control/data planes | Короткую потерю сети через отзыв зависимых полномочий |
| Physical coordinator/ledger | START/STOP и доказанный либо неизвестный физический результат | Потерю сети, process crash и UI reset; не очищается вместе с формой |
| Active recovery checkpoint | Точная lineage acquisition/device/project/evidence и gaps | Поддерживаемый rebind/restart без нового START |
| Recorder/camera producer | Raw evidence и committed prefixes | Закрытие browser, медленного consumer, смену просмотрщика |
| Viewer | Disposable receiver, canvas/media transport, профиль отображения | Пересоздание consumer без управления сканером |
| Core/Node pairing | Постоянное доверие, heartbeat, binding | Обрыв канала и reboot; состояние K1 доказывается отдельно |
Часть журналов кажется дублированием только по названию: журнал сетевого запроса и журнал физического START отвечают на разные вопросы. Их физическое объединение без анализа crash consistency опасно. При этом хранение однотипных presentation-состояний в нескольких frontend-местах не даёт новых гарантий и является кандидатом на упрощение.
`state()` сейчас не чистая проекция: он выполняет локальную retirement/reconciliation работу и может инициировать уже разрешённый active-stream recovery. Это видно с `facade.py:7285`. Поэтому простое изменение частоты polling или перенос `state()` в новый UI может затронуть lifecycle. Чистую проекцию можно выделять только вместе с независимым владельцем reconciliation, сохранив все переходы и их порядок.
## 6. Матрица поведения, которое следует сохранить
Статусы ниже различают историческую физическую приёмку, наличие реализации/тестов и непроверенный Node parity. Наличие теста здесь не означает его нового запуска.
| Сценарий | Обязательное поведение | Основание / текущая граница |
|---|---|---|
| Первый scan не сразу видит включённый K1 | Одно ограниченное discovery; никаких скрытых connect/write; отдельное время первого candidate | `ble/scanner.py:1207`; сегодняшнее окно 6→20 с; свежая UI-приёмка нужна |
| Выбор устройства, ввод сети | Только локальный draft; никаких аппаратных действий от selection | #3, canon CONN-06/-70/-74; frontend fences |
| Apply | Не более одной reviewed сетевой mutation, exact captured handle | `wifi_provisioning.py:687`; durable callback перед write |
| Отказ до write | Прямо сообщить отсутствие отправки; освободить завершённую попытку | Network journal/ledger и error annotations |
| Потеря ACK или HTTP после write | Не повторять write; читать результат того же intent | `networkProvisioning.ts:105`; idempotency journal |
| K1 подключился к Wi-Fi, MQTT ещё не готов | network_applied сохраняется; read-only bootstrap не превращается во второй Apply | `facade.py:12001`, `_schedule_control_bootstrap_continuation:4439` |
| Неверная сеть / старый 7f02 | Не принимать чужой/старый target; ожидание и classifier должны согласоваться | `wifi_provisioning.py:709`, `_post_dispatch_network_target:35334`; найдено расхождение завершения polling |
| Повторный scan после STOP | Новый acquisition без stale camera/ingress/session | #49 field acceptance; `test_next_scan_retires_stale_terminal_live_perception_ingress_before_start` |
| Потеря только интернета при живой Node/K1 LAN | Локальный capture не зависит от Core preview; Core показывает потерю канала | #76 ownership; физический Linux K1 gate открыт |
| Потеря маршрута Node/K1 во время записи | Отозвать control; сохранить exact lineage; bounded read-only rebind | #3 23.08 macOS; `test_control_first_loss_freezes_topology_before_ephemeral_retirement` |
| Сеть off → sleep → wake → сеть on | Не повторять START; новый host epoch и новые identity/control proofs; вернуть data consumers | #3 физически принято на Mac; на Node отдельно |
| Потеря сети между ordinal 12 и 1314 | Сохранить durable START proof, выполнить существующий read-only recovery | `application_session.py:1020`, lifecycle test `test_post_start_refresh_timeout_wakes_existing_read_only_recovery` |
| K1 выключился во время калибровки | Не висеть в ложном ожидании; SCAN_OVER завершает локально, physical ambiguity сохраняется | #3 calibration-loss; `_reconcile_acquisition` |
| K1 вернулся READY после power cycle | Зафиксировать cessation, не объявлять успешный STOP и не начинать новый START | `test_active_stream_recovery_device_standby_never_restarts_scanner` |
| По прежнему IP отвечает другой K1/сервис | Не принимать endpoint за identity; не переписывать pin автоматически | `test_active_stream_recovery_wrong_identity_blocks_without_retry_or_camera_reopen` |
| Node/Core backend restart | Новое runtime поколение, сохранённая pairing/history; никакого replay команд | #76 heartbeat + separate K1 restart checkpoint |
| Restart активной K1 acquisition | Только доказательная rehydration; новый writer/gap, first PCL admission; старое evidence неизменно | `tests/test_xgrids_active_acquisition_restart_rehydration.py:326`; 22 тест-функции в файле; Node E2E ещё не принят |
| STOP ACK есть, READY нет | Не объявлять завершение; standby-unconfirmed/unknown, read-only reconciliation | #49; `test_stop_response_preserves_precise_unknown_outcome_through_predeadline_loss` |
| Принудительное локальное завершение | Закрыть только локальных владельцев; не посылать STOP; поздний recovery не оживляет их | `test_local_force_finish_cleanup_failure_is_visible_retryable_and_fences_late_success` |
| Две вкладки / поздний REST / смена mode | Старый runtime/revision не перетирает новый, команда не дублируется | `stateOrdering.ts`, lifecycle.ts; #3 REST/WS acceptance |
| Browser refresh/cache reset/закрытие | Не влияет на физическую команду и recorder; UI заново читает backend | #49/#76; CONN-62; отдельная clean-cache GUI проверка обязательна |
| Rerun не открылся, камера/данные живы | Viewer-only recovery; не переподключать K1 и не терять raw | #49 listener/admission; #3 live_receiver recovery |
| Камера пропала, MQTT жив | Camera-only recovery точного source/evidence epoch | `test_camera_stall_snapshot_does_not_reconnect_live_mqtt_runtime`, camera watchdog tests |
| Live consumer медленный / worker недоступен | Ограничивать preview, сохранять source-of-record и не менять scanner authority | #10, #66, #76; Node sustained-resource gate открыт |
| Переключение live → Data → LAB | Разные admission/lifecycle/settings policies; исправление Bridge не меняет эти профили | #66; `viewerProfile.ts`, отдельный аудит live camera root |
Восстановление нельзя свести к правилу «никаких повторов»: запрещены автоматические физические mutations, но ограниченные read-only наблюдения и consumer-only reconnect нужны и уже приняты. Для active-stream exception backoff задан 0,5/1/2/4/5 с с пределом интервала; автоматического terminal timeout нет. Exact READY/SCAN_OVER/fault, изменение lineage и явное локальное завершение имеют разные исходы. Источник: `docs/20_K1_CONNECTION_SUPERVISION_CANON.md:574`.
## 7. Конкретные проблемы и риски текущей структуры
### F1 — Перегруженный coordinator и зависимость проекции от lifecycle
**Подтверждено статически.** `facade.py` соединяет protocol, OS/network, durable stores, process/native leases, acquisition/camera, recovery, ошибки и UI policy. `connect()` содержит Bridge, Direct, Quick, host association, reset/retirement и child bootstrap. В `state()` совмещены чтение и упорядоченное локальное завершение.
Практический риск: изменение unrelated presentation/polling влияет на момент cleanup или recovery; вынесенный callback может поменять порядок захвата gate и публикации proof. Размер файла сам по себе не обосновывает удаление guard. Первый допустимый structural шаг — выделение неизменных типов/проекций и изолированных функций с сохранением вызовов и их порядка.
### F2 — Нижний и верхний уровни по-разному заканчивают station observation
**Подтверждённое расхождение критериев; полевой причинный статус открыт.** `wifi_provisioning.py:725` завершает polling при любом адресе, кроме `None` и AP fallback. Верхний слой `facade.py:11792` проверяет requested network и допускаемый post-dispatch target. Если первая post-write выборка ещё описывает прежнюю LAN, helper больше не ждёт следующую, даже при оставшемся 45-second budget.
Для исправления потребуется единый reviewed terminal predicate либо передаваемый transport-слою observation criterion. До изменения нужен сценарий «старая LAN → переходный status → запрошенная LAN» с одной записью и несколькими чтениями. Это не объяснение сегодняшнего ATT4: ATT-исключение возникает на write, до этого polling.
### F3 — UI повторно собирает смысл операции и скрывает полезный контекст
**Подтверждено кодом и пользовательскими скриншотами.** Экран держит отдельные local attempt, candidate-unavailable, saved reconnect, applied recovery и physical recovery representations; один failure записывается в несколько presentation slots. Ошибка дублируется, а SSID из `ProvisioningAttemptPresentation` не показан в итоговой ошибке. Primary reconnect фактически выполняет проверку существующего состояния, а исправление сети требует другого пути.
Из успешного private evidence известна подтверждённая сеть; на раннем скриншоте введено другое написание. SSID последней неуспешной операции не сохранён, поэтому обвинять конкретно последний ввод нельзя. UI должен позволять проверить собственный ввод в текущем локальном intent, без публикации SSID в Ops/общие логи и без восстановления password. Требуется согласовать это с прежней формулировкой secret-free error contract, которая запрещает вставлять SSID из backend exception.
Кандидат на упрощение: одна typed presentation projection из server attempt + текущего локального draft, один операторский error и один явно названный следующий шаг. Backend policy остаётся authority.
### F4 — Новый Node-путь теряет часть уже существующего recovery-контракта
**Подтверждено статически; аппаратный Node/K1 запуск не выполнен.** `NodeBridge` переиспользует общий service — это правильная основа. Но `project()` (`node_bridge.py:55`) экспортирует краткие `connected`, `ready_to_start`, `phase`, candidates и runtime; отсутствуют exact connection attempt, typed failure, safe-next-action, child bootstrap progress и snapshot revision.
`network.provision` возвращает durable network ACK до завершения read-only bootstrap. `DeviceEnrollmentWindow.tsx` получает один projected result и не подписывается на последующее enrollment state; он может показать «связь пока не подтверждена», хотя bootstrap продолжает работать. Это не обязательно отказ соединения. Verify требует `device_id` в текущих candidates (`node_bridge.py:116`): cold saved reconnect после restart не эквивалентен принятому LAB пути.
Python `/operation` сворачивает все exceptions в 409 с одной фразой; Go `call()` заменяет non-200 ещё одной общей ошибкой, а `execute()` классифицирует её как unknown. Это безопасно по запрету replay, но стирает различие между stale-before-dispatch, отказом устройства и unknown-after-dispatch. Backend typed diagnostics, улучшенные в LAB, не доходят до Node UI.
Не следует копировать 3 732-строчный LAB-компонент в Node. Нужно довести общий typed operation/recovery контракт через транспортные оболочки и оставить две компактные поверхности над одной семантикой.
### F5 — Несогласованные deadlines между Node и K1 runtime
**Подтверждено статически; воспроизведение не проводилось.** Node UI выдаёт request deadline 170 с; Core/Go допускают до 180 с; Go HTTP client имеет 185 с, но сам вызов ограничен command deadline; общий `network.provision` journal задаёт 240 с. Timeout доставки может наступить до terminal outcome нижнего уровня. Политика unknown/no-auto-retry корректна, но оператору недоступно полноценное наблюдение той же операции после таймаута через урезанную проекцию.
Нужно разделять срок допуска до отправки, ожидание transport-ответа и наблюдение принятой операции. Увеличить все timeout не решает владение и корреляцию.
### F6 — Матрица приёмки и freezes плохо отражают актуальную систему
**Подтверждено.** Manifest ссылается в основном на целые test files, а не на конкретный test/scenario/physical run. Один lifecycle файл содержит 446 test-функций. `planned` в старом manifest не доказывает отсутствие реализации сегодня; `software-covered` не доказывает Node parity.
Guardrail закреплён на `c041a569...` и защищает packet oracle/frozen paths. Он полезен как защита от случайного protocol drift, но не как единственный критерий нового рефакторинга. Отдельные сегодняшние source changes уже выходят за старый файл-freeze. Нельзя просто переснять hashes, объявив поведение сохранённым.
### F7 — Профильность Rerun частично отделяет lifecycle, но не всю кастомизацию
**Подтверждено текущим кодом и предыдущим аудитом.** Есть разные live/recorded/LAB profile kinds и remount boundary. Но в Control Station общий `App.tsx:183` хранит `sceneSettings`; live и Saved Sessions используют общий канал настроек, тогда как LAB имеет отдельные result settings. Поэтому формулировка «все три профиля полностью изолированы» сейчас слишком сильна.
Это самостоятельный долг, не основание трогать viewer в рефакторинге Bridge. Протокол подключения, live camera producer и presentation-профиль нужно принимать отдельно. Исправление camera evidence-root сегодня также не является изменением Wi-Fi протокола.
## 8. Обязательные границы будущего рефакторинга
Это предложение для последующего решения, не начатая реализация.
1. Сохранить текущий рабочий snapshot и сопоставить каждый обязательный scenario с точным existing test, physical run и платформой. Отдельно перечислить Node-only проверки. Исторические версии Ops не переписывать как новую приёмку.
2. Выделить один typed outcome: `not_dispatched`, `network_applied`, `control_ready`, `outcome_unknown`, конкретный отказ; не выводить результат из HTTP-кода. Во всех оболочках сохранять operation/runtime/target correlation и допустимое действие.
3. Разделить normal Bridge provisioning и recovery orchestration. Нормальный путь может быть коротким; recovery обязан сохранять explicit target, physical ledger и ownership. Quick остаётся отдельной принятой strategy; его не переносить на борт и не удалять из LAB.
4. Разделить state projection и reconciliation owner, только после фиксации существующего порядка переходов и lock ownership. Переносить по одной обязанности, без одновременного изменения protocol, camera и viewer.
5. Упростить UI на основе общей семантики результата. Компактность достигается уменьшением повторной интерпретации, а не скрытием unknown или заменой всех отказов словом «подключение».
6. После каждого узкого изменения — соответствующая автоматическая регрессия, затем отдельный физический UI-сценарий. По указанию владельца перед каждым аппаратным/UI-прогоном очистить cache; обычный reload не засчитывать. Сейчас очищенный прогон не выполнен: browser tool не предоставляет доступную очистку.
7. Производительность измерять на одном и том же сценарии и ledger: click→BLE discovery, connect, write-return, status proof, network ACK, route/TCP/DeviceInfo, START, first PCL, first camera. У каждой стадии свой бюджет; таймер ожидания без стадии недостаточен.
Неприкосновенны: exact wire frame/command order, одна mutation на intent, durability до dispatch, запрет command replay, identity/runtime/host-epoch fences, отделение record от preview, gaps при restart, reader-only recovery и сохранение других Rerun профилей. Менять эти контракты можно только отдельным обоснованным решением, а не попутно при разрезании файлов.
## 9. Что пока нельзя утверждать
- Причина всех сегодняшних ATT-ошибок не установлена единым доказательством. FW-specific interpretation ATT4/6 полезна, но не заменяет exact entered-network evidence последнего intent.
- Найденный ранний выход polling — статически установленный риск другого этапа; он не был воспроизведён на физическом K1.
- Холодный reboot БК на несколько минут во время K1 capture не принят этим аудитом. В коде есть restart rehydration, в Ops принят heartbeat/recovery на отдельных конфигурациях; их Linux end-to-end композиция требует своей приёмки.
- Все 77 scenario не перепроверены аппаратно и все тесты не перезапущены. Список нужен для предотвращения регрессии, а не для новой зелёной отметки.
- Рефакторинг не начат. Нет изменения пакета, установки на Mini, restart сервиса или публикации в Ops.
## 10. Проверяемые источники
- [Полный индекс Ops](2026-09-06-k1-bridge-ops-index.json): все 76 карточек, 41 K1 label, 128 полученных активных комментариев; основные semantic источники указаны выше.
- [Hashes текущего кода](2026-09-06-k1-bridge-code-snapshot.json): точные bytes критических модулей и test-файлов на момент чтения, без proprietary evidence или credentials.
- [LixelGO IP protocol observation](../lab/002_LIXELGO_IPHONE_LOCAL_PROTOCOL_20260716.redacted.md), [Wi-Fi profile](../04_K1_WIFI_PROVISIONING_PROFILE.md).
- [Connection supervision canon](../20_K1_CONNECTION_SUPERVISION_CANON.md), [acceptance manifest](../k1-connection-acceptance.manifest.json), [recovery runbook](../runbooks/K1_CONNECTION_RECOVERY.md), [physical recovery ADR](../adr/0015-k1-physical-state-recovery.md).
- [Bridge incident](2026-09-06-k1-bridge-connection-incident.md), [station reply semantics](2026-09-06-k1-station-reply-semantics.md), [live camera root](2026-09-06-k1-live-reference-camera-root.md), [Node Bridge implementation and open gates](2026-09-06-node-k1-bridge-implementation.md).
Чтение Ops и исходников позволило восстановить рабочие гарантии и найти конкретные границы риска. Следующее решение должно выбирать одну такую границу и её приёмку; переписывание всего K1-контура сразу не имеет достаточного доказательного основания.
## 11. Уточнение цели: необязательная интеграция устройства и переносимость
Дополнительный запрос владельца: K1 должен быть необязательной интеграцией; macOS — первая принимаемая платформа, Ubuntu — следующая проверка переносимости. Другие модели XGRIDS не должны требовать встраивания их протоколов в Core. Ниже — архитектурное предложение, не новая реализация или аппаратная приёмка.
### Что уже отделено, а что ещё нет
- Есть manifest `DevicePlugin`, отдельный frontend пакета `plugins/xgrids-k1`, backend в `src/k1link/device_plugins/xgrids_k1`, нейтральный SDK и загрузчик с проверкой версии, набора действий и handshake. Это реальная существующая основа, которую следует сохранить.
- [Composition frontend](../../apps/control-station/src/composition/devicePlugins.ts) явно включает K1 в сборку. Это правильное место выбора поставляемых модулей, но сейчас выбор статический; независимо устанавливаемый frontend этим не доказан.
- [Backend composition](../../src/k1link/web/device_plugin_composition.py) допускает только `transitional-in-process`. [ADR 0011](../adr/0011-laboratory-plugin-runtime-handshake-and-transport-seam.md) прямо исключает из текущих гарантий crash containment, supervisor, portable media IPC и установку пакетов. Наличие runtime transport не означает, что отдельный процесс уже работает.
- [Общий pyproject](../../pyproject.toml) включает BLE/MQTT-зависимости и K1 CLI. Общий Python wheel содержит весь `src/k1link`. Поэтому независимое удаление драйвера пока нельзя считать принятой возможностью.
- [SensorWorkspace](../../packages/sensor-ui/src/SensorWorkspace.tsx) напрямую импортирует `K1Detail` и выбирает его по `device.kind === 'k1'`. Это конкретная зависимость общей поверхности от устройства; её место — регистрация визуального расширения интеграцией.
- Анализ Python import graph обнаружил 14 модулей `compute`, прямо импортирующих K1 analysis/protocol/replay. Это главным образом работа с данными, а не управление радио. Их нельзя механически удалять вместе с драйвером: нужны отдельные границы чтения архивов и проверка LAB.
- [NodeBridge](../../src/k1link/device_plugins/xgrids_k1/node_bridge.py) уже использует тот же compatibility service с Linux-адаптерами. Следовательно, второй реализации всей K1-логики сейчас нет и создавать её не требуется. Однако полноценная Ubuntu-приёмка и общий контракт проекции результата ещё не завершены.
Размер и связность `facade.py` — проблема внутреннего устройства плагина. Прямые зависимости общей установки, сенсорного UI и вычислительных модулей — проблема его внешней границы. Перенос одного большого файла в новую папку не решит ни ту, ни другую автоматически. Историческое имя Python namespace `k1link` само по себе не является доказательством зависимости от оборудования.
### Предлагаемая ответственность
| Слой | Ответственность |
| --- | --- |
| Mission Core / Node host | Реестр интеграций и execution node, разрешения, общий жизненный цикл операций, хранение evidence, маршрутизация нормализованных потоков, оболочка UI и профили просмотра. |
| K1 domain | Точный протокол BLE/MQTT/RTSP, совместимость модели и прошивки, порядок подключения и acquisition, интерпретация ответов, восстановление и доказательства физического состояния K1. |
| Адаптер платформы | BLE backend, наблюдение сетевого интерфейса и маршрута, доступ к секретам, запуск и остановка локального runtime. Реализации macOS и Ubuntu могут различаться. |
| Представление интеграции | Поля подключения, возможности, настройки и статусы K1 через общий SDK; регистрация в host вместо веток K1 в общих компонентах. |
| Чтение данных | K1 raw codec как явно объявленная зависимость чтения; нормализованные сохранённые данные читаются без активного драйвера оборудования. Совместимость существующих LAB проверяется отдельно. |
Переносимую логику разумно сохранить на текущем Python, отделяя платформенные вызовы через небольшие интерфейсы. Требование переносимости относится к поведению и контракту, а не к одному бинарнику для всех ОС. SDK уже экспортирует JSON Schema; смена языка возможна позднее при доказанной необходимости, но сейчас добавила бы повторную проверку протокола и recovery.
Целевая граница исполнения — отдельный runtime интеграции на том компьютере, рядом с которым находится устройство: локально для LAB, на БК для бортового сценария. Core обращается к выбранному execution node; Wi-Fi оператора не подменяет Wi-Fi около БК. Контракт должен покрывать не только команды, но и события операции, состояние, evidence и media. Объявление permissions в manifest не заменяет их фактическое ограничение.
Плагин имеет общий протокол взаимодействия с host, но собственную семантику устройства. Core не должен знать BLE UUID или порядок команд K1. И наоборот, плагин не должен владеть профилем лабораторного Rerun, глобальной навигацией или реестром аппаратов. Другие модели XGRIDS получают явные model/firmware profiles; общие vendor-компоненты выделяются по подтверждённому совпадению поведения, а не по одному бренду. Обобщение на одновременную работу нескольких устройств — отдельная приёмка, не свойство текущего single-session runtime.
### Последовательность и критерии завершения
1. Закрепить существующие сценарии и одинаковый контракт результата/событий для LAB и Node. Получение HTTP-ответа и физическое завершение операции остаются разными событиями.
2. Выделить внутри K1 provisioning, наблюдение/recovery и проекцию состояния, сохранив durable dispatch boundary, владельца операции, блокировки и порядок команд. Не пересобирать одновременно acquisition, камеру и Rerun.
3. Устранить прямые зависимости общих компонентов, разделить поставку драйвера и чтение записей. Проверить запуск Core без установленного K1 и работу других устройств и доступных архивов.
4. Реализовать процессную границу за существующим transport с явными контрактами событий/media. Проверить зависание и падение плагина: Core остаётся доступным, состояние устройства честно меняется, другая запись не прерывается. Перезапуск runtime не переотправляет provisioning, START или STOP автоматически; сначала восстанавливаются журнал и наблюдаемое состояние.
5. Принять macOS, затем Ubuntu Bridge на том же domain-коде. Перенос считается доказанным, когда меняются адаптеры и упаковка, а не K1-логика или Core. Допустимо начать с явного состава сборки; динамическая установка UI, магазин плагинов и hot reload не нужны для первой проверки границы.
Отдельные обязательные критерии: восстановление связи после потери сети, безопасное обнаружение рестарта устройства и БК, отсутствие повторной физической команды, отсутствие регрессии LAB/recorded/live профилей Rerun. Каждый аппаратный прогон выполняется через UI с предварительной очисткой браузерного кэша по указанию владельца. В этом дополнении выполнены только чтение кода и документирование; проверок на устройстве не было.
@@ -0,0 +1,108 @@
{
"schema_version": "missioncore.bridge-analysis-code-snapshot/v1",
"base_commit": "020a878915ea32c64963975447650fd8eee29071",
"working_tree": "dirty; source code not changed by this analysis",
"analysis": "Static inspection and existing evidence only; no tests executed",
"files": [
{
"path": "src/k1link/device_plugins/xgrids_k1/facade.py",
"sha256": "24c4ec6c33c4d21a99065c4146df12bdc2d81efd5a9da769c077b1ab4bea896d",
"lines": 35996
},
{
"path": "src/k1link/device_plugins/xgrids_k1/connection_supervisor.py",
"sha256": "2adbd7d61c34816d5be9a66baa788634408df7dc0e4a0d21520241d5e965806a",
"lines": 2252
},
{
"path": "src/k1link/device_plugins/xgrids_k1/ble/wifi_provisioning.py",
"sha256": "f169a78f66e71d9c85d503e174d5f19f19e010aa04db3fe3ba8cf032d809de55",
"lines": 818
},
{
"path": "src/k1link/device_plugins/xgrids_k1/ble/scanner.py",
"sha256": "ea6f1fed6db5b3b98735779e14237e0eaf40a3cbe2d0cd8f3196e95308c12153",
"lines": 1306
},
{
"path": "src/k1link/device_plugins/xgrids_k1/protocol/application_session.py",
"sha256": "f91367b19b3f85ce2b47279acbdeff969cd5a10b64bc5ceb90b843840a1e6b26",
"lines": 1930
},
{
"path": "src/k1link/device_plugins/xgrids_k1/node_bridge.py",
"sha256": "550f34cf5b947131ca1067a1dbe74731ca7a7601cb42bbb5a9c1a08ebfd9179b",
"lines": 248
},
{
"path": "src/k1link/device_plugins/xgrids_k1/linux_host.py",
"sha256": "c2e3fa6884e97c9cbd7bbf29cfb1260e539f61fd2e7d18e0dc3c1ab94c59d4d4",
"lines": 214
},
{
"path": "src/k1link/fleet/device_enrollment.py",
"sha256": "9856fd5638aaef88317ec82fa482b6e358efb5fd57deecf61fb9e9d0e14c4fa4",
"lines": 173
},
{
"path": "apps/node-agent/internal/node/device_enrollment.go",
"sha256": "9e92379ed9e262d6ee4b16c754df7852d129bf6daec324140e85a4b15af9c5e0",
"lines": 323
},
{
"path": "plugins/xgrids-k1/frontend/src/components/K1ProvisioningPipeline.tsx",
"sha256": "d6205ce8be004238791471fb7c3eb2eccb0676bedb81a06514771c26419cc0ab",
"lines": 3732,
"hook_mentions": {
"useState": 17,
"useEffect": 13,
"useRef": 10
}
},
{
"path": "plugins/xgrids-k1/frontend/src/useXgridsK1Runtime.ts",
"sha256": "dcc0c1e5ca1c841e963ddc18d24628799ec97d036bc1095eb6f22124cf000565",
"lines": 2135
},
{
"path": "plugins/xgrids-k1/frontend/src/networkProvisioning.ts",
"sha256": "45ba4eaf111b1cf8fa0945884aaf95e3920a274cd1d7f0e4797f8da2ee7fd204",
"lines": 241
},
{
"path": "packages/sensor-ui/src/DeviceEnrollmentWindow.tsx",
"sha256": "de02c9443f606bd0d73ec476c34f095c05851005497426431271dbe1ba36f064",
"lines": 49,
"hook_mentions": {
"useState": 10,
"useEffect": 1,
"useRef": 1
}
},
{
"path": "packages/sensor-ui/src/enrollment.ts",
"sha256": "ad97fbe9ecc1b6567d8ee67d6d32c4cc39a31ce95bf0eb48ef9cbc40a884a16f",
"lines": 37
},
{
"path": "docs/k1-connection-acceptance.manifest.json",
"sha256": "477615cdd34685bc10d9815de0c9642fbdfcdea2eb05e04b5e07b7c87a2e9854",
"lines": 93
},
{
"path": "tests/test_xgrids_acquisition_lifecycle.py",
"sha256": "97728c489551f16a6ccf2606a19ca077f5a2926adc67681f367666188df008ef",
"lines": 34794
},
{
"path": "tests/test_xgrids_active_acquisition_restart_rehydration.py",
"sha256": "3fc85fba32eab54fda12625ac77d54f7e3ae8a454708b4167b71e0ffd4271d61",
"lines": 2482
},
{
"path": "tests/test_node_k1_bridge.py",
"sha256": "25af4bd6d40b8df487b231e599730117b62598a78b838d20e5443395920c2c48",
"lines": 204
}
]
}
@@ -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.
File diff suppressed because it is too large Load Diff
@@ -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,62 @@
# K1 LAB acceptance and onboard continuation
## Accepted local baseline
The owner confirmed a successful local LAB connection after correcting the
network name. Core independently recorded `network_applied` and DeviceInfo /
control ready in 8.298 seconds. In the next operator-run session the owner
reported the usual approximately 20-second calibration, prompt point-cloud
appearance, visible signal loss after disabling Wi-Fi and successful recovery
after enabling it again (approximately 20 seconds of waiting). These recovery
timings are operator observations, not newly instrumented latency measurements.
They qualify that local run, not every outage duration, a Node reboot or Ubuntu.
The owner requested committing and pushing the working implementation and
resuming K1 Bridge on the paired onboard computer. The backend connection
read-model extraction, plugin-owned sensor UI, BLE discovery fixes, station
error messages, camera evidence-root handling, Node enrollment and separate
viewer-profile discriminators are included in this checkpoint. Earlier audit
documents retain their original time-scoped validation and limitations.
## Spatial scene placement
The owner explicitly moved the local test-device live scene from Control to
LAB. Its operator job is inspection of the current local test stream; source
selection, acquisition and live settings retain their existing ownership.
Keeping it under Control would imply the future operational board view; a new
root or duplicate viewer would add an unnecessary product surface. The existing
workspace is therefore registered under LAB with its stable `spatial-scene` ID,
renderer, settings and links intact. Existing sidebar, workspace shell and
`globe` icon are reused; no new Design Guideline primitive is introduced.
Only obsolete Control quick links to that scene are retired on settings read.
Custom page copy, media and unrelated links remain intact. Default Control
shortcuts become cameras and map. Home and LAB links can still open the same
scene. This is an owner-approved relocation, with no Rerun parameter change.
## Validation and onboard candidate
- Environment migration, Node bridge, connection read-model and Fleet
enrollment tests passed: 36 cases. Ruff passed for the environment changes.
- Go package tests passed with the built Node UI embedded and an isolated
build cache. No system toolchain or package installation was needed.
- Core architecture/type checks passed. Full frontend run: 784/786 passed;
the two failures were old LAB workspace-list expectations. Those expectations
were updated and both affected suites passed; no production change followed.
- Node 0.7.1 is the next package version so the earlier 0.7.0 candidate is not
silently replaced. Build provenance now covers the moved K1 frontend sources.
The 51 K1 and 29 RealSense wheel hashes were verified before reuse.
The current paired board was resolved from authenticated Core Fleet data and
its SSH host key matched the previously trusted Mini key. It runs Node 0.6.11.
Administrative installation requires the owner's normal Ubuntu authentication;
non-interactive sudo is unavailable. No password is requested in chat.
The exact package, installation result, source commit and final Core UI
delivery are recorded in a subsequent release addendum after the build.
Device preparation and real Bridge acquisition must still be accepted through
both interfaces on that board. Operator and board WLANs remain independent;
the board owns Bluetooth, network observation, K1 commands and raw data.
Ops direct tools are absent in this session. This local report is prepared for
the K1 and Node cards; no Ops publication is claimed.
@@ -0,0 +1,145 @@
{
"stage": "k1-plugin-boundary-r1",
"source_files": [
{
"path": "src/k1link/device_plugins/xgrids_k1/facade.py",
"sha256": "4d5144ffa261b4e436be037ed421a9a48386485ff81128aa2cf95ca6efe9f469",
"lines": 35549
},
{
"path": "src/k1link/device_plugins/xgrids_k1/connection_attempt.py",
"sha256": "0619c1555c182bb635272c66c998e457d3fb1378d221b440449b5b274fc2a27d",
"lines": 495
},
{
"path": "src/k1link/device_plugins/xgrids_k1/node_bridge.py",
"sha256": "11a58900ca1a8553e4301fa4a2cc61717b06a2b2ee18de412decf8c65ab2acdf",
"lines": 319
},
{
"path": "tests/test_k1_connection_read_model.py",
"sha256": "f3476dad252c9efb536fb6c1e7e84154e7704bae063fe7f1abd4fe536c4ed585",
"lines": 170
},
{
"path": "apps/control-station/src/core/device-plugins/contracts.ts",
"sha256": "c01d9f81d554d7c3155576eee148cf62f3ddbb891ef718c259ef5e4ad0190070",
"lines": 125
},
{
"path": "apps/control-station/src/workspaces/fleet/VehicleSensors.tsx",
"sha256": "d0922fe89b0e9162c045cbf6163737b789af0aa1239034b7bd7596fa9a961897",
"lines": 24
},
{
"path": "apps/control-station/tsconfig.app.json",
"sha256": "29297ad53cb440fe8bb0d399aacbf8d192d1df2bd87cc264537b6444fb15f3cd",
"lines": 52
},
{
"path": "apps/control-station/vite.config.ts",
"sha256": "92dab3df33e06c5e9302d1bacc9f9526b64341fa55a0cd466479b4c9505d4cd3",
"lines": 134
},
{
"path": "apps/control-station/test/sensorEnrollment.test.mjs",
"sha256": "b5d8d3ad4a4baaa5e16c40cfd5ec710c7800625bfdd53a3fa6e310d3b3352f8f",
"lines": 102
},
{
"path": "apps/node-agent/ui/src/NodeSensors.tsx",
"sha256": "e26493746c8a50e604a1e3f8bc75c3ba7cfdafd1a70b9750a1375f8d5bc14300",
"lines": 7
},
{
"path": "apps/node-agent/ui/tsconfig.json",
"sha256": "0bb5f9df862a6ce20e2877a947a0741e7e1c823939ac01dc4f76a449a72a290a",
"lines": 34
},
{
"path": "apps/node-agent/ui/vite.config.js",
"sha256": "682f1d5d691a74a7e3e5c15d38bb522455017b8f873fb424b374859e84d42a84",
"lines": 17
},
{
"path": "packages/sensor-ui/src/contracts.ts",
"sha256": "da575e7fd018e5bf6802c0ae5a706f05dda436587b7088eef2c009e56a7ade51",
"lines": 44
},
{
"path": "packages/sensor-ui/src/enrollment.ts",
"sha256": "2c510e2ae8597953b08ab017f89663f149a95b08b7c416708476f2fe5dc3cfd8",
"lines": 20
},
{
"path": "packages/sensor-ui/src/extensions.ts",
"sha256": "db930b121cf828f7e5e6a533f51b2eae40d99e383a6be6b37e16e28ea2271de3",
"lines": 29
},
{
"path": "packages/sensor-ui/src/pluginSdk.ts",
"sha256": "349f87b22d49e63a9b7f5ffc8fd1b05be424d9a3b918f232a491b333ae08ce8a",
"lines": 4
},
{
"path": "packages/sensor-ui/src/SensorWorkspace.tsx",
"sha256": "30f91a8d313e07c36f7b8577317cadece18467ece9af093abf76b3d59b14fd78",
"lines": 43
},
{
"path": "plugins/xgrids-k1/frontend/src/plugin.ts",
"sha256": "bc3a062cd9408622de39eb00bd60b0e9e701095eba1483d2a22874f1630fde07",
"lines": 17
},
{
"path": "plugins/xgrids-k1/frontend/src/api.ts",
"sha256": "df61477546658836b3472d423c5366c6c6e0de0cb08a178d7c9e31b93edf1e2e",
"lines": 2078
},
{
"path": "plugins/xgrids-k1/frontend/src/connectionAttempt.ts",
"sha256": "ff28453215c6c6f4a23d969b06914fd191e376da87fd72f3a4e44cb8d7a61286",
"lines": 17
},
{
"path": "plugins/xgrids-k1/frontend/src/components/K1OperatorError.tsx",
"sha256": "27bed30edf1397617bfcf51cea0a2783b6d7cdd43ece6469d092b89559f1e154",
"lines": 231
},
{
"path": "plugins/xgrids-k1/frontend/src/sensors/K1Detail.tsx",
"sha256": "b02130ff22bbd81d7a409001dfde21df682b20229a4f08d600d3e8f412a4acde",
"lines": 25
},
{
"path": "plugins/xgrids-k1/frontend/src/sensors/enrollment.ts",
"sha256": "ca29b7f3f5b33e6bf582748e86bc85eceef6c634ea877f494e27694177a46191",
"lines": 124
},
{
"path": "plugins/xgrids-k1/frontend/src/sensors/K1LiveSettings.tsx",
"sha256": "dc7ce392e92240766b69c3db25cd500d6752917a675acdf57c53a65c6a34f6a4",
"lines": 28
},
{
"path": "plugins/xgrids-k1/frontend/src/sensors/plugin.ts",
"sha256": "89bab0bd0955dbc85820b32194ea0d3b3d03fbb0567297fb8527db9543ccf705",
"lines": 7
},
{
"path": "plugins/xgrids-k1/frontend/src/sensors/runtime.ts",
"sha256": "f813948f12b9eb86ee7f5c70dbbbe33cc785ce7e4c941d8059a3d1ed82829de5",
"lines": 7
},
{
"path": "plugins/xgrids-k1/frontend/src/sensors/K1LiveView.tsx",
"sha256": "0493ff5b09f191fdb03bc14f635ec67289100f9e6927f6dd3d124f0f0656babe",
"lines": 63
},
{
"path": "plugins/xgrids-k1/frontend/src/sensors/DeviceEnrollmentWindow.tsx",
"sha256": "59dedf270b7a5b4fad3bb75b47b4ad4cac964a54aa4d73075c38b7f6860a3ff8",
"lines": 62
}
]
}
@@ -0,0 +1,147 @@
# K1 plugin boundary R1 — implementation
Scope: the first independently verifiable increment of the approved plugin
refactor. This report does not declare the whole architecture migration or
the physical Bridge incident resolved.
## Result
The shared sensor workspace no longer imports K1, selects K1-specific controls,
or owns K1 control-operation deadlines. K1 detail, live settings, live renderer
and Bridge enrollment are under `plugins/xgrids-k1/frontend/src/sensors`.
Control Station receives their optional contributions from the installed
`DeviceUiPlugin` registry; Node composes the same integration explicitly.
Both hosts resolve `@mission-core/sensor-sdk` to the same portable host surface.
Missing and ambiguous renderers fail closed; absence does not substitute the
camera detail controls for an unsupported device. Existing camera behavior is
retained through the host's default camera detail.
Four functions were mechanically extracted from `facade.py` into
`connection_attempt.py`. Their ASTs match the pre-change working-tree copy
exactly, including runtime/target/parent/lease/host-epoch proof checks. The
facade is now 35,549 lines; this is a first responsibility boundary, not a claim
that the remaining service is small or process-isolated.
Node now exports the existing connection attempt, snapshot revision, runtime
start time and current permitted enrollment actions. The compact projection
does not forward diagnostics, timeline payloads or exception text. After an
invocation error the driver may read the exact journaled operation once; it
never resends the physical command. Explicit host admission rejection is
distinguished from an unknown post-dispatch outcome. Secrets are removed from
input references even on pre-dispatch rejection; this does not claim secure
erasure of immutable language/runtime copies.
The enrollment observer distinguishes delivery completion from network and
control completion. It sends one POST, resolves lost responses by the same
operation ID, follows the exact owned bootstrap, ignores older snapshots,
stops observing across a runtime replacement, and stops client observation on
window closure without cancelling/replaying physical commands. Read-only
observation has a bounded budget beyond the existing delivery deadline; it
does not extend command admission. Polling in the open window keeps host
availability and backend authority visible. A changed runtime/discovery/mode
invalidates the selected device and credential draft.
## Validation
- All 629 cases in the existing acquisition lifecycle suite passed after the
extraction, including the existing recovery and physical-authority checks.
- The final Python read-model, Node bridge and Fleet enrollment set passed:
22 cases, covering projection privacy, exact operation correlation,
no-resubmit behavior, secret lifetime and pre-dispatch rejection.
- The full frontend unit suite passed: 784 tests. After the final observer and
presentation changes, 41 focused enrollment/boundary/architecture tests passed, including
two additional cases for current authority and delayed results during an
observed board outage.
- The architecture test passed. Control Station and Node TypeScript checks
and production builds passed. Jobs were run without a second backend or
Docker startup. The Node dependency install used the local npm cache with
scripts disabled. Full frontend tests emitted existing sandbox HMR/listener
warnings; the unit tests do not establish browser or hardware acceptance.
- Ruff and `git diff --check` passed. The four extracted functions retain equal
ASTs after formatting. Existing dirty working-tree changes were preserved.
The canonical LaunchAgent was restarted only after `/api/state` showed idle
capture/control and no accepted/running operation. The configured persistent
data directory remained outside the checkout. The replacement serves
`127.0.0.1:8000`, reports liveness `alive`, runtime
`snapshot-runtime-fefcc6209249e845738886a72813e13c`, and idle source state.
No Mission Core backend was found listening on 8765.
## Limits and next acceptance gate
No physical K1 command or new capture was issued in this increment. Browser
hardware acceptance remains pending the owner's required cache clearing; the
available browser tools do not expose that operation. A new tab or reload was
not counted as a cleared-cache test. The Node UI is built locally, but this
increment was not installed on the Ubuntu mini-PC.
The BLE packet format, provisioning dispatch/polling behavior, acquisition
command order, camera producer and Rerun profile settings were not modified.
In particular, this work does not prove resolution of the earlier ATT failure
or the separately identified early network-status polling risk.
Backend optional installation, dependency separation, isolated macOS runtime,
portable media IPC, archive-codec separation and full Node reboot acceptance
remain subsequent stages. Multiple simultaneous device sessions and a
multi-provider enrollment picker are not supplied by this increment.
Before the next deeper lifecycle extraction, accept one cleared-cache UI
Bridge connection and its exact operation stages on the prepared Mac; keep
network/control unknown states honest and compare recovery against the prior
Ops scenarios. Ubuntu Bridge requires its own physical acceptance before any
claim of platform parity. LAB/recorded/live Rerun profiles remain separate
acceptance dimensions.
References: [architecture audit](2026-09-06-k1-bridge-architecture-review.md),
[Ops inventory](2026-09-06-k1-bridge-ops-index.json),
[changed source hashes](2026-09-07-k1-plugin-boundary-r1-sources.json).
The implementation and open hardware checks were added to Ops card #3,
“Mission Core. Lixel K1 / XGRIDS Integration”, as 12 titled R1 blocks. The
17 existing blocks and historical card status were preserved.
## Owner's fresh Chrome test after R1
The owner reported clearing Chrome's cache, scanning, selecting K1 and
submitting the network form. The canonical service journal records discovery
completing in 12.395 seconds and the network attempt running from
2026-09-06 21:16:38.081 UTC to 21:16:49.307 UTC (11.226 seconds).
It failed at `gatt-write`, after one dispatched 99-byte write-with-response,
with `BleakGATTProtocolError`, ATT 4 (`INVALID_PDU`). Advertised characteristic
properties were read/write; the reported command capacity was 253 bytes.
The write was not confirmed. Neither post-write status polling nor MQTT
bootstrap was reached. This failure does not implicate the separate early
status-poll termination risk, camera path or Rerun profiles.
The selected FW 3.0.2 profile classifies this reply as
`k1-wifi-network-not-found`, based on the previously reviewed firmware callback.
This attempt itself did not obtain live DeviceInfo and cannot establish the
actual firmware or exact cause of network invisibility. The submitted network
name is deliberately not retained in the server journal. The exact SSID was
requested from the owner; no password was requested or retained. No spelling
error or radio incompatibility is assumed.
Source review confirmed that the form captures the password before clearing
React state, and the station path passes it to the existing frame encoder.
The existing LAB form trims SSID edges; whether that affects this attempt is
unknown. No provisioning behavior was changed on this evidence. The focused
Wi-Fi provisioning and firmware failure suites passed (43 tests); hardware
acceptance remains failed/pending diagnosis.
Sanitized operation events, UTC and monotonic observation timestamps, owner
test notes and an artifact hash are retained outside Git in the ignored
`.runtime/k1-connect-incident-20260906/fresh-2116/` directory. The agent issued
only local state/liveness reads, with no new device command or service restart.
Core 8000 remained alive. A fresh Ops card read timed out; this addendum has
not yet been copied to Ops.
## Subsequent successful connection and refusal guidance
The owner subsequently confirmed a mistyped network name and a successful
connection after correcting it. Core independently reports network applied
and DeviceInfo/control ready in 8.298 seconds on 2026-09-07. The previous
failure is explained for this incident; its historical outcome is not rewritten.
The UI now names Wi-Fi failure prominently and asks to check the network name
and password. See [the bounded correction and evidence](2026-09-07-k1-wifi-refusal-ui.md).
This accepts that connection attempt, not stream, reboot, Ubuntu or fresh-cache
visual acceptance of the subsequent wording change.
@@ -0,0 +1,57 @@
# K1 Wi-Fi refusal: operator diagnosis and UI correction
The owner confirmed that the previously entered Wi-Fi network name was wrong
and that connection succeeded after correcting it. The canonical Core journal
independently records a successful Bridge attempt on 2026-09-07, from
07:01:10.203 UTC to 07:01:18.501 UTC: `network_applied`,
`device-info-confirmed`, control `ready` (8.298 seconds).
The earlier ATT 4 response is therefore consistent with the exact reviewed
FW 3.0.2 station callback's network-not-found branch. The response establishes
the device's reported failure, not independently which character the operator
typed incorrectly. ATT 6 remains the separate credentials-required branch;
neither is inferred from a generic timeout or lost response.
## Implementation
The existing plugin-owned `networkFailurePresentation.ts` keeps distinct public
codes and specific reasons, while both messages now explicitly ask the operator
to check the network name and password. `K1OperatorError.tsx` gives those exact
station refusals the title “Не удалось подключить K1 к WiFi”. Existing LAB and
onboard enrollment consumers share the text. Unclassified Bluetooth errors,
timeouts and unknown outcomes keep their separate presentation.
This increment changes presentation only. No BLE frame, station callback
classification, ledger, retry authorization, acquisition command, connection
recovery or Rerun setting was changed. The historical failed attempt remains
failed/unconfirmed; the later successful attempt supplies its own authority.
## Validation and delivery
- Existing rendered recovery, provisioning and onboard enrollment assertions
were updated for the explicit Wi-Fi cause and credential guidance.
- Architecture checks passed (4 cases); the full frontend suite passed
(786 cases), including the existing no-resubmit and unknown-outcome coverage.
- Control Station and Node UI TypeScript checks and production builds passed.
Builds/tests were sequential; no Docker or extra backend was started.
- The Core UI was built in a temporary directory, then published with HTML last
and prior hashed assets retained for already open tabs. HTTP verification of
`/assets/app-B5NeGPU1.js` confirmed both the new title and guidance; the HTML
response has `Cache-Control: no-store`.
- Core 8000 remained alive with the same runtime identity and the K1 control
session ready. The backend was not restarted. No backend listens on 8765.
- The Node UI was built locally; this increment was not installed on Ubuntu.
No new device failure or browser hardware attempt was induced. Browser cache
clearing was not performed by the agent and no fresh-cache visual acceptance
is claimed. The user's successful attempt and rendered tests are distinct
evidence sources. Stream, power-loss and Ubuntu acceptance are not established
by this connection-only success.
Sanitized success evidence, owner notes, UTC/monotonic observation timestamps
and SHA-256 artifact metadata are retained outside Git in the ignored
`.runtime/k1-connect-incident-20260906/success-20260907/` directory. No Wi-Fi
password or submitted network name was retained.
The direct Ops tools are absent from this turn's tool inventory, and no tool
discovery endpoint is exposed. This report is prepared for card #3; it has not
been written to Ops. Legacy Ops widgets and raw API workarounds were not used.
+5
View File
@@ -52,6 +52,11 @@ compatibility profile and adapter.
Current host implementation references:
- `packages/sensor-ui/src/pluginSdk.ts` — portable frontend sensor contribution
and transport types exposed as `@mission-core/sensor-sdk` by both hosts;
integration-owned detail/enrollment UI enters through reviewed composition,
not device branches in the shared sensor workspace;
- `apps/control-station/src/core/device-plugins/frontendSdk.ts` — current
public TypeScript/React host surface for statically reviewed UI contributions;
- `plugins/xgrids-k1/frontend/` — first physically plugin-owned consumer of
+13 -8
View File
@@ -1,10 +1,13 @@
import {useCallback,useEffect,useState} from 'react';
import {useCallback,useEffect,useState,type ComponentType} from 'react';
import {ActivityIndicator,Button,Icon,IconButton,ResourceList,ResourceRow,SettingsCard,StatusBadge,TextField,ToastStack,Window,WindowFooterActions} from '@nodedc/ui-react';
import {perform,type Sensor,type SensorInventory,type SensorTransport} from './contracts';
import {SensorDetail} from './SensorDetail';
import {sensorStatus} from './sensorStatus';
import {sensorContribution,type SensorUiContribution,type SensorEnrollmentProps} from './extensions';
import type {RerunHostFactory} from './rerunHost';
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,contributions=[],EnrollmentView}:{transport:SensorTransport;enabled?:boolean;onDetailChange?:(open:boolean)=>void;createRerunHost?:RerunHostFactory;contributions?:readonly SensorUiContribution[];EnrollmentView?:ComponentType<SensorEnrollmentProps>}){
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 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]);
@@ -19,20 +22,22 @@ export function SensorWorkspace({transport,enabled=true,onDetailChange}:{transpo
},[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);}}
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||sensorContribution(contributions,v)?.retainOffline)??[];
const editingCurrent=inventory?.items.find(v=>v.id===editing?.id);
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}/>:<>
<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>
{!inventory?<ActivityIndicator label="Получаем устройства БК"/>:connected.length===0?<SettingsCard title="Поддерживаемые устройства не обнаружены" description="Подключите камеру к бортовому компьютеру."/>:<ResourceList aria-label="Устройства БК">{connected.map(item=>{
const Detail=device?(sensorContribution(contributions,device)?.Detail??(device.kind?null:SensorDetail)):null;
return <div className="sensor-workspace">{device?Detail?<Detail enabled={enabled&&fresh} device={device} transport={transport} back={()=>setSelected(null)} refresh={refresh} failure={failure} createRerunHost={createRerunHost}/>:<div className="sensor-content"><Button onClick={()=>setSelected(null)}>К устройствам</Button><SettingsCard title="Просмотр устройства недоступен" description="Интеграция этого устройства не установлена."/></div>:<>
<div className="sensor-actions sensor-inventory-toolbar"><StatusBadge tone={fresh&&enabled?'success':'neutral'}>{!enabled?'БК недоступен':fresh?'Сведения с БК':'Нет свежих сведений'}</StatusBadge><div className="sensor-actions">{transport.enrollment&&EnrollmentView&&<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=>{
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 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;
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={sensorContribution(contributions,item)?.icon??'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&&sensorContribution(contributions,item)?.supportsPreparation!==false&&<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>}
</>}
<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&&sensorContribution(contributions,editing)?.supportsPreparation!==false&&(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&&EnrollmentView&&<EnrollmentView transport={transport.enrollment} onClose={()=>setAdding(false)} onChange={()=>{void refresh();}}/>}
<ToastStack items={error?[{id:'sensor-error',tone:'error',title:error,durationMs:null}]:[]} onDismiss={()=>setError('')}/>
</div>;
}
+7 -4
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 SensorOption { id: string; sensor: string; label: string; value: number; min: number; max: number; step: number; read_only: boolean }
export interface Sensor {
kind?:string; connection_label?:string; control?:{generation:number;revision:number;phase:string;can_start:boolean;can_stop?:boolean;acquisition_id:string|null};
live_settings?: Record<string,unknown>;
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};
profiles?: SensorProfile[]; defaults?: string[]; options?: SensorOption[]; layers: string[];
@@ -18,19 +20,20 @@ export interface SensorCommand {
}
export interface SensorOperation {state:string;error?:string;result?:unknown}
export interface SensorTransport {
enrollment?: import('./enrollment').EnrollmentTransport;
inventory: () => Promise<SensorInventory>;
subscribe?: (receive:(value:SensorInventory)=>void, unavailable:()=>void) => (()=>void);
submit: (value:SensorCommand) => Promise<SensorOperation>;
operation: (id:string) => Promise<SensorOperation>;
}
export function command(device:Sensor,action:string,parameters:Record<string,unknown>={}):SensorCommand {
export function command(device:Sensor,action:string,parameters:Record<string,unknown>={},timeoutMs=60000):SensorCommand {
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,
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:timeoutMs)).toISOString(),parameters};
}
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);
export async function perform<T>(transport:SensorTransport,device:Sensor,action:string,parameters:Record<string,unknown>={},timeoutMs=60000):Promise<T>{
const request=command(device,action,parameters,timeoutMs);let value=await transport.submit(request);
const deadline=Date.parse(request.deadline_at)+10000;
while (value.state==='running'||value.state==='queued') {
if(Date.now()>deadline) throw new Error('Подтверждение пока не получено. Обновите состояние устройства.');
+20
View File
@@ -0,0 +1,20 @@
export interface EnrollmentState {
available:boolean; fresh?:boolean; node_id:string; name?:string; runtime_id?:string;
mode?:string; 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;
snapshot_revision?:number; runtime_started_at?:string; allowed_actions?:string[];
connection_attempt?:unknown; command_result?:{operation_id:string;status:string;error_code?: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>;
}
+29
View File
@@ -0,0 +1,29 @@
import type {ComponentType} from 'react';
import type {IconName} from '@nodedc/ui-react';
import type {Sensor, SensorTransport} from './contracts';
import type {EnrollmentTransport} from './enrollment';
import type {RerunHostFactory} from './rerunHost';
export interface SensorDetailProps {
device:Sensor; transport:SensorTransport; enabled:boolean;
back:()=>void; refresh:()=>Promise<void>; failure:(error:unknown)=>void;
createRerunHost?:RerunHostFactory;
}
export interface SensorEnrollmentProps {
transport:EnrollmentTransport; onClose:()=>void; onChange:()=>void;
}
export interface SensorUiContribution {
kind:string;
Detail:ComponentType<SensorDetailProps>;
icon:IconName;
retainOffline:boolean;
supportsPreparation:boolean;
}
export function sensorContribution(
contributions:readonly SensorUiContribution[], device:Sensor,
):SensorUiContribution|undefined {
const matches=contributions.filter(value=>value.kind===device.kind);
// An ambiguous renderer must not acquire authority over a device.
return matches.length===1?matches[0]:undefined;
}
+4
View File
@@ -0,0 +1,4 @@
export * from './contracts';
export type * from './enrollment';
export type * from './rerunHost';
export type * from './extensions';
+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 .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-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)}
+17
View File
@@ -14,6 +14,23 @@ This manifest currently exposes only `xgrids.lixelkity-k1`, and the transitional
runtime still owns one active model/session at a time. Concurrent model/session
routing remains a later supervisor milestone.
The onboard sensor views and Bridge enrollment controller are also plugin-owned
under `frontend/src/sensors`. The shared sensor workspace consumes optional
`SensorUiContribution` renderers through `@mission-core/sensor-sdk`; the main
frontend registers these on `DeviceUiPlugin.sensorUi`, and the Node frontend
selects its installed contribution explicitly. An absent or ambiguous renderer
does not fall through into another device's detail controls. The current single
enrollment provider is explicit; a multi-provider enrollment picker is not yet
implemented.
`connection_attempt.py` owns the pure join of network journal, owned control
bootstrap, recovery verification and current control authority. LAB keeps the
full public attempt; Node exports a compact allowlisted projection without
timeline payloads or diagnostic bundles. The Node observer submits once and
follows the same operation across response loss and bootstrap completion.
These are read-model and presentation boundaries, not a new command scheduler
or a claim of independently installable backend/process isolation.
The plugin owns:
- the exact firmware/topology compatibility profiles under
@@ -275,6 +275,11 @@ export function XgridsK1Connection({ model, host }: DevicePluginConnectionProps)
: sourceRuntimeBusy || preparedAcquisition
? "warning"
: "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 =
isConnectionControlBootstrapSettling(state);
const connectionPhaseLabel = livePreparationPending
@@ -293,6 +298,8 @@ export function XgridsK1Connection({ model, host }: DevicePluginConnectionProps)
? savedBridgeNetworkSetupRequired
? "Нужна настройка сети"
: "Требуется действие"
: connectionAttemptFailed
? "Подключение не завершено"
: projectedPhase === "error"
? connectionPhaseFallbackLabel(projectedPhase)
: connectionTopology?.status === "active"
@@ -316,6 +323,8 @@ export function XgridsK1Connection({ model, host }: DevicePluginConnectionProps)
? "accent"
: physicalRecoveryRequired
? "warning"
: connectionAttemptFailed
? "warning"
: projectedPhase === "error"
? phaseTone(projectedPhase)
: connectionTopology?.status === "active"
@@ -336,6 +345,8 @@ export function XgridsK1Connection({ model, host }: DevicePluginConnectionProps)
: physicalRecoveryRequired
? physicalRecoveryDetail
?? "Безопасное восстановление прежнего K1 сейчас недоступно."
: connectionAttemptFailed
? "Проверьте состояние K1 или начните новое подключение."
: !sourceRuntimeBusy && connectionTopology?.status === "configured-unverified"
? "Начните новое подключение."
: !sourceRuntimeBusy && connectionTopology?.status === "active"
+2 -22
View File
@@ -1,3 +1,4 @@
import type {XgridsConnectionAttemptSummary} from './connectionAttempt';
import type { ViewerSettings } from "@mission-core/plugin-sdk";
import { xgridsK1Actions, xgridsK1Manifest } from "./manifest";
@@ -826,28 +827,7 @@ export const XGRIDS_CONNECTION_ATTEMPT_PHASES = [
export type XgridsConnectionAttemptPhase =
typeof XGRIDS_CONNECTION_ATTEMPT_PHASES[number];
export interface XgridsConnectionAttempt {
schema_version: "missioncore.xgrids-k1-connection-attempt/v1";
attempt_id: string;
connection_mode: XgridsConnectionMode;
status: OperationStatus;
stage: string;
public_error_code: string | null;
side_effect_status: string;
phase: XgridsConnectionAttemptPhase;
control_state: "ready" | "control_not_ready" | "unknown";
safe_next_action:
| "wait-for-current-attempt"
| "continue-with-control-verification"
| "verify-control-read-only"
| "start-acquisition"
| "stop-local-receiver"
| "retire-unavailable-physical-target"
| "scan-select-connect"
| "manual-recovery-required";
automatic_retry: false;
accepted_at: string | null;
completed_at: string | null;
export interface XgridsConnectionAttempt extends XgridsConnectionAttemptSummary {
timeline: XgridsOperationEvent[];
diagnostic_bundle?: XgridsConnectionDiagnosticBundle;
}
@@ -3,13 +3,14 @@ import { Button } from "@nodedc/ui-react";
import type { XgridsConnectionAttempt } from "../api";
import { hostFailureDiagnosticPresentation } from "../hostDiagnosticPresentation";
import { STATION_WIFI_FAILURE_MESSAGES } from "../networkFailurePresentation";
const connectionAttemptStageLabels: Record<string, string> = {
accepted: "Запрос принят",
"scan-selection-admitted": "Результат выбран",
"host-wifi-profile-preflight": "Подготовка профиля Wi‑Fi",
"device-ap-activation": "Подготовка локальной сети",
"ble-provisioning-write": ередаются настройки сети",
"ble-provisioning-write": одключение K1 к Wi‑Fi",
"ble-write-dispatched": "Настройки переданы",
"status-observing": "Ожидание ответа",
"device-topology-applied": "Целевая сеть подтверждена",
@@ -62,6 +63,8 @@ export function attemptNextActionLabel(
return "Проверить управление без изменения сети";
case "start-acquisition":
return "Готово к запуску приёма";
case "stop-acquisition":
return "Остановить текущий приём";
case "stop-local-receiver":
return "Завершить только локальный приём";
case "retire-unavailable-physical-target":
@@ -74,6 +77,9 @@ export function attemptNextActionLabel(
}
const publicConnectionErrorLabels: Readonly<Record<string, string>> = {
...STATION_WIFI_FAILURE_MESSAGES,
BleakGATTProtocolError:
"K1 ответил ошибкой Bluetooth. Подключение к Wi‑Fi не подтверждено; проверьте состояние устройства или начните новое подключение.",
"network-provision-discovery-generation-conflict":
"Результат Bluetooth-поиска устарел до отправки. Настройки устройства не изменялись; выполните новый поиск.",
"connection-mode-draft-revision-conflict":
@@ -121,6 +127,7 @@ export function K1OperatorError({
onClear: () => void;
}) {
const structured = hostFailureDiagnosticPresentation(diagnostic);
const stationFailure = STATION_WIFI_FAILURE_MESSAGES[attempt?.public_error_code ?? ""];
const [diagnosticCopied, setDiagnosticCopied] = useState(false);
const copyDiagnosticBundle = async () => {
if (!attempt?.diagnostic_bundle || !navigator.clipboard) return;
@@ -137,7 +144,7 @@ export function K1OperatorError({
>
<span className="error-banner__dot" aria-hidden="true" />
<div className="error-banner__copy">
<strong>{title}</strong>
<strong>{stationFailure ? "Не удалось подключить K1 к Wi‑Fi" : title}</strong>
<p>{publicConnectionErrorLabel(attempt, structured)}</p>
{recoveryActions ? (
<div className="error-banner__recovery-actions">
@@ -31,6 +31,7 @@ import type {
} from "../api";
import { profileSelectionForConnectionMode } from "../compatibility";
import {
BLE_DISCOVERY_TIMEOUT_SECONDS,
DEFAULT_CONNECTION_MODE,
connectionModeOptions,
type ConnectionMode,
@@ -66,10 +67,12 @@ import {
type ReadOnlyConnectionObservationTarget,
} from "../lifecycle";
import { finiteMetric } from "../presentation";
import { STATION_WIFI_FAILURE_MESSAGES } from "../networkFailurePresentation";
import type { XgridsK1Controller } from "../runtimeContext";
import { K1OperatorError } from "./K1OperatorError";
import {
connectionActionAuthoritySnapshot,
networkProvisionFailureMessage,
type BleDiscoverySubmitResult,
type ConnectionActionAuthoritySnapshot,
} from "../useXgridsK1Runtime";
@@ -1866,7 +1869,7 @@ export function K1ProvisioningPipeline({
const connectionRecoveryRequired = connectionRecoveryIsRequired(
connectionRecoveryKey,
escapedConnectionRecoveryKey,
) && !(searchRequested && !searchDisplayActive);
) && (connectionAttemptFailed || !(searchRequested && !searchDisplayActive));
const presentedConnectionRecoveryRequired = !livePreparationPending && (
connectionRecoveryRequired || connectionRecoveryVerificationPending
);
@@ -2089,7 +2092,7 @@ export function K1ProvisioningPipeline({
const startedAt = Date.now();
const updateCountdown = () => {
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();
const timer = window.setInterval(updateCountdown, 250);
@@ -2350,7 +2353,7 @@ export function K1ProvisioningPipeline({
let scanResult: BleDiscoverySubmitResult | null = null;
try {
if (!runtimeActionIsCurrent(actionFence)) return;
scanResult = await scanWithResult({ durationSeconds: 6 });
scanResult = await scanWithResult({ durationSeconds: BLE_DISCOVERY_TIMEOUT_SECONDS });
if (
!localScenarioActionEpochIsCurrent(
actionEpoch,
@@ -2573,12 +2576,12 @@ export function K1ProvisioningPipeline({
}
const freshStartAllowed = result.intentDisposition === "release";
const failureMessage = freshStartAllowed
const failureMessage = networkProvisionFailureMessage(observedOperation) ?? (freshStartAllowed
&& provisioningFailureRequiresFreshCandidate(result.failureReasonCode)
? "Результат Bluetooth-поиска устарел до отправки. Настройки не отправлены; выполните явный повторный поиск."
: freshStartAllowed
? "Подключение не началось. Настройки удалены; после устранения причины начните новый явный поиск."
: "Результат отправки неизвестен. Повторное применение заблокировано, чтобы не отправить BLE-команду дважды; дождитесь, пока система определит безопасное продолжение.";
: "Подключение не подтверждено. Проверьте состояние K1 или начните новое подключение; автоматического повтора не было.");
setConnectionAttemptPresentation((current) => (
current?.idempotencyKey === idempotencyKey
? {
@@ -3203,7 +3206,9 @@ export function K1ProvisioningPipeline({
disabled={isBusy || modeResetInFlight}
onClick={() => void changeDesiredConnectionMode(connectionMode)}
>
{savedBridgeNetworkSetupRequired
{STATION_WIFI_FAILURE_MESSAGES[connectionRecoveryAttempt?.public_error_code ?? ""]
? "Указать сеть Wi‑Fi заново"
: savedBridgeNetworkSetupRequired
? "Подключить K1 к общей сети"
: "Подключить новый K1"}
</Button>
@@ -3256,7 +3261,7 @@ export function K1ProvisioningPipeline({
title="Подключение"
status={
searchActive
? `Поиск · ${scanSecondsRemaining ?? 6} с`
? `Поиск · до ${scanSecondsRemaining ?? BLE_DISCOVERY_TIMEOUT_SECONDS} с`
: connectionAttemptSettling
? "Устройство выбрано"
: presentedPhysicalRecoveryRequired
@@ -3316,7 +3321,7 @@ export function K1ProvisioningPipeline({
>
<ActivityIndicator />
<strong>
Поиск Bluetooth · {scanSecondsRemaining ?? 6} с
Поиск Bluetooth · до {scanSecondsRemaining ?? BLE_DISCOVERY_TIMEOUT_SECONDS} с
</strong>
</div>
) : networkRecoveryRequired
@@ -4,6 +4,9 @@ export type ConnectionMode = "bridge" | "quick-connect" | "direct-connect";
export type MountType = "handheld" | "vehicle-mounted" | "uav" | "backpack";
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 SUPPORTED_MOUNT_TYPE = "handheld" as const satisfies MountType;
export const SUPPORTED_GNSS_MODE = "none" as const satisfies GnssMode;
@@ -0,0 +1,17 @@
/** Public read model. Journal and current backend policy remain authoritative. */
export interface XgridsConnectionAttemptSummary {
schema_version:'missioncore.xgrids-k1-connection-attempt/v1';
attempt_id:string;
connection_mode:'bridge'|'quick-connect'|'direct-connect';
status:'accepted'|'running'|'operator_action_required'|'succeeded'|'failed'|'cancelled'|'timed_out'|'interrupted';
phase:'network_applied'|'network_not_applied'|'network_outcome_unknown';
control_state:'ready'|'control_not_ready'|'unknown';
stage:string;
public_error_code:string|null;
side_effect_status:string;
safe_next_action:'wait-for-current-attempt'|'continue-with-control-verification'|'verify-control-read-only'|'start-acquisition'|'stop-acquisition'|'stop-local-receiver'|'retire-unavailable-physical-target'|'scan-select-connect'|'manual-recovery-required';
automatic_retry:false;
accepted_at:string|null;
completed_at:string|null;
recovery_operation_id?:string|null;
}
@@ -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-wifi-credentials-required":
"K1 не смог подключиться к сети Wi‑Fi: устройство запросило учётные данные сети. Проверьте название сети и пароль, затем укажите сеть заново.",
};
@@ -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;
}
+2
View File
@@ -1,3 +1,4 @@
import {xgridsK1SensorUi,K1EnrollmentWindow} from './sensors/plugin';
import type { DeviceUiPlugin } from "@mission-core/plugin-sdk";
import { XgridsK1Connection } from "./XgridsK1Connection";
import { K1SpatialControls } from "./components/K1SpatialControls";
@@ -7,6 +8,7 @@ import "./styles.css";
export const xgridsK1Plugin: DeviceUiPlugin = {
manifest: xgridsK1Manifest,
sensorUi: {contributions: [xgridsK1SensorUi], Enrollment: K1EnrollmentWindow},
RuntimeProvider: XgridsK1RuntimeProvider,
SpatialControlsView: K1SpatialControls,
connectionViews: Object.freeze({
@@ -90,9 +90,9 @@ const connectionPolicyNextActionCopy: Record<string, string> = {
"diagnose-physical-command-ledger": "Не повторяйте команду; сначала проверьте журнал физических команд.",
"restart-ble-runtime": "Контролируемо перезапустите локальный BLE-контур и обновите состояние.",
"scan-ble": "Выполните свежий поиск Bluetooth-устройств.",
"observe-fresh-device-network": "Дождитесь автоматического восстановления связи с выбранным K1.",
"observe-current-device-network": "Дождитесь автоматического восстановления связи с тем же K1.",
"observe-configured-device-network": "Дождитесь автоматического восстановления сохранённого подключения K1.",
"observe-fresh-device-network": "Нажмите «Переподключиться», чтобы проверить состояние выбранного K1.",
"observe-current-device-network": "Нажмите «Переподключиться», чтобы проверить связь с тем же K1.",
"observe-configured-device-network": "Нажмите «Переподключиться», чтобы проверить сохранённое подключение K1.",
"recover-current-device-network": "Нажмите «Подключиться заново».",
"inspect-host-network": "Проверьте активную локальную сеть и маршрут этого компьютера.",
"probe-endpoint": "Дождитесь обновления подключения K1.",
@@ -0,0 +1,62 @@
import {useEffect,useRef,useState} from 'react';
import {ActivityIndicator,Button,ResourceRow,Select,SettingsCard,StatusBadge,TextField,ToastStack,Window,WindowFooterActions} from '@nodedc/ui-react';
import {bridgeFormValid,enroll,enrollmentAllowed,connectionAttempt,enrollmentNotice,mergeEnrollmentState,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 [scanned,setScanned]=useState(false);
const lifetime=useRef<AbortController|null>(null);const running=useRef(false);
useEffect(()=>{
const controller=new AbortController();lifetime.current=controller;setState(null);setBusy('loading');
let timer:ReturnType<typeof setTimeout>|undefined;
async function observe(){
try{const value=await transport.state();if(!controller.signal.aborted)setState(current=>mergeEnrollmentState(current,value));}
catch{if(!controller.signal.aborted)setState(current=>current?{...current,available:false,fresh:false}:null);}
finally{if(!controller.signal.aborted){setBusy(current=>current==='loading'?'':current);timer=setTimeout(()=>void observe(),3000);}}
}
void observe();return()=>{controller.abort();if(timer)clearTimeout(timer);};
},[transport]);
const attempt=connectionAttempt(state);
const waiting=attempt?.status==='accepted'||attempt?.status==='running';
const notice=state?enrollmentNotice(state):'';
useEffect(()=>{setDevice('');setPassword('');},[state?.runtime_id,state?.discovery_generation,state?.mode_revision]);
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||running.current)return;running.current=true;const signal=lifetime.current?.signal;setBusy(action);setError('');
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,{signal,onState:value=>{if(!signal?.aborted)setState(current=>mergeEnrollmentState(current,value));}});
if(signal?.aborted)return;onChange();
setState(current=>mergeEnrollmentState(current,{...result,node_id:state.node_id,name:state.name}));
if(action==='scan'){setDevice('');setScanned(true);}
if(action==='networks')setNetworks(result.networks??[]);
if(result.command_result?.status==='rejected')setError(enrollmentNotice(result));
}catch(e){if(!signal?.aborted)setError(e instanceof Error?e.message:'Не удалось выполнить действие K1.');}
finally{running.current=false;if(!signal?.aborted)setBusy('');}
}
const close=()=>{setPassword('');onClose();};
return <Window open title="Подключение устройства к БК" onClose={close} footer={<WindowFooterActions>
<Button onClick={close}>Закрыть</Button><Button variant="primary" disabled={!ready||!!busy||waiting||!selected||!enrollmentAllowed(state,'connect')||!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||waiting||!enrollmentAllowed(state,'scan')} 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||waiting}/>}
<ResourceRow title="Wi-Fi рядом с БК" description="Выберите доступную сеть или введите её название. БК должен иметь доступ к этой сети." actions={<Button disabled={!!busy||waiting} 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||waiting}/>}
<TextField label="Название сети Wi-Fi" value={ssid} onChange={e=>{setSSID(e.target.value);setNetwork('manual');}} disabled={!!busy||waiting} autoComplete="off"/>
<TextField label="Пароль Wi-Fi" type="password" value={password} onChange={e=>setPassword(e.target.value)} disabled={!!busy||waiting} autoComplete="new-password"/>
<Button disabled={!!busy||waiting||!selected||!enrollmentAllowed(state,'verify')} onClick={()=>void run('verify')}>Проверить текущее подключение</Button>
</>}
{!!busy&&busy!=='loading'&&<ActivityIndicator label={busy==='scan'?'Ищем K1 на БК':busy==='networks'?'Ищем сети рядом с БК':'Проверяем подключение K1'}/>}
{notice&&<SettingsCard title={notice}/>}<ToastStack items={error?[{id:'enrollment-error',tone:'error',title:error,durationMs:null}]:[]} onDismiss={()=>setError('')}/>
</div></Window>;
}
@@ -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 './runtime';
import {K1LiveView} from './K1LiveView';
import type {RerunHostFactory} from '@mission-core/sensor-sdk';
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>;
}
@@ -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 './runtime';
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>;
}
@@ -0,0 +1,63 @@
import {useEffect,useRef,useState} from 'react';
import {ActivityIndicator,SettingsCard} from '@nodedc/ui-react';
import {perform,type Sensor,type SensorTransport} from './runtime';
import type {RerunHostFactory} from '@mission-core/sensor-sdk';
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>;
}
@@ -0,0 +1,124 @@
import type {EnrollmentState,EnrollmentCommand,EnrollmentOperation,EnrollmentTransport} from '@mission-core/sensor-sdk';
import type {XgridsConnectionAttemptSummary} from '../connectionAttempt';
import {STATION_WIFI_FAILURE_MESSAGES} from '../networkFailurePresentation';
export type {EnrollmentState,EnrollmentTransport} from '@mission-core/sensor-sdk';
export function connectionAttempt(state:EnrollmentState|null):XgridsConnectionAttemptSummary|null {
const value=state?.connection_attempt;
if(!value||typeof value!=='object')return null;
const candidate=value as Partial<XgridsConnectionAttemptSummary>;
return candidate.schema_version==='missioncore.xgrids-k1-connection-attempt/v1'&&typeof candidate.attempt_id==='string'
?candidate as XgridsConnectionAttemptSummary:null;
}
/** Older responses cannot restore readiness or overwrite a newer runtime. */
export function mergeEnrollmentState(current:EnrollmentState|null,incoming:EnrollmentState):EnrollmentState {
if(!current)return incoming;
if(incoming.node_id&&current.node_id&&incoming.node_id!==current.node_id)return current;
if(!incoming.available||incoming.fresh===false)return {...current,available:false,fresh:false};
if(current.runtime_id&&incoming.runtime_id!==current.runtime_id){
if(!incoming.runtime_started_at)return current;
if(current.runtime_started_at&&incoming.runtime_started_at<=current.runtime_started_at)return current;
}else if((incoming.snapshot_revision??-1)<(current.snapshot_revision??-1))return current;
return {...incoming,node_id:current.node_id,name:current.name??incoming.name,fresh:incoming.fresh??current.fresh};
}
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 function enrollmentAllowed(state:EnrollmentState|null,action:EnrollmentCommand['action']):boolean {
if(!state?.available||state.fresh===false)return false;
if(!state.allowed_actions)return true; // Older driver, server admission still applies.
if(action==='networks')return true;
const permissions=action==='scan'?['scan-ble']:action==='connect'?['provision-fresh-device']:
['verify-control-read-only','observe-current-device-network','observe-configured-device-network','observe-fresh-device-network'];
return permissions.some(value=>state.allowed_actions?.includes(value));
}
export interface EnrollmentObserver {
signal?:AbortSignal;
onState?:(state:EnrollmentState)=>void;
now?:()=>number;
pause?:()=>Promise<void>;
}
const unknownResult='Результат подключения пока не подтверждён. Обновите состояние K1 перед новой попыткой.';
/** Submit once. HTTP delivery and the device's network/control result are separate. */
export async function enroll(transport:EnrollmentTransport,initial:EnrollmentState,action:EnrollmentCommand['action'],parameters:Record<string,unknown>={},observer:EnrollmentObserver={}):Promise<EnrollmentState>{
const command=enrollmentCommand(initial,action,parameters);
const now=observer.now??Date.now;
// The physical journal has a 240 s budget. Beyond delivery we only observe.
const observationDeadline=Date.parse(command.deadline_at)+80000;
const assertActive=()=>{if(observer.signal?.aborted)throw new Error('Наблюдение закрыто.');};
const pause=observer.pause??(()=>new Promise<void>(resolve=>setTimeout(resolve,1000)));
let state=initial;
const adopt=(value:EnrollmentState)=>{
assertActive();
const normalized={...value,node_id:value.node_id||initial.node_id};
state=mergeEnrollmentState(state,normalized);observer.onState?.(state);
if(state.runtime_id!==command.runtime_id)throw new Error('Сеанс K1 изменился.');
return state;
};
let operation:EnrollmentOperation|null=null;
assertActive();
try {operation=await transport.submit(command);}catch{/* Resolve only this ID after a lost response. */}
finally{delete command.parameters.password;}
while(true){
assertActive();
if(operation?.operation_id&&operation.operation_id!==command.operation_id)throw new Error('Получен ответ на другое действие. Обновите состояние K1.');
if(operation?.result)adopt(operation.result);
if(operation&&operation.state!=='running'&&operation.state!=='queued')break;
if(now()>=observationDeadline)throw new Error(unknownResult);
await pause();assertActive();
try{operation=await transport.operation(command.operation_id);}catch{operation=null;}
}
if(action==='scan'||action==='networks'){
if(operation.state!=='complete'||!operation.result)throw new Error(operation.error||unknownResult);
return state;
}
// A network ACK can precede its owned DeviceInfo bootstrap. Keep observing
// the same attempt; no second POST, verify command or target selection occurs.
while(true){
assertActive();
const attempt=connectionAttempt(state);
const exactAttempt=attempt&&(attempt.attempt_id===command.operation_id||attempt.recovery_operation_id===command.operation_id);
const exactCommand=state.command_result?.operation_id===command.operation_id;
const terminalAttempt=exactAttempt&&attempt.status!=='accepted'&&attempt.status!=='running';
if(terminalAttempt)return state;
if(action==='verify'&&operation.state==='complete'&&operation.result&&state.selected_device_id===parameters.device_id&&state.connected)return state;
if(exactCommand&&['failed','rejected'].includes(state.command_result?.status??''))return state;
// Legacy drivers do not publish attempts. Do not infer success from a stale
// connected lamp or silently provision again after a transport failure.
if(operation.state==='complete'&&operation.result&&!attempt)return state;
if(now()>=observationDeadline)throw new Error(unknownResult);
await pause();assertActive();
try{adopt(await transport.state());}catch(error){
assertActive();
if(state.runtime_id!==command.runtime_id)throw error;
// Read outages are recoverable. Do not reinterpret them as a Wi-Fi refusal.
}
}
}
export function enrollmentNotice(state:EnrollmentState):string {
const attempt=connectionAttempt(state);
if(!state.available||state.fresh===false)return 'Нет свежих сведений с БК. Восстанавливаем связь.';
if(attempt?.status==='accepted'||attempt?.status==='running'){
return attempt.phase==='network_applied'?'K1 подключился к Wi-Fi. Проверяем канал управления.':'Подключаем K1 к выбранной сети Wi-Fi.';
}
const code=attempt?.public_error_code??state.command_result?.error_code;
if(state.command_result?.status==='rejected')return 'Выбранное устройство или сеанс изменились. Обновите сведения и выберите K1 заново.';
if(code&&STATION_WIFI_FAILURE_MESSAGES[code])return STATION_WIFI_FAILURE_MESSAGES[code];
if(state.connected)return 'K1 подключён к БК.';
if(attempt?.phase==='network_applied')return 'Настройки Wi-Fi применены. Связь с K1 пока не подтверждена; проверьте текущее подключение.';
if(attempt?.phase==='network_outcome_unknown')return unknownResult;
if(attempt?.status==='failed'||state.command_result?.status==='failed')return 'Подключение не завершено. Проверьте состояние K1 и выбранную сеть.';
return '';
}
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;
}
@@ -0,0 +1,7 @@
import type {SensorUiContribution} from '@mission-core/sensor-sdk';
import {K1Detail} from './K1Detail';
export {DeviceEnrollmentWindow as K1EnrollmentWindow} from './DeviceEnrollmentWindow';
export const xgridsK1SensorUi:SensorUiContribution={
kind:'k1', Detail:K1Detail, icon:'network', retainOffline:true, supportsPreparation:false,
};
@@ -0,0 +1,7 @@
import {perform as performSensor, type Sensor, type SensorTransport} from '@mission-core/sensor-sdk';
export type {Sensor, SensorTransport} from '@mission-core/sensor-sdk';
/** K1's established control-operation budget belongs to the integration. */
export function perform<T>(transport:SensorTransport, device:Sensor, action:string, parameters:Record<string,unknown>={}):Promise<T> {
return performSensor(transport,device,action,parameters,['start','verify','stop'].includes(action)?175000:60000);
}
@@ -39,8 +39,6 @@ import {
newMutationContext,
newOperationId,
operationAllowsFreshProvisioningIntent,
operationByIdempotencyKey,
operationNeedsReconciliation,
physicalStopIntentCheckpoint,
recommendedConnectionRecoveryObservationTarget,
readOnlyVerificationClearedReconciliation,
@@ -65,7 +63,9 @@ import {
import { selectMonotonicXgridsState } from "./stateOrdering";
import { operationHostFailureDiagnostic } from "./hostDiagnosticPresentation";
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 =
| "scan"
@@ -265,87 +265,6 @@ export function connectionActionAuthoritySnapshot(
}
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;
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(
operation: XgridsOperation | null | undefined,
): string | null {
@@ -1138,7 +970,7 @@ export function useXgridsK1Runtime(enabled: boolean) {
const operationId = newOperationId();
try {
const nextState = await xgridsK1Api.scanBle({
duration_seconds: options.durationSeconds ?? 6,
duration_seconds: options.durationSeconds ?? BLE_DISCOVERY_TIMEOUT_SECONDS,
operation_id: operationId,
expected_snapshot_runtime_id: expectedSnapshotRuntimeId(),
});
@@ -1412,143 +1244,36 @@ export function useXgridsK1Runtime(enabled: boolean) {
);
}
};
const previous = operationByIdempotencyKey(
state,
"network.provision",
request.idempotency_key,
);
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,
const observation = await observeProvisioningRequest(request, {
initialState: latestState.current,
send: (input) => xgridsK1Api.connect({
...input,
expected_snapshot_runtime_id: expectedSnapshotRuntimeId(),
});
} catch (connectError) {
// A failed network action is terminal and persisted by the backend.
// Re-read state only; never replay the device command. This replaces
// an opaque HTTP 502 banner with the exact host-association outcome.
let failedState: XgridsK1State;
try {
failedState = await xgridsK1Api.getState();
} catch {
throw connectError;
}
acceptState(failedState);
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";
}),
readState: () => xgridsK1Api.getState(),
acceptState,
currentState: () => latestState.current,
assertCurrent: assertOperatorIntentCurrent,
});
// Return the same authoritative snapshot on failure and success. The
// form must not reconstruct an outcome from an empty result object.
observedState = observation.state;
let nextState = observation.state;
const operation = observation.operation;
if (operation?.status !== "succeeded") {
captureFailureReason(operation);
intentDisposition = operationAllowsFreshProvisioningIntent(operation)
? "release"
: "retain";
const failureMessage = networkProvisionFailureMessage(operation);
throw apiErrorForOperation(
failureMessage
?? "Подготовительный этап завершился до команды K1. После устранения причины разрешена новая явная попытка.",
operation,
);
}
if (operation && operation.status !== "succeeded") {
captureFailureReason(operation);
throw apiErrorForOperation(
resetConnectSessionMessage(
"Текущая попытка подключения завершилась без подтверждения успеха.",
),
operation,
if (operation) {
throw apiErrorForOperation(
failureMessage ?? "Подключение не подтверждено. Проверьте состояние K1 или начните новое подключение; автоматического повтора не было.",
operation,
);
}
throw observation.transportError ?? new ApiError(
"Сервис не подтвердил результат подключения. Обновите состояние; команда автоматически не повторяется.",
);
}
const exactNetworkIntentCompleted = exactAppliedNetworkIntentCompleted(
+1
View File
@@ -32,6 +32,7 @@ missioncore-plugin-sdk = { path = "packages/plugin-sdk", editable = true }
[project.optional-dependencies]
perception-stream = ["grpcio>=1.76,<2"]
node-device-media = ["aiortc==1.14.0"]
[project.scripts]
k1link = "k1link.device_plugins.xgrids_k1.cli:app"
@@ -2,7 +2,10 @@ from __future__ import annotations
import asyncio
import math
import re
import sys
from collections.abc import Callable
from contextlib import suppress
from dataclasses import dataclass, replace
from importlib.metadata import version
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.
BLE_DISCOVERY_LEASE_TTL_SECONDS = BLE_RUNTIME_HANDLE_LEASE_TTL_SECONDS
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_handles: dict[str, BLEDevice] = {}
@@ -132,6 +137,14 @@ class BleDeviceRecord(TypedDict):
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):
schema_version: int
started_at_utc: str
@@ -141,6 +154,7 @@ class BleScanResult(TypedDict):
bleak_version: str
device_count: int
devices: list[BleDeviceRecord]
discovery_timing: BleDiscoveryTiming
def advertisement_record(device: BLEDevice, advertisement: AdvertisementData) -> BleDeviceRecord:
@@ -148,7 +162,7 @@ def advertisement_record(device: BLEDevice, advertisement: AdvertisementData) ->
normalized_name = (name or "").casefold()
return {
"macos_uuid": device.address,
"id_kind": "corebluetooth_uuid",
"id_kind": "bluez_address" if sys.platform.startswith("linux") else "corebluetooth_uuid",
"name": device.name,
"local_name": advertisement.local_name,
"rssi": advertisement.rssi,
@@ -803,11 +817,34 @@ def connected_device_recovery_name(
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(
captured: CapturedDiscoveredDevice,
) -> BLEDevice | None:
"""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
if not isinstance(details, tuple) or len(details) != 2:
return None
@@ -899,6 +936,20 @@ async def retrieve_known_device_capture_for_status_read(
or runtime["poisoned"]
):
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)
if context is None:
return None
@@ -977,10 +1028,14 @@ async def discover_known_device_capture_for_status_read(
or runtime["poisoned"]
):
return None
try:
UUID(macos_uuid)
except (TypeError, ValueError):
return None
if sys.platform.startswith("linux"):
if not re.fullmatch(r"(?:[0-9a-fA-F]{2}:){5}[0-9a-fA-F]{2}", macos_uuid):
return None
else:
try:
UUID(macos_uuid)
except (TypeError, ValueError):
return None
candidate = await BleakScanner.find_device_by_address(
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(
duration_seconds: float,
progress: BleOperationProgress,
@@ -1163,7 +1254,10 @@ async def _scan_impl(
progress.operation_stage = "discovery"
scan_generation = _begin_scan_generation(owner_epoch)
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()}
devices = [
advertisement_record(device, advertisement)
@@ -1181,10 +1275,11 @@ async def _scan_impl(
"started_at_utc": started_at,
"completed_at_utc": utc_now_iso(),
"duration_seconds": duration_seconds,
"adapter": "CoreBluetooth",
"adapter": "BlueZ" if sys.platform.startswith("linux") else "CoreBluetooth",
"bleak_version": version("bleak"),
"device_count": len(devices),
"devices": devices,
"discovery_timing": discovery_timing,
}
except BaseException:
_finish_failed_scan(scan_generation)
+11 -4
View File
@@ -303,6 +303,7 @@ class XgridsK1CameraGateway:
repository_root: Path,
plugin_id: str,
*,
evidence_root: Path | None = None,
committed_segment_observer: CommittedCameraSegmentObserver | None = None,
process_fence_descriptor_factory: Callable[[], int] | None = None,
producer_stall_observer: CameraProducerStallObserver | None = None,
@@ -316,6 +317,12 @@ class XgridsK1CameraGateway:
if not 0.01 <= producer_watchdog_interval_seconds <= 60.0:
raise ValueError("camera watchdog interval must be within 0.01..60 seconds")
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._lock = threading.RLock()
self._lifecycle_lock = threading.Lock()
@@ -674,8 +681,8 @@ class XgridsK1CameraGateway:
root = session_dir.expanduser().resolve()
if not root.is_dir():
raise ValueError("observation session directory does not exist")
if not root.is_relative_to(self._repository_root):
raise ValueError("camera recording root must stay inside the repository")
if not root.is_relative_to(self._evidence_root):
raise ValueError("camera recording root must stay inside the configured evidence root")
reserved_generation: list[int] = []
def reserve() -> bool:
@@ -805,8 +812,8 @@ class XgridsK1CameraGateway:
root = session_dir.expanduser().resolve()
if not root.is_dir():
raise ValueError("observation session directory does not exist")
if not root.is_relative_to(self._repository_root):
raise ValueError("camera recording root must stay inside the repository")
if not root.is_relative_to(self._evidence_root):
raise ValueError("camera recording root must stay inside the configured evidence root")
with self._lifecycle_lock:
with self._lock:
@@ -0,0 +1,495 @@
"""Pure K1 connection read model shared by operator and onboard hosts.
Journal and supervisor own state and authority. These functions only join their
existing evidence; they never dispatch commands or schedule recovery.
"""
from __future__ import annotations
from collections.abc import Mapping, Sequence
from typing import Any, Literal
from .connection_supervisor import ConnectionSupervisorSnapshot
ACTION_NETWORK_PROVISION = "network.provision"
ACTION_CONNECTION_VERIFY = "connection.verify"
ACTION_CONNECTION_CONTROL_BOOTSTRAP = "connection.control-bootstrap"
CONTROL_MQTT_PORT = 1883
def compact_connection_attempt(value: object) -> dict[str, Any] | None:
"""Expose the same attempt to Node without journal payloads or diagnostics.
Safe-next-action is already intersected with current authority by the
service. Do not rederive it from historical success in a second host.
"""
if not isinstance(value, Mapping):
return None
if value.get("schema_version") != "missioncore.xgrids-k1-connection-attempt/v1":
return None
keys = (
"schema_version",
"attempt_id",
"connection_mode",
"status",
"phase",
"control_state",
"stage",
"public_error_code",
"side_effect_status",
"safe_next_action",
"accepted_at",
"completed_at",
"recovery_operation_id",
)
result = {
key: item if isinstance(item := value.get(key), str) and len(item) <= 160 else None
for key in keys
}
result["automatic_retry"] = False
return result
def _network_attempt_matches_live_control_authority(
*,
operation_documents: Sequence[Mapping[str, Any]],
attempt_id: object,
snapshot_runtime_id: str,
supervisor: ConnectionSupervisorSnapshot,
active_binding: Mapping[str, Any] | None,
) -> bool:
"""Require historical bootstrap evidence and the exact live supervisor proof."""
if not isinstance(attempt_id, str) or not attempt_id:
return False
network_operation = next(
(
item
for item in reversed(operation_documents)
if item.get("operation_id") == attempt_id
and item.get("action") == ACTION_NETWORK_PROVISION
),
None,
)
if not isinstance(network_operation, Mapping):
return False
context = network_operation.get("context")
result = network_operation.get("result")
if not isinstance(context, Mapping) or not isinstance(result, Mapping):
return False
transport_ref = result.get("transport_ref")
connection_mode = result.get("connection_mode")
target_ipv4 = result.get("target_ipv4")
target_port = result.get("target_port")
expected_live_intent_id = attempt_id
recovery_verify = next(
(
item
for item in reversed(operation_documents)
if item.get("action") == ACTION_CONNECTION_VERIFY
and item.get("status") == "succeeded"
and _recovery_verify_matches_network_attempt(item, network_operation)
),
None,
)
if isinstance(recovery_verify, Mapping):
verify_result = recovery_verify.get("result")
verified_binding = (
verify_result.get("verified_binding") if isinstance(verify_result, Mapping) else None
)
recovered_intent_id = (
verified_binding.get("intent_id") if isinstance(verified_binding, Mapping) else None
)
if not isinstance(recovered_intent_id, str) or not recovered_intent_id:
return False
expected_live_intent_id = recovered_intent_id
return bool(
network_operation.get("status") == "succeeded"
and result.get("phase") == "network_applied"
and context.get("snapshot_runtime_id") == snapshot_runtime_id
and result.get("snapshot_runtime_id") == snapshot_runtime_id
and result.get("parent_intent_id") == attempt_id
and isinstance(transport_ref, str)
and transport_ref
and context.get("transport_ref") == transport_ref
and connection_mode in {"bridge", "quick-connect", "direct-connect"}
and context.get("connection_mode") == connection_mode
and isinstance(target_ipv4, str)
and target_ipv4
and target_port == CONTROL_MQTT_PORT
and isinstance(active_binding, Mapping)
and active_binding.get("intent_id") == expected_live_intent_id
and active_binding.get("transport_ref") == transport_ref
and active_binding.get("connection_mode") == connection_mode
and active_binding.get("target_ipv4") == target_ipv4
and active_binding.get("target_port") == target_port
and supervisor.authority.control_allowed
and supervisor.intent is not None
and supervisor.intent.intent_id == expected_live_intent_id
and supervisor.intent.requested_mode == connection_mode
and supervisor.device_network.state == "applied"
and supervisor.device_network.intent_id == expected_live_intent_id
and supervisor.device_network.transport_ref == transport_ref
and supervisor.device_network.connection_mode == connection_mode
and supervisor.device_network.target is not None
and supervisor.device_network.target.ipv4 == target_ipv4
and supervisor.device_network.target.port == target_port
and supervisor.host_path.available
and supervisor.host_path.epoch >= 1
and supervisor.endpoint.intent_id == expected_live_intent_id
and supervisor.endpoint.target == supervisor.device_network.target
and supervisor.endpoint.tcp_state == "reachable"
and supervisor.endpoint.host_path_epoch == supervisor.host_path.epoch
and supervisor.lease.state == "reachable"
and supervisor.lease.intent_id == expected_live_intent_id
and supervisor.lease.target == supervisor.device_network.target
and supervisor.lease.connection_mode == connection_mode
and supervisor.lease.host_path_epoch == supervisor.host_path.epoch
and supervisor.control_plane.state == "healthy"
and supervisor.control_plane.host_path_epoch == supervisor.host_path.epoch
and active_binding.get("host_path_epoch") == supervisor.host_path.epoch
and isinstance(active_binding.get("control_session_id"), str)
and bool(active_binding.get("control_session_id"))
)
def _owned_control_bootstrap_matches_network_attempt(
bootstrap_operation: Mapping[str, Any],
network_operation: Mapping[str, Any],
) -> bool:
"""Join only the service-owned child admitted for this exact Apply proof."""
bootstrap_context = bootstrap_operation.get("context")
network_context = network_operation.get("context")
network_result = network_operation.get("result")
if not (
isinstance(bootstrap_context, Mapping)
and isinstance(network_context, Mapping)
and isinstance(network_result, Mapping)
):
return False
network_operation_id = network_operation.get("operation_id")
runtime_id = network_result.get("snapshot_runtime_id")
transport_ref = network_result.get("transport_ref")
connection_mode = network_result.get("connection_mode")
target_ipv4 = network_result.get("target_ipv4")
target_port = network_result.get("target_port")
exact_parent = bool(
isinstance(network_operation_id, str)
and network_operation_id
and network_operation.get("status") == "succeeded"
and network_result.get("phase") == "network_applied"
and isinstance(runtime_id, str)
and runtime_id
and network_context.get("snapshot_runtime_id") == runtime_id
and network_context.get("transport_ref") == transport_ref
and network_context.get("connection_mode") == connection_mode
and network_result.get("parent_intent_id") == network_operation_id
and isinstance(transport_ref, str)
and transport_ref
and connection_mode in {"bridge", "quick-connect", "direct-connect"}
and isinstance(target_ipv4, str)
and target_ipv4
and target_port == CONTROL_MQTT_PORT
and bootstrap_context.get("ownership") == "service-owned-apply-continuation"
and bootstrap_context.get("snapshot_runtime_id") == runtime_id
and bootstrap_context.get("parent_operation_id") == network_operation_id
and bootstrap_context.get("parent_intent_id") == network_operation_id
and bootstrap_context.get("transport_ref") == transport_ref
and bootstrap_context.get("connection_mode") == connection_mode
and bootstrap_context.get("target_ipv4") == target_ipv4
and bootstrap_context.get("target_port") == target_port
and isinstance(bootstrap_context.get("device_session_id"), str)
and bool(bootstrap_context.get("device_session_id"))
and bootstrap_context.get("network_mutation_performed") is False
and bootstrap_context.get("ble_operation_performed") is False
and bootstrap_context.get("automatic_retry") is False
)
if not exact_parent:
return False
if bootstrap_operation.get("status") != "succeeded":
return True
bootstrap_result = bootstrap_operation.get("result")
return bool(
isinstance(bootstrap_result, Mapping)
and bootstrap_result.get("connection_mode") == connection_mode
and bootstrap_result.get("control_verified") is True
and bootstrap_result.get("network_mutation_performed") is False
and bootstrap_result.get("ble_operation_performed") is False
and bootstrap_result.get("automatic_retry") is False
)
def _recovery_verify_matches_network_attempt(
verify_operation: Mapping[str, Any],
network_operation: Mapping[str, Any],
) -> bool:
"""Join Verify only to the exact network parent it was admitted against."""
verify_context = verify_operation.get("context")
network_context = network_operation.get("context")
network_result = network_operation.get("result")
if not (
isinstance(verify_context, Mapping)
and isinstance(network_context, Mapping)
and isinstance(network_result, Mapping)
):
return False
network_operation_id = network_operation.get("operation_id")
runtime_id = network_result.get("snapshot_runtime_id")
transport_ref = network_result.get("transport_ref")
connection_mode = network_result.get("connection_mode")
target_ipv4 = network_result.get("target_ipv4")
target_port = network_result.get("target_port")
exact_parent = bool(
isinstance(network_operation_id, str)
and network_operation_id
and isinstance(runtime_id, str)
and runtime_id
and network_context.get("snapshot_runtime_id") == runtime_id
and network_context.get("transport_ref") == transport_ref
and network_context.get("connection_mode") == connection_mode
and network_result.get("parent_intent_id") == network_operation_id
and isinstance(transport_ref, str)
and transport_ref
and connection_mode in {"bridge", "quick-connect", "direct-connect"}
and isinstance(target_ipv4, str)
and target_ipv4
and target_port == CONTROL_MQTT_PORT
and verify_context.get("snapshot_runtime_id") == runtime_id
and verify_context.get("recovery_parent_operation_id") == network_operation_id
and verify_context.get("recovery_parent_intent_id") == network_operation_id
and verify_context.get("recovery_transport_ref") == transport_ref
and verify_context.get("recovery_connection_mode") == connection_mode
and verify_context.get("recovery_target_ipv4") == target_ipv4
and verify_context.get("recovery_target_port") == target_port
)
if not exact_parent:
return False
if verify_operation.get("status") != "succeeded":
return True
verify_result = verify_operation.get("result")
if not isinstance(verify_result, Mapping):
return False
binding = verify_result.get("verified_binding")
return bool(
isinstance(binding, Mapping)
and binding.get("snapshot_runtime_id") == runtime_id
and isinstance(binding.get("intent_id"), str)
and bool(binding.get("intent_id"))
and binding.get("transport_ref") == transport_ref
and binding.get("connection_mode") == connection_mode
and binding.get("target_ipv4") == target_ipv4
and binding.get("target_port") == target_port
and isinstance(binding.get("host_path_epoch"), int)
and not isinstance(binding.get("host_path_epoch"), bool)
and binding.get("host_path_epoch", 0) >= 1
and verify_result.get("write_performed") is False
and verify_result.get("control_verified") is True
)
def _connection_attempt_projection(
operation_documents: Sequence[Mapping[str, Any]],
) -> dict[str, Any] | None:
"""Project the existing operation journal as one K1 connection attempt.
This is deliberately a read model, not another lifecycle authority. The
operation journal, durable network ledger and connection supervisor remain
the writers of their respective facts; the projection only makes the
current stage and a safe next action visible to the operator.
"""
network_operation_index = next(
(
index
for index in range(len(operation_documents) - 1, -1, -1)
if operation_documents[index].get("action") == ACTION_NETWORK_PROVISION
),
None,
)
if network_operation_index is None:
return None
operation = operation_documents[network_operation_index]
network_context = operation.get("context")
network_context_mapping = network_context if isinstance(network_context, Mapping) else {}
control_bootstrap = next(
(
item
for item in reversed(operation_documents)
if item.get("action") == ACTION_CONNECTION_CONTROL_BOOTSTRAP
and _owned_control_bootstrap_matches_network_attempt(item, operation)
),
None,
)
error = operation.get("error")
result = operation.get("result")
error_mapping = error if isinstance(error, Mapping) else {}
result_mapping = result if isinstance(result, Mapping) else {}
context_mapping = network_context_mapping
network_status = str(operation.get("status") or "unknown")
status = network_status
stage = operation.get("stage_code")
control_state: Literal["ready", "control_not_ready", "unknown"] = "unknown"
public_error_code = (
error_mapping.get("code") if isinstance(error_mapping.get("code"), str) else None
)
physical_reconciliation: Mapping[str, Any] | None = None
physical_active_observed = False
if network_status == "succeeded" and control_bootstrap is not None:
bootstrap_status = str(control_bootstrap.get("status") or "unknown")
status = bootstrap_status
stage = control_bootstrap.get("stage_code")
bootstrap_result = control_bootstrap.get("result")
bootstrap_reconciliation = (
bootstrap_result.get("physical_reconciliation")
if isinstance(bootstrap_result, Mapping)
else None
)
if isinstance(bootstrap_reconciliation, Mapping):
physical_reconciliation = bootstrap_reconciliation
physical_active_observed = bool(
bootstrap_reconciliation.get("performed") is True
and bootstrap_reconciliation.get("resolution") == "physical-active-observed"
and bootstrap_reconciliation.get("observed_session_state") == "scanning"
and bootstrap_reconciliation.get("device_write_performed") is False
)
if bootstrap_status == "succeeded":
control_state = "unknown" if physical_active_observed else "ready"
elif bootstrap_status == "failed":
control_state = "control_not_ready"
bootstrap_error = control_bootstrap.get("error")
if isinstance(bootstrap_error, Mapping):
bootstrap_code = bootstrap_error.get("code")
public_error_code = (
bootstrap_code if isinstance(bootstrap_code, str) else public_error_code
)
recovery_verify = next(
(
item
for item in reversed(operation_documents[network_operation_index + 1 :])
if item.get("action") == ACTION_CONNECTION_VERIFY
and _recovery_verify_matches_network_attempt(item, operation)
),
None,
)
# A later explicit read-only Verify is the canonical recovery for an
# applied network edge whose first DeviceInfo bootstrap failed. It must
# update the visible attempt without rewriting or replaying that edge.
if network_status == "succeeded" and recovery_verify is not None:
status = str(recovery_verify.get("status") or "unknown")
stage = recovery_verify.get("stage_code")
verify_result = recovery_verify.get("result")
verify_reconciliation = (
verify_result.get("physical_reconciliation")
if isinstance(verify_result, Mapping)
else None
)
if isinstance(verify_reconciliation, Mapping):
physical_reconciliation = verify_reconciliation
physical_active_observed = bool(
verify_reconciliation.get("performed") is True
and verify_reconciliation.get("resolution") == "physical-active-observed"
and verify_reconciliation.get("observed_session_state") == "scanning"
and verify_reconciliation.get("device_write_performed") is False
)
if status == "succeeded":
control_state = "unknown" if physical_active_observed else "ready"
elif status == "failed":
control_state = "control_not_ready"
else:
control_state = "unknown"
recovery_error = recovery_verify.get("error")
public_error_code = (
recovery_error.get("code")
if isinstance(recovery_error, Mapping) and isinstance(recovery_error.get("code"), str)
else None
)
side_effect_status = error_mapping.get(
"side_effect_status",
result_mapping.get("side_effect_status"),
)
if not isinstance(side_effect_status, str):
side_effect_status = "none" if status in {"accepted", "running"} else "unknown"
phase: Literal[
"network_applied",
"network_not_applied",
"network_outcome_unknown",
]
if network_status == "succeeded":
phase = "network_applied"
elif side_effect_status == "unknown":
# Once a device write may have crossed the GATT boundary, a failed
# host request cannot truthfully claim that the network was not
# applied. This remains an audit/read-model distinction only: it does
# not authorize an automatic retry or replay of the old operation.
phase = "network_outcome_unknown"
else:
phase = "network_not_applied"
owned_bootstrap_pending = bool(
control_bootstrap is not None and status in {"accepted", "running"}
)
recovery_verify_pending = bool(
recovery_verify is not None and status in {"accepted", "running"}
)
if owned_bootstrap_pending or recovery_verify_pending:
safe_next_action = "wait-for-current-attempt"
elif status == "succeeded" and physical_active_observed:
safe_next_action = "stop-acquisition"
elif status == "succeeded" and control_state == "ready":
safe_next_action = "start-acquisition"
elif network_status == "succeeded":
safe_next_action = "verify-control-read-only"
else:
# Historical ambiguity blocks automatic replay of this attempt, not a
# later explicit scan-select-connect session.
safe_next_action = "scan-select-connect"
events = operation.get("events")
return {
"schema_version": "missioncore.xgrids-k1-connection-attempt/v1",
"attempt_id": operation.get("operation_id"),
"connection_mode": context_mapping.get("connection_mode"),
"status": status,
"phase": phase,
"control_state": control_state,
"stage": stage,
"public_error_code": public_error_code,
"side_effect_status": side_effect_status,
"safe_next_action": safe_next_action,
"physical_reconciliation": (
dict(physical_reconciliation) if physical_reconciliation is not None else None
),
"automatic_retry": False,
"accepted_at": operation.get("accepted_at"),
"completed_at": operation.get("completed_at"),
"recovery_operation_id": (
recovery_verify.get("operation_id") if isinstance(recovery_verify, Mapping) else None
),
"timeline": [
*(
[dict(item) for item in events if isinstance(item, Mapping)]
if isinstance(events, list)
else []
),
*(
[
{**dict(item), "source_action": ACTION_CONNECTION_CONTROL_BOOTSTRAP}
for item in control_bootstrap.get("events", [])
if isinstance(item, Mapping)
]
if isinstance(control_bootstrap, Mapping)
and isinstance(control_bootstrap.get("events"), list)
else []
),
*(
[
{**dict(item), "source_action": ACTION_CONNECTION_VERIFY}
for item in recovery_verify.get("events", [])
if isinstance(item, Mapping)
]
if isinstance(recovery_verify, Mapping)
and isinstance(recovery_verify.get("events"), list)
else []
),
],
}
+53 -464
View File
@@ -88,6 +88,7 @@ from k1link.device_plugins.xgrids_k1.ble.runtime_arbiter import (
wait_for_ble_runtime_idle,
)
from k1link.device_plugins.xgrids_k1.ble.scanner import (
BLE_SCAN_DEFAULT_TIMEOUT_SECONDS,
BLE_SCAN_HARD_TIMEOUT_GRACE_SECONDS,
CapturedDiscoveredDevice,
capture_discovered_device,
@@ -122,6 +123,14 @@ from k1link.device_plugins.xgrids_k1.camera import (
build_xgrids_k1_camera_router,
classify_camera_recording_health,
)
from k1link.device_plugins.xgrids_k1.connection_attempt import (
ACTION_CONNECTION_CONTROL_BOOTSTRAP,
ACTION_CONNECTION_VERIFY,
ACTION_NETWORK_PROVISION,
CONTROL_MQTT_PORT,
_connection_attempt_projection,
_network_attempt_matches_live_control_authority,
)
from k1link.device_plugins.xgrids_k1.connection_supervisor import (
ConnectionMonitorProbeSuperseded,
ConnectionSupervisor,
@@ -226,6 +235,7 @@ from k1link.device_plugins.xgrids_k1.viewer.runtime import (
VisualizationRuntime,
new_live_session_dir,
)
from k1link.device_plugins.xgrids_k1.wifi_failure import reviewed_station_failure_code
from k1link.host_network import (
HostWifiAssociationIdentityProbe,
HostWifiAssociationIdentityResult,
@@ -264,7 +274,6 @@ XGRIDS_K1_PLUGIN_VERSION = "0.7.5"
XGRIDS_K1_MODEL_ID = "xgrids.lixelkity-k1"
XGRIDS_K1_COMPATIBILITY_PROFILE_ID = "xgrids.lixelkity-k1.fw-3.0.2.local-network.v2"
DEFAULT_ACQUISITION_CAMERA_SOURCE: CameraSourceId = "sensor.camera.right"
CONTROL_MQTT_PORT = 1883
CONTROL_ENDPOINT_PROBE_TIMEOUT_SECONDS = 1.5
CONTROL_ENDPOINT_ADMISSION_TIMEOUT_SECONDS = 8.0
CONTROL_ENDPOINT_ADMISSION_INTERVAL_SECONDS = 0.5
@@ -425,12 +434,9 @@ ACTION_DISCOVERY_SCAN = "discovery.scan"
ACTION_DEVICE_INSPECT = "device.inspect"
ACTION_SENSOR_CATALOG_READ = "sensor.catalog.read"
ACTION_DEVICE_CALIBRATION_SNAPSHOT_READ = "calibration.device-snapshot.read"
ACTION_NETWORK_PROVISION = "network.provision"
ACTION_CONNECTION_MODE_SELECT = "connection.mode.select"
ACTION_CONNECTION_RECONFIGURE_PREPARE = "connection.reconfigure.prepare"
ACTION_CONNECTION_VERIFY = "connection.verify"
ACTION_CONFIGURED_ENDPOINT_PROBE = "connection.endpoint-probe"
ACTION_CONNECTION_CONTROL_BOOTSTRAP = "connection.control-bootstrap"
ACTION_ACQUISITION_PREPARE = "acquisition.prepare"
ACTION_ACQUISITION_START = "acquisition.start"
ACTION_ACQUISITION_STOP = "acquisition.stop"
@@ -1014,7 +1020,7 @@ class _ConfiguredEndpointHostObservation:
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)
@@ -1476,6 +1482,7 @@ class XgridsK1CompatibilityService:
application_authority_loader: ApplicationAuthorityLoader | None = None,
calibration_snapshot_reader: DeviceCalibrationSnapshotReader | None = None,
host_wifi_association_probe: HostWifiAssociationProbe | None = None,
visualization_bridge_factory: Callable[..., Any] | None = None,
) -> None:
self.repository_root = repository_root.resolve()
# Every CoreBluetooth entrypoint, including read-only calibration and
@@ -1876,6 +1883,7 @@ class XgridsK1CompatibilityService:
physical_command_coordinator=self._physical_command_coordinator,
)
self.runtime = VisualizationRuntime(
bridge_factory=visualization_bridge_factory,
normalizer=normalize_k1_message,
message_observer=self._observe_runtime_message,
published_envelope_observer=self._observe_published_runtime_envelope,
@@ -1886,6 +1894,7 @@ class XgridsK1CompatibilityService:
self.camera_preview = XgridsK1CameraGateway(
self.repository_root,
XGRIDS_K1_PLUGIN_ID,
evidence_root=self.evidence_root,
committed_segment_observer=self._observe_committed_camera_segment,
process_fence_descriptor_factory=(self._duplicate_camera_process_fence_descriptor),
producer_stall_observer=self._observe_camera_producer_stall,
@@ -10190,19 +10199,27 @@ class XgridsK1CompatibilityService:
f"Поиск завершён. Найдено BLE-устройств: {len(devices)}."
)
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(
operation.operation_id,
"succeeded",
stage_code="completed",
message_code="discovery.scan.completed",
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=discovery_result,
)
with self._lock:
if (
@@ -12143,6 +12160,14 @@ class XgridsK1CompatibilityService:
safe_to_retry=not device_write_attempted,
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":
cancelled = isinstance(exc, asyncio.CancelledError)
idempotency_record = idempotency_journal.complete(
@@ -12222,6 +12247,7 @@ class XgridsK1CompatibilityService:
"connection_mode": request.connection_mode,
"error_category": operation_error["category"],
"error_code": operation_error["code"],
"transport_error_code": operation_error.get("transport_error_code"),
"safe_to_retry": operation_error["safe_to_retry"],
"side_effect_status": operation_error["side_effect_status"],
"network_change_attempted": device_write_attempted,
@@ -34801,457 +34827,6 @@ def _network_mutation_ledger_public_snapshot(
}
def _network_attempt_matches_live_control_authority(
*,
operation_documents: Sequence[Mapping[str, Any]],
attempt_id: object,
snapshot_runtime_id: str,
supervisor: ConnectionSupervisorSnapshot,
active_binding: Mapping[str, Any] | None,
) -> bool:
"""Require historical bootstrap evidence and the exact live supervisor proof."""
if not isinstance(attempt_id, str) or not attempt_id:
return False
network_operation = next(
(
item
for item in reversed(operation_documents)
if item.get("operation_id") == attempt_id
and item.get("action") == ACTION_NETWORK_PROVISION
),
None,
)
if not isinstance(network_operation, Mapping):
return False
context = network_operation.get("context")
result = network_operation.get("result")
if not isinstance(context, Mapping) or not isinstance(result, Mapping):
return False
transport_ref = result.get("transport_ref")
connection_mode = result.get("connection_mode")
target_ipv4 = result.get("target_ipv4")
target_port = result.get("target_port")
expected_live_intent_id = attempt_id
recovery_verify = next(
(
item
for item in reversed(operation_documents)
if item.get("action") == ACTION_CONNECTION_VERIFY
and item.get("status") == "succeeded"
and _recovery_verify_matches_network_attempt(item, network_operation)
),
None,
)
if isinstance(recovery_verify, Mapping):
verify_result = recovery_verify.get("result")
verified_binding = (
verify_result.get("verified_binding") if isinstance(verify_result, Mapping) else None
)
recovered_intent_id = (
verified_binding.get("intent_id") if isinstance(verified_binding, Mapping) else None
)
if not isinstance(recovered_intent_id, str) or not recovered_intent_id:
return False
expected_live_intent_id = recovered_intent_id
return bool(
network_operation.get("status") == "succeeded"
and result.get("phase") == "network_applied"
and context.get("snapshot_runtime_id") == snapshot_runtime_id
and result.get("snapshot_runtime_id") == snapshot_runtime_id
and result.get("parent_intent_id") == attempt_id
and isinstance(transport_ref, str)
and transport_ref
and context.get("transport_ref") == transport_ref
and connection_mode in {"bridge", "quick-connect", "direct-connect"}
and context.get("connection_mode") == connection_mode
and isinstance(target_ipv4, str)
and target_ipv4
and target_port == CONTROL_MQTT_PORT
and isinstance(active_binding, Mapping)
and active_binding.get("intent_id") == expected_live_intent_id
and active_binding.get("transport_ref") == transport_ref
and active_binding.get("connection_mode") == connection_mode
and active_binding.get("target_ipv4") == target_ipv4
and active_binding.get("target_port") == target_port
and supervisor.authority.control_allowed
and supervisor.intent is not None
and supervisor.intent.intent_id == expected_live_intent_id
and supervisor.intent.requested_mode == connection_mode
and supervisor.device_network.state == "applied"
and supervisor.device_network.intent_id == expected_live_intent_id
and supervisor.device_network.transport_ref == transport_ref
and supervisor.device_network.connection_mode == connection_mode
and supervisor.device_network.target is not None
and supervisor.device_network.target.ipv4 == target_ipv4
and supervisor.device_network.target.port == target_port
and supervisor.host_path.available
and supervisor.host_path.epoch >= 1
and supervisor.endpoint.intent_id == expected_live_intent_id
and supervisor.endpoint.target == supervisor.device_network.target
and supervisor.endpoint.tcp_state == "reachable"
and supervisor.endpoint.host_path_epoch == supervisor.host_path.epoch
and supervisor.lease.state == "reachable"
and supervisor.lease.intent_id == expected_live_intent_id
and supervisor.lease.target == supervisor.device_network.target
and supervisor.lease.connection_mode == connection_mode
and supervisor.lease.host_path_epoch == supervisor.host_path.epoch
and supervisor.control_plane.state == "healthy"
and supervisor.control_plane.host_path_epoch == supervisor.host_path.epoch
and active_binding.get("host_path_epoch") == supervisor.host_path.epoch
and isinstance(active_binding.get("control_session_id"), str)
and bool(active_binding.get("control_session_id"))
)
def _owned_control_bootstrap_matches_network_attempt(
bootstrap_operation: Mapping[str, Any],
network_operation: Mapping[str, Any],
) -> bool:
"""Join only the service-owned child admitted for this exact Apply proof."""
bootstrap_context = bootstrap_operation.get("context")
network_context = network_operation.get("context")
network_result = network_operation.get("result")
if not (
isinstance(bootstrap_context, Mapping)
and isinstance(network_context, Mapping)
and isinstance(network_result, Mapping)
):
return False
network_operation_id = network_operation.get("operation_id")
runtime_id = network_result.get("snapshot_runtime_id")
transport_ref = network_result.get("transport_ref")
connection_mode = network_result.get("connection_mode")
target_ipv4 = network_result.get("target_ipv4")
target_port = network_result.get("target_port")
exact_parent = bool(
isinstance(network_operation_id, str)
and network_operation_id
and network_operation.get("status") == "succeeded"
and network_result.get("phase") == "network_applied"
and isinstance(runtime_id, str)
and runtime_id
and network_context.get("snapshot_runtime_id") == runtime_id
and network_context.get("transport_ref") == transport_ref
and network_context.get("connection_mode") == connection_mode
and network_result.get("parent_intent_id") == network_operation_id
and isinstance(transport_ref, str)
and transport_ref
and connection_mode in {"bridge", "quick-connect", "direct-connect"}
and isinstance(target_ipv4, str)
and target_ipv4
and target_port == CONTROL_MQTT_PORT
and bootstrap_context.get("ownership") == "service-owned-apply-continuation"
and bootstrap_context.get("snapshot_runtime_id") == runtime_id
and bootstrap_context.get("parent_operation_id") == network_operation_id
and bootstrap_context.get("parent_intent_id") == network_operation_id
and bootstrap_context.get("transport_ref") == transport_ref
and bootstrap_context.get("connection_mode") == connection_mode
and bootstrap_context.get("target_ipv4") == target_ipv4
and bootstrap_context.get("target_port") == target_port
and isinstance(bootstrap_context.get("device_session_id"), str)
and bool(bootstrap_context.get("device_session_id"))
and bootstrap_context.get("network_mutation_performed") is False
and bootstrap_context.get("ble_operation_performed") is False
and bootstrap_context.get("automatic_retry") is False
)
if not exact_parent:
return False
if bootstrap_operation.get("status") != "succeeded":
return True
bootstrap_result = bootstrap_operation.get("result")
return bool(
isinstance(bootstrap_result, Mapping)
and bootstrap_result.get("connection_mode") == connection_mode
and bootstrap_result.get("control_verified") is True
and bootstrap_result.get("network_mutation_performed") is False
and bootstrap_result.get("ble_operation_performed") is False
and bootstrap_result.get("automatic_retry") is False
)
def _recovery_verify_matches_network_attempt(
verify_operation: Mapping[str, Any],
network_operation: Mapping[str, Any],
) -> bool:
"""Join Verify only to the exact network parent it was admitted against."""
verify_context = verify_operation.get("context")
network_context = network_operation.get("context")
network_result = network_operation.get("result")
if not (
isinstance(verify_context, Mapping)
and isinstance(network_context, Mapping)
and isinstance(network_result, Mapping)
):
return False
network_operation_id = network_operation.get("operation_id")
runtime_id = network_result.get("snapshot_runtime_id")
transport_ref = network_result.get("transport_ref")
connection_mode = network_result.get("connection_mode")
target_ipv4 = network_result.get("target_ipv4")
target_port = network_result.get("target_port")
exact_parent = bool(
isinstance(network_operation_id, str)
and network_operation_id
and isinstance(runtime_id, str)
and runtime_id
and network_context.get("snapshot_runtime_id") == runtime_id
and network_context.get("transport_ref") == transport_ref
and network_context.get("connection_mode") == connection_mode
and network_result.get("parent_intent_id") == network_operation_id
and isinstance(transport_ref, str)
and transport_ref
and connection_mode in {"bridge", "quick-connect", "direct-connect"}
and isinstance(target_ipv4, str)
and target_ipv4
and target_port == CONTROL_MQTT_PORT
and verify_context.get("snapshot_runtime_id") == runtime_id
and verify_context.get("recovery_parent_operation_id") == network_operation_id
and verify_context.get("recovery_parent_intent_id") == network_operation_id
and verify_context.get("recovery_transport_ref") == transport_ref
and verify_context.get("recovery_connection_mode") == connection_mode
and verify_context.get("recovery_target_ipv4") == target_ipv4
and verify_context.get("recovery_target_port") == target_port
)
if not exact_parent:
return False
if verify_operation.get("status") != "succeeded":
return True
verify_result = verify_operation.get("result")
if not isinstance(verify_result, Mapping):
return False
binding = verify_result.get("verified_binding")
return bool(
isinstance(binding, Mapping)
and binding.get("snapshot_runtime_id") == runtime_id
and isinstance(binding.get("intent_id"), str)
and bool(binding.get("intent_id"))
and binding.get("transport_ref") == transport_ref
and binding.get("connection_mode") == connection_mode
and binding.get("target_ipv4") == target_ipv4
and binding.get("target_port") == target_port
and isinstance(binding.get("host_path_epoch"), int)
and not isinstance(binding.get("host_path_epoch"), bool)
and binding.get("host_path_epoch", 0) >= 1
and verify_result.get("write_performed") is False
and verify_result.get("control_verified") is True
)
def _connection_attempt_projection(
operation_documents: Sequence[Mapping[str, Any]],
) -> dict[str, Any] | None:
"""Project the existing operation journal as one K1 connection attempt.
This is deliberately a read model, not another lifecycle authority. The
operation journal, durable network ledger and connection supervisor remain
the writers of their respective facts; the projection only makes the
current stage and a safe next action visible to the operator.
"""
network_operation_index = next(
(
index
for index in range(len(operation_documents) - 1, -1, -1)
if operation_documents[index].get("action") == ACTION_NETWORK_PROVISION
),
None,
)
if network_operation_index is None:
return None
operation = operation_documents[network_operation_index]
network_context = operation.get("context")
network_context_mapping = network_context if isinstance(network_context, Mapping) else {}
control_bootstrap = next(
(
item
for item in reversed(operation_documents)
if item.get("action") == ACTION_CONNECTION_CONTROL_BOOTSTRAP
and _owned_control_bootstrap_matches_network_attempt(item, operation)
),
None,
)
error = operation.get("error")
result = operation.get("result")
error_mapping = error if isinstance(error, Mapping) else {}
result_mapping = result if isinstance(result, Mapping) else {}
context_mapping = network_context_mapping
network_status = str(operation.get("status") or "unknown")
status = network_status
stage = operation.get("stage_code")
control_state: Literal["ready", "control_not_ready", "unknown"] = "unknown"
public_error_code = (
error_mapping.get("code") if isinstance(error_mapping.get("code"), str) else None
)
physical_reconciliation: Mapping[str, Any] | None = None
physical_active_observed = False
if network_status == "succeeded" and control_bootstrap is not None:
bootstrap_status = str(control_bootstrap.get("status") or "unknown")
status = bootstrap_status
stage = control_bootstrap.get("stage_code")
bootstrap_result = control_bootstrap.get("result")
bootstrap_reconciliation = (
bootstrap_result.get("physical_reconciliation")
if isinstance(bootstrap_result, Mapping)
else None
)
if isinstance(bootstrap_reconciliation, Mapping):
physical_reconciliation = bootstrap_reconciliation
physical_active_observed = bool(
bootstrap_reconciliation.get("performed") is True
and bootstrap_reconciliation.get("resolution")
== "physical-active-observed"
and bootstrap_reconciliation.get("observed_session_state")
== "scanning"
and bootstrap_reconciliation.get("device_write_performed") is False
)
if bootstrap_status == "succeeded":
control_state = "unknown" if physical_active_observed else "ready"
elif bootstrap_status == "failed":
control_state = "control_not_ready"
bootstrap_error = control_bootstrap.get("error")
if isinstance(bootstrap_error, Mapping):
bootstrap_code = bootstrap_error.get("code")
public_error_code = (
bootstrap_code if isinstance(bootstrap_code, str) else public_error_code
)
recovery_verify = next(
(
item
for item in reversed(operation_documents[network_operation_index + 1 :])
if item.get("action") == ACTION_CONNECTION_VERIFY
and _recovery_verify_matches_network_attempt(item, operation)
),
None,
)
# A later explicit read-only Verify is the canonical recovery for an
# applied network edge whose first DeviceInfo bootstrap failed. It must
# update the visible attempt without rewriting or replaying that edge.
if network_status == "succeeded" and recovery_verify is not None:
status = str(recovery_verify.get("status") or "unknown")
stage = recovery_verify.get("stage_code")
verify_result = recovery_verify.get("result")
verify_reconciliation = (
verify_result.get("physical_reconciliation")
if isinstance(verify_result, Mapping)
else None
)
if isinstance(verify_reconciliation, Mapping):
physical_reconciliation = verify_reconciliation
physical_active_observed = bool(
verify_reconciliation.get("performed") is True
and verify_reconciliation.get("resolution")
== "physical-active-observed"
and verify_reconciliation.get("observed_session_state")
== "scanning"
and verify_reconciliation.get("device_write_performed") is False
)
if status == "succeeded":
control_state = "unknown" if physical_active_observed else "ready"
elif status == "failed":
control_state = "control_not_ready"
else:
control_state = "unknown"
recovery_error = recovery_verify.get("error")
public_error_code = (
recovery_error.get("code")
if isinstance(recovery_error, Mapping) and isinstance(recovery_error.get("code"), str)
else None
)
side_effect_status = error_mapping.get(
"side_effect_status",
result_mapping.get("side_effect_status"),
)
if not isinstance(side_effect_status, str):
side_effect_status = "none" if status in {"accepted", "running"} else "unknown"
phase: Literal[
"network_applied",
"network_not_applied",
"network_outcome_unknown",
]
if network_status == "succeeded":
phase = "network_applied"
elif side_effect_status == "unknown":
# Once a device write may have crossed the GATT boundary, a failed
# host request cannot truthfully claim that the network was not
# applied. This remains an audit/read-model distinction only: it does
# not authorize an automatic retry or replay of the old operation.
phase = "network_outcome_unknown"
else:
phase = "network_not_applied"
owned_bootstrap_pending = bool(
control_bootstrap is not None and status in {"accepted", "running"}
)
recovery_verify_pending = bool(
recovery_verify is not None and status in {"accepted", "running"}
)
if owned_bootstrap_pending or recovery_verify_pending:
safe_next_action = "wait-for-current-attempt"
elif status == "succeeded" and physical_active_observed:
safe_next_action = "stop-acquisition"
elif status == "succeeded" and control_state == "ready":
safe_next_action = "start-acquisition"
elif network_status == "succeeded":
safe_next_action = "verify-control-read-only"
else:
# Historical ambiguity blocks automatic replay of this attempt, not a
# later explicit scan-select-connect session.
safe_next_action = "scan-select-connect"
events = operation.get("events")
return {
"schema_version": "missioncore.xgrids-k1-connection-attempt/v1",
"attempt_id": operation.get("operation_id"),
"connection_mode": context_mapping.get("connection_mode"),
"status": status,
"phase": phase,
"control_state": control_state,
"stage": stage,
"public_error_code": public_error_code,
"side_effect_status": side_effect_status,
"safe_next_action": safe_next_action,
"physical_reconciliation": (
dict(physical_reconciliation)
if physical_reconciliation is not None
else None
),
"automatic_retry": False,
"accepted_at": operation.get("accepted_at"),
"completed_at": operation.get("completed_at"),
"recovery_operation_id": (
recovery_verify.get("operation_id") if isinstance(recovery_verify, Mapping) else None
),
"timeline": [
*(
[dict(item) for item in events if isinstance(item, Mapping)]
if isinstance(events, list)
else []
),
*(
[
{**dict(item), "source_action": ACTION_CONNECTION_CONTROL_BOOTSTRAP}
for item in control_bootstrap.get("events", [])
if isinstance(item, Mapping)
]
if isinstance(control_bootstrap, Mapping)
and isinstance(control_bootstrap.get("events"), list)
else []
),
*(
[
{**dict(item), "source_action": ACTION_CONNECTION_VERIFY}
for item in recovery_verify.get("events", [])
if isinstance(item, Mapping)
]
if isinstance(recovery_verify, Mapping)
and isinstance(recovery_verify.get("events"), list)
else []
),
],
}
def _provisioned_ipv4(result: Mapping[str, Any]) -> str | None:
observations = result.get("observations")
if not isinstance(observations, list):
@@ -35803,6 +35378,14 @@ def _inspect_host_path(target: str) -> HostPathProbeResult:
if separator:
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
destination = fields.get("destination", "")
gateway = fields.get("gateway", "")
@@ -35825,6 +35408,10 @@ def _host_route_class(target: str) -> str:
"""Classify the host route without transmitting packets to the 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":
return "unknown"
try:
@@ -35874,6 +35461,7 @@ def _classify_host_route(
"wireguard",
"gif",
"stf",
"tailscale",
)
direct_lan_prefixes = (
"en",
@@ -35882,6 +35470,7 @@ def _classify_host_route(
"vlan",
"usb",
"p2p",
"wl",
)
if normalized_interface.startswith(tunnel_prefixes):
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,319 @@
"""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 .connection_attempt import compact_connection_attempt
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 NodeEnrollmentRejected(ValueError):
"""A host admission failure proven to precede any device invocation."""
def __init__(self, code: str):
self.code = code
super().__init__(code)
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"],
"snapshot_revision": snapshot.get("snapshot_revision", 0),
"runtime_started_at": snapshot.get("snapshot_runtime_started_at_utc"),
"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,
"connection_attempt": compact_connection_attempt(snapshot.get("connection_attempt")),
"allowed_actions": [
action
for action in lifecycle.get("allowed_actions", [])
if action
in {
"scan-ble",
"provision-fresh-device",
"verify-control-read-only",
"observe-current-device-network",
"observe-configured-device-network",
"observe-fresh-device-network",
}
],
"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:
try:
return await self._execute(command)
finally:
parameters = command.get("parameters")
if isinstance(parameters, dict):
parameters.pop("password", None)
async def deliver(self, command: dict) -> dict:
try:
return await self.execute(command)
except NodeEnrollmentRejected as error:
result = await self.state()
result["command_result"] = {
"operation_id": command["operation_id"],
"status": "rejected",
"error_code": error.code,
}
return result
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 NodeEnrollmentRejected("unsupported-action")
async with self.lock:
if datetime.fromisoformat(command["deadline_at"]) <= datetime.now(UTC):
raise NodeEnrollmentRejected("command-expired")
state = await self.state()
if command.get("runtime_id") != state["runtime_id"]:
raise NodeEnrollmentRejected("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 NodeEnrollmentRejected("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,
)
except Exception:
# A rejected/failed invocation may already have a durable
# result. Read that exact journal row; never resend the
# network command and never serialize exception/payload text.
result = await self.invoke("state.read", {}, identifier + "-observe")
if not any(
item.get("operation_id") == identifier
for item in result.get("operations", [])
):
raise
finally:
payload.pop("password", None)
parameters.pop("password", None)
projected = self.project(result)
operation = next(
(
item
for item in reversed(result.get("operations", []))
if item.get("operation_id") == identifier
),
None,
)
if operation is not None:
error = operation.get("error") or {}
projected["command_result"] = {
"operation_id": identifier,
"status": operation.get("status"),
"error_code": error.get("code"),
}
return projected
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.deliver(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
self.events = FleetEvents()
from .device_enrollment import DeviceEnrollment
self.device_enrollment = DeviceEnrollment()
self.trust = CoreTrust(root)
path = root / "fleet.sqlite3"
if path.is_symlink():
@@ -378,6 +381,7 @@ class FleetRegistry:
return 400, {"error": "Invalid Node inventory"}
sensor_state = sensors.validate_inventory(value, node_id)
sensor_response = sensors.heartbeat(row, value)
enrollment_response = self.device_enrollment.heartbeat(row, value)
host = value.get("host")
if (
not isinstance(host, dict)
@@ -403,4 +407,4 @@ class FleetRegistry:
}
row["binding"]["client_pem"] = self.trust.leaf(node_id, key)
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,
cors_allow_origin: tuple[str, ...] = DEFAULT_CORS_ORIGINS,
recording_factory: Callable[[str], rr.RecordingStream] | None = None,
recording_output: Callable[[rr.RecordingStream], str] | None = None,
) -> None:
self.metrics = metrics or BridgeMetrics()
self._settings_provider = settings_provider or RerunSceneSettings
@@ -130,31 +131,29 @@ class RerunBridge:
else:
recording = recording_factory("nodedc_mission_core_spatial")
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)
url = recording.serve_grpc(
grpc_port=selected_grpc_port,
default_blueprint=blueprint,
# This is a reconnect cushion for the live preview, not the source
# of record. Raw MQTT evidence is persisted independently. A large
# late-client backlog can block the native SDK and freeze preview.
server_memory_limit=LIVE_GRPC_BUFFER_LIMIT,
# Rerun transport can replay ActivateStore before StoreInfo when an
# evicted buffer is served newest-first, leaving late viewers on the
# welcome screen. Preserve protocol order within the bounded cache.
newest_first=False,
cors_allow_origin=list(cors_allow_origin),
)
if recording_output is not None:
# Node's paired WebRTC delivery uses an RRD sink and never opens
# an unauthenticated gRPC listener on an onboard interface.
url = recording_output(recording)
else:
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},
)
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(
blueprint,
make_active=True,

Some files were not shown because too many files have changed in this diff Show More