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