feat(k1): complete canonical control lifecycle

This commit is contained in:
DCCONSTRUCTIONS
2026-07-19 01:07:02 +03:00
parent d7a2c22faf
commit ffffee1879
42 changed files with 3577 additions and 556 deletions
@@ -1,4 +1,3 @@
import { useCallback, useState } from "react";
import { Button, StatusBadge, type StatusTone } from "@nodedc/ui-react";
import type { DevicePluginConnectionProps } from "@mission-core/plugin-sdk";
@@ -19,10 +18,6 @@ import { useXgridsK1Controller } from "./runtimeContext";
export function XgridsK1Connection({ model, host }: DevicePluginConnectionProps) {
const controller = useXgridsK1Controller();
const { state, error, refresh, clearError } = controller;
const [profileConfirmed, setProfileConfirmed] = useState(false);
const updateProfileConfirmation = useCallback((confirmed: boolean) => {
setProfileConfirmed(confirmed);
}, []);
const confirmedLive = isConfirmedLiveState(state);
const sourceRuntimeBusy = isSourceRuntimeBusy(state);
@@ -51,7 +46,7 @@ export function XgridsK1Connection({ model, host }: DevicePluginConnectionProps)
<span className="error-banner__dot" aria-hidden="true" />
<div>
<strong>Локальная операция завершилась ошибкой</strong>
<p>{localizeRuntimeMessage(error)}</p>
<p>{error}</p>
</div>
<div className="error-banner__actions">
<Button size="compact" variant="secondary" onClick={() => void refresh()}>Обновить состояние</Button>
@@ -79,13 +74,10 @@ export function XgridsK1Connection({ model, host }: DevicePluginConnectionProps)
controller={controller}
phaseLabel={connectionPhaseLabel}
phaseTone={connectionPhaseTone}
profileConfirmed={profileConfirmed}
onProfileConfirmedChange={updateProfileConfirmation}
/>
<div className="device-workspace__side">
<K1AcquisitionPipeline
controller={controller}
profileConfirmed={profileConfirmed}
openSpatialScene={host.openSpatialScene}
activateAutomaticSpatialSource={host.activateAutomaticSpatialSource}
/>
+42 -3
View File
@@ -138,7 +138,34 @@ export interface XgridsApplicationControlSession {
outcome_unknown: boolean;
failure?: {
code?: string;
reason_code?: string;
message?: string;
failed_phase?: string | null;
dialogue_stage?: string | null;
transport_state?: string | null;
publish_attempts?: number | null;
qos2_completions?: number | null;
correlated_responses?: number | null;
ignored_known_responses?: number | null;
late_known_responses?: number | null;
modeling_command_attempted?: boolean | null;
diagnostic_snapshot_unavailable?: string[];
diagnostic_evidence_unavailable?: string[];
correlation_failure?: {
phase?: string;
operation_key?: string;
response_topic?: string;
reason_code?: string;
reason?: string;
} | null;
compatibility_failure?: {
phase?: string;
operation_key?: string;
response_topic?: string;
reason_code?: string;
expected?: Record<string, unknown>;
observed?: Record<string, unknown>;
} | null;
safe_to_retry?: boolean;
} | null;
dialogue?: Record<string, unknown> | null;
@@ -153,6 +180,8 @@ export interface XgridsAcquisition {
compatibility_profile_id: string;
control_mode: "operator-manual" | "plugin-commanded" | "observe-only";
project_name?: string | null;
mount_type?: "handheld" | null;
gnss_mode?: "none" | null;
cleanup_pending?: boolean;
requested_streams: string[];
target_host: string;
@@ -260,6 +289,7 @@ export interface XgridsK1State {
devices?: BleDevice[];
selected_device_id?: string | null;
k1_ip?: string | null;
connection_mode?: "bridge" | null;
foxglove_ws_url?: string | null;
foxglove_viewer_url?: string | null;
rerun_grpc_url?: string | null;
@@ -293,13 +323,14 @@ export interface ScanRequest {
export interface CompatibilityAttestation {
firmware_version: "3.0.2";
topology: "direct-lan";
operator_confirmed: true;
verification: "live-device-info";
}
export interface ConnectRequest {
device_id: string;
ssid: string;
password: string;
connection_mode: "bridge";
compatibility_attestation: CompatibilityAttestation;
operation_id?: string;
idempotency_key?: string;
@@ -307,6 +338,8 @@ export interface ConnectRequest {
export interface PrepareAcquisitionRequest {
project_name: string;
mount_type: "handheld";
gnss_mode: "none";
host?: string;
duration_seconds?: number;
requested_streams?: RequestedStreamId[];
@@ -398,11 +431,13 @@ export interface EnterApplicationWorkspaceRequest {
export class ApiError extends Error {
readonly status: number;
readonly transportUnavailable: boolean;
constructor(message: string, status = 0) {
constructor(message: string, status = 0, transportUnavailable = false) {
super(message);
this.name = "ApiError";
this.status = status;
this.transportUnavailable = transportUnavailable;
}
}
@@ -433,7 +468,11 @@ async function requestJson(path: string, init?: RequestInit): Promise<unknown> {
},
});
} catch {
throw new ApiError("Не удалось подключиться к локальному сервису устройства.");
throw new ApiError(
"Не удалось подключиться к локальному сервису устройства.",
0,
true,
);
}
const bodyText = await response.text();
@@ -1,7 +1,7 @@
import type { CompatibilityAttestation } from "./api";
export const EXACT_PROFILE_ATTESTATION: CompatibilityAttestation = Object.freeze({
export const EXACT_PROFILE_SELECTION: CompatibilityAttestation = Object.freeze({
firmware_version: "3.0.2",
topology: "direct-lan",
operator_confirmed: true,
verification: "live-device-info",
});
@@ -4,13 +4,22 @@ import {
Checker,
GlassSurface,
Icon,
Select,
SegmentedControl,
StatusBadge,
TextField,
type StatusTone,
} from "@nodedc/ui-react";
import { EXACT_PROFILE_ATTESTATION } from "../compatibility";
import { EXACT_PROFILE_SELECTION } from "../compatibility";
import {
SUPPORTED_GNSS_MODE,
SUPPORTED_MOUNT_TYPE,
gnssModeOptions,
mountTypeOptions,
type GnssMode,
type MountType,
} from "../configuration";
import { runAutomaticSpatialSourceStart } from "../automaticSourceStart";
import {
isConfirmedLiveState,
@@ -41,12 +50,10 @@ const PHYSICAL_ACCEPTANCE = {
export function K1AcquisitionPipeline({
controller,
profileConfirmed,
openSpatialScene,
activateAutomaticSpatialSource,
}: {
controller: XgridsK1Controller;
profileConfirmed: boolean;
openSpatialScene: () => void;
activateAutomaticSpatialSource: () => void;
}) {
@@ -57,7 +64,6 @@ export function K1AcquisitionPipeline({
startCanonicalAcquisition,
startReplay,
stop,
confirmStoppedAtSteadyGreen,
abort,
} = controller;
const [sessionIntent, setSessionIntent] = useState<SessionIntent>("live");
@@ -66,7 +72,8 @@ export function K1AcquisitionPipeline({
const [replayPath, setReplayPath] = useState("");
const [replaySpeed, setReplaySpeed] = useState("1");
const [replayLoop, setReplayLoop] = useState(false);
const [physicalAcceptanceConfirmed, setPhysicalAcceptanceConfirmed] = useState(false);
const [mountType, setMountType] = useState<MountType>(SUPPORTED_MOUNT_TYPE);
const [gnssMode, setGnssMode] = useState<GnssMode>(SUPPORTED_GNSS_MODE);
const hydratedAcquisitionId = useRef<string | null>(null);
const activeAcquisition = recoverableAcquisition(state);
@@ -125,8 +132,6 @@ export function K1AcquisitionPipeline({
const startLive = async () => {
setProjectNameTouched(true);
if (
!profileConfirmed ||
!physicalAcceptanceConfirmed ||
!state?.k1_ip ||
sourceRuntimeBusy ||
launchBlockedByAcquisition ||
@@ -142,7 +147,9 @@ export function K1AcquisitionPipeline({
},
acquisition: {
project_name: projectNameValidation.value,
compatibility_attestation: EXACT_PROFILE_ATTESTATION,
mount_type: SUPPORTED_MOUNT_TYPE,
gnss_mode: SUPPORTED_GNSS_MODE,
compatibility_attestation: EXACT_PROFILE_SELECTION,
},
physicalAcceptance: PHYSICAL_ACCEPTANCE,
}),
@@ -181,6 +188,30 @@ export function K1AcquisitionPipeline({
/>
{effectiveSessionIntent === "live" ? (
<div className="session-form">
<div className="scan-configuration-grid">
<div className="configuration-field">
<span className="nodedc-field__description">Тип установки / носитель</span>
<Select
label="Тип установки / носитель"
value={mountType}
options={mountTypeOptions}
onChange={setMountType}
disabled={isBusy || sessionLocked}
variant="split"
/>
</div>
<div className="configuration-field">
<span className="nodedc-field__description">Режим GNSS</span>
<Select
label="Режим GNSS"
value={gnssMode}
options={gnssModeOptions}
onChange={setGnssMode}
disabled={isBusy || sessionLocked}
variant="split"
/>
</div>
</div>
<TextField
label="Название проекта"
hint="Имя войдёт в единственный канонический START"
@@ -198,19 +229,12 @@ export function K1AcquisitionPipeline({
: "Отдельной команды сохранения имени на K1 нет: оно отправляется только при START."}
placeholder="Например, TEST001"
/>
<Checker
checked={physicalAcceptanceConfirmed}
label="Я рядом с выбранным K1; LixelGO закрыт; питание и место для записи проверены; индикатор постоянно зелёный"
onChange={setPhysicalAcceptanceConfirmed}
/>
<Button
variant="primary"
icon={<Icon name="activity" />}
disabled={
isBusy ||
!profileConfirmed ||
!state?.k1_ip ||
!physicalAcceptanceConfirmed ||
projectNameValidation.error !== null ||
sourceRuntimeBusy ||
launchBlockedByAcquisition ||
@@ -232,6 +256,9 @@ export function K1AcquisitionPipeline({
? "Продолжить запуск сканирования и приёма"
: "Запустить сканирование и локальный приём"}
</Button>
<p className="start-confirmation-note">
Нажатие запуска явное операторское действие для выбранного K1. Автоматических повторов START нет.
</p>
{control?.control_socket_open && !activeAcquisition && !isBusy ? (
<Button
variant="ghost"
@@ -242,9 +269,7 @@ export function K1AcquisitionPipeline({
</Button>
) : null}
<p className="live-instruction">
{!profileConfirmed
? "Сначала вручную подтвердите FW 3.0.2 и direct-LAN. Интерфейс не аттестует устройство автоматически."
: controlPhase === "failed"
{controlPhase === "failed"
? `Диалог остановлен без автоповтора: ${control?.failure?.message || "требуется ручная проверка K1"}`
: controlPhase === "connecting"
? "Выполняются операции 1–6 записанного диалога; следующий этап ждёт подтверждённый ответ K1."
@@ -256,7 +281,7 @@ export function K1AcquisitionPipeline({
? "Калибровка оборудования. Не перемещайте K1; временных переходов и повторных команд нет."
: controlPhase === "scanning"
? "K1 подтвердил SCANNING и инициализацию. Остановка доступна в пространственной сцене."
: "Одна кнопка выражает намерение запустить сканирование. Внутри этапы идут строго по записанному порядку и только после ответов K1; человеческие паузы из capture не воспроизводятся."}
: "Одна кнопка выражает намерение запустить сканирование. Совместимость подтверждается живым DeviceInfo; этапы идут строго по записанному порядку и только после ответов K1."}
</p>
</div>
) : (
@@ -282,15 +307,6 @@ export function K1AcquisitionPipeline({
: "Остановка завершает только локальный приём и сохранение. Физическое состояние сканера остаётся неизвестным."
: "Активного источника сейчас нет."}
</p>
{control?.can_confirm_standby ? (
<Button
variant="primary"
disabled={isBusy}
onClick={() => void confirmStoppedAtSteadyGreen()}
>
Индикатор постоянно зелёный завершить запись
</Button>
) : null}
<Button
variant="secondary"
disabled={isBusy || (!sourceRuntimeBusy && preparedAcquisition !== null) || (!sourceRuntimeBusy && activeAcquisition === null)}
@@ -4,13 +4,19 @@ import {
Checker,
GlassSurface,
Icon,
Select,
StatusBadge,
TextField,
type StatusTone,
} from "@nodedc/ui-react";
import type { BleDevice } from "../api";
import { EXACT_PROFILE_ATTESTATION } from "../compatibility";
import { EXACT_PROFILE_SELECTION } from "../compatibility";
import {
SUPPORTED_CONNECTION_MODE,
connectionModeOptions,
type ConnectionMode,
} from "../configuration";
import { provisioningIntentKey } from "../lifecycle";
import { finiteMetric } from "../presentation";
import type { XgridsK1Controller } from "../runtimeContext";
@@ -79,30 +85,28 @@ export function K1ProvisioningPipeline({
controller,
phaseLabel,
phaseTone,
profileConfirmed,
onProfileConfirmedChange,
}: {
controller: XgridsK1Controller;
phaseLabel: string;
phaseTone: StatusTone;
profileConfirmed: boolean;
onProfileConfirmedChange: (confirmed: boolean) => void;
}) {
const { state, pendingAction, scan, connect } = controller;
const [powerConfirmed, setPowerConfirmed] = useState(false);
const [selectedDeviceId, setSelectedDeviceId] = useState("");
const [ssid, setSsid] = useState("");
const [password, setPassword] = useState("");
const [connectionMode, setConnectionMode] = useState<ConnectionMode>(
SUPPORTED_CONNECTION_MODE,
);
const provisioningIntentRef = useRef<string | null>(null);
const devices = state?.devices ?? [];
const isBusy = pendingAction !== null;
const credentialsReady = ssid.trim().length > 0 && password.length > 0;
const canConnect = powerConfirmed && profileConfirmed && selectedDeviceId.length > 0 && credentialsReady && !isBusy;
const canConnect = powerConfirmed && selectedDeviceId.length > 0 && credentialsReady && !isBusy;
useEffect(() => {
if (state?.selected_device_id) {
if (state.selected_device_id !== selectedDeviceId) {
onProfileConfirmedChange(false);
provisioningIntentRef.current = null;
}
setSelectedDeviceId(state.selected_device_id);
@@ -111,15 +115,14 @@ export function K1ProvisioningPipeline({
if (selectedDeviceId && state?.devices && !state.devices.some((device) => device.device_id === selectedDeviceId)) {
setSelectedDeviceId("");
}
}, [onProfileConfirmedChange, selectedDeviceId, state?.devices, state?.selected_device_id]);
}, [selectedDeviceId, state?.devices, state?.selected_device_id]);
const deviceSummary = useMemo(
() => devices.find((device) => device.device_id === selectedDeviceId),
[devices, selectedDeviceId],
);
const resetProfile = () => {
onProfileConfirmedChange(false);
const resetProvisioningIntent = () => {
provisioningIntentRef.current = null;
};
@@ -131,7 +134,8 @@ export function K1ProvisioningPipeline({
device_id: selectedDeviceId,
ssid: ssid.trim(),
password,
compatibility_attestation: EXACT_PROFILE_ATTESTATION,
connection_mode: SUPPORTED_CONNECTION_MODE,
compatibility_attestation: EXACT_PROFILE_SELECTION,
idempotency_key: idempotencyKey,
});
if (succeeded) {
@@ -146,6 +150,22 @@ export function K1ProvisioningPipeline({
<div><span className="section-eyebrow">ПОДКЛЮЧЕНИЕ · ШАГИ 0103</span><h2>Подключите устройство к сети</h2></div>
<StatusBadge tone={phaseTone}>{phaseLabel}</StatusBadge>
</header>
<div className="configuration-anchor">
<span className="nodedc-field__description">
Неподтверждённые сетевые топологии уже отражены в интерфейсе, но не могут быть выбраны до отдельной приёмки.
</span>
<Select
label="Способ подключения"
value={connectionMode}
options={connectionModeOptions}
onChange={(value) => {
setConnectionMode(value);
resetProvisioningIntent();
}}
disabled={isBusy}
variant="split"
/>
</div>
<div className="wizard-list">
<WizardStep number="01" title="Включите устройство" status={powerConfirmed ? "Подтверждено" : "Ожидает"} tone={powerConfirmed ? "success" : "warning"}>
<div className="nodedc-field">
@@ -153,7 +173,7 @@ export function K1ProvisioningPipeline({
<Checker
checked={powerConfirmed}
label="Устройство включено, индикатор стабилен"
onChange={(checked) => { setPowerConfirmed(checked); if (!checked) resetProfile(); }}
onChange={(checked) => { setPowerConfirmed(checked); if (!checked) resetProvisioningIntent(); }}
/>
</div>
</WizardStep>
@@ -163,13 +183,13 @@ export function K1ProvisioningPipeline({
status={pendingAction === "scan" ? "Поиск…" : selectedDeviceId ? "Устройство выбрано" : `Найдено: ${devices.length}`}
tone={pendingAction === "scan" ? "accent" : selectedDeviceId ? "success" : "neutral"}
>
<p className="step-copy">Поиск занимает 6 секунд и показывает все видимые BLE-устройства. Метка кандидата основана только на имени; модель и прошивку подтверждает оператор.</p>
<p className="step-copy">Поиск занимает 6 секунд и показывает все видимые BLE-устройства. Метка кандидата основана только на имени; точные модель, platform type и прошивка будут проверены по живому DeviceInfo перед START.</p>
<Button
width="full"
variant="secondary"
icon={<Icon name="search" />}
disabled={!powerConfirmed || isBusy}
onClick={() => { setSelectedDeviceId(""); resetProfile(); void scan(); }}
onClick={() => { setSelectedDeviceId(""); resetProvisioningIntent(); void scan(); }}
>
{pendingAction === "scan" ? "Сканируем Bluetooth — 6 секунд…" : "Показать все BLE-устройства"}
</Button>
@@ -179,21 +199,18 @@ export function K1ProvisioningPipeline({
key={device.device_id}
device={device}
selected={device.device_id === selectedDeviceId}
onSelect={() => { setSelectedDeviceId(device.device_id); resetProfile(); }}
onSelect={() => { setSelectedDeviceId(device.device_id); resetProvisioningIntent(); }}
/>
)) : <div className="empty-device-list">Устройства пока не найдены. Проверьте питание и повторите поиск.</div>}
</div>
</WizardStep>
<WizardStep number="03" title="Передайте настройки Wi‑Fi" status={state?.k1_ip ? "Подключено" : "Не подключено"} tone={state?.k1_ip ? "success" : "neutral"}>
<WizardStep
number="03"
title="Передайте настройки общей сети"
status={state?.k1_ip ? "Адрес ранее получен" : "Настройки не переданы"}
tone={state?.k1_ip ? "warning" : "neutral"}
>
<div className="field-stack">
<div className="nodedc-field">
<span className="nodedc-field__description">Mission Core не определяет прошивку автоматически. Подтвердите только точное соответствие профилю.</span>
<Checker
checked={profileConfirmed}
label="Я вручную подтвердил FW 3.0.2 и direct-LAN"
onChange={(checked) => { onProfileConfirmedChange(checked); provisioningIntentRef.current = null; }}
/>
</div>
<TextField label="Название сети Wi‑Fi" hint="SSID" value={ssid} onChange={(event) => { setSsid(event.target.value); provisioningIntentRef.current = null; }} autoComplete="off" spellCheck={false} placeholder="Сеть локального контура" />
<TextField label="Пароль WiFi" hint="Только в оперативной памяти" type="password" value={password} onChange={(event) => { setPassword(event.target.value); provisioningIntentRef.current = null; }} autoComplete="off" placeholder="Введите пароль" />
</div>
@@ -201,7 +218,7 @@ export function K1ProvisioningPipeline({
<Button width="full" variant="primary" icon={<Icon name="network" />} disabled={!canConnect} onClick={() => void submitConnect()}>
{pendingAction === "connect" ? "Подключаем…" : "Подключить устройство к Wi‑Fi"}
</Button>
<p className="safety-note">Пароль передаётся только локальному сервису, не сохраняется в браузере и удаляется из формы после успеха.</p>
<p className="safety-note">Наличие адреса подтверждает результат предыдущей настройки, но не текущее соединение. Пароль передаётся только локальному сервису, не сохраняется в браузере и удаляется из формы после успеха.</p>
</WizardStep>
</div>
</GlassSurface>
@@ -72,7 +72,7 @@ function phasePresentation(
awaiting_external_stop: {
label: softwareCommanded ? "K1 завершает и сохраняет" : "Ожидание остановки на устройстве",
detail: softwareCommanded
? "STOP не повторяется. Дождитесь READY и постоянного зелёного индикатора."
? "STOP не повторяется. Mission Core завершит запись после READY от K1."
: "Mission Core ждёт подтверждения физической остановки K1.",
busy: true,
},
@@ -108,7 +108,7 @@ function formatDuration(seconds: number): string {
export function K1SpatialControls(_props: DevicePluginConnectionProps) {
const controller = useXgridsK1Controller();
const { state, pendingAction, stop, confirmStoppedAtSteadyGreen } = controller;
const { state, pendingAction, stop } = controller;
const acquisition = state?.acquisition;
const cleanupPending = acquisition?.cleanup_pending === true;
if (!acquisition || !shouldRenderSpatialControls(state)) {
@@ -179,16 +179,6 @@ export function K1SpatialControls(_props: DevicePluginConnectionProps) {
? "Повторить остановку"
: softwareCommanded ? "Остановить устройство и запись" : "Остановить локальный приём"}
</Button>
{state?.application_control_session?.can_confirm_standby ? (
<Button
size="compact"
variant="primary"
disabled={pendingAction !== null}
onClick={() => void confirmStoppedAtSteadyGreen()}
>
Индикатор постоянно зелёный завершить запись
</Button>
) : null}
</section>
);
}
@@ -0,0 +1,75 @@
import type { SelectOption } from "@nodedc/ui-react";
export type ConnectionMode = "bridge" | "quick-connect" | "direct-connect";
export type MountType = "handheld" | "vehicle-mounted" | "uav" | "backpack";
export type GnssMode = "none" | "rtk" | "ppk";
export const SUPPORTED_CONNECTION_MODE = "bridge" as const satisfies ConnectionMode;
export const SUPPORTED_MOUNT_TYPE = "handheld" as const satisfies MountType;
export const SUPPORTED_GNSS_MODE = "none" as const satisfies GnssMode;
export const connectionModeOptions: Array<SelectOption<ConnectionMode>> = [
{
value: "bridge",
label: "Общая сеть · Bridge",
description: "K1 и Mission Core работают в одной локальной сети. Подтверждённый путь.",
},
{
value: "quick-connect",
label: "Точка доступа K1 · Quick Connect",
description: "Mission Core подключается к сети сканера. Будет доступно после отдельной приёмки.",
disabled: true,
},
{
value: "direct-connect",
label: "Хотспот контроллера · Direct Connect",
description: "K1 подключается к сети управляющего устройства. Будет доступно после отдельной приёмки.",
disabled: true,
},
];
export const mountTypeOptions: Array<SelectOption<MountType>> = [
{
value: "handheld",
label: "Ручной",
description: "Подтверждённый канонический профиль MountType.HANDHELD.",
},
{
value: "vehicle-mounted",
label: "На наземной платформе",
description: "Vehicle-Mounted. Недоступно до отдельной приёмки START/STOP.",
disabled: true,
},
{
value: "uav",
label: "На БПЛА",
description: "Drone/UAV. Недоступно до отдельной приёмки START/STOP.",
disabled: true,
},
{
value: "backpack",
label: "Ранцевый",
description: "Backpack. Недоступно до отдельной приёмки START/STOP.",
disabled: true,
},
];
export const gnssModeOptions: Array<SelectOption<GnssMode>> = [
{
value: "none",
label: "Без RTK",
description: "RTK/NTRIP читаются только для обнаружения возможностей и не включаются.",
},
{
value: "rtk",
label: "Использовать RTK",
description: "Недоступно до отдельного захвата настройки и физической приёмки.",
disabled: true,
},
{
value: "ppk",
label: "Использовать PPK",
description: "Недоступно до отдельного захвата настройки и физической приёмки.",
disabled: true,
},
];
+26 -1
View File
@@ -2,6 +2,7 @@ import type { RuntimePhase, SourceMode as RuntimeSourceMode } from "@mission-cor
import type {
AcquisitionState,
XgridsApplicationControlPhase,
XgridsAcquisition,
XgridsK1State,
XgridsOperation,
@@ -22,6 +23,25 @@ const FAILED_OPERATION_STATUSES = new Set([
]);
export type LiveStartPlan = "prepare" | "resume-prepared" | "already-running" | "blocked";
export type ControlSessionEntryPlan =
| "open"
| "continue"
| "failed"
| "duplicate-open";
export function controlSessionEntryPlan(
phase: XgridsApplicationControlPhase,
openedByCurrentOperatorAction: boolean,
canOpen: boolean,
): ControlSessionEntryPlan {
if (phase === "failed") {
return !openedByCurrentOperatorAction && canOpen ? "open" : "failed";
}
if (["idle", "closed", "completed"].includes(phase)) {
return openedByCurrentOperatorAction ? "duplicate-open" : "open";
}
return "continue";
}
export function isTerminalAcquisitionState(
state: AcquisitionState | null | undefined,
@@ -119,7 +139,12 @@ export function normalizeRuntimePhase(
}
if (acquisitionState === "prepared") return "connected";
if (phase === "error") return "error";
if (phase === "connected") return "connected";
if (phase === "connected") {
const controlPhase = state?.application_control_session?.state;
return controlPhase && !["idle", "connecting", "closed", "completed", "failed"].includes(controlPhase)
? "connected"
: "configuring";
}
if (phase === "starting_live") return "starting";
if (phase === "live") return "streaming";
if (phase === "replay") return "replaying";
@@ -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, "неуказанная ошибка"],
[
@@ -0,0 +1,67 @@
export interface RuntimeGenerationToken {
readonly runtimeGeneration: number;
}
export interface OperatorIntentToken extends RuntimeGenerationToken {
readonly intentGeneration: number;
}
/**
* Invalidates asynchronous UI work across both plugin activation changes and
* successive explicit operator intents.
*
* A boolean "mounted" flag is insufficient here: an operation started before
* active=true -> false -> true would otherwise observe true again and resume.
*/
export class OperatorIntentGeneration {
private runtimeGeneration = 0;
private intentGeneration = 0;
private active = false;
activateRuntime(): RuntimeGenerationToken {
this.runtimeGeneration += 1;
this.active = true;
return Object.freeze({ runtimeGeneration: this.runtimeGeneration });
}
deactivateRuntime(): void {
if (!this.active) return;
this.runtimeGeneration += 1;
this.active = false;
}
captureRuntime(): RuntimeGenerationToken | null {
if (!this.active) return null;
return Object.freeze({ runtimeGeneration: this.runtimeGeneration });
}
beginOperatorIntent(): OperatorIntentToken | null {
if (!this.active) return null;
this.intentGeneration += 1;
return Object.freeze({
runtimeGeneration: this.runtimeGeneration,
intentGeneration: this.intentGeneration,
});
}
isRuntimeCurrent(token: RuntimeGenerationToken): boolean {
return this.active && token.runtimeGeneration === this.runtimeGeneration;
}
isOperatorIntentCurrent(token: OperatorIntentToken): boolean {
return (
this.isRuntimeCurrent(token)
&& token.intentGeneration === this.intentGeneration
);
}
}
export async function awaitWhileIntentCurrent<T>(
assertCurrent: () => void,
operation: () => Promise<T>,
): Promise<T> {
assertCurrent();
const result = await operation();
assertCurrent();
return result;
}
@@ -9,7 +9,7 @@ const phaseLabels: Record<string, string> = {
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";
}
+37
View File
@@ -69,6 +69,20 @@
margin-top: 1.4rem;
}
.configuration-anchor {
display: grid;
gap: 0.55rem;
margin-top: 1.2rem;
border-radius: 0.95rem;
background: rgb(255 255 255 / 0.028);
padding: 0.8rem;
}
.configuration-anchor .nodedc-select-anchor,
.configuration-field .nodedc-select-anchor {
width: 100%;
}
.wizard-step {
display: grid;
grid-template-columns: 2.2rem minmax(0, 1fr);
@@ -282,6 +296,25 @@
margin-top: 1rem;
}
.scan-configuration-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.75rem;
}
.configuration-field {
display: grid;
min-width: 0;
gap: 0.45rem;
}
.start-confirmation-note {
margin: -0.25rem 0 0;
color: var(--nodedc-text-muted);
font-size: 0.6rem;
line-height: 1.45;
}
.session-form--replay {
grid-template-columns: minmax(0, 1.55fr) minmax(8rem, 0.45fr);
}
@@ -435,6 +468,10 @@
}
@media (max-width: 760px) {
.xgrids-k1-plugin .scan-configuration-grid {
grid-template-columns: 1fr;
}
.xgrids-k1-plugin .session-form--replay {
grid-template-columns: 1fr;
}
@@ -16,6 +16,7 @@ import {
type XgridsK1State,
} from "./api";
import {
controlSessionEntryPlan,
isTerminalAcquisitionState,
liveStartPlan,
operationByIdempotencyKey,
@@ -23,6 +24,10 @@ import {
isSoftwareCommandedAcquisition,
} from "./lifecycle";
import { localizeRuntimeMessage } from "./messages";
import {
awaitWhileIntentCurrent,
OperatorIntentGeneration,
} from "./operatorIntentGeneration";
import { selectMonotonicXgridsState } from "./stateOrdering";
export type PendingAction =
@@ -50,19 +55,103 @@ function controlPhase(state: XgridsK1State): XgridsApplicationControlPhase {
function controlFailure(state: XgridsK1State): ApiError {
const failure = state.application_control_session?.failure;
const localizedDetail = localizeRuntimeMessage(failure?.message);
const reasonLabels: Record<string, string> = {
application_authority_unavailable:
"Локальный допуск управления устройством недоступен; команды не отправлялись.",
mqtt_connect_call_failed:
"Управляющее MQTT-соединение со сканером не открылось.",
mqtt_connect_rejected:
"Сканер отклонил управляющее MQTT-соединение.",
mqtt_connection_timeout:
"Подключение или подписки MQTT не подтвердились вовремя.",
mqtt_subscription_failed:
"Сканер не подтвердил канонические MQTT-подписки.",
mqtt_response_timeout:
"Ожидаемый ответ сканера не пришёл до безопасной границы ожидания.",
response_identity_decode_failed:
"Ответ сканера не удалось безопасно разобрать и привязать к операции.",
modeling_response_decode_failed:
"Ответ START/STOP не удалось безопасно разобрать.",
duplicate_application_response:
"Сканер прислал повторный ответ на уже завершённую операцию.",
unexpected_response_identity:
"Получен ответ неизвестной операции; диалог остановлен.",
response_identity_mismatch:
"Идентичность ответа не совпала с ожидаемой операцией.",
response_session_mismatch:
"Session ответа не совпал с точной подготовительной операцией.",
response_device_identity_mismatch:
"Ответ относится не к тому экземпляру устройства.",
response_authority_mismatch:
"Ответ не совпал с локальным допуском приложения.",
response_rejected:
"Сканер отклонил подготовительную операцию.",
compatibility_profile_mismatch:
"Живой DeviceInfo не соответствует выбранному профилю модели, platform type, прошивки или активации.",
scan_initialization_timeout:
"После подтверждённого START сканер не завершил инициализацию в безопасный срок.",
operation_reuse_forbidden:
"Повтор уже использованной операции заблокирован.",
};
const reasonDetail = failure?.reason_code
? reasonLabels[failure.reason_code]
: undefined;
const stageLabels: Record<string, string> = {
connecting: "подключение и первичный диалог",
connection: "первичный диалог",
"connection-ready": "подключение подтверждено",
"workspace-requested": "вход в рабочую область",
"workspace-ready": "рабочая область готова",
"project-requested": "подготовка проекта",
"project-ready": "проект готов",
"start-requested": "подтверждение запуска",
"start-attempted": "команда запуска",
initializing: "калибровка оборудования",
scanning: "сканирование",
"stop-requested": "подтверждение остановки",
"stop-attempted": "команда остановки",
stopping: "остановка",
};
const stageCode = failure?.dialogue_stage || failure?.failed_phase;
const stage = stageCode
? stageLabels[stageCode] ?? "неопознанный этап канонического диалога"
: "этап не зафиксирован";
const commandStatus = failure?.modeling_command_attempted === true
? "Команда START или STOP могла быть отправлена; автоматический повтор запрещён."
: failure?.modeling_command_attempted === false
? "Команды START и STOP не отправлялись."
: "Факт отправки START или STOP диагностически не подтверждён; повтор запрещён.";
const retryStatus = failure?.safe_to_retry
? "Новая попытка возможна только отдельным нажатием оператора."
: "Повтор заблокирован до ручной проверки состояния.";
const exchanges = typeof failure?.publish_attempts === "number"
? `MQTT-публикаций до остановки: ${failure.publish_attempts}.`
: "Количество MQTT-публикаций не зафиксировано.";
const failedProtocolEvidence =
failure?.compatibility_failure ?? failure?.correlation_failure;
const failedOperation = failedProtocolEvidence?.operation_key
? `Шаг протокола: ${failedProtocolEvidence.operation_key}.`
: "";
const diagnostics = failure?.diagnostic_evidence_unavailable?.length
? "Часть диагностических доказательств недоступна; результат считается неизвестным."
: "";
return new ApiError(
failure?.message
? `Канонический диалог K1 остановлен: ${failure.message}`
: "Канонический диалог K1 остановлен до запуска сканирования.",
`${reasonDetail || localizedDetail || "Канонический диалог K1 остановлен."} Этап: ${stage}. ${failedOperation} ${exchanges} ${commandStatus} ${diagnostics} ${retryStatus}`,
);
}
async function waitForControlPhase(
expected: XgridsApplicationControlPhase,
acceptState: (state: XgridsK1State) => void,
assertOperatorIntentCurrent: () => void,
): Promise<XgridsK1State> {
for (;;) {
const nextState = await xgridsK1Api.getState();
assertOperatorIntentCurrent();
const nextState = await awaitWhileIntentCurrent(
assertOperatorIntentCurrent,
() => xgridsK1Api.getState(),
);
acceptState(nextState);
const phase = controlPhase(nextState);
if (phase === expected) return nextState;
@@ -74,15 +163,22 @@ async function waitForControlPhase(
}
// This cadence only reads local server state. It never schedules, retries,
// or times a K1 command; every next write remains gated by device response.
await new Promise<void>((resolve) => {
window.setTimeout(resolve, CONTROL_STATE_READ_INTERVAL_MS);
});
await awaitWhileIntentCurrent(
assertOperatorIntentCurrent,
() => new Promise<void>((resolve) => {
window.setTimeout(resolve, CONTROL_STATE_READ_INTERVAL_MS);
}),
);
}
}
function messageFor(error: unknown): string {
if (error instanceof ApiError) {
const message = localizeRuntimeMessage(error.message) ?? error.message;
// Domain errors are already written for the operator. Only HTTP details
// originate at the backend and need vendor/runtime normalization.
const message = error.status
? localizeRuntimeMessage(error.message) ?? error.message
: error.message;
return error.status
? `${message} (HTTP ${error.status})`
: message;
@@ -113,8 +209,12 @@ export function useXgridsK1Runtime(enabled: boolean) {
const [pendingAction, setPendingAction] = useState<PendingAction | null>(null);
const [error, setError] = useState<string | null>(null);
const [latencyHistory, setLatencyHistory] = useState<number[]>([]);
const mounted = useRef(true);
const actionInFlight = useRef(false);
const operatorIntents = useRef(new OperatorIntentGeneration());
const actionSequence = useRef(0);
const actionInFlight = useRef<{
runtimeGeneration: number;
actionSequence: number;
} | null>(null);
const acceptState = useCallback((nextState: XgridsK1State) => {
setState((currentState) => selectMonotonicXgridsState(currentState, nextState));
@@ -122,13 +222,14 @@ export function useXgridsK1Runtime(enabled: boolean) {
}, []);
const refresh = useCallback(async (reportErrors = true) => {
if (!enabled) return;
const runtimeToken = operatorIntents.current.captureRuntime();
if (!enabled || !runtimeToken) return;
const [healthResult, stateResult] = await Promise.allSettled([
xgridsK1Api.getHealth(),
xgridsK1Api.getState(),
]);
if (!mounted.current) return;
if (!operatorIntents.current.isRuntimeCurrent(runtimeToken)) return;
if (stateResult.status === "fulfilled") {
acceptState(stateResult.value);
@@ -150,27 +251,48 @@ export function useXgridsK1Runtime(enabled: boolean) {
const run = useCallback(
async (action: PendingAction, operation: () => Promise<XgridsK1State>) => {
if (!enabled) return false;
if (actionInFlight.current) return false;
actionInFlight.current = true;
const runtimeToken = operatorIntents.current.captureRuntime();
if (!enabled || !runtimeToken) return false;
if (
actionInFlight.current?.runtimeGeneration
=== runtimeToken.runtimeGeneration
) return false;
actionSequence.current += 1;
const actionToken = {
runtimeGeneration: runtimeToken.runtimeGeneration,
actionSequence: actionSequence.current,
};
actionInFlight.current = actionToken;
setPendingAction(action);
setError(null);
try {
if (!operatorIntents.current.isRuntimeCurrent(runtimeToken)) return false;
const nextState = await operation();
if (mounted.current) acceptState(nextState);
if (!operatorIntents.current.isRuntimeCurrent(runtimeToken)) return false;
acceptState(nextState);
return true;
} catch (operationError) {
if (mounted.current) {
if (operatorIntents.current.isRuntimeCurrent(runtimeToken)) {
setError(messageFor(operationError));
if (operationError instanceof ApiError && operationError.status === 0) {
if (
operationError instanceof ApiError
&& operationError.transportUnavailable
) {
setBackendStatus("offline");
}
}
return false;
} finally {
actionInFlight.current = false;
if (mounted.current) setPendingAction(null);
if (
actionInFlight.current?.runtimeGeneration === actionToken.runtimeGeneration
&& actionInFlight.current.actionSequence === actionToken.actionSequence
) {
actionInFlight.current = null;
if (operatorIntents.current.isRuntimeCurrent(runtimeToken)) {
setPendingAction(null);
}
}
}
},
[acceptState, enabled],
@@ -237,7 +359,24 @@ export function useXgridsK1Runtime(enabled: boolean) {
const startCanonicalAcquisition = useCallback(
(request: CanonicalLiveStartRequest) =>
run("live", async () => {
let nextState = await xgridsK1Api.getState();
const intentToken = operatorIntents.current.beginOperatorIntent();
if (!intentToken) {
throw new ApiError(
"Экран управления закрыт; дальнейшие команды канонического диалога не отправлялись.",
);
}
const assertOperatorIntentCurrent = () => {
if (!operatorIntents.current.isOperatorIntentCurrent(intentToken)) {
throw new ApiError(
"Операторское действие завершено или заменено; дальнейшие команды канонического диалога не отправлялись.",
);
}
};
let nextState = await awaitWhileIntentCurrent(
assertOperatorIntentCurrent,
() => xgridsK1Api.getState(),
);
let openedControlSession = false;
acceptState(nextState);
const plan = liveStartPlan(nextState);
if (plan === "blocked") {
@@ -246,50 +385,84 @@ export function useXgridsK1Runtime(enabled: boolean) {
if (plan === "already-running") return nextState;
for (;;) {
assertOperatorIntentCurrent();
const phase = controlPhase(nextState);
const acquisition = nextState.acquisition;
const entryPlan = controlSessionEntryPlan(
phase,
openedControlSession,
nextState.application_control_session?.can_open === true,
);
if (["idle", "closed", "completed"].includes(phase)) {
if (entryPlan === "failed") {
// A failed dialogue always ends this operator intent. Even when
// backend reconciliation says a fresh attempt may be safe, that
// attempt requires another explicit click.
throw controlFailure(nextState);
}
if (entryPlan === "duplicate-open") {
throw new ApiError(
"Управляющая сессия завершилась сразу после открытия. Автоматический повтор заблокирован; проверьте состояние и повторите только отдельным нажатием.",
);
}
if (entryPlan === "open") {
if (acquisition && !isTerminalAcquisitionState(acquisition.state)) {
throw new ApiError(
"Незавершённая подготовка не привязана к открытой control-сессии. Отмените её перед новым запуском.",
);
}
nextState = await xgridsK1Api.openApplicationControlSession(request.control);
acceptState(nextState);
continue;
}
if (phase === "failed") {
if (nextState.application_control_session?.can_open !== true) {
throw controlFailure(nextState);
}
nextState = await xgridsK1Api.openApplicationControlSession(request.control);
openedControlSession = true;
nextState = await awaitWhileIntentCurrent(
assertOperatorIntentCurrent,
() => xgridsK1Api.openApplicationControlSession(request.control),
);
acceptState(nextState);
continue;
}
if (phase === "connecting") {
nextState = await waitForControlPhase("connection-ready", acceptState);
nextState = await awaitWhileIntentCurrent(
assertOperatorIntentCurrent,
() => waitForControlPhase(
"connection-ready",
acceptState,
assertOperatorIntentCurrent,
),
);
continue;
}
if (phase === "connection-ready") {
nextState = await xgridsK1Api.enterApplicationWorkspace({
operator_confirmed: true,
});
nextState = await awaitWhileIntentCurrent(
assertOperatorIntentCurrent,
() => xgridsK1Api.enterApplicationWorkspace({
operator_confirmed: true,
}),
);
acceptState(nextState);
continue;
}
if (phase === "workspace-requested") {
nextState = await waitForControlPhase("workspace-ready", acceptState);
nextState = await awaitWhileIntentCurrent(
assertOperatorIntentCurrent,
() => waitForControlPhase(
"workspace-ready",
acceptState,
assertOperatorIntentCurrent,
),
);
continue;
}
if (phase === "workspace-ready") {
if (!acquisition || isTerminalAcquisitionState(acquisition.state)) {
nextState = await xgridsK1Api.prepareAcquisition(request.acquisition);
nextState = await awaitWhileIntentCurrent(
assertOperatorIntentCurrent,
() => xgridsK1Api.prepareAcquisition(request.acquisition),
);
acceptState(nextState);
continue;
}
@@ -298,12 +471,26 @@ export function useXgridsK1Runtime(enabled: boolean) {
"Текущая подготовка не принадлежит канонической control-сессии K1.",
);
}
nextState = await waitForControlPhase("project-ready", acceptState);
nextState = await awaitWhileIntentCurrent(
assertOperatorIntentCurrent,
() => waitForControlPhase(
"project-ready",
acceptState,
assertOperatorIntentCurrent,
),
);
continue;
}
if (phase === "project-requested") {
nextState = await waitForControlPhase("project-ready", acceptState);
nextState = await awaitWhileIntentCurrent(
assertOperatorIntentCurrent,
() => waitForControlPhase(
"project-ready",
acceptState,
assertOperatorIntentCurrent,
),
);
continue;
}
@@ -311,11 +498,14 @@ export function useXgridsK1Runtime(enabled: boolean) {
if (!acquisition || acquisition.state !== "prepared") {
throw new ApiError("Локальный приём не подготовлен к каноническому START.");
}
nextState = await xgridsK1Api.startAcquisition({
acquisition_id: acquisition.acquisition_id,
expected_state_revision: acquisition.state_revision,
physical_acceptance: request.physicalAcceptance,
});
nextState = await awaitWhileIntentCurrent(
assertOperatorIntentCurrent,
() => xgridsK1Api.startAcquisition({
acquisition_id: acquisition.acquisition_id,
expected_state_revision: acquisition.state_revision,
physical_acceptance: request.physicalAcceptance,
}),
);
acceptState(nextState);
return nextState;
}
@@ -396,33 +586,6 @@ export function useXgridsK1Runtime(enabled: boolean) {
[run, state],
);
const confirmStoppedAtSteadyGreen = useCallback(
() =>
run("stop", () => {
const acquisition = state?.acquisition;
if (!acquisition || acquisition.state !== "awaiting_external_stop") {
throw new ApiError("K1 сейчас не ожидает подтверждения завершённого STOP.");
}
const stopOperation = [...(state?.operations ?? [])]
.reverse()
.find(
(operation) =>
operation.action === "acquisition.stop" &&
operation.status === "operator_action_required",
);
if (!stopOperation) {
throw new ApiError("Не найдена исходная операция STOP; повтор команды запрещён.");
}
return xgridsK1Api.stopAcquisition({
acquisition_id: acquisition.acquisition_id,
mode: "graceful",
operator_confirmed: true,
operation_id: stopOperation.operation_id,
});
}),
[run, state],
);
const abort = useCallback(() => {
const acquisition = state?.acquisition;
if (!acquisition || isTerminalAcquisitionState(acquisition.state)) {
@@ -485,7 +648,7 @@ export function useXgridsK1Runtime(enabled: boolean) {
useEffect(() => {
if (!enabled) {
mounted.current = true;
operatorIntents.current.deactivateRuntime();
setState(null);
setBackendStatus("checking");
setEventStatus("closed");
@@ -495,12 +658,12 @@ export function useXgridsK1Runtime(enabled: boolean) {
return;
}
mounted.current = true;
operatorIntents.current.activateRuntime();
void refresh(true);
const poll = window.setInterval(() => void refresh(false), 4_000);
return () => {
mounted.current = false;
operatorIntents.current.deactivateRuntime();
window.clearInterval(poll);
};
}, [enabled, refresh]);
@@ -563,7 +726,6 @@ export function useXgridsK1Runtime(enabled: boolean) {
startPreparedAcquisition,
startReplay,
stop,
confirmStoppedAtSteadyGreen,
abort,
setObservationSourceActive,
updateViewerSettings,
+19
View File
@@ -366,6 +366,23 @@ def _validate_acquisition_control(profile: dict[str, Any]) -> None:
if control.get("mode") != "operator-manual" or control.get("write_enabled") is not False:
raise CompatibilityProfileError("acquisition control must remain operator-manual")
acceptance_transport = _object(
control.get("software_acceptance_transport"),
"$.acquisition_control.software_acceptance_transport",
)
if acceptance_transport != {
"status": "installed-operator-present",
"default_authority": "disabled",
"profile_gate": "live-device-info-exact-match",
"dialogue": "single-socket-canonical-start-to-stop",
"automatic_retry": False,
"supported_mount_type": "handheld",
"supported_gnss_mode": "none",
}:
raise CompatibilityProfileError(
"software acceptance transport differs from the reviewed operator-present contract"
)
device_control = _object(
control.get("verified_device_control"),
"$.acquisition_control.verified_device_control",
@@ -497,6 +514,8 @@ def validate_compatibility_profile(profile: Any) -> dict[str, Any]:
scope = _object(root.get("scope"), "$.scope")
if scope.get("vendor") != "XGRIDS" or scope.get("model") != "LixelKity K1":
raise CompatibilityProfileError("profile vendor/model must remain XGRIDS LixelKity K1")
if scope.get("platform_type") != "A4":
raise CompatibilityProfileError("profile platform type must remain the observed A4")
firmware = _object(scope.get("firmware"), "$.scope.firmware")
if firmware != {"match": "exact", "version": "3.0.2"}:
raise CompatibilityProfileError("profile must match firmware 3.0.2 exactly")
@@ -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"
],