feat(k1): complete canonical control lifecycle

This commit is contained in:
DCCONSTRUCTIONS 2026-07-19 01:07:02 +03:00
parent d7a2c22faf
commit ffffee1879
42 changed files with 3577 additions and 556 deletions

View File

@ -1,4 +1,5 @@
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import { after, before, test } from "node:test"; import { after, before, test } from "node:test";
import { createServer } from "vite"; import { createServer } from "vite";
@ -9,10 +10,14 @@ let createDevicePluginRegistry;
let xgridsK1Manifest; let xgridsK1Manifest;
let xgridsK1Actions; let xgridsK1Actions;
let xgridsK1Api; let xgridsK1Api;
let ApiError;
let localizeRuntimeMessage;
let lifecycle; let lifecycle;
let projectName; let projectName;
let automaticSourceStart; let automaticSourceStart;
let presentation; let presentation;
let operatorIntentGeneration;
let configuration;
before(async () => { before(async () => {
server = await createServer({ server = await createServer({
@ -41,9 +46,18 @@ before(async () => {
presentation = await server.ssrLoadModule( presentation = await server.ssrLoadModule(
"@xgrids-k1/frontend/presentation.ts", "@xgrids-k1/frontend/presentation.ts",
); );
({ xgridsK1Api } = await server.ssrLoadModule( operatorIntentGeneration = await server.ssrLoadModule(
"@xgrids-k1/frontend/operatorIntentGeneration.ts",
);
configuration = await server.ssrLoadModule(
"@xgrids-k1/frontend/configuration.ts",
);
({ xgridsK1Api, ApiError } = await server.ssrLoadModule(
"@xgrids-k1/frontend/api.ts", "@xgrids-k1/frontend/api.ts",
)); ));
({ localizeRuntimeMessage } = await server.ssrLoadModule(
"@xgrids-k1/frontend/messages.ts",
));
}); });
after(async () => { after(async () => {
@ -200,6 +214,151 @@ test("installed XGRIDS frontend manifest exposes the semantic v1alpha2 actions",
assert.equal(xgridsK1Actions.acquisitionStop, "acquisition.stop"); assert.equal(xgridsK1Actions.acquisitionStop, "acquisition.stop");
}); });
test("one operator action can open at most one K1 control session", () => {
assert.equal(lifecycle.controlSessionEntryPlan("idle", false, true), "open");
assert.equal(lifecycle.controlSessionEntryPlan("failed", false, true), "open");
assert.equal(lifecycle.controlSessionEntryPlan("failed", false, false), "failed");
assert.equal(lifecycle.controlSessionEntryPlan("failed", true, true), "failed");
assert.equal(
lifecycle.controlSessionEntryPlan("idle", true, true),
"duplicate-open",
);
assert.equal(
lifecycle.controlSessionEntryPlan("connection-ready", true, false),
"continue",
);
});
test("K1 operator intent stays invalid after runtime deactivate and reactivate", () => {
const generation = new operatorIntentGeneration.OperatorIntentGeneration();
generation.activateRuntime();
const oldIntent = generation.beginOperatorIntent();
assert.ok(oldIntent);
assert.equal(generation.isOperatorIntentCurrent(oldIntent), true);
generation.deactivateRuntime();
generation.activateRuntime();
const freshIntent = generation.beginOperatorIntent();
assert.ok(freshIntent);
assert.notEqual(freshIntent.runtimeGeneration, oldIntent.runtimeGeneration);
assert.equal(generation.isOperatorIntentCurrent(oldIntent), false);
assert.equal(generation.isOperatorIntentCurrent(freshIntent), true);
});
test("stale K1 intent cannot continue past an await after runtime reactivation", async () => {
const generation = new operatorIntentGeneration.OperatorIntentGeneration();
generation.activateRuntime();
const oldIntent = generation.beginOperatorIntent();
assert.ok(oldIntent);
let resolveOldRead;
const oldRead = new Promise((resolve) => {
resolveOldRead = resolve;
});
const writes = [];
const assertOldIntent = () => {
if (!generation.isOperatorIntentCurrent(oldIntent)) {
throw new Error("stale operator intent");
}
};
const oldContinuation = operatorIntentGeneration.awaitWhileIntentCurrent(
assertOldIntent,
() => oldRead,
).then(() => writes.push("old-checkpoint"));
generation.deactivateRuntime();
generation.activateRuntime();
const freshIntent = generation.beginOperatorIntent();
assert.ok(freshIntent);
resolveOldRead({ phase: "connection-ready" });
await assert.rejects(oldContinuation, /stale operator intent/);
assert.deepEqual(writes, []);
const assertFreshIntent = () => {
if (!generation.isOperatorIntentCurrent(freshIntent)) {
throw new Error("stale fresh intent");
}
};
await operatorIntentGeneration.awaitWhileIntentCurrent(
assertFreshIntent,
async () => ({ phase: "connection-ready" }),
);
writes.push("fresh-checkpoint");
assert.deepEqual(writes, ["fresh-checkpoint"]);
});
test("a fresh explicit K1 intent supersedes the previous intent in one runtime", () => {
const generation = new operatorIntentGeneration.OperatorIntentGeneration();
generation.activateRuntime();
const first = generation.beginOperatorIntent();
const second = generation.beginOperatorIntent();
assert.ok(first);
assert.ok(second);
assert.equal(first.runtimeGeneration, second.runtimeGeneration);
assert.notEqual(first.intentGeneration, second.intentGeneration);
assert.equal(generation.isOperatorIntentCurrent(first), false);
assert.equal(generation.isOperatorIntentCurrent(second), true);
});
test("canonical K1 launch wires the generation guard through every async stage", async () => {
const hookSource = await readFile(
new URL(
"../../../plugins/xgrids-k1/frontend/src/useXgridsK1Runtime.ts",
import.meta.url,
),
"utf8",
);
const canonicalStart = hookSource.slice(
hookSource.indexOf("const startCanonicalAcquisition"),
hookSource.indexOf("const prepareAcquisition"),
);
const pollingLoop = hookSource.slice(
hookSource.indexOf("async function waitForControlPhase"),
hookSource.indexOf("function messageFor"),
);
assert.doesNotMatch(hookSource, /mounted\.current/);
assert.match(canonicalStart, /beginOperatorIntent\(\)/);
assert.match(canonicalStart, /isOperatorIntentCurrent\(intentToken\)/);
assert.doesNotMatch(canonicalStart, /await xgridsK1Api\./);
assert.doesNotMatch(canonicalStart, /await waitForControlPhase\(/);
assert.ok(
canonicalStart.match(/awaitWhileIntentCurrent\(/g)?.length >= 8,
"each canonical REST/checkpoint boundary must use the intent guard",
);
assert.match(pollingLoop, /for \(;;\) \{\s*assertOperatorIntentCurrent\(\)/);
assert.equal(pollingLoop.match(/awaitWhileIntentCurrent\(/g)?.length, 2);
});
test("K1 control errors stay informative and only fetch failures mark transport unavailable", async () => {
assert.equal(
localizeRuntimeMessage(
"control MQTT connect call failed: [Errno 61] Connection refused",
),
"Управляющее соединение со сканером не открылось: устройство не приняло MQTT-соединение. Команды сканирования не отправлялись.",
);
const domainError = new ApiError("Диалог остановлен до команды START.");
assert.equal(domainError.transportUnavailable, false);
const originalFetch = globalThis.fetch;
globalThis.fetch = async () => {
throw new TypeError("synthetic network failure");
};
try {
await assert.rejects(
() => xgridsK1Api.getState(),
(error) => error instanceof ApiError && error.transportUnavailable === true,
);
} finally {
globalThis.fetch = originalFetch;
}
});
test("v1alpha2 compatibility profile is exact-key and rejects duplicated profile status", () => { test("v1alpha2 compatibility profile is exact-key and rejects duplicated profile status", () => {
const document = manifestDocument({ apiVersion: "missioncore.nodedc/v1alpha2" }); const document = manifestDocument({ apiVersion: "missioncore.nodedc/v1alpha2" });
document.spec.compatibilityProfiles[0].status = "verified"; document.spec.compatibilityProfiles[0].status = "verified";
@ -404,6 +563,48 @@ test("replay ignores a stale failed live acquisition", () => {
); );
}); });
test("K1 configuration anchors expose future modes without enabling them", () => {
assert.equal(configuration.SUPPORTED_CONNECTION_MODE, "bridge");
assert.equal(configuration.SUPPORTED_MOUNT_TYPE, "handheld");
assert.equal(configuration.SUPPORTED_GNSS_MODE, "none");
assert.deepEqual(
configuration.connectionModeOptions.map(({ value, disabled = false }) => ({ value, disabled })),
[
{ value: "bridge", disabled: false },
{ value: "quick-connect", disabled: true },
{ value: "direct-connect", disabled: true },
],
);
assert.deepEqual(
configuration.mountTypeOptions.map(({ value, disabled = false }) => ({ value, disabled })),
[
{ value: "handheld", disabled: false },
{ value: "vehicle-mounted", disabled: true },
{ value: "uav", disabled: true },
{ value: "backpack", disabled: true },
],
);
assert.deepEqual(
configuration.gnssModeOptions.map(({ value, disabled = false }) => ({ value, disabled })),
[
{ value: "none", disabled: false },
{ value: "rtk", disabled: true },
{ value: "ppk", disabled: true },
],
);
});
test("a provisioned address is not presented as a verified device connection", () => {
assert.equal(lifecycle.normalizeRuntimePhase({
phase: "connected",
application_control_session: { state: "idle" },
}), "configuring");
assert.equal(lifecycle.normalizeRuntimePhase({
phase: "connected",
application_control_session: { state: "connection-ready" },
}), "connected");
});
test("provisioning intent keeps one idempotency key and exposes unsafe outcomes", () => { test("provisioning intent keeps one idempotency key and exposes unsafe outcomes", () => {
let created = 0; let created = 0;
const createUuid = () => { const createUuid = () => {
@ -437,7 +638,7 @@ test("device mutations send explicit nested compatibility attestation", async ()
const attestation = { const attestation = {
firmware_version: "3.0.2", firmware_version: "3.0.2",
topology: "direct-lan", topology: "direct-lan",
operator_confirmed: true, verification: "live-device-info",
}; };
try { try {
@ -445,11 +646,14 @@ test("device mutations send explicit nested compatibility attestation", async ()
device_id: "ble-device", device_id: "ble-device",
ssid: "lab-network", ssid: "lab-network",
password: syntheticCredential, password: syntheticCredential,
connection_mode: "bridge",
compatibility_attestation: attestation, compatibility_attestation: attestation,
idempotency_key: "network-provision:test", idempotency_key: "network-provision:test",
}); });
await xgridsK1Api.prepareAcquisition({ await xgridsK1Api.prepareAcquisition({
project_name: "Mission 01", project_name: "Mission 01",
mount_type: "handheld",
gnss_mode: "none",
compatibility_attestation: attestation, compatibility_attestation: attestation,
}); });
} finally { } finally {

View File

@ -1,4 +1,3 @@
import { useCallback, useState } from "react";
import { Button, StatusBadge, type StatusTone } from "@nodedc/ui-react"; import { Button, StatusBadge, type StatusTone } from "@nodedc/ui-react";
import type { DevicePluginConnectionProps } from "@mission-core/plugin-sdk"; import type { DevicePluginConnectionProps } from "@mission-core/plugin-sdk";
@ -19,10 +18,6 @@ import { useXgridsK1Controller } from "./runtimeContext";
export function XgridsK1Connection({ model, host }: DevicePluginConnectionProps) { export function XgridsK1Connection({ model, host }: DevicePluginConnectionProps) {
const controller = useXgridsK1Controller(); const controller = useXgridsK1Controller();
const { state, error, refresh, clearError } = controller; const { state, error, refresh, clearError } = controller;
const [profileConfirmed, setProfileConfirmed] = useState(false);
const updateProfileConfirmation = useCallback((confirmed: boolean) => {
setProfileConfirmed(confirmed);
}, []);
const confirmedLive = isConfirmedLiveState(state); const confirmedLive = isConfirmedLiveState(state);
const sourceRuntimeBusy = isSourceRuntimeBusy(state); const sourceRuntimeBusy = isSourceRuntimeBusy(state);
@ -51,7 +46,7 @@ export function XgridsK1Connection({ model, host }: DevicePluginConnectionProps)
<span className="error-banner__dot" aria-hidden="true" /> <span className="error-banner__dot" aria-hidden="true" />
<div> <div>
<strong>Локальная операция завершилась ошибкой</strong> <strong>Локальная операция завершилась ошибкой</strong>
<p>{localizeRuntimeMessage(error)}</p> <p>{error}</p>
</div> </div>
<div className="error-banner__actions"> <div className="error-banner__actions">
<Button size="compact" variant="secondary" onClick={() => void refresh()}>Обновить состояние</Button> <Button size="compact" variant="secondary" onClick={() => void refresh()}>Обновить состояние</Button>
@ -79,13 +74,10 @@ export function XgridsK1Connection({ model, host }: DevicePluginConnectionProps)
controller={controller} controller={controller}
phaseLabel={connectionPhaseLabel} phaseLabel={connectionPhaseLabel}
phaseTone={connectionPhaseTone} phaseTone={connectionPhaseTone}
profileConfirmed={profileConfirmed}
onProfileConfirmedChange={updateProfileConfirmation}
/> />
<div className="device-workspace__side"> <div className="device-workspace__side">
<K1AcquisitionPipeline <K1AcquisitionPipeline
controller={controller} controller={controller}
profileConfirmed={profileConfirmed}
openSpatialScene={host.openSpatialScene} openSpatialScene={host.openSpatialScene}
activateAutomaticSpatialSource={host.activateAutomaticSpatialSource} activateAutomaticSpatialSource={host.activateAutomaticSpatialSource}
/> />

View File

@ -138,7 +138,34 @@ export interface XgridsApplicationControlSession {
outcome_unknown: boolean; outcome_unknown: boolean;
failure?: { failure?: {
code?: string; code?: string;
reason_code?: string;
message?: string; message?: string;
failed_phase?: string | null;
dialogue_stage?: string | null;
transport_state?: string | null;
publish_attempts?: number | null;
qos2_completions?: number | null;
correlated_responses?: number | null;
ignored_known_responses?: number | null;
late_known_responses?: number | null;
modeling_command_attempted?: boolean | null;
diagnostic_snapshot_unavailable?: string[];
diagnostic_evidence_unavailable?: string[];
correlation_failure?: {
phase?: string;
operation_key?: string;
response_topic?: string;
reason_code?: string;
reason?: string;
} | null;
compatibility_failure?: {
phase?: string;
operation_key?: string;
response_topic?: string;
reason_code?: string;
expected?: Record<string, unknown>;
observed?: Record<string, unknown>;
} | null;
safe_to_retry?: boolean; safe_to_retry?: boolean;
} | null; } | null;
dialogue?: Record<string, unknown> | null; dialogue?: Record<string, unknown> | null;
@ -153,6 +180,8 @@ export interface XgridsAcquisition {
compatibility_profile_id: string; compatibility_profile_id: string;
control_mode: "operator-manual" | "plugin-commanded" | "observe-only"; control_mode: "operator-manual" | "plugin-commanded" | "observe-only";
project_name?: string | null; project_name?: string | null;
mount_type?: "handheld" | null;
gnss_mode?: "none" | null;
cleanup_pending?: boolean; cleanup_pending?: boolean;
requested_streams: string[]; requested_streams: string[];
target_host: string; target_host: string;
@ -260,6 +289,7 @@ export interface XgridsK1State {
devices?: BleDevice[]; devices?: BleDevice[];
selected_device_id?: string | null; selected_device_id?: string | null;
k1_ip?: string | null; k1_ip?: string | null;
connection_mode?: "bridge" | null;
foxglove_ws_url?: string | null; foxglove_ws_url?: string | null;
foxglove_viewer_url?: string | null; foxglove_viewer_url?: string | null;
rerun_grpc_url?: string | null; rerun_grpc_url?: string | null;
@ -293,13 +323,14 @@ export interface ScanRequest {
export interface CompatibilityAttestation { export interface CompatibilityAttestation {
firmware_version: "3.0.2"; firmware_version: "3.0.2";
topology: "direct-lan"; topology: "direct-lan";
operator_confirmed: true; verification: "live-device-info";
} }
export interface ConnectRequest { export interface ConnectRequest {
device_id: string; device_id: string;
ssid: string; ssid: string;
password: string; password: string;
connection_mode: "bridge";
compatibility_attestation: CompatibilityAttestation; compatibility_attestation: CompatibilityAttestation;
operation_id?: string; operation_id?: string;
idempotency_key?: string; idempotency_key?: string;
@ -307,6 +338,8 @@ export interface ConnectRequest {
export interface PrepareAcquisitionRequest { export interface PrepareAcquisitionRequest {
project_name: string; project_name: string;
mount_type: "handheld";
gnss_mode: "none";
host?: string; host?: string;
duration_seconds?: number; duration_seconds?: number;
requested_streams?: RequestedStreamId[]; requested_streams?: RequestedStreamId[];
@ -398,11 +431,13 @@ export interface EnterApplicationWorkspaceRequest {
export class ApiError extends Error { export class ApiError extends Error {
readonly status: number; readonly status: number;
readonly transportUnavailable: boolean;
constructor(message: string, status = 0) { constructor(message: string, status = 0, transportUnavailable = false) {
super(message); super(message);
this.name = "ApiError"; this.name = "ApiError";
this.status = status; this.status = status;
this.transportUnavailable = transportUnavailable;
} }
} }
@ -433,7 +468,11 @@ async function requestJson(path: string, init?: RequestInit): Promise<unknown> {
}, },
}); });
} catch { } catch {
throw new ApiError("Не удалось подключиться к локальному сервису устройства."); throw new ApiError(
"Не удалось подключиться к локальному сервису устройства.",
0,
true,
);
} }
const bodyText = await response.text(); const bodyText = await response.text();

View File

@ -1,7 +1,7 @@
import type { CompatibilityAttestation } from "./api"; import type { CompatibilityAttestation } from "./api";
export const EXACT_PROFILE_ATTESTATION: CompatibilityAttestation = Object.freeze({ export const EXACT_PROFILE_SELECTION: CompatibilityAttestation = Object.freeze({
firmware_version: "3.0.2", firmware_version: "3.0.2",
topology: "direct-lan", topology: "direct-lan",
operator_confirmed: true, verification: "live-device-info",
}); });

View File

@ -4,13 +4,22 @@ import {
Checker, Checker,
GlassSurface, GlassSurface,
Icon, Icon,
Select,
SegmentedControl, SegmentedControl,
StatusBadge, StatusBadge,
TextField, TextField,
type StatusTone, type StatusTone,
} from "@nodedc/ui-react"; } from "@nodedc/ui-react";
import { EXACT_PROFILE_ATTESTATION } from "../compatibility"; import { EXACT_PROFILE_SELECTION } from "../compatibility";
import {
SUPPORTED_GNSS_MODE,
SUPPORTED_MOUNT_TYPE,
gnssModeOptions,
mountTypeOptions,
type GnssMode,
type MountType,
} from "../configuration";
import { runAutomaticSpatialSourceStart } from "../automaticSourceStart"; import { runAutomaticSpatialSourceStart } from "../automaticSourceStart";
import { import {
isConfirmedLiveState, isConfirmedLiveState,
@ -41,12 +50,10 @@ const PHYSICAL_ACCEPTANCE = {
export function K1AcquisitionPipeline({ export function K1AcquisitionPipeline({
controller, controller,
profileConfirmed,
openSpatialScene, openSpatialScene,
activateAutomaticSpatialSource, activateAutomaticSpatialSource,
}: { }: {
controller: XgridsK1Controller; controller: XgridsK1Controller;
profileConfirmed: boolean;
openSpatialScene: () => void; openSpatialScene: () => void;
activateAutomaticSpatialSource: () => void; activateAutomaticSpatialSource: () => void;
}) { }) {
@ -57,7 +64,6 @@ export function K1AcquisitionPipeline({
startCanonicalAcquisition, startCanonicalAcquisition,
startReplay, startReplay,
stop, stop,
confirmStoppedAtSteadyGreen,
abort, abort,
} = controller; } = controller;
const [sessionIntent, setSessionIntent] = useState<SessionIntent>("live"); const [sessionIntent, setSessionIntent] = useState<SessionIntent>("live");
@ -66,7 +72,8 @@ export function K1AcquisitionPipeline({
const [replayPath, setReplayPath] = useState(""); const [replayPath, setReplayPath] = useState("");
const [replaySpeed, setReplaySpeed] = useState("1"); const [replaySpeed, setReplaySpeed] = useState("1");
const [replayLoop, setReplayLoop] = useState(false); const [replayLoop, setReplayLoop] = useState(false);
const [physicalAcceptanceConfirmed, setPhysicalAcceptanceConfirmed] = useState(false); const [mountType, setMountType] = useState<MountType>(SUPPORTED_MOUNT_TYPE);
const [gnssMode, setGnssMode] = useState<GnssMode>(SUPPORTED_GNSS_MODE);
const hydratedAcquisitionId = useRef<string | null>(null); const hydratedAcquisitionId = useRef<string | null>(null);
const activeAcquisition = recoverableAcquisition(state); const activeAcquisition = recoverableAcquisition(state);
@ -125,8 +132,6 @@ export function K1AcquisitionPipeline({
const startLive = async () => { const startLive = async () => {
setProjectNameTouched(true); setProjectNameTouched(true);
if ( if (
!profileConfirmed ||
!physicalAcceptanceConfirmed ||
!state?.k1_ip || !state?.k1_ip ||
sourceRuntimeBusy || sourceRuntimeBusy ||
launchBlockedByAcquisition || launchBlockedByAcquisition ||
@ -142,7 +147,9 @@ export function K1AcquisitionPipeline({
}, },
acquisition: { acquisition: {
project_name: projectNameValidation.value, project_name: projectNameValidation.value,
compatibility_attestation: EXACT_PROFILE_ATTESTATION, mount_type: SUPPORTED_MOUNT_TYPE,
gnss_mode: SUPPORTED_GNSS_MODE,
compatibility_attestation: EXACT_PROFILE_SELECTION,
}, },
physicalAcceptance: PHYSICAL_ACCEPTANCE, physicalAcceptance: PHYSICAL_ACCEPTANCE,
}), }),
@ -181,6 +188,30 @@ export function K1AcquisitionPipeline({
/> />
{effectiveSessionIntent === "live" ? ( {effectiveSessionIntent === "live" ? (
<div className="session-form"> <div className="session-form">
<div className="scan-configuration-grid">
<div className="configuration-field">
<span className="nodedc-field__description">Тип установки / носитель</span>
<Select
label="Тип установки / носитель"
value={mountType}
options={mountTypeOptions}
onChange={setMountType}
disabled={isBusy || sessionLocked}
variant="split"
/>
</div>
<div className="configuration-field">
<span className="nodedc-field__description">Режим GNSS</span>
<Select
label="Режим GNSS"
value={gnssMode}
options={gnssModeOptions}
onChange={setGnssMode}
disabled={isBusy || sessionLocked}
variant="split"
/>
</div>
</div>
<TextField <TextField
label="Название проекта" label="Название проекта"
hint="Имя войдёт в единственный канонический START" hint="Имя войдёт в единственный канонический START"
@ -198,19 +229,12 @@ export function K1AcquisitionPipeline({
: "Отдельной команды сохранения имени на K1 нет: оно отправляется только при START."} : "Отдельной команды сохранения имени на K1 нет: оно отправляется только при START."}
placeholder="Например, TEST001" placeholder="Например, TEST001"
/> />
<Checker
checked={physicalAcceptanceConfirmed}
label="Я рядом с выбранным K1; LixelGO закрыт; питание и место для записи проверены; индикатор постоянно зелёный"
onChange={setPhysicalAcceptanceConfirmed}
/>
<Button <Button
variant="primary" variant="primary"
icon={<Icon name="activity" />} icon={<Icon name="activity" />}
disabled={ disabled={
isBusy || isBusy ||
!profileConfirmed ||
!state?.k1_ip || !state?.k1_ip ||
!physicalAcceptanceConfirmed ||
projectNameValidation.error !== null || projectNameValidation.error !== null ||
sourceRuntimeBusy || sourceRuntimeBusy ||
launchBlockedByAcquisition || launchBlockedByAcquisition ||
@ -232,6 +256,9 @@ export function K1AcquisitionPipeline({
? "Продолжить запуск сканирования и приёма" ? "Продолжить запуск сканирования и приёма"
: "Запустить сканирование и локальный приём"} : "Запустить сканирование и локальный приём"}
</Button> </Button>
<p className="start-confirmation-note">
Нажатие запуска явное операторское действие для выбранного K1. Автоматических повторов START нет.
</p>
{control?.control_socket_open && !activeAcquisition && !isBusy ? ( {control?.control_socket_open && !activeAcquisition && !isBusy ? (
<Button <Button
variant="ghost" variant="ghost"
@ -242,9 +269,7 @@ export function K1AcquisitionPipeline({
</Button> </Button>
) : null} ) : null}
<p className="live-instruction"> <p className="live-instruction">
{!profileConfirmed {controlPhase === "failed"
? "Сначала вручную подтвердите FW 3.0.2 и direct-LAN. Интерфейс не аттестует устройство автоматически."
: controlPhase === "failed"
? `Диалог остановлен без автоповтора: ${control?.failure?.message || "требуется ручная проверка K1"}` ? `Диалог остановлен без автоповтора: ${control?.failure?.message || "требуется ручная проверка K1"}`
: controlPhase === "connecting" : controlPhase === "connecting"
? "Выполняются операции 16 записанного диалога; следующий этап ждёт подтверждённый ответ K1." ? "Выполняются операции 16 записанного диалога; следующий этап ждёт подтверждённый ответ K1."
@ -256,7 +281,7 @@ export function K1AcquisitionPipeline({
? "Калибровка оборудования. Не перемещайте K1; временных переходов и повторных команд нет." ? "Калибровка оборудования. Не перемещайте K1; временных переходов и повторных команд нет."
: controlPhase === "scanning" : controlPhase === "scanning"
? "K1 подтвердил SCANNING и инициализацию. Остановка доступна в пространственной сцене." ? "K1 подтвердил SCANNING и инициализацию. Остановка доступна в пространственной сцене."
: "Одна кнопка выражает намерение запустить сканирование. Внутри этапы идут строго по записанному порядку и только после ответов K1; человеческие паузы из capture не воспроизводятся."} : "Одна кнопка выражает намерение запустить сканирование. Совместимость подтверждается живым DeviceInfo; этапы идут строго по записанному порядку и только после ответов K1."}
</p> </p>
</div> </div>
) : ( ) : (
@ -282,15 +307,6 @@ export function K1AcquisitionPipeline({
: "Остановка завершает только локальный приём и сохранение. Физическое состояние сканера остаётся неизвестным." : "Остановка завершает только локальный приём и сохранение. Физическое состояние сканера остаётся неизвестным."
: "Активного источника сейчас нет."} : "Активного источника сейчас нет."}
</p> </p>
{control?.can_confirm_standby ? (
<Button
variant="primary"
disabled={isBusy}
onClick={() => void confirmStoppedAtSteadyGreen()}
>
Индикатор постоянно зелёный завершить запись
</Button>
) : null}
<Button <Button
variant="secondary" variant="secondary"
disabled={isBusy || (!sourceRuntimeBusy && preparedAcquisition !== null) || (!sourceRuntimeBusy && activeAcquisition === null)} disabled={isBusy || (!sourceRuntimeBusy && preparedAcquisition !== null) || (!sourceRuntimeBusy && activeAcquisition === null)}

View File

@ -4,13 +4,19 @@ import {
Checker, Checker,
GlassSurface, GlassSurface,
Icon, Icon,
Select,
StatusBadge, StatusBadge,
TextField, TextField,
type StatusTone, type StatusTone,
} from "@nodedc/ui-react"; } from "@nodedc/ui-react";
import type { BleDevice } from "../api"; import type { BleDevice } from "../api";
import { EXACT_PROFILE_ATTESTATION } from "../compatibility"; import { EXACT_PROFILE_SELECTION } from "../compatibility";
import {
SUPPORTED_CONNECTION_MODE,
connectionModeOptions,
type ConnectionMode,
} from "../configuration";
import { provisioningIntentKey } from "../lifecycle"; import { provisioningIntentKey } from "../lifecycle";
import { finiteMetric } from "../presentation"; import { finiteMetric } from "../presentation";
import type { XgridsK1Controller } from "../runtimeContext"; import type { XgridsK1Controller } from "../runtimeContext";
@ -79,30 +85,28 @@ export function K1ProvisioningPipeline({
controller, controller,
phaseLabel, phaseLabel,
phaseTone, phaseTone,
profileConfirmed,
onProfileConfirmedChange,
}: { }: {
controller: XgridsK1Controller; controller: XgridsK1Controller;
phaseLabel: string; phaseLabel: string;
phaseTone: StatusTone; phaseTone: StatusTone;
profileConfirmed: boolean;
onProfileConfirmedChange: (confirmed: boolean) => void;
}) { }) {
const { state, pendingAction, scan, connect } = controller; const { state, pendingAction, scan, connect } = controller;
const [powerConfirmed, setPowerConfirmed] = useState(false); const [powerConfirmed, setPowerConfirmed] = useState(false);
const [selectedDeviceId, setSelectedDeviceId] = useState(""); const [selectedDeviceId, setSelectedDeviceId] = useState("");
const [ssid, setSsid] = useState(""); const [ssid, setSsid] = useState("");
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");
const [connectionMode, setConnectionMode] = useState<ConnectionMode>(
SUPPORTED_CONNECTION_MODE,
);
const provisioningIntentRef = useRef<string | null>(null); const provisioningIntentRef = useRef<string | null>(null);
const devices = state?.devices ?? []; const devices = state?.devices ?? [];
const isBusy = pendingAction !== null; const isBusy = pendingAction !== null;
const credentialsReady = ssid.trim().length > 0 && password.length > 0; const credentialsReady = ssid.trim().length > 0 && password.length > 0;
const canConnect = powerConfirmed && profileConfirmed && selectedDeviceId.length > 0 && credentialsReady && !isBusy; const canConnect = powerConfirmed && selectedDeviceId.length > 0 && credentialsReady && !isBusy;
useEffect(() => { useEffect(() => {
if (state?.selected_device_id) { if (state?.selected_device_id) {
if (state.selected_device_id !== selectedDeviceId) { if (state.selected_device_id !== selectedDeviceId) {
onProfileConfirmedChange(false);
provisioningIntentRef.current = null; provisioningIntentRef.current = null;
} }
setSelectedDeviceId(state.selected_device_id); setSelectedDeviceId(state.selected_device_id);
@ -111,15 +115,14 @@ export function K1ProvisioningPipeline({
if (selectedDeviceId && state?.devices && !state.devices.some((device) => device.device_id === selectedDeviceId)) { if (selectedDeviceId && state?.devices && !state.devices.some((device) => device.device_id === selectedDeviceId)) {
setSelectedDeviceId(""); setSelectedDeviceId("");
} }
}, [onProfileConfirmedChange, selectedDeviceId, state?.devices, state?.selected_device_id]); }, [selectedDeviceId, state?.devices, state?.selected_device_id]);
const deviceSummary = useMemo( const deviceSummary = useMemo(
() => devices.find((device) => device.device_id === selectedDeviceId), () => devices.find((device) => device.device_id === selectedDeviceId),
[devices, selectedDeviceId], [devices, selectedDeviceId],
); );
const resetProfile = () => { const resetProvisioningIntent = () => {
onProfileConfirmedChange(false);
provisioningIntentRef.current = null; provisioningIntentRef.current = null;
}; };
@ -131,7 +134,8 @@ export function K1ProvisioningPipeline({
device_id: selectedDeviceId, device_id: selectedDeviceId,
ssid: ssid.trim(), ssid: ssid.trim(),
password, password,
compatibility_attestation: EXACT_PROFILE_ATTESTATION, connection_mode: SUPPORTED_CONNECTION_MODE,
compatibility_attestation: EXACT_PROFILE_SELECTION,
idempotency_key: idempotencyKey, idempotency_key: idempotencyKey,
}); });
if (succeeded) { if (succeeded) {
@ -146,6 +150,22 @@ export function K1ProvisioningPipeline({
<div><span className="section-eyebrow">ПОДКЛЮЧЕНИЕ · ШАГИ 0103</span><h2>Подключите устройство к сети</h2></div> <div><span className="section-eyebrow">ПОДКЛЮЧЕНИЕ · ШАГИ 0103</span><h2>Подключите устройство к сети</h2></div>
<StatusBadge tone={phaseTone}>{phaseLabel}</StatusBadge> <StatusBadge tone={phaseTone}>{phaseLabel}</StatusBadge>
</header> </header>
<div className="configuration-anchor">
<span className="nodedc-field__description">
Неподтверждённые сетевые топологии уже отражены в интерфейсе, но не могут быть выбраны до отдельной приёмки.
</span>
<Select
label="Способ подключения"
value={connectionMode}
options={connectionModeOptions}
onChange={(value) => {
setConnectionMode(value);
resetProvisioningIntent();
}}
disabled={isBusy}
variant="split"
/>
</div>
<div className="wizard-list"> <div className="wizard-list">
<WizardStep number="01" title="Включите устройство" status={powerConfirmed ? "Подтверждено" : "Ожидает"} tone={powerConfirmed ? "success" : "warning"}> <WizardStep number="01" title="Включите устройство" status={powerConfirmed ? "Подтверждено" : "Ожидает"} tone={powerConfirmed ? "success" : "warning"}>
<div className="nodedc-field"> <div className="nodedc-field">
@ -153,7 +173,7 @@ export function K1ProvisioningPipeline({
<Checker <Checker
checked={powerConfirmed} checked={powerConfirmed}
label="Устройство включено, индикатор стабилен" label="Устройство включено, индикатор стабилен"
onChange={(checked) => { setPowerConfirmed(checked); if (!checked) resetProfile(); }} onChange={(checked) => { setPowerConfirmed(checked); if (!checked) resetProvisioningIntent(); }}
/> />
</div> </div>
</WizardStep> </WizardStep>
@ -163,13 +183,13 @@ export function K1ProvisioningPipeline({
status={pendingAction === "scan" ? "Поиск…" : selectedDeviceId ? "Устройство выбрано" : `Найдено: ${devices.length}`} status={pendingAction === "scan" ? "Поиск…" : selectedDeviceId ? "Устройство выбрано" : `Найдено: ${devices.length}`}
tone={pendingAction === "scan" ? "accent" : selectedDeviceId ? "success" : "neutral"} tone={pendingAction === "scan" ? "accent" : selectedDeviceId ? "success" : "neutral"}
> >
<p className="step-copy">Поиск занимает 6 секунд и показывает все видимые BLE-устройства. Метка кандидата основана только на имени; модель и прошивку подтверждает оператор.</p> <p className="step-copy">Поиск занимает 6 секунд и показывает все видимые BLE-устройства. Метка кандидата основана только на имени; точные модель, platform type и прошивка будут проверены по живому DeviceInfo перед START.</p>
<Button <Button
width="full" width="full"
variant="secondary" variant="secondary"
icon={<Icon name="search" />} icon={<Icon name="search" />}
disabled={!powerConfirmed || isBusy} disabled={!powerConfirmed || isBusy}
onClick={() => { setSelectedDeviceId(""); resetProfile(); void scan(); }} onClick={() => { setSelectedDeviceId(""); resetProvisioningIntent(); void scan(); }}
> >
{pendingAction === "scan" ? "Сканируем Bluetooth — 6 секунд…" : "Показать все BLE-устройства"} {pendingAction === "scan" ? "Сканируем Bluetooth — 6 секунд…" : "Показать все BLE-устройства"}
</Button> </Button>
@ -179,21 +199,18 @@ export function K1ProvisioningPipeline({
key={device.device_id} key={device.device_id}
device={device} device={device}
selected={device.device_id === selectedDeviceId} selected={device.device_id === selectedDeviceId}
onSelect={() => { setSelectedDeviceId(device.device_id); resetProfile(); }} onSelect={() => { setSelectedDeviceId(device.device_id); resetProvisioningIntent(); }}
/> />
)) : <div className="empty-device-list">Устройства пока не найдены. Проверьте питание и повторите поиск.</div>} )) : <div className="empty-device-list">Устройства пока не найдены. Проверьте питание и повторите поиск.</div>}
</div> </div>
</WizardStep> </WizardStep>
<WizardStep number="03" title="Передайте настройки WiFi" status={state?.k1_ip ? "Подключено" : "Не подключено"} tone={state?.k1_ip ? "success" : "neutral"}> <WizardStep
number="03"
title="Передайте настройки общей сети"
status={state?.k1_ip ? "Адрес ранее получен" : "Настройки не переданы"}
tone={state?.k1_ip ? "warning" : "neutral"}
>
<div className="field-stack"> <div className="field-stack">
<div className="nodedc-field">
<span className="nodedc-field__description">Mission Core не определяет прошивку автоматически. Подтвердите только точное соответствие профилю.</span>
<Checker
checked={profileConfirmed}
label="Я вручную подтвердил FW 3.0.2 и direct-LAN"
onChange={(checked) => { onProfileConfirmedChange(checked); provisioningIntentRef.current = null; }}
/>
</div>
<TextField label="Название сети WiFi" hint="SSID" value={ssid} onChange={(event) => { setSsid(event.target.value); provisioningIntentRef.current = null; }} autoComplete="off" spellCheck={false} placeholder="Сеть локального контура" /> <TextField label="Название сети WiFi" hint="SSID" value={ssid} onChange={(event) => { setSsid(event.target.value); provisioningIntentRef.current = null; }} autoComplete="off" spellCheck={false} placeholder="Сеть локального контура" />
<TextField label="Пароль WiFi" hint="Только в оперативной памяти" type="password" value={password} onChange={(event) => { setPassword(event.target.value); provisioningIntentRef.current = null; }} autoComplete="off" placeholder="Введите пароль" /> <TextField label="Пароль WiFi" hint="Только в оперативной памяти" type="password" value={password} onChange={(event) => { setPassword(event.target.value); provisioningIntentRef.current = null; }} autoComplete="off" placeholder="Введите пароль" />
</div> </div>
@ -201,7 +218,7 @@ export function K1ProvisioningPipeline({
<Button width="full" variant="primary" icon={<Icon name="network" />} disabled={!canConnect} onClick={() => void submitConnect()}> <Button width="full" variant="primary" icon={<Icon name="network" />} disabled={!canConnect} onClick={() => void submitConnect()}>
{pendingAction === "connect" ? "Подключаем…" : "Подключить устройство к WiFi"} {pendingAction === "connect" ? "Подключаем…" : "Подключить устройство к WiFi"}
</Button> </Button>
<p className="safety-note">Пароль передаётся только локальному сервису, не сохраняется в браузере и удаляется из формы после успеха.</p> <p className="safety-note">Наличие адреса подтверждает результат предыдущей настройки, но не текущее соединение. Пароль передаётся только локальному сервису, не сохраняется в браузере и удаляется из формы после успеха.</p>
</WizardStep> </WizardStep>
</div> </div>
</GlassSurface> </GlassSurface>

View File

@ -72,7 +72,7 @@ function phasePresentation(
awaiting_external_stop: { awaiting_external_stop: {
label: softwareCommanded ? "K1 завершает и сохраняет" : "Ожидание остановки на устройстве", label: softwareCommanded ? "K1 завершает и сохраняет" : "Ожидание остановки на устройстве",
detail: softwareCommanded detail: softwareCommanded
? "STOP не повторяется. Дождитесь READY и постоянного зелёного индикатора." ? "STOP не повторяется. Mission Core завершит запись после READY от K1."
: "Mission Core ждёт подтверждения физической остановки K1.", : "Mission Core ждёт подтверждения физической остановки K1.",
busy: true, busy: true,
}, },
@ -108,7 +108,7 @@ function formatDuration(seconds: number): string {
export function K1SpatialControls(_props: DevicePluginConnectionProps) { export function K1SpatialControls(_props: DevicePluginConnectionProps) {
const controller = useXgridsK1Controller(); const controller = useXgridsK1Controller();
const { state, pendingAction, stop, confirmStoppedAtSteadyGreen } = controller; const { state, pendingAction, stop } = controller;
const acquisition = state?.acquisition; const acquisition = state?.acquisition;
const cleanupPending = acquisition?.cleanup_pending === true; const cleanupPending = acquisition?.cleanup_pending === true;
if (!acquisition || !shouldRenderSpatialControls(state)) { if (!acquisition || !shouldRenderSpatialControls(state)) {
@ -179,16 +179,6 @@ export function K1SpatialControls(_props: DevicePluginConnectionProps) {
? "Повторить остановку" ? "Повторить остановку"
: softwareCommanded ? "Остановить устройство и запись" : "Остановить локальный приём"} : softwareCommanded ? "Остановить устройство и запись" : "Остановить локальный приём"}
</Button> </Button>
{state?.application_control_session?.can_confirm_standby ? (
<Button
size="compact"
variant="primary"
disabled={pendingAction !== null}
onClick={() => void confirmStoppedAtSteadyGreen()}
>
Индикатор постоянно зелёный завершить запись
</Button>
) : null}
</section> </section>
); );
} }

View File

@ -0,0 +1,75 @@
import type { SelectOption } from "@nodedc/ui-react";
export type ConnectionMode = "bridge" | "quick-connect" | "direct-connect";
export type MountType = "handheld" | "vehicle-mounted" | "uav" | "backpack";
export type GnssMode = "none" | "rtk" | "ppk";
export const SUPPORTED_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;
export const connectionModeOptions: Array<SelectOption<ConnectionMode>> = [
{
value: "bridge",
label: "Общая сеть · Bridge",
description: "K1 и Mission Core работают в одной локальной сети. Подтверждённый путь.",
},
{
value: "quick-connect",
label: "Точка доступа K1 · Quick Connect",
description: "Mission Core подключается к сети сканера. Будет доступно после отдельной приёмки.",
disabled: true,
},
{
value: "direct-connect",
label: "Хотспот контроллера · Direct Connect",
description: "K1 подключается к сети управляющего устройства. Будет доступно после отдельной приёмки.",
disabled: true,
},
];
export const mountTypeOptions: Array<SelectOption<MountType>> = [
{
value: "handheld",
label: "Ручной",
description: "Подтверждённый канонический профиль MountType.HANDHELD.",
},
{
value: "vehicle-mounted",
label: "На наземной платформе",
description: "Vehicle-Mounted. Недоступно до отдельной приёмки START/STOP.",
disabled: true,
},
{
value: "uav",
label: "На БПЛА",
description: "Drone/UAV. Недоступно до отдельной приёмки START/STOP.",
disabled: true,
},
{
value: "backpack",
label: "Ранцевый",
description: "Backpack. Недоступно до отдельной приёмки START/STOP.",
disabled: true,
},
];
export const gnssModeOptions: Array<SelectOption<GnssMode>> = [
{
value: "none",
label: "Без RTK",
description: "RTK/NTRIP читаются только для обнаружения возможностей и не включаются.",
},
{
value: "rtk",
label: "Использовать RTK",
description: "Недоступно до отдельного захвата настройки и физической приёмки.",
disabled: true,
},
{
value: "ppk",
label: "Использовать PPK",
description: "Недоступно до отдельного захвата настройки и физической приёмки.",
disabled: true,
},
];

View File

@ -2,6 +2,7 @@ import type { RuntimePhase, SourceMode as RuntimeSourceMode } from "@mission-cor
import type { import type {
AcquisitionState, AcquisitionState,
XgridsApplicationControlPhase,
XgridsAcquisition, XgridsAcquisition,
XgridsK1State, XgridsK1State,
XgridsOperation, XgridsOperation,
@ -22,6 +23,25 @@ const FAILED_OPERATION_STATUSES = new Set([
]); ]);
export type LiveStartPlan = "prepare" | "resume-prepared" | "already-running" | "blocked"; export type LiveStartPlan = "prepare" | "resume-prepared" | "already-running" | "blocked";
export type ControlSessionEntryPlan =
| "open"
| "continue"
| "failed"
| "duplicate-open";
export function controlSessionEntryPlan(
phase: XgridsApplicationControlPhase,
openedByCurrentOperatorAction: boolean,
canOpen: boolean,
): ControlSessionEntryPlan {
if (phase === "failed") {
return !openedByCurrentOperatorAction && canOpen ? "open" : "failed";
}
if (["idle", "closed", "completed"].includes(phase)) {
return openedByCurrentOperatorAction ? "duplicate-open" : "open";
}
return "continue";
}
export function isTerminalAcquisitionState( export function isTerminalAcquisitionState(
state: AcquisitionState | null | undefined, state: AcquisitionState | null | undefined,
@ -119,7 +139,12 @@ export function normalizeRuntimePhase(
} }
if (acquisitionState === "prepared") return "connected"; if (acquisitionState === "prepared") return "connected";
if (phase === "error") return "error"; if (phase === "error") return "error";
if (phase === "connected") return "connected"; if (phase === "connected") {
const controlPhase = state?.application_control_session?.state;
return controlPhase && !["idle", "connecting", "closed", "completed", "failed"].includes(controlPhase)
? "connected"
: "configuring";
}
if (phase === "starting_live") return "starting"; if (phase === "starting_live") return "starting";
if (phase === "live") return "streaming"; if (phase === "live") return "streaming";
if (phase === "replay") return "replaying"; if (phase === "replay") return "replaying";

View File

@ -1,5 +1,45 @@
// Vendor message normalization belongs to the XGRIDS frontend contribution. // Vendor message normalization belongs to the XGRIDS frontend contribution.
const runtimeMessageReplacements: Array<[RegExp, string]> = [ const runtimeMessageReplacements: Array<[RegExp, string]> = [
[
/^control MQTT connect call failed(?::.*)?$/gi,
"Управляющее соединение со сканером не открылось: устройство не приняло MQTT-соединение. Команды сканирования не отправлялись.",
],
[
/macOS Keychain authority is unavailable/gi,
"Локальный допуск управления K1 не подготовлен. Команды сканеру не отправлялись.",
],
[
/macOS Keychain authority lookup failed/gi,
"Локальный допуск управления K1 недоступен. Команды сканеру не отправлялись.",
],
[
/control MQTT connect call failed/gi,
"Управляющее соединение с K1 не открылось. Команды сканеру не отправлялись.",
],
[
/^Device plugin returned an invalid non-JSON action result$/gi,
"Плагин устройства сформировал некорректное внутреннее состояние. Запрос к K1 не повторялся.",
],
[
/^bootstrap response correlation failed.*$/gi,
"Ответ K1 на подготовительном этапе не совпал с ожидаемой операцией. Диалог остановлен без автоматического повтора.",
],
[
/^live DeviceInfo is incompatible with the selected.*$/gi,
"Устройство ответило, но его живые данные не соответствуют выбранному профилю совместимости. START и STOP не отправлялись.",
],
[
/^K1 did not confirm bound SCANNING initialization.*$/gi,
"После START устройство не подтвердило завершение инициализации в безопасный срок. Автоматический STOP не отправлялся.",
],
[
/^stale control response makes the next command outcome ambiguous$/gi,
"Получен запоздавший ответ предыдущего этапа. Диалог остановлен, следующая команда не отправлялась.",
],
[
/control MQTT connection\/subscription timed out/gi,
"Управляющее соединение с K1 не подтвердилось вовремя. Команды сканеру не отправлялись.",
],
[/broker connection ended/gi, "соединение с брокером завершено"], [/broker connection ended/gi, "соединение с брокером завершено"],
[/Unspecified error/gi, "неуказанная ошибка"], [/Unspecified error/gi, "неуказанная ошибка"],
[ [

View File

@ -0,0 +1,67 @@
export interface RuntimeGenerationToken {
readonly runtimeGeneration: number;
}
export interface OperatorIntentToken extends RuntimeGenerationToken {
readonly intentGeneration: number;
}
/**
* Invalidates asynchronous UI work across both plugin activation changes and
* successive explicit operator intents.
*
* A boolean "mounted" flag is insufficient here: an operation started before
* active=true -> false -> true would otherwise observe true again and resume.
*/
export class OperatorIntentGeneration {
private runtimeGeneration = 0;
private intentGeneration = 0;
private active = false;
activateRuntime(): RuntimeGenerationToken {
this.runtimeGeneration += 1;
this.active = true;
return Object.freeze({ runtimeGeneration: this.runtimeGeneration });
}
deactivateRuntime(): void {
if (!this.active) return;
this.runtimeGeneration += 1;
this.active = false;
}
captureRuntime(): RuntimeGenerationToken | null {
if (!this.active) return null;
return Object.freeze({ runtimeGeneration: this.runtimeGeneration });
}
beginOperatorIntent(): OperatorIntentToken | null {
if (!this.active) return null;
this.intentGeneration += 1;
return Object.freeze({
runtimeGeneration: this.runtimeGeneration,
intentGeneration: this.intentGeneration,
});
}
isRuntimeCurrent(token: RuntimeGenerationToken): boolean {
return this.active && token.runtimeGeneration === this.runtimeGeneration;
}
isOperatorIntentCurrent(token: OperatorIntentToken): boolean {
return (
this.isRuntimeCurrent(token)
&& token.intentGeneration === this.intentGeneration
);
}
}
export async function awaitWhileIntentCurrent<T>(
assertCurrent: () => void,
operation: () => Promise<T>,
): Promise<T> {
assertCurrent();
const result = await operation();
assertCurrent();
return result;
}

View File

@ -9,7 +9,7 @@ const phaseLabels: Record<string, string> = {
device_selected: "Устройство выбрано", device_selected: "Устройство выбрано",
provisioning: "Передача настроек WiFi", provisioning: "Передача настроек WiFi",
connecting: "Подключение", connecting: "Подключение",
connected: "Устройство подключено", connected: "Сетевой адрес устройства получен",
starting_live: "Запуск потока", starting_live: "Запуск потока",
live: "Поток в реальном времени", live: "Поток в реальном времени",
replay: "Повтор записи", replay: "Повтор записи",
@ -25,7 +25,8 @@ export function phaseLabel(phase: string | null | undefined): string {
export function phaseTone(phase: string | null | undefined): StatusTone { export function phaseTone(phase: string | null | undefined): StatusTone {
if (!phase) return "neutral"; if (!phase) return "neutral";
if (phase === "error") return "danger"; if (phase === "error") return "danger";
if (["connected", "live", "replay"].includes(phase)) return "success"; if (["live", "replay"].includes(phase)) return "success";
if (phase === "connected") return "warning";
if (["scanning", "provisioning", "connecting", "starting_live", "stopping"].includes(phase)) return "accent"; if (["scanning", "provisioning", "connecting", "starting_live", "stopping"].includes(phase)) return "accent";
return "neutral"; return "neutral";
} }

View File

@ -69,6 +69,20 @@
margin-top: 1.4rem; margin-top: 1.4rem;
} }
.configuration-anchor {
display: grid;
gap: 0.55rem;
margin-top: 1.2rem;
border-radius: 0.95rem;
background: rgb(255 255 255 / 0.028);
padding: 0.8rem;
}
.configuration-anchor .nodedc-select-anchor,
.configuration-field .nodedc-select-anchor {
width: 100%;
}
.wizard-step { .wizard-step {
display: grid; display: grid;
grid-template-columns: 2.2rem minmax(0, 1fr); grid-template-columns: 2.2rem minmax(0, 1fr);
@ -282,6 +296,25 @@
margin-top: 1rem; margin-top: 1rem;
} }
.scan-configuration-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.75rem;
}
.configuration-field {
display: grid;
min-width: 0;
gap: 0.45rem;
}
.start-confirmation-note {
margin: -0.25rem 0 0;
color: var(--nodedc-text-muted);
font-size: 0.6rem;
line-height: 1.45;
}
.session-form--replay { .session-form--replay {
grid-template-columns: minmax(0, 1.55fr) minmax(8rem, 0.45fr); grid-template-columns: minmax(0, 1.55fr) minmax(8rem, 0.45fr);
} }
@ -435,6 +468,10 @@
} }
@media (max-width: 760px) { @media (max-width: 760px) {
.xgrids-k1-plugin .scan-configuration-grid {
grid-template-columns: 1fr;
}
.xgrids-k1-plugin .session-form--replay { .xgrids-k1-plugin .session-form--replay {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }

View File

@ -16,6 +16,7 @@ import {
type XgridsK1State, type XgridsK1State,
} from "./api"; } from "./api";
import { import {
controlSessionEntryPlan,
isTerminalAcquisitionState, isTerminalAcquisitionState,
liveStartPlan, liveStartPlan,
operationByIdempotencyKey, operationByIdempotencyKey,
@ -23,6 +24,10 @@ import {
isSoftwareCommandedAcquisition, isSoftwareCommandedAcquisition,
} from "./lifecycle"; } from "./lifecycle";
import { localizeRuntimeMessage } from "./messages"; import { localizeRuntimeMessage } from "./messages";
import {
awaitWhileIntentCurrent,
OperatorIntentGeneration,
} from "./operatorIntentGeneration";
import { selectMonotonicXgridsState } from "./stateOrdering"; import { selectMonotonicXgridsState } from "./stateOrdering";
export type PendingAction = export type PendingAction =
@ -50,19 +55,103 @@ function controlPhase(state: XgridsK1State): XgridsApplicationControlPhase {
function controlFailure(state: XgridsK1State): ApiError { function controlFailure(state: XgridsK1State): ApiError {
const failure = state.application_control_session?.failure; const failure = state.application_control_session?.failure;
const localizedDetail = localizeRuntimeMessage(failure?.message);
const reasonLabels: Record<string, string> = {
application_authority_unavailable:
"Локальный допуск управления устройством недоступен; команды не отправлялись.",
mqtt_connect_call_failed:
"Управляющее MQTT-соединение со сканером не открылось.",
mqtt_connect_rejected:
"Сканер отклонил управляющее MQTT-соединение.",
mqtt_connection_timeout:
"Подключение или подписки MQTT не подтвердились вовремя.",
mqtt_subscription_failed:
"Сканер не подтвердил канонические MQTT-подписки.",
mqtt_response_timeout:
"Ожидаемый ответ сканера не пришёл до безопасной границы ожидания.",
response_identity_decode_failed:
"Ответ сканера не удалось безопасно разобрать и привязать к операции.",
modeling_response_decode_failed:
"Ответ START/STOP не удалось безопасно разобрать.",
duplicate_application_response:
"Сканер прислал повторный ответ на уже завершённую операцию.",
unexpected_response_identity:
"Получен ответ неизвестной операции; диалог остановлен.",
response_identity_mismatch:
"Идентичность ответа не совпала с ожидаемой операцией.",
response_session_mismatch:
"Session ответа не совпал с точной подготовительной операцией.",
response_device_identity_mismatch:
"Ответ относится не к тому экземпляру устройства.",
response_authority_mismatch:
"Ответ не совпал с локальным допуском приложения.",
response_rejected:
"Сканер отклонил подготовительную операцию.",
compatibility_profile_mismatch:
"Живой DeviceInfo не соответствует выбранному профилю модели, platform type, прошивки или активации.",
scan_initialization_timeout:
"После подтверждённого START сканер не завершил инициализацию в безопасный срок.",
operation_reuse_forbidden:
"Повтор уже использованной операции заблокирован.",
};
const reasonDetail = failure?.reason_code
? reasonLabels[failure.reason_code]
: undefined;
const stageLabels: Record<string, string> = {
connecting: "подключение и первичный диалог",
connection: "первичный диалог",
"connection-ready": "подключение подтверждено",
"workspace-requested": "вход в рабочую область",
"workspace-ready": "рабочая область готова",
"project-requested": "подготовка проекта",
"project-ready": "проект готов",
"start-requested": "подтверждение запуска",
"start-attempted": "команда запуска",
initializing: "калибровка оборудования",
scanning: "сканирование",
"stop-requested": "подтверждение остановки",
"stop-attempted": "команда остановки",
stopping: "остановка",
};
const stageCode = failure?.dialogue_stage || failure?.failed_phase;
const stage = stageCode
? stageLabels[stageCode] ?? "неопознанный этап канонического диалога"
: "этап не зафиксирован";
const commandStatus = failure?.modeling_command_attempted === true
? "Команда START или STOP могла быть отправлена; автоматический повтор запрещён."
: failure?.modeling_command_attempted === false
? "Команды START и STOP не отправлялись."
: "Факт отправки START или STOP диагностически не подтверждён; повтор запрещён.";
const retryStatus = failure?.safe_to_retry
? "Новая попытка возможна только отдельным нажатием оператора."
: "Повтор заблокирован до ручной проверки состояния.";
const exchanges = typeof failure?.publish_attempts === "number"
? `MQTT-публикаций до остановки: ${failure.publish_attempts}.`
: "Количество MQTT-публикаций не зафиксировано.";
const failedProtocolEvidence =
failure?.compatibility_failure ?? failure?.correlation_failure;
const failedOperation = failedProtocolEvidence?.operation_key
? `Шаг протокола: ${failedProtocolEvidence.operation_key}.`
: "";
const diagnostics = failure?.diagnostic_evidence_unavailable?.length
? "Часть диагностических доказательств недоступна; результат считается неизвестным."
: "";
return new ApiError( return new ApiError(
failure?.message `${reasonDetail || localizedDetail || "Канонический диалог K1 остановлен."} Этап: ${stage}. ${failedOperation} ${exchanges} ${commandStatus} ${diagnostics} ${retryStatus}`,
? `Канонический диалог K1 остановлен: ${failure.message}`
: "Канонический диалог K1 остановлен до запуска сканирования.",
); );
} }
async function waitForControlPhase( async function waitForControlPhase(
expected: XgridsApplicationControlPhase, expected: XgridsApplicationControlPhase,
acceptState: (state: XgridsK1State) => void, acceptState: (state: XgridsK1State) => void,
assertOperatorIntentCurrent: () => void,
): Promise<XgridsK1State> { ): Promise<XgridsK1State> {
for (;;) { for (;;) {
const nextState = await xgridsK1Api.getState(); assertOperatorIntentCurrent();
const nextState = await awaitWhileIntentCurrent(
assertOperatorIntentCurrent,
() => xgridsK1Api.getState(),
);
acceptState(nextState); acceptState(nextState);
const phase = controlPhase(nextState); const phase = controlPhase(nextState);
if (phase === expected) return nextState; if (phase === expected) return nextState;
@ -74,15 +163,22 @@ async function waitForControlPhase(
} }
// This cadence only reads local server state. It never schedules, retries, // This cadence only reads local server state. It never schedules, retries,
// or times a K1 command; every next write remains gated by device response. // or times a K1 command; every next write remains gated by device response.
await new Promise<void>((resolve) => { await awaitWhileIntentCurrent(
window.setTimeout(resolve, CONTROL_STATE_READ_INTERVAL_MS); assertOperatorIntentCurrent,
}); () => new Promise<void>((resolve) => {
window.setTimeout(resolve, CONTROL_STATE_READ_INTERVAL_MS);
}),
);
} }
} }
function messageFor(error: unknown): string { function messageFor(error: unknown): string {
if (error instanceof ApiError) { if (error instanceof ApiError) {
const message = localizeRuntimeMessage(error.message) ?? error.message; // Domain errors are already written for the operator. Only HTTP details
// originate at the backend and need vendor/runtime normalization.
const message = error.status
? localizeRuntimeMessage(error.message) ?? error.message
: error.message;
return error.status return error.status
? `${message} (HTTP ${error.status})` ? `${message} (HTTP ${error.status})`
: message; : message;
@ -113,8 +209,12 @@ export function useXgridsK1Runtime(enabled: boolean) {
const [pendingAction, setPendingAction] = useState<PendingAction | null>(null); const [pendingAction, setPendingAction] = useState<PendingAction | null>(null);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [latencyHistory, setLatencyHistory] = useState<number[]>([]); const [latencyHistory, setLatencyHistory] = useState<number[]>([]);
const mounted = useRef(true); const operatorIntents = useRef(new OperatorIntentGeneration());
const actionInFlight = useRef(false); const actionSequence = useRef(0);
const actionInFlight = useRef<{
runtimeGeneration: number;
actionSequence: number;
} | null>(null);
const acceptState = useCallback((nextState: XgridsK1State) => { const acceptState = useCallback((nextState: XgridsK1State) => {
setState((currentState) => selectMonotonicXgridsState(currentState, nextState)); setState((currentState) => selectMonotonicXgridsState(currentState, nextState));
@ -122,13 +222,14 @@ export function useXgridsK1Runtime(enabled: boolean) {
}, []); }, []);
const refresh = useCallback(async (reportErrors = true) => { const refresh = useCallback(async (reportErrors = true) => {
if (!enabled) return; const runtimeToken = operatorIntents.current.captureRuntime();
if (!enabled || !runtimeToken) return;
const [healthResult, stateResult] = await Promise.allSettled([ const [healthResult, stateResult] = await Promise.allSettled([
xgridsK1Api.getHealth(), xgridsK1Api.getHealth(),
xgridsK1Api.getState(), xgridsK1Api.getState(),
]); ]);
if (!mounted.current) return; if (!operatorIntents.current.isRuntimeCurrent(runtimeToken)) return;
if (stateResult.status === "fulfilled") { if (stateResult.status === "fulfilled") {
acceptState(stateResult.value); acceptState(stateResult.value);
@ -150,27 +251,48 @@ export function useXgridsK1Runtime(enabled: boolean) {
const run = useCallback( const run = useCallback(
async (action: PendingAction, operation: () => Promise<XgridsK1State>) => { async (action: PendingAction, operation: () => Promise<XgridsK1State>) => {
if (!enabled) return false; const runtimeToken = operatorIntents.current.captureRuntime();
if (actionInFlight.current) return false; if (!enabled || !runtimeToken) return false;
actionInFlight.current = true; if (
actionInFlight.current?.runtimeGeneration
=== runtimeToken.runtimeGeneration
) return false;
actionSequence.current += 1;
const actionToken = {
runtimeGeneration: runtimeToken.runtimeGeneration,
actionSequence: actionSequence.current,
};
actionInFlight.current = actionToken;
setPendingAction(action); setPendingAction(action);
setError(null); setError(null);
try { try {
if (!operatorIntents.current.isRuntimeCurrent(runtimeToken)) return false;
const nextState = await operation(); const nextState = await operation();
if (mounted.current) acceptState(nextState); if (!operatorIntents.current.isRuntimeCurrent(runtimeToken)) return false;
acceptState(nextState);
return true; return true;
} catch (operationError) { } catch (operationError) {
if (mounted.current) { if (operatorIntents.current.isRuntimeCurrent(runtimeToken)) {
setError(messageFor(operationError)); setError(messageFor(operationError));
if (operationError instanceof ApiError && operationError.status === 0) { if (
operationError instanceof ApiError
&& operationError.transportUnavailable
) {
setBackendStatus("offline"); setBackendStatus("offline");
} }
} }
return false; return false;
} finally { } finally {
actionInFlight.current = false; if (
if (mounted.current) setPendingAction(null); actionInFlight.current?.runtimeGeneration === actionToken.runtimeGeneration
&& actionInFlight.current.actionSequence === actionToken.actionSequence
) {
actionInFlight.current = null;
if (operatorIntents.current.isRuntimeCurrent(runtimeToken)) {
setPendingAction(null);
}
}
} }
}, },
[acceptState, enabled], [acceptState, enabled],
@ -237,7 +359,24 @@ export function useXgridsK1Runtime(enabled: boolean) {
const startCanonicalAcquisition = useCallback( const startCanonicalAcquisition = useCallback(
(request: CanonicalLiveStartRequest) => (request: CanonicalLiveStartRequest) =>
run("live", async () => { run("live", async () => {
let nextState = await xgridsK1Api.getState(); const intentToken = operatorIntents.current.beginOperatorIntent();
if (!intentToken) {
throw new ApiError(
"Экран управления закрыт; дальнейшие команды канонического диалога не отправлялись.",
);
}
const assertOperatorIntentCurrent = () => {
if (!operatorIntents.current.isOperatorIntentCurrent(intentToken)) {
throw new ApiError(
"Операторское действие завершено или заменено; дальнейшие команды канонического диалога не отправлялись.",
);
}
};
let nextState = await awaitWhileIntentCurrent(
assertOperatorIntentCurrent,
() => xgridsK1Api.getState(),
);
let openedControlSession = false;
acceptState(nextState); acceptState(nextState);
const plan = liveStartPlan(nextState); const plan = liveStartPlan(nextState);
if (plan === "blocked") { if (plan === "blocked") {
@ -246,50 +385,84 @@ export function useXgridsK1Runtime(enabled: boolean) {
if (plan === "already-running") return nextState; if (plan === "already-running") return nextState;
for (;;) { for (;;) {
assertOperatorIntentCurrent();
const phase = controlPhase(nextState); const phase = controlPhase(nextState);
const acquisition = nextState.acquisition; const acquisition = nextState.acquisition;
const entryPlan = controlSessionEntryPlan(
phase,
openedControlSession,
nextState.application_control_session?.can_open === true,
);
if (["idle", "closed", "completed"].includes(phase)) { if (entryPlan === "failed") {
// A failed dialogue always ends this operator intent. Even when
// backend reconciliation says a fresh attempt may be safe, that
// attempt requires another explicit click.
throw controlFailure(nextState);
}
if (entryPlan === "duplicate-open") {
throw new ApiError(
"Управляющая сессия завершилась сразу после открытия. Автоматический повтор заблокирован; проверьте состояние и повторите только отдельным нажатием.",
);
}
if (entryPlan === "open") {
if (acquisition && !isTerminalAcquisitionState(acquisition.state)) { if (acquisition && !isTerminalAcquisitionState(acquisition.state)) {
throw new ApiError( throw new ApiError(
"Незавершённая подготовка не привязана к открытой control-сессии. Отмените её перед новым запуском.", "Незавершённая подготовка не привязана к открытой control-сессии. Отмените её перед новым запуском.",
); );
} }
nextState = await xgridsK1Api.openApplicationControlSession(request.control); openedControlSession = true;
acceptState(nextState); nextState = await awaitWhileIntentCurrent(
continue; assertOperatorIntentCurrent,
} () => xgridsK1Api.openApplicationControlSession(request.control),
);
if (phase === "failed") {
if (nextState.application_control_session?.can_open !== true) {
throw controlFailure(nextState);
}
nextState = await xgridsK1Api.openApplicationControlSession(request.control);
acceptState(nextState); acceptState(nextState);
continue; continue;
} }
if (phase === "connecting") { if (phase === "connecting") {
nextState = await waitForControlPhase("connection-ready", acceptState); nextState = await awaitWhileIntentCurrent(
assertOperatorIntentCurrent,
() => waitForControlPhase(
"connection-ready",
acceptState,
assertOperatorIntentCurrent,
),
);
continue; continue;
} }
if (phase === "connection-ready") { if (phase === "connection-ready") {
nextState = await xgridsK1Api.enterApplicationWorkspace({ nextState = await awaitWhileIntentCurrent(
operator_confirmed: true, assertOperatorIntentCurrent,
}); () => xgridsK1Api.enterApplicationWorkspace({
operator_confirmed: true,
}),
);
acceptState(nextState); acceptState(nextState);
continue; continue;
} }
if (phase === "workspace-requested") { if (phase === "workspace-requested") {
nextState = await waitForControlPhase("workspace-ready", acceptState); nextState = await awaitWhileIntentCurrent(
assertOperatorIntentCurrent,
() => waitForControlPhase(
"workspace-ready",
acceptState,
assertOperatorIntentCurrent,
),
);
continue; continue;
} }
if (phase === "workspace-ready") { if (phase === "workspace-ready") {
if (!acquisition || isTerminalAcquisitionState(acquisition.state)) { if (!acquisition || isTerminalAcquisitionState(acquisition.state)) {
nextState = await xgridsK1Api.prepareAcquisition(request.acquisition); nextState = await awaitWhileIntentCurrent(
assertOperatorIntentCurrent,
() => xgridsK1Api.prepareAcquisition(request.acquisition),
);
acceptState(nextState); acceptState(nextState);
continue; continue;
} }
@ -298,12 +471,26 @@ export function useXgridsK1Runtime(enabled: boolean) {
"Текущая подготовка не принадлежит канонической control-сессии K1.", "Текущая подготовка не принадлежит канонической control-сессии K1.",
); );
} }
nextState = await waitForControlPhase("project-ready", acceptState); nextState = await awaitWhileIntentCurrent(
assertOperatorIntentCurrent,
() => waitForControlPhase(
"project-ready",
acceptState,
assertOperatorIntentCurrent,
),
);
continue; continue;
} }
if (phase === "project-requested") { if (phase === "project-requested") {
nextState = await waitForControlPhase("project-ready", acceptState); nextState = await awaitWhileIntentCurrent(
assertOperatorIntentCurrent,
() => waitForControlPhase(
"project-ready",
acceptState,
assertOperatorIntentCurrent,
),
);
continue; continue;
} }
@ -311,11 +498,14 @@ export function useXgridsK1Runtime(enabled: boolean) {
if (!acquisition || acquisition.state !== "prepared") { if (!acquisition || acquisition.state !== "prepared") {
throw new ApiError("Локальный приём не подготовлен к каноническому START."); throw new ApiError("Локальный приём не подготовлен к каноническому START.");
} }
nextState = await xgridsK1Api.startAcquisition({ nextState = await awaitWhileIntentCurrent(
acquisition_id: acquisition.acquisition_id, assertOperatorIntentCurrent,
expected_state_revision: acquisition.state_revision, () => xgridsK1Api.startAcquisition({
physical_acceptance: request.physicalAcceptance, acquisition_id: acquisition.acquisition_id,
}); expected_state_revision: acquisition.state_revision,
physical_acceptance: request.physicalAcceptance,
}),
);
acceptState(nextState); acceptState(nextState);
return nextState; return nextState;
} }
@ -396,33 +586,6 @@ export function useXgridsK1Runtime(enabled: boolean) {
[run, state], [run, state],
); );
const confirmStoppedAtSteadyGreen = useCallback(
() =>
run("stop", () => {
const acquisition = state?.acquisition;
if (!acquisition || acquisition.state !== "awaiting_external_stop") {
throw new ApiError("K1 сейчас не ожидает подтверждения завершённого STOP.");
}
const stopOperation = [...(state?.operations ?? [])]
.reverse()
.find(
(operation) =>
operation.action === "acquisition.stop" &&
operation.status === "operator_action_required",
);
if (!stopOperation) {
throw new ApiError("Не найдена исходная операция STOP; повтор команды запрещён.");
}
return xgridsK1Api.stopAcquisition({
acquisition_id: acquisition.acquisition_id,
mode: "graceful",
operator_confirmed: true,
operation_id: stopOperation.operation_id,
});
}),
[run, state],
);
const abort = useCallback(() => { const abort = useCallback(() => {
const acquisition = state?.acquisition; const acquisition = state?.acquisition;
if (!acquisition || isTerminalAcquisitionState(acquisition.state)) { if (!acquisition || isTerminalAcquisitionState(acquisition.state)) {
@ -485,7 +648,7 @@ export function useXgridsK1Runtime(enabled: boolean) {
useEffect(() => { useEffect(() => {
if (!enabled) { if (!enabled) {
mounted.current = true; operatorIntents.current.deactivateRuntime();
setState(null); setState(null);
setBackendStatus("checking"); setBackendStatus("checking");
setEventStatus("closed"); setEventStatus("closed");
@ -495,12 +658,12 @@ export function useXgridsK1Runtime(enabled: boolean) {
return; return;
} }
mounted.current = true; operatorIntents.current.activateRuntime();
void refresh(true); void refresh(true);
const poll = window.setInterval(() => void refresh(false), 4_000); const poll = window.setInterval(() => void refresh(false), 4_000);
return () => { return () => {
mounted.current = false; operatorIntents.current.deactivateRuntime();
window.clearInterval(poll); window.clearInterval(poll);
}; };
}, [enabled, refresh]); }, [enabled, refresh]);
@ -563,7 +726,6 @@ export function useXgridsK1Runtime(enabled: boolean) {
startPreparedAcquisition, startPreparedAcquisition,
startReplay, startReplay,
stop, stop,
confirmStoppedAtSteadyGreen,
abort, abort,
setObservationSourceActive, setObservationSourceActive,
updateViewerSettings, updateViewerSettings,

View File

@ -366,6 +366,23 @@ def _validate_acquisition_control(profile: dict[str, Any]) -> None:
if control.get("mode") != "operator-manual" or control.get("write_enabled") is not False: if control.get("mode") != "operator-manual" or control.get("write_enabled") is not False:
raise CompatibilityProfileError("acquisition control must remain operator-manual") raise CompatibilityProfileError("acquisition control must remain operator-manual")
acceptance_transport = _object(
control.get("software_acceptance_transport"),
"$.acquisition_control.software_acceptance_transport",
)
if acceptance_transport != {
"status": "installed-operator-present",
"default_authority": "disabled",
"profile_gate": "live-device-info-exact-match",
"dialogue": "single-socket-canonical-start-to-stop",
"automatic_retry": False,
"supported_mount_type": "handheld",
"supported_gnss_mode": "none",
}:
raise CompatibilityProfileError(
"software acceptance transport differs from the reviewed operator-present contract"
)
device_control = _object( device_control = _object(
control.get("verified_device_control"), control.get("verified_device_control"),
"$.acquisition_control.verified_device_control", "$.acquisition_control.verified_device_control",
@ -497,6 +514,8 @@ def validate_compatibility_profile(profile: Any) -> dict[str, Any]:
scope = _object(root.get("scope"), "$.scope") scope = _object(root.get("scope"), "$.scope")
if scope.get("vendor") != "XGRIDS" or scope.get("model") != "LixelKity K1": if scope.get("vendor") != "XGRIDS" or scope.get("model") != "LixelKity K1":
raise CompatibilityProfileError("profile vendor/model must remain XGRIDS LixelKity K1") raise CompatibilityProfileError("profile vendor/model must remain XGRIDS LixelKity K1")
if scope.get("platform_type") != "A4":
raise CompatibilityProfileError("profile platform type must remain the observed A4")
firmware = _object(scope.get("firmware"), "$.scope.firmware") firmware = _object(scope.get("firmware"), "$.scope.firmware")
if firmware != {"match": "exact", "version": "3.0.2"}: if firmware != {"match": "exact", "version": "3.0.2"}:
raise CompatibilityProfileError("profile must match firmware 3.0.2 exactly") raise CompatibilityProfileError("profile must match firmware 3.0.2 exactly")

View File

@ -5,6 +5,7 @@
"scope": { "scope": {
"vendor": "XGRIDS", "vendor": "XGRIDS",
"model": "LixelKity K1", "model": "LixelKity K1",
"platform_type": "A4",
"firmware": { "firmware": {
"match": "exact", "match": "exact",
"version": "3.0.2" "version": "3.0.2"
@ -26,8 +27,8 @@
"request_topic_subscription_enabled": false, "request_topic_subscription_enabled": false,
"notes": [ "notes": [
"Loading this descriptive profile does not authorize a BLE or MQTT write.", "Loading this descriptive profile does not authorize a BLE or MQTT write.",
"The existing reviewed Wi-Fi provisioning procedure remains separately operator-confirmed and is not activated by this profile.", "The reviewed Wi-Fi provisioning procedure remains a separate explicit operator action and is not activated by loading this profile.",
"Observed LixelGO modeling requests describe the wire contract but remain non-replayable and write-disabled.", "Observed LixelGO modeling requests remain write-disabled in this descriptive profile; the separately installed acceptance transport requires a live DeviceInfo match and one operator-present action permit.",
"Unknown firmware, transport, topics, fields, and action responses fail closed." "Unknown firmware, transport, topics, fields, and action responses fail closed."
] ]
}, },
@ -316,6 +317,15 @@
"acquisition_control": { "acquisition_control": {
"mode": "operator-manual", "mode": "operator-manual",
"write_enabled": false, "write_enabled": false,
"software_acceptance_transport": {
"status": "installed-operator-present",
"default_authority": "disabled",
"profile_gate": "live-device-info-exact-match",
"dialogue": "single-socket-canonical-start-to-stop",
"automatic_retry": false,
"supported_mount_type": "handheld",
"supported_gnss_mode": "none"
},
"verified_device_control": { "verified_device_control": {
"gesture": "physical-double-click", "gesture": "physical-double-click",
"state_dependent_result": "start from steady-green standby; stop during active scanning", "state_dependent_result": "start from steady-green standby; stop during active scanning",
@ -359,7 +369,7 @@
}, },
"success_result_code": 302252033, "success_result_code": 302252033,
"required_unresolved_context": [ "required_unresolved_context": [
"operator-owned Keychain item provisioning and physical acceptance of the uninstalled reviewed transport", "operator-owned Keychain authority provisioning and operator-present physical acceptance",
"authorization policy for any setting outside the retained request", "authorization policy for any setting outside the retained request",
"timeout, rejection and rollback contract" "timeout, rejection and rollback contract"
], ],
@ -406,7 +416,7 @@
"request_fields": {}, "request_fields": {},
"success_result_code": 302252033, "success_result_code": 302252033,
"required_unresolved_context": [ "required_unresolved_context": [
"operator-owned Keychain item provisioning and physical acceptance of the uninstalled reviewed transport", "operator-owned Keychain authority provisioning and operator-present physical acceptance",
"save-completion and final-standby state mapping", "save-completion and final-standby state mapping",
"timeout and rollback contract" "timeout and rollback contract"
], ],

View File

@ -90,6 +90,7 @@ class XgridsK1CameraGateway:
self._target_host: str | None = None self._target_host: str | None = None
self._producer: _CameraProducer | None = None self._producer: _CameraProducer | None = None
self._recording_root: Path | None = None self._recording_root: Path | None = None
self._expected_source_end_generation: int | None = None
self._archive_summaries: list[dict[str, Any]] = [] self._archive_summaries: list[dict[str, Any]] = []
self._error: dict[str, str] | None = None self._error: dict[str, str] | None = None
self._closed = False self._closed = False
@ -124,6 +125,9 @@ class XgridsK1CameraGateway:
}, },
"recording": { "recording": {
"active": self._recording_root is not None, "active": self._recording_root is not None,
"source_end_expected": (
self._expected_source_end_generation is not None
),
"session": ( "session": (
self._recording_root.name if self._recording_root is not None else None self._recording_root.name if self._recording_root is not None else None
), ),
@ -175,6 +179,7 @@ class XgridsK1CameraGateway:
self._revision += 1 self._revision += 1
self._source_id = source_id self._source_id = source_id
self._target_host = target self._target_host = target
self._expected_source_end_generation = None
self._phase = "selected" self._phase = "selected"
self._error = None self._error = None
recording_active = self._recording_root is not None recording_active = self._recording_root is not None
@ -221,6 +226,7 @@ class XgridsK1CameraGateway:
self._source_id = None self._source_id = None
self._target_host = None self._target_host = None
self._recording_root = None self._recording_root = None
self._expected_source_end_generation = None
self._error = None self._error = None
self._shutdown_producer( self._shutdown_producer(
producer, producer,
@ -248,6 +254,7 @@ class XgridsK1CameraGateway:
raise RuntimeError("для camera gateway уже активна другая acquisition-сессия") raise RuntimeError("для camera gateway уже активна другая acquisition-сессия")
producer, delivery = self._detach_producer_locked() producer, delivery = self._detach_producer_locked()
self._recording_root = root self._recording_root = root
self._expected_source_end_generation = None
self._archive_summaries = [] self._archive_summaries = []
selected = self._source_id is not None selected = self._source_id is not None
if selected: if selected:
@ -266,6 +273,29 @@ class XgridsK1CameraGateway:
self._spawn_selected_producer() self._spawn_selected_producer()
return self.snapshot() return self.snapshot()
def expect_source_end_for_device_stop(self) -> dict[str, Any]:
"""Classify one clean FFmpeg EOF as the camera tail of canonical STOP."""
with self._lock:
producer = self._producer
if (
self._recording_root is not None
and producer is not None
and producer.archive is not None
):
self._expected_source_end_generation = producer.generation
self._revision += 1
return self.snapshot()
def cancel_expected_source_end(self) -> dict[str, Any]:
"""Revoke a STOP expectation when the control checkpoint was not released."""
with self._lock:
if self._expected_source_end_generation is not None:
self._expected_source_end_generation = None
self._revision += 1
return self.snapshot()
def stop_recording( def stop_recording(
self, self,
*, *,
@ -279,6 +309,7 @@ class XgridsK1CameraGateway:
producer, delivery = self._detach_producer_locked() producer, delivery = self._detach_producer_locked()
recording_was_active = self._recording_root is not None recording_was_active = self._recording_root is not None
self._recording_root = None self._recording_root = None
self._expected_source_end_generation = None
if self._source_id is not None and self._phase != "error": if self._source_id is not None and self._phase != "error":
self._phase = "selected" self._phase = "selected"
if recording_was_active or producer is not None: if recording_was_active or producer is not None:
@ -361,6 +392,7 @@ class XgridsK1CameraGateway:
self._source_id = None self._source_id = None
self._target_host = None self._target_host = None
self._recording_root = None self._recording_root = None
self._expected_source_end_generation = None
self._error = None self._error = None
self._shutdown_producer( self._shutdown_producer(
producer, producer,
@ -559,29 +591,49 @@ class XgridsK1CameraGateway:
delivery = producer.delivery delivery = producer.delivery
producer.delivery = None producer.delivery = None
owns_shutdown = self._producer is producer and not producer.stop_requested owns_shutdown = self._producer is producer and not producer.stop_requested
expected_clean_end = (
owns_shutdown
and producer.failure_code is None
and self._expected_source_end_generation == producer.generation
)
if owns_shutdown: if owns_shutdown:
self._producer = None self._producer = None
self._expected_source_end_generation = None
self._revision += 1 self._revision += 1
if producer.failure_code is None: if expected_clean_end:
self._phase = "idle"
self._source_id = None
self._target_host = None
self._error = None
elif producer.failure_code is None:
producer.failure_code = "camera-source-ended" producer.failure_code = "camera-source-ended"
self._set_error_locked( self._set_error_locked(
"camera-source-ended", "camera-source-ended",
_safe_ffmpeg_message(producer.stderr_tail), _safe_ffmpeg_message(producer.stderr_tail),
) )
if delivery is not None: if delivery is not None:
delivery.failure_code = producer.failure_code or "camera-source-ended" delivery.failure_code = (
None
if expected_clean_end
else producer.failure_code or "camera-source-ended"
)
_close_segment_queue(delivery) _close_segment_queue(delivery)
if not owns_shutdown: if not owns_shutdown:
return return
_terminate_process(producer.process) _terminate_process(producer.process)
status: CameraArchiveStatus = ( status: CameraArchiveStatus = (
"interrupted" "complete"
if producer.failure_code if expected_clean_end
in {"camera-source-ended", "incomplete-fmp4-fragment"} else "interrupted"
if producer.failure_code in {"camera-source-ended", "incomplete-fmp4-fragment"}
else "failed" else "failed"
) )
try: try:
self._finalize_archive(producer, status, producer.failure_code) self._finalize_archive(
producer,
status,
None if expected_clean_end else producer.failure_code,
)
except CameraArchiveError: except CameraArchiveError:
with self._lock: with self._lock:
self._set_error_locked( self._set_error_locked(

View File

@ -251,7 +251,7 @@ def serve_console(
] = 8000, ] = 8000,
) -> None: ) -> None:
"""Serve the built Mission Core Control Station and loopback control API.""" """Serve the built Mission Core Control Station and loopback control API."""
frontend = Path(__file__).resolve().parents[2] / "apps" / "control-station" / "dist" frontend = Path(__file__).resolve().parents[4] / "apps" / "control-station" / "dist"
if not frontend.is_dir(): if not frontend.is_dir():
console.print( console.print(
"[red]Frontend build is missing.[/red] Run npm install && npm run build " "[red]Frontend build is missing.[/red] Run npm install && npm run build "

View File

@ -6,6 +6,7 @@ import hmac
import importlib.util import importlib.util
import json import json
import secrets import secrets
import socket
import threading import threading
import time import time
import unicodedata import unicodedata
@ -178,17 +179,18 @@ class BleScanRequest(StrictRequest):
class CompatibilityAttestationRequest(StrictRequest): class CompatibilityAttestationRequest(StrictRequest):
"""Explicit operator claim required before using the exact lab profile.""" """Selected profile whose facts must be verified from live DeviceInfo."""
firmware_version: Literal["3.0.2"] firmware_version: Literal["3.0.2"]
topology: Literal["direct-lan"] topology: Literal["direct-lan"]
operator_confirmed: Literal[True] verification: Literal["live-device-info"]
class ConnectRequest(StrictRequest): class ConnectRequest(StrictRequest):
device_id: str = Field(min_length=1, max_length=128) device_id: str = Field(min_length=1, max_length=128)
ssid: str = Field(min_length=1, max_length=128) ssid: str = Field(min_length=1, max_length=128)
password: SecretStr = Field(min_length=1, max_length=256) password: SecretStr = Field(min_length=1, max_length=256)
connection_mode: Literal["bridge"] = "bridge"
compatibility_attestation: CompatibilityAttestationRequest compatibility_attestation: CompatibilityAttestationRequest
operation_id: str | None = Field(default=None, min_length=1, max_length=128) operation_id: str | None = Field(default=None, min_length=1, max_length=128)
idempotency_key: str | None = Field(default=None, min_length=1, max_length=160) idempotency_key: str | None = Field(default=None, min_length=1, max_length=160)
@ -234,6 +236,8 @@ class OperatorPresenceRequest(StrictRequest):
class PrepareAcquisitionRequest(OperationContextRequest): class PrepareAcquisitionRequest(OperationContextRequest):
project_name: str = Field(min_length=1, max_length=96) project_name: str = Field(min_length=1, max_length=96)
mount_type: Literal["handheld"] = "handheld"
gnss_mode: Literal["none"] = "none"
host: str | None = Field(default=None, max_length=15) host: str | None = Field(default=None, max_length=15)
duration_seconds: float = Field(default=3600.0, ge=1.0, le=86_400.0) duration_seconds: float = Field(default=3600.0, ge=1.0, le=86_400.0)
requested_streams: tuple[RequestedStreamId, ...] = DEFAULT_LIVE_STREAMS requested_streams: tuple[RequestedStreamId, ...] = DEFAULT_LIVE_STREAMS
@ -331,6 +335,7 @@ class XgridsK1CompatibilityService:
self._devices: list[dict[str, Any]] = [] self._devices: list[dict[str, Any]] = []
self._selected_device_id: str | None = None self._selected_device_id: str | None = None
self._k1_ip: str | None = None self._k1_ip: str | None = None
self._connection_mode: Literal["bridge"] | None = None
self._device_ids_by_transport_ref: dict[str, str] = {} self._device_ids_by_transport_ref: dict[str, str] = {}
self._device_id: str | None = None self._device_id: str | None = None
self._device_session_id: str | None = None self._device_session_id: str | None = None
@ -347,6 +352,8 @@ class XgridsK1CompatibilityService:
self._operations = OperationJournal() self._operations = OperationJournal()
self._acquisition: AcquisitionRecord | None = None self._acquisition: AcquisitionRecord | None = None
self._acquisition_project_name: str | None = None self._acquisition_project_name: str | None = None
self._acquisition_mount_type: Literal["handheld"] | None = None
self._acquisition_gnss_mode: Literal["none"] | None = None
self._acquisition_out_dir: Path | None = None self._acquisition_out_dir: Path | None = None
self._acquisition_start_operation_id: str | None = None self._acquisition_start_operation_id: str | None = None
self._acquisition_stop_operation_id: str | None = None self._acquisition_stop_operation_id: str | None = None
@ -379,7 +386,8 @@ class XgridsK1CompatibilityService:
application_control_session = self._application_control_session.snapshot() application_control_session = self._application_control_session.snapshot()
runtime = self.runtime.snapshot() runtime = self.runtime.snapshot()
camera_preview = self.camera_preview.snapshot() camera_preview = self.camera_preview.snapshot()
self._reconcile_acquisition(runtime, camera_preview) self._reconcile_acquisition(runtime, camera_preview, application_control_session)
runtime = self.runtime.snapshot()
camera_preview = self.camera_preview.snapshot() camera_preview = self.camera_preview.snapshot()
metrics = runtime["metrics"] metrics = runtime["metrics"]
with self._lock: with self._lock:
@ -388,6 +396,7 @@ class XgridsK1CompatibilityService:
devices = list(self._devices) devices = list(self._devices)
selected_device_id = self._selected_device_id selected_device_id = self._selected_device_id
k1_ip = self._k1_ip k1_ip = self._k1_ip
connection_mode = self._connection_mode
device_id = self._device_id device_id = self._device_id
device_session_id = self._device_session_id device_session_id = self._device_session_id
device_session_opened_at = self._device_session_opened_at device_session_opened_at = self._device_session_opened_at
@ -400,6 +409,8 @@ class XgridsK1CompatibilityService:
acquisition = self._acquisition.as_dict() if self._acquisition is not None else None acquisition = self._acquisition.as_dict() if self._acquisition is not None else None
if acquisition is not None: if acquisition is not None:
acquisition["project_name"] = self._acquisition_project_name acquisition["project_name"] = self._acquisition_project_name
acquisition["mount_type"] = self._acquisition_mount_type
acquisition["gnss_mode"] = self._acquisition_gnss_mode
acquisition["cleanup_pending"] = ( acquisition["cleanup_pending"] = (
self._acquisition_session_lease is not None self._acquisition_session_lease is not None
and acquisition["state"] in TERMINAL_ACQUISITION_STATES and acquisition["state"] in TERMINAL_ACQUISITION_STATES
@ -450,6 +461,7 @@ class XgridsK1CompatibilityService:
"devices": devices, "devices": devices,
"selected_device_id": selected_device_id, "selected_device_id": selected_device_id,
"k1_ip": k1_ip, "k1_ip": k1_ip,
"connection_mode": connection_mode,
"compatibility": { "compatibility": {
"profile_id": active_profile_id, "profile_id": active_profile_id,
"decision": "limited" if active_profile_id is not None else "unknown", "decision": "limited" if active_profile_id is not None else "unknown",
@ -461,9 +473,9 @@ class XgridsK1CompatibilityService:
else "evidence-only" else "evidence-only"
), ),
"firmware_claim": ( "firmware_claim": (
"operator-attested-exact-3.0.2" "live-device-info-verification-required"
if compatibility_attestation is not None if compatibility_attestation is not None
else "exact-3.0.2-profile-not-attested" else "exact-3.0.2-profile-not-selected"
), ),
"attestation": compatibility_attestation, "attestation": compatibility_attestation,
"vendor_writes_enabled": active_control, "vendor_writes_enabled": active_control,
@ -598,6 +610,7 @@ class XgridsK1CompatibilityService:
"device_id": request.device_id, "device_id": request.device_id,
"ssid": request.ssid, "ssid": request.ssid,
"password": password, "password": password,
"connection_mode": request.connection_mode,
"compatibility_attestation": request.compatibility_attestation.model_dump( "compatibility_attestation": request.compatibility_attestation.model_dump(
mode="json" mode="json"
), ),
@ -678,6 +691,7 @@ class XgridsK1CompatibilityService:
) )
write_json_atomic(session_dir / "provisioning.sensitive.json", result) write_json_atomic(session_dir / "provisioning.sensitive.json", result)
ipv4 = _provisioned_ipv4(result) ipv4 = _provisioned_ipv4(result)
local_address_conflict = ipv4 is not None and _target_is_local_ipv4(ipv4)
write_json_atomic( write_json_atomic(
session_dir / "manifest.redacted.json", session_dir / "manifest.redacted.json",
{ {
@ -688,6 +702,9 @@ class XgridsK1CompatibilityService:
"profile_id": result["profile_id"], "profile_id": result["profile_id"],
"outcome": result["outcome"], "outcome": result["outcome"],
"k1_lan_address_observed": ipv4 is not None, "k1_lan_address_observed": ipv4 is not None,
"k1_lan_address_admitted": ipv4 is not None
and not local_address_conflict,
"local_address_conflict": local_address_conflict,
"credentials_persisted_by_connector": False, "credentials_persisted_by_connector": False,
}, },
) )
@ -695,9 +712,15 @@ class XgridsK1CompatibilityService:
raise RuntimeError( raise RuntimeError(
"Устройство не сообщило адрес в локальной сети; автоматического повтора не было" "Устройство не сообщило адрес в локальной сети; автоматического повтора не было"
) )
if local_address_conflict:
raise RuntimeError(
"Устройство сообщило IPv4-адрес, который уже принадлежит этому компьютеру; "
"адрес K1 не принят и автоматического повтора не было"
)
with self._lock: with self._lock:
self._selected_device_id = request.device_id self._selected_device_id = request.device_id
self._k1_ip = ipv4 self._k1_ip = ipv4
self._connection_mode = request.connection_mode
self._device_id = self._device_ids_by_transport_ref.setdefault( self._device_id = self._device_ids_by_transport_ref.setdefault(
request.device_id, request.device_id,
new_device_id(), new_device_id(),
@ -792,7 +815,7 @@ class XgridsK1CompatibilityService:
attestation = self._compatibility_attestation attestation = self._compatibility_attestation
acquisition = self._acquisition acquisition = self._acquisition
if target is None or attestation is None: if target is None or attestation is None:
raise RuntimeError("сначала подключите и подтвердите exact-profile K1") raise RuntimeError("сначала подключите K1 и выберите exact-profile")
if acquisition is not None and acquisition.state not in TERMINAL_ACQUISITION_STATES: if acquisition is not None and acquisition.state not in TERMINAL_ACQUISITION_STATES:
raise RuntimeError("control-сессия должна быть открыта до подготовки acquisition") raise RuntimeError("control-сессия должна быть открыта до подготовки acquisition")
self._application_control.disarm() self._application_control.disarm()
@ -830,7 +853,7 @@ class XgridsK1CompatibilityService:
if self._k1_ip is None or self._selected_device_id is None: if self._k1_ip is None or self._selected_device_id is None:
raise RuntimeError("сначала выберите и подключите K1 через BLE/Wi-Fi") raise RuntimeError("сначала выберите и подключите K1 через BLE/Wi-Fi")
if self._compatibility_attestation is None: if self._compatibility_attestation is None:
raise RuntimeError("exact FW 3.0.2 direct-LAN profile не подтверждён") raise RuntimeError("exact FW 3.0.2 direct-LAN profile не выбран")
acquisition = self._acquisition acquisition = self._acquisition
if acquisition is not None and acquisition.state not in TERMINAL_ACQUISITION_STATES: if acquisition is not None and acquisition.state not in TERMINAL_ACQUISITION_STATES:
raise RuntimeError( raise RuntimeError(
@ -914,6 +937,8 @@ class XgridsK1CompatibilityService:
"requested_streams": requested_streams, "requested_streams": requested_streams,
"evidence_policy": request.evidence_policy, "evidence_policy": request.evidence_policy,
"project_name": request.project_name, "project_name": request.project_name,
"mount_type": request.mount_type,
"gnss_mode": request.gnss_mode,
"compatibility_attestation": request.compatibility_attestation.model_dump( "compatibility_attestation": request.compatibility_attestation.model_dump(
mode="json" mode="json"
), ),
@ -974,6 +999,8 @@ class XgridsK1CompatibilityService:
) )
self._acquisition = acquisition self._acquisition = acquisition
self._acquisition_project_name = request.project_name self._acquisition_project_name = request.project_name
self._acquisition_mount_type = request.mount_type
self._acquisition_gnss_mode = request.gnss_mode
self._acquisition_out_dir = new_live_session_dir(self.evidence_root) self._acquisition_out_dir = new_live_session_dir(self.evidence_root)
self._acquisition_start_operation_id = None self._acquisition_start_operation_id = None
self._acquisition_stop_operation_id = None self._acquisition_stop_operation_id = None
@ -1136,20 +1163,27 @@ class XgridsK1CompatibilityService:
expected_stop_operation_id = self._acquisition_stop_operation_id expected_stop_operation_id = self._acquisition_stop_operation_id
lease_retained = self._acquisition_session_lease is not None lease_retained = self._acquisition_session_lease is not None
terminal_manual_stop_recovery = (
request.operator_confirmed
and not plugin_commanded
and acquisition_state in TERMINAL_ACQUISITION_STATES
)
if request.operator_confirmed: if request.operator_confirmed:
if request.mode != "graceful": if request.mode != "graceful":
raise ValueError("operator_confirmed допустим только для graceful stop") raise ValueError("operator_confirmed допустим только для graceful stop")
if acquisition_state != "awaiting_external_stop": if (
acquisition_state != "awaiting_external_stop"
and not terminal_manual_stop_recovery
):
raise ValueError("acquisition не ожидает подтверждения физической остановки") raise ValueError("acquisition не ожидает подтверждения физической остановки")
if request.operation_id is None or request.operation_id != expected_stop_operation_id: if request.operation_id is None or request.operation_id != expected_stop_operation_id:
raise ValueError( raise ValueError(
"подтверждение остановки должно ссылаться на исходную stop-operation" "подтверждение остановки должно ссылаться на исходную stop-operation"
) )
if plugin_commanded and not self._application_control_session.snapshot()[ if plugin_commanded:
"can_confirm_standby" raise ValueError(
]: "канонический STOP завершается автоматически по READY от K1"
raise RuntimeError(
"сначала дождитесь READY от K1 и визуально подтвердите зелёный индикатор"
) )
elif ( elif (
request.mode == "graceful" request.mode == "graceful"
@ -1180,6 +1214,14 @@ class XgridsK1CompatibilityService:
if self._application_control_session.snapshot()["state"] != "scanning": if self._application_control_session.snapshot()["state"] != "scanning":
raise RuntimeError("канонический диалог K1 ещё не готов принять STOP") raise RuntimeError("канонический диалог K1 ещё не готов принять STOP")
if terminal_manual_stop_recovery:
if lease_retained:
self._stop_acquisition_sources(
camera_status="failed",
camera_failure_code="terminal-local-failure-after-device-stop",
)
return self.state()
request_fingerprint = self._request_fingerprint( request_fingerprint = self._request_fingerprint(
ACTION_ACQUISITION_STOP, ACTION_ACQUISITION_STOP,
{ {
@ -1271,9 +1313,14 @@ class XgridsK1CompatibilityService:
if request.mode == "graceful" and not request.operator_confirmed: if request.mode == "graceful" and not request.operator_confirmed:
if plugin_commanded: if plugin_commanded:
assert request.physical_acceptance is not None assert request.physical_acceptance is not None
self._application_control_session.request_stop( self.camera_preview.expect_source_end_for_device_stop()
confirmation=request.physical_acceptance.confirmation() try:
) self._application_control_session.request_stop(
confirmation=request.physical_acceptance.confirmation()
)
except Exception:
self.camera_preview.cancel_expected_source_end()
raise
with self._lock: with self._lock:
acquisition.transition( acquisition.transition(
"awaiting_external_stop", "awaiting_external_stop",
@ -1282,22 +1329,17 @@ class XgridsK1CompatibilityService:
if plugin_commanded if plugin_commanded
else "acquisition.stop.operator_action_required" else "acquisition.stop.operator_action_required"
), ),
operator_instructions=( operator_instructions=()
( if plugin_commanded
"Канонический STOP запрошен один раз. Дождитесь READY, " else (
"убедитесь, что индикатор постоянно зелёный, и подтвердите это." "Дважды нажмите физическую кнопку устройства и "
if plugin_commanded "подтвердите остановку.",
else (
"Дважды нажмите физическую кнопку устройства и "
"подтвердите остановку."
)
),
), ),
) )
self._acquisition_stop_operation_id = operation.operation_id self._acquisition_stop_operation_id = operation.operation_id
self._operations.transition( self._operations.transition(
operation.operation_id, operation.operation_id,
"operator_action_required", "running" if plugin_commanded else "operator_action_required",
stage_code="awaiting-external-stop", stage_code="awaiting-external-stop",
message_code=( message_code=(
"acquisition.stop.device_stopping" "acquisition.stop.device_stopping"
@ -1315,8 +1357,6 @@ class XgridsK1CompatibilityService:
message_code="acquisition.stop.stopping_receiver", message_code="acquisition.stop.stopping_receiver",
) )
try: try:
if plugin_commanded and request.mode == "graceful" and request.operator_confirmed:
self._application_control_session.confirm_standby()
with self._lock: with self._lock:
acquisition.transition("stopping", message_code="acquisition.stopping") acquisition.transition("stopping", message_code="acquisition.stopping")
self._stop_acquisition_sources( self._stop_acquisition_sources(
@ -1814,18 +1854,51 @@ class XgridsK1CompatibilityService:
self, self,
runtime: Mapping[str, Any], runtime: Mapping[str, Any],
camera: Mapping[str, Any], camera: Mapping[str, Any],
application_control_session: Mapping[str, Any],
) -> None: ) -> None:
with self._lock:
current = self._acquisition
terminal_canonical_cleanup = (
current is not None
and current.state in TERMINAL_ACQUISITION_STATES
and current.control_mode == "plugin-commanded"
and application_control_session.get("state") == "completed"
and self._acquisition_session_lease is not None
)
terminal_camera_status: Literal["complete", "failed"] = (
"complete"
if current is not None and current.state == "completed"
else "failed"
)
if terminal_canonical_cleanup:
self._stop_acquisition_sources(
camera_status=terminal_camera_status,
camera_failure_code=(
None
if terminal_camera_status == "complete"
else "terminal-local-failure-after-device-standby"
),
)
return
completed_operation_id: str | None = None completed_operation_id: str | None = None
failed_operation_id: str | None = None failed_operation_id: str | None = None
failed_stop_operation_id: str | None = None failed_stop_operation_id: str | None = None
unconfirmed_stop_operation_id: str | None = None unconfirmed_stop_operation_id: str | None = None
completed_stop_operation_id: str | None = None
receiver_ready_operation_id: str | None = None receiver_ready_operation_id: str | None = None
receiver_plugin_commanded = False receiver_plugin_commanded = False
acquisition_id: str | None = None acquisition_id: str | None = None
camera_terminal_status: Literal["complete", "failed"] | None = None camera_terminal_status: Literal["complete", "failed"] | None = None
camera_failure_code: str | None = None camera_failure_code: str | None = None
stop_runtime_for_camera_failure = False stop_runtime_for_camera_failure = False
stop_runtime_for_canonical_completion = False
complete_after_seal = False complete_after_seal = False
completion_message_code = "acquisition.receiver_completed"
completion_result: dict[str, Any] = {
"receiver_stopped": True,
"device_state": "unknown",
}
with self._lock: with self._lock:
acquisition = self._acquisition acquisition = self._acquisition
if acquisition is None or acquisition.state in TERMINAL_ACQUISITION_STATES: if acquisition is None or acquisition.state in TERMINAL_ACQUISITION_STATES:
@ -1870,6 +1943,25 @@ class XgridsK1CompatibilityService:
failed_stop_operation_id = self._acquisition_stop_operation_id failed_stop_operation_id = self._acquisition_stop_operation_id
camera_terminal_status = "failed" camera_terminal_status = "failed"
stop_runtime_for_camera_failure = True stop_runtime_for_camera_failure = True
elif (
acquisition.state == "awaiting_external_stop"
and acquisition.control_mode == "plugin-commanded"
and application_control_session.get("state") == "completed"
):
acquisition.transition(
"finalizing",
message_code="acquisition.stop.device_standby_confirmed",
)
camera_terminal_status = "complete"
stop_runtime_for_canonical_completion = True
complete_after_seal = True
completed_stop_operation_id = self._acquisition_stop_operation_id
completion_message_code = "acquisition.stop.completed"
completion_result = {
"receiver_stopped": True,
"device_state": "ready",
"device_stop": "protocol-confirmed",
}
elif acquisition.state in {"starting", "awaiting_external_start"} and point_frames > 0: elif acquisition.state in {"starting", "awaiting_external_start"} and point_frames > 0:
acquisition.transition("acquiring", message_code="acquisition.acquiring") acquisition.transition("acquiring", message_code="acquisition.acquiring")
completed_operation_id = self._acquisition_start_operation_id completed_operation_id = self._acquisition_start_operation_id
@ -1954,7 +2046,7 @@ class XgridsK1CompatibilityService:
) )
except Exception as exc: except Exception as exc:
terminal_error = exc terminal_error = exc
if stop_runtime_for_camera_failure: if stop_runtime_for_camera_failure or stop_runtime_for_canonical_completion:
try: try:
self.runtime.stop() self.runtime.stop()
except Exception as exc: except Exception as exc:
@ -1973,11 +2065,8 @@ class XgridsK1CompatibilityService:
if acquisition.state not in TERMINAL_ACQUISITION_STATES: if acquisition.state not in TERMINAL_ACQUISITION_STATES:
acquisition.transition( acquisition.transition(
"completed", "completed",
message_code="acquisition.receiver_completed", message_code=completion_message_code,
result={ result=completion_result,
"receiver_stopped": True,
"device_state": "unknown",
},
) )
except Exception as exc: except Exception as exc:
reconciliation_error = exc reconciliation_error = exc
@ -2058,6 +2147,32 @@ class XgridsK1CompatibilityService:
"side_effect_status": "unknown", "side_effect_status": "unknown",
}, },
) )
if completed_stop_operation_id is not None:
if reconciliation_error is None:
self._operations.transition_if_pending(
completed_stop_operation_id,
"succeeded",
stage_code="device-standby-confirmed",
message_code="acquisition.stop.completed",
result={
"acquisition_id": acquisition.acquisition_id,
"confirmation": "device-status-ready-unbound",
},
)
else:
self._operations.transition_if_pending(
completed_stop_operation_id,
"failed",
stage_code="local-finalization-failed",
message_code="acquisition.stop.local_finalization_failed",
error={
"category": "stream",
"code": "local-finalization-failed-after-device-standby",
"retryable": True,
"safe_to_retry": True,
"side_effect_status": "succeeded",
},
)
with self._lock: with self._lock:
if self._acquisition_start_operation_id in { if self._acquisition_start_operation_id in {
completed_operation_id, completed_operation_id,
@ -2068,6 +2183,8 @@ class XgridsK1CompatibilityService:
self._acquisition_stop_operation_id = None self._acquisition_stop_operation_id = None
if self._acquisition_stop_operation_id == unconfirmed_stop_operation_id: if self._acquisition_stop_operation_id == unconfirmed_stop_operation_id:
self._acquisition_stop_operation_id = None self._acquisition_stop_operation_id = None
if self._acquisition_stop_operation_id == completed_stop_operation_id:
self._acquisition_stop_operation_id = None
if reconciliation_error is not None: if reconciliation_error is not None:
raise reconciliation_error raise reconciliation_error
@ -2345,7 +2462,8 @@ def _attestation_snapshot(
return { return {
"firmware_version": attestation.firmware_version, "firmware_version": attestation.firmware_version,
"topology": attestation.topology, "topology": attestation.topology,
"basis": "operator-attested", "verification": attestation.verification,
"basis": "selected-profile-live-device-info-required",
"observed_at": _utc_now_iso(), "observed_at": _utc_now_iso(),
} }
@ -2394,7 +2512,7 @@ def _sensor_catalog(
else ( else (
"local-runtime-dependency-missing" "local-runtime-dependency-missing"
if camera_profile_active if camera_profile_active
else "profile-not-attested" else "profile-not-selected"
) )
), ),
"endpoint_label": f"RTSP · {side}", "endpoint_label": f"RTSP · {side}",
@ -2506,6 +2624,24 @@ def _provisioned_ipv4(result: Mapping[str, Any]) -> str | None:
return None return None
def _target_is_local_ipv4(target: str) -> bool:
"""Reject a BLE-reported target when the host route resolves back to itself.
Connecting a UDP socket only asks the kernel to select a route and source
address; it does not transmit a datagram. This keeps provisioning admission
free of a speculative device probe while preventing a host address from
being mistaken for the K1 endpoint.
"""
try:
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as route_socket:
route_socket.connect((validate_private_ipv4(target), 9))
source_address = str(route_socket.getsockname()[0])
except OSError as exc:
raise RuntimeError("не удалось безопасно проверить маршрут к адресу K1") from exc
return source_address == target
def _validate_installed_compatibility_profile(repository_root: Path) -> None: def _validate_installed_compatibility_profile(repository_root: Path) -> None:
"""Run the plugin-owned, fail-closed profile validator before activation.""" """Run the plugin-owned, fail-closed profile validator before activation."""

View File

@ -11,6 +11,7 @@ from typing import Literal, Protocol
from k1link.device_plugins.xgrids_k1.protocol.application_bootstrap import ( from k1link.device_plugins.xgrids_k1.protocol.application_bootstrap import (
MODELING_STATUS_RESPONSE_TOPIC, MODELING_STATUS_RESPONSE_TOPIC,
ApplicationBootstrapError, ApplicationBootstrapError,
ApplicationCompatibilityProfileError,
ApplicationControlAuthority, ApplicationControlAuthority,
CanonicalPostStartObservation, CanonicalPostStartObservation,
LiveDeviceControlBinding, LiveDeviceControlBinding,
@ -39,18 +40,31 @@ MAX_ACCEPTANCE_PERMIT_SECONDS = 120.0
# Socket-pump quantum only. It is never used to advance a K1 dialogue stage. # Socket-pump quantum only. It is never used to advance a K1 dialogue stage.
CONTROL_NETWORK_PUMP_QUANTUM_SECONDS = 1.0 CONTROL_NETWORK_PUMP_QUANTUM_SECONDS = 1.0
SCAN_INITIALIZATION_TIMEOUT_SECONDS = 120.0
class ApplicationAcceptanceError(RuntimeError): class ApplicationAcceptanceError(RuntimeError):
"""The operator-present physical acceptance contract failed closed.""" """The operator-present physical acceptance contract failed closed."""
class ScanInitializationTimeout(ApplicationAcceptanceError):
"""START was acknowledged but the reviewed SCANNING gate never completed."""
reason_code = "scan_initialization_timeout"
class ApplicationProfileMismatchError(ApplicationAcceptanceError):
"""A correlated live DeviceInfo response rejected the selected profile."""
reason_code = "compatibility_profile_mismatch"
class ApplicationBatchExchange(Protocol): class ApplicationBatchExchange(Protocol):
def exchange_batch_once( def exchange_batch_once(
self, self,
envelopes: Sequence[OneShotPublishEnvelope], envelopes: Sequence[OneShotPublishEnvelope],
*, *,
required_response_topics: Collection[str], required_response_operation_keys: Collection[str],
) -> dict[str, bytes]: ... ) -> dict[str, bytes]: ...
def maintain_open_for( def maintain_open_for(
@ -182,8 +196,24 @@ class PhysicalAcceptanceDialogueExecutor:
def __init__( def __init__(
self, self,
transport: ApplicationBatchExchange, transport: ApplicationBatchExchange,
*,
monotonic: Callable[[], float] = time.monotonic,
scan_initialization_timeout_seconds: float = SCAN_INITIALIZATION_TIMEOUT_SECONDS,
) -> None: ) -> None:
if (
not isinstance(scan_initialization_timeout_seconds, (int, float))
or isinstance(scan_initialization_timeout_seconds, bool)
or not math.isfinite(scan_initialization_timeout_seconds)
or scan_initialization_timeout_seconds <= 0
):
raise ApplicationAcceptanceError(
"scan initialization timeout must be a positive finite number"
)
self._transport = transport self._transport = transport
self._monotonic = monotonic
self._scan_initialization_timeout_seconds = float(
scan_initialization_timeout_seconds
)
self._bootstrap_complete = False self._bootstrap_complete = False
self._command_complete = False self._command_complete = False
self._dialogue_stage = "new" self._dialogue_stage = "new"
@ -199,6 +229,7 @@ class PhysicalAcceptanceDialogueExecutor:
self._stop_permit_snapshot: dict[str, object] | None = None self._stop_permit_snapshot: dict[str, object] | None = None
self._response_evidence: list[dict[str, object]] = [] self._response_evidence: list[dict[str, object]] = []
self._correlation_failure: dict[str, object] | None = None self._correlation_failure: dict[str, object] | None = None
self._compatibility_failure: dict[str, object] | None = None
def run_bootstrap( def run_bootstrap(
self, self,
@ -217,7 +248,7 @@ class PhysicalAcceptanceDialogueExecutor:
if self._dialogue_stage != "new" or self._bootstrap_complete or self._command_complete: if self._dialogue_stage != "new" or self._bootstrap_complete or self._command_complete:
raise ApplicationAcceptanceError("connection stage is not admissible now") raise ApplicationAcceptanceError("connection stage is not admissible now")
for expected_batch in (1, 2, 3): for expected_batch in (1, 2):
self._exchange_bootstrap_batch(orchestrator, expected_batch=expected_batch) self._exchange_bootstrap_batch(orchestrator, expected_batch=expected_batch)
binding = orchestrator.binding binding = orchestrator.binding
if binding is None: if binding is None:
@ -270,7 +301,7 @@ class PhysicalAcceptanceDialogueExecutor:
if self._dialogue_stage != "connection-ready": if self._dialogue_stage != "connection-ready":
raise ApplicationAcceptanceError("workspace entry requires the connection stage") raise ApplicationAcceptanceError("workspace entry requires the connection stage")
self._consume_checkpoint(checkpoint, expected="workspace-entered") self._consume_checkpoint(checkpoint, expected="workspace-entered")
self._exchange_bootstrap_batch(orchestrator, expected_batch=4) self._exchange_bootstrap_batch(orchestrator, expected_batch=3)
binding = orchestrator.binding binding = orchestrator.binding
if binding is None: if binding is None:
raise ApplicationAcceptanceError("workspace entry lost the live device binding") raise ApplicationAcceptanceError("workspace entry lost the live device binding")
@ -287,7 +318,7 @@ class PhysicalAcceptanceDialogueExecutor:
if self._dialogue_stage != "workspace-ready": if self._dialogue_stage != "workspace-ready":
raise ApplicationAcceptanceError("project prompt requires workspace entry") raise ApplicationAcceptanceError("project prompt requires workspace entry")
self._consume_checkpoint(checkpoint, expected="project-prompt-opened") self._consume_checkpoint(checkpoint, expected="project-prompt-opened")
self._exchange_bootstrap_batch(orchestrator, expected_batch=5) self._exchange_bootstrap_batch(orchestrator, expected_batch=4)
if not orchestrator.snapshot().bootstrap_complete: if not orchestrator.snapshot().bootstrap_complete:
raise ApplicationAcceptanceError("project prompt did not complete the transcript") raise ApplicationAcceptanceError("project prompt did not complete the transcript")
binding = orchestrator.binding binding = orchestrator.binding
@ -338,9 +369,9 @@ class PhysicalAcceptanceDialogueExecutor:
self._dialogue_stage = "start-attempted" self._dialogue_stage = "start-attempted"
responses = self._transport.exchange_batch_once( responses = self._transport.exchange_batch_once(
[OneShotPublishEnvelope.from_modeling_command(command)], [OneShotPublishEnvelope.from_modeling_command(command)],
required_response_topics={MODELING_RESPONSE_TOPIC}, required_response_operation_keys={"modeling:start"},
) )
payload = responses[MODELING_RESPONSE_TOPIC] payload = responses["modeling:start"]
self._record_response_evidence( self._record_response_evidence(
phase="modeling", phase="modeling",
operation_key="modeling:start", operation_key="modeling:start",
@ -363,24 +394,33 @@ class PhysicalAcceptanceDialogueExecutor:
immediate = post_start.immediate_modeling_status immediate = post_start.immediate_modeling_status
self._transport.exchange_batch_once( self._transport.exchange_batch_once(
[OneShotPublishEnvelope.from_dialogue_request(immediate)], [OneShotPublishEnvelope.from_dialogue_request(immediate)],
required_response_topics=(), required_response_operation_keys=(),
) )
self._dialogue_stage = "initializing" self._dialogue_stage = "initializing"
initialization_deadline = (
self._monotonic() + self._scan_initialization_timeout_seconds
)
while not self._transport.scan_initialization_complete(binding): while not self._transport.scan_initialization_complete(binding):
if self._monotonic() >= initialization_deadline:
raise ScanInitializationTimeout(
"K1 did not confirm bound SCANNING initialization before the safety deadline"
)
self._transport.maintain_open_for( self._transport.maintain_open_for(
CONTROL_NETWORK_PUMP_QUANTUM_SECONDS, CONTROL_NETWORK_PUMP_QUANTUM_SECONDS,
allowed_response_topics={MODELING_STATUS_RESPONSE_TOPIC}, allowed_response_topics={MODELING_STATUS_RESPONSE_TOPIC},
) )
refresh = post_start.post_initialization_refresh refresh = post_start.post_initialization_refresh
required_topics = {request.response_topic for request in refresh} required_operations = {
f"dialogue:{request.ordinal}:{request.message_type}" for request in refresh
}
refresh_responses = self._transport.exchange_batch_once( refresh_responses = self._transport.exchange_batch_once(
[OneShotPublishEnvelope.from_dialogue_request(request) for request in refresh], [OneShotPublishEnvelope.from_dialogue_request(request) for request in refresh],
required_response_topics=required_topics, required_response_operation_keys=required_operations,
) )
for request in refresh: for request in refresh:
refresh_payload = refresh_responses[request.response_topic]
operation_key = f"dialogue:{request.ordinal}:{request.message_type}" operation_key = f"dialogue:{request.ordinal}:{request.message_type}"
refresh_payload = refresh_responses[operation_key]
self._record_response_evidence( self._record_response_evidence(
phase="post-start", phase="post-start",
operation_key=operation_key, operation_key=operation_key,
@ -480,9 +520,9 @@ class PhysicalAcceptanceDialogueExecutor:
self._dialogue_stage = "stop-attempted" self._dialogue_stage = "stop-attempted"
responses = self._transport.exchange_batch_once( responses = self._transport.exchange_batch_once(
[OneShotPublishEnvelope.from_modeling_command(command)], [OneShotPublishEnvelope.from_modeling_command(command)],
required_response_topics={MODELING_RESPONSE_TOPIC}, required_response_operation_keys={"modeling:stop"},
) )
payload = responses[MODELING_RESPONSE_TOPIC] payload = responses["modeling:stop"]
self._record_response_evidence( self._record_response_evidence(
phase="modeling", phase="modeling",
operation_key="modeling:stop", operation_key="modeling:stop",
@ -505,16 +545,13 @@ class PhysicalAcceptanceDialogueExecutor:
self._dialogue_stage = "stop-acknowledged" self._dialogue_stage = "stop-acknowledged"
return response return response
def maintain_post_stop_until_standby_confirmed( def maintain_post_stop_until_standby(self) -> None:
self, """Keep servicing control reports through save and protocol standby.
standby_confirmed: Callable[[], bool],
) -> None:
"""Keep servicing control reports through save and physical standby.
The retained capture does not expose a protocol timer that proves save There is no wall-clock shortcut after STOP. The dialogue remains owned
completion. The session therefore remains owned until an explicit until the bound K1 itself reports READY with no project attached. That
operator/status gate confirms standby and never closes itself merely live protocol state is the canonical completion evidence; a second
because a captured wall-clock duration elapsed. operator acknowledgement would add no independent information.
""" """
if self._dialogue_stage != "stop-acknowledged" or not self._stop_complete: if self._dialogue_stage != "stop-acknowledged" or not self._stop_complete:
@ -524,9 +561,7 @@ class PhysicalAcceptanceDialogueExecutor:
binding = self._active_binding binding = self._active_binding
if binding is None: if binding is None:
raise ApplicationAcceptanceError("canonical START binding is no longer available") raise ApplicationAcceptanceError("canonical START binding is no longer available")
while not ( while not self._transport.standby_complete(binding):
self._transport.standby_complete(binding) and standby_confirmed()
):
self._transport.maintain_open_for( self._transport.maintain_open_for(
CONTROL_NETWORK_PUMP_QUANTUM_SECONDS, CONTROL_NETWORK_PUMP_QUANTUM_SECONDS,
allowed_response_topics={MODELING_STATUS_RESPONSE_TOPIC}, allowed_response_topics={MODELING_STATUS_RESPONSE_TOPIC},
@ -555,12 +590,19 @@ class PhysicalAcceptanceDialogueExecutor:
if self._stop_permit_snapshot is not None if self._stop_permit_snapshot is not None
else None else None
), ),
"response_evidence": tuple(dict(item) for item in self._response_evidence), # This snapshot crosses the plugin SDK boundary, whose JsonValue
# contract intentionally rejects Python-only container types.
"response_evidence": [dict(item) for item in self._response_evidence],
"correlation_failure": ( "correlation_failure": (
dict(self._correlation_failure) dict(self._correlation_failure)
if self._correlation_failure is not None if self._correlation_failure is not None
else None else None
), ),
"compatibility_failure": (
dict(self._compatibility_failure)
if self._compatibility_failure is not None
else None
),
"automatic_retry": False, "automatic_retry": False,
} }
@ -608,18 +650,20 @@ class PhysicalAcceptanceDialogueExecutor:
f"canonical bootstrap expected batch {expected_batch}" f"canonical bootstrap expected batch {expected_batch}"
) )
batch = orchestrator.next_batch() batch = orchestrator.next_batch()
required_topics = { required_operations = {
request.response_topic for request in batch if request.response_required f"bootstrap:{request.ordinal}:{request.message_type}"
for request in batch
if request.response_required
} }
responses = self._transport.exchange_batch_once( responses = self._transport.exchange_batch_once(
[OneShotPublishEnvelope.from_bootstrap_request(request) for request in batch], [OneShotPublishEnvelope.from_bootstrap_request(request) for request in batch],
required_response_topics=required_topics, required_response_operation_keys=required_operations,
) )
for request in batch: for request in batch:
if not request.response_required: if not request.response_required:
continue continue
payload = responses[request.response_topic]
operation_key = f"bootstrap:{request.ordinal}:{request.message_type}" operation_key = f"bootstrap:{request.ordinal}:{request.message_type}"
payload = responses[operation_key]
self._record_response_evidence( self._record_response_evidence(
phase="bootstrap", phase="bootstrap",
operation_key=operation_key, operation_key=operation_key,
@ -627,7 +671,25 @@ class PhysicalAcceptanceDialogueExecutor:
payload=payload, payload=payload,
) )
try: try:
orchestrator.accept_response(request.response_topic, payload) orchestrator.accept_response(operation_key, payload)
except ApplicationCompatibilityProfileError as exc:
self._compatibility_failure = {
"phase": "bootstrap",
"operation_key": operation_key,
"response_topic": request.response_topic,
"reason_code": exc.reason_code,
"expected": {
"device_model": "LixelKity K1",
"platform_type": "A4",
"firmware": "3.0.2",
"is_activated": True,
},
"observed": dict(exc.observed_facts),
}
raise ApplicationProfileMismatchError(
"live DeviceInfo is incompatible with the selected "
"LixelKity K1 / A4 / FW 3.0.2 profile"
) from exc
except ApplicationBootstrapError as exc: except ApplicationBootstrapError as exc:
self._record_correlation_failure( self._record_correlation_failure(
phase="bootstrap", phase="bootstrap",
@ -670,5 +732,23 @@ class PhysicalAcceptanceDialogueExecutor:
"phase": phase, "phase": phase,
"operation_key": operation_key, "operation_key": operation_key,
"response_topic": response_topic, "response_topic": response_topic,
"reason_code": self._correlation_reason_code(reason),
"reason": reason, "reason": reason,
} }
@staticmethod
def _correlation_reason_code(reason: str) -> str:
reason_fragments = (
("session correlation failed", "response_session_mismatch"),
("vendor identity mismatch", "response_device_identity_mismatch"),
("authority mismatch", "response_authority_mismatch"),
("result code", "response_rejected"),
("facts changed", "response_device_facts_changed"),
("invalid", "response_decode_failed"),
("missing", "response_decode_failed"),
("wrong wire type", "response_decode_failed"),
)
return next(
(code for fragment, code in reason_fragments if fragment in reason),
"response_correlation_failed",
)

View File

@ -1,11 +1,12 @@
from __future__ import annotations from __future__ import annotations
import importlib
import platform import platform
import shutil import shutil
import subprocess import subprocess
import sys import sys
from dataclasses import dataclass from dataclasses import dataclass
from typing import Protocol from typing import Any, Protocol, cast
from k1link.device_plugins.xgrids_k1.protocol.application_bootstrap import ( from k1link.device_plugins.xgrids_k1.protocol.application_bootstrap import (
ApplicationBootstrapError, ApplicationBootstrapError,
@ -33,6 +34,10 @@ class CommandRunner(Protocol):
) -> subprocess.CompletedProcess[bytes]: ... ) -> subprocess.CompletedProcess[bytes]: ...
class KeychainSecretReader(Protocol):
def __call__(self, *, service: str, account: str) -> bytes: ...
class InteractiveCommandRunner(Protocol): class InteractiveCommandRunner(Protocol):
def __call__( def __call__(
self, self,
@ -43,6 +48,63 @@ class InteractiveCommandRunner(Protocol):
) -> subprocess.CompletedProcess[bytes]: ... ) -> subprocess.CompletedProcess[bytes]: ...
def _read_keychain_secret_via_security_framework(*, service: str, account: str) -> bytes:
"""Read one fixed generic-password item without spawning a UI/CLI process."""
try:
objc = importlib.import_module("objc")
foundation = importlib.import_module("Foundation")
bundle = foundation.NSBundle.bundleWithPath_(
"/System/Library/Frameworks/Security.framework"
)
if bundle is None or not bundle.load():
raise RuntimeError("Security.framework is unavailable")
symbols: dict[str, Any] = {}
objc.loadBundleVariables(
bundle,
symbols,
[
("kSecClass", b"@"),
("kSecClassGenericPassword", b"@"),
("kSecAttrService", b"@"),
("kSecAttrAccount", b"@"),
("kSecReturnData", b"@"),
("kSecMatchLimit", b"@"),
("kSecMatchLimitOne", b"@"),
],
)
functions: dict[str, Any] = {}
objc.loadBundleFunctions(
bundle,
functions,
[("SecItemCopyMatching", b"i@o^@")],
)
query = {
symbols["kSecClass"]: symbols["kSecClassGenericPassword"],
symbols["kSecAttrService"]: service,
symbols["kSecAttrAccount"]: account,
symbols["kSecReturnData"]: True,
symbols["kSecMatchLimit"]: symbols["kSecMatchLimitOne"],
}
copy_matching = cast(Any, functions["SecItemCopyMatching"])
result = copy_matching(query, None)
except ApplicationAuthorityLoadError:
raise
except Exception as exc:
raise ApplicationAuthorityLoadError("macOS Keychain authority lookup failed") from exc
if not isinstance(result, tuple) or len(result) != 2:
raise ApplicationAuthorityLoadError("macOS Keychain authority lookup failed")
status, secret_data = result
if int(status) != 0 or secret_data is None:
raise ApplicationAuthorityLoadError("macOS Keychain authority is unavailable")
try:
return bytes(secret_data)
except Exception as exc:
raise ApplicationAuthorityLoadError("macOS Keychain authority lookup failed") from exc
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
class ApplicationAuthoritySourceSnapshot: class ApplicationAuthoritySourceSnapshot:
provider: str = "macos-keychain" provider: str = "macos-keychain"
@ -69,12 +131,19 @@ class MacOSKeychainApplicationAuthorityLoader:
"""Read the exact-profile authority from the current user's Keychain. """Read the exact-profile authority from the current user's Keychain.
There is deliberately no environment, plaintext-file, browser or API There is deliberately no environment, plaintext-file, browser or API
fallback. The secret is requested through the absolute ``security`` binary fallback. Runtime reads call Apple's Security.framework in-process and
and is never interpolated into an exception, repr or subprocess argument. never launch Keychain Access or the ``security`` CLI. The optional command
runner exists only as a compatibility seam for the reviewed test harness.
""" """
def __init__(self, *, runner: CommandRunner = subprocess.run) -> None: def __init__(
self,
*,
runner: CommandRunner | None = None,
framework_reader: KeychainSecretReader = _read_keychain_secret_via_security_framework,
) -> None:
self._runner = runner self._runner = runner
self._framework_reader = framework_reader
def snapshot(self) -> ApplicationAuthoritySourceSnapshot: def snapshot(self) -> ApplicationAuthoritySourceSnapshot:
return ApplicationAuthoritySourceSnapshot() return ApplicationAuthoritySourceSnapshot()
@ -84,32 +153,48 @@ class MacOSKeychainApplicationAuthorityLoader:
raise ApplicationAuthorityLoadError( raise ApplicationAuthorityLoadError(
"application authority loading is supported only through macOS Keychain" "application authority loading is supported only through macOS Keychain"
) )
security = shutil.which("security") if self._runner is None:
if security != "/usr/bin/security": try:
raise ApplicationAuthorityLoadError("trusted macOS security binary is unavailable") secret_bytes = self._framework_reader(
service=KEYCHAIN_SERVICE,
account=KEYCHAIN_ACCOUNT,
)
except ApplicationAuthorityLoadError:
raise
except Exception as exc:
raise ApplicationAuthorityLoadError(
"macOS Keychain authority lookup failed"
) from exc
else:
security = shutil.which("security")
if security != "/usr/bin/security":
raise ApplicationAuthorityLoadError(
"trusted macOS security binary is unavailable"
)
try:
completed = self._runner(
[
security,
"find-generic-password",
"-s",
KEYCHAIN_SERVICE,
"-a",
KEYCHAIN_ACCOUNT,
"-w",
],
capture_output=True,
check=False,
timeout=KEYCHAIN_TIMEOUT_SECONDS,
)
except (OSError, subprocess.SubprocessError) as exc:
raise ApplicationAuthorityLoadError(
"macOS Keychain authority lookup failed"
) from exc
if completed.returncode != 0:
raise ApplicationAuthorityLoadError("macOS Keychain authority is unavailable")
secret_bytes = completed.stdout
try: secret_buffer = bytearray(secret_bytes)
completed = self._runner(
[
security,
"find-generic-password",
"-s",
KEYCHAIN_SERVICE,
"-a",
KEYCHAIN_ACCOUNT,
"-w",
],
capture_output=True,
check=False,
timeout=KEYCHAIN_TIMEOUT_SECONDS,
)
except (OSError, subprocess.SubprocessError) as exc:
raise ApplicationAuthorityLoadError("macOS Keychain authority lookup failed") from exc
if completed.returncode != 0:
raise ApplicationAuthorityLoadError("macOS Keychain authority is unavailable")
secret_buffer = bytearray(completed.stdout)
try: try:
while secret_buffer.endswith((b"\n", b"\r")): while secret_buffer.endswith((b"\n", b"\r")):
secret_buffer.pop() secret_buffer.pop()

View File

@ -11,6 +11,7 @@ from k1link.device_plugins.xgrids_k1.protocol.modeling_control import OPENAPI_SU
from k1link.device_plugins.xgrids_k1.protocol.protobuf_wire import ( from k1link.device_plugins.xgrids_k1.protocol.protobuf_wire import (
ProtobufWireError, ProtobufWireError,
ProtoField, ProtoField,
encode_zigzag64,
iter_fields, iter_fields,
) )
@ -19,6 +20,12 @@ MAX_HEADER_BYTES = 4 * 1024
MAX_TEXT_BYTES = 4 * 1024 MAX_TEXT_BYTES = 4 * 1024
APPLICATION_AUTHORITY_SOURCE = "owner-captured-lixelgo-application" APPLICATION_AUTHORITY_SOURCE = "owner-captured-lixelgo-application"
COMPATIBILITY_PROFILE_ID = "xgrids.lixelkity-k1.fw-3.0.2.direct-lan.v1" COMPATIBILITY_PROFILE_ID = "xgrids.lixelkity-k1.fw-3.0.2.direct-lan.v1"
REVIEWED_DEVICE_MODEL = "LixelKity K1"
REVIEWED_DEVICE_TYPE = "A4"
REVIEWED_PROFILE_ATTESTATION_FAILURE = (
"live DeviceInfo does not attest the reviewed activated "
"LixelKity K1 (platform type A4) FW 3.0.2 profile"
)
DEVICE_CONFIG_TIME_CONTEXT = "Publish_Proto_DeviceConfig_SetTime" DEVICE_CONFIG_TIME_CONTEXT = "Publish_Proto_DeviceConfig_SetTime"
SHADOW_BLOCKERS = ("vendor-writes-disabled", "publisher-not-installed") SHADOW_BLOCKERS = ("vendor-writes-disabled", "publisher-not-installed")
@ -40,6 +47,22 @@ class ApplicationBootstrapError(ValueError):
"""The recovered K1 application bootstrap contract was violated.""" """The recovered K1 application bootstrap contract was violated."""
class ApplicationCompatibilityProfileError(ApplicationBootstrapError):
"""A correlated DeviceInfo response does not match the selected profile."""
reason_code = "compatibility_profile_mismatch"
def __init__(self, binding: LiveDeviceControlBinding) -> None:
self.observed_facts = {
"device_model": binding.device_model,
"platform_type": binding.device_type,
"software_version": binding.software_version,
"system_version": binding.system_version,
"is_activated": binding.is_activated,
}
super().__init__(REVIEWED_PROFILE_ATTESTATION_FAILURE)
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
class ApplicationControlAuthority: class ApplicationControlAuthority:
"""Private authority belonging to the reviewed LixelGO application profile. """Private authority belonging to the reviewed LixelGO application profile.
@ -98,9 +121,22 @@ class LiveDeviceControlBinding:
self.system_version, "3.0.2" self.system_version, "3.0.2"
) )
@property
def matches_reviewed_device(self) -> bool:
"""Match the exact model/type strings retained from the reviewed K1."""
return (
self.device_model == REVIEWED_DEVICE_MODEL
and self.device_type == REVIEWED_DEVICE_TYPE
)
@property @property
def ready_for_reviewed_profile(self) -> bool: def ready_for_reviewed_profile(self) -> bool:
return self.is_activated and self.matches_reviewed_firmware return (
self.is_activated
and self.matches_reviewed_device
and self.matches_reviewed_firmware
)
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
@ -213,6 +249,15 @@ class ApplicationResponse:
result_code: int result_code: int
@dataclass(frozen=True, slots=True)
class ApplicationMessageIdentity:
"""Redacted identity used to route one protobuf request/response safely."""
vendor_device_id: str | None = field(repr=False)
session_id: str = field(repr=False)
openapi_key: str = field(repr=False)
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
class ApplicationBootstrapSnapshot: class ApplicationBootstrapSnapshot:
current_batch: int current_batch: int
@ -253,9 +298,7 @@ def build_shadow_bootstrap(
""" """
if not binding.ready_for_reviewed_profile: if not binding.ready_for_reviewed_profile:
raise ApplicationBootstrapError( raise ApplicationCompatibilityProfileError(binding)
"live DeviceInfo does not attest the reviewed activated FW 3.0.2 profile"
)
if not isinstance(epoch_seconds, int) or isinstance(epoch_seconds, bool): if not isinstance(epoch_seconds, int) or isinstance(epoch_seconds, bool):
raise ApplicationBootstrapError("epoch_seconds must be an integer") raise ApplicationBootstrapError("epoch_seconds must be an integer")
if epoch_seconds < 0 or epoch_seconds > 0x7FFF_FFFF_FFFF_FFFF: if epoch_seconds < 0 or epoch_seconds > 0x7FFF_FFFF_FFFF_FFFF:
@ -373,7 +416,9 @@ def build_shadow_bootstrap(
) )
body = b"" body = b""
if message_type == "DeviceConfigRequest": if message_type == "DeviceConfigRequest":
time_config = _varint_field(1, epoch_seconds) + _text_field(2, timezone_name) time_config = _varint_field(1, encode_zigzag64(epoch_seconds)) + _text_field(
2, timezone_name
)
body = _bytes_field(4, time_config) body = _bytes_field(4, time_config)
payload = _bytes_field(1, _encode_header(header)) + body payload = _bytes_field(1, _encode_header(header)) + body
_check_payload(payload, "application request") _check_payload(payload, "application request")
@ -406,9 +451,7 @@ def build_canonical_post_start_observation(
"""Build retained operations 12-14 without granting publish authority.""" """Build retained operations 12-14 without granting publish authority."""
if not binding.ready_for_reviewed_profile: if not binding.ready_for_reviewed_profile:
raise ApplicationBootstrapError( raise ApplicationCompatibilityProfileError(binding)
"live DeviceInfo does not attest the reviewed activated FW 3.0.2 profile"
)
def build( def build(
ordinal: Literal[12, 13, 14], ordinal: Literal[12, 13, 14],
@ -577,15 +620,43 @@ def correlate_application_response(
return ApplicationResponse(session_id, device_id, openapi_key, result_code) return ApplicationResponse(session_id, device_id, openapi_key, result_code)
def decode_application_message_identity(payload: bytes) -> ApplicationMessageIdentity:
"""Decode only the common header identity without exposing message bodies."""
_check_payload(payload, "application message")
top = _selected_unique_fields(
payload,
"application message",
max_fields=64,
selected={1},
)
header = _selected_unique_fields(
_required_bytes(top, 1, "message.header"),
"header",
max_fields=32,
selected={4, 5, 6},
)
return ApplicationMessageIdentity(
vendor_device_id=(
_identity_field(header, 4, "header.device_id") if 4 in header else None
),
session_id=_identity_field(header, 5, "header.session_id"),
openapi_key=_identity_field(header, 6, "header.openapi_key"),
)
class ShadowApplicationBootstrapOrchestrator: class ShadowApplicationBootstrapOrchestrator:
"""Advance the retained dialogue only across observed response barriers. """Advance the retained dialogue only across observed response barriers.
The clean cycle emitted five batches. ``ModelingStatusRequest`` in the DeviceInfo is the sole identity-binding barrier. After it succeeds, the
second batch had no required synchronous response before preparation retained captures publish ordinals 2-6 in order without inventing a barrier
continued; readiness remains a separate live DeviceStatus gate. between unbound ordinal 3 and bound ordinals 4-6. Their exact responses
are correlated by operation/session identity before the operator may
advance. ``ModelingStatusRequest`` ordinal 2 remains response-free;
readiness is a separate live DeviceStatus gate.
""" """
_BATCH_ORDINALS = ((1,), (2, 3), (4, 5, 6), (7,), (8, 9, 10)) _BATCH_ORDINALS = ((1,), (2, 3, 4, 5, 6), (7,), (8, 9, 10))
def __init__( def __init__(
self, self,
@ -612,7 +683,9 @@ class ShadowApplicationBootstrapOrchestrator:
current_batch=min(self._batch_index + 1, len(self._BATCH_ORDINALS)), current_batch=min(self._batch_index + 1, len(self._BATCH_ORDINALS)),
total_batches=len(self._BATCH_ORDINALS), total_batches=len(self._BATCH_ORDINALS),
batch_issued=self._batch_issued, batch_issued=self._batch_issued,
pending_response_topics=tuple(sorted(self._pending)), pending_response_topics=tuple(
sorted(request.response_topic for request in self._pending.values())
),
live_binding_observed=self._binding is not None, live_binding_observed=self._binding is not None,
bootstrap_complete=self._complete, bootstrap_complete=self._complete,
) )
@ -643,25 +716,23 @@ class ShadowApplicationBootstrapOrchestrator:
for ordinal in self._BATCH_ORDINALS[self._batch_index] for ordinal in self._BATCH_ORDINALS[self._batch_index]
) )
pending = { pending = {
request.response_topic: request for request in requests if request.response_required self._operation_key(request): request
for request in requests
if request.response_required
} }
if len(pending) != sum(request.response_required for request in requests):
raise ApplicationBootstrapError(
"bootstrap batch contains ambiguous required response topics"
)
if not pending: if not pending:
raise ApplicationBootstrapError("bootstrap batch has no recovered response barrier") raise ApplicationBootstrapError("bootstrap batch has no recovered response barrier")
self._pending = pending self._pending = pending
self._batch_issued = True self._batch_issued = True
return requests return requests
def accept_response(self, topic: str, payload: bytes) -> None: def accept_response(self, operation_key: str, payload: bytes) -> None:
"""Correlate one required response and unlock only the next batch.""" """Correlate one required response and unlock only the next batch."""
with self._lock: with self._lock:
if not self._batch_issued: if not self._batch_issued:
raise ApplicationBootstrapError("no application bootstrap batch is in flight") raise ApplicationBootstrapError("no application bootstrap batch is in flight")
expected = self._pending.get(topic) expected = self._pending.get(operation_key)
if expected is None: if expected is None:
raise ApplicationBootstrapError( raise ApplicationBootstrapError(
"application response is not required by the current batch" "application response is not required by the current batch"
@ -704,7 +775,7 @@ class ShadowApplicationBootstrapOrchestrator:
live_binding=self._binding, live_binding=self._binding,
) )
del self._pending[topic] del self._pending[operation_key]
if self._pending: if self._pending:
return return
self._batch_issued = False self._batch_issued = False
@ -712,6 +783,10 @@ class ShadowApplicationBootstrapOrchestrator:
if self._batch_index == len(self._BATCH_ORDINALS): if self._batch_index == len(self._BATCH_ORDINALS):
self._complete = True self._complete = True
@staticmethod
def _operation_key(request: EncodedApplicationRequest) -> str:
return f"bootstrap:{request.ordinal}:{request.message_type}"
def _build_initial_device_info_request( def _build_initial_device_info_request(
authority: ApplicationControlAuthority, authority: ApplicationControlAuthority,

View File

@ -1,5 +1,6 @@
from __future__ import annotations from __future__ import annotations
import hmac
import math import math
import secrets import secrets
import threading import threading
@ -16,19 +17,31 @@ from paho.mqtt.reasoncodes import ReasonCode
from k1link.device_plugins.xgrids_k1.ble.wifi_provisioning import AP_FALLBACK_IPV4 from k1link.device_plugins.xgrids_k1.ble.wifi_provisioning import AP_FALLBACK_IPV4
from k1link.device_plugins.xgrids_k1.mqtt import validate_private_ipv4 from k1link.device_plugins.xgrids_k1.mqtt import validate_private_ipv4
from k1link.device_plugins.xgrids_k1.protocol.application_bootstrap import ( from k1link.device_plugins.xgrids_k1.protocol.application_bootstrap import (
DEVICE_CONFIG_REQUEST_TOPIC,
DEVICE_CONFIG_RESPONSE_TOPIC, DEVICE_CONFIG_RESPONSE_TOPIC,
DEVICE_INFO_REQUEST_TOPIC,
DEVICE_INFO_RESPONSE_TOPIC, DEVICE_INFO_RESPONSE_TOPIC,
GET_CLOUD_CONFIG_REQUEST_TOPIC,
GET_CLOUD_CONFIG_RESPONSE_TOPIC, GET_CLOUD_CONFIG_RESPONSE_TOPIC,
GET_NTRIP_PROFILE_REQUEST_TOPIC,
GET_NTRIP_PROFILE_RESPONSE_TOPIC, GET_NTRIP_PROFILE_RESPONSE_TOPIC,
GET_RTK_ADVANCE_REQUEST_TOPIC,
GET_RTK_ADVANCE_RESPONSE_TOPIC, GET_RTK_ADVANCE_RESPONSE_TOPIC,
MODELING_STATUS_REQUEST_TOPIC,
MODELING_STATUS_RESPONSE_TOPIC, MODELING_STATUS_RESPONSE_TOPIC,
ApplicationBootstrapError,
ApplicationMessageIdentity,
LiveDeviceControlBinding, LiveDeviceControlBinding,
decode_application_message_identity,
) )
from k1link.device_plugins.xgrids_k1.protocol.application_publish import ( from k1link.device_plugins.xgrids_k1.protocol.application_publish import (
OneShotPublishEnvelope, OneShotPublishEnvelope,
) )
from k1link.device_plugins.xgrids_k1.protocol.modeling_control import ( from k1link.device_plugins.xgrids_k1.protocol.modeling_control import (
ModelingAction,
ModelingProtocolError,
decode_device_status_report, decode_device_status_report,
decode_modeling_response,
decode_system_error_report, decode_system_error_report,
) )
from k1link.device_plugins.xgrids_k1.protocol.modeling_safety import ( from k1link.device_plugins.xgrids_k1.protocol.modeling_safety import (
@ -45,6 +58,16 @@ MAX_CONTROL_MAINTAIN_SECONDS = 30.0
MAX_CONTROL_RESPONSE_BYTES = 64 * 1024 MAX_CONTROL_RESPONSE_BYTES = 64 * 1024
SYSTEM_ERROR_TOPIC = "lixel/application/report/system_error" SYSTEM_ERROR_TOPIC = "lixel/application/report/system_error"
APPLICATION_RESPONSE_TOPIC_BY_REQUEST_TOPIC = {
DEVICE_INFO_REQUEST_TOPIC: DEVICE_INFO_RESPONSE_TOPIC,
MODELING_STATUS_REQUEST_TOPIC: MODELING_STATUS_RESPONSE_TOPIC,
GET_RTK_ADVANCE_REQUEST_TOPIC: GET_RTK_ADVANCE_RESPONSE_TOPIC,
DEVICE_CONFIG_REQUEST_TOPIC: DEVICE_CONFIG_RESPONSE_TOPIC,
GET_NTRIP_PROFILE_REQUEST_TOPIC: GET_NTRIP_PROFILE_RESPONSE_TOPIC,
GET_CLOUD_CONFIG_REQUEST_TOPIC: GET_CLOUD_CONFIG_RESPONSE_TOPIC,
MODELING_REQUEST_TOPIC: MODELING_RESPONSE_TOPIC,
}
# The retained LixelGO control connection issued these three SUBSCRIBE packets # The retained LixelGO control connection issued these three SUBSCRIBE packets
# in this exact order. Point-cloud subscriptions belonged to a separate client. # in this exact order. Point-cloud subscriptions belonged to a separate client.
CONTROL_SUBSCRIPTION_GROUPS: tuple[tuple[tuple[str, int], ...], ...] = ( CONTROL_SUBSCRIPTION_GROUPS: tuple[tuple[tuple[str, int], ...], ...] = (
@ -147,14 +170,60 @@ APPLICATION_REQUEST_ALLOWLIST = frozenset(
class ApplicationMqttTransportError(RuntimeError): class ApplicationMqttTransportError(RuntimeError):
"""The reviewed control transport failed before a request was attempted.""" """The reviewed control transport failed before a request was attempted."""
def __init__(self, message: str, *, reason_code: str = "transport_failure") -> None:
super().__init__(message)
self.reason_code = reason_code
class ApplicationCommandOutcomeUnknown(RuntimeError): class ApplicationCommandOutcomeUnknown(RuntimeError):
"""A request may have reached the K1 and must never be retried automatically.""" """A request may have reached the K1 and must never be retried automatically."""
def __init__(
self,
message: str,
*,
reason_code: str = "command_outcome_unknown",
) -> None:
super().__init__(message)
self.reason_code = reason_code
class ApplicationControlDeviceFault(RuntimeError): class ApplicationControlDeviceFault(RuntimeError):
"""Live status made further automatic control inadmissible.""" """Live status made further automatic control inadmissible."""
def __init__(self, message: str, *, reason_code: str = "device_status_fault") -> None:
super().__init__(message)
self.reason_code = reason_code
@dataclass(frozen=True, slots=True)
class _ApplicationResponseExpectation:
operation_key: str
topic: str
identity: ApplicationMessageIdentity
modeling_action: ModelingAction | None = None
@property
def correlation_key(self) -> tuple[str, str, ModelingAction | None]:
return (self.topic, self.identity.session_id, self.modeling_action)
def matches(
self,
observed: ApplicationMessageIdentity,
observed_modeling_action: ModelingAction | None,
) -> bool:
if self.modeling_action is not observed_modeling_action:
return False
if not hmac.compare_digest(self.identity.session_id, observed.session_id):
return False
if not hmac.compare_digest(self.identity.openapi_key, observed.openapi_key):
return False
expected_device_id = self.identity.vendor_device_id
return expected_device_id is None or (
observed.vendor_device_id is not None
and hmac.compare_digest(expected_device_id, observed.vendor_device_id)
)
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
class ApplicationMqttTransportSnapshot: class ApplicationMqttTransportSnapshot:
@ -165,6 +234,7 @@ class ApplicationMqttTransportSnapshot:
qos2_completions: int qos2_completions: int
correlated_responses: int correlated_responses: int
ignored_known_responses: int ignored_known_responses: int
late_known_responses: int
device_status_reports: int device_status_reports: int
system_error_reports: int system_error_reports: int
report_decode_errors: int report_decode_errors: int
@ -189,6 +259,7 @@ class ApplicationMqttTransportSnapshot:
"qos2_completions": self.qos2_completions, "qos2_completions": self.qos2_completions,
"correlated_responses": self.correlated_responses, "correlated_responses": self.correlated_responses,
"ignored_known_responses": self.ignored_known_responses, "ignored_known_responses": self.ignored_known_responses,
"late_known_responses": self.late_known_responses,
"device_status_reports": self.device_status_reports, "device_status_reports": self.device_status_reports,
"system_error_reports": self.system_error_reports, "system_error_reports": self.system_error_reports,
"report_decode_errors": self.report_decode_errors, "report_decode_errors": self.report_decode_errors,
@ -208,10 +279,10 @@ class ApplicationMqttTransportSnapshot:
class ReviewedApplicationMqttTransport: class ReviewedApplicationMqttTransport:
"""Exact-profile MQTT exchange for an operator-present physical acceptance. """Exact-profile MQTT exchange for an operator-present physical acceptance.
This type is deliberately not installed in the facade or plugin runtime. Plugin v0.5.0 installs this type only behind the operator-driven continuous
It performs one connection attempt, never reconnects, consumes every session owner. It performs one connection attempt, never reconnects,
operation key before calling ``publish``, and permanently poisons itself consumes every operation key before calling ``publish``, and permanently
after any unknown post-publish outcome. poisons itself after any unknown post-publish outcome.
""" """
def __init__( def __init__(
@ -252,12 +323,19 @@ class ReviewedApplicationMqttTransport:
self._messages: deque[tuple[str, bytes]] = deque() self._messages: deque[tuple[str, bytes]] = deque()
self._callback_error: str | None = None self._callback_error: str | None = None
self._consumed_operation_keys: set[str] = set() self._consumed_operation_keys: set[str] = set()
self._known_response_expectations: dict[str, _ApplicationResponseExpectation] = {}
self._response_operations_by_correlation: dict[
tuple[str, str, ModelingAction | None], list[str]
] = {}
self._optional_response_operations: set[str] = set()
self._observed_response_operations: set[str] = set()
self._connect_attempts = 0 self._connect_attempts = 0
self._subscribe_attempts = 0 self._subscribe_attempts = 0
self._publish_attempts = 0 self._publish_attempts = 0
self._qos2_completions = 0 self._qos2_completions = 0
self._correlated_responses = 0 self._correlated_responses = 0
self._ignored_known_responses = 0 self._ignored_known_responses = 0
self._late_known_responses = 0
self._device_status_reports = 0 self._device_status_reports = 0
self._system_error_reports = 0 self._system_error_reports = 0
self._report_decode_errors = 0 self._report_decode_errors = 0
@ -273,10 +351,14 @@ class ReviewedApplicationMqttTransport:
def open(self) -> ApplicationMqttTransportSnapshot: def open(self) -> ApplicationMqttTransportSnapshot:
with self._lock: with self._lock:
if self._state != "new": if self._state != "new":
raise ApplicationMqttTransportError("control transport can be opened only once") raise ApplicationMqttTransportError(
"control transport can be opened only once",
reason_code="transport_already_opened",
)
self._state = "connecting" self._state = "connecting"
self._connect_attempts = 1 self._connect_attempts = 1
client = self._new_client() client = self._new_client()
client.connect_timeout = self._connect_timeout_seconds
self._install_callbacks(client) self._install_callbacks(client)
self._client = client self._client = client
try: try:
@ -300,10 +382,10 @@ class ReviewedApplicationMqttTransport:
self, self,
envelopes: Sequence[OneShotPublishEnvelope], envelopes: Sequence[OneShotPublishEnvelope],
*, *,
required_response_topics: Collection[str], required_response_operation_keys: Collection[str],
) -> dict[str, bytes]: ) -> dict[str, bytes]:
batch = tuple(envelopes) batch = tuple(envelopes)
required = frozenset(required_response_topics) required = frozenset(required_response_operation_keys)
if not batch: if not batch:
raise ValueError("control exchange batch must not be empty") raise ValueError("control exchange batch must not be empty")
if not required and any( if not required and any(
@ -311,37 +393,100 @@ class ReviewedApplicationMqttTransport:
for envelope in batch for envelope in batch
): ):
raise ValueError("response-free exchange is limited to retained ModelingStatus reads") raise ValueError("response-free exchange is limited to retained ModelingStatus reads")
if not required <= APPLICATION_RESPONSE_TOPICS:
raise ValueError("required response topics exceed the reviewed allowlist")
operation_keys = tuple(envelope.operation_key for envelope in batch) operation_keys = tuple(envelope.operation_key for envelope in batch)
if len(set(operation_keys)) != len(operation_keys): if len(set(operation_keys)) != len(operation_keys):
raise ValueError("control exchange batch repeats an operation key") raise ValueError("control exchange batch repeats an operation key")
if not required <= frozenset(operation_keys):
raise ValueError("required responses exceed the issued operation keys")
if any(envelope.topic not in APPLICATION_REQUEST_ALLOWLIST for envelope in batch): if any(envelope.topic not in APPLICATION_REQUEST_ALLOWLIST for envelope in batch):
raise ValueError("control exchange batch exceeds the reviewed request allowlist") raise ValueError("control exchange batch exceeds the reviewed request allowlist")
allowed_non_barrier_responses = ( expectations = tuple(self._expectation_for_envelope(envelope) for envelope in batch)
frozenset({MODELING_STATUS_RESPONSE_TOPIC}) pending = {
if any( expectation.correlation_key: expectation
envelope.topic == "lixel/application/request/modeling_status" for envelope in batch for expectation in expectations
if expectation.operation_key in required
}
if len(pending) != len(required):
raise ValueError(
"required responses do not identify one exact batch operation each"
) )
else frozenset()
) # Classify packets already delivered by the manual Paho loop before
# consuming this batch. An exact response to an earlier issued
# operation is harmless; an unknown session/identity remains fatal.
self._drain_responses({}, {})
if self._consumed_operation_keys:
# Service packets already readable on the retained socket before
# admitting another operation. This is a zero-wait network pump,
# not a timer or retry; it prevents a queued protocol deviation
# from being hidden behind the next explicit operator action.
self._service_once(post_publish=True)
self._drain_responses({}, {})
with self._lock: with self._lock:
if self._consumed_operation_keys.intersection(operation_keys): if self._consumed_operation_keys.intersection(operation_keys):
raise ApplicationCommandOutcomeUnknown( raise ApplicationCommandOutcomeUnknown(
"control operation key was already consumed; retry is forbidden" "control operation key was already consumed; retry is forbidden",
reason_code="operation_reuse_forbidden",
) )
if self._state != "ready" or not self._connected or not self._subscribed: if self._state != "ready" or not self._connected or not self._subscribed:
raise ApplicationMqttTransportError("control transport is not ready") raise ApplicationMqttTransportError(
stale_response = bool(self._messages) "control transport is not ready",
if stale_response: reason_code="transport_not_ready",
self._poison_locked("stale response preceded the request batch") )
for expectation in expectations:
existing_operations = self._response_operations_by_correlation.get(
expectation.correlation_key,
[],
)
if any(
not self._known_response_expectations[operation_key].matches(
expectation.identity,
expectation.modeling_action,
)
for operation_key in existing_operations
):
raise ApplicationCommandOutcomeUnknown(
"control response identity changed for an issued session",
reason_code="response_identity_changed",
)
if expectation.operation_key in required:
# A response-free read owns its correlation only until the
# next exact same-identity operation is admitted. We have
# already drained callbacks and given the retained socket
# one zero-wait service turn above, so any response already
# available for the optional operation has been observed.
# Once the newer required operation is issued, the first
# response after that issuance belongs to the newer
# operation; the unanswered optional read must not reserve
# FIFO ownership and swallow it.
superseded_optional = {
operation_key
for operation_key in existing_operations
if operation_key in self._optional_response_operations
and operation_key not in self._observed_response_operations
}
if superseded_optional:
existing_operations[:] = [
operation_key
for operation_key in existing_operations
if operation_key not in superseded_optional
]
self._optional_response_operations.difference_update(
superseded_optional
)
for operation_key in superseded_optional:
del self._known_response_expectations[operation_key]
self._known_response_expectations[
expectation.operation_key
] = expectation
self._response_operations_by_correlation.setdefault(
expectation.correlation_key,
[],
).append(expectation.operation_key)
if expectation.operation_key not in required:
self._optional_response_operations.add(expectation.operation_key)
self._consumed_operation_keys.update(operation_keys) self._consumed_operation_keys.update(operation_keys)
if stale_response:
self.close()
raise ApplicationCommandOutcomeUnknown(
"stale control response makes the next command outcome ambiguous"
)
client = self._require_client() client = self._require_client()
publish_mids: set[int] = set() publish_mids: set[int] = set()
@ -367,12 +512,17 @@ class ReviewedApplicationMqttTransport:
deadline = self._monotonic() + self._exchange_timeout_seconds deadline = self._monotonic() + self._exchange_timeout_seconds
def complete() -> bool: def complete() -> bool:
self._drain_responses(required, allowed_non_barrier_responses, responses) self._drain_responses(pending, responses)
with self._lock: with self._lock:
return publish_mids <= self._completed_publish_mids and required <= responses.keys() return publish_mids <= self._completed_publish_mids and required <= responses.keys()
self._drive_until(complete, deadline, post_publish=True) self._drive_until(complete, deadline, post_publish=True)
self._drain_responses(required, allowed_non_barrier_responses, responses) # The completion predicate can become true immediately after the first
# required response callback. Give an already-readable second packet
# one zero-wait service turn so a duplicate application response fails
# closed instead of leaking into the next operator checkpoint.
self._service_once(post_publish=True)
self._drain_responses(pending, responses)
with self._lock: with self._lock:
self._correlated_responses += len(responses) self._correlated_responses += len(responses)
return responses return responses
@ -422,9 +572,15 @@ class ReviewedApplicationMqttTransport:
client = self._client client = self._client
if client is not None: if client is not None:
try: try:
if self._subscribed: with self._lock:
accepted_group_count = self._subscription_group_index
if accepted_group_count:
client.unsubscribe( client.unsubscribe(
[topic for group in CONTROL_SUBSCRIPTION_GROUPS for topic, _qos in group] [
topic
for group in CONTROL_SUBSCRIPTION_GROUPS[:accepted_group_count]
for topic, _qos in group
]
) )
client.disconnect() client.disconnect()
except (OSError, RuntimeError, ValueError): except (OSError, RuntimeError, ValueError):
@ -483,6 +639,7 @@ class ReviewedApplicationMqttTransport:
qos2_completions=self._qos2_completions, qos2_completions=self._qos2_completions,
correlated_responses=self._correlated_responses, correlated_responses=self._correlated_responses,
ignored_known_responses=self._ignored_known_responses, ignored_known_responses=self._ignored_known_responses,
late_known_responses=self._late_known_responses,
device_status_reports=self._device_status_reports, device_status_reports=self._device_status_reports,
system_error_reports=self._system_error_reports, system_error_reports=self._system_error_reports,
report_decode_errors=self._report_decode_errors, report_decode_errors=self._report_decode_errors,
@ -725,48 +882,144 @@ class ReviewedApplicationMqttTransport:
self._fail_after_publish("control MQTT network loop returned an error") self._fail_after_publish("control MQTT network loop returned an error")
self._fail_before_publish("control MQTT network loop returned an error") self._fail_before_publish("control MQTT network loop returned an error")
def _service_once(self, *, post_publish: bool) -> None:
with self._lock:
callback_error = self._callback_error
if callback_error is not None:
if post_publish:
self._fail_after_publish(callback_error)
self._fail_before_publish(callback_error)
client = self._require_client()
try:
result = client.loop(timeout=0.0)
except (OSError, RuntimeError, ValueError) as exc:
if post_publish:
self._fail_after_publish("control MQTT network loop failed", exc)
self._fail_before_publish("control MQTT network loop failed", exc)
if result != mqtt.MQTT_ERR_SUCCESS:
if post_publish:
self._fail_after_publish("control MQTT network loop returned an error")
self._fail_before_publish("control MQTT network loop returned an error")
def _drain_responses( def _drain_responses(
self, self,
required: frozenset[str], pending: dict[
allowed_non_barrier: frozenset[str], tuple[str, str, ModelingAction | None],
_ApplicationResponseExpectation,
],
responses: dict[str, bytes], responses: dict[str, bytes],
) -> None: ) -> None:
ambiguous_message: str | None = None ambiguous_message: str | None = None
ambiguous_reason_code: str | None = None
with self._lock: with self._lock:
while self._messages: while self._messages:
topic, payload = self._messages.popleft() topic, payload = self._messages.popleft()
if topic not in required: try:
if topic in allowed_non_barrier: observed = decode_application_message_identity(payload)
self._ignored_known_responses += 1 except ApplicationBootstrapError:
continue self._poison_locked("response identity decoding failed")
self._poison_locked("unexpected response made correlation ambiguous") ambiguous_message = (
ambiguous_message = "unexpected control response made command outcome ambiguous" "control response identity could not be decoded safely"
)
ambiguous_reason_code = "response_identity_decode_failed"
break break
if topic in responses: observed_modeling_action: ModelingAction | None = None
self._poison_locked("duplicate required response made correlation ambiguous") if topic == MODELING_RESPONSE_TOPIC:
ambiguous_message = "duplicate control response made command outcome ambiguous" try:
break observed_modeling_action = decode_modeling_response(
responses[topic] = payload payload
if ambiguous_message is not None: ).action
self.close() except ModelingProtocolError:
raise ApplicationCommandOutcomeUnknown(ambiguous_message) self._poison_locked("modeling response action decoding failed")
ambiguous_message = (
def _discard_allowed_responses(self, allowed: frozenset[str]) -> None: "control modeling response could not be decoded safely"
ambiguous_message: str | None = None )
with self._lock: ambiguous_reason_code = "modeling_response_decode_failed"
while self._messages: break
topic, _payload = self._messages.popleft() key = (topic, observed.session_id, observed_modeling_action)
if topic in allowed: expectation = pending.get(key)
self._ignored_known_responses += 1 known_operations = self._response_operations_by_correlation.get(key, [])
continue matching_operations = [
self._poison_locked("unexpected response during retained control hold") operation_key
ambiguous_message = ( for operation_key in known_operations
"unexpected control response during retained control-session hold" if self._known_response_expectations[operation_key].matches(
observed,
observed_modeling_action,
)
]
next_unobserved = next(
(
operation_key
for operation_key in matching_operations
if operation_key not in self._observed_response_operations
),
None,
) )
if next_unobserved is not None:
self._observed_response_operations.add(next_unobserved)
if (
expectation is not None
and expectation.operation_key == next_unobserved
):
responses[next_unobserved] = payload
continue
self._ignored_known_responses += 1
self._late_known_responses += 1
continue
if matching_operations:
self._poison_locked("duplicate application response")
ambiguous_message = (
"duplicate application response made command outcome ambiguous"
)
ambiguous_reason_code = "duplicate_application_response"
break
self._poison_locked("unexpected response identity made correlation ambiguous")
ambiguous_message = (
"unexpected control response session or identity made command outcome ambiguous"
)
ambiguous_reason_code = "unexpected_response_identity"
if expectation is not None or known_operations:
ambiguous_message = (
"control response identity mismatch made command outcome ambiguous"
)
ambiguous_reason_code = "response_identity_mismatch"
break break
if ambiguous_message is not None: if ambiguous_message is not None:
self.close() self.close()
raise ApplicationCommandOutcomeUnknown(ambiguous_message) raise ApplicationCommandOutcomeUnknown(
ambiguous_message,
reason_code=ambiguous_reason_code or "response_correlation_failed",
)
def _discard_allowed_responses(self, allowed: frozenset[str]) -> None:
del allowed
self._drain_responses({}, {})
@staticmethod
def _expectation_for_envelope(
envelope: OneShotPublishEnvelope,
) -> _ApplicationResponseExpectation:
response_topic = APPLICATION_RESPONSE_TOPIC_BY_REQUEST_TOPIC.get(envelope.topic)
if response_topic is None:
raise ValueError("control request has no reviewed response topic")
try:
identity = decode_application_message_identity(envelope.payload)
except ApplicationBootstrapError as exc:
raise ValueError("control request identity is not decodable") from exc
modeling_action: ModelingAction | None = None
if response_topic == MODELING_RESPONSE_TOPIC:
if envelope.operation_key == "modeling:start":
modeling_action = ModelingAction.START
elif envelope.operation_key == "modeling:stop":
modeling_action = ModelingAction.STOP
else:
raise ValueError("modeling request operation key has no exact action")
return _ApplicationResponseExpectation(
operation_key=envelope.operation_key,
topic=response_topic,
identity=identity,
modeling_action=modeling_action,
)
def _set_callback_error(self, message: str) -> None: def _set_callback_error(self, message: str) -> None:
with self._lock: with self._lock:
@ -777,7 +1030,10 @@ class ReviewedApplicationMqttTransport:
with self._lock: with self._lock:
self._state = "failed" self._state = "failed"
self.close() self.close()
error = ApplicationMqttTransportError(message) error = ApplicationMqttTransportError(
message,
reason_code=self._transport_failure_reason_code(message),
)
if cause is not None: if cause is not None:
raise error from cause raise error from cause
raise error raise error
@ -787,7 +1043,8 @@ class ReviewedApplicationMqttTransport:
self._poison_locked(message) self._poison_locked(message)
self.close() self.close()
error = ApplicationCommandOutcomeUnknown( error = ApplicationCommandOutcomeUnknown(
f"{message}; automatic retry is forbidden until physical/status reconciliation" f"{message}; automatic retry is forbidden until physical/status reconciliation",
reason_code=self._transport_failure_reason_code(message),
) )
if cause is not None: if cause is not None:
raise error from cause raise error from cause
@ -799,5 +1056,33 @@ class ReviewedApplicationMqttTransport:
def _require_client(self) -> mqtt.Client: def _require_client(self) -> mqtt.Client:
client = self._client client = self._client
if client is None: if client is None:
raise ApplicationMqttTransportError("control MQTT client is not installed") raise ApplicationMqttTransportError(
"control MQTT client is not installed",
reason_code="mqtt_client_unavailable",
)
return client return client
@staticmethod
def _transport_failure_reason_code(message: str) -> str:
reason_fragments = (
("connect call failed", "mqtt_connect_call_failed"),
("connect call was rejected", "mqtt_connect_rejected"),
("broker rejected connection", "mqtt_broker_rejected_connection"),
("connection/subscription timed out", "mqtt_connection_timeout"),
("response subscription", "mqtt_subscription_failed"),
("SUBACK", "mqtt_subscription_protocol_error"),
("publish call failed", "mqtt_publish_call_failed"),
("publish call returned", "mqtt_publish_result_unsafe"),
("reused a packet identifier", "mqtt_packet_identifier_reused"),
("QoS2 transaction failed", "mqtt_qos2_failed"),
("response barrier timed out", "mqtt_response_timeout"),
("network loop failed", "mqtt_network_loop_failed"),
("network loop returned", "mqtt_network_loop_failed"),
("connection ended unexpectedly", "mqtt_connection_ended"),
("unreviewed subscribed topic", "mqtt_unreviewed_topic"),
("response exceeds", "mqtt_response_too_large"),
)
return next(
(code for fragment, code in reason_fragments if fragment in message),
"mqtt_transport_failure",
)

View File

@ -1,5 +1,6 @@
from __future__ import annotations from __future__ import annotations
import logging
import threading import threading
import time import time
from collections.abc import Callable from collections.abc import Callable
@ -57,6 +58,8 @@ ApplicationControlPhase = Literal[
"failed", "failed",
] ]
logger = logging.getLogger(__name__)
TransportFactory = Callable[[str], ReviewedApplicationMqttTransport] TransportFactory = Callable[[str], ReviewedApplicationMqttTransport]
@ -108,7 +111,6 @@ class InteractiveApplicationControlSession:
self._project_requested = threading.Event() self._project_requested = threading.Event()
self._start_requested = threading.Event() self._start_requested = threading.Event()
self._stop_requested = threading.Event() self._stop_requested = threading.Event()
self._standby_confirmed = threading.Event()
self._cancel_requested = False self._cancel_requested = False
self._start_confirmation: OperatorPresenceConfirmation | None = None self._start_confirmation: OperatorPresenceConfirmation | None = None
self._stop_confirmation: OperatorPresenceConfirmation | None = None self._stop_confirmation: OperatorPresenceConfirmation | None = None
@ -117,6 +119,7 @@ class InteractiveApplicationControlSession:
self._dialogue_snapshot: dict[str, object] | None = None self._dialogue_snapshot: dict[str, object] | None = None
self._transport_snapshot: dict[str, object] | None = None self._transport_snapshot: dict[str, object] | None = None
self._outcome_unknown = False self._outcome_unknown = False
self._run_generation = 0
def open( def open(
self, self,
@ -128,21 +131,28 @@ class InteractiveApplicationControlSession:
# Validate every explicit confirmation before creating a network owner. # Validate every explicit confirmation before creating a network owner.
confirmation.checklist(ModelingAction.START) confirmation.checklist(ModelingAction.START)
with self._lock: with self._lock:
if self._phase in {"completed", "closed"} or ( if self._phase != "idle" and self._can_open_locked():
self._phase == "failed"
and self._failure is not None
and self._failure.get("safe_to_retry") is True
):
self._reset_locked() self._reset_locked()
if self._phase != "idle": if self._phase != "idle":
raise ApplicationAcceptanceError( raise ApplicationAcceptanceError(
"application control session is already open or requires manual recovery" "application control session is already open or requires manual recovery"
) )
# A previous worker may already have published a terminal phase but
# still own its local MQTT socket while its ``finally`` block runs.
# Never create the next network owner until that thread has exited
# and cleared the closed transport.
if not self._worker_retired_locked():
raise ApplicationAcceptanceError(
"previous application control worker is still retiring"
)
self._host = host self._host = host
self._timezone_name = timezone_name self._timezone_name = timezone_name
self._set_phase_locked("connecting") self._set_phase_locked("connecting")
self._run_generation += 1
generation = self._run_generation
self._thread = threading.Thread( self._thread = threading.Thread(
target=self._run, target=self._run,
args=(generation,),
name="xgrids-k1-canonical-control", name="xgrids-k1-canonical-control",
daemon=True, daemon=True,
) )
@ -189,16 +199,6 @@ class InteractiveApplicationControlSession:
self._stop_requested.set() self._stop_requested.set()
return self.snapshot() return self.snapshot()
def confirm_standby(self) -> dict[str, object]:
with self._lock:
self._require_phase_locked("awaiting-standby-confirmation")
if not self._device_ready_for_standby_confirmation_locked():
raise ApplicationAcceptanceError(
"K1 has not reported unbound READY after canonical STOP"
)
self._standby_confirmed.set()
return self.snapshot()
def close_prestart(self) -> dict[str, object]: def close_prestart(self) -> dict[str, object]:
with self._lock: with self._lock:
if self._phase not in { if self._phase not in {
@ -234,33 +234,27 @@ class InteractiveApplicationControlSession:
return { return {
"mode": "interactive-canonical", "mode": "interactive-canonical",
"state": phase, "state": phase,
"control_socket_open": phase "control_socket_open": phase not in {"idle", "completed", "closed", "failed"},
not in {"idle", "completed", "closed", "failed"}, "can_open": self._can_open_locked(),
"can_open": phase in {"idle", "completed", "closed"}
or (
phase == "failed"
and self._failure is not None
and self._failure.get("safe_to_retry") is True
),
"can_enter_workspace": phase == "connection-ready", "can_enter_workspace": phase == "connection-ready",
"can_prepare_project": phase == "workspace-ready", "can_prepare_project": phase == "workspace-ready",
"can_start": phase == "project-ready", "can_start": phase == "project-ready",
"can_stop": phase == "scanning", "can_stop": phase == "scanning",
"can_confirm_standby": self._device_ready_for_standby_confirmation_locked(), # Retained for wire compatibility with the v1alpha2 snapshot.
# Standby is now concluded solely from live K1 protocol state.
"can_confirm_standby": False,
"pending_operator_action": self._pending_operator_action_locked(), "pending_operator_action": self._pending_operator_action_locked(),
"scripted_transitions": False, "scripted_transitions": False,
"automatic_retry": False, "automatic_retry": False,
"outcome_unknown": self._outcome_unknown, "outcome_unknown": self._outcome_unknown,
"failure": dict(self._failure) if self._failure is not None else None, "failure": dict(self._failure) if self._failure is not None else None,
"dialogue": ( "dialogue": (
dict(self._dialogue_snapshot) dict(self._dialogue_snapshot) if self._dialogue_snapshot is not None else None
if self._dialogue_snapshot is not None
else None
), ),
"transport": transport_snapshot, "transport": transport_snapshot,
} }
def _run(self) -> None: def _run(self, generation: int) -> None:
executor: PhysicalAcceptanceDialogueExecutor | None = None executor: PhysicalAcceptanceDialogueExecutor | None = None
transport: ReviewedApplicationMqttTransport | None = None transport: ReviewedApplicationMqttTransport | None = None
try: try:
@ -319,64 +313,195 @@ class InteractiveApplicationControlSession:
executor.maintain_active_until_stop_requested(self._stop_requested.is_set) executor.maintain_active_until_stop_requested(self._stop_requested.is_set)
stop_confirmation = self._stop_request() stop_confirmation = self._stop_request()
stop_permit = PhysicalAcceptancePermit( stop_permit = PhysicalAcceptancePermit(stop_confirmation.checklist(ModelingAction.STOP))
stop_confirmation.checklist(ModelingAction.STOP)
)
self._set_phase("stopping") self._set_phase("stopping")
executor.execute_canonical_stop( executor.execute_canonical_stop(
self._stop_command(authority, binding), self._stop_command(authority, binding),
stop_permit, stop_permit,
) )
self._set_phase("awaiting-standby-confirmation") self._set_phase("awaiting-standby-confirmation")
executor.maintain_post_stop_until_standby_confirmed( executor.maintain_post_stop_until_standby()
self._standby_confirmed.is_set
)
self._set_phase("completed") self._set_phase("completed")
except Exception as exc: except Exception as exc:
dialogue_snapshot, dialogue_snapshot_available = self._executor_snapshot_safely(
executor
)
transport_snapshot, transport_snapshot_available = self._transport_snapshot_safely(
transport
)
with self._lock: with self._lock:
if self._cancel_requested: if self._cancel_requested:
self._set_phase_locked("closed") self._set_phase_locked("closed")
else: else:
publish_attempts = 0 failed_phase = self._phase
if transport is not None: transport_created = transport is not None
observed_attempts = transport.snapshot().as_dict().get( dialogue_executor_created = executor is not None
"publish_attempts", publish_attempts = (
0, self._json_int_or_none(transport_snapshot.get("publish_attempts"))
if transport_snapshot_available
else None
)
dialogue_stage = self._json_string(dialogue_snapshot.get("dialogue_stage"))
correlation_failure = dialogue_snapshot.get("correlation_failure")
compatibility_failure = dialogue_snapshot.get("compatibility_failure")
failure_reason_code = self._failure_reason_code(
exc,
correlation_failure=correlation_failure,
)
modeling_command_attempted: bool | None = (
(
dialogue_snapshot.get("start_attempted") is True
or dialogue_snapshot.get("stop_attempted") is True
) )
if isinstance(observed_attempts, int) and not isinstance( if dialogue_snapshot_available
observed_attempts, else (False if not dialogue_executor_created else None)
bool, )
): diagnostic_snapshot_unavailable = [
publish_attempts = observed_attempts component
outcome_unknown = isinstance( for component, created, available in (
exc, ApplicationCommandOutcomeUnknown (
) or self._phase in { "transport",
"start-requested", transport_created,
"initializing", transport_snapshot_available,
"scanning", ),
"stop-requested", (
"stopping", "dialogue",
"awaiting-standby-confirmation", dialogue_executor_created,
} dialogue_snapshot_available,
),
)
if created and not available
]
diagnostic_evidence_unavailable = [
evidence
for evidence, unavailable in (
(
"transport.publish_attempts",
transport_created and publish_attempts is None,
),
(
"dialogue.modeling_command_attempted",
dialogue_executor_created and modeling_command_attempted is None,
),
)
if unavailable
]
outcome_unknown = (
isinstance(exc, ApplicationCommandOutcomeUnknown)
or modeling_command_attempted is True
or bool(diagnostic_evidence_unavailable)
or failed_phase
in {
"scanning",
"stop-requested",
"stopping",
"awaiting-standby-confirmation",
}
)
# A correlated ordinal-1 DeviceInfo profile mismatch is a
# completed read-only exchange. It cannot have started or
# stopped modeling, so a later explicit operator click may
# open a fresh dialogue after the software/profile issue is
# corrected. No other post-publish failure is promoted.
correlated_read_only_profile_mismatch = (
failure_reason_code == "compatibility_profile_mismatch"
and modeling_command_attempted is False
and publish_attempts == 1
and self._json_int_or_none(
transport_snapshot.get("correlated_responses")
)
== 1
)
safe_to_retry = not outcome_unknown and (
not transport_created
or (transport_snapshot_available and publish_attempts == 0)
or correlated_read_only_profile_mismatch
)
self._failure = { self._failure = {
"code": type(exc).__name__, "code": type(exc).__name__,
"reason_code": failure_reason_code,
"message": str(exc), "message": str(exc),
"safe_to_retry": publish_attempts == 0 and not outcome_unknown, "failed_phase": failed_phase,
"dialogue_stage": dialogue_stage,
"transport_state": self._json_string(transport_snapshot.get("state")),
"publish_attempts": publish_attempts,
"qos2_completions": self._json_int_or_none(
transport_snapshot.get("qos2_completions")
),
"correlated_responses": self._json_int_or_none(
transport_snapshot.get("correlated_responses")
),
"ignored_known_responses": self._json_int_or_none(
transport_snapshot.get("ignored_known_responses")
),
"late_known_responses": self._json_int_or_none(
transport_snapshot.get("late_known_responses")
),
"modeling_command_attempted": modeling_command_attempted,
"diagnostic_snapshot_unavailable": (diagnostic_snapshot_unavailable),
"diagnostic_evidence_unavailable": (diagnostic_evidence_unavailable),
"correlation_failure": (
dict(correlation_failure)
if isinstance(correlation_failure, dict)
else None
),
"compatibility_failure": (
dict(compatibility_failure)
if isinstance(compatibility_failure, dict)
else None
),
"safe_to_retry": safe_to_retry,
} }
self._outcome_unknown = outcome_unknown self._outcome_unknown = outcome_unknown
self._dialogue_snapshot = dialogue_snapshot or None
self._transport_snapshot = transport_snapshot or None
self._set_phase_locked("failed") self._set_phase_locked("failed")
logger.error(
"K1 application control session failed: code=%s reason_code=%s "
"phase=%s dialogue_stage=%s transport_state=%s "
"publish_attempts=%s modeling_command_attempted=%s "
"diagnostic_snapshot_unavailable=%s "
"diagnostic_evidence_unavailable=%s outcome_unknown=%s "
"safe_to_retry=%s",
type(exc).__name__,
failure_reason_code,
failed_phase,
dialogue_stage,
self._json_string(transport_snapshot.get("state")),
publish_attempts,
modeling_command_attempted,
diagnostic_snapshot_unavailable,
diagnostic_evidence_unavailable,
outcome_unknown,
safe_to_retry,
)
finally: finally:
final_dialogue_snapshot: dict[str, object] | None = None
final_transport_snapshot: dict[str, object] | None = None
if executor is not None: if executor is not None:
with self._lock: captured_dialogue, dialogue_available = self._executor_snapshot_safely(executor)
self._dialogue_snapshot = executor.snapshot() if dialogue_available:
final_dialogue_snapshot = captured_dialogue
if transport is not None: if transport is not None:
try: try:
with self._lock: captured_transport, transport_available = self._transport_snapshot_safely(
self._transport_snapshot = transport.snapshot().as_dict() transport
)
if transport_available:
final_transport_snapshot = captured_transport
finally: finally:
transport.close() transport.close()
with self._lock: with self._lock:
self._transport = None # The generation guard remains defense in depth for shutdown
# races, although open() also requires the prior worker to be
# fully retired before a new generation can be created.
if generation == self._run_generation:
if final_dialogue_snapshot is not None:
self._dialogue_snapshot = final_dialogue_snapshot
if final_transport_snapshot is not None:
self._transport_snapshot = final_transport_snapshot
if self._transport is transport:
self._transport = None
@staticmethod @staticmethod
def _start_command( def _start_command(
@ -427,6 +552,62 @@ class InteractiveApplicationControlSession:
with self._lock: with self._lock:
self._set_phase_locked(phase) self._set_phase_locked(phase)
@staticmethod
def _executor_snapshot_safely(
executor: PhysicalAcceptanceDialogueExecutor | None,
) -> tuple[dict[str, object], bool]:
if executor is None:
return {}, False
try:
return dict(executor.snapshot()), True
except Exception:
logger.error("K1 application dialogue snapshot failed")
return {}, False
@staticmethod
def _transport_snapshot_safely(
transport: ReviewedApplicationMqttTransport | None,
) -> tuple[dict[str, object], bool]:
if transport is None:
return {}, False
try:
return dict(transport.snapshot().as_dict()), True
except Exception:
logger.error("K1 application transport snapshot failed")
return {}, False
@staticmethod
def _json_int_or_none(value: object) -> int | None:
return value if isinstance(value, int) and not isinstance(value, bool) else None
@staticmethod
def _json_string(value: object) -> str | None:
return value if isinstance(value, str) else None
@staticmethod
def _failure_reason_code(
exc: Exception,
*,
correlation_failure: object,
) -> str:
explicit = getattr(exc, "reason_code", None)
if isinstance(explicit, str) and explicit:
return explicit
if isinstance(correlation_failure, dict):
correlated = correlation_failure.get("reason_code")
if isinstance(correlated, str) and correlated:
return correlated
exception_name = type(exc).__name__
if "Authority" in exception_name or "Keychain" in exception_name:
return "application_authority_unavailable"
if isinstance(exc, TimeoutError):
return "control_checkpoint_timeout"
if isinstance(exc, ApplicationAcceptanceError):
return "application_acceptance_failed"
if isinstance(exc, RuntimeError):
return "unexpected_runtime_error"
return "internal_control_error"
def _set_phase_locked(self, phase: ApplicationControlPhase) -> None: def _set_phase_locked(self, phase: ApplicationControlPhase) -> None:
self._phase = phase self._phase = phase
@ -436,18 +617,17 @@ class InteractiveApplicationControlSession:
f"control action requires {expected}; current state is {self._phase}" f"control action requires {expected}; current state is {self._phase}"
) )
def _device_ready_for_standby_confirmation_locked(self) -> bool:
if self._phase != "awaiting-standby-confirmation":
return False
snapshot = self._live_transport_snapshot_locked()
return (
snapshot.get("latest_device_session_state") == "ready"
and snapshot.get("latest_device_project_bound") is False
)
def _live_transport_snapshot_locked(self) -> dict[str, object]: def _live_transport_snapshot_locked(self) -> dict[str, object]:
if self._transport is not None: if self._transport is not None:
return self._transport.snapshot().as_dict() snapshot, available = self._transport_snapshot_safely(self._transport)
if available:
return snapshot
return {
"state": "snapshot-unavailable",
"diagnostic_snapshot_available": False,
"automatic_retry": False,
"automatic_reconnect": False,
}
return ( return (
dict(self._transport_snapshot) dict(self._transport_snapshot)
if self._transport_snapshot is not None if self._transport_snapshot is not None
@ -464,9 +644,20 @@ class InteractiveApplicationControlSession:
"workspace-ready": "prepare-project", "workspace-ready": "prepare-project",
"project-ready": "start", "project-ready": "start",
"scanning": "stop", "scanning": "stop",
"awaiting-standby-confirmation": "confirm-steady-green",
}.get(self._phase) }.get(self._phase)
def _can_open_locked(self) -> bool:
reopenable_phase = self._phase in {"idle", "completed", "closed"} or (
self._phase == "failed"
and self._failure is not None
and self._failure.get("safe_to_retry") is True
)
return reopenable_phase and self._worker_retired_locked()
def _worker_retired_locked(self) -> bool:
thread = self._thread
return (thread is None or not thread.is_alive()) and self._transport is None
def _reset_locked(self) -> None: def _reset_locked(self) -> None:
self._phase = "idle" self._phase = "idle"
self._host = None self._host = None
@ -477,7 +668,6 @@ class InteractiveApplicationControlSession:
self._project_requested = threading.Event() self._project_requested = threading.Event()
self._start_requested = threading.Event() self._start_requested = threading.Event()
self._stop_requested = threading.Event() self._stop_requested = threading.Event()
self._standby_confirmed = threading.Event()
self._cancel_requested = False self._cancel_requested = False
self._start_confirmation = None self._start_confirmation = None
self._stop_confirmation = None self._stop_confirmation = None

View File

@ -6,6 +6,7 @@ from dataclasses import dataclass, field
from typing import Literal from typing import Literal
from k1link.device_plugins.xgrids_k1.protocol.application_bootstrap import ( from k1link.device_plugins.xgrids_k1.protocol.application_bootstrap import (
REVIEWED_PROFILE_ATTESTATION_FAILURE,
ApplicationControlAuthority, ApplicationControlAuthority,
LiveDeviceControlBinding, LiveDeviceControlBinding,
) )
@ -217,9 +218,7 @@ class LiveModelingControlSafety:
"DeviceInfo binding does not match the live K1 status stream" "DeviceInfo binding does not match the live K1 status stream"
) )
if not binding.ready_for_reviewed_profile: if not binding.ready_for_reviewed_profile:
raise ModelingControlSafetyError( raise ModelingControlSafetyError(REVIEWED_PROFILE_ATTESTATION_FAILURE)
"live DeviceInfo does not attest the reviewed activated FW 3.0.2 profile"
)
if report.session_state is not required_state: if report.session_state is not required_state:
raise ModelingControlSafetyError( raise ModelingControlSafetyError(
f"live K1 must be {required_state.name.casefold()} for this shadow plan" f"live K1 must be {required_state.name.casefold()} for this shadow plan"

View File

@ -38,6 +38,13 @@ def decode_zigzag64(value: int) -> int:
return (value >> 1) ^ -(value & 1) return (value >> 1) ^ -(value & 1)
def encode_zigzag64(value: int) -> int:
"""Encode a signed 64-bit integer as protobuf sint64 ZigZag."""
if value < -(1 << 63) or value > (1 << 63) - 1:
raise ProtobufWireError("ZigZag input is outside int64")
return ((value << 1) ^ (value >> 63)) & 0xFFFFFFFFFFFFFFFF
def iter_fields(data: bytes, *, max_fields: int = 1_000_000) -> Iterator[ProtoField]: def iter_fields(data: bytes, *, max_fields: int = 1_000_000) -> Iterator[ProtoField]:
"""Iterate supported protobuf fields without recursion or unbounded allocation.""" """Iterate supported protobuf fields without recursion or unbounded allocation."""
if max_fields < 1: if max_fields < 1:

View File

@ -1,5 +1,6 @@
from __future__ import annotations from __future__ import annotations
import logging
from collections.abc import Callable, Mapping from collections.abc import Callable, Mapping
from dataclasses import dataclass from dataclasses import dataclass
from datetime import UTC, datetime from datetime import UTC, datetime
@ -16,12 +17,14 @@ from missioncore_plugin_sdk.v0alpha2 import (
RuntimeHealthSnapshot, RuntimeHealthSnapshot,
RuntimePluginDescriptor, RuntimePluginDescriptor,
) )
from pydantic import BaseModel, ConfigDict, Field from pydantic import BaseModel, ConfigDict, Field, ValidationError
from k1link.sessions.plugin_contract import ObservationRuntimeContribution from k1link.sessions.plugin_contract import ObservationRuntimeContribution
STATE_READ_ACTION_ID = "state.read" STATE_READ_ACTION_ID = "state.read"
logger = logging.getLogger(__name__)
class DevicePluginActionRequest(BaseModel): class DevicePluginActionRequest(BaseModel):
model_config = ConfigDict(extra="forbid") model_config = ConfigDict(extra="forbid")
@ -162,13 +165,28 @@ class InProcessDevicePluginRuntime:
f"{invocation.action_id}" f"{invocation.action_id}"
) )
output = await self._adapter.invoke(invocation) output = await self._adapter.invoke(invocation)
return RuntimeActionResult( try:
invocation_id=invocation.invocation_id, return RuntimeActionResult(
plugin_id=invocation.plugin_id, invocation_id=invocation.invocation_id,
action_id=invocation.action_id, plugin_id=invocation.plugin_id,
completed_at=datetime.now(UTC), action_id=invocation.action_id,
output=dict(output), completed_at=datetime.now(UTC),
) output=dict(output),
)
except ValidationError as exc:
# Never allow a malformed plugin result to masquerade as invalid
# operator input (HTTP 422). Do not log the output or validation
# input: device snapshots may contain retained evidence.
logger.error(
"device plugin returned a non-JSON action result: "
"plugin_id=%s action_id=%s validation_errors=%d",
invocation.plugin_id,
invocation.action_id,
exc.error_count(),
)
raise PluginExecutionError(
"Device plugin returned an invalid non-JSON action result"
) from exc
def close(self) -> None: def close(self) -> None:
with self._lock: with self._lock:

View File

@ -0,0 +1,10 @@
{
"schema_version": 1,
"source": "sanitized-owner-captured-clean-cycle-device-info",
"device_model": "LixelKity K1",
"platform_type": "A4",
"software_version": "V3.0.2_20250624.122658",
"system_version": "V3.0.2",
"is_activated": true,
"redaction": "No device identifier, serial, authority, network address, credential, or raw payload is retained."
}

View File

@ -4,6 +4,7 @@ from typing import Any
from typer.testing import CliRunner from typer.testing import CliRunner
from k1link.device_plugins.xgrids_k1 import cli
from k1link.device_plugins.xgrids_k1.cli import app from k1link.device_plugins.xgrids_k1.cli import app
runner = CliRunner() runner = CliRunner()
@ -25,6 +26,26 @@ def test_doctor_json() -> None:
assert any(item["name"] == "tcpdump" for item in payload["tools"]) assert any(item["name"] == "tcpdump" for item in payload["tools"])
def test_serve_resolves_frontend_from_repository_root(monkeypatch: Any) -> None:
captured: dict[str, object] = {}
def fake_run(application: str, **kwargs: object) -> None:
captured.update({"application": application, **kwargs})
monkeypatch.setattr(cli.uvicorn, "run", fake_run)
result = runner.invoke(app, ["serve"])
assert result.exit_code == 0
assert captured == {
"application": "k1link.web.app:app",
"host": "127.0.0.1",
"port": 8000,
"log_level": "info",
"access_log": True,
}
def test_authority_provision_requires_explicit_reviewed_value_confirmation() -> None: def test_authority_provision_requires_explicit_reviewed_value_confirmation() -> None:
result = runner.invoke(app, ["authority", "provision"]) result = runner.invoke(app, ["authority", "provision"])

View File

@ -361,6 +361,37 @@ def test_dispatcher_routes_allowlisted_action_to_xgrids_facade() -> None:
assert service.calls == [("scan", 6.0)] assert service.calls == [("scan", 6.0)]
def test_runtime_classifies_non_json_plugin_output_as_execution_failure(
caplog: pytest.LogCaptureFixture,
) -> None:
retained_marker = "must-not-appear-in-the-server-log"
class NonJsonAdapter:
plugin_id = "example.non-json"
action_ids = frozenset({"state.read"})
async def invoke(
self,
_invocation: RuntimeActionInvocation,
) -> dict[str, Any]:
return {"dialogue": {"response_evidence": (retained_marker,)}}
dispatcher = DevicePluginDispatcher(
[
_in_process_runtime(
NonJsonAdapter(),
plugin_version="0.1.0",
host_api_version="missioncore.nodedc/v1alpha2",
)
]
)
with pytest.raises(PluginExecutionError, match="non-JSON"):
asyncio.run(dispatcher.invoke("example.non-json", "state.read", {}))
assert retained_marker not in caplog.text
def test_dispatcher_rejects_unknown_plugin_and_action() -> None: def test_dispatcher_rejects_unknown_plugin_and_action() -> None:
dispatcher = DevicePluginDispatcher( dispatcher = DevicePluginDispatcher(
[_in_process_runtime(XgridsK1PluginFacade(FakeXgridsService()))] [_in_process_runtime(XgridsK1PluginFacade(FakeXgridsService()))]
@ -404,7 +435,7 @@ def test_facade_validates_payload_before_calling_service() -> None:
"compatibility_attestation": { "compatibility_attestation": {
"firmware_version": "3.0.2", "firmware_version": "3.0.2",
"topology": "direct-lan", "topology": "direct-lan",
"operator_confirmed": True, "verification": "live-device-info",
}, },
}, },
"live", "live",

View File

@ -17,6 +17,7 @@ from k1link.device_plugins.xgrids_k1.protocol.modeling import (
from k1link.device_plugins.xgrids_k1.protocol.protobuf_wire import ( from k1link.device_plugins.xgrids_k1.protocol.protobuf_wire import (
ProtobufWireError, ProtobufWireError,
decode_zigzag64, decode_zigzag64,
encode_zigzag64,
iter_fields, iter_fields,
) )
from k1link.device_plugins.xgrids_k1.protocol.streams import ( from k1link.device_plugins.xgrids_k1.protocol.streams import (
@ -94,6 +95,13 @@ def test_protobuf_wire_zigzag_and_bounds() -> None:
assert decode_zigzag64(0) == 0 assert decode_zigzag64(0) == 0
assert decode_zigzag64(1) == -1 assert decode_zigzag64(1) == -1
assert decode_zigzag64(2) == 1 assert decode_zigzag64(2) == 1
assert encode_zigzag64(0) == 0
assert encode_zigzag64(-1) == 1
assert encode_zigzag64(1) == 2
assert decode_zigzag64(encode_zigzag64(-(1 << 63))) == -(1 << 63)
assert decode_zigzag64(encode_zigzag64((1 << 63) - 1)) == (1 << 63) - 1
with pytest.raises(ProtobufWireError, match="outside int64"):
encode_zigzag64(1 << 63)
with pytest.raises(ProtobufWireError, match="truncated"): with pytest.raises(ProtobufWireError, match="truncated"):
list(iter_fields(b"\x0a\x02\x01")) list(iter_fields(b"\x0a\x02\x01"))
with pytest.raises(ProtobufWireError, match="unsupported"): with pytest.raises(ProtobufWireError, match="unsupported"):

View File

@ -77,7 +77,7 @@ def test_validation_errors_do_not_echo_sensitive_request_values(
"compatibility_attestation": { "compatibility_attestation": {
"firmware_version": "3.0.2", "firmware_version": "3.0.2",
"topology": "direct-lan", "topology": "direct-lan",
"operator_confirmed": True, "verification": "live-device-info",
}, },
} }
payload = {"input": action_input} if wrap_input else action_input payload = {"input": action_input} if wrap_input else action_input

View File

@ -31,7 +31,7 @@ from k1link.device_plugins.xgrids_k1.protocol.application_bootstrap import (
ATTESTATION = CompatibilityAttestationRequest( ATTESTATION = CompatibilityAttestationRequest(
firmware_version="3.0.2", firmware_version="3.0.2",
topology="direct-lan", topology="direct-lan",
operator_confirmed=True, verification="live-device-info",
) )
PRIMARY_TEST_CREDENTIAL = "x" * 24 PRIMARY_TEST_CREDENTIAL = "x" * 24
SECONDARY_TEST_CREDENTIAL = "y" * 24 SECONDARY_TEST_CREDENTIAL = "y" * 24
@ -120,12 +120,11 @@ class FakeInteractiveControlSession:
self.state = "workspace-ready" self.state = "workspace-ready"
self.start_projects: list[str] = [] self.start_projects: list[str] = []
self.stop_calls = 0 self.stop_calls = 0
self.confirm_calls = 0
def snapshot(self) -> dict[str, object]: def snapshot(self) -> dict[str, object]:
return { return {
"state": self.state, "state": self.state,
"can_confirm_standby": self.state == "awaiting-standby-confirmation", "can_confirm_standby": False,
} }
def open_project_prompt(self) -> dict[str, object]: def open_project_prompt(self) -> dict[str, object]:
@ -147,12 +146,6 @@ class FakeInteractiveControlSession:
self.state = "awaiting-standby-confirmation" self.state = "awaiting-standby-confirmation"
return self.snapshot() return self.snapshot()
def confirm_standby(self) -> dict[str, object]:
assert self.state == "awaiting-standby-confirmation"
self.confirm_calls += 1
self.state = "completed"
return self.snapshot()
def close_prestart(self) -> dict[str, object]: def close_prestart(self) -> dict[str, object]:
self.state = "closed" self.state = "closed"
return self.snapshot() return self.snapshot()
@ -211,6 +204,8 @@ def test_prepare_creates_provisional_device_session_and_profiled_acquisition(
assert state["device_ref"]["device_id"] != state["device_session"]["device_session_id"] assert state["device_ref"]["device_id"] != state["device_session"]["device_session_id"]
assert state["acquisition"]["state"] == "prepared" assert state["acquisition"]["state"] == "prepared"
assert state["acquisition"]["project_name"] == PROJECT_NAME assert state["acquisition"]["project_name"] == PROJECT_NAME
assert state["acquisition"]["mount_type"] == "handheld"
assert state["acquisition"]["gnss_mode"] == "none"
assert state["acquisition"]["compatibility_profile_id"] == (XGRIDS_K1_COMPATIBILITY_PROFILE_ID) assert state["acquisition"]["compatibility_profile_id"] == (XGRIDS_K1_COMPATIBILITY_PROFILE_ID)
assert state["compatibility"]["vendor_writes_enabled"] is False assert state["compatibility"]["vendor_writes_enabled"] is False
@ -230,6 +225,29 @@ def test_project_name_is_normalized_and_control_characters_are_rejected() -> Non
host="192.168.1.20", host="192.168.1.20",
compatibility_attestation=ATTESTATION, compatibility_attestation=ATTESTATION,
) )
def test_only_physically_accepted_configuration_values_are_admitted() -> None:
with pytest.raises(ValidationError):
ConnectRequest(
device_id="synthetic-device",
ssid="synthetic-network",
password=SecretStr(PRIMARY_TEST_CREDENTIAL),
connection_mode="quick-connect", # type: ignore[arg-type]
compatibility_attestation=ATTESTATION,
)
with pytest.raises(ValidationError):
PrepareAcquisitionRequest(
project_name=PROJECT_NAME,
host="192.168.1.20",
mount_type="uav", # type: ignore[arg-type]
compatibility_attestation=ATTESTATION,
)
with pytest.raises(ValidationError):
PrepareAcquisitionRequest(
project_name=PROJECT_NAME,
host="192.168.1.20",
gnss_mode="rtk", # type: ignore[arg-type]
compatibility_attestation=ATTESTATION,
)
def test_facade_arms_bounded_shadow_lease_without_installing_publish_transport( def test_facade_arms_bounded_shadow_lease_without_installing_publish_transport(
@ -247,7 +265,8 @@ def test_facade_arms_bounded_shadow_lease_without_installing_publish_transport(
service._compatibility_attestation = { # noqa: SLF001 service._compatibility_attestation = { # noqa: SLF001
"firmware_version": "3.0.2", "firmware_version": "3.0.2",
"topology": "direct-lan", "topology": "direct-lan",
"basis": "operator-attested", "verification": "live-device-info",
"basis": "selected-profile-live-device-info-required",
"observed_at": "2026-07-18T00:00:00Z", "observed_at": "2026-07-18T00:00:00Z",
} }
@ -274,7 +293,7 @@ def test_facade_arms_bounded_shadow_lease_without_installing_publish_transport(
assert disarmed["application_control_execution"]["lease"] is None assert disarmed["application_control_execution"]["lease"] is None
def test_shadow_arm_requires_idle_connected_attested_device_before_keychain_read( def test_shadow_arm_requires_idle_connected_profile_selected_device_before_keychain_read(
tmp_path: Path, tmp_path: Path,
) -> None: ) -> None:
loader = FakeApplicationAuthorityLoader() loader = FakeApplicationAuthorityLoader()
@ -405,21 +424,82 @@ def test_plugin_commanded_acquisition_keeps_start_and_stop_as_explicit_actions(
physical_acceptance=PHYSICAL_ACCEPTANCE, physical_acceptance=PHYSICAL_ACCEPTANCE,
) )
) )
stop_operation = stopping["last_operation"]
assert control.stop_calls == 1 assert control.stop_calls == 1
assert stopping["acquisition"]["state"] == "awaiting_external_stop" assert stopping["acquisition"]["state"] == "awaiting_external_stop"
assert stopping["last_operation"]["status"] == "running"
completed = service.stop_acquisition( control.state = "completed"
completed = service.state()
assert control.stop_calls == 1
assert completed["acquisition"]["state"] == "completed"
assert completed["acquisition"]["result"]["device_state"] == "ready"
assert completed["last_operation"]["status"] == "succeeded"
assert runtime.stop_calls == 1
def test_device_standby_retires_sources_after_terminal_local_stop_failure(
tmp_path: Path,
) -> None:
service, runtime = service_with_fake_runtime(tmp_path)
control = FakeInteractiveControlSession()
service._application_control_session = control # type: ignore[assignment] # noqa: SLF001
service._k1_ip = "192.168.1.20" # noqa: SLF001
service._compatibility_attestation = {"profile": "exact"} # noqa: SLF001
prepared = service.prepare_acquisition(
PrepareAcquisitionRequest(
project_name="TEST001",
host="192.168.1.20",
compatibility_attestation=ATTESTATION,
)
)
acquisition_id = prepared["acquisition"]["acquisition_id"]
service.start_acquisition(
StartAcquisitionRequest(
acquisition_id=acquisition_id,
physical_acceptance=PHYSICAL_ACCEPTANCE,
)
)
runtime.mark_ready()
runtime.pcl_frames = 1
service.state()
stopping = service.stop_acquisition(
StopAcquisitionRequest( StopAcquisitionRequest(
acquisition_id=acquisition_id, acquisition_id=acquisition_id,
mode="graceful", mode="graceful",
operator_confirmed=True, physical_acceptance=PHYSICAL_ACCEPTANCE,
operation_id=stop_operation["operation_id"],
) )
) )
stop_operation = stopping["last_operation"]
with service._lock: # noqa: SLF001
assert service._acquisition is not None # noqa: SLF001
service._acquisition.transition( # noqa: SLF001
"failed",
message_code="acquisition.camera_failed",
result={"camera_failure_code": "camera-source-ended"},
)
service._operations.transition( # noqa: SLF001
stop_operation["operation_id"],
"failed",
stage_code="runtime-failed",
message_code="acquisition.stop.runtime_failed",
error={
"category": "stream",
"code": "runtime-failed",
"retryable": False,
"safe_to_retry": False,
"side_effect_status": "unknown",
},
)
control.state = "completed"
recovered = service.state()
assert control.stop_calls == 1 assert control.stop_calls == 1
assert control.confirm_calls == 1 assert control.state == "completed"
assert completed["acquisition"]["state"] == "completed" assert recovered["acquisition"]["state"] == "failed"
assert recovered["application_control_session"]["state"] == "completed"
assert runtime.stop_calls == 1 assert runtime.stop_calls == 1
@ -1257,7 +1337,7 @@ def test_prepare_rejects_stream_subsets_and_duplicates(
) )
def test_exact_profile_is_inactive_until_explicit_operator_attestation( def test_exact_profile_is_inactive_until_selected_for_live_device_info_verification(
tmp_path: Path, tmp_path: Path,
) -> None: ) -> None:
service, _ = service_with_fake_runtime(tmp_path) service, _ = service_with_fake_runtime(tmp_path)
@ -1267,7 +1347,7 @@ def test_exact_profile_is_inactive_until_explicit_operator_attestation(
"profile_id": None, "profile_id": None,
"decision": "unknown", "decision": "unknown",
"permitted_mode": "evidence-only", "permitted_mode": "evidence-only",
"firmware_claim": "exact-3.0.2-profile-not-attested", "firmware_claim": "exact-3.0.2-profile-not-selected",
"attestation": None, "attestation": None,
"vendor_writes_enabled": False, "vendor_writes_enabled": False,
"camera_preview": "unverified", "camera_preview": "unverified",
@ -1284,7 +1364,9 @@ def test_exact_profile_is_inactive_until_explicit_operator_attestation(
assert attested["compatibility"]["profile_id"] == XGRIDS_K1_COMPATIBILITY_PROFILE_ID assert attested["compatibility"]["profile_id"] == XGRIDS_K1_COMPATIBILITY_PROFILE_ID
assert attested["compatibility"]["decision"] == "limited" assert attested["compatibility"]["decision"] == "limited"
assert attested["compatibility"]["attestation"]["basis"] == "operator-attested" assert attested["compatibility"]["attestation"]["basis"] == (
"selected-profile-live-device-info-required"
)
assert attested["device_session"]["compatibility_profile_id"] == ( assert attested["device_session"]["compatibility_profile_id"] == (
XGRIDS_K1_COMPATIBILITY_PROFILE_ID XGRIDS_K1_COMPATIBILITY_PROFILE_ID
) )
@ -1722,6 +1804,7 @@ def test_network_provisioning_is_single_flight_and_secret_is_unwrapped_only_at_b
assert boundary_calls == [("k1-a", "lab-network", PRIMARY_TEST_CREDENTIAL)] assert boundary_calls == [("k1-a", "lab-network", PRIMARY_TEST_CREDENTIAL)]
assert connected["k1_ip"] == "192.168.1.20" assert connected["k1_ip"] == "192.168.1.20"
assert connected["connection_mode"] == "bridge"
assert PRIMARY_TEST_CREDENTIAL not in str(connected) assert PRIMARY_TEST_CREDENTIAL not in str(connected)
provision_operations = [ provision_operations = [
item for item in connected["operations"] if item["action"] == "network.provision" item for item in connected["operations"] if item["action"] == "network.provision"
@ -1729,6 +1812,48 @@ def test_network_provisioning_is_single_flight_and_secret_is_unwrapped_only_at_b
assert {item["status"] for item in provision_operations} == {"succeeded", "failed"} assert {item["status"] for item in provision_operations} == {"succeeded", "failed"}
def test_network_provisioning_rejects_an_ipv4_owned_by_the_local_host(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
service, _ = service_with_fake_runtime(tmp_path)
service._devices = [{"device_id": "k1-a"}] # noqa: SLF001
async def fake_provision(*_: object, **__: object) -> dict[str, Any]:
return {
"started_at_utc": "2026-07-18T15:19:25Z",
"completed_at_utc": "2026-07-18T15:19:26Z",
"profile_id": "xgrids-k1-fw3-wifi-v1",
"outcome": "lan_address_observed",
"observations": [{"status": {"ipv4": "192.168.68.51"}}],
}
monkeypatch.setattr(facade_module, "provision_wifi_once", fake_provision)
monkeypatch.setattr(facade_module, "_target_is_local_ipv4", lambda _target: True)
with pytest.raises(RuntimeError, match="уже принадлежит этому компьютеру"):
asyncio.run(
service.connect(
ConnectRequest(
device_id="k1-a",
ssid="lab-network",
password=SecretStr(PRIMARY_TEST_CREDENTIAL),
compatibility_attestation=ATTESTATION,
)
)
)
state = service.state()
assert state["selected_device_id"] is None
assert state["k1_ip"] is None
operation = next(
item for item in state["operations"] if item["action"] == "network.provision"
)
assert operation["status"] == "failed"
assert operation["error"]["safe_to_retry"] is False
assert operation["error"]["side_effect_status"] == "unknown"
def test_provisioning_cannot_switch_device_during_active_acquisition( def test_provisioning_cannot_switch_device_during_active_acquisition(
monkeypatch: pytest.MonkeyPatch, monkeypatch: pytest.MonkeyPatch,
tmp_path: Path, tmp_path: Path,

View File

@ -4,6 +4,7 @@ import hashlib
from collections.abc import Collection, Sequence from collections.abc import Collection, Sequence
import pytest import pytest
from pydantic import JsonValue, TypeAdapter
from k1link.device_plugins.xgrids_k1.protocol.application_acceptance import ( from k1link.device_plugins.xgrids_k1.protocol.application_acceptance import (
ApplicationAcceptanceError, ApplicationAcceptanceError,
@ -11,6 +12,7 @@ from k1link.device_plugins.xgrids_k1.protocol.application_acceptance import (
PhysicalAcceptanceChecklist, PhysicalAcceptanceChecklist,
PhysicalAcceptanceDialogueExecutor, PhysicalAcceptanceDialogueExecutor,
PhysicalAcceptancePermit, PhysicalAcceptancePermit,
ScanInitializationTimeout,
) )
from k1link.device_plugins.xgrids_k1.protocol.application_bootstrap import ( from k1link.device_plugins.xgrids_k1.protocol.application_bootstrap import (
DEVICE_CONFIG_RESPONSE_TOPIC, DEVICE_CONFIG_RESPONSE_TOPIC,
@ -18,9 +20,6 @@ from k1link.device_plugins.xgrids_k1.protocol.application_bootstrap import (
ShadowApplicationBootstrapOrchestrator, ShadowApplicationBootstrapOrchestrator,
build_canonical_post_start_observation, build_canonical_post_start_observation,
) )
from k1link.device_plugins.xgrids_k1.protocol.application_mqtt import (
MODELING_RESPONSE_TOPIC,
)
from k1link.device_plugins.xgrids_k1.protocol.application_publish import ( from k1link.device_plugins.xgrids_k1.protocol.application_publish import (
OneShotPublishEnvelope, OneShotPublishEnvelope,
) )
@ -67,14 +66,19 @@ def _header(session_id: str) -> bytes:
return _text(4, VENDOR_DEVICE_ID) + _text(5, session_id) + _text(6, APPLICATION_KEY) return _text(4, VENDOR_DEVICE_ID) + _text(5, session_id) + _text(6, APPLICATION_KEY)
def _device_info_response(session_id: str) -> bytes: def _device_info_response(
session_id: str,
*,
device_model: str = "LixelKity K1",
device_type: str = "A4",
) -> bytes:
base_info = b"".join( base_info = b"".join(
( (
_text(2, "V3.0.2-20260101-release"), _text(2, "V3.0.2_20250624.122658"),
_text(3, "V3.0.2"), _text(3, "V3.0.2"),
_text(6, "LixelKity K1"), _text(6, device_model),
_text(7, "K1SERIAL01"), _text(7, "K1SERIAL01"),
_text(8, "K1"), _text(8, device_type),
) )
) )
working_status = _uint(1, 1) working_status = _uint(1, 1)
@ -93,7 +97,13 @@ def _modeling_response(action: ModelingAction) -> bytes:
class SyntheticAcceptanceTransport: class SyntheticAcceptanceTransport:
def __init__(self, clock: FakeClock | None = None) -> None: def __init__(
self,
clock: FakeClock | None = None,
*,
device_model: str = "LixelKity K1",
device_type: str = "A4",
) -> None:
self.batches: list[tuple[str, ...]] = [] self.batches: list[tuple[str, ...]] = []
self.maintain_calls: list[tuple[float, tuple[str, ...]]] = [] self.maintain_calls: list[tuple[float, tuple[str, ...]]] = []
self.clock = clock self.clock = clock
@ -102,22 +112,32 @@ class SyntheticAcceptanceTransport:
self.post_stop_maintain_calls = 0 self.post_stop_maintain_calls = 0
self.start_emitted = False self.start_emitted = False
self.stop_emitted = False self.stop_emitted = False
self.device_model = device_model
self.device_type = device_type
def exchange_batch_once( def exchange_batch_once(
self, self,
envelopes: Sequence[OneShotPublishEnvelope], envelopes: Sequence[OneShotPublishEnvelope],
*, *,
required_response_topics: Collection[str], required_response_operation_keys: Collection[str],
) -> dict[str, bytes]: ) -> dict[str, bytes]:
self.batches.append(tuple(envelope.operation_key for envelope in envelopes)) self.batches.append(tuple(envelope.operation_key for envelope in envelopes))
responses: dict[str, bytes] = {} responses: dict[str, bytes] = {}
if MODELING_RESPONSE_TOPIC in required_response_topics: modeling_operation = next(
(
operation_key
for operation_key in required_response_operation_keys
if operation_key in {"modeling:start", "modeling:stop"}
),
None,
)
if modeling_operation is not None:
action = ( action = (
ModelingAction.START ModelingAction.START
if envelopes[0].operation_key == "modeling:start" if modeling_operation == "modeling:start"
else ModelingAction.STOP else ModelingAction.STOP
) )
responses[MODELING_RESPONSE_TOPIC] = _modeling_response(action) responses[modeling_operation] = _modeling_response(action)
if action is ModelingAction.START: if action is ModelingAction.START:
self.start_emitted = True self.start_emitted = True
else: else:
@ -134,7 +154,11 @@ class SyntheticAcceptanceTransport:
if ordinal == 1 if ordinal == 1
else f"{VENDOR_DEVICE_ID}:DeviceInfoRequest" else f"{VENDOR_DEVICE_ID}:DeviceInfoRequest"
) )
response = _device_info_response(session) response = _device_info_response(
session,
device_model=self.device_model,
device_type=self.device_type,
)
else: else:
session = ( session = (
f":{message_type}" if ordinal <= 3 else f"{VENDOR_DEVICE_ID}:{message_type}" f":{message_type}" if ordinal <= 3 else f"{VENDOR_DEVICE_ID}:{message_type}"
@ -142,9 +166,8 @@ class SyntheticAcceptanceTransport:
if message_type == "DeviceConfigRequest": if message_type == "DeviceConfigRequest":
session += ":Publish_Proto_DeviceConfig_SetTime" session += ":Publish_Proto_DeviceConfig_SetTime"
response = _generic_response(session) response = _generic_response(session)
for topic in required_response_topics: if envelope.operation_key in required_response_operation_keys:
if topic.endswith(_response_suffix(message_type)): responses[envelope.operation_key] = response
responses[topic] = response
return responses return responses
def maintain_open_for( def maintain_open_for(
@ -178,18 +201,6 @@ class SyntheticAcceptanceTransport:
return None return None
def _response_suffix(message_type: str) -> str:
values = {
"DeviceInfoRequest": "device_info",
"ModelingStatusRequest": "modeling_status",
"GetRtkAdvanceRequest": "get_rtk_advance",
"DeviceConfigRequest": "device_config",
"GetNtripProfileRequest": "get_ntrip_profile",
"GetCloudServerConfigRequest": "get_cloud_server_config",
}
return values[message_type]
def _checklist(action: ModelingAction) -> PhysicalAcceptanceChecklist: def _checklist(action: ModelingAction) -> PhysicalAcceptanceChecklist:
return PhysicalAcceptanceChecklist( return PhysicalAcceptanceChecklist(
action=action, action=action,
@ -232,7 +243,14 @@ def test_canonical_session_owns_start_active_scan_stop_and_save_boundary() -> No
owner_token=object(), owner_token=object(),
), ),
) )
assert [len(batch) for batch in transport.batches] == [1, 2, 3] assert [len(batch) for batch in transport.batches] == [1, 5]
assert transport.batches[1] == (
"bootstrap:2:ModelingStatusRequest",
"bootstrap:3:GetRtkAdvanceRequest",
"bootstrap:4:DeviceConfigRequest",
"bootstrap:5:DeviceInfoRequest",
"bootstrap:6:GetRtkAdvanceRequest",
)
workspace_checks = iter((False, False, True)) workspace_checks = iter((False, False, True))
workspace_checkpoint = executor.wait_for_operator_checkpoint( workspace_checkpoint = executor.wait_for_operator_checkpoint(
"workspace-entered", "workspace-entered",
@ -313,14 +331,11 @@ def test_canonical_session_owns_start_active_scan_stop_and_save_boundary() -> No
) )
) )
stop_response = executor.execute_canonical_stop(stop_command, stop_permit) stop_response = executor.execute_canonical_stop(stop_command, stop_permit)
standby_checks = iter((False, False, True)) executor.maintain_post_stop_until_standby()
executor.maintain_post_stop_until_standby_confirmed(
lambda: next(standby_checks)
)
assert stop_response.action is ModelingAction.STOP assert stop_response.action is ModelingAction.STOP
assert [len(batch) for batch in transport.batches] == [1, 2, 3, 1, 3, 1, 1, 2, 1] assert [len(batch) for batch in transport.batches] == [1, 5, 1, 3, 1, 1, 2, 1]
assert len(transport.maintain_calls) == 214 assert len(transport.maintain_calls) == 212
assert set(transport.maintain_calls) == { assert set(transport.maintain_calls) == {
(1.0, ("lixel/application/response/modeling_status",)) (1.0, ("lixel/application/response/modeling_status",))
} }
@ -332,13 +347,120 @@ def test_canonical_session_owns_start_active_scan_stop_and_save_boundary() -> No
assert executor.snapshot()["stop_complete"] is True assert executor.snapshot()["stop_complete"] is True
assert executor.snapshot()["dialogue_stage"] == "standby-confirmed" assert executor.snapshot()["dialogue_stage"] == "standby-confirmed"
response_evidence = executor.snapshot()["response_evidence"] response_evidence = executor.snapshot()["response_evidence"]
assert isinstance(response_evidence, tuple) assert isinstance(response_evidence, list)
assert len(response_evidence) == 13 assert len(response_evidence) == 13
assert response_evidence[0]["operation_key"] == "bootstrap:1:DeviceInfoRequest" assert response_evidence[0]["operation_key"] == "bootstrap:1:DeviceInfoRequest"
assert response_evidence[-1]["operation_key"] == "modeling:stop" assert response_evidence[-1]["operation_key"] == "modeling:stop"
assert all("payload" not in item for item in response_evidence) assert all("payload" not in item for item in response_evidence)
assert APPLICATION_KEY not in str(executor.snapshot()) assert APPLICATION_KEY not in str(executor.snapshot())
assert VENDOR_DEVICE_ID not in str(executor.snapshot()) assert VENDOR_DEVICE_ID not in str(executor.snapshot())
TypeAdapter(JsonValue).validate_python(executor.snapshot())
def test_start_initialization_wait_has_fail_closed_watchdog() -> None:
class NeverInitializedTransport(SyntheticAcceptanceTransport):
def scan_initialization_complete(self, _binding: object) -> bool:
return False
clock = FakeClock()
transport = NeverInitializedTransport(clock)
executor = PhysicalAcceptanceDialogueExecutor(
transport,
monotonic=clock,
scan_initialization_timeout_seconds=2.0,
)
authority = ApplicationControlAuthority(openapi_key=APPLICATION_KEY)
orchestrator = ShadowApplicationBootstrapOrchestrator(
authority,
epoch_seconds=1_752_680_000,
timezone_name="Europe/Moscow",
)
binding = executor.run_connection_stage(orchestrator)
executor.run_workspace_entry_stage(
orchestrator,
executor.wait_for_operator_checkpoint("workspace-entered", lambda: True),
)
executor.run_project_prompt_stage(
orchestrator,
executor.wait_for_operator_checkpoint("project-prompt-opened", lambda: True),
)
start_checkpoint = executor.wait_for_operator_checkpoint(
"start-confirmed",
lambda: True,
)
command = ShadowModelingCommand.from_command(
encode_modeling_start(
CommandHeaderIdentity(
device_id=binding.vendor_device_id,
openapi_key=APPLICATION_KEY,
),
project_name="SAFE_PROJECT",
record_mode=RecordMode.RECORD_AND_CALCULATE,
scan_mode=ScanMode.LCC,
mount_type=MountType.HANDHELD,
)
)
permit = PhysicalAcceptancePermit(
_checklist(ModelingAction.START),
monotonic=clock,
)
with pytest.raises(ScanInitializationTimeout):
executor.execute_canonical_start(
command,
build_canonical_post_start_observation(authority, binding),
authority=authority,
binding=binding,
permit=permit,
checkpoint=start_checkpoint,
)
assert transport.start_emitted
assert not transport.stop_emitted
assert executor.snapshot()["dialogue_stage"] == "initializing"
assert executor.snapshot()["start_attempted"] is True
assert executor.snapshot()["start_complete"] is False
@pytest.mark.parametrize(
("device_model", "device_type"),
(
("LixelKity K2", "A4"),
("LixelKity K1", "K1"),
),
)
def test_connection_stage_rejects_other_device_model_or_type_before_preparation(
device_model: str,
device_type: str,
) -> None:
transport = SyntheticAcceptanceTransport(
device_model=device_model,
device_type=device_type,
)
executor = PhysicalAcceptanceDialogueExecutor(transport)
orchestrator = ShadowApplicationBootstrapOrchestrator(
ApplicationControlAuthority(openapi_key=APPLICATION_KEY),
epoch_seconds=1_752_680_000,
timezone_name="Europe/Moscow",
)
with pytest.raises(ApplicationAcceptanceError, match="incompatible with the selected"):
executor.run_connection_stage(orchestrator)
assert transport.batches == [("bootstrap:1:DeviceInfoRequest",)]
assert not transport.start_emitted
assert orchestrator.binding is None
snapshot = executor.snapshot()
assert snapshot["correlation_failure"] is None
compatibility_failure = snapshot["compatibility_failure"]
assert isinstance(compatibility_failure, dict)
assert compatibility_failure["reason_code"] == "compatibility_profile_mismatch"
assert compatibility_failure["expected"] == {
"device_model": "LixelKity K1",
"platform_type": "A4",
"firmware": "3.0.2",
"is_activated": True,
}
def test_bootstrap_correlation_failure_records_only_redacted_response_evidence() -> None: def test_bootstrap_correlation_failure_records_only_redacted_response_evidence() -> None:
@ -347,14 +469,15 @@ def test_bootstrap_correlation_failure_records_only_redacted_response_evidence()
self, self,
envelopes: Sequence[OneShotPublishEnvelope], envelopes: Sequence[OneShotPublishEnvelope],
*, *,
required_response_topics: Collection[str], required_response_operation_keys: Collection[str],
) -> dict[str, bytes]: ) -> dict[str, bytes]:
responses = super().exchange_batch_once( responses = super().exchange_batch_once(
envelopes, envelopes,
required_response_topics=required_response_topics, required_response_operation_keys=required_response_operation_keys,
) )
if DEVICE_CONFIG_RESPONSE_TOPIC in responses: operation_key = "bootstrap:4:DeviceConfigRequest"
responses[DEVICE_CONFIG_RESPONSE_TOPIC] = _generic_response( if operation_key in responses:
responses[operation_key] = _generic_response(
f"{VENDOR_DEVICE_ID}:wrong-session" f"{VENDOR_DEVICE_ID}:wrong-session"
) )
return responses return responses
@ -380,10 +503,11 @@ def test_bootstrap_correlation_failure_records_only_redacted_response_evidence()
"phase": "bootstrap", "phase": "bootstrap",
"operation_key": "bootstrap:4:DeviceConfigRequest", "operation_key": "bootstrap:4:DeviceConfigRequest",
"response_topic": DEVICE_CONFIG_RESPONSE_TOPIC, "response_topic": DEVICE_CONFIG_RESPONSE_TOPIC,
"reason_code": "response_session_mismatch",
"reason": "application response session correlation failed", "reason": "application response session correlation failed",
} }
evidence = snapshot["response_evidence"] evidence = snapshot["response_evidence"]
assert isinstance(evidence, tuple) assert isinstance(evidence, list)
assert evidence[-1]["payload_sha256"] == hashlib.sha256( assert evidence[-1]["payload_sha256"] == hashlib.sha256(
_generic_response(f"{VENDOR_DEVICE_ID}:wrong-session") _generic_response(f"{VENDOR_DEVICE_ID}:wrong-session")
).hexdigest() ).hexdigest()
@ -391,6 +515,7 @@ def test_bootstrap_correlation_failure_records_only_redacted_response_evidence()
assert all("payload" not in item for item in evidence) assert all("payload" not in item for item in evidence)
assert APPLICATION_KEY not in str(snapshot) assert APPLICATION_KEY not in str(snapshot)
assert VENDOR_DEVICE_ID not in str(snapshot) assert VENDOR_DEVICE_ID not in str(snapshot)
TypeAdapter(JsonValue).validate_python(snapshot)
def test_standalone_start_and_stop_are_both_disabled() -> None: def test_standalone_start_and_stop_are_both_disabled() -> None:

View File

@ -16,6 +16,23 @@ from k1link.device_plugins.xgrids_k1.protocol.application_authority import (
PRIVATE_AUTHORITY = b"11111111-2222-3333-4444-555555555555\n" PRIVATE_AUTHORITY = b"11111111-2222-3333-4444-555555555555\n"
@patch("platform.system", return_value="Darwin")
def test_runtime_authority_loads_through_security_framework_without_subprocess(
_system: object,
) -> None:
calls: list[tuple[str, str]] = []
def framework_reader(*, service: str, account: str) -> bytes:
calls.append((service, account))
return PRIVATE_AUTHORITY
loader = MacOSKeychainApplicationAuthorityLoader(framework_reader=framework_reader)
authority = loader.load()
assert calls == [(KEYCHAIN_SERVICE, KEYCHAIN_ACCOUNT)]
assert PRIVATE_AUTHORITY.decode().strip() not in repr(authority)
@patch("platform.system", return_value="Darwin") @patch("platform.system", return_value="Darwin")
@patch("shutil.which", return_value="/usr/bin/security") @patch("shutil.which", return_value="/usr/bin/security")
def test_authority_loads_only_from_fixed_macos_keychain_item( def test_authority_loads_only_from_fixed_macos_keychain_item(
@ -111,6 +128,21 @@ def test_authority_loader_rejects_wrong_profile_length_without_reflection(
assert invalid_secret.decode() not in str(error.value) assert invalid_secret.decode() not in str(error.value)
@patch("platform.system", return_value="Darwin")
def test_framework_authority_failure_is_redacted(_system: object) -> None:
private_error = "private-framework-diagnostic"
def framework_reader(*, service: str, account: str) -> bytes:
assert service == KEYCHAIN_SERVICE
assert account == KEYCHAIN_ACCOUNT
raise RuntimeError(private_error)
with pytest.raises(ApplicationAuthorityLoadError) as error:
MacOSKeychainApplicationAuthorityLoader(framework_reader=framework_reader).load()
assert private_error not in str(error.value)
@patch("sys.stdout.isatty", return_value=True) @patch("sys.stdout.isatty", return_value=True)
@patch("sys.stdin.isatty", return_value=True) @patch("sys.stdin.isatty", return_value=True)
@patch("platform.system", return_value="Darwin") @patch("platform.system", return_value="Darwin")

View File

@ -1,5 +1,8 @@
from __future__ import annotations from __future__ import annotations
import json
from pathlib import Path
import pytest import pytest
from k1link.device_plugins.xgrids_k1.protocol.application_bootstrap import ( from k1link.device_plugins.xgrids_k1.protocol.application_bootstrap import (
@ -16,6 +19,14 @@ from k1link.device_plugins.xgrids_k1.protocol.protobuf_wire import iter_fields
APPLICATION_KEY = "11111111-2222-3333-4444-555555555555" APPLICATION_KEY = "11111111-2222-3333-4444-555555555555"
VENDOR_DEVICE_ID = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee" VENDOR_DEVICE_ID = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"
CAPTURE_FACTS = json.loads(
(
Path(__file__).parent
/ "fixtures"
/ "xgrids_k1"
/ "device_info_fw3_0_2_semantics.json"
).read_text(encoding="utf-8")
)
def _varint(value: int) -> bytes: def _varint(value: int) -> bytes:
@ -47,11 +58,11 @@ def _binding(**changes: object) -> LiveDeviceControlBinding:
values = { values = {
"vendor_device_id": VENDOR_DEVICE_ID, "vendor_device_id": VENDOR_DEVICE_ID,
"device_serial": "K1SERIAL01", "device_serial": "K1SERIAL01",
"software_version": "V3.0.2-20260101-release", "software_version": CAPTURE_FACTS["software_version"],
"system_version": "V3.0.2", "system_version": CAPTURE_FACTS["system_version"],
"device_model": "LixelKity K1", "device_model": CAPTURE_FACTS["device_model"],
"device_type": "K1", "device_type": CAPTURE_FACTS["platform_type"],
"is_activated": True, "is_activated": CAPTURE_FACTS["is_activated"],
} }
values.update(changes) values.update(changes)
return LiveDeviceControlBinding(**values) # type: ignore[arg-type] return LiveDeviceControlBinding(**values) # type: ignore[arg-type]
@ -62,13 +73,13 @@ def _device_info_response(*, session_id: str = ":DeviceInfoRequest") -> bytes:
base_info = b"".join( base_info = b"".join(
( (
_text(1, "2026-01-01T00:00:00"), _text(1, "2026-01-01T00:00:00"),
_text(2, "V3.0.2-20260101-release"), _text(2, CAPTURE_FACTS["software_version"]),
_text(3, "V3.0.2"), _text(3, CAPTURE_FACTS["system_version"]),
_text(4, "V1.2.3"), _text(4, "V1.2.3"),
_text(5, "V1.0"), _text(5, "V1.0"),
_text(6, "LixelKity K1"), _text(6, CAPTURE_FACTS["device_model"]),
_text(7, "K1SERIAL01"), _text(7, "K1SERIAL01"),
_text(8, "K1"), _text(8, CAPTURE_FACTS["platform_type"]),
) )
) )
working_status = _uint(1, 1) + _uint(2, 0) working_status = _uint(1, 1) + _uint(2, 0)
@ -149,6 +160,27 @@ def test_bootstrap_headers_switch_from_unbound_to_live_device_identity() -> None
) )
def test_device_config_timestamp_uses_captured_sint64_zigzag_wire_semantics() -> None:
captured_epoch_seconds = 1_784_215_030
plan = build_shadow_bootstrap(
_authority(),
_binding(),
epoch_seconds=captured_epoch_seconds,
timezone_name="Europe/Moscow",
)
config_request = plan.requests[3]
top_fields = {field.number: field.value for field in iter_fields(config_request.payload)}
time_config = top_fields[4]
assert isinstance(time_config, bytes)
time_fields = {field.number: field.value for field in iter_fields(time_config)}
assert time_fields == {
1: 3_568_430_060,
2: b"Europe/Moscow",
}
def test_canonical_post_start_observation_preserves_retained_operations_12_to_14() -> None: def test_canonical_post_start_observation_preserves_retained_operations_12_to_14() -> None:
plan = build_canonical_post_start_observation(_authority(), _binding()) plan = build_canonical_post_start_observation(_authority(), _binding())
@ -169,6 +201,7 @@ def test_device_info_response_produces_live_binding_without_a_saved_device_profi
assert response.result_code == OPENAPI_SUCCESS assert response.result_code == OPENAPI_SUCCESS
assert response.binding.ready_for_reviewed_profile assert response.binding.ready_for_reviewed_profile
assert response.binding.matches_reviewed_device
assert response.binding.matches_reviewed_firmware assert response.binding.matches_reviewed_firmware
assert response.binding.is_activated assert response.binding.is_activated
assert VENDOR_DEVICE_ID not in repr(response) assert VENDOR_DEVICE_ID not in repr(response)
@ -181,7 +214,6 @@ def test_device_info_response_correlation_and_profile_attestation_fail_closed()
_device_info_response(session_id=":AnotherRequest"), _device_info_response(session_id=":AnotherRequest"),
_authority(), _authority(),
) )
with pytest.raises(ApplicationBootstrapError, match="does not attest"): with pytest.raises(ApplicationBootstrapError, match="does not attest"):
build_shadow_bootstrap( build_shadow_bootstrap(
_authority(), _authority(),
@ -191,6 +223,37 @@ def test_device_info_response_correlation_and_profile_attestation_fail_closed()
) )
@pytest.mark.parametrize(
("device_model", "device_type"),
(
("lixelkity K1", "A4"),
("LixelKity K1 ", "A4"),
("LixelKity K11", "A4"),
("LixelKity K1", "a4"),
("LixelKity K1", " A4"),
("LixelKity K1", "K1"),
),
)
def test_reviewed_profile_rejects_non_exact_device_model_and_type(
device_model: str,
device_type: str,
) -> None:
binding = _binding(device_model=device_model, device_type=device_type)
assert binding.matches_reviewed_firmware
assert not binding.matches_reviewed_device
assert not binding.ready_for_reviewed_profile
with pytest.raises(ApplicationBootstrapError, match="does not attest"):
build_shadow_bootstrap(
_authority(),
binding,
epoch_seconds=1_752_680_000,
timezone_name="Europe/Moscow",
)
with pytest.raises(ApplicationBootstrapError, match="does not attest"):
build_canonical_post_start_observation(_authority(), binding)
def test_response_barrier_orchestrator_emits_each_retained_batch_once() -> None: def test_response_barrier_orchestrator_emits_each_retained_batch_once() -> None:
orchestrator = ShadowApplicationBootstrapOrchestrator( orchestrator = ShadowApplicationBootstrapOrchestrator(
_authority(), _authority(),
@ -202,35 +265,37 @@ def test_response_barrier_orchestrator_emits_each_retained_batch_once() -> None:
assert [request.ordinal for request in identity] == [1] assert [request.ordinal for request in identity] == [1]
with pytest.raises(ApplicationBootstrapError, match="already issued"): with pytest.raises(ApplicationBootstrapError, match="already issued"):
orchestrator.next_batch() orchestrator.next_batch()
orchestrator.accept_response(identity[0].response_topic, _device_info_response())
initial_reads = orchestrator.next_batch()
assert [request.ordinal for request in initial_reads] == [2, 3]
assert [request.response_required for request in initial_reads] == [False, True]
orchestrator.accept_response( orchestrator.accept_response(
initial_reads[1].response_topic, "bootstrap:1:DeviceInfoRequest",
_generic_response(initial_reads[1].session_id), _device_info_response(),
) )
preparation = orchestrator.next_batch() initial_reads = orchestrator.next_batch()
assert [request.ordinal for request in preparation] == [4, 5, 6] assert [request.ordinal for request in initial_reads] == [2, 3, 4, 5, 6]
device_info = preparation[1] assert [request.response_required for request in initial_reads] == [
False,
True,
True,
True,
True,
]
device_info = initial_reads[3]
orchestrator.accept_response( orchestrator.accept_response(
device_info.response_topic, "bootstrap:5:DeviceInfoRequest",
_device_info_response(session_id=device_info.session_id), _device_info_response(session_id=device_info.session_id),
) )
with pytest.raises(ApplicationBootstrapError, match="already issued"): with pytest.raises(ApplicationBootstrapError, match="already issued"):
orchestrator.next_batch() orchestrator.next_batch()
for request in (preparation[2], preparation[0]): for request in (initial_reads[4], initial_reads[1], initial_reads[2]):
orchestrator.accept_response( orchestrator.accept_response(
request.response_topic, f"bootstrap:{request.ordinal}:{request.message_type}",
_generic_response(request.session_id), _generic_response(request.session_id),
) )
ntrip = orchestrator.next_batch() ntrip = orchestrator.next_batch()
assert [request.ordinal for request in ntrip] == [7] assert [request.ordinal for request in ntrip] == [7]
orchestrator.accept_response( orchestrator.accept_response(
ntrip[0].response_topic, "bootstrap:7:GetNtripProfileRequest",
_generic_response(ntrip[0].session_id), _generic_response(ntrip[0].session_id),
) )
@ -242,7 +307,10 @@ def test_response_barrier_orchestrator_emits_each_retained_batch_once() -> None:
if request.message_type == "DeviceInfoRequest" if request.message_type == "DeviceInfoRequest"
else _generic_response(request.session_id) else _generic_response(request.session_id)
) )
orchestrator.accept_response(request.response_topic, payload) orchestrator.accept_response(
f"bootstrap:{request.ordinal}:{request.message_type}",
payload,
)
snapshot = orchestrator.snapshot() snapshot = orchestrator.snapshot()
assert snapshot.bootstrap_complete assert snapshot.bootstrap_complete
@ -264,7 +332,7 @@ def test_orchestrator_rejects_unexpected_response_without_advancing() -> None:
with pytest.raises(ApplicationBootstrapError, match="not required"): with pytest.raises(ApplicationBootstrapError, match="not required"):
orchestrator.accept_response( orchestrator.accept_response(
"lixel/application/response/get_rtk_advance", "bootstrap:3:GetRtkAdvanceRequest",
_generic_response(":GetRtkAdvanceRequest"), _generic_response(":GetRtkAdvanceRequest"),
) )

View File

@ -10,11 +10,14 @@ import pytest
from k1link.device_plugins.xgrids_k1.protocol.application_bootstrap import ( from k1link.device_plugins.xgrids_k1.protocol.application_bootstrap import (
DEVICE_INFO_RESPONSE_TOPIC, DEVICE_INFO_RESPONSE_TOPIC,
GET_RTK_ADVANCE_REQUEST_TOPIC,
GET_RTK_ADVANCE_RESPONSE_TOPIC,
MODELING_STATUS_RESPONSE_TOPIC, MODELING_STATUS_RESPONSE_TOPIC,
LiveDeviceControlBinding, LiveDeviceControlBinding,
) )
from k1link.device_plugins.xgrids_k1.protocol.application_mqtt import ( from k1link.device_plugins.xgrids_k1.protocol.application_mqtt import (
CONTROL_SUBSCRIPTION_GROUPS, CONTROL_SUBSCRIPTION_GROUPS,
MODELING_RESPONSE_TOPIC,
SYSTEM_ERROR_TOPIC, SYSTEM_ERROR_TOPIC,
ApplicationCommandOutcomeUnknown, ApplicationCommandOutcomeUnknown,
ApplicationControlDeviceFault, ApplicationControlDeviceFault,
@ -26,12 +29,17 @@ from k1link.device_plugins.xgrids_k1.protocol.application_publish import (
from k1link.device_plugins.xgrids_k1.protocol.modeling_control import ( from k1link.device_plugins.xgrids_k1.protocol.modeling_control import (
MODELING_STATE_BASE, MODELING_STATE_BASE,
SYSTEM_ERROR_STATE_BASE, SYSTEM_ERROR_STATE_BASE,
ModelingAction,
SessionState, SessionState,
) )
from k1link.device_plugins.xgrids_k1.protocol.modeling_safety import DEVICE_STATUS_TOPIC from k1link.device_plugins.xgrids_k1.protocol.modeling_safety import (
DEVICE_STATUS_TOPIC,
MODELING_REQUEST_TOPIC,
)
VENDOR_DEVICE_ID = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee" VENDOR_DEVICE_ID = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"
DEVICE_SERIAL = "K1SERIAL01" DEVICE_SERIAL = "K1SERIAL01"
APPLICATION_KEY = "11111111-2222-3333-4444-555555555555"
class FakeReasonCode: class FakeReasonCode:
@ -70,6 +78,7 @@ class FakeControlClient:
self.unsubscribe_calls: list[list[str]] = [] self.unsubscribe_calls: list[list[str]] = []
self.disconnect_calls = 0 self.disconnect_calls = 0
self.next_mid = 20 self.next_mid = 20
self.connect_timeout = 5.0
def connect(self, host: str, port: int, keepalive: int) -> mqtt.MQTTErrorCode: def connect(self, host: str, port: int, keepalive: int) -> mqtt.MQTTErrorCode:
self.connect_calls.append((host, port, keepalive)) self.connect_calls.append((host, port, keepalive))
@ -94,7 +103,7 @@ class FakeControlClient:
self.next_mid += 1 self.next_mid += 1
if self.emit_exchange: if self.emit_exchange:
self.events.append(("publish", mid)) self.events.append(("publish", mid))
self.events.append(("message", (self.response_topic, b"response"))) self.events.append(("message", (self.response_topic, payload)))
return SimpleNamespace(rc=mqtt.MQTT_ERR_SUCCESS, mid=mid) return SimpleNamespace(rc=mqtt.MQTT_ERR_SUCCESS, mid=mid)
def loop(self, timeout: float) -> mqtt.MQTTErrorCode: def loop(self, timeout: float) -> mqtt.MQTTErrorCode:
@ -131,11 +140,32 @@ class FakeControlClient:
return mqtt.MQTT_ERR_SUCCESS return mqtt.MQTT_ERR_SUCCESS
def _envelope(operation_key: str = "bootstrap:1:DeviceInfoRequest") -> OneShotPublishEnvelope: def _application_message(
payload = b"synthetic-reviewed-request" session_id: str,
*,
device_id: str | None = None,
) -> bytes:
header = b"".join(
(
_text(4, device_id) if device_id is not None else b"",
_text(5, session_id),
_text(6, APPLICATION_KEY),
)
)
return _bytes(1, header)
def _envelope(
operation_key: str = "bootstrap:1:DeviceInfoRequest",
*,
topic: str = "lixel/application/request/device_info",
session_id: str = ":DeviceInfoRequest",
device_id: str | None = None,
) -> OneShotPublishEnvelope:
payload = _application_message(session_id, device_id=device_id)
return OneShotPublishEnvelope( return OneShotPublishEnvelope(
operation_key=operation_key, operation_key=operation_key,
topic="lixel/application/request/device_info", topic=topic,
payload=payload, payload=payload,
payload_sha256=hashlib.sha256(payload).hexdigest(), payload_sha256=hashlib.sha256(payload).hexdigest(),
payload_bytes=len(payload), payload_bytes=len(payload),
@ -144,10 +174,15 @@ def _envelope(operation_key: str = "bootstrap:1:DeviceInfoRequest") -> OneShotPu
) )
def _modeling_status_envelope() -> OneShotPublishEnvelope: def _modeling_status_envelope(
payload = b"synthetic-reviewed-modeling-status" operation_key: str = "dialogue:12:ModelingStatusRequest",
) -> OneShotPublishEnvelope:
payload = _application_message(
f"{VENDOR_DEVICE_ID}:ModelingStatusRequest",
device_id=VENDOR_DEVICE_ID,
)
return OneShotPublishEnvelope( return OneShotPublishEnvelope(
operation_key="dialogue:12:ModelingStatusRequest", operation_key=operation_key,
topic="lixel/application/request/modeling_status", topic="lixel/application/request/modeling_status",
payload=payload, payload=payload,
payload_sha256=hashlib.sha256(payload).hexdigest(), payload_sha256=hashlib.sha256(payload).hexdigest(),
@ -157,6 +192,33 @@ def _modeling_status_envelope() -> OneShotPublishEnvelope:
) )
def _modeling_envelope(action: ModelingAction) -> OneShotPublishEnvelope:
payload = _application_message(
f"{VENDOR_DEVICE_ID}:ModelingRequest",
device_id=VENDOR_DEVICE_ID,
) + _uint(2, action)
return OneShotPublishEnvelope(
operation_key=f"modeling:{action.name.casefold()}",
topic=MODELING_REQUEST_TOPIC,
payload=payload,
payload_sha256=hashlib.sha256(payload).hexdigest(),
payload_bytes=len(payload),
qos=2,
retain=False,
)
def _modeling_response(action: ModelingAction) -> bytes:
return (
_application_message(
f"{VENDOR_DEVICE_ID}:ModelingRequest",
device_id=VENDOR_DEVICE_ID,
)
+ _uint(2, action)
+ _bytes(15, _uint(1, 0))
)
def _varint(value: int) -> bytes: def _varint(value: int) -> bytes:
encoded = bytearray() encoded = bytearray()
while value > 0x7F: while value > 0x7F:
@ -203,7 +265,7 @@ def _binding() -> LiveDeviceControlBinding:
software_version="V3.0.2-20260101-release", software_version="V3.0.2-20260101-release",
system_version="V3.0.2", system_version="V3.0.2",
device_model="LixelKity K1", device_model="LixelKity K1",
device_type="K1", device_type="A4",
is_activated=True, is_activated=True,
) )
@ -234,21 +296,24 @@ def test_acceptance_transport_connects_once_and_completes_one_qos2_exchange() ->
opened = transport.open().as_dict() opened = transport.open().as_dict()
responses = transport.exchange_batch_once( responses = transport.exchange_batch_once(
[_envelope()], [_envelope()],
required_response_topics={DEVICE_INFO_RESPONSE_TOPIC}, required_response_operation_keys={"bootstrap:1:DeviceInfoRequest"},
) )
transport.close() transport.close()
assert fake.connect_calls == [("192.168.1.20", 1883, 60)] assert fake.connect_calls == [("192.168.1.20", 1883, 60)]
assert fake.connect_timeout == 10.0
assert fake.subscribe_calls == [list(group) for group in CONTROL_SUBSCRIPTION_GROUPS] assert fake.subscribe_calls == [list(group) for group in CONTROL_SUBSCRIPTION_GROUPS]
assert fake.publish_calls == [ assert fake.publish_calls == [
( (
"lixel/application/request/device_info", "lixel/application/request/device_info",
b"synthetic-reviewed-request", _application_message(":DeviceInfoRequest"),
2, 2,
False, False,
) )
] ]
assert responses == {DEVICE_INFO_RESPONSE_TOPIC: b"response"} assert responses == {
"bootstrap:1:DeviceInfoRequest": _application_message(":DeviceInfoRequest")
}
assert opened["clean_session"] is False assert opened["clean_session"] is False
assert opened["automatic_reconnect"] is False assert opened["automatic_reconnect"] is False
assert opened["automatic_retry"] is False assert opened["automatic_retry"] is False
@ -270,18 +335,325 @@ def test_consumed_operation_key_can_never_be_published_again() -> None:
transport.open() transport.open()
transport.exchange_batch_once( transport.exchange_batch_once(
[_envelope()], [_envelope()],
required_response_topics={DEVICE_INFO_RESPONSE_TOPIC}, required_response_operation_keys={"bootstrap:1:DeviceInfoRequest"},
) )
with pytest.raises(ApplicationCommandOutcomeUnknown, match="already consumed"): with pytest.raises(ApplicationCommandOutcomeUnknown, match="already consumed"):
transport.exchange_batch_once( transport.exchange_batch_once(
[_envelope()], [_envelope()],
required_response_topics={DEVICE_INFO_RESPONSE_TOPIC}, required_response_operation_keys={"bootstrap:1:DeviceInfoRequest"},
) )
assert len(fake.publish_calls) == 1 assert len(fake.publish_calls) == 1
def test_same_topic_inflight_responses_are_routed_by_exact_session_without_retry() -> None:
fake = FakeControlClient(response_topic=GET_RTK_ADVANCE_RESPONSE_TOPIC)
transport = ReviewedApplicationMqttTransport(
"192.168.1.20",
client_factory=lambda: cast(mqtt.Client, fake),
)
transport.open()
unbound = _envelope(
"bootstrap:3:GetRtkAdvanceRequest",
topic=GET_RTK_ADVANCE_REQUEST_TOPIC,
session_id=":GetRtkAdvanceRequest",
)
bound_session = f"{VENDOR_DEVICE_ID}:GetRtkAdvanceRequest"
bound = _envelope(
"bootstrap:6:GetRtkAdvanceRequest",
topic=GET_RTK_ADVANCE_REQUEST_TOPIC,
session_id=bound_session,
device_id=VENDOR_DEVICE_ID,
)
responses = transport.exchange_batch_once(
[unbound, bound],
required_response_operation_keys={unbound.operation_key, bound.operation_key},
)
assert responses[unbound.operation_key] == _application_message(
":GetRtkAdvanceRequest"
)
assert responses[bound.operation_key] == _application_message(
bound_session,
device_id=VENDOR_DEVICE_ID,
)
snapshot = transport.snapshot()
assert snapshot.publish_attempts == 2
assert snapshot.qos2_completions == 2
assert snapshot.correlated_responses == 2
assert snapshot.late_known_responses == 0
assert snapshot.automatic_retry is False
assert len(fake.publish_calls) == 2
def test_duplicate_start_response_blocks_stop_before_stop_is_published() -> None:
class ModelingControlClient(FakeControlClient):
def __init__(self) -> None:
super().__init__(
emit_exchange=False,
response_topic=MODELING_RESPONSE_TOPIC,
)
def publish(
self,
topic: str,
payload: bytes,
qos: int,
retain: bool,
) -> SimpleNamespace:
info = super().publish(topic, payload, qos, retain)
action = (
ModelingAction.START
if len(self.publish_calls) == 1
else ModelingAction.STOP
)
self.events.append(("publish", info.mid))
self.events.append(
(
"message",
(MODELING_RESPONSE_TOPIC, _modeling_response(action)),
)
)
return info
fake = ModelingControlClient()
transport = ReviewedApplicationMqttTransport(
"192.168.1.20",
client_factory=lambda: cast(mqtt.Client, fake),
)
transport.open()
transport.exchange_batch_once(
[_modeling_envelope(ModelingAction.START)],
required_response_operation_keys={"modeling:start"},
)
fake.events.append(
(
"message",
(
MODELING_RESPONSE_TOPIC,
_modeling_response(ModelingAction.START),
),
)
)
with pytest.raises(ApplicationCommandOutcomeUnknown, match="duplicate application response"):
transport.exchange_batch_once(
[_modeling_envelope(ModelingAction.STOP)],
required_response_operation_keys={"modeling:stop"},
)
snapshot = transport.snapshot()
assert snapshot.publish_attempts == 1
assert snapshot.correlated_responses == 1
assert snapshot.late_known_responses == 0
assert snapshot.state == "poisoned"
assert len(fake.publish_calls) == 1
def test_unanswered_optional_status_does_not_steal_same_identity_required_refresh() -> None:
class ReusedStatusClient(FakeControlClient):
def __init__(self) -> None:
super().__init__(
emit_exchange=False,
response_topic=MODELING_STATUS_RESPONSE_TOPIC,
)
def publish(
self,
topic: str,
payload: bytes,
qos: int,
retain: bool,
) -> SimpleNamespace:
info = super().publish(topic, payload, qos, retain)
self.events.append(("publish", info.mid))
if len(self.publish_calls) == 2:
# Ordinals 12 and 14 intentionally reuse the same protocol
# identity. The capture has only one response after ordinal
# 14; unanswered optional ordinal 12 must not swallow it.
self.events.append(
("message", (MODELING_STATUS_RESPONSE_TOPIC, payload))
)
return info
fake = ReusedStatusClient()
transport = ReviewedApplicationMqttTransport(
"192.168.1.20",
client_factory=lambda: cast(mqtt.Client, fake),
)
transport.open()
immediate = _modeling_status_envelope()
refresh = _modeling_status_envelope("dialogue:14:ModelingStatusRequest")
transport.exchange_batch_once(
[immediate],
required_response_operation_keys=(),
)
responses = transport.exchange_batch_once(
[refresh],
required_response_operation_keys={refresh.operation_key},
)
assert responses[refresh.operation_key] == refresh.payload
snapshot = transport.snapshot()
assert snapshot.publish_attempts == 2
assert snapshot.correlated_responses == 1
assert snapshot.ignored_known_responses == 0
assert snapshot.late_known_responses == 0
def test_already_arrived_optional_status_is_consumed_before_required_refresh() -> None:
class ReusedStatusClient(FakeControlClient):
def __init__(self) -> None:
super().__init__(
emit_exchange=False,
response_topic=MODELING_STATUS_RESPONSE_TOPIC,
)
def publish(
self,
topic: str,
payload: bytes,
qos: int,
retain: bool,
) -> SimpleNamespace:
info = super().publish(topic, payload, qos, retain)
self.events.append(("publish", info.mid))
if len(self.publish_calls) == 2:
self.events.append(
("message", (MODELING_STATUS_RESPONSE_TOPIC, payload))
)
return info
fake = ReusedStatusClient()
transport = ReviewedApplicationMqttTransport(
"192.168.1.20",
client_factory=lambda: cast(mqtt.Client, fake),
)
transport.open()
immediate = _modeling_status_envelope()
refresh = _modeling_status_envelope("dialogue:14:ModelingStatusRequest")
transport.exchange_batch_once(
[immediate],
required_response_operation_keys=(),
)
# This callback is already available before ordinal 14 is admitted. The
# retained socket's zero-wait service turn must consume it as ordinal 12.
fake.events.append(
("message", (MODELING_STATUS_RESPONSE_TOPIC, immediate.payload))
)
responses = transport.exchange_batch_once(
[refresh],
required_response_operation_keys={refresh.operation_key},
)
assert responses[refresh.operation_key] == refresh.payload
snapshot = transport.snapshot()
assert snapshot.publish_attempts == 2
assert snapshot.correlated_responses == 1
assert snapshot.ignored_known_responses == 1
assert snapshot.late_known_responses == 1
def test_unbound_optional_status_remains_distinct_from_bound_required_refresh() -> None:
class DistinctStatusClient(FakeControlClient):
def __init__(self) -> None:
super().__init__(
emit_exchange=False,
response_topic=MODELING_STATUS_RESPONSE_TOPIC,
)
def publish(
self,
topic: str,
payload: bytes,
qos: int,
retain: bool,
) -> SimpleNamespace:
info = super().publish(topic, payload, qos, retain)
self.events.append(("publish", info.mid))
if len(self.publish_calls) == 2:
self.events.append(
(
"message",
(
MODELING_STATUS_RESPONSE_TOPIC,
_application_message(":ModelingStatusRequest"),
),
)
)
self.events.append(
("message", (MODELING_STATUS_RESPONSE_TOPIC, payload))
)
return info
fake = DistinctStatusClient()
transport = ReviewedApplicationMqttTransport(
"192.168.1.20",
client_factory=lambda: cast(mqtt.Client, fake),
)
transport.open()
initial = _envelope(
"bootstrap:2:ModelingStatusRequest",
topic="lixel/application/request/modeling_status",
session_id=":ModelingStatusRequest",
)
refresh = _modeling_status_envelope("dialogue:14:ModelingStatusRequest")
transport.exchange_batch_once(
[initial],
required_response_operation_keys=(),
)
responses = transport.exchange_batch_once(
[refresh],
required_response_operation_keys={refresh.operation_key},
)
assert responses[refresh.operation_key] == refresh.payload
snapshot = transport.snapshot()
assert snapshot.publish_attempts == 2
assert snapshot.correlated_responses == 1
assert snapshot.ignored_known_responses == 1
assert snapshot.late_known_responses == 1
def test_duplicate_current_application_response_fails_closed() -> None:
class DuplicateResponseClient(FakeControlClient):
def publish(
self,
topic: str,
payload: bytes,
qos: int,
retain: bool,
) -> SimpleNamespace:
info = super().publish(topic, payload, qos, retain)
self.events.append(("message", (self.response_topic, payload)))
return info
fake = DuplicateResponseClient()
transport = ReviewedApplicationMqttTransport(
"192.168.1.20",
client_factory=lambda: cast(mqtt.Client, fake),
)
transport.open()
with pytest.raises(
ApplicationCommandOutcomeUnknown,
match="duplicate application response",
) as raised:
transport.exchange_batch_once(
[_envelope()],
required_response_operation_keys={"bootstrap:1:DeviceInfoRequest"},
)
assert raised.value.reason_code == "duplicate_application_response"
assert transport.snapshot().state == "poisoned"
assert len(fake.publish_calls) == 1
def test_post_start_status_can_be_response_free_and_socket_is_continuously_serviced() -> None: def test_post_start_status_can_be_response_free_and_socket_is_continuously_serviced() -> None:
clock = FakeClock() clock = FakeClock()
fake = FakeControlClient( fake = FakeControlClient(
@ -297,7 +669,7 @@ def test_post_start_status_can_be_response_free_and_socket_is_continuously_servi
responses = transport.exchange_batch_once( responses = transport.exchange_batch_once(
[_modeling_status_envelope()], [_modeling_status_envelope()],
required_response_topics=(), required_response_operation_keys=(),
) )
transport.maintain_open_for( transport.maintain_open_for(
2.0, 2.0,
@ -386,7 +758,7 @@ def test_post_publish_timeout_poisoned_transport_never_retries() -> None:
with pytest.raises(ApplicationCommandOutcomeUnknown, match="automatic retry is forbidden"): with pytest.raises(ApplicationCommandOutcomeUnknown, match="automatic retry is forbidden"):
transport.exchange_batch_once( transport.exchange_batch_once(
[_envelope()], [_envelope()],
required_response_topics={DEVICE_INFO_RESPONSE_TOPIC}, required_response_operation_keys={"bootstrap:1:DeviceInfoRequest"},
) )
assert transport.snapshot().state == "poisoned" assert transport.snapshot().state == "poisoned"
@ -394,6 +766,6 @@ def test_post_publish_timeout_poisoned_transport_never_retries() -> None:
with pytest.raises(ApplicationCommandOutcomeUnknown, match="already consumed"): with pytest.raises(ApplicationCommandOutcomeUnknown, match="already consumed"):
transport.exchange_batch_once( transport.exchange_batch_once(
[_envelope()], [_envelope()],
required_response_topics={DEVICE_INFO_RESPONSE_TOPIC}, required_response_operation_keys={"bootstrap:1:DeviceInfoRequest"},
) )
assert len(fake.publish_calls) == 1 assert len(fake.publish_calls) == 1

View File

@ -40,7 +40,7 @@ def _envelope() -> OneShotPublishEnvelope:
software_version="V3.0.2-build.1", software_version="V3.0.2-build.1",
system_version="V3.0.2", system_version="V3.0.2",
device_model="LixelKity K1", device_model="LixelKity K1",
device_type="K1", device_type="A4",
is_activated=True, is_activated=True,
) )
request = build_shadow_bootstrap( request = build_shadow_bootstrap(

View File

@ -1,5 +1,6 @@
from __future__ import annotations from __future__ import annotations
import logging
import threading import threading
import time import time
from dataclasses import dataclass from dataclasses import dataclass
@ -37,6 +38,11 @@ class FakeTransportSnapshot:
def as_dict(self) -> dict[str, object]: def as_dict(self) -> dict[str, object]:
return { return {
"state": self.state, "state": self.state,
"publish_attempts": 0,
"qos2_completions": 0,
"correlated_responses": 0,
"ignored_known_responses": 0,
"late_known_responses": 0,
"latest_device_session_state": "ready" if self.ready else "scanning", "latest_device_session_state": "ready" if self.ready else "scanning",
"latest_device_project_bound": not self.ready, "latest_device_project_bound": not self.ready,
"automatic_retry": False, "automatic_retry": False,
@ -71,8 +77,8 @@ class FakeExecutor:
device_serial="serial-id", device_serial="serial-id",
software_version="3.0.2", software_version="3.0.2",
system_version="3.0.2", system_version="3.0.2",
device_model="K1", device_model="LixelKity K1",
device_type="scanner", device_type="A4",
is_activated=True, is_activated=True,
) )
@ -124,13 +130,17 @@ class FakeExecutor:
self.transport.ready = True self.transport.ready = True
return object() return object()
def maintain_post_stop_until_standby_confirmed(self, observed: Any) -> None: def maintain_post_stop_until_standby(self) -> None:
self.records.append("wait:steady-green") self.records.append("wait:device-standby")
while not observed():
threading.Event().wait(0.005)
def snapshot(self) -> dict[str, object]: def snapshot(self) -> dict[str, object]:
return {"records": tuple(self.records), "automatic_retry": False} return {
"records": list(self.records),
"dialogue_stage": "test-stage",
"start_attempted": False,
"stop_attempted": False,
"automatic_retry": False,
}
def _confirmation() -> OperatorPresenceConfirmation: def _confirmation() -> OperatorPresenceConfirmation:
@ -156,7 +166,7 @@ def _wait_phase(
raise AssertionError(f"session did not reach {expected}: {session.snapshot()}") raise AssertionError(f"session did not reach {expected}: {session.snapshot()}")
def test_every_canonical_stage_requires_a_separate_operator_event( def test_canonical_stages_require_operator_events_but_device_standby_does_not(
monkeypatch: pytest.MonkeyPatch, monkeypatch: pytest.MonkeyPatch,
) -> None: ) -> None:
FakeExecutor.records = [] FakeExecutor.records = []
@ -195,12 +205,9 @@ def test_every_canonical_stage_requires_a_separate_operator_event(
assert FakeExecutor.records[-1] == "wait:stop" assert FakeExecutor.records[-1] == "wait:stop"
session.request_stop(confirmation=_confirmation()) session.request_stop(confirmation=_confirmation())
standby = _wait_phase(session, "awaiting-standby-confirmation")
assert standby["can_confirm_standby"] is True
assert FakeExecutor.records[-1] == "wait:steady-green"
session.confirm_standby()
completed = _wait_phase(session, "completed") completed = _wait_phase(session, "completed")
assert completed["can_confirm_standby"] is False
assert completed["pending_operator_action"] is None
assert loader.calls == 1 assert loader.calls == 1
assert completed["automatic_retry"] is False assert completed["automatic_retry"] is False
assert completed["scripted_transitions"] is False assert completed["scripted_transitions"] is False
@ -214,5 +221,508 @@ def test_every_canonical_stage_requires_a_separate_operator_event(
"start:11-14", "start:11-14",
"wait:stop", "wait:stop",
"stop", "stop",
"wait:steady-green", "wait:device-standby",
] ]
def test_prestart_failure_is_reported_and_requires_a_new_operator_click(
monkeypatch: pytest.MonkeyPatch,
caplog: pytest.LogCaptureFixture,
) -> None:
caplog.set_level(logging.ERROR)
class BootstrapFailureExecutor(FakeExecutor):
def run_connection_stage(
self,
_orchestrator: object,
) -> LiveDeviceControlBinding:
raise RuntimeError("bootstrap correlation failed")
def snapshot(self) -> dict[str, object]:
return {
"dialogue_stage": "connection",
"start_attempted": False,
"stop_attempted": False,
"response_evidence": [],
"automatic_retry": False,
}
monkeypatch.setattr(
session_module,
"PhysicalAcceptanceDialogueExecutor",
BootstrapFailureExecutor,
)
session = InteractiveApplicationControlSession(
FakeAuthorityLoader(),
transport_factory=lambda host: FakeTransport(host), # type: ignore[arg-type]
)
session.open(
host="192.168.1.20",
timezone_name="Europe/Moscow",
confirmation=_confirmation(),
)
_wait_phase(session, "failed")
failed_thread = session._thread # noqa: SLF001
assert failed_thread is not None
failed_thread.join(timeout=2.0)
assert not failed_thread.is_alive()
failed = session.snapshot()
assert failed["outcome_unknown"] is False
assert failed["can_open"] is True
assert failed["automatic_retry"] is False
assert failed["failure"] == {
"code": "RuntimeError",
"reason_code": "unexpected_runtime_error",
"message": "bootstrap correlation failed",
"failed_phase": "connecting",
"dialogue_stage": "connection",
"transport_state": "ready",
"publish_attempts": 0,
"qos2_completions": 0,
"correlated_responses": 0,
"ignored_known_responses": 0,
"late_known_responses": 0,
"modeling_command_attempted": False,
"diagnostic_snapshot_unavailable": [],
"diagnostic_evidence_unavailable": [],
"correlation_failure": None,
"compatibility_failure": None,
"safe_to_retry": True,
}
assert "reason_code=unexpected_runtime_error" in caplog.text
def test_correlated_read_only_profile_mismatch_allows_only_a_fresh_explicit_attempt(
monkeypatch: pytest.MonkeyPatch,
) -> None:
class ProfileMismatchError(RuntimeError):
reason_code = "compatibility_profile_mismatch"
@dataclass
class CorrelatedReadSnapshot:
state: str
def as_dict(self) -> dict[str, object]:
return {
"state": self.state,
"publish_attempts": 1,
"qos2_completions": 1,
"correlated_responses": 1,
"ignored_known_responses": 0,
"late_known_responses": 0,
"automatic_retry": False,
"automatic_reconnect": False,
}
class CorrelatedReadTransport(FakeTransport):
def snapshot(self) -> CorrelatedReadSnapshot:
return CorrelatedReadSnapshot(self.state)
class ProfileMismatchExecutor(FakeExecutor):
def run_connection_stage(
self,
_orchestrator: object,
) -> LiveDeviceControlBinding:
raise ProfileMismatchError("live DeviceInfo profile mismatch")
def snapshot(self) -> dict[str, object]:
return {
"dialogue_stage": "new",
"start_attempted": False,
"stop_attempted": False,
"response_evidence": [],
"correlation_failure": None,
"compatibility_failure": {
"operation_key": "bootstrap:1:DeviceInfoRequest",
"reason_code": "compatibility_profile_mismatch",
},
"automatic_retry": False,
}
monkeypatch.setattr(
session_module,
"PhysicalAcceptanceDialogueExecutor",
ProfileMismatchExecutor,
)
session = InteractiveApplicationControlSession(
FakeAuthorityLoader(),
transport_factory=lambda host: CorrelatedReadTransport(host), # type: ignore[arg-type]
)
session.open(
host="192.168.1.20",
timezone_name="Europe/Moscow",
confirmation=_confirmation(),
)
failed_thread = session._thread # noqa: SLF001
assert failed_thread is not None
failed_thread.join(timeout=2.0)
assert not failed_thread.is_alive()
failed = session.snapshot()
assert failed["state"] == "failed"
assert failed["outcome_unknown"] is False
assert failed["automatic_retry"] is False
assert failed["can_open"] is True
failure = failed["failure"]
assert isinstance(failure, dict)
assert failure["reason_code"] == "compatibility_profile_mismatch"
assert failure["modeling_command_attempted"] is False
assert failure["safe_to_retry"] is True
def test_new_explicit_session_waits_for_old_worker_transport_retirement(
monkeypatch: pytest.MonkeyPatch,
) -> None:
old_close_entered = threading.Event()
release_old_close = threading.Event()
transports: list[TaggedTransport] = []
@dataclass
class TaggedTransportSnapshot:
state: str
ready: bool
tag: str
def as_dict(self) -> dict[str, object]:
return {
"state": self.state,
"tag": self.tag,
"publish_attempts": 0,
"qos2_completions": 0,
"correlated_responses": 0,
"ignored_known_responses": 0,
"late_known_responses": 0,
"latest_device_session_state": "ready" if self.ready else "scanning",
"latest_device_project_bound": not self.ready,
"automatic_retry": False,
"automatic_reconnect": False,
}
class TaggedTransport(FakeTransport):
def __init__(self, host: str, tag: str) -> None:
super().__init__(host)
self.tag = tag
self.close_calls = 0
def close(self) -> None:
if self.tag == "generation-1":
old_close_entered.set()
assert release_old_close.wait(timeout=2.0)
self.close_calls += 1
super().close()
def snapshot(self) -> TaggedTransportSnapshot:
return TaggedTransportSnapshot(self.state, self.ready, self.tag)
class RacingExecutor(FakeExecutor):
instances = 0
def __init__(self, transport: TaggedTransport) -> None:
super().__init__(transport)
type(self).instances += 1
self.instance = type(self).instances
def run_connection_stage(
self,
orchestrator: object,
) -> LiveDeviceControlBinding:
if self.instance == 1:
raise RuntimeError("first generation failed before publish")
return super().run_connection_stage(orchestrator)
def transport_factory(host: str) -> TaggedTransport:
transport = TaggedTransport(host, f"generation-{len(transports) + 1}")
transports.append(transport)
return transport
FakeExecutor.records = []
monkeypatch.setattr(
session_module,
"PhysicalAcceptanceDialogueExecutor",
RacingExecutor,
)
session = InteractiveApplicationControlSession(
FakeAuthorityLoader(),
transport_factory=transport_factory, # type: ignore[arg-type]
)
session.open(
host="192.168.1.20",
timezone_name="Europe/Moscow",
confirmation=_confirmation(),
)
_wait_phase(session, "failed")
assert old_close_entered.wait(timeout=2.0)
old_thread = session._thread # noqa: SLF001
assert old_thread is not None
# The failure is safe to retry at the protocol level, but the old worker
# still owns its socket. A new explicit click must not construct or open a
# second transport until close() completes and the worker exits.
retiring = session.snapshot()
assert retiring["failure"]["safe_to_retry"] is True # type: ignore[index]
assert retiring["can_open"] is False
with pytest.raises(
session_module.ApplicationAcceptanceError,
match="already open or requires manual recovery",
):
session.open(
host="192.168.1.20",
timezone_name="Europe/Moscow",
confirmation=_confirmation(),
)
assert len(transports) == 1
assert transports[0].close_calls == 0
release_old_close.set()
old_thread.join(timeout=2.0)
assert not old_thread.is_alive()
retired = session.snapshot()
assert retired["state"] == "failed"
assert retired["can_open"] is True
assert transports[0].close_calls == 1
# Only a fresh explicit action after retirement creates generation 2.
session.open(
host="192.168.1.20",
timezone_name="Europe/Moscow",
confirmation=_confirmation(),
)
new_thread = session._thread # noqa: SLF001
assert new_thread is not None
current = _wait_phase(session, "connection-ready")
assert current["transport"]["tag"] == "generation-2" # type: ignore[index]
assert session._transport is transports[1] # noqa: SLF001
assert transports[1].close_calls == 0
# Complete the second synthetic session so no background waiter remains.
session.enter_workspace()
_wait_phase(session, "workspace-ready")
session.open_project_prompt()
_wait_phase(session, "project-ready")
session.request_start(project_name="TEST002", confirmation=_confirmation())
_wait_phase(session, "scanning")
session.request_stop(confirmation=_confirmation())
_wait_phase(session, "completed")
new_thread.join(timeout=2.0)
assert not new_thread.is_alive()
assert transports[1].close_calls == 1
def test_start_outcome_unknown_blocks_reopen_even_when_transport_is_closed(
monkeypatch: pytest.MonkeyPatch,
) -> None:
class StartFailureExecutor(FakeExecutor):
start_attempted = False
def execute_canonical_start(self, *_args: object, **_kwargs: object) -> object:
type(self).start_attempted = True
raise session_module.ApplicationCommandOutcomeUnknown(
"START response was not correlated"
)
def snapshot(self) -> dict[str, object]:
return {
"dialogue_stage": "start-attempted",
"start_attempted": type(self).start_attempted,
"stop_attempted": False,
"response_evidence": [],
"automatic_retry": False,
}
FakeExecutor.records = []
StartFailureExecutor.start_attempted = False
monkeypatch.setattr(
session_module,
"PhysicalAcceptanceDialogueExecutor",
StartFailureExecutor,
)
session = InteractiveApplicationControlSession(
FakeAuthorityLoader(),
transport_factory=lambda host: FakeTransport(host), # type: ignore[arg-type]
)
session.open(
host="192.168.1.20",
timezone_name="Europe/Moscow",
confirmation=_confirmation(),
)
_wait_phase(session, "connection-ready")
session.enter_workspace()
_wait_phase(session, "workspace-ready")
session.open_project_prompt()
_wait_phase(session, "project-ready")
session.request_start(project_name="TEST001", confirmation=_confirmation())
failed = _wait_phase(session, "failed")
assert failed["outcome_unknown"] is True
assert failed["can_open"] is False
assert failed["failure"]["modeling_command_attempted"] is True # type: ignore[index]
assert failed["failure"]["safe_to_retry"] is False # type: ignore[index]
def test_unavailable_transport_snapshot_after_publish_blocks_reopen(
monkeypatch: pytest.MonkeyPatch,
) -> None:
executor_entered = threading.Event()
release_failure = threading.Event()
class SnapshotFailureTransport(FakeTransport):
def __init__(self, host: str) -> None:
super().__init__(host)
self.simulated_publish_attempts = 0
self.snapshot_unavailable = False
def snapshot(self) -> FakeTransportSnapshot:
if self.snapshot_unavailable:
raise RuntimeError("simulated transport snapshot failure")
return super().snapshot()
class PublishedThenFailedExecutor(FakeExecutor):
def run_connection_stage(
self,
_orchestrator: object,
) -> LiveDeviceControlBinding:
executor_entered.set()
assert release_failure.wait(timeout=2.0)
assert isinstance(self.transport, SnapshotFailureTransport)
self.transport.simulated_publish_attempts = 1
self.transport.snapshot_unavailable = True
raise RuntimeError("bootstrap failed after simulated publish")
monkeypatch.setattr(
session_module,
"PhysicalAcceptanceDialogueExecutor",
PublishedThenFailedExecutor,
)
transport = SnapshotFailureTransport("192.168.1.20")
session = InteractiveApplicationControlSession(
FakeAuthorityLoader(),
transport_factory=lambda _host: transport, # type: ignore[arg-type]
)
session.open(
host="192.168.1.20",
timezone_name="Europe/Moscow",
confirmation=_confirmation(),
)
assert executor_entered.wait(timeout=2.0)
failed_thread = session._thread # noqa: SLF001
assert failed_thread is not None
release_failure.set()
failed_thread.join(timeout=2.0)
assert not failed_thread.is_alive()
failed = session.snapshot()
assert transport.simulated_publish_attempts == 1
assert failed["state"] == "failed"
assert failed["outcome_unknown"] is True
assert failed["can_open"] is False
failure = failed["failure"]
assert isinstance(failure, dict)
assert failure["transport_state"] is None
assert failure["publish_attempts"] is None
assert failure["diagnostic_snapshot_unavailable"] == ["transport"]
assert failure["diagnostic_evidence_unavailable"] == ["transport.publish_attempts"]
assert failure["modeling_command_attempted"] is False
assert failure["safe_to_retry"] is False
with pytest.raises(
session_module.ApplicationAcceptanceError,
match="already open or requires manual recovery",
):
session.open(
host="192.168.1.20",
timezone_name="Europe/Moscow",
confirmation=_confirmation(),
)
def test_unavailable_dialogue_snapshot_is_outcome_unknown(
monkeypatch: pytest.MonkeyPatch,
) -> None:
class DialogueSnapshotFailureExecutor(FakeExecutor):
def run_connection_stage(
self,
_orchestrator: object,
) -> LiveDeviceControlBinding:
raise RuntimeError("bootstrap failed with unavailable dialogue evidence")
def snapshot(self) -> dict[str, object]:
raise RuntimeError("simulated dialogue snapshot failure")
monkeypatch.setattr(
session_module,
"PhysicalAcceptanceDialogueExecutor",
DialogueSnapshotFailureExecutor,
)
session = InteractiveApplicationControlSession(
FakeAuthorityLoader(),
transport_factory=lambda host: FakeTransport(host), # type: ignore[arg-type]
)
session.open(
host="192.168.1.20",
timezone_name="Europe/Moscow",
confirmation=_confirmation(),
)
failed_thread = session._thread # noqa: SLF001
assert failed_thread is not None
failed_thread.join(timeout=2.0)
assert not failed_thread.is_alive()
failed = session.snapshot()
assert failed["state"] == "failed"
assert failed["outcome_unknown"] is True
assert failed["can_open"] is False
failure = failed["failure"]
assert isinstance(failure, dict)
assert failure["publish_attempts"] == 0
assert failure["modeling_command_attempted"] is None
assert failure["diagnostic_snapshot_unavailable"] == ["dialogue"]
assert failure["diagnostic_evidence_unavailable"] == ["dialogue.modeling_command_attempted"]
assert failure["safe_to_retry"] is False
def test_pretransport_authority_failure_remains_safe_after_worker_retirement() -> None:
class FailingAuthorityLoader(FakeAuthorityLoader):
def load(self) -> ApplicationControlAuthority:
self.calls += 1
raise RuntimeError("authority unavailable before transport creation")
transport_factory_calls = 0
def transport_factory(host: str) -> FakeTransport:
nonlocal transport_factory_calls
transport_factory_calls += 1
return FakeTransport(host)
session = InteractiveApplicationControlSession(
FailingAuthorityLoader(),
transport_factory=transport_factory, # type: ignore[arg-type]
)
session.open(
host="192.168.1.20",
timezone_name="Europe/Moscow",
confirmation=_confirmation(),
)
failed_thread = session._thread # noqa: SLF001
assert failed_thread is not None
failed_thread.join(timeout=2.0)
assert not failed_thread.is_alive()
failed = session.snapshot()
assert transport_factory_calls == 0
assert failed["state"] == "failed"
assert failed["outcome_unknown"] is False
assert failed["can_open"] is True
failure = failed["failure"]
assert isinstance(failure, dict)
assert failure["publish_attempts"] is None
assert failure["modeling_command_attempted"] is False
assert failure["diagnostic_snapshot_unavailable"] == []
assert failure["diagnostic_evidence_unavailable"] == []
assert failure["safe_to_retry"] is True

View File

@ -64,6 +64,23 @@ def _burst_ffmpeg(tmp_path: Path) -> Path:
return executable return executable
def _clean_source_end_ffmpeg(tmp_path: Path) -> Path:
executable = tmp_path / "clean-source-end-ffmpeg"
executable.write_text(
f"#!{sys.executable}\n"
"import sys, time\n"
"def box(kind, payload=b''):\n"
" return (8 + len(payload)).to_bytes(4, 'big') + kind + payload\n"
"sys.stdout.buffer.write(box(b'ftyp', b'isom') + box(b'moov'))\n"
"sys.stdout.buffer.write(box(b'moof') + box(b'mdat', b'final-frame'))\n"
"sys.stdout.buffer.flush()\n"
"time.sleep(0.15)\n",
encoding="utf-8",
)
executable.chmod(0o700)
return executable
def _buffered_tail_ffmpeg(tmp_path: Path, sentinel: Path, *, fragments: int) -> Path: def _buffered_tail_ffmpeg(tmp_path: Path, sentinel: Path, *, fragments: int) -> Path:
executable = tmp_path / "buffered-tail-ffmpeg" executable = tmp_path / "buffered-tail-ffmpeg"
executable.write_text( executable.write_text(
@ -289,6 +306,76 @@ def test_acquisition_records_without_browser_and_source_switch_seals_epochs(
gateway.close() gateway.close()
def test_expected_camera_source_end_during_device_stop_seals_complete_epoch(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setenv(
"MISSIONCORE_FFMPEG_BINARY",
str(_clean_source_end_ffmpeg(tmp_path)),
)
gateway = XgridsK1CameraGateway(tmp_path, XGRIDS_K1_PLUGIN_ID)
session = tmp_path / "session"
session.mkdir()
try:
selected = gateway.select("sensor.camera.left", "192.168.1.20")
gateway.start_recording(session)
gateway.expect_source_end_for_device_stop()
_wait_until(
lambda: gateway.snapshot()["recording"]["completed_epochs"] == 1,
)
state = gateway.snapshot()
assert state["phase"] == "idle"
assert state["error"] is None
assert state["delivery"] is None
assert state["recording"]["active"] is True
assert state["recording"]["source_end_expected"] is False
gateway.stop_recording(status="complete")
epoch = (
session
/ "media"
/ "sensor.camera.left"
/ f"epoch-{selected['generation']}"
)
summary = json.loads((epoch / "summary.json").read_text(encoding="utf-8"))
assert summary["status"] == "complete"
assert summary["failure_code"] is None
assert summary["media_segment_count"] == 1
finally:
gateway.close()
def test_unexpected_camera_source_end_remains_a_recording_failure(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setenv(
"MISSIONCORE_FFMPEG_BINARY",
str(_clean_source_end_ffmpeg(tmp_path)),
)
gateway = XgridsK1CameraGateway(tmp_path, XGRIDS_K1_PLUGIN_ID)
session = tmp_path / "session"
session.mkdir()
try:
selected = gateway.select("sensor.camera.left", "192.168.1.20")
gateway.start_recording(session)
_wait_until(lambda: gateway.snapshot()["phase"] == "error")
epoch = (
session
/ "media"
/ "sensor.camera.left"
/ f"epoch-{selected['generation']}"
)
summary = json.loads((epoch / "summary.json").read_text(encoding="utf-8"))
assert summary["status"] == "interrupted"
assert summary["failure_code"] == "camera-source-ended"
finally:
gateway.close()
def test_camera_storage_open_failure_is_loud_and_never_starts_ffmpeg( def test_camera_storage_open_failure_is_loud_and_never_starts_ffmpeg(
tmp_path: Path, tmp_path: Path,
monkeypatch: pytest.MonkeyPatch, monkeypatch: pytest.MonkeyPatch,
@ -501,7 +588,8 @@ def test_service_publishes_two_dynamic_camera_rows_and_stale_stop_is_safe(
service._compatibility_attestation = { service._compatibility_attestation = {
"firmware_version": "3.0.2", "firmware_version": "3.0.2",
"topology": "direct-lan", "topology": "direct-lan",
"basis": "operator-attested", "verification": "live-device-info",
"basis": "selected-profile-live-device-info-required",
"observed_at": "2026-07-16T20:00:00Z", "observed_at": "2026-07-16T20:00:00Z",
} }

View File

@ -48,6 +48,7 @@ def test_xgrids_compatibility_profile_loads_exact_firmware_and_sources() -> None
assert profile["profile_id"] == "xgrids.lixelkity-k1.fw-3.0.2.direct-lan.v1" assert profile["profile_id"] == "xgrids.lixelkity-k1.fw-3.0.2.direct-lan.v1"
assert profile["scope"]["vendor"] == "XGRIDS" assert profile["scope"]["vendor"] == "XGRIDS"
assert profile["scope"]["model"] == "LixelKity K1" assert profile["scope"]["model"] == "LixelKity K1"
assert profile["scope"]["platform_type"] == "A4"
assert profile["scope"]["firmware"] == {"match": "exact", "version": "3.0.2"} assert profile["scope"]["firmware"] == {"match": "exact", "version": "3.0.2"}
assert profile["scope"]["topology"] == "direct-lan" assert profile["scope"]["topology"] == "direct-lan"
assert LOADER.matches_target(profile, firmware="3.0.2", topology="direct-lan") assert LOADER.matches_target(profile, firmware="3.0.2", topology="direct-lan")
@ -146,6 +147,15 @@ def test_xgrids_compatibility_profile_maps_actions_without_enabling_writes() ->
assert profile["safety"]["default_mode"] == "read-only" assert profile["safety"]["default_mode"] == "read-only"
assert profile["safety"]["vendor_writes_enabled"] is False assert profile["safety"]["vendor_writes_enabled"] is False
assert control["mode"] == "operator-manual" assert control["mode"] == "operator-manual"
assert control["software_acceptance_transport"] == {
"status": "installed-operator-present",
"default_authority": "disabled",
"profile_gate": "live-device-info-exact-match",
"dialogue": "single-socket-canonical-start-to-stop",
"automatic_retry": False,
"supported_mount_type": "handheld",
"supported_gnss_mode": "none",
}
assert control["verified_device_control"]["gesture"] == "physical-double-click" assert control["verified_device_control"]["gesture"] == "physical-double-click"
for action_id, action_code in (("acquisition.start", 1), ("acquisition.stop", 2)): for action_id, action_code in (("acquisition.start", 1), ("acquisition.stop", 2)):

View File

@ -75,7 +75,7 @@ def _binding(**changes: object) -> LiveDeviceControlBinding:
"software_version": "V3.0.2-build.1", "software_version": "V3.0.2-build.1",
"system_version": "V3.0.2", "system_version": "V3.0.2",
"device_model": "LixelKity K1", "device_model": "LixelKity K1",
"device_type": "K1", "device_type": "A4",
"is_activated": True, "is_activated": True,
} }
values.update(changes) values.update(changes)