feat(k1): stabilize LAB bridge and isolate onboard device integration
This commit is contained in:
@@ -762,7 +762,15 @@ export async function probeRecordedPerceptionViewerSource(
|
|||||||
return { sourceUrl: endpoint.href, byteLength: declaredLength };
|
return { sourceUrl: endpoint.href, byteLength: declaredLength };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function RerunViewport({
|
export function RerunViewport(props: RerunViewportProps) {
|
||||||
|
// Even when source URLs coincide, another profile cannot inherit a mounted
|
||||||
|
// viewer's playback, blueprint channels, recovery state or scene draft.
|
||||||
|
const identity = props.profile.kind === "laboratory-result"
|
||||||
|
? `${props.profile.kind}:${props.profile.resultId}` : props.profile.kind;
|
||||||
|
return <RerunViewportInstance key={identity} {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function RerunViewportInstance({
|
||||||
profile,
|
profile,
|
||||||
onStatusChange,
|
onStatusChange,
|
||||||
onSelectionChange,
|
onSelectionChange,
|
||||||
@@ -772,7 +780,7 @@ export function RerunViewport({
|
|||||||
onPerceptionLoadChange,
|
onPerceptionLoadChange,
|
||||||
onPointColorLoadChange,
|
onPointColorLoadChange,
|
||||||
}: RerunViewportProps) {
|
}: RerunViewportProps) {
|
||||||
const recordedProfile = profile.kind === "recorded-session" ? profile : null;
|
const recordedProfile = profile.kind === "recorded-session" || profile.kind === "laboratory-result" ? profile : null;
|
||||||
const liveProfile = profile.kind === "live-acquisition" ? profile : null;
|
const liveProfile = profile.kind === "live-acquisition" ? profile : null;
|
||||||
const sourceUrl = profile.sourceUrl;
|
const sourceUrl = profile.sourceUrl;
|
||||||
const recordedArtifact = recordedProfile?.artifact ?? null;
|
const recordedArtifact = recordedProfile?.artifact ?? null;
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ import {
|
|||||||
} from "../laboratory/CanonicalRecordedLabReplay";
|
} from "../laboratory/CanonicalRecordedLabReplay";
|
||||||
import type { CanonicalLabReplayDescriptor } from "../../core/laboratory/canonicalLabReplay";
|
import type { CanonicalLabReplayDescriptor } from "../../core/laboratory/canonicalLabReplay";
|
||||||
import type { ObservationSessionReplayLaunch } from "../../core/observation/sessionArchive";
|
import type { ObservationSessionReplayLaunch } from "../../core/observation/sessionArchive";
|
||||||
import { recordedSessionRerunProfile } from "../../core/observation/viewerProfile";
|
import { laboratoryResultRerunProfile } from "../../core/observation/viewerProfile";
|
||||||
import { resolveObservationSessionReplay } from "../../core/observation/useObservationSessions";
|
import { resolveObservationSessionReplay } from "../../core/observation/useObservationSessions";
|
||||||
import type { AIViewerLayer } from "../../core/observatory/aiComposition";
|
import type { AIViewerLayer } from "../../core/observatory/aiComposition";
|
||||||
import {
|
import {
|
||||||
@@ -305,7 +305,8 @@ export function CanonicalResultRerunReplay({
|
|||||||
accumulationSeconds: showLocalSlam ? sceneDraft.accumulationSeconds : 0,
|
accumulationSeconds: showLocalSlam ? sceneDraft.accumulationSeconds : 0,
|
||||||
}), [sceneDraft, showLocalSlam, showSourcePoints, spatialMode]);
|
}), [sceneDraft, showLocalSlam, showSourcePoints, spatialMode]);
|
||||||
const semanticVisible = showDDRNet || showEoMT;
|
const semanticVisible = showDDRNet || showEoMT;
|
||||||
const profile = launch ? recordedSessionRerunProfile({
|
const profile = launch ? laboratoryResultRerunProfile({
|
||||||
|
resultId,
|
||||||
sourceUrl: launch.replay.sourceUrl,
|
sourceUrl: launch.replay.sourceUrl,
|
||||||
artifact: {
|
artifact: {
|
||||||
sourceUrl: launch.replay.sourceUrl,
|
sourceUrl: launch.replay.sourceUrl,
|
||||||
|
|||||||
@@ -103,6 +103,10 @@ export interface DevicePluginConnectionProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface DeviceUiPlugin {
|
export interface DeviceUiPlugin {
|
||||||
|
sensorUi?: {
|
||||||
|
contributions: readonly import('../../../../../packages/sensor-ui/src/extensions').SensorUiContribution[];
|
||||||
|
Enrollment?: ComponentType<import('../../../../../packages/sensor-ui/src/extensions').SensorEnrollmentProps>;
|
||||||
|
};
|
||||||
manifest: DevicePluginManifest;
|
manifest: DevicePluginManifest;
|
||||||
RuntimeProvider: ComponentType<{
|
RuntimeProvider: ComponentType<{
|
||||||
activeModel: DeviceModelDefinition | null;
|
activeModel: DeviceModelDefinition | null;
|
||||||
|
|||||||
@@ -113,13 +113,13 @@ const defaultQuickActions: Record<
|
|||||||
EnvironmentSurfaceId,
|
EnvironmentSurfaceId,
|
||||||
readonly [string | null, string | null]
|
readonly [string | null, string | null]
|
||||||
> = {
|
> = {
|
||||||
home: ["spatial-scene", "local-device"],
|
home: ["spatial-scene", "vehicles"],
|
||||||
fleet: ["contour-health", "local-device"],
|
fleet: ["vehicles", "contour-health"],
|
||||||
observation: ["spatial-scene", "cameras"],
|
observation: ["cameras", "world-map"],
|
||||||
missions: ["mission-planner", null],
|
missions: ["mission-planner", null],
|
||||||
data: ["recordings", "datasets"],
|
data: ["recordings", "datasets"],
|
||||||
system: ["modules", "integrations"],
|
system: ["modules", "integrations"],
|
||||||
polygon: ["lab-archive", null],
|
polygon: ["lab-archive", "local-device"],
|
||||||
};
|
};
|
||||||
|
|
||||||
export function defaultEnvironmentSettings(): EnvironmentSettings {
|
export function defaultEnvironmentSettings(): EnvironmentSettings {
|
||||||
|
|||||||
@@ -85,6 +85,13 @@ export interface RecordedSessionRerunProfile {
|
|||||||
lockPerceptionCameraInteraction: boolean;
|
lockPerceptionCameraInteraction: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A LAB owns its result presentation; it does not inherit session-view settings. */
|
||||||
|
export interface LaboratoryResultRerunProfile
|
||||||
|
extends Omit<RecordedSessionRerunProfile, "kind"> {
|
||||||
|
kind: "laboratory-result";
|
||||||
|
resultId: string;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Deprecated comparison-only contract for LAB artifacts that have not yet
|
* Deprecated comparison-only contract for LAB artifacts that have not yet
|
||||||
* been republished as a native Rerun sidecar. It must never be selected by a
|
* been republished as a native Rerun sidecar. It must never be selected by a
|
||||||
@@ -101,7 +108,8 @@ export interface LaboratoryRecordedEvidenceViewerProfile {
|
|||||||
|
|
||||||
export type RerunViewerProfile =
|
export type RerunViewerProfile =
|
||||||
| LiveAcquisitionRerunProfile
|
| LiveAcquisitionRerunProfile
|
||||||
| RecordedSessionRerunProfile;
|
| RecordedSessionRerunProfile
|
||||||
|
| LaboratoryResultRerunProfile;
|
||||||
|
|
||||||
export type ObservationViewerProfile =
|
export type ObservationViewerProfile =
|
||||||
| RerunViewerProfile
|
| RerunViewerProfile
|
||||||
@@ -119,11 +127,18 @@ export const LABORATORY_RECORDED_EVIDENCE_VIEWER_PROFILE = Object.freeze({
|
|||||||
export function liveAcquisitionRerunProfile(
|
export function liveAcquisitionRerunProfile(
|
||||||
input: Omit<LiveAcquisitionRerunProfile, "kind" | "clock">,
|
input: Omit<LiveAcquisitionRerunProfile, "kind" | "clock">,
|
||||||
): LiveAcquisitionRerunProfile {
|
): LiveAcquisitionRerunProfile {
|
||||||
return { kind: "live-acquisition", clock: "stream_time", ...input };
|
return { ...input, kind: "live-acquisition", clock: "stream_time" };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function recordedSessionRerunProfile(
|
export function recordedSessionRerunProfile(
|
||||||
input: Omit<RecordedSessionRerunProfile, "kind" | "clock">,
|
input: Omit<RecordedSessionRerunProfile, "kind" | "clock">,
|
||||||
): RecordedSessionRerunProfile {
|
): RecordedSessionRerunProfile {
|
||||||
return { kind: "recorded-session", clock: "session_time", ...input };
|
return { ...input, kind: "recorded-session", clock: "session_time" };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function laboratoryResultRerunProfile(
|
||||||
|
input: Omit<LaboratoryResultRerunProfile, "kind" | "clock">,
|
||||||
|
): LaboratoryResultRerunProfile {
|
||||||
|
if (!input.resultId.trim()) throw new Error("LAB result identity is required");
|
||||||
|
return { ...input, kind: "laboratory-result", clock: "session_time" };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -94,7 +94,7 @@ export const roots: RootDefinition[] = [
|
|||||||
label: "Парк",
|
label: "Парк",
|
||||||
title: "Аппараты и устройства",
|
title: "Аппараты и устройства",
|
||||||
eyebrow: "ПАРК / УСТРОЙСТВА",
|
eyebrow: "ПАРК / УСТРОЙСТВА",
|
||||||
description: "Реестр аппаратов, локальное подключение, сенсоры и конфигурации борта.",
|
description: "Реестр аппаратов, бортовые компьютеры, сенсоры и конфигурации борта.",
|
||||||
statement: "Одинаково подключать одиночный стенд, наземную платформу и будущий рой.",
|
statement: "Одинаково подключать одиночный стенд, наземную платформу и будущий рой.",
|
||||||
accent: "АППАРАТЫ И ПОЛЕЗНАЯ НАГРУЗКА",
|
accent: "АППАРАТЫ И ПОЛЕЗНАЯ НАГРУЗКА",
|
||||||
},
|
},
|
||||||
@@ -203,11 +203,11 @@ export const workspaces: WorkspaceDefinition[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "local-device",
|
id: "local-device",
|
||||||
root: "fleet",
|
root: "polygon",
|
||||||
label: "Подключение",
|
label: "Тестовые устройства",
|
||||||
title: "Подключение",
|
title: "Тестовые устройства",
|
||||||
eyebrow: "ПАРК / ТЕКУЩИЙ АДАПТЕР",
|
eyebrow: "LAB / ТЕСТОВЫЕ УСТРОЙСТВА",
|
||||||
description: "Выбор модели, сценарий установленного плагина и запуск доступного потока.",
|
description: "Подключение устройств к компьютеру оператора для испытаний и записи.",
|
||||||
icon: "network",
|
icon: "network",
|
||||||
kind: "device",
|
kind: "device",
|
||||||
groups: [],
|
groups: [],
|
||||||
@@ -258,11 +258,11 @@ export const workspaces: WorkspaceDefinition[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "spatial-scene",
|
id: "spatial-scene",
|
||||||
root: "observation",
|
root: "polygon",
|
||||||
label: "Пространственная сцена",
|
label: "Пространственная сцена",
|
||||||
title: "Пространственная сцена",
|
title: "Пространственная сцена",
|
||||||
eyebrow: "НАБЛЮДЕНИЕ / ЭФИР",
|
eyebrow: "LAB / ПРОСТРАНСТВЕННАЯ СЦЕНА",
|
||||||
description: "Облако точек, траектория, преобразования и пространственные слои в единой 3D-сцене.",
|
description: "Облако точек, камеры и траектория тестового устройства в реальном времени.",
|
||||||
icon: "globe",
|
icon: "globe",
|
||||||
kind: "spatial",
|
kind: "spatial",
|
||||||
groups: [
|
groups: [
|
||||||
|
|||||||
@@ -1,13 +1,24 @@
|
|||||||
import {useMemo} from 'react';
|
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 {SensorWorkspace} from '../../../../../packages/sensor-ui/src/SensorWorkspace';
|
||||||
import type {SensorInventory,SensorTransport} from '../../../../../packages/sensor-ui/src/contracts';
|
import type {SensorInventory,SensorTransport} from '../../../../../packages/sensor-ui/src/contracts';
|
||||||
import {fleetRequest} from '../../core/fleet/useFleet';
|
import {fleetRequest} from '../../core/fleet/useFleet';
|
||||||
export function VehicleSensors({vehicleID,enabled,onDetailChange}:{vehicleID:string;enabled:boolean;onDetailChange:(open:boolean)=>void}){
|
export function VehicleSensors({vehicleID,enabled,onDetailChange}:{vehicleID:string;enabled:boolean;onDetailChange:(open:boolean)=>void}){
|
||||||
|
const {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>(()=>({
|
const transport=useMemo<SensorTransport>(()=>({
|
||||||
|
enrollment:{
|
||||||
|
state:()=>fleetRequest(`/${encodeURIComponent(vehicleID)}/devices/enrollment`),
|
||||||
|
submit:value=>fleetRequest(`/${encodeURIComponent(vehicleID)}/devices/enrollment/operations`,'POST',value),
|
||||||
|
operation:id=>fleetRequest(`/${encodeURIComponent(vehicleID)}/devices/enrollment/operations/${encodeURIComponent(id)}`),
|
||||||
|
},
|
||||||
inventory:async()=>{const fleet=await fleetRequest<{items:{id:string;connectivity:string;sensor_state:SensorInventory}[]}>();const value=fleet.items.find(v=>v.id===vehicleID);if(!value)throw new Error('Аппарат не найден.');return {...value.sensor_state,fresh:value.connectivity==='online'};},
|
inventory:async()=>{const fleet=await fleetRequest<{items:{id:string;connectivity:string;sensor_state:SensorInventory}[]}>();const value=fleet.items.find(v=>v.id===vehicleID);if(!value)throw new Error('Аппарат не найден.');return {...value.sensor_state,fresh:value.connectivity==='online'};},
|
||||||
subscribe:(receive,unavailable)=>{const events=new EventSource('/api/v1/fleet/events');events.onmessage=e=>{try{const value=JSON.parse(e.data).items.find((v:{id:string})=>v.id===vehicleID);if(value)receive({...value.sensor_state,fresh:value.connectivity==='online'});else unavailable();}catch{unavailable();}};events.onerror=unavailable;return()=>events.close();},
|
subscribe:(receive,unavailable)=>{const events=new EventSource('/api/v1/fleet/events');events.onmessage=e=>{try{const value=JSON.parse(e.data).items.find((v:{id:string})=>v.id===vehicleID);if(value)receive({...value.sensor_state,fresh:value.connectivity==='online'});else unavailable();}catch{unavailable();}};events.onerror=unavailable;return()=>events.close();},
|
||||||
submit:value=>fleetRequest(`/${encodeURIComponent(vehicleID)}/devices/operations`,'POST',value),
|
submit:value=>fleetRequest(`/${encodeURIComponent(vehicleID)}/devices/operations`,'POST',value),
|
||||||
operation:id=>fleetRequest(`/${encodeURIComponent(vehicleID)}/devices/operations/${encodeURIComponent(id)}`),
|
operation:id=>fleetRequest(`/${encodeURIComponent(vehicleID)}/devices/operations/${encodeURIComponent(id)}`),
|
||||||
}),[vehicleID]);
|
}),[vehicleID]);
|
||||||
return <SensorWorkspace key={vehicleID} transport={transport} enabled={enabled} onDetailChange={onDetailChange}/>;
|
return <SensorWorkspace 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/,
|
/advanced-index|resolveObservationSessionReplay|resolveCanonicalLabReplay|RerunViewport/,
|
||||||
);
|
);
|
||||||
assert.equal(sharedReplay.match(/<RerunViewport\b/g)?.length, 1);
|
assert.equal(sharedReplay.match(/<RerunViewport\b/g)?.length, 1);
|
||||||
assert.match(sharedReplay, /recordedSessionRerunProfile/);
|
assert.match(sharedReplay, /laboratoryResultRerunProfile/);
|
||||||
assert.match(sharedReplay, /useState<0 \| 1>\(hasTgs \? 1 : 0\)/);
|
assert.match(sharedReplay, /useState<0 \| 1>\(hasTgs \? 1 : 0\)/);
|
||||||
assert.match(sharedReplay, /hasTgs \? 0\.000001 : 0/);
|
assert.match(sharedReplay, /hasTgs \? 0\.000001 : 0/);
|
||||||
for (const adapter of ["CanonicalVegetationRerunReplay", "PortableResultReplay"]) {
|
for (const adapter of ["CanonicalVegetationRerunReplay", "PortableResultReplay"]) {
|
||||||
|
|||||||
@@ -1676,7 +1676,7 @@ test("physical-command guidance projects backend policy without exposing reason
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("restart-recovery guidance describes automatic recovery without protocol ceremony", () => {
|
test("restart-recovery guidance offers an explicit read-only recovery", () => {
|
||||||
const deniedFresh = {
|
const deniedFresh = {
|
||||||
connection_policy: {
|
connection_policy: {
|
||||||
schema_version: "missioncore.xgrids-k1-connection-policy/v1",
|
schema_version: "missioncore.xgrids-k1-connection-policy/v1",
|
||||||
@@ -1712,7 +1712,7 @@ test("restart-recovery guidance describes automatic recovery without protocol ce
|
|||||||
);
|
);
|
||||||
assert.deepEqual(durableGuidance, {
|
assert.deepEqual(durableGuidance, {
|
||||||
reason: "В свежем Bluetooth-поиске не найден K1, связанный с незавершённой записью.",
|
reason: "В свежем Bluetooth-поиске не найден K1, связанный с незавершённой записью.",
|
||||||
nextAction: "Дождитесь автоматического восстановления сохранённого подключения K1.",
|
nextAction: "Нажмите «Переподключиться», чтобы проверить сохранённое подключение K1.",
|
||||||
});
|
});
|
||||||
assert.doesNotMatch(
|
assert.doesNotMatch(
|
||||||
`${durableGuidance.reason} ${durableGuidance.nextAction}`,
|
`${durableGuidance.reason} ${durableGuidance.nextAction}`,
|
||||||
@@ -1725,7 +1725,7 @@ test("restart-recovery guidance describes automatic recovery without protocol ce
|
|||||||
deniedFresh,
|
deniedFresh,
|
||||||
"observe-fresh-device-network",
|
"observe-fresh-device-network",
|
||||||
).nextAction,
|
).nextAction,
|
||||||
"Дождитесь автоматического восстановления связи с тем же K1.",
|
"Нажмите «Переподключиться», чтобы проверить связь с тем же K1.",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -3461,7 +3461,7 @@ test("each terminal explicit provisioning click starts a fresh operation identit
|
|||||||
|
|
||||||
assert.match(
|
assert.match(
|
||||||
connectRecovery,
|
connectRecovery,
|
||||||
/failedOperation\?\.status === "succeeded"[\s\S]*?!exactAppliedNetworkIntentCompleted\([\s\S]*?&& !hasExactConnectionReady/,
|
/observeProvisioningRequest\([\s\S]*?observedState = observation\.state[\s\S]*?const exactNetworkIntentCompleted = exactAppliedNetworkIntentCompleted\(/,
|
||||||
);
|
);
|
||||||
assert.match(
|
assert.match(
|
||||||
connectRecovery,
|
connectRecovery,
|
||||||
@@ -4009,7 +4009,7 @@ test("failed network-profile writes close the UI session with a fresh explicit n
|
|||||||
|
|
||||||
assert.equal(
|
assert.equal(
|
||||||
message,
|
message,
|
||||||
"Bluetooth-периферия завершила сетевую операцию ошибкой. Команда могла быть принята K1; итог текущей попытки не подтверждён. Автоматический повтор команды K1 не отправлялся. Сессия подключения в интерфейсе сброшена. Выполните новый поиск Bluetooth, выберите K1 и запустите подключение ещё раз.",
|
"Bluetooth-периферия завершила сетевую операцию ошибкой. Команда могла быть принята K1; итог текущей попытки не подтверждён. Автоматический повтор команды K1 не отправлялся. Проверьте состояние K1 через «Переподключиться» или начните новое подключение через поиск Bluetooth.",
|
||||||
);
|
);
|
||||||
assert.doesNotMatch(message, /Bleak|GATT|ATT/i);
|
assert.doesNotMatch(message, /Bleak|GATT|ATT/i);
|
||||||
|
|
||||||
@@ -4024,8 +4024,8 @@ test("failed network-profile writes close the UI session with a fresh explicit n
|
|||||||
});
|
});
|
||||||
assert.match(attMessage, /ATT 4 INVALID_PDU/);
|
assert.match(attMessage, /ATT 4 INVALID_PDU/);
|
||||||
assert.match(attMessage, /Автоматический повтор команды K1 не отправлялся/);
|
assert.match(attMessage, /Автоматический повтор команды K1 не отправлялся/);
|
||||||
assert.match(attMessage, /Сессия подключения в интерфейсе сброшена/);
|
assert.match(attMessage, /Проверьте состояние K1 через «Переподключиться»/);
|
||||||
assert.match(attMessage, /новый поиск Bluetooth, выберите K1/);
|
assert.match(attMessage, /новое подключение через поиск Bluetooth/);
|
||||||
|
|
||||||
const preWriteAttMessage = networkProvisionFailureMessage({
|
const preWriteAttMessage = networkProvisionFailureMessage({
|
||||||
action: "network.provision",
|
action: "network.provision",
|
||||||
@@ -4059,7 +4059,7 @@ test("post-dispatch ambiguity resets the UI session without an automatic retry",
|
|||||||
|
|
||||||
assert.equal(
|
assert.equal(
|
||||||
message,
|
message,
|
||||||
"После BLE-команды K1 вернул сетевой статус, неотличимый от исходного; итог текущей попытки подключения не подтверждён. Автоматический повтор команды K1 не отправлялся. Сессия подключения в интерфейсе сброшена. Выполните новый поиск Bluetooth, выберите K1 и запустите подключение ещё раз.",
|
"После BLE-команды K1 вернул сетевой статус, неотличимый от исходного; итог текущей попытки подключения не подтверждён. Автоматический повтор команды K1 не отправлялся. Проверьте состояние K1 через «Переподключиться» или начните новое подключение через поиск Bluetooth.",
|
||||||
);
|
);
|
||||||
assert.doesNotMatch(message, /ручн|read-only|защитный барьер/i);
|
assert.doesNotMatch(message, /ручн|read-only|защитный барьер/i);
|
||||||
});
|
});
|
||||||
@@ -4247,7 +4247,7 @@ test("host Wi-Fi failures close the attempt and require a fresh explicit connect
|
|||||||
|
|
||||||
assert.equal(
|
assert.equal(
|
||||||
operationTimeout,
|
operationTimeout,
|
||||||
"Локальная операция подготовки Wi‑Fi не завершилась вовремя; итог текущей попытки подключения не подтверждён. Автоматический повтор команды K1 не отправлялся. Сессия подключения в интерфейсе сброшена. Выполните новый поиск Bluetooth, выберите K1 и запустите подключение ещё раз.",
|
"Локальная операция подготовки Wi‑Fi не завершилась вовремя; итог текущей попытки подключения не подтверждён. Автоматический повтор команды K1 не отправлялся. Проверьте состояние K1 через «Переподключиться» или начните новое подключение через поиск Bluetooth.",
|
||||||
);
|
);
|
||||||
assert.doesNotMatch(operationTimeout, /получите пароль|пароль отсутствует/i);
|
assert.doesNotMatch(operationTimeout, /получите пароль|пароль отсутствует/i);
|
||||||
assert.match(missingNetwork, /K1 принял команду Quick Connect/);
|
assert.match(missingNetwork, /K1 принял команду Quick Connect/);
|
||||||
|
|||||||
@@ -218,8 +218,8 @@ test("K1 connection surface enforces one scan, local selection, and one Apply",
|
|||||||
);
|
);
|
||||||
assert.match(provisioning, /scanSecondsRemaining/);
|
assert.match(provisioning, /scanSecondsRemaining/);
|
||||||
assert.match(provisioning, /setInterval\(updateCountdown, 250\)/);
|
assert.match(provisioning, /setInterval\(updateCountdown, 250\)/);
|
||||||
assert.match(provisioning, /Поиск Bluetooth · \{scanSecondsRemaining \?\? 6\} с/);
|
assert.match(provisioning, /Поиск Bluetooth · до \{scanSecondsRemaining \?\? BLE_DISCOVERY_TIMEOUT_SECONDS\} с/);
|
||||||
assert.match(provisioning, /scanWithResult\(\{[^}]*durationSeconds:\s*6/);
|
assert.match(provisioning, /scanWithResult\(\{[^}]*durationSeconds:\s*BLE_DISCOVERY_TIMEOUT_SECONDS/);
|
||||||
assert.doesNotMatch(provisioning, /K1 уже доступен|Сетевой адрес K1 доступен/);
|
assert.doesNotMatch(provisioning, /K1 уже доступен|Сетевой адрес K1 доступен/);
|
||||||
assert.doesNotMatch(
|
assert.doesNotMatch(
|
||||||
provisioning,
|
provisioning,
|
||||||
@@ -284,7 +284,7 @@ test("K1 click-owned actions are fenced without hidden frontend continuations",
|
|||||||
provisioning.indexOf("const verifyAppliedNetwork"),
|
provisioning.indexOf("const verifyAppliedNetwork"),
|
||||||
);
|
);
|
||||||
assert.equal((search.match(/scanWithResult\(/g) ?? []).length, 1);
|
assert.equal((search.match(/scanWithResult\(/g) ?? []).length, 1);
|
||||||
assert.match(search, /durationSeconds:\s*6/);
|
assert.match(search, /durationSeconds:\s*BLE_DISCOVERY_TIMEOUT_SECONDS/);
|
||||||
assert.equal((apply.match(/await connect\(/g) ?? []).length, 1);
|
assert.equal((apply.match(/await connect\(/g) ?? []).length, 1);
|
||||||
assert.doesNotMatch(
|
assert.doesNotMatch(
|
||||||
apply,
|
apply,
|
||||||
@@ -680,7 +680,7 @@ test("automatic K1 live start opens the selected delivered camera despite an old
|
|||||||
assert.match(layout, /source\.capabilities\.overlay/);
|
assert.match(layout, /source\.capabilities\.overlay/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("K1 connect errors reset the UI session without exposing reconciliation ceremony", () => {
|
test("K1 connect errors preserve the result and expose explicit recovery", () => {
|
||||||
const runtime = readFileSync(
|
const runtime = readFileSync(
|
||||||
join(pluginFrontendRoot, "useXgridsK1Runtime.ts"),
|
join(pluginFrontendRoot, "useXgridsK1Runtime.ts"),
|
||||||
"utf8",
|
"utf8",
|
||||||
@@ -691,15 +691,17 @@ test("K1 connect errors reset the UI session without exposing reconciliation cer
|
|||||||
assert.notEqual(connectEnd, -1);
|
assert.notEqual(connectEnd, -1);
|
||||||
const connectFlow = runtime.slice(connectStart, connectEnd);
|
const connectFlow = runtime.slice(connectStart, connectEnd);
|
||||||
|
|
||||||
assert.match(connectFlow, /operationByIdempotencyKey\(/);
|
assert.match(connectFlow, /observeProvisioningRequest\(/);
|
||||||
assert.match(connectFlow, /failedOperation\?\.status === "succeeded"/);
|
assert.match(connectFlow, /operation\?\.status !== "succeeded"/);
|
||||||
assert.match(connectFlow, /resetConnectSessionMessage\(/);
|
assert.match(connectFlow, /networkProvisionFailureMessage\(/);
|
||||||
assert.doesNotMatch(
|
assert.doesNotMatch(
|
||||||
connectFlow,
|
connectFlow,
|
||||||
/требует ручной проверки|измените параметры только после проверки устройства|Сохранён тот же ключ/,
|
/требует ручной проверки|измените параметры только после проверки устройства|Сохранён тот же ключ/,
|
||||||
);
|
);
|
||||||
assert.match(runtime, /Сессия подключения в интерфейсе сброшена/);
|
const networkFlow = readFileSync(join(pluginFrontendRoot, "networkProvisioning.ts"), "utf8");
|
||||||
assert.match(runtime, /новый поиск Bluetooth, выберите K1 и запустите подключение ещё раз/);
|
assert.match(networkFlow, /Проверьте состояние K1 через «Переподключиться»/);
|
||||||
|
assert.match(networkFlow, /новое подключение через поиск Bluetooth/);
|
||||||
|
assert.equal((networkFlow.match(/await context\.send\(/g) ?? []).length, 1);
|
||||||
const verifyStart = runtime.indexOf("const verifyConnection = useCallback(");
|
const verifyStart = runtime.indexOf("const verifyConnection = useCallback(");
|
||||||
const verifyEnd = runtime.indexOf("const probeConfiguredEndpoint = useCallback(", verifyStart);
|
const verifyEnd = runtime.indexOf("const probeConfiguredEndpoint = useCallback(", verifyStart);
|
||||||
const verifyFlow = runtime.slice(verifyStart, verifyEnd);
|
const verifyFlow = runtime.slice(verifyStart, verifyEnd);
|
||||||
@@ -729,7 +731,7 @@ test("K1 provisioning keeps the operator draft separate from the backend lease",
|
|||||||
assert.match(selection, /requestExplicitProvisioning\(device\.device_id/);
|
assert.match(selection, /requestExplicitProvisioning\(device\.device_id/);
|
||||||
assert.equal((apply.match(/await connect\(/g) ?? []).length, 1);
|
assert.equal((apply.match(/await connect\(/g) ?? []).length, 1);
|
||||||
assert.doesNotMatch(apply, /scanWithResult\(|verifyConnection\(|candidateRefresh/);
|
assert.doesNotMatch(apply, /scanWithResult\(|verifyConnection\(|candidateRefresh/);
|
||||||
assert.match(provisioning, /scanWithResult\(\{ durationSeconds: 6 \}\)/);
|
assert.match(provisioning, /scanWithResult\(\{ durationSeconds: BLE_DISCOVERY_TIMEOUT_SECONDS \}\)/);
|
||||||
assert.match(
|
assert.match(
|
||||||
provisioning,
|
provisioning,
|
||||||
/onChange=\{\(event\) => setPassword\(event\.target\.value\)\}/,
|
/onChange=\{\(event\) => setPassword\(event\.target\.value\)\}/,
|
||||||
|
|||||||
@@ -65,8 +65,8 @@ test("default environment exposes every current product page", () => {
|
|||||||
"system",
|
"system",
|
||||||
"polygon",
|
"polygon",
|
||||||
]);
|
]);
|
||||||
assert.equal(defaults.pages.fleet.primaryWorkspaceId, "contour-health");
|
assert.equal(defaults.pages.fleet.primaryWorkspaceId, "vehicles");
|
||||||
assert.equal(defaults.pages.observation.primaryWorkspaceId, "spatial-scene");
|
assert.equal(defaults.pages.observation.primaryWorkspaceId, "cameras");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("server document decodes and re-encodes the complete page contract", () => {
|
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",
|
"const chooseAnother",
|
||||||
);
|
);
|
||||||
|
|
||||||
assert.match(search, /scanWithResult\(\{[^}]*durationSeconds:\s*6/);
|
assert.match(search, /scanWithResult\(\{[^}]*durationSeconds:\s*BLE_DISCOVERY_TIMEOUT_SECONDS/);
|
||||||
assert.equal((search.match(/scanWithResult\(/g) ?? []).length, 1);
|
assert.equal((search.match(/scanWithResult\(/g) ?? []).length, 1);
|
||||||
assert.doesNotMatch(search, /\b(?:connect|verifyConnection|submitConnect)\s*\(/);
|
assert.doesNotMatch(search, /\b(?:connect|verifyConnection|submitConnect)\s*\(/);
|
||||||
|
|
||||||
@@ -295,7 +295,7 @@ test("K1 one-intent source contract makes mode reset explicit and keeps device I
|
|||||||
}
|
}
|
||||||
assert.equal((source.match(/buttonLabel:\s*"Применить"/g) ?? []).length, 3);
|
assert.equal((source.match(/buttonLabel:\s*"Применить"/g) ?? []).length, 3);
|
||||||
assert.match(source, /устарел|stale/i);
|
assert.match(source, /устарел|stale/i);
|
||||||
assert.match(source, /неизвест|outcome-unknown|safe_to_retry/i);
|
assert.match(source, /network_outcome_unknown|safe_to_retry/i);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("exact network-applied REST proof completes frontend Apply before control is ready", () => {
|
test("exact network-applied REST proof completes frontend Apply before control is ready", () => {
|
||||||
@@ -377,7 +377,7 @@ test("exact network-applied REST proof completes frontend Apply before control i
|
|||||||
);
|
);
|
||||||
assert.match(
|
assert.match(
|
||||||
connectFlow,
|
connectFlow,
|
||||||
/failedOperation\?\.status === "succeeded"[\s\S]*?!exactAppliedNetworkIntentCompleted\([\s\S]*?&& !hasExactConnectionReady/,
|
/observeProvisioningRequest\([\s\S]*?observedState = observation\.state[\s\S]*?const exactNetworkIntentCompleted = exactAppliedNetworkIntentCompleted\(/,
|
||||||
);
|
);
|
||||||
assert.doesNotMatch(
|
assert.doesNotMatch(
|
||||||
connectFlow,
|
connectFlow,
|
||||||
@@ -1359,7 +1359,7 @@ function actionByLabel(node, label) {
|
|||||||
return actionByLabel(node.props.children, label);
|
return actionByLabel(node.props.children, label);
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderProvisioningWithAttempt(props, presentation) {
|
function renderProvisioningWithAttempt(props, presentation, afterSearch = false) {
|
||||||
function AttemptHarness() {
|
function AttemptHarness() {
|
||||||
const internals =
|
const internals =
|
||||||
React.__CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE;
|
React.__CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE;
|
||||||
@@ -1367,6 +1367,12 @@ function renderProvisioningWithAttempt(props, presentation) {
|
|||||||
assert.equal(typeof dispatcher?.useState, "function");
|
assert.equal(typeof dispatcher?.useState, "function");
|
||||||
const originalUseState = dispatcher.useState;
|
const originalUseState = dispatcher.useState;
|
||||||
dispatcher.useState = (initialState) => {
|
dispatcher.useState = (initialState) => {
|
||||||
|
if (afterSearch && initialState === emptySearchPresentation) {
|
||||||
|
const state = props.controller.state;
|
||||||
|
return [{ sequence: 1, snapshotRuntimeId: state.snapshot_runtime_id,
|
||||||
|
connectionMode: props.desiredMode, desiredModeRevision: state.desired_connection_mode_revision,
|
||||||
|
active: false, completedDiscoveryGeneration: state.ble_discovery_generation }, () => undefined];
|
||||||
|
}
|
||||||
if (initialState === emptyProvisioningAttemptPresentation) {
|
if (initialState === emptyProvisioningAttemptPresentation) {
|
||||||
return [presentation, () => undefined];
|
return [presentation, () => undefined];
|
||||||
}
|
}
|
||||||
@@ -2487,10 +2493,11 @@ test("toolbar reset in one runtime rejects late Scan A and admits exactly one Sc
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test("local connection navigation and selector copy use process nouns", () => {
|
test("operator-local connection belongs to LAB and retains all test modes", () => {
|
||||||
const localConnection = workspaces.find((workspace) => workspace.id === "local-device");
|
const localConnection = workspaces.find((workspace) => workspace.id === "local-device");
|
||||||
assert.equal(localConnection?.label, "Подключение");
|
assert.equal(localConnection?.root, "polygon");
|
||||||
assert.equal(localConnection?.title, "Подключение");
|
assert.equal(localConnection?.label, "Тестовые устройства");
|
||||||
|
assert.equal(localConnection?.title, "Тестовые устройства");
|
||||||
assert.equal(model().displayName, "XGRIDS LixelKity K1");
|
assert.equal(model().displayName, "XGRIDS LixelKity K1");
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
connectionModeOptions.map(({ label }) => label),
|
connectionModeOptions.map(({ label }) => label),
|
||||||
@@ -7185,7 +7192,7 @@ test("a current-authority Bluetooth scan owns Step 01 and no network step", () =
|
|||||||
});
|
});
|
||||||
|
|
||||||
assert.match(markup, /<span>01<\/span>/);
|
assert.match(markup, /<span>01<\/span>/);
|
||||||
assert.match(markup, /Поиск Bluetooth · 6 с/);
|
assert.match(markup, /Поиск Bluetooth · до 20 с/);
|
||||||
assert.equal(
|
assert.equal(
|
||||||
(markup.match(/class="nodedc-activity-indicator"/g) ?? []).length,
|
(markup.match(/class="nodedc-activity-indicator"/g) ?? []).length,
|
||||||
1,
|
1,
|
||||||
@@ -7478,7 +7485,7 @@ test("applied-network recovery spends the old intent and admits only explicit se
|
|||||||
search,
|
search,
|
||||||
/const recoveryScanAttempt = appliedRecoveryEscape[\s\S]*?canScanAppliedRecovery[\s\S]*?unresolvedAppliedAttempt/,
|
/const recoveryScanAttempt = appliedRecoveryEscape[\s\S]*?canScanAppliedRecovery[\s\S]*?unresolvedAppliedAttempt/,
|
||||||
);
|
);
|
||||||
assert.match(search, /scanResult = await scanWithResult\(\{ durationSeconds: 6 \}\)/);
|
assert.match(search, /scanResult = await scanWithResult\(\{ durationSeconds: BLE_DISCOVERY_TIMEOUT_SECONDS \}\)/);
|
||||||
assert.match(
|
assert.match(
|
||||||
search,
|
search,
|
||||||
/if \(scanResult\.succeeded && recoveryAttemptKey\) \{\s*setEscapedAppliedAttemptKey\(recoveryAttemptKey\)/,
|
/if \(scanResult\.succeeded && recoveryAttemptKey\) \{\s*setEscapedAppliedAttemptKey\(recoveryAttemptKey\)/,
|
||||||
@@ -7708,3 +7715,57 @@ test("contour health never promotes selection or replay metrics to live authorit
|
|||||||
});
|
});
|
||||||
assert.equal(replay.aiActive, false);
|
assert.equal(replay.aiActive, false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
test("the recovery panel explains the reviewed K1 network-not-found reply", () => {
|
||||||
|
const state = terminalConnectionRecoveryState();
|
||||||
|
state.connection_attempt.public_error_code = "k1-wifi-network-not-found";
|
||||||
|
const markup = renderToStaticMarkup(createElement(K1OperatorError, {
|
||||||
|
attempt: state.connection_attempt, onRefresh() {}, onClear() {},
|
||||||
|
}));
|
||||||
|
assert.match(markup, /<strong>Не удалось подключить K1 к Wi‑Fi<\/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", () => {
|
test("Observatory is the third independent Polygon workspace", () => {
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
productModel.workspacesForRoot("polygon").map(({ id }) => id),
|
productModel.workspacesForRoot("polygon").map(({ id }) => id),
|
||||||
["lab-archive", "simulations", "observatory"],
|
["lab-archive", "simulations", "observatory", "local-device", "spatial-scene"],
|
||||||
);
|
);
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
productModel.workspaceById("observatory"),
|
productModel.workspaceById("observatory"),
|
||||||
@@ -138,7 +138,7 @@ test("Observatory mounts the one shared canonical replay only after explicit adm
|
|||||||
/RerunViewport|ObservationSessionSelect|resolveObservationSessionReplay|resolveCanonicalLabReplay|deleteObservationSession|setInterval|setTimeout|useObservationSessions|useAdvancedLaboratoryCatalog|advanced-index|prefetch|preload/,
|
/RerunViewport|ObservationSessionSelect|resolveObservationSessionReplay|resolveCanonicalLabReplay|deleteObservationSession|setInterval|setTimeout|useObservationSessions|useAdvancedLaboratoryCatalog|advanced-index|prefetch|preload/,
|
||||||
);
|
);
|
||||||
assert.equal(sharedReplay.match(/<RerunViewport\b/g)?.length, 1);
|
assert.equal(sharedReplay.match(/<RerunViewport\b/g)?.length, 1);
|
||||||
assert.match(sharedReplay, /recordedSessionRerunProfile/);
|
assert.match(sharedReplay, /laboratoryResultRerunProfile/);
|
||||||
assert.match(sharedReplay, /timelineStartSeconds/);
|
assert.match(sharedReplay, /timelineStartSeconds/);
|
||||||
assert.doesNotMatch(sharedReplay, /live-acquisition|lab-recorded-evidence/);
|
assert.doesNotMatch(sharedReplay, /live-acquisition|lab-recorded-evidence/);
|
||||||
assert.match(
|
assert.match(
|
||||||
|
|||||||
@@ -324,7 +324,7 @@ test("Polygon exposes one dataset surface and keeps legacy links compatible", ()
|
|||||||
assert.equal(workspaceById("datasets").kind, "datasets");
|
assert.equal(workspaceById("datasets").kind, "datasets");
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
workspacesForRoot("polygon").map(({ id }) => id),
|
workspacesForRoot("polygon").map(({ id }) => id),
|
||||||
["lab-archive", "simulations", "observatory"],
|
["lab-archive", "simulations", "observatory", "local-device", "spatial-scene"],
|
||||||
);
|
);
|
||||||
assert.equal(
|
assert.equal(
|
||||||
workspacesForRoot("system").some(({ id }) => id === "polygon-run"),
|
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.equal(productModel.workspaceById("contour-health")?.root, "fleet");
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
productModel.workspacesForRoot("polygon").map(({ id }) => id),
|
productModel.workspacesForRoot("polygon").map(({ id }) => id),
|
||||||
["lab-archive", "simulations", "observatory"],
|
["lab-archive", "simulations", "observatory", "local-device", "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.equal(productModel.workspaceById("lab-archive")?.kind, "lab-archive");
|
||||||
assert.deepEqual(
|
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 LABORATORY_RECORDED_EVIDENCE_VIEWER_PROFILE;
|
||||||
let liveAcquisitionRerunProfile;
|
let liveAcquisitionRerunProfile;
|
||||||
let recordedSessionRerunProfile;
|
let recordedSessionRerunProfile;
|
||||||
|
let laboratoryResultRerunProfile;
|
||||||
|
|
||||||
before(async () => {
|
before(async () => {
|
||||||
server = await createServer({
|
server = await createServer({
|
||||||
@@ -18,6 +19,7 @@ before(async () => {
|
|||||||
LABORATORY_RECORDED_EVIDENCE_VIEWER_PROFILE,
|
LABORATORY_RECORDED_EVIDENCE_VIEWER_PROFILE,
|
||||||
liveAcquisitionRerunProfile,
|
liveAcquisitionRerunProfile,
|
||||||
recordedSessionRerunProfile,
|
recordedSessionRerunProfile,
|
||||||
|
laboratoryResultRerunProfile,
|
||||||
} = await server.ssrLoadModule("/src/core/observation/viewerProfile.ts"));
|
} = await server.ssrLoadModule("/src/core/observation/viewerProfile.ts"));
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -86,3 +88,14 @@ test("the old LAB transport is fenced as explicit legacy comparison only", () =>
|
|||||||
});
|
});
|
||||||
assert.equal(Object.isFrozen(LABORATORY_RECORDED_EVIDENCE_VIEWER_PROFILE), true);
|
assert.equal(Object.isFrozen(LABORATORY_RECORDED_EVIDENCE_VIEWER_PROFILE), true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("LAB has its own immutable result identity and discriminator", () => {
|
||||||
|
const input = {resultId:"E38-result-1",sourceUrl:"/shared.rrd",kind:"recorded-session",clock:"stream_time"};
|
||||||
|
const lab = laboratoryResultRerunProfile(input);
|
||||||
|
assert.equal(lab.kind,"laboratory-result");
|
||||||
|
assert.equal(lab.clock,"session_time");
|
||||||
|
assert.equal(lab.resultId,"E38-result-1");
|
||||||
|
assert.equal(recordedSessionRerunProfile({...input}).kind,"recorded-session");
|
||||||
|
assert.equal(liveAcquisitionRerunProfile({...input}).kind,"live-acquisition");
|
||||||
|
assert.throws(()=>laboratoryResultRerunProfile({...input,resultId:" "}));
|
||||||
|
});
|
||||||
|
|||||||
@@ -3,7 +3,11 @@
|
|||||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||||
"target": "ES2022",
|
"target": "ES2022",
|
||||||
"useDefineForClassFields": true,
|
"useDefineForClassFields": true,
|
||||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
"lib": [
|
||||||
|
"ES2022",
|
||||||
|
"DOM",
|
||||||
|
"DOM.Iterable"
|
||||||
|
],
|
||||||
"allowJs": false,
|
"allowJs": false,
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"esModuleInterop": true,
|
"esModuleInterop": true,
|
||||||
@@ -14,11 +18,24 @@
|
|||||||
"moduleResolution": "Bundler",
|
"moduleResolution": "Bundler",
|
||||||
"baseUrl": ".",
|
"baseUrl": ".",
|
||||||
"paths": {
|
"paths": {
|
||||||
"@mission-core/plugin-sdk": ["src/core/device-plugins/frontendSdk.ts"],
|
"@mission-core/plugin-sdk": [
|
||||||
"@xgrids-k1/frontend/*": ["../../plugins/xgrids-k1/frontend/src/*"],
|
"src/core/device-plugins/frontendSdk.ts"
|
||||||
"react": ["node_modules/@types/react/index.d.ts"],
|
],
|
||||||
"react/jsx-runtime": ["node_modules/@types/react/jsx-runtime.d.ts"],
|
"@xgrids-k1/frontend/*": [
|
||||||
"@nodedc/ui-react": ["node_modules/@nodedc/ui-react/dist/index.d.ts"]
|
"../../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,
|
"resolveJsonModule": true,
|
||||||
"isolatedModules": true,
|
"isolatedModules": true,
|
||||||
@@ -28,5 +45,8 @@
|
|||||||
"noUnusedParameters": true,
|
"noUnusedParameters": true,
|
||||||
"noFallthroughCasesInSwitch": true
|
"noFallthroughCasesInSwitch": true
|
||||||
},
|
},
|
||||||
"include": ["src", "../../plugins/xgrids-k1/frontend/src"]
|
"include": [
|
||||||
|
"src",
|
||||||
|
"../../plugins/xgrids-k1/frontend/src"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -74,6 +74,7 @@ export default defineConfig(({ mode }) => {
|
|||||||
plugins: [react(), wasm(), cesiumRuntimeAssets()],
|
plugins: [react(), wasm(), cesiumRuntimeAssets()],
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
|
"@mission-core/sensor-sdk": fileURLToPath(new URL("../../packages/sensor-ui/src/pluginSdk.ts", import.meta.url)),
|
||||||
"@mission-core/plugin-sdk": fileURLToPath(
|
"@mission-core/plugin-sdk": fileURLToPath(
|
||||||
new URL("./src/core/device-plugins/frontendSdk.ts", import.meta.url),
|
new URL("./src/core/device-plugins/frontendSdk.ts", import.meta.url),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -80,6 +80,12 @@ func run() error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
pairing.Sensors = app.Sensors
|
pairing.Sensors = app.Sensors
|
||||||
|
app.DeviceEnrollment, err = node.OpenDeviceEnrollment(*dir, nodeID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
pairing.DeviceEnrollment = app.DeviceEnrollment
|
||||||
|
app.Sensors.NetworkDevices = app.DeviceEnrollment
|
||||||
app.Access = &node.AccessStore{Path: filepath.Join(*dir, "ssh-keys.json"), Users: func() []string { return node.LocalAdmins("/") }}
|
app.Access = &node.AccessStore{Path: filepath.Join(*dir, "ssh-keys.json"), Users: func() []string { return node.LocalAdmins("/") }}
|
||||||
if err := os.MkdirAll(filepath.Dir(*socket), 0700); err != nil {
|
if err := os.MkdirAll(filepath.Dir(*socket), 0700); err != nil {
|
||||||
return err
|
return err
|
||||||
|
|||||||
@@ -0,0 +1,323 @@
|
|||||||
|
package node
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type EnrollmentCommand struct {
|
||||||
|
ID string `json:"operation_id"`
|
||||||
|
NodeID string `json:"node_id"`
|
||||||
|
RuntimeID string `json:"runtime_id"`
|
||||||
|
Action string `json:"action"`
|
||||||
|
Deadline string `json:"deadline_at"`
|
||||||
|
Parameters map[string]any `json:"parameters"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// This is the complete journal schema. No command parameters or credentials.
|
||||||
|
type EnrollmentOperation struct {
|
||||||
|
ID string `json:"operation_id"`
|
||||||
|
NodeID string `json:"node_id"`
|
||||||
|
RuntimeID string `json:"runtime_id"`
|
||||||
|
Action string `json:"action"`
|
||||||
|
State string `json:"state"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
Result map[string]any `json:"result,omitempty"`
|
||||||
|
Remote bool `json:"remote,omitempty"`
|
||||||
|
Updated int64 `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type DeviceEnrollment struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
root string
|
||||||
|
nodeID string
|
||||||
|
client *http.Client
|
||||||
|
operations map[string]*EnrollmentOperation
|
||||||
|
}
|
||||||
|
|
||||||
|
func OpenDeviceEnrollment(root, nodeID string) (*DeviceEnrollment, error) {
|
||||||
|
dir := filepath.Join(root, "device-enrollment")
|
||||||
|
if e := os.MkdirAll(dir, 0700); e != nil {
|
||||||
|
return nil, e
|
||||||
|
}
|
||||||
|
d := &DeviceEnrollment{root: dir, nodeID: nodeID, operations: map[string]*EnrollmentOperation{}}
|
||||||
|
d.client = &http.Client{Timeout: 185 * time.Second, Transport: &http.Transport{DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) {
|
||||||
|
return (&net.Dialer{}).DialContext(ctx, "unix", "/run/mission-core-k1/driver.sock")
|
||||||
|
}}}
|
||||||
|
files, _ := filepath.Glob(filepath.Join(dir, "op_*.json"))
|
||||||
|
for _, path := range files {
|
||||||
|
data, e := os.ReadFile(path)
|
||||||
|
if e != nil {
|
||||||
|
return nil, e
|
||||||
|
}
|
||||||
|
var op EnrollmentOperation
|
||||||
|
if json.Unmarshal(data, &op) != nil || !operationID.MatchString(op.ID) {
|
||||||
|
return nil, errors.New("invalid enrollment journal")
|
||||||
|
}
|
||||||
|
if op.State == "running" {
|
||||||
|
op.State = "unknown"
|
||||||
|
op.Error = "БК перезапущен. Обновите состояние K1 перед новой командой."
|
||||||
|
}
|
||||||
|
d.operations[op.ID] = &op
|
||||||
|
}
|
||||||
|
return d, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *DeviceEnrollment) call(ctx context.Context, path string, command any) (map[string]any, error) {
|
||||||
|
var body io.Reader
|
||||||
|
method := "GET"
|
||||||
|
if command != nil {
|
||||||
|
data, e := json.Marshal(command)
|
||||||
|
if e != nil {
|
||||||
|
return nil, e
|
||||||
|
}
|
||||||
|
body = bytes.NewReader(data)
|
||||||
|
method = "POST"
|
||||||
|
}
|
||||||
|
request, e := http.NewRequestWithContext(ctx, method, "http://k1"+path, body)
|
||||||
|
if e != nil {
|
||||||
|
return nil, e
|
||||||
|
}
|
||||||
|
request.Header.Set("Content-Type", "application/json")
|
||||||
|
request.Header.Set("X-Node-Id", d.nodeID)
|
||||||
|
response, e := d.client.Do(request)
|
||||||
|
if e != nil {
|
||||||
|
return nil, errors.New("Служба K1 на БК недоступна.")
|
||||||
|
}
|
||||||
|
defer response.Body.Close()
|
||||||
|
var value map[string]any
|
||||||
|
if json.NewDecoder(io.LimitReader(response.Body, 65536)).Decode(&value) != nil || response.StatusCode != 200 {
|
||||||
|
return nil, errors.New("Действие K1 не подтверждено. Обновите состояние устройства.")
|
||||||
|
}
|
||||||
|
return value, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *DeviceEnrollment) Status() map[string]any {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
value, e := d.call(ctx, "/status", nil)
|
||||||
|
if e != nil {
|
||||||
|
value = map[string]any{"available": false}
|
||||||
|
}
|
||||||
|
value["node_id"] = d.nodeID
|
||||||
|
value["name"], _ = os.Hostname()
|
||||||
|
value["fresh"] = true
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c EnrollmentCommand) valid(nodeID string) bool {
|
||||||
|
deadline, e := time.Parse(time.RFC3339Nano, c.Deadline)
|
||||||
|
if e != nil || !deadline.After(time.Now()) || time.Until(deadline) > 180*time.Second || c.NodeID != nodeID || !operationID.MatchString(c.ID) || c.RuntimeID == "" || len(c.RuntimeID) > 128 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
keys := map[string]bool{}
|
||||||
|
switch c.Action {
|
||||||
|
case "scan", "networks":
|
||||||
|
case "connect", "verify":
|
||||||
|
keys = map[string]bool{"device_id": true, "discovery_generation": true, "mode_revision": true}
|
||||||
|
id, ok := c.Parameters["device_id"].(string)
|
||||||
|
if !ok || len(id) < 1 || len(id) > 128 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, key := range []string{"discovery_generation", "mode_revision"} {
|
||||||
|
v, ok := c.Parameters[key].(float64)
|
||||||
|
if !ok || v < 0 || v != float64(int64(v)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if c.Action == "connect" {
|
||||||
|
keys["ssid"] = true
|
||||||
|
keys["password"] = true
|
||||||
|
for key, limit := range map[string]int{"ssid": 32, "password": 64} {
|
||||||
|
v, ok := c.Parameters[key].(string)
|
||||||
|
if !ok || len(v) < 1 || len(v) > limit {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if len(keys) != len(c.Parameters) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for key := range c.Parameters {
|
||||||
|
if !keys[key] {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func copyEnrollment(value *EnrollmentOperation) *EnrollmentOperation {
|
||||||
|
if value == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
data, _ := json.Marshal(value)
|
||||||
|
var out EnrollmentOperation
|
||||||
|
_ = json.Unmarshal(data, &out)
|
||||||
|
return &out
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *DeviceEnrollment) Submit(c EnrollmentCommand, remote bool) (*EnrollmentOperation, error) {
|
||||||
|
if !c.valid(d.nodeID) {
|
||||||
|
return nil, errors.New("Проверьте БК, выбранное устройство и параметры сети.")
|
||||||
|
}
|
||||||
|
d.mu.Lock()
|
||||||
|
if old := d.operations[c.ID]; old != nil {
|
||||||
|
out := copyEnrollment(old)
|
||||||
|
d.mu.Unlock()
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
if len(d.operations) >= 2000 {
|
||||||
|
for id, value := range d.operations {
|
||||||
|
if value.State != "running" && time.Now().Unix()-value.Updated > 86400 {
|
||||||
|
if os.Remove(filepath.Join(d.root, id+".json")) == nil {
|
||||||
|
delete(d.operations, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(d.operations) >= 2000 {
|
||||||
|
d.mu.Unlock()
|
||||||
|
return nil, errors.New("Журнал подключений заполнен. Повторите позже.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, v := range d.operations {
|
||||||
|
if v.State == "running" {
|
||||||
|
d.mu.Unlock()
|
||||||
|
return nil, errors.New("Дождитесь текущего действия K1.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
d.mu.Unlock()
|
||||||
|
if d.Status()["runtime_id"] != c.RuntimeID {
|
||||||
|
return nil, errors.New("Сеанс K1 изменился. Обновите устройства.")
|
||||||
|
}
|
||||||
|
d.mu.Lock()
|
||||||
|
// Status can yield; repeat admission under the lock before the durable write.
|
||||||
|
if old := d.operations[c.ID]; old != nil {
|
||||||
|
out := copyEnrollment(old)
|
||||||
|
d.mu.Unlock()
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
for _, v := range d.operations {
|
||||||
|
if v.State == "running" {
|
||||||
|
d.mu.Unlock()
|
||||||
|
return nil, errors.New("Дождитесь текущего действия K1.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
op := &EnrollmentOperation{ID: c.ID, NodeID: c.NodeID, RuntimeID: c.RuntimeID, Action: c.Action, State: "running", Remote: remote, Updated: time.Now().Unix()}
|
||||||
|
if e := savePrivateJSON(filepath.Join(d.root, c.ID+".json"), op); e != nil {
|
||||||
|
d.mu.Unlock()
|
||||||
|
return nil, e
|
||||||
|
}
|
||||||
|
d.operations[c.ID] = op
|
||||||
|
out := copyEnrollment(op)
|
||||||
|
d.mu.Unlock()
|
||||||
|
go d.execute(c)
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *DeviceEnrollment) execute(c EnrollmentCommand) {
|
||||||
|
deadline, _ := time.Parse(time.RFC3339Nano, c.Deadline)
|
||||||
|
ctx, cancel := context.WithDeadline(context.Background(), deadline)
|
||||||
|
defer cancel()
|
||||||
|
result, e := d.call(ctx, "/operation", c)
|
||||||
|
// Dropping references is best-effort lifetime reduction, not a claim that
|
||||||
|
// Go can zero every immutable JSON/string copy. No secret is journalled.
|
||||||
|
delete(c.Parameters, "password")
|
||||||
|
d.mu.Lock()
|
||||||
|
defer d.mu.Unlock()
|
||||||
|
op := d.operations[c.ID]
|
||||||
|
op.Updated = time.Now().Unix()
|
||||||
|
op.State = "complete"
|
||||||
|
op.Result = result
|
||||||
|
if e != nil {
|
||||||
|
op.State = "unknown"
|
||||||
|
op.Error = "Действие K1 не подтверждено. Обновите состояние; повторная команда автоматически не отправляется."
|
||||||
|
}
|
||||||
|
if e := savePrivateJSON(filepath.Join(d.root, c.ID+".json"), op); e != nil {
|
||||||
|
op.State = "unknown"
|
||||||
|
op.Error = "Результат не сохранён. Обновите состояние K1."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *DeviceEnrollment) Get(id string) *EnrollmentOperation {
|
||||||
|
d.mu.Lock()
|
||||||
|
defer d.mu.Unlock()
|
||||||
|
return copyEnrollment(d.operations[id])
|
||||||
|
}
|
||||||
|
func (d *DeviceEnrollment) RemoteResults() []*EnrollmentOperation {
|
||||||
|
d.mu.Lock()
|
||||||
|
defer d.mu.Unlock()
|
||||||
|
out := []*EnrollmentOperation{}
|
||||||
|
for _, op := range d.operations {
|
||||||
|
if op.Remote {
|
||||||
|
out = append(out, copyEnrollment(op))
|
||||||
|
if len(out) == 8 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
func (d *DeviceEnrollment) Acknowledge(ids []string) {
|
||||||
|
d.mu.Lock()
|
||||||
|
defer d.mu.Unlock()
|
||||||
|
for _, id := range ids {
|
||||||
|
if op := d.operations[id]; op != nil && op.State != "running" {
|
||||||
|
op.Remote = false
|
||||||
|
_ = savePrivateJSON(filepath.Join(d.root, id+".json"), op)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *DeviceEnrollment) Routes(mux *http.ServeMux, server *Server) {
|
||||||
|
mux.HandleFunc("GET /api/devices/enrollment", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !server.authorized(w, r) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
v := d.Status()
|
||||||
|
_, name := server.Store.Public()
|
||||||
|
v["name"] = name
|
||||||
|
reply(w, 200, v)
|
||||||
|
})
|
||||||
|
mux.HandleFunc("POST /api/devices/enrollment/operations", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !server.authorized(w, r) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
r.Body = http.MaxBytesReader(w, r.Body, 16384)
|
||||||
|
var c EnrollmentCommand
|
||||||
|
decoder := json.NewDecoder(r.Body)
|
||||||
|
decoder.DisallowUnknownFields()
|
||||||
|
if r.Header.Get("Content-Type") != "application/json" || decoder.Decode(&c) != nil {
|
||||||
|
reply(w, 400, map[string]string{"error": "Некорректный запрос подключения"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
op, e := d.Submit(c, false)
|
||||||
|
if e != nil {
|
||||||
|
reply(w, 409, map[string]string{"error": e.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
reply(w, 202, op)
|
||||||
|
})
|
||||||
|
mux.HandleFunc("GET /api/devices/enrollment/operations/{id}", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !server.authorized(w, r) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
op := d.Get(r.PathValue("id"))
|
||||||
|
if op == nil {
|
||||||
|
reply(w, 200, map[string]string{"state": "unknown", "error": "Результат недоступен. Обновите состояние K1."})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
reply(w, 200, op)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
package node
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type enrollmentHTTP func(*http.Request) (*http.Response, error)
|
||||||
|
|
||||||
|
func (f enrollmentHTTP) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) }
|
||||||
|
|
||||||
|
func enrollmentRequest() EnrollmentCommand {
|
||||||
|
return EnrollmentCommand{ID: "op_" + strings.Repeat("a", 32), NodeID: "node-test", RuntimeID: "runtime-test", Action: "connect", Deadline: time.Now().Add(time.Minute).UTC().Format(time.RFC3339Nano), Parameters: map[string]any{"device_id": "test-ble", "mode_revision": float64(0), "discovery_generation": float64(1), "ssid": "test-network", "password": token()}}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEnrollmentJournalsNoCredentialAndDispatchesOnce(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
d, e := OpenDeviceEnrollment(root, "node-test")
|
||||||
|
if e != nil {
|
||||||
|
t.Fatal(e)
|
||||||
|
}
|
||||||
|
var posts atomic.Int32
|
||||||
|
d.client = &http.Client{Transport: enrollmentHTTP(func(r *http.Request) (*http.Response, error) {
|
||||||
|
body := `{"available":true,"runtime_id":"runtime-test"}`
|
||||||
|
if r.Method == "POST" {
|
||||||
|
posts.Add(1)
|
||||||
|
body = `{"available":true,"connected":true}`
|
||||||
|
}
|
||||||
|
return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(body)), Header: make(http.Header)}, nil
|
||||||
|
})}
|
||||||
|
c := enrollmentRequest()
|
||||||
|
secret := c.Parameters["password"].(string)
|
||||||
|
if _, e = d.Submit(c, true); e != nil {
|
||||||
|
t.Fatal(e)
|
||||||
|
}
|
||||||
|
deadline := time.Now().Add(time.Second)
|
||||||
|
for d.Get(c.ID).State == "running" && time.Now().Before(deadline) {
|
||||||
|
time.Sleep(time.Millisecond)
|
||||||
|
}
|
||||||
|
if d.Get(c.ID).State != "complete" {
|
||||||
|
t.Fatal("operation did not complete")
|
||||||
|
}
|
||||||
|
// A repeated delivery owns no new physical intent, even with a replaced secret.
|
||||||
|
c.Parameters["password"] = token()
|
||||||
|
if _, e = d.Submit(c, true); e != nil {
|
||||||
|
t.Fatal(e)
|
||||||
|
}
|
||||||
|
if posts.Load() != 1 {
|
||||||
|
t.Fatal("duplicate dispatch")
|
||||||
|
}
|
||||||
|
journal, e := os.ReadFile(filepath.Join(root, "device-enrollment", c.ID+".json"))
|
||||||
|
if e != nil {
|
||||||
|
t.Fatal(e)
|
||||||
|
}
|
||||||
|
results, _ := json.Marshal(d.RemoteResults())
|
||||||
|
if bytes.Contains(journal, []byte(secret)) || bytes.Contains(results, []byte(secret)) || bytes.Contains(journal, []byte("parameters")) {
|
||||||
|
t.Fatal("credential/parameters entered journal or results")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEnrollmentRestartDoesNotReplayRunningIntent(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
d, e := OpenDeviceEnrollment(root, "node-test")
|
||||||
|
if e != nil {
|
||||||
|
t.Fatal(e)
|
||||||
|
}
|
||||||
|
c := enrollmentRequest()
|
||||||
|
op := EnrollmentOperation{ID: c.ID, NodeID: c.NodeID, RuntimeID: c.RuntimeID, Action: c.Action, State: "running", Remote: true}
|
||||||
|
if e := savePrivateJSON(filepath.Join(d.root, c.ID+".json"), op); e != nil {
|
||||||
|
t.Fatal(e)
|
||||||
|
}
|
||||||
|
restarted, e := OpenDeviceEnrollment(root, "node-test")
|
||||||
|
if e != nil {
|
||||||
|
t.Fatal(e)
|
||||||
|
}
|
||||||
|
if restarted.Get(c.ID).State != "unknown" {
|
||||||
|
t.Fatal("restart did not fence unknown result")
|
||||||
|
}
|
||||||
|
restarted.client = &http.Client{Transport: enrollmentHTTP(func(*http.Request) (*http.Response, error) { t.Fatal("restart contacted hardware"); return nil, nil })}
|
||||||
|
result, e := restarted.Submit(c, true)
|
||||||
|
if e != nil || result.State != "unknown" {
|
||||||
|
t.Fatal("repeated intent did not retain unknown outcome")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEnrollmentRejectsAnotherNodeExpiredAndHostMutation(t *testing.T) {
|
||||||
|
c := enrollmentRequest()
|
||||||
|
if !c.valid("node-test") {
|
||||||
|
t.Fatal("valid intent rejected")
|
||||||
|
}
|
||||||
|
if c.valid("another-node") {
|
||||||
|
t.Fatal("wrong node admitted")
|
||||||
|
}
|
||||||
|
c.Parameters["allow_host_wifi_switch"] = true
|
||||||
|
if c.valid("node-test") {
|
||||||
|
t.Fatal("host association admitted")
|
||||||
|
}
|
||||||
|
delete(c.Parameters, "allow_host_wifi_switch")
|
||||||
|
c.Deadline = time.Now().Add(-time.Second).Format(time.RFC3339Nano)
|
||||||
|
if c.valid("node-test") {
|
||||||
|
t.Fatal("expired operation admitted")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -39,20 +39,21 @@ type PairState struct {
|
|||||||
Revocations []CoreBinding `json:"revocations,omitempty"`
|
Revocations []CoreBinding `json:"revocations,omitempty"`
|
||||||
}
|
}
|
||||||
type Pairing struct {
|
type Pairing struct {
|
||||||
Sensors *Sensors
|
Sensors *Sensors
|
||||||
mu sync.Mutex
|
DeviceEnrollment *DeviceEnrollment
|
||||||
path string
|
mu sync.Mutex
|
||||||
store *Store
|
path string
|
||||||
state PairState
|
store *Store
|
||||||
now func() time.Time
|
state PairState
|
||||||
inventory func() Inventory
|
now func() time.Time
|
||||||
version string
|
inventory func() Inventory
|
||||||
lastSeen int64
|
version string
|
||||||
connection string
|
lastSeen int64
|
||||||
listenError string
|
connection string
|
||||||
failureWindow int64
|
listenError string
|
||||||
failures int
|
failureWindow int64
|
||||||
clients map[string]*http.Client
|
failures int
|
||||||
|
clients map[string]*http.Client
|
||||||
}
|
}
|
||||||
|
|
||||||
func OpenPairing(store *Store, dir, version string, inventory func() Inventory) (*Pairing, error) {
|
func OpenPairing(store *Store, dir, version string, inventory func() Inventory) (*Pairing, error) {
|
||||||
|
|||||||
@@ -282,12 +282,28 @@ func (p *Pairing) channel(ctx context.Context) {
|
|||||||
payload["sensor_state"] = inv
|
payload["sensor_state"] = inv
|
||||||
payload["sensor_results"] = p.Sensors.RemoteResults()
|
payload["sensor_results"] = p.Sensors.RemoteResults()
|
||||||
}
|
}
|
||||||
|
if p.DeviceEnrollment != nil {
|
||||||
|
payload["device_enrollment"] = p.DeviceEnrollment.Status()
|
||||||
|
payload["enrollment_results"] = p.DeviceEnrollment.RemoteResults()
|
||||||
|
}
|
||||||
result, status, e := p.send(ctx, *binding, "/v1/node/heartbeat", payload)
|
result, status, e := p.send(ctx, *binding, "/v1/node/heartbeat", payload)
|
||||||
p.mu.Lock()
|
p.mu.Lock()
|
||||||
if p.state.Phase == "paired" && p.state.Binding != nil && p.state.Binding.BindingID == binding.BindingID {
|
if p.state.Phase == "paired" && p.state.Binding != nil && p.state.Binding.BindingID == binding.BindingID {
|
||||||
if e == nil && status == 200 {
|
if e == nil && status == 200 {
|
||||||
p.connection = "online"
|
p.connection = "online"
|
||||||
p.lastSeen = p.now().Unix()
|
p.lastSeen = p.now().Unix()
|
||||||
|
if p.DeviceEnrollment != nil {
|
||||||
|
var ack []string
|
||||||
|
if json.Unmarshal(result["enrollment_acknowledgements"], &ack) == nil {
|
||||||
|
p.DeviceEnrollment.Acknowledge(ack)
|
||||||
|
}
|
||||||
|
var commands []EnrollmentCommand
|
||||||
|
if json.Unmarshal(result["enrollment_commands"], &commands) == nil {
|
||||||
|
for _, c := range commands {
|
||||||
|
_, _ = p.DeviceEnrollment.Submit(c, true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
if p.Sensors != nil {
|
if p.Sensors != nil {
|
||||||
var ack []string
|
var ack []string
|
||||||
if json.Unmarshal(result["sensor_acknowledgements"], &ack) == nil {
|
if json.Unmarshal(result["sensor_acknowledgements"], &ack) == nil {
|
||||||
|
|||||||
@@ -45,19 +45,20 @@ type SensorOperation struct {
|
|||||||
Updated int64 `json:"updated_at"`
|
Updated int64 `json:"updated_at"`
|
||||||
}
|
}
|
||||||
type Sensors struct {
|
type Sensors struct {
|
||||||
events sensorEvents
|
events sensorEvents
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
prepareMu sync.Mutex
|
prepareMu sync.Mutex
|
||||||
root string
|
root string
|
||||||
nodeID string
|
nodeID string
|
||||||
instance string
|
instance string
|
||||||
client *http.Client
|
client *http.Client
|
||||||
operations map[string]*SensorOperation
|
NetworkDevices *DeviceEnrollment
|
||||||
names map[string]string
|
operations map[string]*SensorOperation
|
||||||
initialized map[string]bool
|
names map[string]string
|
||||||
|
initialized map[string]bool
|
||||||
}
|
}
|
||||||
|
|
||||||
var sensorID = regexp.MustCompile(`^rsd455_[0-9a-f]{32}$`)
|
var sensorID = regexp.MustCompile(`^(rsd455|k1)_[0-9a-f]{32}$`)
|
||||||
var operationID = regexp.MustCompile(`^op_[0-9a-f]{32}$`)
|
var operationID = regexp.MustCompile(`^op_[0-9a-f]{32}$`)
|
||||||
|
|
||||||
func OpenSensors(root, nodeID string) (*Sensors, error) {
|
func OpenSensors(root, nodeID string) (*Sensors, error) {
|
||||||
@@ -168,6 +169,32 @@ func (s *Sensors) driver(path string, body any) (map[string]any, error) {
|
|||||||
func (s *Sensors) Inventory() map[string]any {
|
func (s *Sensors) Inventory() map[string]any {
|
||||||
items := []any{}
|
items := []any{}
|
||||||
seen := map[string]bool{}
|
seen := map[string]bool{}
|
||||||
|
if s.NetworkDevices != nil {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||||
|
result, e := s.NetworkDevices.call(ctx, "/inventory", nil)
|
||||||
|
cancel()
|
||||||
|
if e == nil {
|
||||||
|
if found, ok := result["items"].([]any); ok {
|
||||||
|
for _, raw := range found {
|
||||||
|
item, ok := raw.(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
id, _ := item["id"].(string)
|
||||||
|
if !strings.HasPrefix(id, "k1_") || !sensorID.MatchString(id) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
s.mu.Lock()
|
||||||
|
if name := s.names[id]; name != "" {
|
||||||
|
item["name"] = name
|
||||||
|
}
|
||||||
|
s.mu.Unlock()
|
||||||
|
seen[id] = true
|
||||||
|
items = append(items, item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
if result, e := s.driver("/inventory", nil); e == nil {
|
if result, e := s.driver("/inventory", nil); e == nil {
|
||||||
if found, ok := result["items"].([]any); ok {
|
if found, ok := result["items"].([]any); ok {
|
||||||
for _, v := range found {
|
for _, v := range found {
|
||||||
@@ -259,6 +286,9 @@ func sensorViewAction(action string) bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Sensors) Submit(c SensorCommand, remote bool) (*SensorOperation, error) {
|
func (s *Sensors) Submit(c SensorCommand, remote bool) (*SensorOperation, error) {
|
||||||
|
if strings.HasPrefix(c.Session.DeviceID, "k1_") && (c.Action == "prepare" || c.Action == "replay") {
|
||||||
|
return nil, errors.New("Эта операция не поддерживается K1.")
|
||||||
|
}
|
||||||
if c.APIVersion != SensorSchema || c.Kind != "OperationRequest" || !operationID.MatchString(c.ID) || c.Idempotency != c.ID || !sensorID.MatchString(c.Session.DeviceID) || len(c.Session.SessionID) > 192 {
|
if c.APIVersion != SensorSchema || c.Kind != "OperationRequest" || !operationID.MatchString(c.ID) || c.Idempotency != c.ID || !sensorID.MatchString(c.Session.DeviceID) || len(c.Session.SessionID) > 192 {
|
||||||
return nil, errors.New("Некорректная команда устройства.")
|
return nil, errors.New("Некорректная команда устройства.")
|
||||||
}
|
}
|
||||||
@@ -344,7 +374,14 @@ func (s *Sensors) execute(c SensorCommand) {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
var v map[string]any
|
var v map[string]any
|
||||||
v, err = s.driver("/operation", c)
|
if strings.HasPrefix(c.Session.DeviceID, "k1_") && s.NetworkDevices != nil {
|
||||||
|
deadline, _ := time.Parse(time.RFC3339Nano, c.Deadline)
|
||||||
|
ctx, cancel := context.WithDeadline(context.Background(), deadline)
|
||||||
|
v, err = s.NetworkDevices.call(ctx, "/sensor-operation", c)
|
||||||
|
cancel()
|
||||||
|
} else {
|
||||||
|
v, err = s.driver("/operation", c)
|
||||||
|
}
|
||||||
uncertain = err != nil || v["state"] == "unknown"
|
uncertain = err != nil || v["state"] == "unknown"
|
||||||
if err == nil {
|
if err == nil {
|
||||||
if v["state"] == "complete" {
|
if v["state"] == "complete" {
|
||||||
|
|||||||
@@ -13,20 +13,21 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type Server struct {
|
type Server struct {
|
||||||
Store *Store
|
Store *Store
|
||||||
Pairing *Pairing
|
Pairing *Pairing
|
||||||
Sensors *Sensors
|
Sensors *Sensors
|
||||||
Assets fs.FS
|
DeviceEnrollment *DeviceEnrollment
|
||||||
Origin string
|
Assets fs.FS
|
||||||
Version string
|
Origin string
|
||||||
Inventory func() Inventory
|
Version string
|
||||||
Access *AccessStore
|
Inventory func() Inventory
|
||||||
Tailscale func() TailscaleStatus
|
Access *AccessStore
|
||||||
Environment func() EnvironmentStatus
|
Tailscale func() TailscaleStatus
|
||||||
mu sync.Mutex
|
Environment func() EnvironmentStatus
|
||||||
logins map[string]time.Time
|
mu sync.Mutex
|
||||||
sessions map[string]time.Time
|
logins map[string]time.Time
|
||||||
Now func() time.Time
|
sessions map[string]time.Time
|
||||||
|
Now func() time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
func token() string {
|
func token() string {
|
||||||
@@ -82,6 +83,9 @@ func (s *Server) Handler() http.Handler {
|
|||||||
if s.Sensors != nil {
|
if s.Sensors != nil {
|
||||||
s.Sensors.Routes(mux, s)
|
s.Sensors.Routes(mux, s)
|
||||||
}
|
}
|
||||||
|
if s.DeviceEnrollment != nil {
|
||||||
|
s.DeviceEnrollment.Routes(mux, s)
|
||||||
|
}
|
||||||
if s.Pairing != nil {
|
if s.Pairing != nil {
|
||||||
s.Pairing.localRoutes(mux, s)
|
s.Pairing.localRoutes(mux, s)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
// Only WLAN discovery. No host association/profile modification authority.
|
||||||
|
polkit.addRule(function(action, subject) {
|
||||||
|
if (subject.user === "mission-core-k1" && action.id === "org.freedesktop.NetworkManager.wifi.scan") {
|
||||||
|
return polkit.Result.YES;
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -11,7 +11,7 @@ import sys
|
|||||||
from build_deb import build, VERSION, BRAND_SHA256
|
from build_deb import build, VERSION, BRAND_SHA256
|
||||||
|
|
||||||
ROOT = Path(__file__).resolve().parents[1]
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
DG_COMMIT = "999864e5b0a81555823cfa1ea6e8cf8a417c37f1"
|
DG_COMMIT = "1bdfc6c24072d38cc1068086ea271c444f2524ad"
|
||||||
|
|
||||||
|
|
||||||
def guideline_sources():
|
def guideline_sources():
|
||||||
@@ -32,6 +32,9 @@ def provenance():
|
|||||||
"base_commit": subprocess.check_output(["git", "rev-parse", "HEAD"], cwd=ROOT, text=True).strip(),
|
"base_commit": subprocess.check_output(["git", "rev-parse", "HEAD"], cwd=ROOT, text=True).strip(),
|
||||||
"design_guideline_commit": DG_COMMIT,
|
"design_guideline_commit": DG_COMMIT,
|
||||||
"design_guideline_files": guideline_sources(),
|
"design_guideline_files": guideline_sources(),
|
||||||
|
"shared_sensor_ui_files": {str(p.relative_to(ROOT.parents[1])): hashlib.sha256(p.read_bytes()).hexdigest() for p in sorted((ROOT.parents[1] / "packages/sensor-ui/src").rglob("*")) if p.is_file()},
|
||||||
|
"k1_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}
|
"toolchain": json.loads((ROOT / "toolchain.json").read_text()), "files": files}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import tarfile
|
|||||||
|
|
||||||
|
|
||||||
ROOT = Path(__file__).resolve().parents[1]
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
VERSION = "0.6.11"
|
VERSION = "0.7.1"
|
||||||
BRAND_SHA256 = "8bfee8ca9f98e0db48d98aae3af4b32493b8593e18b064a0239d513d824182af"
|
BRAND_SHA256 = "8bfee8ca9f98e0db48d98aae3af4b32493b8593e18b064a0239d513d824182af"
|
||||||
|
|
||||||
|
|
||||||
@@ -65,7 +65,7 @@ Architecture: amd64
|
|||||||
Maintainer: NODE.DC local build <noreply@example.invalid>
|
Maintainer: NODE.DC local build <noreply@example.invalid>
|
||||||
Section: admin
|
Section: admin
|
||||||
Priority: optional
|
Priority: optional
|
||||||
Depends: adduser, systemd, python3, python3-gi, gir1.2-gtk-3.0, gir1.2-webkit2-4.1, pkexec, polkitd, ca-certificates, hicolor-icon-theme
|
Depends: adduser, systemd, python3, python3-gi, gir1.2-gtk-3.0, gir1.2-webkit2-4.1, pkexec, polkitd, ca-certificates, hicolor-icon-theme, bluez, network-manager, iproute2, ffmpeg
|
||||||
Description: Mission Core onboard computer configuration
|
Description: Mission Core onboard computer configuration
|
||||||
Local graphical setup, host inventory, SSH access and persistent node identity.
|
Local graphical setup, host inventory, SSH access and persistent node identity.
|
||||||
""".encode()
|
""".encode()
|
||||||
@@ -107,6 +107,29 @@ Description: Mission Core onboard computer configuration
|
|||||||
files.append(("usr/share/mission-core-node/realsense/" + item["name"], data, 0o644))
|
files.append(("usr/share/mission-core-node/realsense/" + item["name"], data, 0o644))
|
||||||
for path in (ROOT / "sensors").glob("*.py"):
|
for path in (ROOT / "sensors").glob("*.py"):
|
||||||
files.append(("usr/lib/mission-core-node/sensors/" + path.name, path.read_bytes(), 0o644))
|
files.append(("usr/lib/mission-core-node/sensors/" + path.name, path.read_bytes(), 0o644))
|
||||||
|
for name in ("k1_prepare.py", "k1_bootstrap.py"):
|
||||||
|
files.append(("usr/lib/mission-core-node/" + name, (p / name).read_bytes(), 0o644))
|
||||||
|
files.append(("usr/lib/mission-core-node/install-k1-credential", (p / "install-k1-credential").read_bytes(), 0o755))
|
||||||
|
files.append(("usr/lib/systemd/system/mission-core-k1.service", (p / "mission-core-k1.service").read_bytes(), 0o644))
|
||||||
|
files.append(("usr/share/polkit-1/rules.d/50-mission-core-k1.rules", (p / "50-mission-core-k1.rules").read_bytes(), 0o644))
|
||||||
|
k1_bundle = json.loads((p / "k1-bundle.json").read_text())
|
||||||
|
files.append(("usr/share/mission-core-node/k1/bundle.json", (p / "k1-bundle.json").read_bytes(), 0o644))
|
||||||
|
for item in k1_bundle["wheels"]:
|
||||||
|
data = (ROOT / "build/k1-wheels" / item["name"]).read_bytes()
|
||||||
|
if hashlib.sha256(data).hexdigest() != item["sha256"]:
|
||||||
|
raise ValueError("K1 bundle hash mismatch")
|
||||||
|
files.append(("usr/share/mission-core-node/k1/" + item["name"], data, 0o644))
|
||||||
|
repository = ROOT.parents[1]
|
||||||
|
# Reuse the admitted plugin runtime and the transport-neutral renderer.
|
||||||
|
# No separate Core web service is started on the board.
|
||||||
|
for path in (repository / "src/k1link").rglob("*"):
|
||||||
|
if path.is_file() and path.suffix in (".py", ".json"):
|
||||||
|
files.append(("usr/lib/mission-core-node/k1/src/k1link/" + str(path.relative_to(repository / "src/k1link")), path.read_bytes(), 0o644))
|
||||||
|
for relative in ("plugins/xgrids-k1/profile_loader.py", "plugins/xgrids-k1/plugin.manifest.json",
|
||||||
|
"config/observatory-equipment-models.json",
|
||||||
|
"config/observatory-recorded-capture-profiles.json",
|
||||||
|
"plugins/xgrids-k1/profiles/fw-3.0.2/local-network.v2.json"):
|
||||||
|
files.append(("usr/lib/mission-core-node/k1/" + relative, (repository / relative).read_bytes(), 0o644))
|
||||||
sdk = ROOT.parents[1] / "packages/plugin-sdk/python/missioncore_plugin_sdk"
|
sdk = ROOT.parents[1] / "packages/plugin-sdk/python/missioncore_plugin_sdk"
|
||||||
for path in sdk.rglob("*.py"):
|
for path in sdk.rglob("*.py"):
|
||||||
files.append(("usr/lib/mission-core-node/sdk/missioncore_plugin_sdk/" + str(path.relative_to(sdk)), path.read_bytes(), 0o644))
|
files.append(("usr/lib/mission-core-node/sdk/missioncore_plugin_sdk/" + str(path.relative_to(sdk)), path.read_bytes(), 0o644))
|
||||||
|
|||||||
@@ -1,13 +1,18 @@
|
|||||||
"""Engineering build input, never run on an operator board. Exact PyPI hashes only."""
|
"""Engineering build input, never run on an operator board. Exact PyPI hashes only."""
|
||||||
|
|
||||||
import hashlib
|
import hashlib
|
||||||
|
import argparse
|
||||||
import json
|
import json
|
||||||
|
import time
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from urllib.request import urlopen
|
from urllib.request import Request, urlopen
|
||||||
|
|
||||||
root = Path(__file__).resolve().parents[1]
|
root = Path(__file__).resolve().parents[1]
|
||||||
manifest = json.loads((root / "packaging/realsense-bundle.json").read_text())
|
parser = argparse.ArgumentParser()
|
||||||
output = root / "build/realsense-wheels"
|
parser.add_argument("--model", choices=("realsense", "k1"), default="realsense")
|
||||||
|
model = parser.parse_args().model
|
||||||
|
manifest = json.loads((root / f"packaging/{model}-bundle.json").read_text())
|
||||||
|
output = root / f"build/{model}-wheels"
|
||||||
output.mkdir(parents=True, exist_ok=True)
|
output.mkdir(parents=True, exist_ok=True)
|
||||||
for item in manifest["wheels"]:
|
for item in manifest["wheels"]:
|
||||||
target = output / item["name"]
|
target = output / item["name"]
|
||||||
@@ -23,8 +28,24 @@ for item in manifest["wheels"]:
|
|||||||
)
|
)
|
||||||
if not source["url"].startswith("https://files.pythonhosted.org/"):
|
if not source["url"].startswith("https://files.pythonhosted.org/"):
|
||||||
raise ValueError("Unexpected package origin")
|
raise ValueError("Unexpected package origin")
|
||||||
with urlopen(source["url"], timeout=120) as response:
|
partial = target.with_suffix(target.suffix + ".partial")
|
||||||
data = response.read(item["bytes"] + 1)
|
for attempt in range(4):
|
||||||
|
offset = partial.stat().st_size if partial.exists() else 0
|
||||||
|
request = Request(source["url"], headers={"Range": f"bytes={offset}-"} if offset else {})
|
||||||
|
try:
|
||||||
|
with urlopen(request, timeout=60) as response:
|
||||||
|
if offset and response.status != 206:
|
||||||
|
raise RuntimeError("Package server did not honor resume range")
|
||||||
|
with partial.open("ab" if offset else "wb") as stream:
|
||||||
|
while chunk := response.read(1024 * 1024):
|
||||||
|
stream.write(chunk)
|
||||||
|
break
|
||||||
|
except (OSError, TimeoutError):
|
||||||
|
if attempt == 3:
|
||||||
|
raise
|
||||||
|
time.sleep(2)
|
||||||
|
data = partial.read_bytes()
|
||||||
if len(data) != item["bytes"] or hashlib.sha256(data).hexdigest() != item["sha256"]:
|
if len(data) != item["bytes"] or hashlib.sha256(data).hexdigest() != item["sha256"]:
|
||||||
raise ValueError("Driver checksum mismatch")
|
raise ValueError("Driver checksum mismatch")
|
||||||
target.write_bytes(data)
|
partial.replace(target)
|
||||||
|
print(json.dumps({"model": model, "wheel": item["name"], "bytes": len(data)}), flush=True)
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
#!/usr/bin/python3 -I
|
||||||
|
"""Administrator-only import of the exact application key from protected stdin."""
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
if os.geteuid() != 0 or len(sys.argv) != 1:
|
||||||
|
raise SystemExit("Root stdin import required")
|
||||||
|
secret = bytearray(sys.stdin.buffer.read(1025).strip())
|
||||||
|
try:
|
||||||
|
if len(secret) != 36 or any(v < 33 or v > 126 for v in secret):
|
||||||
|
raise SystemExit("Credential does not match the reviewed K1 profile")
|
||||||
|
root = Path("/etc/credstore.encrypted")
|
||||||
|
root.mkdir(mode=0o700, exist_ok=True)
|
||||||
|
if root.is_symlink() or root.stat().st_uid != 0 or root.stat().st_mode & 0o022:
|
||||||
|
raise SystemExit("Unsafe credential store")
|
||||||
|
path = root / "k1-application"
|
||||||
|
if path.is_symlink() or path.exists():
|
||||||
|
raise SystemExit("K1 credential already installed; explicit rotation required")
|
||||||
|
with tempfile.TemporaryDirectory(prefix=".k1-", dir=root) as directory:
|
||||||
|
staged = Path(directory) / "encrypted"
|
||||||
|
completed = subprocess.run(
|
||||||
|
["/usr/bin/systemd-creds", "encrypt", "--name=k1-application", "--with-key=host", "-", str(staged)],
|
||||||
|
input=secret, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=30,
|
||||||
|
)
|
||||||
|
if completed.returncode:
|
||||||
|
raise SystemExit("K1 credential import failed")
|
||||||
|
staged.chmod(0o600)
|
||||||
|
with staged.open("rb") as stream:
|
||||||
|
os.fsync(stream.fileno())
|
||||||
|
# Atomic publication without overwriting a concurrently installed key.
|
||||||
|
os.link(staged, path)
|
||||||
|
finally:
|
||||||
|
secret[:] = b"\0" * len(secret)
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
@@ -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
|
||||||
@@ -8,6 +8,10 @@ case "$1" in
|
|||||||
if ! getent passwd mission-core-sensors >/dev/null; then
|
if ! getent passwd mission-core-sensors >/dev/null; then
|
||||||
adduser --system --group --home /var/lib/mission-core-sensors --no-create-home --disabled-login mission-core-sensors
|
adduser --system --group --home /var/lib/mission-core-sensors --no-create-home --disabled-login mission-core-sensors
|
||||||
fi
|
fi
|
||||||
|
if ! getent passwd mission-core-k1 >/dev/null; then
|
||||||
|
adduser --system --home /var/lib/mission-core-k1 --no-create-home --disabled-login --ingroup mission-core-node mission-core-k1
|
||||||
|
fi
|
||||||
|
/usr/bin/python3 -I /usr/lib/mission-core-node/k1_prepare.py
|
||||||
# Only bootstrap required to open the GUI. Operational configuration is a
|
# Only bootstrap required to open the GUI. Operational configuration is a
|
||||||
# versioned job started by «Настройка окружения → Сконфигурировать».
|
# versioned job started by «Настройка окружения → Сконфигурировать».
|
||||||
if [ -d /run/systemd/system ]; then
|
if [ -d /run/systemd/system ]; then
|
||||||
@@ -15,6 +19,8 @@ case "$1" in
|
|||||||
systemctl enable mission-core-node.service
|
systemctl enable mission-core-node.service
|
||||||
systemctl restart mission-core-node.service
|
systemctl restart mission-core-node.service
|
||||||
systemctl try-restart mission-core-realsense.service
|
systemctl try-restart mission-core-realsense.service
|
||||||
|
systemctl enable mission-core-k1.service
|
||||||
|
systemctl restart mission-core-k1.service
|
||||||
fi
|
fi
|
||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
|
|||||||
@@ -8,6 +8,12 @@ if [ "$1" = install ] || [ "$1" = upgrade ]; then
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
if [ -S /run/mission-core-k1/driver.sock ]; then
|
||||||
|
if ! /usr/bin/python3 -I -c 'import http.client,json,socket; c=http.client.HTTPConnection("k1",timeout=5); c.sock=socket.socket(socket.AF_UNIX); c.sock.settimeout(5); c.sock.connect("/run/mission-core-k1/driver.sock"); c.request("GET","/prepare-safe"); r=c.getresponse(); assert r.status==200 and json.load(r).get("safe") is True'; then
|
||||||
|
echo "Mission Core Node: завершите подключение или запись K1 перед обновлением." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
mc_node_device_job=$(systemctl show --property=ActiveState --value mission-core-node-realsense-prepare.service 2>/dev/null || true)
|
mc_node_device_job=$(systemctl show --property=ActiveState --value mission-core-node-realsense-prepare.service 2>/dev/null || true)
|
||||||
case "$mc_node_device_job" in
|
case "$mc_node_device_job" in
|
||||||
active|activating) echo "Mission Core Node: дождитесь завершения подготовки устройства." >&2; exit 1 ;;
|
active|activating) echo "Mission Core Node: дождитесь завершения подготовки устройства." >&2; exit 1 ;;
|
||||||
@@ -20,6 +26,11 @@ if [ "$1" = install ] || [ "$1" = upgrade ]; then
|
|||||||
exit 1
|
exit 1
|
||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
|
# End the admitted idle worker before dpkg replaces its Python modules.
|
||||||
|
# New UI commands now fail unavailable instead of racing the package copy.
|
||||||
|
if [ -f /usr/lib/systemd/system/mission-core-k1.service ]; then
|
||||||
|
systemctl stop mission-core-k1.service
|
||||||
|
fi
|
||||||
fi
|
fi
|
||||||
. /etc/os-release
|
. /etc/os-release
|
||||||
if [ "${ID:-}" != ubuntu ] || [ "${VERSION_ID:-}" != 24.04 ]; then
|
if [ "${ID:-}" != ubuntu ] || [ "${VERSION_ID:-}" != 24.04 ]; then
|
||||||
|
|||||||
@@ -7,6 +7,12 @@ if [ -d /run/systemd/system ]; then
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
if [ -S /run/mission-core-k1/driver.sock ]; then
|
||||||
|
if ! /usr/bin/python3 -I -c 'import http.client,json,socket; c=http.client.HTTPConnection("k1",timeout=5); c.sock=socket.socket(socket.AF_UNIX); c.sock.settimeout(5); c.sock.connect("/run/mission-core-k1/driver.sock"); c.request("GET","/prepare-safe"); r=c.getresponse(); assert r.status==200 and json.load(r).get("safe") is True'; then
|
||||||
|
echo "Mission Core Node: завершите подключение или запись K1 перед обновлением." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
mc_node_device_job=$(systemctl show --property=ActiveState --value mission-core-node-realsense-prepare.service 2>/dev/null || true)
|
mc_node_device_job=$(systemctl show --property=ActiveState --value mission-core-node-realsense-prepare.service 2>/dev/null || true)
|
||||||
case "$mc_node_device_job" in
|
case "$mc_node_device_job" in
|
||||||
active|activating) echo "Mission Core Node: дождитесь завершения подготовки устройства." >&2; exit 1 ;;
|
active|activating) echo "Mission Core Node: дождитесь завершения подготовки устройства." >&2; exit 1 ;;
|
||||||
@@ -40,6 +46,8 @@ case "$1" in
|
|||||||
/usr/sbin/sshd -t
|
/usr/sbin/sshd -t
|
||||||
systemctl try-reload-or-restart ssh.service
|
systemctl try-reload-or-restart ssh.service
|
||||||
fi
|
fi
|
||||||
|
systemctl stop mission-core-k1.service
|
||||||
|
systemctl disable mission-core-k1.service || true
|
||||||
systemctl stop mission-core-realsense.service
|
systemctl stop mission-core-realsense.service
|
||||||
systemctl disable mission-core-realsense.service || true
|
systemctl disable mission-core-realsense.service || true
|
||||||
systemctl stop mission-core-node.service
|
systemctl stop mission-core-node.service
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
"""Resolve Linux wheel inputs from the frozen monorepo lock, without downloading."""
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import subprocess
|
||||||
|
import tomllib
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from packaging.markers import default_environment
|
||||||
|
from packaging.requirements import Requirement
|
||||||
|
from packaging.tags import compatible_tags, cpython_tags
|
||||||
|
from packaging.utils import canonicalize_name, parse_wheel_filename
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
REPOSITORY = ROOT.parents[1]
|
||||||
|
|
||||||
|
|
||||||
|
def resolve():
|
||||||
|
requirements = ROOT / "build/k1-requirements.txt"
|
||||||
|
requirements.parent.mkdir(exist_ok=True)
|
||||||
|
subprocess.run(["uv", "export", "--frozen", "--extra", "node-device-media", "--no-dev",
|
||||||
|
"--no-emit-project", "--no-emit-package", "missioncore-plugin-sdk", "--no-hashes",
|
||||||
|
"--output-file", str(requirements)], cwd=REPOSITORY, check=True, stdout=subprocess.DEVNULL)
|
||||||
|
environment = default_environment()
|
||||||
|
environment.update(sys_platform="linux", platform_system="Linux", platform_machine="x86_64",
|
||||||
|
python_version="3.12", python_full_version="3.12.3", implementation_name="cpython",
|
||||||
|
platform_python_implementation="CPython")
|
||||||
|
platforms = [f"manylinux_2_{n}_x86_64" for n in range(39, 16, -1)] + ["manylinux2014_x86_64", "linux_x86_64"]
|
||||||
|
tags = list(cpython_tags((3, 12), platforms=platforms)) + list(compatible_tags((3, 12), interpreter="cp312", platforms=platforms))
|
||||||
|
ranks = {tag: i for i, tag in enumerate(tags)}
|
||||||
|
lock_data = (REPOSITORY / "uv.lock").read_bytes()
|
||||||
|
lock = tomllib.loads(lock_data.decode())
|
||||||
|
items = []
|
||||||
|
for line in requirements.read_text().splitlines():
|
||||||
|
if not line or line.lstrip().startswith("#"):
|
||||||
|
continue
|
||||||
|
requirement = Requirement(line)
|
||||||
|
if requirement.marker and not requirement.marker.evaluate(environment):
|
||||||
|
continue
|
||||||
|
name = canonicalize_name(requirement.name)
|
||||||
|
package = next(v for v in lock["package"] if canonicalize_name(v["name"]) == name and v["version"] in requirement.specifier)
|
||||||
|
candidates = []
|
||||||
|
for wheel in package.get("wheels", []):
|
||||||
|
filename = wheel["url"].split("/")[-1]
|
||||||
|
_, _, _, wheel_tags = parse_wheel_filename(filename)
|
||||||
|
matches = [ranks[tag] for tag in wheel_tags if tag in ranks]
|
||||||
|
if matches:
|
||||||
|
candidates.append((min(matches), filename, wheel))
|
||||||
|
if not candidates:
|
||||||
|
raise RuntimeError("No reviewed Linux wheel: " + name)
|
||||||
|
_, filename, wheel = min(candidates)
|
||||||
|
items.append({"name": filename, "sha256": wheel["hash"].removeprefix("sha256:"), "bytes": wheel["size"]})
|
||||||
|
revision = hashlib.sha256(json.dumps(items, sort_keys=True).encode()).hexdigest()[:24]
|
||||||
|
manifest = {"schema": "missioncore.node.driver-bundle/v1", "model_id": "xgrids.k1", "revision": revision,
|
||||||
|
"python": "3.12", "platform": "linux-amd64", "lock_sha256": hashlib.sha256(lock_data).hexdigest(), "wheels": items}
|
||||||
|
(ROOT / "packaging/k1-bundle.json").write_text(json.dumps(manifest, indent=2) + "\n")
|
||||||
|
print(json.dumps({"wheels": len(items), "bytes": sum(v["bytes"] for v in items), "revision": revision}))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
resolve()
|
||||||
Generated
+19
-1
@@ -11,6 +11,7 @@
|
|||||||
"@nodedc/tokens": "file:../../../../NODEDC_DESIGN_GUIDELINE/packages/tokens",
|
"@nodedc/tokens": "file:../../../../NODEDC_DESIGN_GUIDELINE/packages/tokens",
|
||||||
"@nodedc/ui-core": "file:../../../../NODEDC_DESIGN_GUIDELINE/packages/ui-core",
|
"@nodedc/ui-core": "file:../../../../NODEDC_DESIGN_GUIDELINE/packages/ui-core",
|
||||||
"@nodedc/ui-react": "file:../../../../NODEDC_DESIGN_GUIDELINE/packages/ui-react",
|
"@nodedc/ui-react": "file:../../../../NODEDC_DESIGN_GUIDELINE/packages/ui-react",
|
||||||
|
"@rerun-io/web-viewer": "0.36.3",
|
||||||
"react": "19.1.0",
|
"react": "19.1.0",
|
||||||
"react-dom": "19.1.0"
|
"react-dom": "19.1.0"
|
||||||
},
|
},
|
||||||
@@ -18,7 +19,8 @@
|
|||||||
"@types/react": "^19.1.0",
|
"@types/react": "^19.1.0",
|
||||||
"@types/react-dom": "^19.1.0",
|
"@types/react-dom": "^19.1.0",
|
||||||
"typescript": "^5.8.3",
|
"typescript": "^5.8.3",
|
||||||
"vite": "^7.0.0"
|
"vite": "^7.0.0",
|
||||||
|
"vite-plugin-wasm": "^3.6.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"../../../../NODEDC_DESIGN_GUIDELINE/packages/tokens": {
|
"../../../../NODEDC_DESIGN_GUIDELINE/packages/tokens": {
|
||||||
@@ -524,6 +526,12 @@
|
|||||||
"resolved": "../../../../NODEDC_DESIGN_GUIDELINE/packages/ui-react",
|
"resolved": "../../../../NODEDC_DESIGN_GUIDELINE/packages/ui-react",
|
||||||
"link": true
|
"link": true
|
||||||
},
|
},
|
||||||
|
"node_modules/@rerun-io/web-viewer": {
|
||||||
|
"version": "0.36.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@rerun-io/web-viewer/-/web-viewer-0.36.3.tgz",
|
||||||
|
"integrity": "sha512-LMGnsxRmY5UwiGras2dZrMnEYkow5Xr4v+1hAUSspXWPPiilMqoz9G77jo8Ps/deAaX81TnOq123DFO8iX/Ulw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@rollup/rollup-android-arm-eabi": {
|
"node_modules/@rollup/rollup-android-arm-eabi": {
|
||||||
"version": "4.63.1",
|
"version": "4.63.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.63.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.63.1.tgz",
|
||||||
@@ -1239,6 +1247,16 @@
|
|||||||
"optional": true
|
"optional": true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"node_modules/vite-plugin-wasm": {
|
||||||
|
"version": "3.6.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/vite-plugin-wasm/-/vite-plugin-wasm-3.6.0.tgz",
|
||||||
|
"integrity": "sha512-mL/QPziiIA4RAA6DkaZZzOstdwbW5jO4Vz7Zenj0wieKWBlNvIvX5L5ljum9lcUX0ShNfBgCNLKTjNkRVVqcsw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"vite": "^2 || ^3 || ^4 || ^5 || ^6 || ^7 || ^8"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
"build": "tsc --noEmit && vite build"
|
"build": "tsc --noEmit && vite build"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@rerun-io/web-viewer": "0.36.3",
|
||||||
"@nodedc/ui-react": "file:../../../../NODEDC_DESIGN_GUIDELINE/packages/ui-react",
|
"@nodedc/ui-react": "file:../../../../NODEDC_DESIGN_GUIDELINE/packages/ui-react",
|
||||||
"@nodedc/ui-core": "file:../../../../NODEDC_DESIGN_GUIDELINE/packages/ui-core",
|
"@nodedc/ui-core": "file:../../../../NODEDC_DESIGN_GUIDELINE/packages/ui-core",
|
||||||
"@nodedc/tokens": "file:../../../../NODEDC_DESIGN_GUIDELINE/packages/tokens",
|
"@nodedc/tokens": "file:../../../../NODEDC_DESIGN_GUIDELINE/packages/tokens",
|
||||||
@@ -16,6 +17,7 @@
|
|||||||
"react-dom": "19.1.0"
|
"react-dom": "19.1.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"vite-plugin-wasm": "^3.6.0",
|
||||||
"@types/react": "^19.1.0",
|
"@types/react": "^19.1.0",
|
||||||
"@types/react-dom": "^19.1.0",
|
"@types/react-dom": "^19.1.0",
|
||||||
"typescript": "^5.8.3",
|
"typescript": "^5.8.3",
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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 {SensorWorkspace} from '../../../../packages/sensor-ui/src/SensorWorkspace';
|
||||||
import type {SensorTransport} from '../../../../packages/sensor-ui/src/contracts';
|
import type {SensorTransport} from '../../../../packages/sensor-ui/src/contracts';
|
||||||
import {request} from './api';
|
import {request} from './api';
|
||||||
const transport:SensorTransport={subscribe:(receive,unavailable)=>{const events=new EventSource('/api/devices/events');events.onmessage=e=>{try{receive(JSON.parse(e.data));}catch{unavailable();}};events.onerror=unavailable;return()=>events.close();},inventory:()=>request('/api/devices'),submit:value=>request('/api/devices/operations','POST',value),operation:id=>request('/api/devices/operations/'+encodeURIComponent(id))};
|
const transport:SensorTransport={enrollment:{state:()=>request("/api/devices/enrollment"),submit:value=>request("/api/devices/enrollment/operations","POST",value),operation:id=>request("/api/devices/enrollment/operations/"+encodeURIComponent(id))},subscribe:(receive,unavailable)=>{const events=new EventSource('/api/devices/events');events.onmessage=e=>{try{receive(JSON.parse(e.data));}catch{unavailable();}};events.onerror=unavailable;return()=>events.close();},inventory:()=>request('/api/devices'),submit:value=>request('/api/devices/operations','POST',value),operation:id=>request('/api/devices/operations/'+encodeURIComponent(id))};
|
||||||
export function NodeSensors(){return <SensorWorkspace transport={transport}/>;}
|
export function NodeSensors(){return <SensorWorkspace contributions={[xgridsK1SensorUi]} EnrollmentView={K1EnrollmentWindow} createRerunHost={createIsolatedRerunHost} transport={transport}/>;}
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
import "../../../control-station/src/components/rerun/isolatedRerunEntry";
|
||||||
@@ -22,6 +22,9 @@
|
|||||||
],
|
],
|
||||||
"@nodedc/ui-react": [
|
"@nodedc/ui-react": [
|
||||||
"node_modules/@nodedc/ui-react/dist/index.d.ts"
|
"node_modules/@nodedc/ui-react/dist/index.d.ts"
|
||||||
|
],
|
||||||
|
"@mission-core/sensor-sdk": [
|
||||||
|
"../../../packages/sensor-ui/src/pluginSdk.ts"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,9 +1,17 @@
|
|||||||
import { defineConfig } from "vite";
|
import { defineConfig } from "vite";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import wasm from "vite-plugin-wasm";
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
|
plugins: [wasm()],
|
||||||
// Shared sensor TSX lives outside this app tsconfig; use the same JSX runtime.
|
// Shared sensor TSX lives outside this app tsconfig; use the same JSX runtime.
|
||||||
esbuild: { jsx: "automatic" },
|
esbuild: { jsx: "automatic" },
|
||||||
// Design Guideline packages are linked during development. Their own React
|
// Design Guideline packages are linked during development. Their own React
|
||||||
// must never become a second hook dispatcher in the portable production bundle.
|
// must never become a second hook dispatcher in the portable production bundle.
|
||||||
resolve: { dedupe: ["react", "react-dom", "@nodedc/ui-react"] },
|
resolve: { 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)),
|
||||||
|
} } },
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -171,6 +171,25 @@ mode. It never fragments or retries the payload automatically.
|
|||||||
|
|
||||||
## Expected transition and evidence of acceptance
|
## Expected transition and evidence of acceptance
|
||||||
|
|
||||||
|
### Firmware station error replies
|
||||||
|
|
||||||
|
Offline review of the exact 3.0.2 `lixel_nman` callback found that station
|
||||||
|
`wifi_connect` returns are passed directly to the 7f01 GATT write result:
|
||||||
|
4 denotes its SSID-not-found paths and 6 denotes its credentials-required path.
|
||||||
|
Generic Bluetooth libraries label the same values INVALID_PDU and
|
||||||
|
REQUEST_NOT_SUPPORTED. Preserve those raw values but do not discard the
|
||||||
|
reviewed device interpretation. The evidence and disassembly addresses are in
|
||||||
|
[the callback audit](audits/2026-09-06-k1-station-reply-semantics.md).
|
||||||
|
|
||||||
|
Apply this interpretation only to the selected exact firmware station profile
|
||||||
|
and its annotated 99-byte write-with-response failure. It does not apply to
|
||||||
|
Quick Connect, a failed baseline read or arbitrary ATT errors. It grants no
|
||||||
|
retry, network-state confirmation or ledger resolution. The firmware performs
|
||||||
|
Wi-Fi work before completing this callback; a delayed reply alone is not proof
|
||||||
|
that the BLE connection request was the slow stage.
|
||||||
|
|
||||||
|
### Acceptance observations
|
||||||
|
|
||||||
A completed GATT write only proves transport completion. It does not prove that
|
A completed GATT write only proves transport completion. It does not prove that
|
||||||
the K1 joined Wi-Fi or began beaconing. The application polls `7f02`; the
|
the K1 joined Wi-Fi or began beaconing. The application polls `7f02`; the
|
||||||
observed response frame contains a fixed-width text slot, an address slot, a
|
observed response frame contains a fixed-width text slot, an address slot, a
|
||||||
|
|||||||
@@ -0,0 +1,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 |
|
||||||
|
| 21–22.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 |
|
||||||
|
| 05–06.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 01–10 перед START; ordinal 11 — START; 12 — свежий initialized SCANNING; 13–14 — завершающее read-only обновление. Подготовка включает согласованную синхронизацию времени и не является целиком read-only. Recovery использует inspection-only путь, который её не повторяет.
|
||||||
|
|
||||||
|
Критическая граница сна: после ordinal 12 physical resolve и recovery checkpoint сохраняются до 13–14; 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 и 13–14 | Сохранить 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 4–34 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 к Wi‑Fi”. 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.
|
||||||
@@ -52,6 +52,11 @@ compatibility profile and adapter.
|
|||||||
|
|
||||||
Current host implementation references:
|
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
|
- `apps/control-station/src/core/device-plugins/frontendSdk.ts` — current
|
||||||
public TypeScript/React host surface for statically reviewed UI contributions;
|
public TypeScript/React host surface for statically reviewed UI contributions;
|
||||||
- `plugins/xgrids-k1/frontend/` — first physically plugin-owned consumer of
|
- `plugins/xgrids-k1/frontend/` — first physically plugin-owned consumer of
|
||||||
|
|||||||
@@ -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 {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 {perform,type Sensor,type SensorInventory,type SensorTransport} from './contracts';
|
||||||
import {SensorDetail} from './SensorDetail';
|
import {SensorDetail} from './SensorDetail';
|
||||||
import {sensorStatus} from './sensorStatus';
|
import {sensorStatus} from './sensorStatus';
|
||||||
|
import {sensorContribution,type SensorUiContribution,type SensorEnrollmentProps} from './extensions';
|
||||||
|
import type {RerunHostFactory} from './rerunHost';
|
||||||
import './sensors.css';
|
import './sensors.css';
|
||||||
export function SensorWorkspace({transport,enabled=true,onDetailChange}:{transport:SensorTransport;enabled?:boolean;onDetailChange?:(open:boolean)=>void}){
|
export function SensorWorkspace({transport,enabled=true,onDetailChange,createRerunHost,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 [inventory,setInventory]=useState<SensorInventory|null>(null);const [selected,setSelected]=useState<string|null>(null);const [editing,setEditing]=useState<Sensor|null>(null);const [name,setName]=useState('');const [localBusy,setBusy]=useState<string|null>(null);const [error,setError]=useState('');const [fresh,setFresh]=useState(false);
|
||||||
const failure=useCallback((e:unknown)=>{setError(e===null?'':e instanceof Error?e.message:'Не удалось выполнить действие устройства.');},[]);
|
const failure=useCallback((e:unknown)=>{setError(e===null?'':e instanceof Error?e.message:'Не удалось выполнить действие устройства.');},[]);
|
||||||
const refresh=useCallback(async()=>{if(!enabled){setFresh(false);return;}try{const value=await transport.inventory();setInventory(value);setFresh(value.fresh!==false);}catch(e){setFresh(false);failure(e);}},[transport,enabled,failure]);
|
const refresh=useCallback(async()=>{if(!enabled){setFresh(false);return;}try{const value=await transport.inventory();setInventory(value);setFresh(value.fresh!==false);}catch(e){setFresh(false);failure(e);}},[transport,enabled,failure]);
|
||||||
@@ -19,20 +22,22 @@ export function SensorWorkspace({transport,enabled=true,onDetailChange}:{transpo
|
|||||||
},[transport,enabled,refresh]);
|
},[transport,enabled,refresh]);
|
||||||
async function action(device:Sensor,action:string,parameters:Record<string,unknown>={}){if(localBusy)return;setError('');setBusy(device.id);try{await perform(transport,device,action,parameters);await refresh();setEditing(null);}catch(e){failure(e);}finally{setBusy(null);}}
|
async function action(device:Sensor,action:string,parameters:Record<string,unknown>={}){if(localBusy)return;setError('');setBusy(device.id);try{await perform(transport,device,action,parameters);await refresh();setEditing(null);}catch(e){failure(e);}finally{setBusy(null);}}
|
||||||
const device=inventory?.items.find(v=>v.id===selected);
|
const device=inventory?.items.find(v=>v.id===selected);
|
||||||
const connected=inventory?.items.filter(v=>v.online)??[];
|
const connected=inventory?.items.filter(v=>v.online||sensorContribution(contributions,v)?.retainOffline)??[];
|
||||||
const editingCurrent=inventory?.items.find(v=>v.id===editing?.id);
|
const editingCurrent=inventory?.items.find(v=>v.id===editing?.id);
|
||||||
useEffect(()=>{onDetailChange?.(!!device);},[!!device,onDetailChange]);
|
useEffect(()=>{onDetailChange?.(!!device);},[!!device,onDetailChange]);
|
||||||
return <div className="sensor-workspace">{device?<SensorDetail enabled={enabled&&fresh} device={device} transport={transport} back={()=>setSelected(null)} refresh={refresh} failure={failure}/>:<>
|
const Detail=device?(sensorContribution(contributions,device)?.Detail??(device.kind?null:SensorDetail)):null;
|
||||||
<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>
|
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>:<>
|
||||||
{!inventory?<ActivityIndicator label="Получаем устройства БК"/>:connected.length===0?<SettingsCard title="Поддерживаемые устройства не обнаружены" description="Подключите камеру к бортовому компьютеру."/>:<ResourceList aria-label="Устройства БК">{connected.map(item=>{
|
<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 operation=inventory.operations?.find(v=>v.device_id===item.id&&v.state==='running');const busy=!!operation||localBusy===item.id;
|
||||||
const configured=item.configured??item.snapshot.enrollment==='enrolled';
|
const configured=item.configured??item.snapshot.enrollment==='enrolled';
|
||||||
const prep=operation?.action_id==='prepare'&&inventory.preparation&&(inventory.preparation.started_at*1000>=Date.parse(operation.requested_at)-1000)?inventory.preparation:undefined;
|
const prep=operation?.action_id==='prepare'&&inventory.preparation&&(inventory.preparation.started_at*1000>=Date.parse(operation.requested_at)-1000)?inventory.preparation:undefined;
|
||||||
const status=sensorStatus(item,enabled&&fresh);const label=busy?'Подготовка или команда выполняется':status.label;
|
const status=sensorStatus(item,enabled&&fresh);const label=busy?'Подготовка или команда выполняется':status.label;
|
||||||
return <li key={item.id}><ResourceRow icon={<Icon name="camera"/>} title={item.name} description={item.model} metadata={<span>USB {item.usb}</span>} statusPlacement={configured?'leading':'trailing'} aria-busy={busy} progress={busy?{label, value:prep?.state==='running'?prep.steps.filter(s=>s.state==='complete').length/(prep.steps.length+1):prep?.state==='complete'?5/6:undefined,valueText:prep?.steps.find(s=>s.state==='running')?.label??'Проверка кадров камеры'}:undefined} status={<StatusBadge variant={configured&&item.online?'indicator':'badge'} tone={status.tone} aria-label={label} title={label}>{configured&&item.online?null:label}</StatusBadge>} actions={<>{!configured&&<IconButton label={`Подготовить и проверить: ${item.name}`} disabled={!enabled||!fresh||busy||!item.online||item.snapshot.acquisition==='streaming'||item.snapshot.acquisition==='stopping'} onClick={()=>void action(item,'prepare')}><Icon name="download"/></IconButton>}<IconButton label={`Настройки: ${item.name}`} disabled={!enabled||!fresh||busy} onClick={()=>{setEditing(item);setName(item.name);}}><Icon name="settings"/></IconButton><IconButton label={`Просмотр: ${item.name}`} disabled={!item.prepared||busy} onClick={()=>setSelected(item.id)}><Icon name="eye"/></IconButton></>}/></li>;})}</ResourceList>}
|
return <li key={item.id}><ResourceRow icon={<Icon name={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>}
|
{(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('')}/>
|
<ToastStack items={error?[{id:'sensor-error',tone:'error',title:error,durationMs:null}]:[]} onDismiss={()=>setError('')}/>
|
||||||
</div>;
|
</div>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
export interface SensorProfile { id: string; sensor: number; stream: string; index: number; format: string; fps: number; width?: number; height?: number }
|
export interface SensorProfile { id: string; sensor: number; stream: string; index: number; format: string; fps: number; width?: number; height?: number }
|
||||||
export interface SensorOption { id: string; sensor: string; label: string; value: number; min: number; max: number; step: number; read_only: boolean }
|
export interface SensorOption { id: string; sensor: string; label: string; value: number; min: number; max: number; step: number; read_only: boolean }
|
||||||
export interface Sensor {
|
export interface Sensor {
|
||||||
|
kind?: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;
|
id: string; name: string; model: string; prepared: boolean; configured?: boolean; verified: boolean; online: boolean; usb: string; firmware?: string; playback_id?: string | null;
|
||||||
snapshot: {context: {session_id: string; device: {device_id: string}; execution: {node_id: string}}; acquisition: string; enrollment: string; message?: string};
|
snapshot: {context: {session_id: string; device: {device_id: string}; execution: {node_id: string}}; acquisition: string; enrollment: string; message?: string};
|
||||||
profiles?: SensorProfile[]; defaults?: string[]; options?: SensorOption[]; layers: string[];
|
profiles?: SensorProfile[]; defaults?: string[]; options?: SensorOption[]; layers: string[];
|
||||||
@@ -18,19 +20,20 @@ export interface SensorCommand {
|
|||||||
}
|
}
|
||||||
export interface SensorOperation {state:string;error?:string;result?:unknown}
|
export interface SensorOperation {state:string;error?:string;result?:unknown}
|
||||||
export interface SensorTransport {
|
export interface SensorTransport {
|
||||||
|
enrollment?: import('./enrollment').EnrollmentTransport;
|
||||||
inventory: () => Promise<SensorInventory>;
|
inventory: () => Promise<SensorInventory>;
|
||||||
subscribe?: (receive:(value:SensorInventory)=>void, unavailable:()=>void) => (()=>void);
|
subscribe?: (receive:(value:SensorInventory)=>void, unavailable:()=>void) => (()=>void);
|
||||||
submit: (value:SensorCommand) => Promise<SensorOperation>;
|
submit: (value:SensorCommand) => Promise<SensorOperation>;
|
||||||
operation: (id:string) => 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();
|
const id='op_'+crypto.randomUUID().replaceAll('-','');const now=Date.now();
|
||||||
return {api_version:'missioncore.nodedc/plugin-sdk/v0alpha2',kind:'OperationRequest',operation_id:id,idempotency_key:id,
|
return {api_version:'missioncore.nodedc/plugin-sdk/v0alpha2',kind:'OperationRequest',operation_id:id,idempotency_key:id,
|
||||||
session:{session_id:device.snapshot.context.session_id,device_id:device.id},action_id:action,
|
session:{session_id:device.snapshot.context.session_id,device_id:device.id},action_id:action,
|
||||||
requested_at:new Date(now).toISOString(),deadline_at:new Date(now+(action==='prepare'?350000:60000)).toISOString(),parameters};
|
requested_at:new Date(now).toISOString(),deadline_at:new Date(now+(action==='prepare'?350000:timeoutMs)).toISOString(),parameters};
|
||||||
}
|
}
|
||||||
export async function perform<T>(transport:SensorTransport,device:Sensor,action:string,parameters:Record<string,unknown>={}):Promise<T>{
|
export async function perform<T>(transport:SensorTransport,device:Sensor,action:string,parameters:Record<string,unknown>={},timeoutMs=60000):Promise<T>{
|
||||||
const request=command(device,action,parameters);let value=await transport.submit(request);
|
const request=command(device,action,parameters,timeoutMs);let value=await transport.submit(request);
|
||||||
const deadline=Date.parse(request.deadline_at)+10000;
|
const deadline=Date.parse(request.deadline_at)+10000;
|
||||||
while (value.state==='running'||value.state==='queued') {
|
while (value.state==='running'||value.state==='queued') {
|
||||||
if(Date.now()>deadline) throw new Error('Подтверждение пока не получено. Обновите состояние устройства.');
|
if(Date.now()>deadline) throw new Error('Подтверждение пока не получено. Обновите состояние устройства.');
|
||||||
|
|||||||
@@ -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>;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
export * from './contracts';
|
||||||
|
export type * from './enrollment';
|
||||||
|
export type * from './rerunHost';
|
||||||
|
export type * from './extensions';
|
||||||
@@ -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};
|
||||||
@@ -9,3 +9,7 @@
|
|||||||
.sensor-viewer-expanded {position:fixed;inset:var(--nodedc-space-4);z-index:var(--nodedc-layer-overlay);background:var(--nodedc-canvas);overflow:auto}
|
.sensor-viewer-expanded {position:fixed;inset:var(--nodedc-space-4);z-index:var(--nodedc-layer-overlay);background:var(--nodedc-canvas);overflow:auto}
|
||||||
.sensor-viewer-expanded .sensor-media {height:calc(100vh - 190px)}
|
.sensor-viewer-expanded .sensor-media {height:calc(100vh - 190px)}
|
||||||
.sensor-record {display:flex;flex-wrap:wrap;gap:var(--nodedc-space-4);padding-block:var(--nodedc-space-3);font-size:var(--nodedc-font-size-sm)}
|
.sensor-record {display:flex;flex-wrap:wrap;gap:var(--nodedc-space-4);padding-block:var(--nodedc-space-3);font-size:var(--nodedc-font-size-sm)}
|
||||||
|
.sensor-live-layout {display:grid;grid-template-columns:repeat(auto-fit,minmax(280px,1fr));gap:var(--nodedc-space-4)}
|
||||||
|
.sensor-live-spatial {min-height:420px;overflow:hidden}
|
||||||
|
.sensor-live-spatial iframe {display:block;width:100%;height:100%;min-height:420px;border:0}
|
||||||
|
.sensor-viewer-expanded .sensor-live-spatial,.sensor-viewer-expanded .sensor-live-spatial iframe {min-height:calc(100vh - 230px)}
|
||||||
|
|||||||
@@ -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
|
runtime still owns one active model/session at a time. Concurrent model/session
|
||||||
routing remains a later supervisor milestone.
|
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 plugin owns:
|
||||||
|
|
||||||
- the exact firmware/topology compatibility profiles under
|
- the exact firmware/topology compatibility profiles under
|
||||||
|
|||||||
@@ -275,6 +275,11 @@ export function XgridsK1Connection({ model, host }: DevicePluginConnectionProps)
|
|||||||
: sourceRuntimeBusy || preparedAcquisition
|
: sourceRuntimeBusy || preparedAcquisition
|
||||||
? "warning"
|
? "warning"
|
||||||
: "neutral";
|
: "neutral";
|
||||||
|
const connectionAttemptFailed = !sourceRuntimeBusy && !preparedAcquisition
|
||||||
|
&& connectionTopology?.status !== "active"
|
||||||
|
&& state?.connection_attempt?.connection_mode === effectiveDesiredConnectionMode
|
||||||
|
&& ["failed", "cancelled", "timed_out", "interrupted", "operator_action_required"]
|
||||||
|
.includes(state.connection_attempt.status);
|
||||||
const connectionControlBootstrapSettling =
|
const connectionControlBootstrapSettling =
|
||||||
isConnectionControlBootstrapSettling(state);
|
isConnectionControlBootstrapSettling(state);
|
||||||
const connectionPhaseLabel = livePreparationPending
|
const connectionPhaseLabel = livePreparationPending
|
||||||
@@ -293,6 +298,8 @@ export function XgridsK1Connection({ model, host }: DevicePluginConnectionProps)
|
|||||||
? savedBridgeNetworkSetupRequired
|
? savedBridgeNetworkSetupRequired
|
||||||
? "Нужна настройка сети"
|
? "Нужна настройка сети"
|
||||||
: "Требуется действие"
|
: "Требуется действие"
|
||||||
|
: connectionAttemptFailed
|
||||||
|
? "Подключение не завершено"
|
||||||
: projectedPhase === "error"
|
: projectedPhase === "error"
|
||||||
? connectionPhaseFallbackLabel(projectedPhase)
|
? connectionPhaseFallbackLabel(projectedPhase)
|
||||||
: connectionTopology?.status === "active"
|
: connectionTopology?.status === "active"
|
||||||
@@ -316,6 +323,8 @@ export function XgridsK1Connection({ model, host }: DevicePluginConnectionProps)
|
|||||||
? "accent"
|
? "accent"
|
||||||
: physicalRecoveryRequired
|
: physicalRecoveryRequired
|
||||||
? "warning"
|
? "warning"
|
||||||
|
: connectionAttemptFailed
|
||||||
|
? "warning"
|
||||||
: projectedPhase === "error"
|
: projectedPhase === "error"
|
||||||
? phaseTone(projectedPhase)
|
? phaseTone(projectedPhase)
|
||||||
: connectionTopology?.status === "active"
|
: connectionTopology?.status === "active"
|
||||||
@@ -336,6 +345,8 @@ export function XgridsK1Connection({ model, host }: DevicePluginConnectionProps)
|
|||||||
: physicalRecoveryRequired
|
: physicalRecoveryRequired
|
||||||
? physicalRecoveryDetail
|
? physicalRecoveryDetail
|
||||||
?? "Безопасное восстановление прежнего K1 сейчас недоступно."
|
?? "Безопасное восстановление прежнего K1 сейчас недоступно."
|
||||||
|
: connectionAttemptFailed
|
||||||
|
? "Проверьте состояние K1 или начните новое подключение."
|
||||||
: !sourceRuntimeBusy && connectionTopology?.status === "configured-unverified"
|
: !sourceRuntimeBusy && connectionTopology?.status === "configured-unverified"
|
||||||
? "Начните новое подключение."
|
? "Начните новое подключение."
|
||||||
: !sourceRuntimeBusy && connectionTopology?.status === "active"
|
: !sourceRuntimeBusy && connectionTopology?.status === "active"
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import type {XgridsConnectionAttemptSummary} from './connectionAttempt';
|
||||||
import type { ViewerSettings } from "@mission-core/plugin-sdk";
|
import type { ViewerSettings } from "@mission-core/plugin-sdk";
|
||||||
import { xgridsK1Actions, xgridsK1Manifest } from "./manifest";
|
import { xgridsK1Actions, xgridsK1Manifest } from "./manifest";
|
||||||
|
|
||||||
@@ -826,28 +827,7 @@ export const XGRIDS_CONNECTION_ATTEMPT_PHASES = [
|
|||||||
export type XgridsConnectionAttemptPhase =
|
export type XgridsConnectionAttemptPhase =
|
||||||
typeof XGRIDS_CONNECTION_ATTEMPT_PHASES[number];
|
typeof XGRIDS_CONNECTION_ATTEMPT_PHASES[number];
|
||||||
|
|
||||||
export interface XgridsConnectionAttempt {
|
export interface XgridsConnectionAttempt extends XgridsConnectionAttemptSummary {
|
||||||
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;
|
|
||||||
timeline: XgridsOperationEvent[];
|
timeline: XgridsOperationEvent[];
|
||||||
diagnostic_bundle?: XgridsConnectionDiagnosticBundle;
|
diagnostic_bundle?: XgridsConnectionDiagnosticBundle;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,13 +3,14 @@ import { Button } from "@nodedc/ui-react";
|
|||||||
|
|
||||||
import type { XgridsConnectionAttempt } from "../api";
|
import type { XgridsConnectionAttempt } from "../api";
|
||||||
import { hostFailureDiagnosticPresentation } from "../hostDiagnosticPresentation";
|
import { hostFailureDiagnosticPresentation } from "../hostDiagnosticPresentation";
|
||||||
|
import { STATION_WIFI_FAILURE_MESSAGES } from "../networkFailurePresentation";
|
||||||
|
|
||||||
const connectionAttemptStageLabels: Record<string, string> = {
|
const connectionAttemptStageLabels: Record<string, string> = {
|
||||||
accepted: "Запрос принят",
|
accepted: "Запрос принят",
|
||||||
"scan-selection-admitted": "Результат выбран",
|
"scan-selection-admitted": "Результат выбран",
|
||||||
"host-wifi-profile-preflight": "Подготовка профиля Wi‑Fi",
|
"host-wifi-profile-preflight": "Подготовка профиля Wi‑Fi",
|
||||||
"device-ap-activation": "Подготовка локальной сети",
|
"device-ap-activation": "Подготовка локальной сети",
|
||||||
"ble-provisioning-write": "Передаются настройки сети",
|
"ble-provisioning-write": "Подключение K1 к Wi‑Fi",
|
||||||
"ble-write-dispatched": "Настройки переданы",
|
"ble-write-dispatched": "Настройки переданы",
|
||||||
"status-observing": "Ожидание ответа",
|
"status-observing": "Ожидание ответа",
|
||||||
"device-topology-applied": "Целевая сеть подтверждена",
|
"device-topology-applied": "Целевая сеть подтверждена",
|
||||||
@@ -62,6 +63,8 @@ export function attemptNextActionLabel(
|
|||||||
return "Проверить управление без изменения сети";
|
return "Проверить управление без изменения сети";
|
||||||
case "start-acquisition":
|
case "start-acquisition":
|
||||||
return "Готово к запуску приёма";
|
return "Готово к запуску приёма";
|
||||||
|
case "stop-acquisition":
|
||||||
|
return "Остановить текущий приём";
|
||||||
case "stop-local-receiver":
|
case "stop-local-receiver":
|
||||||
return "Завершить только локальный приём";
|
return "Завершить только локальный приём";
|
||||||
case "retire-unavailable-physical-target":
|
case "retire-unavailable-physical-target":
|
||||||
@@ -74,6 +77,9 @@ export function attemptNextActionLabel(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const publicConnectionErrorLabels: Readonly<Record<string, string>> = {
|
const publicConnectionErrorLabels: Readonly<Record<string, string>> = {
|
||||||
|
...STATION_WIFI_FAILURE_MESSAGES,
|
||||||
|
BleakGATTProtocolError:
|
||||||
|
"K1 ответил ошибкой Bluetooth. Подключение к Wi‑Fi не подтверждено; проверьте состояние устройства или начните новое подключение.",
|
||||||
"network-provision-discovery-generation-conflict":
|
"network-provision-discovery-generation-conflict":
|
||||||
"Результат Bluetooth-поиска устарел до отправки. Настройки устройства не изменялись; выполните новый поиск.",
|
"Результат Bluetooth-поиска устарел до отправки. Настройки устройства не изменялись; выполните новый поиск.",
|
||||||
"connection-mode-draft-revision-conflict":
|
"connection-mode-draft-revision-conflict":
|
||||||
@@ -121,6 +127,7 @@ export function K1OperatorError({
|
|||||||
onClear: () => void;
|
onClear: () => void;
|
||||||
}) {
|
}) {
|
||||||
const structured = hostFailureDiagnosticPresentation(diagnostic);
|
const structured = hostFailureDiagnosticPresentation(diagnostic);
|
||||||
|
const stationFailure = STATION_WIFI_FAILURE_MESSAGES[attempt?.public_error_code ?? ""];
|
||||||
const [diagnosticCopied, setDiagnosticCopied] = useState(false);
|
const [diagnosticCopied, setDiagnosticCopied] = useState(false);
|
||||||
const copyDiagnosticBundle = async () => {
|
const copyDiagnosticBundle = async () => {
|
||||||
if (!attempt?.diagnostic_bundle || !navigator.clipboard) return;
|
if (!attempt?.diagnostic_bundle || !navigator.clipboard) return;
|
||||||
@@ -137,7 +144,7 @@ export function K1OperatorError({
|
|||||||
>
|
>
|
||||||
<span className="error-banner__dot" aria-hidden="true" />
|
<span className="error-banner__dot" aria-hidden="true" />
|
||||||
<div className="error-banner__copy">
|
<div className="error-banner__copy">
|
||||||
<strong>{title}</strong>
|
<strong>{stationFailure ? "Не удалось подключить K1 к Wi‑Fi" : title}</strong>
|
||||||
<p>{publicConnectionErrorLabel(attempt, structured)}</p>
|
<p>{publicConnectionErrorLabel(attempt, structured)}</p>
|
||||||
{recoveryActions ? (
|
{recoveryActions ? (
|
||||||
<div className="error-banner__recovery-actions">
|
<div className="error-banner__recovery-actions">
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ import type {
|
|||||||
} from "../api";
|
} from "../api";
|
||||||
import { profileSelectionForConnectionMode } from "../compatibility";
|
import { profileSelectionForConnectionMode } from "../compatibility";
|
||||||
import {
|
import {
|
||||||
|
BLE_DISCOVERY_TIMEOUT_SECONDS,
|
||||||
DEFAULT_CONNECTION_MODE,
|
DEFAULT_CONNECTION_MODE,
|
||||||
connectionModeOptions,
|
connectionModeOptions,
|
||||||
type ConnectionMode,
|
type ConnectionMode,
|
||||||
@@ -66,10 +67,12 @@ import {
|
|||||||
type ReadOnlyConnectionObservationTarget,
|
type ReadOnlyConnectionObservationTarget,
|
||||||
} from "../lifecycle";
|
} from "../lifecycle";
|
||||||
import { finiteMetric } from "../presentation";
|
import { finiteMetric } from "../presentation";
|
||||||
|
import { STATION_WIFI_FAILURE_MESSAGES } from "../networkFailurePresentation";
|
||||||
import type { XgridsK1Controller } from "../runtimeContext";
|
import type { XgridsK1Controller } from "../runtimeContext";
|
||||||
import { K1OperatorError } from "./K1OperatorError";
|
import { K1OperatorError } from "./K1OperatorError";
|
||||||
import {
|
import {
|
||||||
connectionActionAuthoritySnapshot,
|
connectionActionAuthoritySnapshot,
|
||||||
|
networkProvisionFailureMessage,
|
||||||
type BleDiscoverySubmitResult,
|
type BleDiscoverySubmitResult,
|
||||||
type ConnectionActionAuthoritySnapshot,
|
type ConnectionActionAuthoritySnapshot,
|
||||||
} from "../useXgridsK1Runtime";
|
} from "../useXgridsK1Runtime";
|
||||||
@@ -1866,7 +1869,7 @@ export function K1ProvisioningPipeline({
|
|||||||
const connectionRecoveryRequired = connectionRecoveryIsRequired(
|
const connectionRecoveryRequired = connectionRecoveryIsRequired(
|
||||||
connectionRecoveryKey,
|
connectionRecoveryKey,
|
||||||
escapedConnectionRecoveryKey,
|
escapedConnectionRecoveryKey,
|
||||||
) && !(searchRequested && !searchDisplayActive);
|
) && (connectionAttemptFailed || !(searchRequested && !searchDisplayActive));
|
||||||
const presentedConnectionRecoveryRequired = !livePreparationPending && (
|
const presentedConnectionRecoveryRequired = !livePreparationPending && (
|
||||||
connectionRecoveryRequired || connectionRecoveryVerificationPending
|
connectionRecoveryRequired || connectionRecoveryVerificationPending
|
||||||
);
|
);
|
||||||
@@ -2089,7 +2092,7 @@ export function K1ProvisioningPipeline({
|
|||||||
const startedAt = Date.now();
|
const startedAt = Date.now();
|
||||||
const updateCountdown = () => {
|
const updateCountdown = () => {
|
||||||
const elapsedSeconds = Math.floor((Date.now() - startedAt) / 1_000);
|
const elapsedSeconds = Math.floor((Date.now() - startedAt) / 1_000);
|
||||||
setScanSecondsRemaining(Math.max(0, 6 - elapsedSeconds));
|
setScanSecondsRemaining(Math.max(0, BLE_DISCOVERY_TIMEOUT_SECONDS - elapsedSeconds));
|
||||||
};
|
};
|
||||||
updateCountdown();
|
updateCountdown();
|
||||||
const timer = window.setInterval(updateCountdown, 250);
|
const timer = window.setInterval(updateCountdown, 250);
|
||||||
@@ -2350,7 +2353,7 @@ export function K1ProvisioningPipeline({
|
|||||||
let scanResult: BleDiscoverySubmitResult | null = null;
|
let scanResult: BleDiscoverySubmitResult | null = null;
|
||||||
try {
|
try {
|
||||||
if (!runtimeActionIsCurrent(actionFence)) return;
|
if (!runtimeActionIsCurrent(actionFence)) return;
|
||||||
scanResult = await scanWithResult({ durationSeconds: 6 });
|
scanResult = await scanWithResult({ durationSeconds: BLE_DISCOVERY_TIMEOUT_SECONDS });
|
||||||
if (
|
if (
|
||||||
!localScenarioActionEpochIsCurrent(
|
!localScenarioActionEpochIsCurrent(
|
||||||
actionEpoch,
|
actionEpoch,
|
||||||
@@ -2573,12 +2576,12 @@ export function K1ProvisioningPipeline({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const freshStartAllowed = result.intentDisposition === "release";
|
const freshStartAllowed = result.intentDisposition === "release";
|
||||||
const failureMessage = freshStartAllowed
|
const failureMessage = networkProvisionFailureMessage(observedOperation) ?? (freshStartAllowed
|
||||||
&& provisioningFailureRequiresFreshCandidate(result.failureReasonCode)
|
&& provisioningFailureRequiresFreshCandidate(result.failureReasonCode)
|
||||||
? "Результат Bluetooth-поиска устарел до отправки. Настройки не отправлены; выполните явный повторный поиск."
|
? "Результат Bluetooth-поиска устарел до отправки. Настройки не отправлены; выполните явный повторный поиск."
|
||||||
: freshStartAllowed
|
: freshStartAllowed
|
||||||
? "Подключение не началось. Настройки удалены; после устранения причины начните новый явный поиск."
|
? "Подключение не началось. Настройки удалены; после устранения причины начните новый явный поиск."
|
||||||
: "Результат отправки неизвестен. Повторное применение заблокировано, чтобы не отправить BLE-команду дважды; дождитесь, пока система определит безопасное продолжение.";
|
: "Подключение не подтверждено. Проверьте состояние K1 или начните новое подключение; автоматического повтора не было.");
|
||||||
setConnectionAttemptPresentation((current) => (
|
setConnectionAttemptPresentation((current) => (
|
||||||
current?.idempotencyKey === idempotencyKey
|
current?.idempotencyKey === idempotencyKey
|
||||||
? {
|
? {
|
||||||
@@ -3203,7 +3206,9 @@ export function K1ProvisioningPipeline({
|
|||||||
disabled={isBusy || modeResetInFlight}
|
disabled={isBusy || modeResetInFlight}
|
||||||
onClick={() => void changeDesiredConnectionMode(connectionMode)}
|
onClick={() => void changeDesiredConnectionMode(connectionMode)}
|
||||||
>
|
>
|
||||||
{savedBridgeNetworkSetupRequired
|
{STATION_WIFI_FAILURE_MESSAGES[connectionRecoveryAttempt?.public_error_code ?? ""]
|
||||||
|
? "Указать сеть Wi‑Fi заново"
|
||||||
|
: savedBridgeNetworkSetupRequired
|
||||||
? "Подключить K1 к общей сети"
|
? "Подключить K1 к общей сети"
|
||||||
: "Подключить новый K1"}
|
: "Подключить новый K1"}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -3256,7 +3261,7 @@ export function K1ProvisioningPipeline({
|
|||||||
title="Подключение"
|
title="Подключение"
|
||||||
status={
|
status={
|
||||||
searchActive
|
searchActive
|
||||||
? `Поиск · ${scanSecondsRemaining ?? 6} с`
|
? `Поиск · до ${scanSecondsRemaining ?? BLE_DISCOVERY_TIMEOUT_SECONDS} с`
|
||||||
: connectionAttemptSettling
|
: connectionAttemptSettling
|
||||||
? "Устройство выбрано"
|
? "Устройство выбрано"
|
||||||
: presentedPhysicalRecoveryRequired
|
: presentedPhysicalRecoveryRequired
|
||||||
@@ -3316,7 +3321,7 @@ export function K1ProvisioningPipeline({
|
|||||||
>
|
>
|
||||||
<ActivityIndicator />
|
<ActivityIndicator />
|
||||||
<strong>
|
<strong>
|
||||||
Поиск Bluetooth · {scanSecondsRemaining ?? 6} с
|
Поиск Bluetooth · до {scanSecondsRemaining ?? BLE_DISCOVERY_TIMEOUT_SECONDS} с
|
||||||
</strong>
|
</strong>
|
||||||
</div>
|
</div>
|
||||||
) : networkRecoveryRequired
|
) : networkRecoveryRequired
|
||||||
|
|||||||
@@ -4,6 +4,9 @@ export type ConnectionMode = "bridge" | "quick-connect" | "direct-connect";
|
|||||||
export type MountType = "handheld" | "vehicle-mounted" | "uav" | "backpack";
|
export type MountType = "handheld" | "vehicle-mounted" | "uav" | "backpack";
|
||||||
export type GnssMode = "none" | "rtk" | "ppk";
|
export type GnssMode = "none" | "rtk" | "ppk";
|
||||||
|
|
||||||
|
// Upper bound for one continuous discovery session; it can finish earlier when K1 is visible.
|
||||||
|
export const BLE_DISCOVERY_TIMEOUT_SECONDS = 20;
|
||||||
|
|
||||||
export const DEFAULT_CONNECTION_MODE = "bridge" as const satisfies ConnectionMode;
|
export const DEFAULT_CONNECTION_MODE = "bridge" as const satisfies ConnectionMode;
|
||||||
export const SUPPORTED_MOUNT_TYPE = "handheld" as const satisfies MountType;
|
export const SUPPORTED_MOUNT_TYPE = "handheld" as const satisfies MountType;
|
||||||
export const SUPPORTED_GNSS_MODE = "none" as const satisfies GnssMode;
|
export const SUPPORTED_GNSS_MODE = "none" as const satisfies GnssMode;
|
||||||
|
|||||||
@@ -0,0 +1,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;
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import {xgridsK1SensorUi,K1EnrollmentWindow} from './sensors/plugin';
|
||||||
import type { DeviceUiPlugin } from "@mission-core/plugin-sdk";
|
import type { DeviceUiPlugin } from "@mission-core/plugin-sdk";
|
||||||
import { XgridsK1Connection } from "./XgridsK1Connection";
|
import { XgridsK1Connection } from "./XgridsK1Connection";
|
||||||
import { K1SpatialControls } from "./components/K1SpatialControls";
|
import { K1SpatialControls } from "./components/K1SpatialControls";
|
||||||
@@ -7,6 +8,7 @@ import "./styles.css";
|
|||||||
|
|
||||||
export const xgridsK1Plugin: DeviceUiPlugin = {
|
export const xgridsK1Plugin: DeviceUiPlugin = {
|
||||||
manifest: xgridsK1Manifest,
|
manifest: xgridsK1Manifest,
|
||||||
|
sensorUi: {contributions: [xgridsK1SensorUi], Enrollment: K1EnrollmentWindow},
|
||||||
RuntimeProvider: XgridsK1RuntimeProvider,
|
RuntimeProvider: XgridsK1RuntimeProvider,
|
||||||
SpatialControlsView: K1SpatialControls,
|
SpatialControlsView: K1SpatialControls,
|
||||||
connectionViews: Object.freeze({
|
connectionViews: Object.freeze({
|
||||||
|
|||||||
@@ -90,9 +90,9 @@ const connectionPolicyNextActionCopy: Record<string, string> = {
|
|||||||
"diagnose-physical-command-ledger": "Не повторяйте команду; сначала проверьте журнал физических команд.",
|
"diagnose-physical-command-ledger": "Не повторяйте команду; сначала проверьте журнал физических команд.",
|
||||||
"restart-ble-runtime": "Контролируемо перезапустите локальный BLE-контур и обновите состояние.",
|
"restart-ble-runtime": "Контролируемо перезапустите локальный BLE-контур и обновите состояние.",
|
||||||
"scan-ble": "Выполните свежий поиск Bluetooth-устройств.",
|
"scan-ble": "Выполните свежий поиск Bluetooth-устройств.",
|
||||||
"observe-fresh-device-network": "Дождитесь автоматического восстановления связи с выбранным K1.",
|
"observe-fresh-device-network": "Нажмите «Переподключиться», чтобы проверить состояние выбранного K1.",
|
||||||
"observe-current-device-network": "Дождитесь автоматического восстановления связи с тем же K1.",
|
"observe-current-device-network": "Нажмите «Переподключиться», чтобы проверить связь с тем же K1.",
|
||||||
"observe-configured-device-network": "Дождитесь автоматического восстановления сохранённого подключения K1.",
|
"observe-configured-device-network": "Нажмите «Переподключиться», чтобы проверить сохранённое подключение K1.",
|
||||||
"recover-current-device-network": "Нажмите «Подключиться заново».",
|
"recover-current-device-network": "Нажмите «Подключиться заново».",
|
||||||
"inspect-host-network": "Проверьте активную локальную сеть и маршрут этого компьютера.",
|
"inspect-host-network": "Проверьте активную локальную сеть и маршрут этого компьютера.",
|
||||||
"probe-endpoint": "Дождитесь обновления подключения K1.",
|
"probe-endpoint": "Дождитесь обновления подключения K1.",
|
||||||
|
|||||||
@@ -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&¤t-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&¤t.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,
|
newMutationContext,
|
||||||
newOperationId,
|
newOperationId,
|
||||||
operationAllowsFreshProvisioningIntent,
|
operationAllowsFreshProvisioningIntent,
|
||||||
operationByIdempotencyKey,
|
|
||||||
operationNeedsReconciliation,
|
|
||||||
physicalStopIntentCheckpoint,
|
physicalStopIntentCheckpoint,
|
||||||
recommendedConnectionRecoveryObservationTarget,
|
recommendedConnectionRecoveryObservationTarget,
|
||||||
readOnlyVerificationClearedReconciliation,
|
readOnlyVerificationClearedReconciliation,
|
||||||
@@ -65,7 +63,9 @@ import {
|
|||||||
import { selectMonotonicXgridsState } from "./stateOrdering";
|
import { selectMonotonicXgridsState } from "./stateOrdering";
|
||||||
import { operationHostFailureDiagnostic } from "./hostDiagnosticPresentation";
|
import { operationHostFailureDiagnostic } from "./hostDiagnosticPresentation";
|
||||||
import { activeStreamForceFinishAuthority } from "./activeStreamRecovery";
|
import { activeStreamForceFinishAuthority } from "./activeStreamRecovery";
|
||||||
import { DEFAULT_CONNECTION_MODE } from "./configuration";
|
import { BLE_DISCOVERY_TIMEOUT_SECONDS, DEFAULT_CONNECTION_MODE } from "./configuration";
|
||||||
|
import { observeProvisioningRequest, networkProvisionFailureMessage } from "./networkProvisioning";
|
||||||
|
export { awaitNetworkProvisionSettlementAfterLostResponse, networkProvisionFailureMessage } from "./networkProvisioning";
|
||||||
|
|
||||||
export type PendingAction =
|
export type PendingAction =
|
||||||
| "scan"
|
| "scan"
|
||||||
@@ -265,87 +265,6 @@ export function connectionActionAuthoritySnapshot(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const CONTROL_STATE_READ_INTERVAL_MS = 250;
|
const CONTROL_STATE_READ_INTERVAL_MS = 250;
|
||||||
const NETWORK_PROVISION_SETTLEMENT_FALLBACK_MS = 30_000;
|
|
||||||
const NETWORK_PROVISION_SETTLEMENT_MAX_MS = 300_000;
|
|
||||||
const NETWORK_PROVISION_SETTLEMENT_GRACE_MS = 1_000;
|
|
||||||
|
|
||||||
function networkProvisionSettlementDeadline(
|
|
||||||
operation: XgridsOperation,
|
|
||||||
startedAtMs: number,
|
|
||||||
): number {
|
|
||||||
const operationDeadlineMs = operation.deadline_at
|
|
||||||
? Date.parse(operation.deadline_at)
|
|
||||||
: Number.NaN;
|
|
||||||
const requestedDeadlineMs = Number.isFinite(operationDeadlineMs)
|
|
||||||
&& operationDeadlineMs > startedAtMs
|
|
||||||
? operationDeadlineMs + NETWORK_PROVISION_SETTLEMENT_GRACE_MS
|
|
||||||
: startedAtMs + NETWORK_PROVISION_SETTLEMENT_FALLBACK_MS;
|
|
||||||
return Math.min(
|
|
||||||
requestedDeadlineMs,
|
|
||||||
startedAtMs + NETWORK_PROVISION_SETTLEMENT_MAX_MS,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Follow one already-admitted Apply after a browser response is interrupted by
|
|
||||||
* the host Wi-Fi handoff. This loop only reads the local journal; it never
|
|
||||||
* retries the HTTP mutation, BLE write, CoreWLAN association or control open.
|
|
||||||
*/
|
|
||||||
export async function awaitNetworkProvisionSettlementAfterLostResponse(
|
|
||||||
initialState: XgridsK1State,
|
|
||||||
idempotencyKey: string,
|
|
||||||
readState: () => Promise<XgridsK1State>,
|
|
||||||
acceptState: (state: XgridsK1State) => void,
|
|
||||||
assertOperatorIntentCurrent: () => void,
|
|
||||||
options: {
|
|
||||||
now?: () => number;
|
|
||||||
wait?: (delayMs: number) => Promise<void>;
|
|
||||||
} = {},
|
|
||||||
): Promise<XgridsK1State> {
|
|
||||||
const now = options.now ?? Date.now;
|
|
||||||
const wait = options.wait ?? ((delayMs: number) => new Promise<void>((resolve) => {
|
|
||||||
globalThis.setTimeout(resolve, delayMs);
|
|
||||||
}));
|
|
||||||
let state = initialState;
|
|
||||||
let operation = operationByIdempotencyKey(
|
|
||||||
state,
|
|
||||||
"network.provision",
|
|
||||||
idempotencyKey,
|
|
||||||
);
|
|
||||||
if (!operation || !["accepted", "running"].includes(operation.status)) {
|
|
||||||
return state;
|
|
||||||
}
|
|
||||||
const deadlineMs = networkProvisionSettlementDeadline(operation, now());
|
|
||||||
|
|
||||||
while (["accepted", "running"].includes(operation.status)) {
|
|
||||||
assertOperatorIntentCurrent();
|
|
||||||
const remainingMs = deadlineMs - now();
|
|
||||||
if (remainingMs <= 0) return state;
|
|
||||||
await wait(Math.min(CONTROL_STATE_READ_INTERVAL_MS, remainingMs));
|
|
||||||
assertOperatorIntentCurrent();
|
|
||||||
try {
|
|
||||||
const nextState = await readState();
|
|
||||||
assertOperatorIntentCurrent();
|
|
||||||
acceptState(nextState);
|
|
||||||
state = nextState;
|
|
||||||
} catch (readError) {
|
|
||||||
// A host Wi-Fi transition can briefly abort even a localhost fetch.
|
|
||||||
// Preserve the admitted operation and perform only the next bounded
|
|
||||||
// journal read; the original Apply is never reissued.
|
|
||||||
if (!(readError instanceof ApiError) || !readError.transportUnavailable) {
|
|
||||||
throw readError;
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
operation = operationByIdempotencyKey(
|
|
||||||
state,
|
|
||||||
"network.provision",
|
|
||||||
idempotencyKey,
|
|
||||||
);
|
|
||||||
if (!operation) return state;
|
|
||||||
}
|
|
||||||
return state;
|
|
||||||
}
|
|
||||||
const CONTROL_PHASE_WAIT_TIMEOUT_MS = 120_000;
|
const CONTROL_PHASE_WAIT_TIMEOUT_MS = 120_000;
|
||||||
|
|
||||||
function controlPhase(state: XgridsK1State): XgridsApplicationControlPhase {
|
function controlPhase(state: XgridsK1State): XgridsApplicationControlPhase {
|
||||||
@@ -642,93 +561,6 @@ function apiErrorForOperation(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const CONNECT_SESSION_RESET_COPY =
|
|
||||||
"Автоматический повтор команды K1 не отправлялся. Сессия подключения в интерфейсе сброшена. Выполните новый поиск Bluetooth, выберите K1 и запустите подключение ещё раз.";
|
|
||||||
|
|
||||||
function resetConnectSessionMessage(detail: string): string {
|
|
||||||
return `${detail} ${CONNECT_SESSION_RESET_COPY}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function networkProvisionFailureMessage(
|
|
||||||
operation: XgridsOperation | null | undefined,
|
|
||||||
): string | null {
|
|
||||||
if (!operation || operation.status !== "failed") return null;
|
|
||||||
const code = operation.error?.code;
|
|
||||||
if (typeof code !== "string") return null;
|
|
||||||
|
|
||||||
if (code === "BleakGATTProtocolError") {
|
|
||||||
const attCode = operation.error?.ble_att_error_code;
|
|
||||||
const attName = operation.error?.ble_att_error_name;
|
|
||||||
const attDetail = typeof attCode === "number" && typeof attName === "string"
|
|
||||||
? ` Код периферии: ATT ${attCode} ${attName}.`
|
|
||||||
: "";
|
|
||||||
if (operation.error?.device_write_attempted === false) {
|
|
||||||
return `Bluetooth-сеанс K1 завершился ошибкой до команды изменения сети.${attDetail} Запись сетевого профиля не выполнялась; выполните новый поиск после освобождения Bluetooth.`;
|
|
||||||
}
|
|
||||||
if (typeof attCode === "number" && typeof attName === "string") {
|
|
||||||
return resetConnectSessionMessage(
|
|
||||||
`Bluetooth-периферия завершила сетевую операцию ошибкой.${attDetail} Команда могла быть принята K1; итог текущей попытки не подтверждён.`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return resetConnectSessionMessage(
|
|
||||||
"Bluetooth-периферия завершила сетевую операцию ошибкой. Команда могла быть принята K1; итог текущей попытки не подтверждён.",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (code === "network-not-found") {
|
|
||||||
const attemptCount = operation.error?.scan_attempt_count;
|
|
||||||
const elapsedMs = operation.error?.scan_elapsed_ms;
|
|
||||||
const scanDetail = typeof attemptCount === "number" && typeof elapsedMs === "number"
|
|
||||||
? ` macOS выполнила ${attemptCount} проверок за ${(elapsedMs / 1000).toFixed(1)} с.`
|
|
||||||
: "";
|
|
||||||
return `K1 принял команду Quick Connect и подтвердил готовность точки доступа, но macOS не увидела её Wi‑Fi-сеть за отведённое время.${scanDetail} Quick Connect не установлен; автоматического повтора не было. Выполните новый поиск перед следующей явной попыткой или используйте Bridge.`;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
code === "keychain-authorization-required"
|
|
||||||
|| code === "keychain-authorization-denied"
|
|
||||||
|| code === "keychain-authorization-cancelled"
|
|
||||||
|| code === "keychain-access-failed"
|
|
||||||
) {
|
|
||||||
const failedBeforeDeviceWrite = operation.error?.side_effect_status === "none";
|
|
||||||
return failedBeforeDeviceWrite
|
|
||||||
? "Локальный профиль K1 недоступен в связке ключей. Команда устройству не отправлялась; подготовьте разрешение профиля отдельным действием и затем повторите подключение."
|
|
||||||
: resetConnectSessionMessage(
|
|
||||||
"После подтверждённого включения точки K1 локальный профиль стал недоступен в связке ключей. Дополнительный пароль не запрашивался; итог текущей попытки не подтверждён.",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const messages: Record<string, string> = {
|
|
||||||
"host-wifi-operation-timeout":
|
|
||||||
resetConnectSessionMessage("Локальная операция подготовки Wi‑Fi не завершилась вовремя; итог текущей попытки подключения не подтверждён."),
|
|
||||||
"profile-ssid-mismatch":
|
|
||||||
"Сохранённый профиль относится к другому устройству. Подключение остановлено без повторной команды сканеру.",
|
|
||||||
"profile-credential-source-mismatch":
|
|
||||||
"Сохранённый профиль K1 не подтверждён для точной версии прошивки. Автоматического выбора другого пароля нет; подготовьте профиль отдельно перед новой попыткой.",
|
|
||||||
"profile-unavailable":
|
|
||||||
resetConnectSessionMessage("Локальный профиль выбранного K1 отсутствует; текущая попытка подключения завершилась ошибкой."),
|
|
||||||
"corewlan-error":
|
|
||||||
"macOS не смогла подключиться к точке доступа K1. Проверьте пароль сохранённой сети этого K1; автоматического повтора не было.",
|
|
||||||
"wifi-interface-unavailable":
|
|
||||||
"Системный Wi-Fi-интерфейс macOS недоступен. Команда сканеру автоматически не повторялась.",
|
|
||||||
"unsupported-platform":
|
|
||||||
"Для этой операционной системы адаптер подключения к точке K1 ещё не реализован.",
|
|
||||||
"credential-source-unavailable":
|
|
||||||
"Локальный профиль выбранного K1 не готов. Команда устройству не отправлялась; после подготовки профиля разрешена новая явная попытка.",
|
|
||||||
"network-provision-candidate-not-fresh":
|
|
||||||
"Результат Bluetooth-поиска отсутствует или устарел. Команда K1 не отправлялась; выполните один свежий поиск.",
|
|
||||||
"network-provision-candidate-changed":
|
|
||||||
"Bluetooth-кандидат изменился до команды K1. Записи не было; выполните один свежий поиск.",
|
|
||||||
"network-provision-candidate-name-unavailable":
|
|
||||||
"K1 не сообщил имя своей точки доступа. Команда устройству не отправлялась; выполните новый поиск.",
|
|
||||||
"network-provision-target-not-distinguishable-from-baseline":
|
|
||||||
resetConnectSessionMessage("После BLE-команды K1 вернул сетевой статус, неотличимый от исходного; итог текущей попытки подключения не подтверждён."),
|
|
||||||
"network-provision-lifecycle-busy":
|
|
||||||
"Сетевая операция заблокирована активной сессией или локальной очисткой. Команда K1 не отправлялась; завершите текущую сессию и повторите явно.",
|
|
||||||
};
|
|
||||||
return messages[code] ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function discoveryScanFailureMessage(
|
export function discoveryScanFailureMessage(
|
||||||
operation: XgridsOperation | null | undefined,
|
operation: XgridsOperation | null | undefined,
|
||||||
): string | null {
|
): string | null {
|
||||||
@@ -1138,7 +970,7 @@ export function useXgridsK1Runtime(enabled: boolean) {
|
|||||||
const operationId = newOperationId();
|
const operationId = newOperationId();
|
||||||
try {
|
try {
|
||||||
const nextState = await xgridsK1Api.scanBle({
|
const nextState = await xgridsK1Api.scanBle({
|
||||||
duration_seconds: options.durationSeconds ?? 6,
|
duration_seconds: options.durationSeconds ?? BLE_DISCOVERY_TIMEOUT_SECONDS,
|
||||||
operation_id: operationId,
|
operation_id: operationId,
|
||||||
expected_snapshot_runtime_id: expectedSnapshotRuntimeId(),
|
expected_snapshot_runtime_id: expectedSnapshotRuntimeId(),
|
||||||
});
|
});
|
||||||
@@ -1412,143 +1244,36 @@ export function useXgridsK1Runtime(enabled: boolean) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
const previous = operationByIdempotencyKey(
|
const observation = await observeProvisioningRequest(request, {
|
||||||
state,
|
initialState: latestState.current,
|
||||||
"network.provision",
|
send: (input) => xgridsK1Api.connect({
|
||||||
request.idempotency_key,
|
...input,
|
||||||
);
|
|
||||||
if (operationNeedsReconciliation(previous)) {
|
|
||||||
captureFailureReason(previous);
|
|
||||||
throw apiErrorForOperation(
|
|
||||||
resetConnectSessionMessage(
|
|
||||||
"Текущая попытка подключения не получила подтверждённого результата.",
|
|
||||||
),
|
|
||||||
previous,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (operationAllowsFreshProvisioningIntent(previous)) {
|
|
||||||
intentDisposition = "release";
|
|
||||||
captureFailureReason(previous);
|
|
||||||
const failureMessage = networkProvisionFailureMessage(previous);
|
|
||||||
throw apiErrorForOperation(
|
|
||||||
failureMessage
|
|
||||||
?? "Подготовительный этап завершился до команды K1. После устранения причины разрешена новая явная попытка.",
|
|
||||||
previous,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
let nextState: XgridsK1State;
|
|
||||||
if (previous?.status === "succeeded" && state) {
|
|
||||||
nextState = state;
|
|
||||||
} else try {
|
|
||||||
nextState = await xgridsK1Api.connect({
|
|
||||||
...request,
|
|
||||||
expected_snapshot_runtime_id: expectedSnapshotRuntimeId(),
|
expected_snapshot_runtime_id: expectedSnapshotRuntimeId(),
|
||||||
});
|
}),
|
||||||
} catch (connectError) {
|
readState: () => xgridsK1Api.getState(),
|
||||||
// A failed network action is terminal and persisted by the backend.
|
acceptState,
|
||||||
// Re-read state only; never replay the device command. This replaces
|
currentState: () => latestState.current,
|
||||||
// an opaque HTTP 502 banner with the exact host-association outcome.
|
assertCurrent: assertOperatorIntentCurrent,
|
||||||
let failedState: XgridsK1State;
|
});
|
||||||
try {
|
// Return the same authoritative snapshot on failure and success. The
|
||||||
failedState = await xgridsK1Api.getState();
|
// form must not reconstruct an outcome from an empty result object.
|
||||||
} catch {
|
observedState = observation.state;
|
||||||
throw connectError;
|
let nextState = observation.state;
|
||||||
}
|
const operation = observation.operation;
|
||||||
acceptState(failedState);
|
if (operation?.status !== "succeeded") {
|
||||||
failedState = await awaitNetworkProvisionSettlementAfterLostResponse(
|
|
||||||
failedState,
|
|
||||||
request.idempotency_key,
|
|
||||||
() => xgridsK1Api.getState(),
|
|
||||||
acceptState,
|
|
||||||
assertOperatorIntentCurrent,
|
|
||||||
);
|
|
||||||
const failedOperation = operationByIdempotencyKey(
|
|
||||||
failedState,
|
|
||||||
"network.provision",
|
|
||||||
request.idempotency_key,
|
|
||||||
);
|
|
||||||
// The response body can be lost after the backend has already
|
|
||||||
// committed the exact idempotent operation. The read-only journal is
|
|
||||||
// authoritative: acknowledge that success instead of presenting a
|
|
||||||
// false failure which could invite another operator click.
|
|
||||||
if (failedOperation?.status === "succeeded") {
|
|
||||||
nextState = failedState;
|
|
||||||
if (
|
|
||||||
!exactAppliedNetworkIntentCompleted(
|
|
||||||
nextState,
|
|
||||||
request,
|
|
||||||
failedOperation,
|
|
||||||
)
|
|
||||||
&& !hasExactConnectionReady(nextState, request.connection_mode)
|
|
||||||
) {
|
|
||||||
throw connectError;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
captureFailureReason(failedOperation);
|
|
||||||
if (operationNeedsReconciliation(failedOperation)) {
|
|
||||||
throw apiErrorForOperation(
|
|
||||||
resetConnectSessionMessage(
|
|
||||||
"Текущая попытка подключения завершилась с неизвестным результатом.",
|
|
||||||
),
|
|
||||||
failedOperation,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (operationAllowsFreshProvisioningIntent(failedOperation)) {
|
|
||||||
intentDisposition = "release";
|
|
||||||
const failureMessage = networkProvisionFailureMessage(failedOperation);
|
|
||||||
throw apiErrorForOperation(
|
|
||||||
failureMessage
|
|
||||||
?? "Подготовительный этап завершился до команды K1. После устранения причины разрешена новая явная попытка.",
|
|
||||||
failedOperation,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const failureMessage = networkProvisionFailureMessage(failedOperation);
|
|
||||||
if (failureMessage) {
|
|
||||||
throw apiErrorForOperation(failureMessage, failedOperation);
|
|
||||||
}
|
|
||||||
if (failedOperation) {
|
|
||||||
throw apiErrorForOperation(
|
|
||||||
resetConnectSessionMessage(
|
|
||||||
"Текущая попытка подключения не получила подтверждённого результата.",
|
|
||||||
),
|
|
||||||
failedOperation,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
throw connectError;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const operation = operationByIdempotencyKey(
|
|
||||||
nextState,
|
|
||||||
"network.provision",
|
|
||||||
request.idempotency_key,
|
|
||||||
);
|
|
||||||
if (operationNeedsReconciliation(operation)) {
|
|
||||||
captureFailureReason(operation);
|
|
||||||
throw apiErrorForOperation(
|
|
||||||
resetConnectSessionMessage(
|
|
||||||
"Текущая попытка подключения завершилась с неизвестным результатом.",
|
|
||||||
),
|
|
||||||
operation,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (operationAllowsFreshProvisioningIntent(operation)) {
|
|
||||||
intentDisposition = "release";
|
|
||||||
captureFailureReason(operation);
|
captureFailureReason(operation);
|
||||||
|
intentDisposition = operationAllowsFreshProvisioningIntent(operation)
|
||||||
|
? "release"
|
||||||
|
: "retain";
|
||||||
const failureMessage = networkProvisionFailureMessage(operation);
|
const failureMessage = networkProvisionFailureMessage(operation);
|
||||||
throw apiErrorForOperation(
|
if (operation) {
|
||||||
failureMessage
|
throw apiErrorForOperation(
|
||||||
?? "Подготовительный этап завершился до команды K1. После устранения причины разрешена новая явная попытка.",
|
failureMessage ?? "Подключение не подтверждено. Проверьте состояние K1 или начните новое подключение; автоматического повтора не было.",
|
||||||
operation,
|
operation,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (operation && operation.status !== "succeeded") {
|
throw observation.transportError ?? new ApiError(
|
||||||
captureFailureReason(operation);
|
"Сервис не подтвердил результат подключения. Обновите состояние; команда автоматически не повторяется.",
|
||||||
throw apiErrorForOperation(
|
|
||||||
resetConnectSessionMessage(
|
|
||||||
"Текущая попытка подключения завершилась без подтверждения успеха.",
|
|
||||||
),
|
|
||||||
operation,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const exactNetworkIntentCompleted = exactAppliedNetworkIntentCompleted(
|
const exactNetworkIntentCompleted = exactAppliedNetworkIntentCompleted(
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ missioncore-plugin-sdk = { path = "packages/plugin-sdk", editable = true }
|
|||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
perception-stream = ["grpcio>=1.76,<2"]
|
perception-stream = ["grpcio>=1.76,<2"]
|
||||||
|
node-device-media = ["aiortc==1.14.0"]
|
||||||
|
|
||||||
[project.scripts]
|
[project.scripts]
|
||||||
k1link = "k1link.device_plugins.xgrids_k1.cli:app"
|
k1link = "k1link.device_plugins.xgrids_k1.cli:app"
|
||||||
|
|||||||
@@ -2,7 +2,10 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import math
|
import math
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
|
from contextlib import suppress
|
||||||
from dataclasses import dataclass, replace
|
from dataclasses import dataclass, replace
|
||||||
from importlib.metadata import version
|
from importlib.metadata import version
|
||||||
from threading import Lock
|
from threading import Lock
|
||||||
@@ -33,6 +36,8 @@ BLE_RUNTIME_HANDLE_LEASE_TTL_SECONDS = BLE_DISCOVERY_CANDIDATE_LEASE_TTL_SECONDS
|
|||||||
# low-level retained-handle lifetime.
|
# low-level retained-handle lifetime.
|
||||||
BLE_DISCOVERY_LEASE_TTL_SECONDS = BLE_RUNTIME_HANDLE_LEASE_TTL_SECONDS
|
BLE_DISCOVERY_LEASE_TTL_SECONDS = BLE_RUNTIME_HANDLE_LEASE_TTL_SECONDS
|
||||||
BLE_SCAN_HARD_TIMEOUT_GRACE_SECONDS = 5.0
|
BLE_SCAN_HARD_TIMEOUT_GRACE_SECONDS = 5.0
|
||||||
|
BLE_SCAN_INITIAL_WINDOW_SECONDS = 6.0
|
||||||
|
BLE_SCAN_DEFAULT_TIMEOUT_SECONDS = 20.0
|
||||||
|
|
||||||
_runtime_handle_lock = Lock()
|
_runtime_handle_lock = Lock()
|
||||||
_runtime_handles: dict[str, BLEDevice] = {}
|
_runtime_handles: dict[str, BLEDevice] = {}
|
||||||
@@ -132,6 +137,14 @@ class BleDeviceRecord(TypedDict):
|
|||||||
k1_name_candidate: bool
|
k1_name_candidate: bool
|
||||||
|
|
||||||
|
|
||||||
|
class BleDiscoveryTiming(TypedDict, total=False):
|
||||||
|
scan_elapsed_ms: int
|
||||||
|
scanner_start_ms: int
|
||||||
|
initial_window_ms: int
|
||||||
|
first_candidate_ms: int
|
||||||
|
scan_extended: bool
|
||||||
|
|
||||||
|
|
||||||
class BleScanResult(TypedDict):
|
class BleScanResult(TypedDict):
|
||||||
schema_version: int
|
schema_version: int
|
||||||
started_at_utc: str
|
started_at_utc: str
|
||||||
@@ -141,6 +154,7 @@ class BleScanResult(TypedDict):
|
|||||||
bleak_version: str
|
bleak_version: str
|
||||||
device_count: int
|
device_count: int
|
||||||
devices: list[BleDeviceRecord]
|
devices: list[BleDeviceRecord]
|
||||||
|
discovery_timing: BleDiscoveryTiming
|
||||||
|
|
||||||
|
|
||||||
def advertisement_record(device: BLEDevice, advertisement: AdvertisementData) -> BleDeviceRecord:
|
def advertisement_record(device: BLEDevice, advertisement: AdvertisementData) -> BleDeviceRecord:
|
||||||
@@ -148,7 +162,7 @@ def advertisement_record(device: BLEDevice, advertisement: AdvertisementData) ->
|
|||||||
normalized_name = (name or "").casefold()
|
normalized_name = (name or "").casefold()
|
||||||
return {
|
return {
|
||||||
"macos_uuid": device.address,
|
"macos_uuid": device.address,
|
||||||
"id_kind": "corebluetooth_uuid",
|
"id_kind": "bluez_address" if sys.platform.startswith("linux") else "corebluetooth_uuid",
|
||||||
"name": device.name,
|
"name": device.name,
|
||||||
"local_name": advertisement.local_name,
|
"local_name": advertisement.local_name,
|
||||||
"rssi": advertisement.rssi,
|
"rssi": advertisement.rssi,
|
||||||
@@ -803,11 +817,34 @@ def connected_device_recovery_name(
|
|||||||
return normalized or None
|
return normalized or None
|
||||||
|
|
||||||
|
|
||||||
|
async def _retrieve_bluez_device(address: str, details: object = None) -> BLEDevice | None:
|
||||||
|
"""Retrieve only the exact cached BlueZ object; no discovery or connection."""
|
||||||
|
if not re.fullmatch(r"(?:[0-9a-fA-F]{2}:){5}[0-9a-fA-F]{2}", address):
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
from bleak.backends.bluezdbus.manager import get_global_bluez_manager
|
||||||
|
|
||||||
|
manager = await asyncio.wait_for(get_global_bluez_manager(), timeout=3)
|
||||||
|
path = details.get("path") if isinstance(details, dict) else None
|
||||||
|
if not path:
|
||||||
|
path = manager.get_default_adapter() + "/dev_" + address.upper().replace(":", "_")
|
||||||
|
if manager.get_device_address(path).casefold() != address.casefold():
|
||||||
|
return None
|
||||||
|
name = manager.get_device_name(path)
|
||||||
|
return BLEDevice(address, name, details={"path": path, "props": {
|
||||||
|
"Address": address, "Alias": name, "Adapter": path.rsplit("/", 1)[0],
|
||||||
|
}})
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
async def _retrieve_corebluetooth_device(
|
async def _retrieve_corebluetooth_device(
|
||||||
captured: CapturedDiscoveredDevice,
|
captured: CapturedDiscoveredDevice,
|
||||||
) -> BLEDevice | None:
|
) -> BLEDevice | None:
|
||||||
"""Retrieve one UUID through the exact CoreBluetooth manager that observed it."""
|
"""Retrieve one UUID through the exact CoreBluetooth manager that observed it."""
|
||||||
|
|
||||||
|
if sys.platform.startswith("linux"):
|
||||||
|
return await _retrieve_bluez_device(captured.macos_uuid, captured.device.details)
|
||||||
details = captured.device.details
|
details = captured.device.details
|
||||||
if not isinstance(details, tuple) or len(details) != 2:
|
if not isinstance(details, tuple) or len(details) != 2:
|
||||||
return None
|
return None
|
||||||
@@ -899,6 +936,20 @@ async def retrieve_known_device_capture_for_status_read(
|
|||||||
or runtime["poisoned"]
|
or runtime["poisoned"]
|
||||||
):
|
):
|
||||||
return None
|
return None
|
||||||
|
if sys.platform.startswith("linux"):
|
||||||
|
device = await _retrieve_bluez_device(macos_uuid)
|
||||||
|
after = ble_runtime_snapshot()
|
||||||
|
if (device is None or ble_runtime_owner_epoch_for_current_loop() != owner_epoch
|
||||||
|
or after["owner_epoch"] != owner_epoch
|
||||||
|
or not after["owner_loop_bound"] or after["poisoned"]
|
||||||
|
or after["active_operation_kind"] != "status-read"):
|
||||||
|
return None
|
||||||
|
now = _freshness_observed_at()
|
||||||
|
return CapturedDiscoveredDevice(
|
||||||
|
device=device, macos_uuid=macos_uuid, owner_epoch=owner_epoch,
|
||||||
|
scan_generation=0, captured_at_monotonic=now.monotonic,
|
||||||
|
captured_at_suspend_aware=now.suspend_aware, source="retrieved-durable",
|
||||||
|
)
|
||||||
context = _new_corebluetooth_retrieval_context(macos_uuid)
|
context = _new_corebluetooth_retrieval_context(macos_uuid)
|
||||||
if context is None:
|
if context is None:
|
||||||
return None
|
return None
|
||||||
@@ -977,10 +1028,14 @@ async def discover_known_device_capture_for_status_read(
|
|||||||
or runtime["poisoned"]
|
or runtime["poisoned"]
|
||||||
):
|
):
|
||||||
return None
|
return None
|
||||||
try:
|
if sys.platform.startswith("linux"):
|
||||||
UUID(macos_uuid)
|
if not re.fullmatch(r"(?:[0-9a-fA-F]{2}:){5}[0-9a-fA-F]{2}", macos_uuid):
|
||||||
except (TypeError, ValueError):
|
return None
|
||||||
return None
|
else:
|
||||||
|
try:
|
||||||
|
UUID(macos_uuid)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
|
||||||
candidate = await BleakScanner.find_device_by_address(
|
candidate = await BleakScanner.find_device_by_address(
|
||||||
macos_uuid,
|
macos_uuid,
|
||||||
@@ -1148,6 +1203,42 @@ def connected_device_recovery_snapshot(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def _discover_k1_advertisements(
|
||||||
|
*, timeout: float, diagnostics: BleDiscoveryTiming
|
||||||
|
) -> dict[str, tuple[BLEDevice, AdvertisementData]]:
|
||||||
|
"""Listen once, extending the initial window only while no K1 is visible.
|
||||||
|
|
||||||
|
Every result and native handle comes from this scanner's callbacks. A name
|
||||||
|
match only ends discovery; it grants no identity or GATT authority.
|
||||||
|
"""
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
started = loop.time()
|
||||||
|
initial_window = min(timeout, BLE_SCAN_INITIAL_WINDOW_SECONDS)
|
||||||
|
discovered: dict[str, tuple[BLEDevice, AdvertisementData]] = {}
|
||||||
|
candidate_seen = asyncio.Event()
|
||||||
|
|
||||||
|
def observe(device: BLEDevice, advertisement: AdvertisementData) -> None:
|
||||||
|
discovered[device.address] = (device, advertisement)
|
||||||
|
if advertisement_record(device, advertisement)["k1_name_candidate"]:
|
||||||
|
if not candidate_seen.is_set():
|
||||||
|
diagnostics["first_candidate_ms"] = round((loop.time() - started) * 1000)
|
||||||
|
candidate_seen.set()
|
||||||
|
|
||||||
|
diagnostics["initial_window_ms"] = round(initial_window * 1000)
|
||||||
|
diagnostics["scan_extended"] = False
|
||||||
|
async with BleakScanner(detection_callback=observe):
|
||||||
|
listening_started = loop.time()
|
||||||
|
diagnostics["scanner_start_ms"] = round((listening_started - started) * 1000)
|
||||||
|
await asyncio.sleep(initial_window)
|
||||||
|
remaining = max(0.0, timeout - (loop.time() - listening_started))
|
||||||
|
if not candidate_seen.is_set() and remaining > 0:
|
||||||
|
diagnostics["scan_extended"] = True
|
||||||
|
with suppress(TimeoutError):
|
||||||
|
await asyncio.wait_for(candidate_seen.wait(), timeout=remaining)
|
||||||
|
diagnostics["scan_elapsed_ms"] = round((loop.time() - started) * 1000)
|
||||||
|
return discovered
|
||||||
|
|
||||||
|
|
||||||
async def _scan_impl(
|
async def _scan_impl(
|
||||||
duration_seconds: float,
|
duration_seconds: float,
|
||||||
progress: BleOperationProgress,
|
progress: BleOperationProgress,
|
||||||
@@ -1163,7 +1254,10 @@ async def _scan_impl(
|
|||||||
progress.operation_stage = "discovery"
|
progress.operation_stage = "discovery"
|
||||||
scan_generation = _begin_scan_generation(owner_epoch)
|
scan_generation = _begin_scan_generation(owner_epoch)
|
||||||
try:
|
try:
|
||||||
discovered = await BleakScanner.discover(timeout=duration_seconds, return_adv=True)
|
discovery_timing: BleDiscoveryTiming = {}
|
||||||
|
discovered = await _discover_k1_advertisements(
|
||||||
|
timeout=duration_seconds, diagnostics=discovery_timing
|
||||||
|
)
|
||||||
handles = {device.address: device for device, _advertisement in discovered.values()}
|
handles = {device.address: device for device, _advertisement in discovered.values()}
|
||||||
devices = [
|
devices = [
|
||||||
advertisement_record(device, advertisement)
|
advertisement_record(device, advertisement)
|
||||||
@@ -1181,10 +1275,11 @@ async def _scan_impl(
|
|||||||
"started_at_utc": started_at,
|
"started_at_utc": started_at,
|
||||||
"completed_at_utc": utc_now_iso(),
|
"completed_at_utc": utc_now_iso(),
|
||||||
"duration_seconds": duration_seconds,
|
"duration_seconds": duration_seconds,
|
||||||
"adapter": "CoreBluetooth",
|
"adapter": "BlueZ" if sys.platform.startswith("linux") else "CoreBluetooth",
|
||||||
"bleak_version": version("bleak"),
|
"bleak_version": version("bleak"),
|
||||||
"device_count": len(devices),
|
"device_count": len(devices),
|
||||||
"devices": devices,
|
"devices": devices,
|
||||||
|
"discovery_timing": discovery_timing,
|
||||||
}
|
}
|
||||||
except BaseException:
|
except BaseException:
|
||||||
_finish_failed_scan(scan_generation)
|
_finish_failed_scan(scan_generation)
|
||||||
|
|||||||
@@ -303,6 +303,7 @@ class XgridsK1CameraGateway:
|
|||||||
repository_root: Path,
|
repository_root: Path,
|
||||||
plugin_id: str,
|
plugin_id: str,
|
||||||
*,
|
*,
|
||||||
|
evidence_root: Path | None = None,
|
||||||
committed_segment_observer: CommittedCameraSegmentObserver | None = None,
|
committed_segment_observer: CommittedCameraSegmentObserver | None = None,
|
||||||
process_fence_descriptor_factory: Callable[[], int] | None = None,
|
process_fence_descriptor_factory: Callable[[], int] | None = None,
|
||||||
producer_stall_observer: CameraProducerStallObserver | None = None,
|
producer_stall_observer: CameraProducerStallObserver | None = None,
|
||||||
@@ -316,6 +317,12 @@ class XgridsK1CameraGateway:
|
|||||||
if not 0.01 <= producer_watchdog_interval_seconds <= 60.0:
|
if not 0.01 <= producer_watchdog_interval_seconds <= 60.0:
|
||||||
raise ValueError("camera watchdog interval must be within 0.01..60 seconds")
|
raise ValueError("camera watchdog interval must be within 0.01..60 seconds")
|
||||||
self._repository_root = repository_root.resolve()
|
self._repository_root = repository_root.resolve()
|
||||||
|
# Runtime evidence may live outside a source checkout (worktrees/Node).
|
||||||
|
# The composition supplies this trusted root; request paths cannot widen it.
|
||||||
|
self._evidence_root = (
|
||||||
|
evidence_root.expanduser().resolve() if evidence_root is not None
|
||||||
|
else self._repository_root
|
||||||
|
)
|
||||||
self._plugin_id = plugin_id
|
self._plugin_id = plugin_id
|
||||||
self._lock = threading.RLock()
|
self._lock = threading.RLock()
|
||||||
self._lifecycle_lock = threading.Lock()
|
self._lifecycle_lock = threading.Lock()
|
||||||
@@ -674,8 +681,8 @@ class XgridsK1CameraGateway:
|
|||||||
root = session_dir.expanduser().resolve()
|
root = session_dir.expanduser().resolve()
|
||||||
if not root.is_dir():
|
if not root.is_dir():
|
||||||
raise ValueError("observation session directory does not exist")
|
raise ValueError("observation session directory does not exist")
|
||||||
if not root.is_relative_to(self._repository_root):
|
if not root.is_relative_to(self._evidence_root):
|
||||||
raise ValueError("camera recording root must stay inside the repository")
|
raise ValueError("camera recording root must stay inside the configured evidence root")
|
||||||
reserved_generation: list[int] = []
|
reserved_generation: list[int] = []
|
||||||
|
|
||||||
def reserve() -> bool:
|
def reserve() -> bool:
|
||||||
@@ -805,8 +812,8 @@ class XgridsK1CameraGateway:
|
|||||||
root = session_dir.expanduser().resolve()
|
root = session_dir.expanduser().resolve()
|
||||||
if not root.is_dir():
|
if not root.is_dir():
|
||||||
raise ValueError("observation session directory does not exist")
|
raise ValueError("observation session directory does not exist")
|
||||||
if not root.is_relative_to(self._repository_root):
|
if not root.is_relative_to(self._evidence_root):
|
||||||
raise ValueError("camera recording root must stay inside the repository")
|
raise ValueError("camera recording root must stay inside the configured evidence root")
|
||||||
|
|
||||||
with self._lifecycle_lock:
|
with self._lifecycle_lock:
|
||||||
with self._lock:
|
with self._lock:
|
||||||
|
|||||||
@@ -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 []
|
||||||
|
),
|
||||||
|
],
|
||||||
|
}
|
||||||
@@ -88,6 +88,7 @@ from k1link.device_plugins.xgrids_k1.ble.runtime_arbiter import (
|
|||||||
wait_for_ble_runtime_idle,
|
wait_for_ble_runtime_idle,
|
||||||
)
|
)
|
||||||
from k1link.device_plugins.xgrids_k1.ble.scanner import (
|
from k1link.device_plugins.xgrids_k1.ble.scanner import (
|
||||||
|
BLE_SCAN_DEFAULT_TIMEOUT_SECONDS,
|
||||||
BLE_SCAN_HARD_TIMEOUT_GRACE_SECONDS,
|
BLE_SCAN_HARD_TIMEOUT_GRACE_SECONDS,
|
||||||
CapturedDiscoveredDevice,
|
CapturedDiscoveredDevice,
|
||||||
capture_discovered_device,
|
capture_discovered_device,
|
||||||
@@ -122,6 +123,14 @@ from k1link.device_plugins.xgrids_k1.camera import (
|
|||||||
build_xgrids_k1_camera_router,
|
build_xgrids_k1_camera_router,
|
||||||
classify_camera_recording_health,
|
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 (
|
from k1link.device_plugins.xgrids_k1.connection_supervisor import (
|
||||||
ConnectionMonitorProbeSuperseded,
|
ConnectionMonitorProbeSuperseded,
|
||||||
ConnectionSupervisor,
|
ConnectionSupervisor,
|
||||||
@@ -226,6 +235,7 @@ from k1link.device_plugins.xgrids_k1.viewer.runtime import (
|
|||||||
VisualizationRuntime,
|
VisualizationRuntime,
|
||||||
new_live_session_dir,
|
new_live_session_dir,
|
||||||
)
|
)
|
||||||
|
from k1link.device_plugins.xgrids_k1.wifi_failure import reviewed_station_failure_code
|
||||||
from k1link.host_network import (
|
from k1link.host_network import (
|
||||||
HostWifiAssociationIdentityProbe,
|
HostWifiAssociationIdentityProbe,
|
||||||
HostWifiAssociationIdentityResult,
|
HostWifiAssociationIdentityResult,
|
||||||
@@ -264,7 +274,6 @@ XGRIDS_K1_PLUGIN_VERSION = "0.7.5"
|
|||||||
XGRIDS_K1_MODEL_ID = "xgrids.lixelkity-k1"
|
XGRIDS_K1_MODEL_ID = "xgrids.lixelkity-k1"
|
||||||
XGRIDS_K1_COMPATIBILITY_PROFILE_ID = "xgrids.lixelkity-k1.fw-3.0.2.local-network.v2"
|
XGRIDS_K1_COMPATIBILITY_PROFILE_ID = "xgrids.lixelkity-k1.fw-3.0.2.local-network.v2"
|
||||||
DEFAULT_ACQUISITION_CAMERA_SOURCE: CameraSourceId = "sensor.camera.right"
|
DEFAULT_ACQUISITION_CAMERA_SOURCE: CameraSourceId = "sensor.camera.right"
|
||||||
CONTROL_MQTT_PORT = 1883
|
|
||||||
CONTROL_ENDPOINT_PROBE_TIMEOUT_SECONDS = 1.5
|
CONTROL_ENDPOINT_PROBE_TIMEOUT_SECONDS = 1.5
|
||||||
CONTROL_ENDPOINT_ADMISSION_TIMEOUT_SECONDS = 8.0
|
CONTROL_ENDPOINT_ADMISSION_TIMEOUT_SECONDS = 8.0
|
||||||
CONTROL_ENDPOINT_ADMISSION_INTERVAL_SECONDS = 0.5
|
CONTROL_ENDPOINT_ADMISSION_INTERVAL_SECONDS = 0.5
|
||||||
@@ -425,12 +434,9 @@ ACTION_DISCOVERY_SCAN = "discovery.scan"
|
|||||||
ACTION_DEVICE_INSPECT = "device.inspect"
|
ACTION_DEVICE_INSPECT = "device.inspect"
|
||||||
ACTION_SENSOR_CATALOG_READ = "sensor.catalog.read"
|
ACTION_SENSOR_CATALOG_READ = "sensor.catalog.read"
|
||||||
ACTION_DEVICE_CALIBRATION_SNAPSHOT_READ = "calibration.device-snapshot.read"
|
ACTION_DEVICE_CALIBRATION_SNAPSHOT_READ = "calibration.device-snapshot.read"
|
||||||
ACTION_NETWORK_PROVISION = "network.provision"
|
|
||||||
ACTION_CONNECTION_MODE_SELECT = "connection.mode.select"
|
ACTION_CONNECTION_MODE_SELECT = "connection.mode.select"
|
||||||
ACTION_CONNECTION_RECONFIGURE_PREPARE = "connection.reconfigure.prepare"
|
ACTION_CONNECTION_RECONFIGURE_PREPARE = "connection.reconfigure.prepare"
|
||||||
ACTION_CONNECTION_VERIFY = "connection.verify"
|
|
||||||
ACTION_CONFIGURED_ENDPOINT_PROBE = "connection.endpoint-probe"
|
ACTION_CONFIGURED_ENDPOINT_PROBE = "connection.endpoint-probe"
|
||||||
ACTION_CONNECTION_CONTROL_BOOTSTRAP = "connection.control-bootstrap"
|
|
||||||
ACTION_ACQUISITION_PREPARE = "acquisition.prepare"
|
ACTION_ACQUISITION_PREPARE = "acquisition.prepare"
|
||||||
ACTION_ACQUISITION_START = "acquisition.start"
|
ACTION_ACQUISITION_START = "acquisition.start"
|
||||||
ACTION_ACQUISITION_STOP = "acquisition.stop"
|
ACTION_ACQUISITION_STOP = "acquisition.stop"
|
||||||
@@ -1014,7 +1020,7 @@ class _ConfiguredEndpointHostObservation:
|
|||||||
|
|
||||||
|
|
||||||
class BleScanRequest(StrictRequest):
|
class BleScanRequest(StrictRequest):
|
||||||
duration_seconds: float = Field(default=6.0, ge=1.0, le=60.0)
|
duration_seconds: float = Field(default=BLE_SCAN_DEFAULT_TIMEOUT_SECONDS, ge=1.0, le=60.0)
|
||||||
operation_id: str | None = Field(default=None, min_length=1, max_length=128)
|
operation_id: str | None = Field(default=None, min_length=1, max_length=128)
|
||||||
|
|
||||||
|
|
||||||
@@ -1476,6 +1482,7 @@ class XgridsK1CompatibilityService:
|
|||||||
application_authority_loader: ApplicationAuthorityLoader | None = None,
|
application_authority_loader: ApplicationAuthorityLoader | None = None,
|
||||||
calibration_snapshot_reader: DeviceCalibrationSnapshotReader | None = None,
|
calibration_snapshot_reader: DeviceCalibrationSnapshotReader | None = None,
|
||||||
host_wifi_association_probe: HostWifiAssociationProbe | None = None,
|
host_wifi_association_probe: HostWifiAssociationProbe | None = None,
|
||||||
|
visualization_bridge_factory: Callable[..., Any] | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
self.repository_root = repository_root.resolve()
|
self.repository_root = repository_root.resolve()
|
||||||
# Every CoreBluetooth entrypoint, including read-only calibration and
|
# Every CoreBluetooth entrypoint, including read-only calibration and
|
||||||
@@ -1876,6 +1883,7 @@ class XgridsK1CompatibilityService:
|
|||||||
physical_command_coordinator=self._physical_command_coordinator,
|
physical_command_coordinator=self._physical_command_coordinator,
|
||||||
)
|
)
|
||||||
self.runtime = VisualizationRuntime(
|
self.runtime = VisualizationRuntime(
|
||||||
|
bridge_factory=visualization_bridge_factory,
|
||||||
normalizer=normalize_k1_message,
|
normalizer=normalize_k1_message,
|
||||||
message_observer=self._observe_runtime_message,
|
message_observer=self._observe_runtime_message,
|
||||||
published_envelope_observer=self._observe_published_runtime_envelope,
|
published_envelope_observer=self._observe_published_runtime_envelope,
|
||||||
@@ -1886,6 +1894,7 @@ class XgridsK1CompatibilityService:
|
|||||||
self.camera_preview = XgridsK1CameraGateway(
|
self.camera_preview = XgridsK1CameraGateway(
|
||||||
self.repository_root,
|
self.repository_root,
|
||||||
XGRIDS_K1_PLUGIN_ID,
|
XGRIDS_K1_PLUGIN_ID,
|
||||||
|
evidence_root=self.evidence_root,
|
||||||
committed_segment_observer=self._observe_committed_camera_segment,
|
committed_segment_observer=self._observe_committed_camera_segment,
|
||||||
process_fence_descriptor_factory=(self._duplicate_camera_process_fence_descriptor),
|
process_fence_descriptor_factory=(self._duplicate_camera_process_fence_descriptor),
|
||||||
producer_stall_observer=self._observe_camera_producer_stall,
|
producer_stall_observer=self._observe_camera_producer_stall,
|
||||||
@@ -10190,19 +10199,27 @@ class XgridsK1CompatibilityService:
|
|||||||
f"Поиск завершён. Найдено BLE-устройств: {len(devices)}."
|
f"Поиск завершён. Найдено BLE-устройств: {len(devices)}."
|
||||||
)
|
)
|
||||||
self._operation_phase = None
|
self._operation_phase = None
|
||||||
|
discovery_result = {
|
||||||
|
"candidate_count": len(devices),
|
||||||
|
"likely_k1_candidate_count": sum(item["likely_k1"] is True for item in devices),
|
||||||
|
"duration_seconds": duration_seconds,
|
||||||
|
"discovery_generation": scan_generation,
|
||||||
|
**result.get("discovery_timing", {}),
|
||||||
|
}
|
||||||
|
logger.info(
|
||||||
|
"K1 BLE discovery completed",
|
||||||
|
extra={
|
||||||
|
"event_code": "k1_ble_discovery_completed",
|
||||||
|
"operation_id": operation.operation_id,
|
||||||
|
**discovery_result,
|
||||||
|
},
|
||||||
|
)
|
||||||
self._operations.transition(
|
self._operations.transition(
|
||||||
operation.operation_id,
|
operation.operation_id,
|
||||||
"succeeded",
|
"succeeded",
|
||||||
stage_code="completed",
|
stage_code="completed",
|
||||||
message_code="discovery.scan.completed",
|
message_code="discovery.scan.completed",
|
||||||
result={
|
result=discovery_result,
|
||||||
"candidate_count": len(devices),
|
|
||||||
"likely_k1_candidate_count": sum(
|
|
||||||
item["likely_k1"] is True for item in devices
|
|
||||||
),
|
|
||||||
"duration_seconds": duration_seconds,
|
|
||||||
"discovery_generation": scan_generation,
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
with self._lock:
|
with self._lock:
|
||||||
if (
|
if (
|
||||||
@@ -12143,6 +12160,14 @@ class XgridsK1CompatibilityService:
|
|||||||
safe_to_retry=not device_write_attempted,
|
safe_to_retry=not device_write_attempted,
|
||||||
host_boundary="corebluetooth" if isinstance(exc, BleakError) else None,
|
host_boundary="corebluetooth" if isinstance(exc, BleakError) else None,
|
||||||
)
|
)
|
||||||
|
station_failure_code = reviewed_station_failure_code(
|
||||||
|
operation_error,
|
||||||
|
firmware_version=request.compatibility_attestation.firmware_version,
|
||||||
|
connection_mode=request.connection_mode,
|
||||||
|
)
|
||||||
|
if station_failure_code is not None:
|
||||||
|
operation_error["transport_error_code"] = operation_error["code"]
|
||||||
|
operation_error["code"] = station_failure_code
|
||||||
if idempotency_record.stage == "prepared":
|
if idempotency_record.stage == "prepared":
|
||||||
cancelled = isinstance(exc, asyncio.CancelledError)
|
cancelled = isinstance(exc, asyncio.CancelledError)
|
||||||
idempotency_record = idempotency_journal.complete(
|
idempotency_record = idempotency_journal.complete(
|
||||||
@@ -12222,6 +12247,7 @@ class XgridsK1CompatibilityService:
|
|||||||
"connection_mode": request.connection_mode,
|
"connection_mode": request.connection_mode,
|
||||||
"error_category": operation_error["category"],
|
"error_category": operation_error["category"],
|
||||||
"error_code": operation_error["code"],
|
"error_code": operation_error["code"],
|
||||||
|
"transport_error_code": operation_error.get("transport_error_code"),
|
||||||
"safe_to_retry": operation_error["safe_to_retry"],
|
"safe_to_retry": operation_error["safe_to_retry"],
|
||||||
"side_effect_status": operation_error["side_effect_status"],
|
"side_effect_status": operation_error["side_effect_status"],
|
||||||
"network_change_attempted": device_write_attempted,
|
"network_change_attempted": device_write_attempted,
|
||||||
@@ -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:
|
def _provisioned_ipv4(result: Mapping[str, Any]) -> str | None:
|
||||||
observations = result.get("observations")
|
observations = result.get("observations")
|
||||||
if not isinstance(observations, list):
|
if not isinstance(observations, list):
|
||||||
@@ -35803,6 +35378,14 @@ def _inspect_host_path(target: str) -> HostPathProbeResult:
|
|||||||
if separator:
|
if separator:
|
||||||
fields[key] = value.strip()
|
fields[key] = value.strip()
|
||||||
|
|
||||||
|
if sys.platform.startswith("linux"):
|
||||||
|
from .linux_host import route_fields
|
||||||
|
|
||||||
|
try:
|
||||||
|
fields = route_fields(target)
|
||||||
|
except (OSError, ValueError, subprocess.SubprocessError):
|
||||||
|
inspection_reason = "host-route-inspection-unavailable"
|
||||||
|
|
||||||
interface = fields.get("interface") or None
|
interface = fields.get("interface") or None
|
||||||
destination = fields.get("destination", "")
|
destination = fields.get("destination", "")
|
||||||
gateway = fields.get("gateway", "")
|
gateway = fields.get("gateway", "")
|
||||||
@@ -35825,6 +35408,10 @@ def _host_route_class(target: str) -> str:
|
|||||||
"""Classify the host route without transmitting packets to the target."""
|
"""Classify the host route without transmitting packets to the target."""
|
||||||
|
|
||||||
target = validate_private_ipv4(target)
|
target = validate_private_ipv4(target)
|
||||||
|
if sys.platform.startswith("linux"):
|
||||||
|
path = _inspect_host_path(target)
|
||||||
|
return {"direct": "direct-or-routed", "default": "default-route",
|
||||||
|
"tunnel": "tunnel"}.get(path.route_class, "unknown")
|
||||||
if sys.platform != "darwin":
|
if sys.platform != "darwin":
|
||||||
return "unknown"
|
return "unknown"
|
||||||
try:
|
try:
|
||||||
@@ -35874,6 +35461,7 @@ def _classify_host_route(
|
|||||||
"wireguard",
|
"wireguard",
|
||||||
"gif",
|
"gif",
|
||||||
"stf",
|
"stf",
|
||||||
|
"tailscale",
|
||||||
)
|
)
|
||||||
direct_lan_prefixes = (
|
direct_lan_prefixes = (
|
||||||
"en",
|
"en",
|
||||||
@@ -35882,6 +35470,7 @@ def _classify_host_route(
|
|||||||
"vlan",
|
"vlan",
|
||||||
"usb",
|
"usb",
|
||||||
"p2p",
|
"p2p",
|
||||||
|
"wl",
|
||||||
)
|
)
|
||||||
if normalized_interface.startswith(tunnel_prefixes):
|
if normalized_interface.startswith(tunnel_prefixes):
|
||||||
return "tunnel", "host-route-tunnel"
|
return "tunnel", "host-route-tunnel"
|
||||||
|
|||||||
@@ -0,0 +1,214 @@
|
|||||||
|
"""Read-only Linux host adapters for the reviewed K1 Bridge workflow.
|
||||||
|
|
||||||
|
No host association or subnet discovery is available through this module.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import secrets
|
||||||
|
import stat
|
||||||
|
import subprocess
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from k1link.device_plugins.xgrids_k1.protocol.application_authority import (
|
||||||
|
ApplicationAuthorityLoadError,
|
||||||
|
ApplicationAuthoritySourceSnapshot,
|
||||||
|
)
|
||||||
|
from k1link.device_plugins.xgrids_k1.protocol.application_bootstrap import (
|
||||||
|
ApplicationControlAuthority,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _run(args: list[str], timeout: float = 8) -> str:
|
||||||
|
result = subprocess.run(args, capture_output=True, text=True, timeout=timeout, check=False)
|
||||||
|
if result.returncode:
|
||||||
|
# Never forward subprocess diagnostics: SSIDs or other local data may occur.
|
||||||
|
raise OSError("Network observation unavailable")
|
||||||
|
if len(result.stdout) > 131072:
|
||||||
|
raise OSError("Network observation exceeded its bound")
|
||||||
|
return result.stdout
|
||||||
|
|
||||||
|
|
||||||
|
def nm_fields(line: str) -> list[str]:
|
||||||
|
"""Parse nmcli terse escaping, including SSIDs containing colons/backslashes."""
|
||||||
|
fields, value, escaped = [], "", False
|
||||||
|
for char in line:
|
||||||
|
if escaped:
|
||||||
|
value += char
|
||||||
|
escaped = False
|
||||||
|
elif char == "\\":
|
||||||
|
escaped = True
|
||||||
|
elif char == ":":
|
||||||
|
fields.append(value)
|
||||||
|
value = ""
|
||||||
|
else:
|
||||||
|
value += char
|
||||||
|
if escaped:
|
||||||
|
value += "\\"
|
||||||
|
return [*fields, value]
|
||||||
|
|
||||||
|
|
||||||
|
def wifi_networks() -> list[dict]:
|
||||||
|
text = _run(
|
||||||
|
[
|
||||||
|
"nmcli",
|
||||||
|
"-t",
|
||||||
|
"--escape",
|
||||||
|
"yes",
|
||||||
|
"-f",
|
||||||
|
"SSID,SIGNAL,SECURITY",
|
||||||
|
"device",
|
||||||
|
"wifi",
|
||||||
|
"list",
|
||||||
|
"--rescan",
|
||||||
|
"yes",
|
||||||
|
],
|
||||||
|
timeout=20,
|
||||||
|
)
|
||||||
|
networks = {}
|
||||||
|
for line in text.splitlines():
|
||||||
|
fields = nm_fields(line)
|
||||||
|
if len(fields) != 3:
|
||||||
|
continue
|
||||||
|
ssid, strength, security = fields
|
||||||
|
if not ssid or not 1 <= len(ssid.encode()) <= 32 or not strength.isdigit():
|
||||||
|
continue
|
||||||
|
signal = min(100, max(0, int(strength)))
|
||||||
|
if signal >= networks.get(ssid, {}).get("signal", -1):
|
||||||
|
networks[ssid] = {"ssid": ssid, "signal": signal, "security": security}
|
||||||
|
return sorted(networks.values(), key=lambda v: (-v["signal"], v["ssid"]))[:64]
|
||||||
|
|
||||||
|
|
||||||
|
def route_fields(target: str) -> dict[str, str]:
|
||||||
|
# fibmatch returns the actual matched route (including "default"), whereas
|
||||||
|
# an ordinary `route get` resolves dst to the host even for a default route.
|
||||||
|
routes = json.loads(_run(["ip", "-j", "route", "get", target, "fibmatch"]))
|
||||||
|
if not isinstance(routes, list) or len(routes) != 1:
|
||||||
|
raise OSError("Ambiguous kernel route")
|
||||||
|
route = routes[0]
|
||||||
|
if route.get("type", "unicast") != "unicast" or not route.get("dev"):
|
||||||
|
raise OSError("No unicast route")
|
||||||
|
return {
|
||||||
|
"interface": str(route["dev"]),
|
||||||
|
"destination": str(route.get("dst", "")),
|
||||||
|
"gateway": str(route.get("gateway", "")),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class LinuxWifiAssociationProbe:
|
||||||
|
def __init__(self, sys_net: Path = Path("/sys/class/net")):
|
||||||
|
self.sys_net = sys_net
|
||||||
|
self.key = secrets.token_bytes(32)
|
||||||
|
|
||||||
|
def observe(self, *, interface_name: str | None, timeout_seconds: float = 30) -> dict:
|
||||||
|
result = {
|
||||||
|
"schema_version": 1,
|
||||||
|
"adapter": "linux-networkmanager",
|
||||||
|
"wifi_interface": None,
|
||||||
|
"association_state": "unavailable",
|
||||||
|
"evidence_quality": "unavailable",
|
||||||
|
"continuity_proven": False,
|
||||||
|
"continuity_token": "",
|
||||||
|
"reason_code": "host-wifi-observation-unavailable",
|
||||||
|
}
|
||||||
|
if not interface_name or not re.fullmatch(r"[A-Za-z0-9_.:-]{1,64}", interface_name):
|
||||||
|
return result
|
||||||
|
interface = self.sys_net / interface_name
|
||||||
|
if not interface.exists():
|
||||||
|
return result
|
||||||
|
wireless = (interface / "wireless").exists() or (interface / "phy80211").exists()
|
||||||
|
result["wifi_interface"] = wireless
|
||||||
|
if not wireless:
|
||||||
|
result.update(
|
||||||
|
association_state="not-wifi",
|
||||||
|
evidence_quality="not-wifi",
|
||||||
|
continuity_proven=True,
|
||||||
|
reason_code=None,
|
||||||
|
)
|
||||||
|
material = "not-wifi:" + interface_name
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
lines = _run(
|
||||||
|
[
|
||||||
|
"nmcli",
|
||||||
|
"-t",
|
||||||
|
"--escape",
|
||||||
|
"yes",
|
||||||
|
"-f",
|
||||||
|
"ACTIVE,SSID,BSSID",
|
||||||
|
"device",
|
||||||
|
"wifi",
|
||||||
|
"list",
|
||||||
|
"ifname",
|
||||||
|
interface_name,
|
||||||
|
"--rescan",
|
||||||
|
"no",
|
||||||
|
],
|
||||||
|
timeout=min(timeout_seconds, 8),
|
||||||
|
)
|
||||||
|
active = [nm_fields(line) for line in lines.splitlines() if line.startswith("yes:")]
|
||||||
|
if len(active) != 1 or len(active[0]) != 3 or not active[0][2]:
|
||||||
|
return result
|
||||||
|
except (OSError, subprocess.SubprocessError):
|
||||||
|
return result
|
||||||
|
result.update(
|
||||||
|
association_state="associated",
|
||||||
|
evidence_quality="ssid+bssid",
|
||||||
|
continuity_proven=True,
|
||||||
|
reason_code=None,
|
||||||
|
)
|
||||||
|
material = json.dumps([interface_name, *active[0][1:]], ensure_ascii=False)
|
||||||
|
result["continuity_token"] = hmac.new(
|
||||||
|
self.key, material.encode(), hashlib.sha256
|
||||||
|
).hexdigest()
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
class LinuxApplicationAuthorityLoader:
|
||||||
|
"""Read only a systemd-delivered credential, never an argv/env secret.
|
||||||
|
|
||||||
|
Provisioning of the encrypted credential is a separate privileged install
|
||||||
|
operation. Missing authority does not prevent discovery but prevents control.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, directory: Path | None = None):
|
||||||
|
self.directory = directory
|
||||||
|
|
||||||
|
def snapshot(self) -> ApplicationAuthoritySourceSnapshot:
|
||||||
|
return ApplicationAuthoritySourceSnapshot(
|
||||||
|
provider="linux-systemd-credential",
|
||||||
|
service="mission-core-k1.service",
|
||||||
|
account="k1-application",
|
||||||
|
)
|
||||||
|
|
||||||
|
def load(self) -> ApplicationControlAuthority:
|
||||||
|
directory = self.directory or Path(
|
||||||
|
os.environ.get("CREDENTIALS_DIRECTORY", "/run/credentials/mission-core-k1.service")
|
||||||
|
)
|
||||||
|
buffer = bytearray()
|
||||||
|
try:
|
||||||
|
fd = os.open(directory / "k1-application", os.O_RDONLY | os.O_NOFOLLOW)
|
||||||
|
with os.fdopen(fd, "rb") as stream:
|
||||||
|
info = os.fstat(stream.fileno())
|
||||||
|
if (
|
||||||
|
not stat.S_ISREG(info.st_mode)
|
||||||
|
or info.st_uid != os.geteuid()
|
||||||
|
or info.st_mode & 0o077
|
||||||
|
):
|
||||||
|
raise ValueError("Invalid credential permissions")
|
||||||
|
buffer.extend(stream.read(1025))
|
||||||
|
if len(buffer) > 1024:
|
||||||
|
raise ValueError("Credential too large")
|
||||||
|
return ApplicationControlAuthority(openapi_key=buffer.decode("ascii").strip())
|
||||||
|
except (OSError, ValueError, UnicodeError):
|
||||||
|
raise ApplicationAuthorityLoadError(
|
||||||
|
"Служебный ключ K1 не установлен на БК.",
|
||||||
|
reason_code="application_authority_unavailable",
|
||||||
|
) from None
|
||||||
|
finally:
|
||||||
|
buffer[:] = b"\0" * len(buffer)
|
||||||
@@ -0,0 +1,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)
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -30,6 +30,9 @@ class FleetRegistry:
|
|||||||
from .events import FleetEvents
|
from .events import FleetEvents
|
||||||
|
|
||||||
self.events = FleetEvents()
|
self.events = FleetEvents()
|
||||||
|
from .device_enrollment import DeviceEnrollment
|
||||||
|
|
||||||
|
self.device_enrollment = DeviceEnrollment()
|
||||||
self.trust = CoreTrust(root)
|
self.trust = CoreTrust(root)
|
||||||
path = root / "fleet.sqlite3"
|
path = root / "fleet.sqlite3"
|
||||||
if path.is_symlink():
|
if path.is_symlink():
|
||||||
@@ -378,6 +381,7 @@ class FleetRegistry:
|
|||||||
return 400, {"error": "Invalid Node inventory"}
|
return 400, {"error": "Invalid Node inventory"}
|
||||||
sensor_state = sensors.validate_inventory(value, node_id)
|
sensor_state = sensors.validate_inventory(value, node_id)
|
||||||
sensor_response = sensors.heartbeat(row, value)
|
sensor_response = sensors.heartbeat(row, value)
|
||||||
|
enrollment_response = self.device_enrollment.heartbeat(row, value)
|
||||||
host = value.get("host")
|
host = value.get("host")
|
||||||
if (
|
if (
|
||||||
not isinstance(host, dict)
|
not isinstance(host, dict)
|
||||||
@@ -403,4 +407,4 @@ class FleetRegistry:
|
|||||||
}
|
}
|
||||||
row["binding"]["client_pem"] = self.trust.leaf(node_id, key)
|
row["binding"]["client_pem"] = self.trust.leaf(node_id, key)
|
||||||
self.save(row)
|
self.save(row)
|
||||||
return 200, {"ok": True, "client_pem": row["binding"]["client_pem"], **sensor_response}
|
return 200, {"ok": True, "client_pem": row["binding"]["client_pem"], **sensor_response, **enrollment_response}
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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()
|
||||||
@@ -118,6 +118,7 @@ class RerunBridge:
|
|||||||
settings_provider: SettingsProvider | None = None,
|
settings_provider: SettingsProvider | None = None,
|
||||||
cors_allow_origin: tuple[str, ...] = DEFAULT_CORS_ORIGINS,
|
cors_allow_origin: tuple[str, ...] = DEFAULT_CORS_ORIGINS,
|
||||||
recording_factory: Callable[[str], rr.RecordingStream] | None = None,
|
recording_factory: Callable[[str], rr.RecordingStream] | None = None,
|
||||||
|
recording_output: Callable[[rr.RecordingStream], str] | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
self.metrics = metrics or BridgeMetrics()
|
self.metrics = metrics or BridgeMetrics()
|
||||||
self._settings_provider = settings_provider or RerunSceneSettings
|
self._settings_provider = settings_provider or RerunSceneSettings
|
||||||
@@ -130,31 +131,29 @@ class RerunBridge:
|
|||||||
else:
|
else:
|
||||||
recording = recording_factory("nodedc_mission_core_spatial")
|
recording = recording_factory("nodedc_mission_core_spatial")
|
||||||
try:
|
try:
|
||||||
selected_grpc_port = _select_available_grpc_port(grpc_port)
|
|
||||||
if selected_grpc_port != grpc_port:
|
|
||||||
logger.info(
|
|
||||||
"Mission Core selected a new Rerun port because an earlier "
|
|
||||||
"viewer still owns the preferred listener",
|
|
||||||
extra={
|
|
||||||
"event_code": "rerun_grpc_port_rotated",
|
|
||||||
"preferred_port": grpc_port,
|
|
||||||
"selected_port": selected_grpc_port,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
blueprint = _blueprint(self._settings)
|
blueprint = _blueprint(self._settings)
|
||||||
url = recording.serve_grpc(
|
if recording_output is not None:
|
||||||
grpc_port=selected_grpc_port,
|
# Node's paired WebRTC delivery uses an RRD sink and never opens
|
||||||
default_blueprint=blueprint,
|
# an unauthenticated gRPC listener on an onboard interface.
|
||||||
# This is a reconnect cushion for the live preview, not the source
|
url = recording_output(recording)
|
||||||
# of record. Raw MQTT evidence is persisted independently. A large
|
else:
|
||||||
# late-client backlog can block the native SDK and freeze preview.
|
selected_grpc_port = _select_available_grpc_port(grpc_port)
|
||||||
server_memory_limit=LIVE_GRPC_BUFFER_LIMIT,
|
if selected_grpc_port != grpc_port:
|
||||||
# Rerun transport can replay ActivateStore before StoreInfo when an
|
logger.info(
|
||||||
# evicted buffer is served newest-first, leaving late viewers on the
|
"Mission Core selected a new Rerun port because an earlier viewer "
|
||||||
# welcome screen. Preserve protocol order within the bounded cache.
|
"still owns the preferred listener",
|
||||||
newest_first=False,
|
extra={"event_code": "rerun_grpc_port_rotated", "preferred_port": grpc_port,
|
||||||
cors_allow_origin=list(cors_allow_origin),
|
"selected_port": selected_grpc_port},
|
||||||
)
|
)
|
||||||
|
url = recording.serve_grpc(
|
||||||
|
grpc_port=selected_grpc_port,
|
||||||
|
default_blueprint=blueprint,
|
||||||
|
# Bounded reconnect cushion, not the source of record.
|
||||||
|
server_memory_limit=LIVE_GRPC_BUFFER_LIMIT,
|
||||||
|
# StoreInfo must precede ActivateStore for late viewers.
|
||||||
|
newest_first=False,
|
||||||
|
cors_allow_origin=list(cors_allow_origin),
|
||||||
|
)
|
||||||
recording.send_blueprint(
|
recording.send_blueprint(
|
||||||
blueprint,
|
blueprint,
|
||||||
make_active=True,
|
make_active=True,
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user