) : (
@@ -282,15 +307,6 @@ export function K1AcquisitionPipeline({
: "Остановка завершает только локальный приём и сохранение. Физическое состояние сканера остаётся неизвестным."
: "Активного источника сейчас нет."}
- {control?.can_confirm_standby ? (
-
- ) : null}
-
Пароль передаётся только локальному сервису, не сохраняется в браузере и удаляется из формы после успеха.
+
Наличие адреса подтверждает результат предыдущей настройки, но не текущее соединение. Пароль передаётся только локальному сервису, не сохраняется в браузере и удаляется из формы после успеха.
diff --git a/plugins/xgrids-k1/frontend/src/components/K1SpatialControls.tsx b/plugins/xgrids-k1/frontend/src/components/K1SpatialControls.tsx
index 379a29d..cbb9d93 100644
--- a/plugins/xgrids-k1/frontend/src/components/K1SpatialControls.tsx
+++ b/plugins/xgrids-k1/frontend/src/components/K1SpatialControls.tsx
@@ -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 ? "Остановить устройство и запись" : "Остановить локальный приём"}
- {state?.application_control_session?.can_confirm_standby ? (
- void confirmStoppedAtSteadyGreen()}
- >
- Индикатор постоянно зелёный — завершить запись
-
- ) : null}
);
}
diff --git a/plugins/xgrids-k1/frontend/src/configuration.ts b/plugins/xgrids-k1/frontend/src/configuration.ts
new file mode 100644
index 0000000..39525ef
--- /dev/null
+++ b/plugins/xgrids-k1/frontend/src/configuration.ts
@@ -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> = [
+ {
+ 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> = [
+ {
+ 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> = [
+ {
+ value: "none",
+ label: "Без RTK",
+ description: "RTK/NTRIP читаются только для обнаружения возможностей и не включаются.",
+ },
+ {
+ value: "rtk",
+ label: "Использовать RTK",
+ description: "Недоступно до отдельного захвата настройки и физической приёмки.",
+ disabled: true,
+ },
+ {
+ value: "ppk",
+ label: "Использовать PPK",
+ description: "Недоступно до отдельного захвата настройки и физической приёмки.",
+ disabled: true,
+ },
+];
diff --git a/plugins/xgrids-k1/frontend/src/lifecycle.ts b/plugins/xgrids-k1/frontend/src/lifecycle.ts
index 163bb21..e4647f8 100644
--- a/plugins/xgrids-k1/frontend/src/lifecycle.ts
+++ b/plugins/xgrids-k1/frontend/src/lifecycle.ts
@@ -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";
diff --git a/plugins/xgrids-k1/frontend/src/messages.ts b/plugins/xgrids-k1/frontend/src/messages.ts
index 5a1d4cb..0cf447e 100644
--- a/plugins/xgrids-k1/frontend/src/messages.ts
+++ b/plugins/xgrids-k1/frontend/src/messages.ts
@@ -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, "неуказанная ошибка"],
[
diff --git a/plugins/xgrids-k1/frontend/src/operatorIntentGeneration.ts b/plugins/xgrids-k1/frontend/src/operatorIntentGeneration.ts
new file mode 100644
index 0000000..d80cf19
--- /dev/null
+++ b/plugins/xgrids-k1/frontend/src/operatorIntentGeneration.ts
@@ -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(
+ assertCurrent: () => void,
+ operation: () => Promise,
+): Promise {
+ assertCurrent();
+ const result = await operation();
+ assertCurrent();
+ return result;
+}
diff --git a/plugins/xgrids-k1/frontend/src/presentation.ts b/plugins/xgrids-k1/frontend/src/presentation.ts
index a16b495..7611d2f 100644
--- a/plugins/xgrids-k1/frontend/src/presentation.ts
+++ b/plugins/xgrids-k1/frontend/src/presentation.ts
@@ -9,7 +9,7 @@ const phaseLabels: Record = {
device_selected: "Устройство выбрано",
provisioning: "Передача настроек Wi‑Fi",
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";
}
diff --git a/plugins/xgrids-k1/frontend/src/styles.css b/plugins/xgrids-k1/frontend/src/styles.css
index a8e2c6a..b23944d 100644
--- a/plugins/xgrids-k1/frontend/src/styles.css
+++ b/plugins/xgrids-k1/frontend/src/styles.css
@@ -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;
}
diff --git a/plugins/xgrids-k1/frontend/src/useXgridsK1Runtime.ts b/plugins/xgrids-k1/frontend/src/useXgridsK1Runtime.ts
index e1592cb..ece7562 100644
--- a/plugins/xgrids-k1/frontend/src/useXgridsK1Runtime.ts
+++ b/plugins/xgrids-k1/frontend/src/useXgridsK1Runtime.ts
@@ -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 = {
+ 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 = {
+ 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 {
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((resolve) => {
- window.setTimeout(resolve, CONTROL_STATE_READ_INTERVAL_MS);
- });
+ await awaitWhileIntentCurrent(
+ assertOperatorIntentCurrent,
+ () => new Promise((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(null);
const [error, setError] = useState(null);
const [latencyHistory, setLatencyHistory] = useState([]);
- 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) => {
- 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,
diff --git a/plugins/xgrids-k1/profile_loader.py b/plugins/xgrids-k1/profile_loader.py
index a209ea2..8db8abb 100644
--- a/plugins/xgrids-k1/profile_loader.py
+++ b/plugins/xgrids-k1/profile_loader.py
@@ -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")
diff --git a/plugins/xgrids-k1/profiles/fw-3.0.2/direct-lan.v1.json b/plugins/xgrids-k1/profiles/fw-3.0.2/direct-lan.v1.json
index c06f877..5d0e29f 100644
--- a/plugins/xgrids-k1/profiles/fw-3.0.2/direct-lan.v1.json
+++ b/plugins/xgrids-k1/profiles/fw-3.0.2/direct-lan.v1.json
@@ -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"
],
diff --git a/src/k1link/device_plugins/xgrids_k1/camera.py b/src/k1link/device_plugins/xgrids_k1/camera.py
index 1467747..2cc974f 100644
--- a/src/k1link/device_plugins/xgrids_k1/camera.py
+++ b/src/k1link/device_plugins/xgrids_k1/camera.py
@@ -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(
diff --git a/src/k1link/device_plugins/xgrids_k1/cli.py b/src/k1link/device_plugins/xgrids_k1/cli.py
index e8a0ac3..c3ff939 100644
--- a/src/k1link/device_plugins/xgrids_k1/cli.py
+++ b/src/k1link/device_plugins/xgrids_k1/cli.py
@@ -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 "
diff --git a/src/k1link/device_plugins/xgrids_k1/facade.py b/src/k1link/device_plugins/xgrids_k1/facade.py
index 4af7b52..5ee68ab 100644
--- a/src/k1link/device_plugins/xgrids_k1/facade.py
+++ b/src/k1link/device_plugins/xgrids_k1/facade.py
@@ -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."""
diff --git a/src/k1link/device_plugins/xgrids_k1/protocol/application_acceptance.py b/src/k1link/device_plugins/xgrids_k1/protocol/application_acceptance.py
index 1d231b9..b1064ce 100644
--- a/src/k1link/device_plugins/xgrids_k1/protocol/application_acceptance.py
+++ b/src/k1link/device_plugins/xgrids_k1/protocol/application_acceptance.py
@@ -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",
+ )
diff --git a/src/k1link/device_plugins/xgrids_k1/protocol/application_authority.py b/src/k1link/device_plugins/xgrids_k1/protocol/application_authority.py
index cf4d1d8..5fc3e3a 100644
--- a/src/k1link/device_plugins/xgrids_k1/protocol/application_authority.py
+++ b/src/k1link/device_plugins/xgrids_k1/protocol/application_authority.py
@@ -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()
diff --git a/src/k1link/device_plugins/xgrids_k1/protocol/application_bootstrap.py b/src/k1link/device_plugins/xgrids_k1/protocol/application_bootstrap.py
index 5ff4b45..21f122b 100644
--- a/src/k1link/device_plugins/xgrids_k1/protocol/application_bootstrap.py
+++ b/src/k1link/device_plugins/xgrids_k1/protocol/application_bootstrap.py
@@ -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,
diff --git a/src/k1link/device_plugins/xgrids_k1/protocol/application_mqtt.py b/src/k1link/device_plugins/xgrids_k1/protocol/application_mqtt.py
index 9362d76..06ccda0 100644
--- a/src/k1link/device_plugins/xgrids_k1/protocol/application_mqtt.py
+++ b/src/k1link/device_plugins/xgrids_k1/protocol/application_mqtt.py
@@ -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",
+ )
diff --git a/src/k1link/device_plugins/xgrids_k1/protocol/application_session.py b/src/k1link/device_plugins/xgrids_k1/protocol/application_session.py
index 04cda95..a1e57ec 100644
--- a/src/k1link/device_plugins/xgrids_k1/protocol/application_session.py
+++ b/src/k1link/device_plugins/xgrids_k1/protocol/application_session.py
@@ -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
diff --git a/src/k1link/device_plugins/xgrids_k1/protocol/modeling_safety.py b/src/k1link/device_plugins/xgrids_k1/protocol/modeling_safety.py
index 79399ad..5083fdb 100644
--- a/src/k1link/device_plugins/xgrids_k1/protocol/modeling_safety.py
+++ b/src/k1link/device_plugins/xgrids_k1/protocol/modeling_safety.py
@@ -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"
diff --git a/src/k1link/device_plugins/xgrids_k1/protocol/protobuf_wire.py b/src/k1link/device_plugins/xgrids_k1/protocol/protobuf_wire.py
index 84911cd..328db83 100644
--- a/src/k1link/device_plugins/xgrids_k1/protocol/protobuf_wire.py
+++ b/src/k1link/device_plugins/xgrids_k1/protocol/protobuf_wire.py
@@ -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:
diff --git a/src/k1link/web/plugin_runtime.py b/src/k1link/web/plugin_runtime.py
index 7ca017e..493591a 100644
--- a/src/k1link/web/plugin_runtime.py
+++ b/src/k1link/web/plugin_runtime.py
@@ -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:
diff --git a/tests/fixtures/xgrids_k1/device_info_fw3_0_2_semantics.json b/tests/fixtures/xgrids_k1/device_info_fw3_0_2_semantics.json
new file mode 100644
index 0000000..6a7f0d6
--- /dev/null
+++ b/tests/fixtures/xgrids_k1/device_info_fw3_0_2_semantics.json
@@ -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."
+}
diff --git a/tests/test_cli.py b/tests/test_cli.py
index f99f32f..3e3fef5 100644
--- a/tests/test_cli.py
+++ b/tests/test_cli.py
@@ -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"])
diff --git a/tests/test_plugin_runtime.py b/tests/test_plugin_runtime.py
index 495e794..506b1ae 100644
--- a/tests/test_plugin_runtime.py
+++ b/tests/test_plugin_runtime.py
@@ -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",
diff --git a/tests/test_stream_decoders.py b/tests/test_stream_decoders.py
index 02111c0..fa68c40 100644
--- a/tests/test_stream_decoders.py
+++ b/tests/test_stream_decoders.py
@@ -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"):
diff --git a/tests/test_web_validation_security.py b/tests/test_web_validation_security.py
index 156613d..00b5bf7 100644
--- a/tests/test_web_validation_security.py
+++ b/tests/test_web_validation_security.py
@@ -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
diff --git a/tests/test_xgrids_acquisition_lifecycle.py b/tests/test_xgrids_acquisition_lifecycle.py
index 70ddb9d..81f5556 100644
--- a/tests/test_xgrids_acquisition_lifecycle.py
+++ b/tests/test_xgrids_acquisition_lifecycle.py
@@ -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,
diff --git a/tests/test_xgrids_application_acceptance.py b/tests/test_xgrids_application_acceptance.py
index c1eef78..419938d 100644
--- a/tests/test_xgrids_application_acceptance.py
+++ b/tests/test_xgrids_application_acceptance.py
@@ -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:
diff --git a/tests/test_xgrids_application_authority.py b/tests/test_xgrids_application_authority.py
index 22075bc..072ed5d 100644
--- a/tests/test_xgrids_application_authority.py
+++ b/tests/test_xgrids_application_authority.py
@@ -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")
diff --git a/tests/test_xgrids_application_bootstrap.py b/tests/test_xgrids_application_bootstrap.py
index 953f43d..f97e6f3 100644
--- a/tests/test_xgrids_application_bootstrap.py
+++ b/tests/test_xgrids_application_bootstrap.py
@@ -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"),
)
diff --git a/tests/test_xgrids_application_mqtt.py b/tests/test_xgrids_application_mqtt.py
index 6daef33..52fab0f 100644
--- a/tests/test_xgrids_application_mqtt.py
+++ b/tests/test_xgrids_application_mqtt.py
@@ -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
diff --git a/tests/test_xgrids_application_publish.py b/tests/test_xgrids_application_publish.py
index fde3c9a..c054586 100644
--- a/tests/test_xgrids_application_publish.py
+++ b/tests/test_xgrids_application_publish.py
@@ -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(
diff --git a/tests/test_xgrids_application_session.py b/tests/test_xgrids_application_session.py
index c2095c8..3c9250d 100644
--- a/tests/test_xgrids_application_session.py
+++ b/tests/test_xgrids_application_session.py
@@ -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
diff --git a/tests/test_xgrids_camera_gateway.py b/tests/test_xgrids_camera_gateway.py
index e7e0bc0..a476eff 100644
--- a/tests/test_xgrids_camera_gateway.py
+++ b/tests/test_xgrids_camera_gateway.py
@@ -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",
}
diff --git a/tests/test_xgrids_compatibility_profile.py b/tests/test_xgrids_compatibility_profile.py
index 91ce2f8..386d324 100644
--- a/tests/test_xgrids_compatibility_profile.py
+++ b/tests/test_xgrids_compatibility_profile.py
@@ -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)):
diff --git a/tests/test_xgrids_modeling_safety.py b/tests/test_xgrids_modeling_safety.py
index 0fa36fc..f548ec4 100644
--- a/tests/test_xgrids_modeling_safety.py
+++ b/tests/test_xgrids_modeling_safety.py
@@ -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)