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 { readFile } from "node:fs/promises";
import { after, before, test } from "node:test";
import { createServer } from "vite";
@ -9,10 +10,14 @@ let createDevicePluginRegistry;
let xgridsK1Manifest;
let xgridsK1Actions;
let xgridsK1Api;
let ApiError;
let localizeRuntimeMessage;
let lifecycle;
let projectName;
let automaticSourceStart;
let presentation;
let operatorIntentGeneration;
let configuration;
before(async () => {
server = await createServer({
@ -41,9 +46,18 @@ before(async () => {
presentation = await server.ssrLoadModule(
"@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",
));
({ localizeRuntimeMessage } = await server.ssrLoadModule(
"@xgrids-k1/frontend/messages.ts",
));
});
after(async () => {
@ -200,6 +214,151 @@ test("installed XGRIDS frontend manifest exposes the semantic v1alpha2 actions",
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", () => {
const document = manifestDocument({ apiVersion: "missioncore.nodedc/v1alpha2" });
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", () => {
let created = 0;
const createUuid = () => {
@ -437,7 +638,7 @@ test("device mutations send explicit nested compatibility attestation", async ()
const attestation = {
firmware_version: "3.0.2",
topology: "direct-lan",
operator_confirmed: true,
verification: "live-device-info",
};
try {
@ -445,11 +646,14 @@ test("device mutations send explicit nested compatibility attestation", async ()
device_id: "ble-device",
ssid: "lab-network",
password: syntheticCredential,
connection_mode: "bridge",
compatibility_attestation: attestation,
idempotency_key: "network-provision:test",
});
await xgridsK1Api.prepareAcquisition({
project_name: "Mission 01",
mount_type: "handheld",
gnss_mode: "none",
compatibility_attestation: attestation,
});
} finally {

View File

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

View File

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

View File

@ -1,7 +1,7 @@
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",
topology: "direct-lan",
operator_confirmed: true,
verification: "live-device-info",
});

View File

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

View File

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

View File

@ -72,7 +72,7 @@ function phasePresentation(
awaiting_external_stop: {
label: softwareCommanded ? "K1 завершает и сохраняет" : "Ожидание остановки на устройстве",
detail: softwareCommanded
? "STOP не повторяется. Дождитесь READY и постоянного зелёного индикатора."
? "STOP не повторяется. Mission Core завершит запись после READY от K1."
: "Mission Core ждёт подтверждения физической остановки K1.",
busy: true,
},
@ -108,7 +108,7 @@ function formatDuration(seconds: number): string {
export function K1SpatialControls(_props: DevicePluginConnectionProps) {
const controller = useXgridsK1Controller();
const { state, pendingAction, stop, confirmStoppedAtSteadyGreen } = controller;
const { state, pendingAction, stop } = controller;
const acquisition = state?.acquisition;
const cleanupPending = acquisition?.cleanup_pending === true;
if (!acquisition || !shouldRenderSpatialControls(state)) {
@ -179,16 +179,6 @@ export function K1SpatialControls(_props: DevicePluginConnectionProps) {
? "Повторить остановку"
: softwareCommanded ? "Остановить устройство и запись" : "Остановить локальный приём"}
</Button>
{state?.application_control_session?.can_confirm_standby ? (
<Button
size="compact"
variant="primary"
disabled={pendingAction !== null}
onClick={() => void confirmStoppedAtSteadyGreen()}
>
Индикатор постоянно зелёный завершить запись
</Button>
) : null}
</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 {
AcquisitionState,
XgridsApplicationControlPhase,
XgridsAcquisition,
XgridsK1State,
XgridsOperation,
@ -22,6 +23,25 @@ const FAILED_OPERATION_STATUSES = new Set([
]);
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(
state: AcquisitionState | null | undefined,
@ -119,7 +139,12 @@ export function normalizeRuntimePhase(
}
if (acquisitionState === "prepared") return "connected";
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 === "live") return "streaming";
if (phase === "replay") return "replaying";

View File

@ -1,5 +1,45 @@
// Vendor message normalization belongs to the XGRIDS frontend contribution.
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, "соединение с брокером завершено"],
[/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: "Устройство выбрано",
provisioning: "Передача настроек WiFi",
connecting: "Подключение",
connected: "Устройство подключено",
connected: "Сетевой адрес устройства получен",
starting_live: "Запуск потока",
live: "Поток в реальном времени",
replay: "Повтор записи",
@ -25,7 +25,8 @@ export function phaseLabel(phase: string | null | undefined): string {
export function phaseTone(phase: string | null | undefined): StatusTone {
if (!phase) return "neutral";
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";
return "neutral";
}

View File

@ -69,6 +69,20 @@
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 {
display: grid;
grid-template-columns: 2.2rem minmax(0, 1fr);
@ -282,6 +296,25 @@
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 {
grid-template-columns: minmax(0, 1.55fr) minmax(8rem, 0.45fr);
}
@ -435,6 +468,10 @@
}
@media (max-width: 760px) {
.xgrids-k1-plugin .scan-configuration-grid {
grid-template-columns: 1fr;
}
.xgrids-k1-plugin .session-form--replay {
grid-template-columns: 1fr;
}

View File

@ -16,6 +16,7 @@ import {
type XgridsK1State,
} from "./api";
import {
controlSessionEntryPlan,
isTerminalAcquisitionState,
liveStartPlan,
operationByIdempotencyKey,
@ -23,6 +24,10 @@ import {
isSoftwareCommandedAcquisition,
} from "./lifecycle";
import { localizeRuntimeMessage } from "./messages";
import {
awaitWhileIntentCurrent,
OperatorIntentGeneration,
} from "./operatorIntentGeneration";
import { selectMonotonicXgridsState } from "./stateOrdering";
export type PendingAction =
@ -50,19 +55,103 @@ function controlPhase(state: XgridsK1State): XgridsApplicationControlPhase {
function controlFailure(state: XgridsK1State): ApiError {
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(
failure?.message
? `Канонический диалог K1 остановлен: ${failure.message}`
: "Канонический диалог K1 остановлен до запуска сканирования.",
`${reasonDetail || localizedDetail || "Канонический диалог K1 остановлен."} Этап: ${stage}. ${failedOperation} ${exchanges} ${commandStatus} ${diagnostics} ${retryStatus}`,
);
}
async function waitForControlPhase(
expected: XgridsApplicationControlPhase,
acceptState: (state: XgridsK1State) => void,
assertOperatorIntentCurrent: () => void,
): Promise<XgridsK1State> {
for (;;) {
const nextState = await xgridsK1Api.getState();
assertOperatorIntentCurrent();
const nextState = await awaitWhileIntentCurrent(
assertOperatorIntentCurrent,
() => xgridsK1Api.getState(),
);
acceptState(nextState);
const phase = controlPhase(nextState);
if (phase === expected) return nextState;
@ -74,15 +163,22 @@ async function waitForControlPhase(
}
// This cadence only reads local server state. It never schedules, retries,
// or times a K1 command; every next write remains gated by device response.
await new Promise<void>((resolve) => {
window.setTimeout(resolve, CONTROL_STATE_READ_INTERVAL_MS);
});
await awaitWhileIntentCurrent(
assertOperatorIntentCurrent,
() => new Promise<void>((resolve) => {
window.setTimeout(resolve, CONTROL_STATE_READ_INTERVAL_MS);
}),
);
}
}
function messageFor(error: unknown): string {
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
? `${message} (HTTP ${error.status})`
: message;
@ -113,8 +209,12 @@ export function useXgridsK1Runtime(enabled: boolean) {
const [pendingAction, setPendingAction] = useState<PendingAction | null>(null);
const [error, setError] = useState<string | null>(null);
const [latencyHistory, setLatencyHistory] = useState<number[]>([]);
const mounted = useRef(true);
const actionInFlight = useRef(false);
const operatorIntents = useRef(new OperatorIntentGeneration());
const actionSequence = useRef(0);
const actionInFlight = useRef<{
runtimeGeneration: number;
actionSequence: number;
} | null>(null);
const acceptState = useCallback((nextState: XgridsK1State) => {
setState((currentState) => selectMonotonicXgridsState(currentState, nextState));
@ -122,13 +222,14 @@ export function useXgridsK1Runtime(enabled: boolean) {
}, []);
const refresh = useCallback(async (reportErrors = true) => {
if (!enabled) return;
const runtimeToken = operatorIntents.current.captureRuntime();
if (!enabled || !runtimeToken) return;
const [healthResult, stateResult] = await Promise.allSettled([
xgridsK1Api.getHealth(),
xgridsK1Api.getState(),
]);
if (!mounted.current) return;
if (!operatorIntents.current.isRuntimeCurrent(runtimeToken)) return;
if (stateResult.status === "fulfilled") {
acceptState(stateResult.value);
@ -150,27 +251,48 @@ export function useXgridsK1Runtime(enabled: boolean) {
const run = useCallback(
async (action: PendingAction, operation: () => Promise<XgridsK1State>) => {
if (!enabled) return false;
if (actionInFlight.current) return false;
actionInFlight.current = true;
const runtimeToken = operatorIntents.current.captureRuntime();
if (!enabled || !runtimeToken) return false;
if (
actionInFlight.current?.runtimeGeneration
=== runtimeToken.runtimeGeneration
) return false;
actionSequence.current += 1;
const actionToken = {
runtimeGeneration: runtimeToken.runtimeGeneration,
actionSequence: actionSequence.current,
};
actionInFlight.current = actionToken;
setPendingAction(action);
setError(null);
try {
if (!operatorIntents.current.isRuntimeCurrent(runtimeToken)) return false;
const nextState = await operation();
if (mounted.current) acceptState(nextState);
if (!operatorIntents.current.isRuntimeCurrent(runtimeToken)) return false;
acceptState(nextState);
return true;
} catch (operationError) {
if (mounted.current) {
if (operatorIntents.current.isRuntimeCurrent(runtimeToken)) {
setError(messageFor(operationError));
if (operationError instanceof ApiError && operationError.status === 0) {
if (
operationError instanceof ApiError
&& operationError.transportUnavailable
) {
setBackendStatus("offline");
}
}
return false;
} finally {
actionInFlight.current = false;
if (mounted.current) setPendingAction(null);
if (
actionInFlight.current?.runtimeGeneration === actionToken.runtimeGeneration
&& actionInFlight.current.actionSequence === actionToken.actionSequence
) {
actionInFlight.current = null;
if (operatorIntents.current.isRuntimeCurrent(runtimeToken)) {
setPendingAction(null);
}
}
}
},
[acceptState, enabled],
@ -237,7 +359,24 @@ export function useXgridsK1Runtime(enabled: boolean) {
const startCanonicalAcquisition = useCallback(
(request: CanonicalLiveStartRequest) =>
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);
const plan = liveStartPlan(nextState);
if (plan === "blocked") {
@ -246,50 +385,84 @@ export function useXgridsK1Runtime(enabled: boolean) {
if (plan === "already-running") return nextState;
for (;;) {
assertOperatorIntentCurrent();
const phase = controlPhase(nextState);
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)) {
throw new ApiError(
"Незавершённая подготовка не привязана к открытой control-сессии. Отмените её перед новым запуском.",
);
}
nextState = await xgridsK1Api.openApplicationControlSession(request.control);
acceptState(nextState);
continue;
}
if (phase === "failed") {
if (nextState.application_control_session?.can_open !== true) {
throw controlFailure(nextState);
}
nextState = await xgridsK1Api.openApplicationControlSession(request.control);
openedControlSession = true;
nextState = await awaitWhileIntentCurrent(
assertOperatorIntentCurrent,
() => xgridsK1Api.openApplicationControlSession(request.control),
);
acceptState(nextState);
continue;
}
if (phase === "connecting") {
nextState = await waitForControlPhase("connection-ready", acceptState);
nextState = await awaitWhileIntentCurrent(
assertOperatorIntentCurrent,
() => waitForControlPhase(
"connection-ready",
acceptState,
assertOperatorIntentCurrent,
),
);
continue;
}
if (phase === "connection-ready") {
nextState = await xgridsK1Api.enterApplicationWorkspace({
operator_confirmed: true,
});
nextState = await awaitWhileIntentCurrent(
assertOperatorIntentCurrent,
() => xgridsK1Api.enterApplicationWorkspace({
operator_confirmed: true,
}),
);
acceptState(nextState);
continue;
}
if (phase === "workspace-requested") {
nextState = await waitForControlPhase("workspace-ready", acceptState);
nextState = await awaitWhileIntentCurrent(
assertOperatorIntentCurrent,
() => waitForControlPhase(
"workspace-ready",
acceptState,
assertOperatorIntentCurrent,
),
);
continue;
}
if (phase === "workspace-ready") {
if (!acquisition || isTerminalAcquisitionState(acquisition.state)) {
nextState = await xgridsK1Api.prepareAcquisition(request.acquisition);
nextState = await awaitWhileIntentCurrent(
assertOperatorIntentCurrent,
() => xgridsK1Api.prepareAcquisition(request.acquisition),
);
acceptState(nextState);
continue;
}
@ -298,12 +471,26 @@ export function useXgridsK1Runtime(enabled: boolean) {
"Текущая подготовка не принадлежит канонической control-сессии K1.",
);
}
nextState = await waitForControlPhase("project-ready", acceptState);
nextState = await awaitWhileIntentCurrent(
assertOperatorIntentCurrent,
() => waitForControlPhase(
"project-ready",
acceptState,
assertOperatorIntentCurrent,
),
);
continue;
}
if (phase === "project-requested") {
nextState = await waitForControlPhase("project-ready", acceptState);
nextState = await awaitWhileIntentCurrent(
assertOperatorIntentCurrent,
() => waitForControlPhase(
"project-ready",
acceptState,
assertOperatorIntentCurrent,
),
);
continue;
}
@ -311,11 +498,14 @@ export function useXgridsK1Runtime(enabled: boolean) {
if (!acquisition || acquisition.state !== "prepared") {
throw new ApiError("Локальный приём не подготовлен к каноническому START.");
}
nextState = await xgridsK1Api.startAcquisition({
acquisition_id: acquisition.acquisition_id,
expected_state_revision: acquisition.state_revision,
physical_acceptance: request.physicalAcceptance,
});
nextState = await awaitWhileIntentCurrent(
assertOperatorIntentCurrent,
() => xgridsK1Api.startAcquisition({
acquisition_id: acquisition.acquisition_id,
expected_state_revision: acquisition.state_revision,
physical_acceptance: request.physicalAcceptance,
}),
);
acceptState(nextState);
return nextState;
}
@ -396,33 +586,6 @@ export function useXgridsK1Runtime(enabled: boolean) {
[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 acquisition = state?.acquisition;
if (!acquisition || isTerminalAcquisitionState(acquisition.state)) {
@ -485,7 +648,7 @@ export function useXgridsK1Runtime(enabled: boolean) {
useEffect(() => {
if (!enabled) {
mounted.current = true;
operatorIntents.current.deactivateRuntime();
setState(null);
setBackendStatus("checking");
setEventStatus("closed");
@ -495,12 +658,12 @@ export function useXgridsK1Runtime(enabled: boolean) {
return;
}
mounted.current = true;
operatorIntents.current.activateRuntime();
void refresh(true);
const poll = window.setInterval(() => void refresh(false), 4_000);
return () => {
mounted.current = false;
operatorIntents.current.deactivateRuntime();
window.clearInterval(poll);
};
}, [enabled, refresh]);
@ -563,7 +726,6 @@ export function useXgridsK1Runtime(enabled: boolean) {
startPreparedAcquisition,
startReplay,
stop,
confirmStoppedAtSteadyGreen,
abort,
setObservationSourceActive,
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:
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(
control.get("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")
if scope.get("vendor") != "XGRIDS" or scope.get("model") != "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")
if firmware != {"match": "exact", "version": "3.0.2"}:
raise CompatibilityProfileError("profile must match firmware 3.0.2 exactly")

View File

@ -5,6 +5,7 @@
"scope": {
"vendor": "XGRIDS",
"model": "LixelKity K1",
"platform_type": "A4",
"firmware": {
"match": "exact",
"version": "3.0.2"
@ -26,8 +27,8 @@
"request_topic_subscription_enabled": false,
"notes": [
"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.",
"Observed LixelGO modeling requests describe the wire contract but remain non-replayable and write-disabled.",
"The reviewed Wi-Fi provisioning procedure remains a separate explicit operator action and is not activated by loading this profile.",
"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."
]
},
@ -316,6 +317,15 @@
"acquisition_control": {
"mode": "operator-manual",
"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": {
"gesture": "physical-double-click",
"state_dependent_result": "start from steady-green standby; stop during active scanning",
@ -359,7 +369,7 @@
},
"success_result_code": 302252033,
"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",
"timeout, rejection and rollback contract"
],
@ -406,7 +416,7 @@
"request_fields": {},
"success_result_code": 302252033,
"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",
"timeout and rollback contract"
],

View File

@ -90,6 +90,7 @@ class XgridsK1CameraGateway:
self._target_host: str | None = None
self._producer: _CameraProducer | None = None
self._recording_root: Path | None = None
self._expected_source_end_generation: int | None = None
self._archive_summaries: list[dict[str, Any]] = []
self._error: dict[str, str] | None = None
self._closed = False
@ -124,6 +125,9 @@ class XgridsK1CameraGateway:
},
"recording": {
"active": self._recording_root is not None,
"source_end_expected": (
self._expected_source_end_generation is not None
),
"session": (
self._recording_root.name if self._recording_root is not None else None
),
@ -175,6 +179,7 @@ class XgridsK1CameraGateway:
self._revision += 1
self._source_id = source_id
self._target_host = target
self._expected_source_end_generation = None
self._phase = "selected"
self._error = None
recording_active = self._recording_root is not None
@ -221,6 +226,7 @@ class XgridsK1CameraGateway:
self._source_id = None
self._target_host = None
self._recording_root = None
self._expected_source_end_generation = None
self._error = None
self._shutdown_producer(
producer,
@ -248,6 +254,7 @@ class XgridsK1CameraGateway:
raise RuntimeError("для camera gateway уже активна другая acquisition-сессия")
producer, delivery = self._detach_producer_locked()
self._recording_root = root
self._expected_source_end_generation = None
self._archive_summaries = []
selected = self._source_id is not None
if selected:
@ -266,6 +273,29 @@ class XgridsK1CameraGateway:
self._spawn_selected_producer()
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(
self,
*,
@ -279,6 +309,7 @@ class XgridsK1CameraGateway:
producer, delivery = self._detach_producer_locked()
recording_was_active = self._recording_root is not None
self._recording_root = None
self._expected_source_end_generation = None
if self._source_id is not None and self._phase != "error":
self._phase = "selected"
if recording_was_active or producer is not None:
@ -361,6 +392,7 @@ class XgridsK1CameraGateway:
self._source_id = None
self._target_host = None
self._recording_root = None
self._expected_source_end_generation = None
self._error = None
self._shutdown_producer(
producer,
@ -559,29 +591,49 @@ class XgridsK1CameraGateway:
delivery = producer.delivery
producer.delivery = None
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:
self._producer = None
self._expected_source_end_generation = None
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"
self._set_error_locked(
"camera-source-ended",
_safe_ffmpeg_message(producer.stderr_tail),
)
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)
if not owns_shutdown:
return
_terminate_process(producer.process)
status: CameraArchiveStatus = (
"interrupted"
if producer.failure_code
in {"camera-source-ended", "incomplete-fmp4-fragment"}
"complete"
if expected_clean_end
else "interrupted"
if producer.failure_code in {"camera-source-ended", "incomplete-fmp4-fragment"}
else "failed"
)
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:
with self._lock:
self._set_error_locked(

View File

@ -251,7 +251,7 @@ def serve_console(
] = 8000,
) -> None:
"""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():
console.print(
"[red]Frontend build is missing.[/red] Run npm install && npm run build "

View File

@ -6,6 +6,7 @@ import hmac
import importlib.util
import json
import secrets
import socket
import threading
import time
import unicodedata
@ -178,17 +179,18 @@ class BleScanRequest(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"]
topology: Literal["direct-lan"]
operator_confirmed: Literal[True]
verification: Literal["live-device-info"]
class ConnectRequest(StrictRequest):
device_id: 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)
connection_mode: Literal["bridge"] = "bridge"
compatibility_attestation: CompatibilityAttestationRequest
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)
@ -234,6 +236,8 @@ class OperatorPresenceRequest(StrictRequest):
class PrepareAcquisitionRequest(OperationContextRequest):
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)
duration_seconds: float = Field(default=3600.0, ge=1.0, le=86_400.0)
requested_streams: tuple[RequestedStreamId, ...] = DEFAULT_LIVE_STREAMS
@ -331,6 +335,7 @@ class XgridsK1CompatibilityService:
self._devices: list[dict[str, Any]] = []
self._selected_device_id: 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_id: str | None = None
self._device_session_id: str | None = None
@ -347,6 +352,8 @@ class XgridsK1CompatibilityService:
self._operations = OperationJournal()
self._acquisition: AcquisitionRecord | 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_start_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()
runtime = self.runtime.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()
metrics = runtime["metrics"]
with self._lock:
@ -388,6 +396,7 @@ class XgridsK1CompatibilityService:
devices = list(self._devices)
selected_device_id = self._selected_device_id
k1_ip = self._k1_ip
connection_mode = self._connection_mode
device_id = self._device_id
device_session_id = self._device_session_id
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
if acquisition is not None:
acquisition["project_name"] = self._acquisition_project_name
acquisition["mount_type"] = self._acquisition_mount_type
acquisition["gnss_mode"] = self._acquisition_gnss_mode
acquisition["cleanup_pending"] = (
self._acquisition_session_lease is not None
and acquisition["state"] in TERMINAL_ACQUISITION_STATES
@ -450,6 +461,7 @@ class XgridsK1CompatibilityService:
"devices": devices,
"selected_device_id": selected_device_id,
"k1_ip": k1_ip,
"connection_mode": connection_mode,
"compatibility": {
"profile_id": active_profile_id,
"decision": "limited" if active_profile_id is not None else "unknown",
@ -461,9 +473,9 @@ class XgridsK1CompatibilityService:
else "evidence-only"
),
"firmware_claim": (
"operator-attested-exact-3.0.2"
"live-device-info-verification-required"
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,
"vendor_writes_enabled": active_control,
@ -598,6 +610,7 @@ class XgridsK1CompatibilityService:
"device_id": request.device_id,
"ssid": request.ssid,
"password": password,
"connection_mode": request.connection_mode,
"compatibility_attestation": request.compatibility_attestation.model_dump(
mode="json"
),
@ -678,6 +691,7 @@ class XgridsK1CompatibilityService:
)
write_json_atomic(session_dir / "provisioning.sensitive.json", result)
ipv4 = _provisioned_ipv4(result)
local_address_conflict = ipv4 is not None and _target_is_local_ipv4(ipv4)
write_json_atomic(
session_dir / "manifest.redacted.json",
{
@ -688,6 +702,9 @@ class XgridsK1CompatibilityService:
"profile_id": result["profile_id"],
"outcome": result["outcome"],
"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,
},
)
@ -695,9 +712,15 @@ class XgridsK1CompatibilityService:
raise RuntimeError(
"Устройство не сообщило адрес в локальной сети; автоматического повтора не было"
)
if local_address_conflict:
raise RuntimeError(
"Устройство сообщило IPv4-адрес, который уже принадлежит этому компьютеру; "
"адрес K1 не принят и автоматического повтора не было"
)
with self._lock:
self._selected_device_id = request.device_id
self._k1_ip = ipv4
self._connection_mode = request.connection_mode
self._device_id = self._device_ids_by_transport_ref.setdefault(
request.device_id,
new_device_id(),
@ -792,7 +815,7 @@ class XgridsK1CompatibilityService:
attestation = self._compatibility_attestation
acquisition = self._acquisition
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:
raise RuntimeError("control-сессия должна быть открыта до подготовки acquisition")
self._application_control.disarm()
@ -830,7 +853,7 @@ class XgridsK1CompatibilityService:
if self._k1_ip is None or self._selected_device_id is None:
raise RuntimeError("сначала выберите и подключите K1 через BLE/Wi-Fi")
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
if acquisition is not None and acquisition.state not in TERMINAL_ACQUISITION_STATES:
raise RuntimeError(
@ -914,6 +937,8 @@ class XgridsK1CompatibilityService:
"requested_streams": requested_streams,
"evidence_policy": request.evidence_policy,
"project_name": request.project_name,
"mount_type": request.mount_type,
"gnss_mode": request.gnss_mode,
"compatibility_attestation": request.compatibility_attestation.model_dump(
mode="json"
),
@ -974,6 +999,8 @@ class XgridsK1CompatibilityService:
)
self._acquisition = acquisition
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_start_operation_id = None
self._acquisition_stop_operation_id = None
@ -1136,20 +1163,27 @@ class XgridsK1CompatibilityService:
expected_stop_operation_id = self._acquisition_stop_operation_id
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.mode != "graceful":
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 не ожидает подтверждения физической остановки")
if request.operation_id is None or request.operation_id != expected_stop_operation_id:
raise ValueError(
"подтверждение остановки должно ссылаться на исходную stop-operation"
)
if plugin_commanded and not self._application_control_session.snapshot()[
"can_confirm_standby"
]:
raise RuntimeError(
"сначала дождитесь READY от K1 и визуально подтвердите зелёный индикатор"
if plugin_commanded:
raise ValueError(
"канонический STOP завершается автоматически по READY от K1"
)
elif (
request.mode == "graceful"
@ -1180,6 +1214,14 @@ class XgridsK1CompatibilityService:
if self._application_control_session.snapshot()["state"] != "scanning":
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(
ACTION_ACQUISITION_STOP,
{
@ -1271,9 +1313,14 @@ class XgridsK1CompatibilityService:
if request.mode == "graceful" and not request.operator_confirmed:
if plugin_commanded:
assert request.physical_acceptance is not None
self._application_control_session.request_stop(
confirmation=request.physical_acceptance.confirmation()
)
self.camera_preview.expect_source_end_for_device_stop()
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:
acquisition.transition(
"awaiting_external_stop",
@ -1282,22 +1329,17 @@ class XgridsK1CompatibilityService:
if plugin_commanded
else "acquisition.stop.operator_action_required"
),
operator_instructions=(
(
"Канонический STOP запрошен один раз. Дождитесь READY, "
"убедитесь, что индикатор постоянно зелёный, и подтвердите это."
if plugin_commanded
else (
"Дважды нажмите физическую кнопку устройства и "
"подтвердите остановку."
)
),
operator_instructions=()
if plugin_commanded
else (
"Дважды нажмите физическую кнопку устройства и "
"подтвердите остановку.",
),
)
self._acquisition_stop_operation_id = operation.operation_id
self._operations.transition(
operation.operation_id,
"operator_action_required",
"running" if plugin_commanded else "operator_action_required",
stage_code="awaiting-external-stop",
message_code=(
"acquisition.stop.device_stopping"
@ -1315,8 +1357,6 @@ class XgridsK1CompatibilityService:
message_code="acquisition.stop.stopping_receiver",
)
try:
if plugin_commanded and request.mode == "graceful" and request.operator_confirmed:
self._application_control_session.confirm_standby()
with self._lock:
acquisition.transition("stopping", message_code="acquisition.stopping")
self._stop_acquisition_sources(
@ -1814,18 +1854,51 @@ class XgridsK1CompatibilityService:
self,
runtime: Mapping[str, Any],
camera: Mapping[str, Any],
application_control_session: Mapping[str, Any],
) -> 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
failed_operation_id: str | None = None
failed_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_plugin_commanded = False
acquisition_id: str | None = None
camera_terminal_status: Literal["complete", "failed"] | None = None
camera_failure_code: str | None = None
stop_runtime_for_camera_failure = False
stop_runtime_for_canonical_completion = 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:
acquisition = self._acquisition
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
camera_terminal_status = "failed"
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:
acquisition.transition("acquiring", message_code="acquisition.acquiring")
completed_operation_id = self._acquisition_start_operation_id
@ -1954,7 +2046,7 @@ class XgridsK1CompatibilityService:
)
except Exception as exc:
terminal_error = exc
if stop_runtime_for_camera_failure:
if stop_runtime_for_camera_failure or stop_runtime_for_canonical_completion:
try:
self.runtime.stop()
except Exception as exc:
@ -1973,11 +2065,8 @@ class XgridsK1CompatibilityService:
if acquisition.state not in TERMINAL_ACQUISITION_STATES:
acquisition.transition(
"completed",
message_code="acquisition.receiver_completed",
result={
"receiver_stopped": True,
"device_state": "unknown",
},
message_code=completion_message_code,
result=completion_result,
)
except Exception as exc:
reconciliation_error = exc
@ -2058,6 +2147,32 @@ class XgridsK1CompatibilityService:
"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:
if self._acquisition_start_operation_id in {
completed_operation_id,
@ -2068,6 +2183,8 @@ class XgridsK1CompatibilityService:
self._acquisition_stop_operation_id = None
if self._acquisition_stop_operation_id == unconfirmed_stop_operation_id:
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:
raise reconciliation_error
@ -2345,7 +2462,8 @@ def _attestation_snapshot(
return {
"firmware_version": attestation.firmware_version,
"topology": attestation.topology,
"basis": "operator-attested",
"verification": attestation.verification,
"basis": "selected-profile-live-device-info-required",
"observed_at": _utc_now_iso(),
}
@ -2394,7 +2512,7 @@ def _sensor_catalog(
else (
"local-runtime-dependency-missing"
if camera_profile_active
else "profile-not-attested"
else "profile-not-selected"
)
),
"endpoint_label": f"RTSP · {side}",
@ -2506,6 +2624,24 @@ def _provisioned_ipv4(result: Mapping[str, Any]) -> str | 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:
"""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 (
MODELING_STATUS_RESPONSE_TOPIC,
ApplicationBootstrapError,
ApplicationCompatibilityProfileError,
ApplicationControlAuthority,
CanonicalPostStartObservation,
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.
CONTROL_NETWORK_PUMP_QUANTUM_SECONDS = 1.0
SCAN_INITIALIZATION_TIMEOUT_SECONDS = 120.0
class ApplicationAcceptanceError(RuntimeError):
"""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):
def exchange_batch_once(
self,
envelopes: Sequence[OneShotPublishEnvelope],
*,
required_response_topics: Collection[str],
required_response_operation_keys: Collection[str],
) -> dict[str, bytes]: ...
def maintain_open_for(
@ -182,8 +196,24 @@ class PhysicalAcceptanceDialogueExecutor:
def __init__(
self,
transport: ApplicationBatchExchange,
*,
monotonic: Callable[[], float] = time.monotonic,
scan_initialization_timeout_seconds: float = SCAN_INITIALIZATION_TIMEOUT_SECONDS,
) -> 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._monotonic = monotonic
self._scan_initialization_timeout_seconds = float(
scan_initialization_timeout_seconds
)
self._bootstrap_complete = False
self._command_complete = False
self._dialogue_stage = "new"
@ -199,6 +229,7 @@ class PhysicalAcceptanceDialogueExecutor:
self._stop_permit_snapshot: dict[str, object] | None = None
self._response_evidence: list[dict[str, object]] = []
self._correlation_failure: dict[str, object] | None = None
self._compatibility_failure: dict[str, object] | None = None
def run_bootstrap(
self,
@ -217,7 +248,7 @@ class PhysicalAcceptanceDialogueExecutor:
if self._dialogue_stage != "new" or self._bootstrap_complete or self._command_complete:
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)
binding = orchestrator.binding
if binding is None:
@ -270,7 +301,7 @@ class PhysicalAcceptanceDialogueExecutor:
if self._dialogue_stage != "connection-ready":
raise ApplicationAcceptanceError("workspace entry requires the connection stage")
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
if binding is None:
raise ApplicationAcceptanceError("workspace entry lost the live device binding")
@ -287,7 +318,7 @@ class PhysicalAcceptanceDialogueExecutor:
if self._dialogue_stage != "workspace-ready":
raise ApplicationAcceptanceError("project prompt requires workspace entry")
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:
raise ApplicationAcceptanceError("project prompt did not complete the transcript")
binding = orchestrator.binding
@ -338,9 +369,9 @@ class PhysicalAcceptanceDialogueExecutor:
self._dialogue_stage = "start-attempted"
responses = self._transport.exchange_batch_once(
[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(
phase="modeling",
operation_key="modeling:start",
@ -363,24 +394,33 @@ class PhysicalAcceptanceDialogueExecutor:
immediate = post_start.immediate_modeling_status
self._transport.exchange_batch_once(
[OneShotPublishEnvelope.from_dialogue_request(immediate)],
required_response_topics=(),
required_response_operation_keys=(),
)
self._dialogue_stage = "initializing"
initialization_deadline = (
self._monotonic() + self._scan_initialization_timeout_seconds
)
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(
CONTROL_NETWORK_PUMP_QUANTUM_SECONDS,
allowed_response_topics={MODELING_STATUS_RESPONSE_TOPIC},
)
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(
[OneShotPublishEnvelope.from_dialogue_request(request) for request in refresh],
required_response_topics=required_topics,
required_response_operation_keys=required_operations,
)
for request in refresh:
refresh_payload = refresh_responses[request.response_topic]
operation_key = f"dialogue:{request.ordinal}:{request.message_type}"
refresh_payload = refresh_responses[operation_key]
self._record_response_evidence(
phase="post-start",
operation_key=operation_key,
@ -480,9 +520,9 @@ class PhysicalAcceptanceDialogueExecutor:
self._dialogue_stage = "stop-attempted"
responses = self._transport.exchange_batch_once(
[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(
phase="modeling",
operation_key="modeling:stop",
@ -505,16 +545,13 @@ class PhysicalAcceptanceDialogueExecutor:
self._dialogue_stage = "stop-acknowledged"
return response
def maintain_post_stop_until_standby_confirmed(
self,
standby_confirmed: Callable[[], bool],
) -> None:
"""Keep servicing control reports through save and physical standby.
def maintain_post_stop_until_standby(self) -> None:
"""Keep servicing control reports through save and protocol standby.
The retained capture does not expose a protocol timer that proves save
completion. The session therefore remains owned until an explicit
operator/status gate confirms standby and never closes itself merely
because a captured wall-clock duration elapsed.
There is no wall-clock shortcut after STOP. The dialogue remains owned
until the bound K1 itself reports READY with no project attached. That
live protocol state is the canonical completion evidence; a second
operator acknowledgement would add no independent information.
"""
if self._dialogue_stage != "stop-acknowledged" or not self._stop_complete:
@ -524,9 +561,7 @@ class PhysicalAcceptanceDialogueExecutor:
binding = self._active_binding
if binding is None:
raise ApplicationAcceptanceError("canonical START binding is no longer available")
while not (
self._transport.standby_complete(binding) and standby_confirmed()
):
while not self._transport.standby_complete(binding):
self._transport.maintain_open_for(
CONTROL_NETWORK_PUMP_QUANTUM_SECONDS,
allowed_response_topics={MODELING_STATUS_RESPONSE_TOPIC},
@ -555,12 +590,19 @@ class PhysicalAcceptanceDialogueExecutor:
if self._stop_permit_snapshot is not 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": (
dict(self._correlation_failure)
if self._correlation_failure is not None
else None
),
"compatibility_failure": (
dict(self._compatibility_failure)
if self._compatibility_failure is not None
else None
),
"automatic_retry": False,
}
@ -608,18 +650,20 @@ class PhysicalAcceptanceDialogueExecutor:
f"canonical bootstrap expected batch {expected_batch}"
)
batch = orchestrator.next_batch()
required_topics = {
request.response_topic for request in batch if request.response_required
required_operations = {
f"bootstrap:{request.ordinal}:{request.message_type}"
for request in batch
if request.response_required
}
responses = self._transport.exchange_batch_once(
[OneShotPublishEnvelope.from_bootstrap_request(request) for request in batch],
required_response_topics=required_topics,
required_response_operation_keys=required_operations,
)
for request in batch:
if not request.response_required:
continue
payload = responses[request.response_topic]
operation_key = f"bootstrap:{request.ordinal}:{request.message_type}"
payload = responses[operation_key]
self._record_response_evidence(
phase="bootstrap",
operation_key=operation_key,
@ -627,7 +671,25 @@ class PhysicalAcceptanceDialogueExecutor:
payload=payload,
)
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:
self._record_correlation_failure(
phase="bootstrap",
@ -670,5 +732,23 @@ class PhysicalAcceptanceDialogueExecutor:
"phase": phase,
"operation_key": operation_key,
"response_topic": response_topic,
"reason_code": self._correlation_reason_code(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
import importlib
import platform
import shutil
import subprocess
import sys
from dataclasses import dataclass
from typing import Protocol
from typing import Any, Protocol, cast
from k1link.device_plugins.xgrids_k1.protocol.application_bootstrap import (
ApplicationBootstrapError,
@ -33,6 +34,10 @@ class CommandRunner(Protocol):
) -> subprocess.CompletedProcess[bytes]: ...
class KeychainSecretReader(Protocol):
def __call__(self, *, service: str, account: str) -> bytes: ...
class InteractiveCommandRunner(Protocol):
def __call__(
self,
@ -43,6 +48,63 @@ class InteractiveCommandRunner(Protocol):
) -> 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)
class ApplicationAuthoritySourceSnapshot:
provider: str = "macos-keychain"
@ -69,12 +131,19 @@ class MacOSKeychainApplicationAuthorityLoader:
"""Read the exact-profile authority from the current user's Keychain.
There is deliberately no environment, plaintext-file, browser or API
fallback. The secret is requested through the absolute ``security`` binary
and is never interpolated into an exception, repr or subprocess argument.
fallback. Runtime reads call Apple's Security.framework in-process and
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._framework_reader = framework_reader
def snapshot(self) -> ApplicationAuthoritySourceSnapshot:
return ApplicationAuthoritySourceSnapshot()
@ -84,32 +153,48 @@ class MacOSKeychainApplicationAuthorityLoader:
raise ApplicationAuthorityLoadError(
"application authority loading is supported only through macOS Keychain"
)
security = shutil.which("security")
if security != "/usr/bin/security":
raise ApplicationAuthorityLoadError("trusted macOS security binary is unavailable")
if self._runner is None:
try:
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:
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)
secret_buffer = bytearray(secret_bytes)
try:
while secret_buffer.endswith((b"\n", b"\r")):
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 (
ProtobufWireError,
ProtoField,
encode_zigzag64,
iter_fields,
)
@ -19,6 +20,12 @@ MAX_HEADER_BYTES = 4 * 1024
MAX_TEXT_BYTES = 4 * 1024
APPLICATION_AUTHORITY_SOURCE = "owner-captured-lixelgo-application"
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"
SHADOW_BLOCKERS = ("vendor-writes-disabled", "publisher-not-installed")
@ -40,6 +47,22 @@ class ApplicationBootstrapError(ValueError):
"""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)
class ApplicationControlAuthority:
"""Private authority belonging to the reviewed LixelGO application profile.
@ -98,9 +121,22 @@ class LiveDeviceControlBinding:
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
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)
@ -213,6 +249,15 @@ class ApplicationResponse:
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)
class ApplicationBootstrapSnapshot:
current_batch: int
@ -253,9 +298,7 @@ def build_shadow_bootstrap(
"""
if not binding.ready_for_reviewed_profile:
raise ApplicationBootstrapError(
"live DeviceInfo does not attest the reviewed activated FW 3.0.2 profile"
)
raise ApplicationCompatibilityProfileError(binding)
if not isinstance(epoch_seconds, int) or isinstance(epoch_seconds, bool):
raise ApplicationBootstrapError("epoch_seconds must be an integer")
if epoch_seconds < 0 or epoch_seconds > 0x7FFF_FFFF_FFFF_FFFF:
@ -373,7 +416,9 @@ def build_shadow_bootstrap(
)
body = b""
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)
payload = _bytes_field(1, _encode_header(header)) + body
_check_payload(payload, "application request")
@ -406,9 +451,7 @@ def build_canonical_post_start_observation(
"""Build retained operations 12-14 without granting publish authority."""
if not binding.ready_for_reviewed_profile:
raise ApplicationBootstrapError(
"live DeviceInfo does not attest the reviewed activated FW 3.0.2 profile"
)
raise ApplicationCompatibilityProfileError(binding)
def build(
ordinal: Literal[12, 13, 14],
@ -577,15 +620,43 @@ def correlate_application_response(
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:
"""Advance the retained dialogue only across observed response barriers.
The clean cycle emitted five batches. ``ModelingStatusRequest`` in the
second batch had no required synchronous response before preparation
continued; readiness remains a separate live DeviceStatus gate.
DeviceInfo is the sole identity-binding barrier. After it succeeds, the
retained captures publish ordinals 2-6 in order without inventing a barrier
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__(
self,
@ -612,7 +683,9 @@ class ShadowApplicationBootstrapOrchestrator:
current_batch=min(self._batch_index + 1, len(self._BATCH_ORDINALS)),
total_batches=len(self._BATCH_ORDINALS),
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,
bootstrap_complete=self._complete,
)
@ -643,25 +716,23 @@ class ShadowApplicationBootstrapOrchestrator:
for ordinal in self._BATCH_ORDINALS[self._batch_index]
)
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:
raise ApplicationBootstrapError("bootstrap batch has no recovered response barrier")
self._pending = pending
self._batch_issued = True
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."""
with self._lock:
if not self._batch_issued:
raise ApplicationBootstrapError("no application bootstrap batch is in flight")
expected = self._pending.get(topic)
expected = self._pending.get(operation_key)
if expected is None:
raise ApplicationBootstrapError(
"application response is not required by the current batch"
@ -704,7 +775,7 @@ class ShadowApplicationBootstrapOrchestrator:
live_binding=self._binding,
)
del self._pending[topic]
del self._pending[operation_key]
if self._pending:
return
self._batch_issued = False
@ -712,6 +783,10 @@ class ShadowApplicationBootstrapOrchestrator:
if self._batch_index == len(self._BATCH_ORDINALS):
self._complete = True
@staticmethod
def _operation_key(request: EncodedApplicationRequest) -> str:
return f"bootstrap:{request.ordinal}:{request.message_type}"
def _build_initial_device_info_request(
authority: ApplicationControlAuthority,

View File

@ -1,5 +1,6 @@
from __future__ import annotations
import hmac
import math
import secrets
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.mqtt import validate_private_ipv4
from k1link.device_plugins.xgrids_k1.protocol.application_bootstrap import (
DEVICE_CONFIG_REQUEST_TOPIC,
DEVICE_CONFIG_RESPONSE_TOPIC,
DEVICE_INFO_REQUEST_TOPIC,
DEVICE_INFO_RESPONSE_TOPIC,
GET_CLOUD_CONFIG_REQUEST_TOPIC,
GET_CLOUD_CONFIG_RESPONSE_TOPIC,
GET_NTRIP_PROFILE_REQUEST_TOPIC,
GET_NTRIP_PROFILE_RESPONSE_TOPIC,
GET_RTK_ADVANCE_REQUEST_TOPIC,
GET_RTK_ADVANCE_RESPONSE_TOPIC,
MODELING_STATUS_REQUEST_TOPIC,
MODELING_STATUS_RESPONSE_TOPIC,
ApplicationBootstrapError,
ApplicationMessageIdentity,
LiveDeviceControlBinding,
decode_application_message_identity,
)
from k1link.device_plugins.xgrids_k1.protocol.application_publish import (
OneShotPublishEnvelope,
)
from k1link.device_plugins.xgrids_k1.protocol.modeling_control import (
ModelingAction,
ModelingProtocolError,
decode_device_status_report,
decode_modeling_response,
decode_system_error_report,
)
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
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
# in this exact order. Point-cloud subscriptions belonged to a separate client.
CONTROL_SUBSCRIPTION_GROUPS: tuple[tuple[tuple[str, int], ...], ...] = (
@ -147,14 +170,60 @@ APPLICATION_REQUEST_ALLOWLIST = frozenset(
class ApplicationMqttTransportError(RuntimeError):
"""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):
"""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):
"""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)
class ApplicationMqttTransportSnapshot:
@ -165,6 +234,7 @@ class ApplicationMqttTransportSnapshot:
qos2_completions: int
correlated_responses: int
ignored_known_responses: int
late_known_responses: int
device_status_reports: int
system_error_reports: int
report_decode_errors: int
@ -189,6 +259,7 @@ class ApplicationMqttTransportSnapshot:
"qos2_completions": self.qos2_completions,
"correlated_responses": self.correlated_responses,
"ignored_known_responses": self.ignored_known_responses,
"late_known_responses": self.late_known_responses,
"device_status_reports": self.device_status_reports,
"system_error_reports": self.system_error_reports,
"report_decode_errors": self.report_decode_errors,
@ -208,10 +279,10 @@ class ApplicationMqttTransportSnapshot:
class ReviewedApplicationMqttTransport:
"""Exact-profile MQTT exchange for an operator-present physical acceptance.
This type is deliberately not installed in the facade or plugin runtime.
It performs one connection attempt, never reconnects, consumes every
operation key before calling ``publish``, and permanently poisons itself
after any unknown post-publish outcome.
Plugin v0.5.0 installs this type only behind the operator-driven continuous
session owner. It performs one connection attempt, never reconnects,
consumes every operation key before calling ``publish``, and permanently
poisons itself after any unknown post-publish outcome.
"""
def __init__(
@ -252,12 +323,19 @@ class ReviewedApplicationMqttTransport:
self._messages: deque[tuple[str, bytes]] = deque()
self._callback_error: str | None = None
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._subscribe_attempts = 0
self._publish_attempts = 0
self._qos2_completions = 0
self._correlated_responses = 0
self._ignored_known_responses = 0
self._late_known_responses = 0
self._device_status_reports = 0
self._system_error_reports = 0
self._report_decode_errors = 0
@ -273,10 +351,14 @@ class ReviewedApplicationMqttTransport:
def open(self) -> ApplicationMqttTransportSnapshot:
with self._lock:
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._connect_attempts = 1
client = self._new_client()
client.connect_timeout = self._connect_timeout_seconds
self._install_callbacks(client)
self._client = client
try:
@ -300,10 +382,10 @@ class ReviewedApplicationMqttTransport:
self,
envelopes: Sequence[OneShotPublishEnvelope],
*,
required_response_topics: Collection[str],
required_response_operation_keys: Collection[str],
) -> dict[str, bytes]:
batch = tuple(envelopes)
required = frozenset(required_response_topics)
required = frozenset(required_response_operation_keys)
if not batch:
raise ValueError("control exchange batch must not be empty")
if not required and any(
@ -311,37 +393,100 @@ class ReviewedApplicationMqttTransport:
for envelope in batch
):
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)
if len(set(operation_keys)) != len(operation_keys):
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):
raise ValueError("control exchange batch exceeds the reviewed request allowlist")
allowed_non_barrier_responses = (
frozenset({MODELING_STATUS_RESPONSE_TOPIC})
if any(
envelope.topic == "lixel/application/request/modeling_status" for envelope in batch
expectations = tuple(self._expectation_for_envelope(envelope) for envelope in batch)
pending = {
expectation.correlation_key: expectation
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:
if self._consumed_operation_keys.intersection(operation_keys):
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:
raise ApplicationMqttTransportError("control transport is not ready")
stale_response = bool(self._messages)
if stale_response:
self._poison_locked("stale response preceded the request batch")
raise ApplicationMqttTransportError(
"control transport is not ready",
reason_code="transport_not_ready",
)
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)
if stale_response:
self.close()
raise ApplicationCommandOutcomeUnknown(
"stale control response makes the next command outcome ambiguous"
)
client = self._require_client()
publish_mids: set[int] = set()
@ -367,12 +512,17 @@ class ReviewedApplicationMqttTransport:
deadline = self._monotonic() + self._exchange_timeout_seconds
def complete() -> bool:
self._drain_responses(required, allowed_non_barrier_responses, responses)
self._drain_responses(pending, responses)
with self._lock:
return publish_mids <= self._completed_publish_mids and required <= responses.keys()
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:
self._correlated_responses += len(responses)
return responses
@ -422,9 +572,15 @@ class ReviewedApplicationMqttTransport:
client = self._client
if client is not None:
try:
if self._subscribed:
with self._lock:
accepted_group_count = self._subscription_group_index
if accepted_group_count:
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()
except (OSError, RuntimeError, ValueError):
@ -483,6 +639,7 @@ class ReviewedApplicationMqttTransport:
qos2_completions=self._qos2_completions,
correlated_responses=self._correlated_responses,
ignored_known_responses=self._ignored_known_responses,
late_known_responses=self._late_known_responses,
device_status_reports=self._device_status_reports,
system_error_reports=self._system_error_reports,
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_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(
self,
required: frozenset[str],
allowed_non_barrier: frozenset[str],
pending: dict[
tuple[str, str, ModelingAction | None],
_ApplicationResponseExpectation,
],
responses: dict[str, bytes],
) -> None:
ambiguous_message: str | None = None
ambiguous_reason_code: str | None = None
with self._lock:
while self._messages:
topic, payload = self._messages.popleft()
if topic not in required:
if topic in allowed_non_barrier:
self._ignored_known_responses += 1
continue
self._poison_locked("unexpected response made correlation ambiguous")
ambiguous_message = "unexpected control response made command outcome ambiguous"
try:
observed = decode_application_message_identity(payload)
except ApplicationBootstrapError:
self._poison_locked("response identity decoding failed")
ambiguous_message = (
"control response identity could not be decoded safely"
)
ambiguous_reason_code = "response_identity_decode_failed"
break
if topic in responses:
self._poison_locked("duplicate required response made correlation ambiguous")
ambiguous_message = "duplicate control response made command outcome ambiguous"
break
responses[topic] = payload
if ambiguous_message is not None:
self.close()
raise ApplicationCommandOutcomeUnknown(ambiguous_message)
def _discard_allowed_responses(self, allowed: frozenset[str]) -> None:
ambiguous_message: str | None = None
with self._lock:
while self._messages:
topic, _payload = self._messages.popleft()
if topic in allowed:
self._ignored_known_responses += 1
continue
self._poison_locked("unexpected response during retained control hold")
ambiguous_message = (
"unexpected control response during retained control-session hold"
observed_modeling_action: ModelingAction | None = None
if topic == MODELING_RESPONSE_TOPIC:
try:
observed_modeling_action = decode_modeling_response(
payload
).action
except ModelingProtocolError:
self._poison_locked("modeling response action decoding failed")
ambiguous_message = (
"control modeling response could not be decoded safely"
)
ambiguous_reason_code = "modeling_response_decode_failed"
break
key = (topic, observed.session_id, observed_modeling_action)
expectation = pending.get(key)
known_operations = self._response_operations_by_correlation.get(key, [])
matching_operations = [
operation_key
for operation_key in known_operations
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
if ambiguous_message is not None:
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:
with self._lock:
@ -777,7 +1030,10 @@ class ReviewedApplicationMqttTransport:
with self._lock:
self._state = "failed"
self.close()
error = ApplicationMqttTransportError(message)
error = ApplicationMqttTransportError(
message,
reason_code=self._transport_failure_reason_code(message),
)
if cause is not None:
raise error from cause
raise error
@ -787,7 +1043,8 @@ class ReviewedApplicationMqttTransport:
self._poison_locked(message)
self.close()
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:
raise error from cause
@ -799,5 +1056,33 @@ class ReviewedApplicationMqttTransport:
def _require_client(self) -> mqtt.Client:
client = self._client
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
@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
import logging
import threading
import time
from collections.abc import Callable
@ -57,6 +58,8 @@ ApplicationControlPhase = Literal[
"failed",
]
logger = logging.getLogger(__name__)
TransportFactory = Callable[[str], ReviewedApplicationMqttTransport]
@ -108,7 +111,6 @@ class InteractiveApplicationControlSession:
self._project_requested = threading.Event()
self._start_requested = threading.Event()
self._stop_requested = threading.Event()
self._standby_confirmed = threading.Event()
self._cancel_requested = False
self._start_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._transport_snapshot: dict[str, object] | None = None
self._outcome_unknown = False
self._run_generation = 0
def open(
self,
@ -128,21 +131,28 @@ class InteractiveApplicationControlSession:
# Validate every explicit confirmation before creating a network owner.
confirmation.checklist(ModelingAction.START)
with self._lock:
if self._phase in {"completed", "closed"} or (
self._phase == "failed"
and self._failure is not None
and self._failure.get("safe_to_retry") is True
):
if self._phase != "idle" and self._can_open_locked():
self._reset_locked()
if self._phase != "idle":
raise ApplicationAcceptanceError(
"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._timezone_name = timezone_name
self._set_phase_locked("connecting")
self._run_generation += 1
generation = self._run_generation
self._thread = threading.Thread(
target=self._run,
args=(generation,),
name="xgrids-k1-canonical-control",
daemon=True,
)
@ -189,16 +199,6 @@ class InteractiveApplicationControlSession:
self._stop_requested.set()
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]:
with self._lock:
if self._phase not in {
@ -234,33 +234,27 @@ class InteractiveApplicationControlSession:
return {
"mode": "interactive-canonical",
"state": phase,
"control_socket_open": phase
not in {"idle", "completed", "closed", "failed"},
"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
),
"control_socket_open": phase not in {"idle", "completed", "closed", "failed"},
"can_open": self._can_open_locked(),
"can_enter_workspace": phase == "connection-ready",
"can_prepare_project": phase == "workspace-ready",
"can_start": phase == "project-ready",
"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(),
"scripted_transitions": False,
"automatic_retry": False,
"outcome_unknown": self._outcome_unknown,
"failure": dict(self._failure) if self._failure is not None else None,
"dialogue": (
dict(self._dialogue_snapshot)
if self._dialogue_snapshot is not None
else None
dict(self._dialogue_snapshot) if self._dialogue_snapshot is not None else None
),
"transport": transport_snapshot,
}
def _run(self) -> None:
def _run(self, generation: int) -> None:
executor: PhysicalAcceptanceDialogueExecutor | None = None
transport: ReviewedApplicationMqttTransport | None = None
try:
@ -319,64 +313,195 @@ class InteractiveApplicationControlSession:
executor.maintain_active_until_stop_requested(self._stop_requested.is_set)
stop_confirmation = self._stop_request()
stop_permit = PhysicalAcceptancePermit(
stop_confirmation.checklist(ModelingAction.STOP)
)
stop_permit = PhysicalAcceptancePermit(stop_confirmation.checklist(ModelingAction.STOP))
self._set_phase("stopping")
executor.execute_canonical_stop(
self._stop_command(authority, binding),
stop_permit,
)
self._set_phase("awaiting-standby-confirmation")
executor.maintain_post_stop_until_standby_confirmed(
self._standby_confirmed.is_set
)
executor.maintain_post_stop_until_standby()
self._set_phase("completed")
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:
if self._cancel_requested:
self._set_phase_locked("closed")
else:
publish_attempts = 0
if transport is not None:
observed_attempts = transport.snapshot().as_dict().get(
"publish_attempts",
0,
failed_phase = self._phase
transport_created = transport is not None
dialogue_executor_created = executor is not None
publish_attempts = (
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(
observed_attempts,
bool,
):
publish_attempts = observed_attempts
outcome_unknown = isinstance(
exc, ApplicationCommandOutcomeUnknown
) or self._phase in {
"start-requested",
"initializing",
"scanning",
"stop-requested",
"stopping",
"awaiting-standby-confirmation",
}
if dialogue_snapshot_available
else (False if not dialogue_executor_created else None)
)
diagnostic_snapshot_unavailable = [
component
for component, created, available in (
(
"transport",
transport_created,
transport_snapshot_available,
),
(
"dialogue",
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 = {
"code": type(exc).__name__,
"reason_code": failure_reason_code,
"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._dialogue_snapshot = dialogue_snapshot or None
self._transport_snapshot = transport_snapshot or None
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:
final_dialogue_snapshot: dict[str, object] | None = None
final_transport_snapshot: dict[str, object] | None = None
if executor is not None:
with self._lock:
self._dialogue_snapshot = executor.snapshot()
captured_dialogue, dialogue_available = self._executor_snapshot_safely(executor)
if dialogue_available:
final_dialogue_snapshot = captured_dialogue
if transport is not None:
try:
with self._lock:
self._transport_snapshot = transport.snapshot().as_dict()
captured_transport, transport_available = self._transport_snapshot_safely(
transport
)
if transport_available:
final_transport_snapshot = captured_transport
finally:
transport.close()
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
def _start_command(
@ -427,6 +552,62 @@ class InteractiveApplicationControlSession:
with self._lock:
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:
self._phase = phase
@ -436,18 +617,17 @@ class InteractiveApplicationControlSession:
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]:
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 (
dict(self._transport_snapshot)
if self._transport_snapshot is not None
@ -464,9 +644,20 @@ class InteractiveApplicationControlSession:
"workspace-ready": "prepare-project",
"project-ready": "start",
"scanning": "stop",
"awaiting-standby-confirmation": "confirm-steady-green",
}.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:
self._phase = "idle"
self._host = None
@ -477,7 +668,6 @@ class InteractiveApplicationControlSession:
self._project_requested = threading.Event()
self._start_requested = threading.Event()
self._stop_requested = threading.Event()
self._standby_confirmed = threading.Event()
self._cancel_requested = False
self._start_confirmation = None
self._stop_confirmation = None

View File

@ -6,6 +6,7 @@ from dataclasses import dataclass, field
from typing import Literal
from k1link.device_plugins.xgrids_k1.protocol.application_bootstrap import (
REVIEWED_PROFILE_ATTESTATION_FAILURE,
ApplicationControlAuthority,
LiveDeviceControlBinding,
)
@ -217,9 +218,7 @@ class LiveModelingControlSafety:
"DeviceInfo binding does not match the live K1 status stream"
)
if not binding.ready_for_reviewed_profile:
raise ModelingControlSafetyError(
"live DeviceInfo does not attest the reviewed activated FW 3.0.2 profile"
)
raise ModelingControlSafetyError(REVIEWED_PROFILE_ATTESTATION_FAILURE)
if report.session_state is not required_state:
raise ModelingControlSafetyError(
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)
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]:
"""Iterate supported protobuf fields without recursion or unbounded allocation."""
if max_fields < 1:

View File

@ -1,5 +1,6 @@
from __future__ import annotations
import logging
from collections.abc import Callable, Mapping
from dataclasses import dataclass
from datetime import UTC, datetime
@ -16,12 +17,14 @@ from missioncore_plugin_sdk.v0alpha2 import (
RuntimeHealthSnapshot,
RuntimePluginDescriptor,
)
from pydantic import BaseModel, ConfigDict, Field
from pydantic import BaseModel, ConfigDict, Field, ValidationError
from k1link.sessions.plugin_contract import ObservationRuntimeContribution
STATE_READ_ACTION_ID = "state.read"
logger = logging.getLogger(__name__)
class DevicePluginActionRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
@ -162,13 +165,28 @@ class InProcessDevicePluginRuntime:
f"{invocation.action_id}"
)
output = await self._adapter.invoke(invocation)
return RuntimeActionResult(
invocation_id=invocation.invocation_id,
plugin_id=invocation.plugin_id,
action_id=invocation.action_id,
completed_at=datetime.now(UTC),
output=dict(output),
)
try:
return RuntimeActionResult(
invocation_id=invocation.invocation_id,
plugin_id=invocation.plugin_id,
action_id=invocation.action_id,
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:
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 k1link.device_plugins.xgrids_k1 import cli
from k1link.device_plugins.xgrids_k1.cli import app
runner = CliRunner()
@ -25,6 +26,26 @@ def test_doctor_json() -> None:
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:
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)]
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:
dispatcher = DevicePluginDispatcher(
[_in_process_runtime(XgridsK1PluginFacade(FakeXgridsService()))]
@ -404,7 +435,7 @@ def test_facade_validates_payload_before_calling_service() -> None:
"compatibility_attestation": {
"firmware_version": "3.0.2",
"topology": "direct-lan",
"operator_confirmed": True,
"verification": "live-device-info",
},
},
"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 (
ProtobufWireError,
decode_zigzag64,
encode_zigzag64,
iter_fields,
)
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(1) == -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"):
list(iter_fields(b"\x0a\x02\x01"))
with pytest.raises(ProtobufWireError, match="unsupported"):

View File

@ -77,7 +77,7 @@ def test_validation_errors_do_not_echo_sensitive_request_values(
"compatibility_attestation": {
"firmware_version": "3.0.2",
"topology": "direct-lan",
"operator_confirmed": True,
"verification": "live-device-info",
},
}
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(
firmware_version="3.0.2",
topology="direct-lan",
operator_confirmed=True,
verification="live-device-info",
)
PRIMARY_TEST_CREDENTIAL = "x" * 24
SECONDARY_TEST_CREDENTIAL = "y" * 24
@ -120,12 +120,11 @@ class FakeInteractiveControlSession:
self.state = "workspace-ready"
self.start_projects: list[str] = []
self.stop_calls = 0
self.confirm_calls = 0
def snapshot(self) -> dict[str, object]:
return {
"state": self.state,
"can_confirm_standby": self.state == "awaiting-standby-confirmation",
"can_confirm_standby": False,
}
def open_project_prompt(self) -> dict[str, object]:
@ -147,12 +146,6 @@ class FakeInteractiveControlSession:
self.state = "awaiting-standby-confirmation"
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]:
self.state = "closed"
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["acquisition"]["state"] == "prepared"
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["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",
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(
@ -247,7 +265,8 @@ def test_facade_arms_bounded_shadow_lease_without_installing_publish_transport(
service._compatibility_attestation = { # noqa: SLF001
"firmware_version": "3.0.2",
"topology": "direct-lan",
"basis": "operator-attested",
"verification": "live-device-info",
"basis": "selected-profile-live-device-info-required",
"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
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,
) -> None:
loader = FakeApplicationAuthorityLoader()
@ -405,21 +424,82 @@ def test_plugin_commanded_acquisition_keeps_start_and_stop_as_explicit_actions(
physical_acceptance=PHYSICAL_ACCEPTANCE,
)
)
stop_operation = stopping["last_operation"]
assert control.stop_calls == 1
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(
acquisition_id=acquisition_id,
mode="graceful",
operator_confirmed=True,
operation_id=stop_operation["operation_id"],
physical_acceptance=PHYSICAL_ACCEPTANCE,
)
)
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.confirm_calls == 1
assert completed["acquisition"]["state"] == "completed"
assert control.state == "completed"
assert recovered["acquisition"]["state"] == "failed"
assert recovered["application_control_session"]["state"] == "completed"
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,
) -> None:
service, _ = service_with_fake_runtime(tmp_path)
@ -1267,7 +1347,7 @@ def test_exact_profile_is_inactive_until_explicit_operator_attestation(
"profile_id": None,
"decision": "unknown",
"permitted_mode": "evidence-only",
"firmware_claim": "exact-3.0.2-profile-not-attested",
"firmware_claim": "exact-3.0.2-profile-not-selected",
"attestation": None,
"vendor_writes_enabled": False,
"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"]["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"] == (
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 connected["k1_ip"] == "192.168.1.20"
assert connected["connection_mode"] == "bridge"
assert PRIMARY_TEST_CREDENTIAL not in str(connected)
provision_operations = [
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"}
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(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,

View File

@ -4,6 +4,7 @@ import hashlib
from collections.abc import Collection, Sequence
import pytest
from pydantic import JsonValue, TypeAdapter
from k1link.device_plugins.xgrids_k1.protocol.application_acceptance import (
ApplicationAcceptanceError,
@ -11,6 +12,7 @@ from k1link.device_plugins.xgrids_k1.protocol.application_acceptance import (
PhysicalAcceptanceChecklist,
PhysicalAcceptanceDialogueExecutor,
PhysicalAcceptancePermit,
ScanInitializationTimeout,
)
from k1link.device_plugins.xgrids_k1.protocol.application_bootstrap import (
DEVICE_CONFIG_RESPONSE_TOPIC,
@ -18,9 +20,6 @@ from k1link.device_plugins.xgrids_k1.protocol.application_bootstrap import (
ShadowApplicationBootstrapOrchestrator,
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 (
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)
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(
(
_text(2, "V3.0.2-20260101-release"),
_text(2, "V3.0.2_20250624.122658"),
_text(3, "V3.0.2"),
_text(6, "LixelKity K1"),
_text(6, device_model),
_text(7, "K1SERIAL01"),
_text(8, "K1"),
_text(8, device_type),
)
)
working_status = _uint(1, 1)
@ -93,7 +97,13 @@ def _modeling_response(action: ModelingAction) -> bytes:
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.maintain_calls: list[tuple[float, tuple[str, ...]]] = []
self.clock = clock
@ -102,22 +112,32 @@ class SyntheticAcceptanceTransport:
self.post_stop_maintain_calls = 0
self.start_emitted = False
self.stop_emitted = False
self.device_model = device_model
self.device_type = device_type
def exchange_batch_once(
self,
envelopes: Sequence[OneShotPublishEnvelope],
*,
required_response_topics: Collection[str],
required_response_operation_keys: Collection[str],
) -> dict[str, bytes]:
self.batches.append(tuple(envelope.operation_key for envelope in envelopes))
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 = (
ModelingAction.START
if envelopes[0].operation_key == "modeling:start"
if modeling_operation == "modeling:start"
else ModelingAction.STOP
)
responses[MODELING_RESPONSE_TOPIC] = _modeling_response(action)
responses[modeling_operation] = _modeling_response(action)
if action is ModelingAction.START:
self.start_emitted = True
else:
@ -134,7 +154,11 @@ class SyntheticAcceptanceTransport:
if ordinal == 1
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:
session = (
f":{message_type}" if ordinal <= 3 else f"{VENDOR_DEVICE_ID}:{message_type}"
@ -142,9 +166,8 @@ class SyntheticAcceptanceTransport:
if message_type == "DeviceConfigRequest":
session += ":Publish_Proto_DeviceConfig_SetTime"
response = _generic_response(session)
for topic in required_response_topics:
if topic.endswith(_response_suffix(message_type)):
responses[topic] = response
if envelope.operation_key in required_response_operation_keys:
responses[envelope.operation_key] = response
return responses
def maintain_open_for(
@ -178,18 +201,6 @@ class SyntheticAcceptanceTransport:
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:
return PhysicalAcceptanceChecklist(
action=action,
@ -232,7 +243,14 @@ def test_canonical_session_owns_start_active_scan_stop_and_save_boundary() -> No
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_checkpoint = executor.wait_for_operator_checkpoint(
"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)
standby_checks = iter((False, False, True))
executor.maintain_post_stop_until_standby_confirmed(
lambda: next(standby_checks)
)
executor.maintain_post_stop_until_standby()
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(transport.maintain_calls) == 214
assert [len(batch) for batch in transport.batches] == [1, 5, 1, 3, 1, 1, 2, 1]
assert len(transport.maintain_calls) == 212
assert set(transport.maintain_calls) == {
(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()["dialogue_stage"] == "standby-confirmed"
response_evidence = executor.snapshot()["response_evidence"]
assert isinstance(response_evidence, tuple)
assert isinstance(response_evidence, list)
assert len(response_evidence) == 13
assert response_evidence[0]["operation_key"] == "bootstrap:1:DeviceInfoRequest"
assert response_evidence[-1]["operation_key"] == "modeling:stop"
assert all("payload" not in item for item in response_evidence)
assert APPLICATION_KEY 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:
@ -347,14 +469,15 @@ def test_bootstrap_correlation_failure_records_only_redacted_response_evidence()
self,
envelopes: Sequence[OneShotPublishEnvelope],
*,
required_response_topics: Collection[str],
required_response_operation_keys: Collection[str],
) -> dict[str, bytes]:
responses = super().exchange_batch_once(
envelopes,
required_response_topics=required_response_topics,
required_response_operation_keys=required_response_operation_keys,
)
if DEVICE_CONFIG_RESPONSE_TOPIC in responses:
responses[DEVICE_CONFIG_RESPONSE_TOPIC] = _generic_response(
operation_key = "bootstrap:4:DeviceConfigRequest"
if operation_key in responses:
responses[operation_key] = _generic_response(
f"{VENDOR_DEVICE_ID}:wrong-session"
)
return responses
@ -380,10 +503,11 @@ def test_bootstrap_correlation_failure_records_only_redacted_response_evidence()
"phase": "bootstrap",
"operation_key": "bootstrap:4:DeviceConfigRequest",
"response_topic": DEVICE_CONFIG_RESPONSE_TOPIC,
"reason_code": "response_session_mismatch",
"reason": "application response session correlation failed",
}
evidence = snapshot["response_evidence"]
assert isinstance(evidence, tuple)
assert isinstance(evidence, list)
assert evidence[-1]["payload_sha256"] == hashlib.sha256(
_generic_response(f"{VENDOR_DEVICE_ID}:wrong-session")
).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 APPLICATION_KEY 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:

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"
@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("shutil.which", return_value="/usr/bin/security")
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)
@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.stdin.isatty", return_value=True)
@patch("platform.system", return_value="Darwin")

View File

@ -1,5 +1,8 @@
from __future__ import annotations
import json
from pathlib import Path
import pytest
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"
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:
@ -47,11 +58,11 @@ def _binding(**changes: object) -> LiveDeviceControlBinding:
values = {
"vendor_device_id": VENDOR_DEVICE_ID,
"device_serial": "K1SERIAL01",
"software_version": "V3.0.2-20260101-release",
"system_version": "V3.0.2",
"device_model": "LixelKity K1",
"device_type": "K1",
"is_activated": True,
"software_version": CAPTURE_FACTS["software_version"],
"system_version": CAPTURE_FACTS["system_version"],
"device_model": CAPTURE_FACTS["device_model"],
"device_type": CAPTURE_FACTS["platform_type"],
"is_activated": CAPTURE_FACTS["is_activated"],
}
values.update(changes)
return LiveDeviceControlBinding(**values) # type: ignore[arg-type]
@ -62,13 +73,13 @@ def _device_info_response(*, session_id: str = ":DeviceInfoRequest") -> bytes:
base_info = b"".join(
(
_text(1, "2026-01-01T00:00:00"),
_text(2, "V3.0.2-20260101-release"),
_text(3, "V3.0.2"),
_text(2, CAPTURE_FACTS["software_version"]),
_text(3, CAPTURE_FACTS["system_version"]),
_text(4, "V1.2.3"),
_text(5, "V1.0"),
_text(6, "LixelKity K1"),
_text(6, CAPTURE_FACTS["device_model"]),
_text(7, "K1SERIAL01"),
_text(8, "K1"),
_text(8, CAPTURE_FACTS["platform_type"]),
)
)
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:
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.binding.ready_for_reviewed_profile
assert response.binding.matches_reviewed_device
assert response.binding.matches_reviewed_firmware
assert response.binding.is_activated
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"),
_authority(),
)
with pytest.raises(ApplicationBootstrapError, match="does not attest"):
build_shadow_bootstrap(
_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:
orchestrator = ShadowApplicationBootstrapOrchestrator(
_authority(),
@ -202,35 +265,37 @@ def test_response_barrier_orchestrator_emits_each_retained_batch_once() -> None:
assert [request.ordinal for request in identity] == [1]
with pytest.raises(ApplicationBootstrapError, match="already issued"):
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(
initial_reads[1].response_topic,
_generic_response(initial_reads[1].session_id),
"bootstrap:1:DeviceInfoRequest",
_device_info_response(),
)
preparation = orchestrator.next_batch()
assert [request.ordinal for request in preparation] == [4, 5, 6]
device_info = preparation[1]
initial_reads = orchestrator.next_batch()
assert [request.ordinal for request in initial_reads] == [2, 3, 4, 5, 6]
assert [request.response_required for request in initial_reads] == [
False,
True,
True,
True,
True,
]
device_info = initial_reads[3]
orchestrator.accept_response(
device_info.response_topic,
"bootstrap:5:DeviceInfoRequest",
_device_info_response(session_id=device_info.session_id),
)
with pytest.raises(ApplicationBootstrapError, match="already issued"):
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(
request.response_topic,
f"bootstrap:{request.ordinal}:{request.message_type}",
_generic_response(request.session_id),
)
ntrip = orchestrator.next_batch()
assert [request.ordinal for request in ntrip] == [7]
orchestrator.accept_response(
ntrip[0].response_topic,
"bootstrap:7:GetNtripProfileRequest",
_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"
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()
assert snapshot.bootstrap_complete
@ -264,7 +332,7 @@ def test_orchestrator_rejects_unexpected_response_without_advancing() -> None:
with pytest.raises(ApplicationBootstrapError, match="not required"):
orchestrator.accept_response(
"lixel/application/response/get_rtk_advance",
"bootstrap:3:GetRtkAdvanceRequest",
_generic_response(":GetRtkAdvanceRequest"),
)

View File

@ -10,11 +10,14 @@ import pytest
from k1link.device_plugins.xgrids_k1.protocol.application_bootstrap import (
DEVICE_INFO_RESPONSE_TOPIC,
GET_RTK_ADVANCE_REQUEST_TOPIC,
GET_RTK_ADVANCE_RESPONSE_TOPIC,
MODELING_STATUS_RESPONSE_TOPIC,
LiveDeviceControlBinding,
)
from k1link.device_plugins.xgrids_k1.protocol.application_mqtt import (
CONTROL_SUBSCRIPTION_GROUPS,
MODELING_RESPONSE_TOPIC,
SYSTEM_ERROR_TOPIC,
ApplicationCommandOutcomeUnknown,
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 (
MODELING_STATE_BASE,
SYSTEM_ERROR_STATE_BASE,
ModelingAction,
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"
DEVICE_SERIAL = "K1SERIAL01"
APPLICATION_KEY = "11111111-2222-3333-4444-555555555555"
class FakeReasonCode:
@ -70,6 +78,7 @@ class FakeControlClient:
self.unsubscribe_calls: list[list[str]] = []
self.disconnect_calls = 0
self.next_mid = 20
self.connect_timeout = 5.0
def connect(self, host: str, port: int, keepalive: int) -> mqtt.MQTTErrorCode:
self.connect_calls.append((host, port, keepalive))
@ -94,7 +103,7 @@ class FakeControlClient:
self.next_mid += 1
if self.emit_exchange:
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)
def loop(self, timeout: float) -> mqtt.MQTTErrorCode:
@ -131,11 +140,32 @@ class FakeControlClient:
return mqtt.MQTT_ERR_SUCCESS
def _envelope(operation_key: str = "bootstrap:1:DeviceInfoRequest") -> OneShotPublishEnvelope:
payload = b"synthetic-reviewed-request"
def _application_message(
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(
operation_key=operation_key,
topic="lixel/application/request/device_info",
topic=topic,
payload=payload,
payload_sha256=hashlib.sha256(payload).hexdigest(),
payload_bytes=len(payload),
@ -144,10 +174,15 @@ def _envelope(operation_key: str = "bootstrap:1:DeviceInfoRequest") -> OneShotPu
)
def _modeling_status_envelope() -> OneShotPublishEnvelope:
payload = b"synthetic-reviewed-modeling-status"
def _modeling_status_envelope(
operation_key: str = "dialogue:12:ModelingStatusRequest",
) -> OneShotPublishEnvelope:
payload = _application_message(
f"{VENDOR_DEVICE_ID}:ModelingStatusRequest",
device_id=VENDOR_DEVICE_ID,
)
return OneShotPublishEnvelope(
operation_key="dialogue:12:ModelingStatusRequest",
operation_key=operation_key,
topic="lixel/application/request/modeling_status",
payload=payload,
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:
encoded = bytearray()
while value > 0x7F:
@ -203,7 +265,7 @@ def _binding() -> LiveDeviceControlBinding:
software_version="V3.0.2-20260101-release",
system_version="V3.0.2",
device_model="LixelKity K1",
device_type="K1",
device_type="A4",
is_activated=True,
)
@ -234,21 +296,24 @@ def test_acceptance_transport_connects_once_and_completes_one_qos2_exchange() ->
opened = transport.open().as_dict()
responses = transport.exchange_batch_once(
[_envelope()],
required_response_topics={DEVICE_INFO_RESPONSE_TOPIC},
required_response_operation_keys={"bootstrap:1:DeviceInfoRequest"},
)
transport.close()
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.publish_calls == [
(
"lixel/application/request/device_info",
b"synthetic-reviewed-request",
_application_message(":DeviceInfoRequest"),
2,
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["automatic_reconnect"] 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.exchange_batch_once(
[_envelope()],
required_response_topics={DEVICE_INFO_RESPONSE_TOPIC},
required_response_operation_keys={"bootstrap:1:DeviceInfoRequest"},
)
with pytest.raises(ApplicationCommandOutcomeUnknown, match="already consumed"):
transport.exchange_batch_once(
[_envelope()],
required_response_topics={DEVICE_INFO_RESPONSE_TOPIC},
required_response_operation_keys={"bootstrap:1:DeviceInfoRequest"},
)
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:
clock = FakeClock()
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(
[_modeling_status_envelope()],
required_response_topics=(),
required_response_operation_keys=(),
)
transport.maintain_open_for(
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"):
transport.exchange_batch_once(
[_envelope()],
required_response_topics={DEVICE_INFO_RESPONSE_TOPIC},
required_response_operation_keys={"bootstrap:1:DeviceInfoRequest"},
)
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"):
transport.exchange_batch_once(
[_envelope()],
required_response_topics={DEVICE_INFO_RESPONSE_TOPIC},
required_response_operation_keys={"bootstrap:1:DeviceInfoRequest"},
)
assert len(fake.publish_calls) == 1

View File

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

View File

@ -1,5 +1,6 @@
from __future__ import annotations
import logging
import threading
import time
from dataclasses import dataclass
@ -37,6 +38,11 @@ class FakeTransportSnapshot:
def as_dict(self) -> dict[str, object]:
return {
"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_project_bound": not self.ready,
"automatic_retry": False,
@ -71,8 +77,8 @@ class FakeExecutor:
device_serial="serial-id",
software_version="3.0.2",
system_version="3.0.2",
device_model="K1",
device_type="scanner",
device_model="LixelKity K1",
device_type="A4",
is_activated=True,
)
@ -124,13 +130,17 @@ class FakeExecutor:
self.transport.ready = True
return object()
def maintain_post_stop_until_standby_confirmed(self, observed: Any) -> None:
self.records.append("wait:steady-green")
while not observed():
threading.Event().wait(0.005)
def maintain_post_stop_until_standby(self) -> None:
self.records.append("wait:device-standby")
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:
@ -156,7 +166,7 @@ def _wait_phase(
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,
) -> None:
FakeExecutor.records = []
@ -195,12 +205,9 @@ def test_every_canonical_stage_requires_a_separate_operator_event(
assert FakeExecutor.records[-1] == "wait:stop"
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")
assert completed["can_confirm_standby"] is False
assert completed["pending_operator_action"] is None
assert loader.calls == 1
assert completed["automatic_retry"] 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",
"wait: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
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:
executable = tmp_path / "buffered-tail-ffmpeg"
executable.write_text(
@ -289,6 +306,76 @@ def test_acquisition_records_without_browser_and_source_switch_seals_epochs(
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(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
@ -501,7 +588,8 @@ def test_service_publishes_two_dynamic_camera_rows_and_stale_stop_is_safe(
service._compatibility_attestation = {
"firmware_version": "3.0.2",
"topology": "direct-lan",
"basis": "operator-attested",
"verification": "live-device-info",
"basis": "selected-profile-live-device-info-required",
"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["scope"]["vendor"] == "XGRIDS"
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"]["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"]["vendor_writes_enabled"] is False
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"
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",
"system_version": "V3.0.2",
"device_model": "LixelKity K1",
"device_type": "K1",
"device_type": "A4",
"is_activated": True,
}
values.update(changes)