feat(k1): complete canonical control lifecycle
This commit is contained in:
parent
d7a2c22faf
commit
ffffee1879
|
|
@ -1,4 +1,5 @@
|
||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
|
import { readFile } from "node:fs/promises";
|
||||||
import { after, before, test } from "node:test";
|
import { after, before, test } from "node:test";
|
||||||
|
|
||||||
import { createServer } from "vite";
|
import { createServer } from "vite";
|
||||||
|
|
@ -9,10 +10,14 @@ let createDevicePluginRegistry;
|
||||||
let xgridsK1Manifest;
|
let xgridsK1Manifest;
|
||||||
let xgridsK1Actions;
|
let xgridsK1Actions;
|
||||||
let xgridsK1Api;
|
let xgridsK1Api;
|
||||||
|
let ApiError;
|
||||||
|
let localizeRuntimeMessage;
|
||||||
let lifecycle;
|
let lifecycle;
|
||||||
let projectName;
|
let projectName;
|
||||||
let automaticSourceStart;
|
let automaticSourceStart;
|
||||||
let presentation;
|
let presentation;
|
||||||
|
let operatorIntentGeneration;
|
||||||
|
let configuration;
|
||||||
|
|
||||||
before(async () => {
|
before(async () => {
|
||||||
server = await createServer({
|
server = await createServer({
|
||||||
|
|
@ -41,9 +46,18 @@ before(async () => {
|
||||||
presentation = await server.ssrLoadModule(
|
presentation = await server.ssrLoadModule(
|
||||||
"@xgrids-k1/frontend/presentation.ts",
|
"@xgrids-k1/frontend/presentation.ts",
|
||||||
);
|
);
|
||||||
({ xgridsK1Api } = await server.ssrLoadModule(
|
operatorIntentGeneration = await server.ssrLoadModule(
|
||||||
|
"@xgrids-k1/frontend/operatorIntentGeneration.ts",
|
||||||
|
);
|
||||||
|
configuration = await server.ssrLoadModule(
|
||||||
|
"@xgrids-k1/frontend/configuration.ts",
|
||||||
|
);
|
||||||
|
({ xgridsK1Api, ApiError } = await server.ssrLoadModule(
|
||||||
"@xgrids-k1/frontend/api.ts",
|
"@xgrids-k1/frontend/api.ts",
|
||||||
));
|
));
|
||||||
|
({ localizeRuntimeMessage } = await server.ssrLoadModule(
|
||||||
|
"@xgrids-k1/frontend/messages.ts",
|
||||||
|
));
|
||||||
});
|
});
|
||||||
|
|
||||||
after(async () => {
|
after(async () => {
|
||||||
|
|
@ -200,6 +214,151 @@ test("installed XGRIDS frontend manifest exposes the semantic v1alpha2 actions",
|
||||||
assert.equal(xgridsK1Actions.acquisitionStop, "acquisition.stop");
|
assert.equal(xgridsK1Actions.acquisitionStop, "acquisition.stop");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("one operator action can open at most one K1 control session", () => {
|
||||||
|
assert.equal(lifecycle.controlSessionEntryPlan("idle", false, true), "open");
|
||||||
|
assert.equal(lifecycle.controlSessionEntryPlan("failed", false, true), "open");
|
||||||
|
assert.equal(lifecycle.controlSessionEntryPlan("failed", false, false), "failed");
|
||||||
|
assert.equal(lifecycle.controlSessionEntryPlan("failed", true, true), "failed");
|
||||||
|
assert.equal(
|
||||||
|
lifecycle.controlSessionEntryPlan("idle", true, true),
|
||||||
|
"duplicate-open",
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
lifecycle.controlSessionEntryPlan("connection-ready", true, false),
|
||||||
|
"continue",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("K1 operator intent stays invalid after runtime deactivate and reactivate", () => {
|
||||||
|
const generation = new operatorIntentGeneration.OperatorIntentGeneration();
|
||||||
|
generation.activateRuntime();
|
||||||
|
const oldIntent = generation.beginOperatorIntent();
|
||||||
|
|
||||||
|
assert.ok(oldIntent);
|
||||||
|
assert.equal(generation.isOperatorIntentCurrent(oldIntent), true);
|
||||||
|
|
||||||
|
generation.deactivateRuntime();
|
||||||
|
generation.activateRuntime();
|
||||||
|
const freshIntent = generation.beginOperatorIntent();
|
||||||
|
|
||||||
|
assert.ok(freshIntent);
|
||||||
|
assert.notEqual(freshIntent.runtimeGeneration, oldIntent.runtimeGeneration);
|
||||||
|
assert.equal(generation.isOperatorIntentCurrent(oldIntent), false);
|
||||||
|
assert.equal(generation.isOperatorIntentCurrent(freshIntent), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("stale K1 intent cannot continue past an await after runtime reactivation", async () => {
|
||||||
|
const generation = new operatorIntentGeneration.OperatorIntentGeneration();
|
||||||
|
generation.activateRuntime();
|
||||||
|
const oldIntent = generation.beginOperatorIntent();
|
||||||
|
assert.ok(oldIntent);
|
||||||
|
|
||||||
|
let resolveOldRead;
|
||||||
|
const oldRead = new Promise((resolve) => {
|
||||||
|
resolveOldRead = resolve;
|
||||||
|
});
|
||||||
|
const writes = [];
|
||||||
|
const assertOldIntent = () => {
|
||||||
|
if (!generation.isOperatorIntentCurrent(oldIntent)) {
|
||||||
|
throw new Error("stale operator intent");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const oldContinuation = operatorIntentGeneration.awaitWhileIntentCurrent(
|
||||||
|
assertOldIntent,
|
||||||
|
() => oldRead,
|
||||||
|
).then(() => writes.push("old-checkpoint"));
|
||||||
|
|
||||||
|
generation.deactivateRuntime();
|
||||||
|
generation.activateRuntime();
|
||||||
|
const freshIntent = generation.beginOperatorIntent();
|
||||||
|
assert.ok(freshIntent);
|
||||||
|
resolveOldRead({ phase: "connection-ready" });
|
||||||
|
|
||||||
|
await assert.rejects(oldContinuation, /stale operator intent/);
|
||||||
|
assert.deepEqual(writes, []);
|
||||||
|
|
||||||
|
const assertFreshIntent = () => {
|
||||||
|
if (!generation.isOperatorIntentCurrent(freshIntent)) {
|
||||||
|
throw new Error("stale fresh intent");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
await operatorIntentGeneration.awaitWhileIntentCurrent(
|
||||||
|
assertFreshIntent,
|
||||||
|
async () => ({ phase: "connection-ready" }),
|
||||||
|
);
|
||||||
|
writes.push("fresh-checkpoint");
|
||||||
|
assert.deepEqual(writes, ["fresh-checkpoint"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a fresh explicit K1 intent supersedes the previous intent in one runtime", () => {
|
||||||
|
const generation = new operatorIntentGeneration.OperatorIntentGeneration();
|
||||||
|
generation.activateRuntime();
|
||||||
|
const first = generation.beginOperatorIntent();
|
||||||
|
const second = generation.beginOperatorIntent();
|
||||||
|
|
||||||
|
assert.ok(first);
|
||||||
|
assert.ok(second);
|
||||||
|
assert.equal(first.runtimeGeneration, second.runtimeGeneration);
|
||||||
|
assert.notEqual(first.intentGeneration, second.intentGeneration);
|
||||||
|
assert.equal(generation.isOperatorIntentCurrent(first), false);
|
||||||
|
assert.equal(generation.isOperatorIntentCurrent(second), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("canonical K1 launch wires the generation guard through every async stage", async () => {
|
||||||
|
const hookSource = await readFile(
|
||||||
|
new URL(
|
||||||
|
"../../../plugins/xgrids-k1/frontend/src/useXgridsK1Runtime.ts",
|
||||||
|
import.meta.url,
|
||||||
|
),
|
||||||
|
"utf8",
|
||||||
|
);
|
||||||
|
const canonicalStart = hookSource.slice(
|
||||||
|
hookSource.indexOf("const startCanonicalAcquisition"),
|
||||||
|
hookSource.indexOf("const prepareAcquisition"),
|
||||||
|
);
|
||||||
|
const pollingLoop = hookSource.slice(
|
||||||
|
hookSource.indexOf("async function waitForControlPhase"),
|
||||||
|
hookSource.indexOf("function messageFor"),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.doesNotMatch(hookSource, /mounted\.current/);
|
||||||
|
assert.match(canonicalStart, /beginOperatorIntent\(\)/);
|
||||||
|
assert.match(canonicalStart, /isOperatorIntentCurrent\(intentToken\)/);
|
||||||
|
assert.doesNotMatch(canonicalStart, /await xgridsK1Api\./);
|
||||||
|
assert.doesNotMatch(canonicalStart, /await waitForControlPhase\(/);
|
||||||
|
assert.ok(
|
||||||
|
canonicalStart.match(/awaitWhileIntentCurrent\(/g)?.length >= 8,
|
||||||
|
"each canonical REST/checkpoint boundary must use the intent guard",
|
||||||
|
);
|
||||||
|
assert.match(pollingLoop, /for \(;;\) \{\s*assertOperatorIntentCurrent\(\)/);
|
||||||
|
assert.equal(pollingLoop.match(/awaitWhileIntentCurrent\(/g)?.length, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("K1 control errors stay informative and only fetch failures mark transport unavailable", async () => {
|
||||||
|
assert.equal(
|
||||||
|
localizeRuntimeMessage(
|
||||||
|
"control MQTT connect call failed: [Errno 61] Connection refused",
|
||||||
|
),
|
||||||
|
"Управляющее соединение со сканером не открылось: устройство не приняло MQTT-соединение. Команды сканирования не отправлялись.",
|
||||||
|
);
|
||||||
|
|
||||||
|
const domainError = new ApiError("Диалог остановлен до команды START.");
|
||||||
|
assert.equal(domainError.transportUnavailable, false);
|
||||||
|
|
||||||
|
const originalFetch = globalThis.fetch;
|
||||||
|
globalThis.fetch = async () => {
|
||||||
|
throw new TypeError("synthetic network failure");
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
await assert.rejects(
|
||||||
|
() => xgridsK1Api.getState(),
|
||||||
|
(error) => error instanceof ApiError && error.transportUnavailable === true,
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
globalThis.fetch = originalFetch;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test("v1alpha2 compatibility profile is exact-key and rejects duplicated profile status", () => {
|
test("v1alpha2 compatibility profile is exact-key and rejects duplicated profile status", () => {
|
||||||
const document = manifestDocument({ apiVersion: "missioncore.nodedc/v1alpha2" });
|
const document = manifestDocument({ apiVersion: "missioncore.nodedc/v1alpha2" });
|
||||||
document.spec.compatibilityProfiles[0].status = "verified";
|
document.spec.compatibilityProfiles[0].status = "verified";
|
||||||
|
|
@ -404,6 +563,48 @@ test("replay ignores a stale failed live acquisition", () => {
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("K1 configuration anchors expose future modes without enabling them", () => {
|
||||||
|
assert.equal(configuration.SUPPORTED_CONNECTION_MODE, "bridge");
|
||||||
|
assert.equal(configuration.SUPPORTED_MOUNT_TYPE, "handheld");
|
||||||
|
assert.equal(configuration.SUPPORTED_GNSS_MODE, "none");
|
||||||
|
assert.deepEqual(
|
||||||
|
configuration.connectionModeOptions.map(({ value, disabled = false }) => ({ value, disabled })),
|
||||||
|
[
|
||||||
|
{ value: "bridge", disabled: false },
|
||||||
|
{ value: "quick-connect", disabled: true },
|
||||||
|
{ value: "direct-connect", disabled: true },
|
||||||
|
],
|
||||||
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
configuration.mountTypeOptions.map(({ value, disabled = false }) => ({ value, disabled })),
|
||||||
|
[
|
||||||
|
{ value: "handheld", disabled: false },
|
||||||
|
{ value: "vehicle-mounted", disabled: true },
|
||||||
|
{ value: "uav", disabled: true },
|
||||||
|
{ value: "backpack", disabled: true },
|
||||||
|
],
|
||||||
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
configuration.gnssModeOptions.map(({ value, disabled = false }) => ({ value, disabled })),
|
||||||
|
[
|
||||||
|
{ value: "none", disabled: false },
|
||||||
|
{ value: "rtk", disabled: true },
|
||||||
|
{ value: "ppk", disabled: true },
|
||||||
|
],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a provisioned address is not presented as a verified device connection", () => {
|
||||||
|
assert.equal(lifecycle.normalizeRuntimePhase({
|
||||||
|
phase: "connected",
|
||||||
|
application_control_session: { state: "idle" },
|
||||||
|
}), "configuring");
|
||||||
|
assert.equal(lifecycle.normalizeRuntimePhase({
|
||||||
|
phase: "connected",
|
||||||
|
application_control_session: { state: "connection-ready" },
|
||||||
|
}), "connected");
|
||||||
|
});
|
||||||
|
|
||||||
test("provisioning intent keeps one idempotency key and exposes unsafe outcomes", () => {
|
test("provisioning intent keeps one idempotency key and exposes unsafe outcomes", () => {
|
||||||
let created = 0;
|
let created = 0;
|
||||||
const createUuid = () => {
|
const createUuid = () => {
|
||||||
|
|
@ -437,7 +638,7 @@ test("device mutations send explicit nested compatibility attestation", async ()
|
||||||
const attestation = {
|
const attestation = {
|
||||||
firmware_version: "3.0.2",
|
firmware_version: "3.0.2",
|
||||||
topology: "direct-lan",
|
topology: "direct-lan",
|
||||||
operator_confirmed: true,
|
verification: "live-device-info",
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
@ -445,11 +646,14 @@ test("device mutations send explicit nested compatibility attestation", async ()
|
||||||
device_id: "ble-device",
|
device_id: "ble-device",
|
||||||
ssid: "lab-network",
|
ssid: "lab-network",
|
||||||
password: syntheticCredential,
|
password: syntheticCredential,
|
||||||
|
connection_mode: "bridge",
|
||||||
compatibility_attestation: attestation,
|
compatibility_attestation: attestation,
|
||||||
idempotency_key: "network-provision:test",
|
idempotency_key: "network-provision:test",
|
||||||
});
|
});
|
||||||
await xgridsK1Api.prepareAcquisition({
|
await xgridsK1Api.prepareAcquisition({
|
||||||
project_name: "Mission 01",
|
project_name: "Mission 01",
|
||||||
|
mount_type: "handheld",
|
||||||
|
gnss_mode: "none",
|
||||||
compatibility_attestation: attestation,
|
compatibility_attestation: attestation,
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
import { useCallback, useState } from "react";
|
|
||||||
import { Button, StatusBadge, type StatusTone } from "@nodedc/ui-react";
|
import { Button, StatusBadge, type StatusTone } from "@nodedc/ui-react";
|
||||||
|
|
||||||
import type { DevicePluginConnectionProps } from "@mission-core/plugin-sdk";
|
import type { DevicePluginConnectionProps } from "@mission-core/plugin-sdk";
|
||||||
|
|
@ -19,10 +18,6 @@ import { useXgridsK1Controller } from "./runtimeContext";
|
||||||
export function XgridsK1Connection({ model, host }: DevicePluginConnectionProps) {
|
export function XgridsK1Connection({ model, host }: DevicePluginConnectionProps) {
|
||||||
const controller = useXgridsK1Controller();
|
const controller = useXgridsK1Controller();
|
||||||
const { state, error, refresh, clearError } = controller;
|
const { state, error, refresh, clearError } = controller;
|
||||||
const [profileConfirmed, setProfileConfirmed] = useState(false);
|
|
||||||
const updateProfileConfirmation = useCallback((confirmed: boolean) => {
|
|
||||||
setProfileConfirmed(confirmed);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const confirmedLive = isConfirmedLiveState(state);
|
const confirmedLive = isConfirmedLiveState(state);
|
||||||
const sourceRuntimeBusy = isSourceRuntimeBusy(state);
|
const sourceRuntimeBusy = isSourceRuntimeBusy(state);
|
||||||
|
|
@ -51,7 +46,7 @@ export function XgridsK1Connection({ model, host }: DevicePluginConnectionProps)
|
||||||
<span className="error-banner__dot" aria-hidden="true" />
|
<span className="error-banner__dot" aria-hidden="true" />
|
||||||
<div>
|
<div>
|
||||||
<strong>Локальная операция завершилась ошибкой</strong>
|
<strong>Локальная операция завершилась ошибкой</strong>
|
||||||
<p>{localizeRuntimeMessage(error)}</p>
|
<p>{error}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="error-banner__actions">
|
<div className="error-banner__actions">
|
||||||
<Button size="compact" variant="secondary" onClick={() => void refresh()}>Обновить состояние</Button>
|
<Button size="compact" variant="secondary" onClick={() => void refresh()}>Обновить состояние</Button>
|
||||||
|
|
@ -79,13 +74,10 @@ export function XgridsK1Connection({ model, host }: DevicePluginConnectionProps)
|
||||||
controller={controller}
|
controller={controller}
|
||||||
phaseLabel={connectionPhaseLabel}
|
phaseLabel={connectionPhaseLabel}
|
||||||
phaseTone={connectionPhaseTone}
|
phaseTone={connectionPhaseTone}
|
||||||
profileConfirmed={profileConfirmed}
|
|
||||||
onProfileConfirmedChange={updateProfileConfirmation}
|
|
||||||
/>
|
/>
|
||||||
<div className="device-workspace__side">
|
<div className="device-workspace__side">
|
||||||
<K1AcquisitionPipeline
|
<K1AcquisitionPipeline
|
||||||
controller={controller}
|
controller={controller}
|
||||||
profileConfirmed={profileConfirmed}
|
|
||||||
openSpatialScene={host.openSpatialScene}
|
openSpatialScene={host.openSpatialScene}
|
||||||
activateAutomaticSpatialSource={host.activateAutomaticSpatialSource}
|
activateAutomaticSpatialSource={host.activateAutomaticSpatialSource}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -138,7 +138,34 @@ export interface XgridsApplicationControlSession {
|
||||||
outcome_unknown: boolean;
|
outcome_unknown: boolean;
|
||||||
failure?: {
|
failure?: {
|
||||||
code?: string;
|
code?: string;
|
||||||
|
reason_code?: string;
|
||||||
message?: string;
|
message?: string;
|
||||||
|
failed_phase?: string | null;
|
||||||
|
dialogue_stage?: string | null;
|
||||||
|
transport_state?: string | null;
|
||||||
|
publish_attempts?: number | null;
|
||||||
|
qos2_completions?: number | null;
|
||||||
|
correlated_responses?: number | null;
|
||||||
|
ignored_known_responses?: number | null;
|
||||||
|
late_known_responses?: number | null;
|
||||||
|
modeling_command_attempted?: boolean | null;
|
||||||
|
diagnostic_snapshot_unavailable?: string[];
|
||||||
|
diagnostic_evidence_unavailable?: string[];
|
||||||
|
correlation_failure?: {
|
||||||
|
phase?: string;
|
||||||
|
operation_key?: string;
|
||||||
|
response_topic?: string;
|
||||||
|
reason_code?: string;
|
||||||
|
reason?: string;
|
||||||
|
} | null;
|
||||||
|
compatibility_failure?: {
|
||||||
|
phase?: string;
|
||||||
|
operation_key?: string;
|
||||||
|
response_topic?: string;
|
||||||
|
reason_code?: string;
|
||||||
|
expected?: Record<string, unknown>;
|
||||||
|
observed?: Record<string, unknown>;
|
||||||
|
} | null;
|
||||||
safe_to_retry?: boolean;
|
safe_to_retry?: boolean;
|
||||||
} | null;
|
} | null;
|
||||||
dialogue?: Record<string, unknown> | null;
|
dialogue?: Record<string, unknown> | null;
|
||||||
|
|
@ -153,6 +180,8 @@ export interface XgridsAcquisition {
|
||||||
compatibility_profile_id: string;
|
compatibility_profile_id: string;
|
||||||
control_mode: "operator-manual" | "plugin-commanded" | "observe-only";
|
control_mode: "operator-manual" | "plugin-commanded" | "observe-only";
|
||||||
project_name?: string | null;
|
project_name?: string | null;
|
||||||
|
mount_type?: "handheld" | null;
|
||||||
|
gnss_mode?: "none" | null;
|
||||||
cleanup_pending?: boolean;
|
cleanup_pending?: boolean;
|
||||||
requested_streams: string[];
|
requested_streams: string[];
|
||||||
target_host: string;
|
target_host: string;
|
||||||
|
|
@ -260,6 +289,7 @@ export interface XgridsK1State {
|
||||||
devices?: BleDevice[];
|
devices?: BleDevice[];
|
||||||
selected_device_id?: string | null;
|
selected_device_id?: string | null;
|
||||||
k1_ip?: string | null;
|
k1_ip?: string | null;
|
||||||
|
connection_mode?: "bridge" | null;
|
||||||
foxglove_ws_url?: string | null;
|
foxglove_ws_url?: string | null;
|
||||||
foxglove_viewer_url?: string | null;
|
foxglove_viewer_url?: string | null;
|
||||||
rerun_grpc_url?: string | null;
|
rerun_grpc_url?: string | null;
|
||||||
|
|
@ -293,13 +323,14 @@ export interface ScanRequest {
|
||||||
export interface CompatibilityAttestation {
|
export interface CompatibilityAttestation {
|
||||||
firmware_version: "3.0.2";
|
firmware_version: "3.0.2";
|
||||||
topology: "direct-lan";
|
topology: "direct-lan";
|
||||||
operator_confirmed: true;
|
verification: "live-device-info";
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ConnectRequest {
|
export interface ConnectRequest {
|
||||||
device_id: string;
|
device_id: string;
|
||||||
ssid: string;
|
ssid: string;
|
||||||
password: string;
|
password: string;
|
||||||
|
connection_mode: "bridge";
|
||||||
compatibility_attestation: CompatibilityAttestation;
|
compatibility_attestation: CompatibilityAttestation;
|
||||||
operation_id?: string;
|
operation_id?: string;
|
||||||
idempotency_key?: string;
|
idempotency_key?: string;
|
||||||
|
|
@ -307,6 +338,8 @@ export interface ConnectRequest {
|
||||||
|
|
||||||
export interface PrepareAcquisitionRequest {
|
export interface PrepareAcquisitionRequest {
|
||||||
project_name: string;
|
project_name: string;
|
||||||
|
mount_type: "handheld";
|
||||||
|
gnss_mode: "none";
|
||||||
host?: string;
|
host?: string;
|
||||||
duration_seconds?: number;
|
duration_seconds?: number;
|
||||||
requested_streams?: RequestedStreamId[];
|
requested_streams?: RequestedStreamId[];
|
||||||
|
|
@ -398,11 +431,13 @@ export interface EnterApplicationWorkspaceRequest {
|
||||||
|
|
||||||
export class ApiError extends Error {
|
export class ApiError extends Error {
|
||||||
readonly status: number;
|
readonly status: number;
|
||||||
|
readonly transportUnavailable: boolean;
|
||||||
|
|
||||||
constructor(message: string, status = 0) {
|
constructor(message: string, status = 0, transportUnavailable = false) {
|
||||||
super(message);
|
super(message);
|
||||||
this.name = "ApiError";
|
this.name = "ApiError";
|
||||||
this.status = status;
|
this.status = status;
|
||||||
|
this.transportUnavailable = transportUnavailable;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -433,7 +468,11 @@ async function requestJson(path: string, init?: RequestInit): Promise<unknown> {
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
throw new ApiError("Не удалось подключиться к локальному сервису устройства.");
|
throw new ApiError(
|
||||||
|
"Не удалось подключиться к локальному сервису устройства.",
|
||||||
|
0,
|
||||||
|
true,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const bodyText = await response.text();
|
const bodyText = await response.text();
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import type { CompatibilityAttestation } from "./api";
|
import type { CompatibilityAttestation } from "./api";
|
||||||
|
|
||||||
export const EXACT_PROFILE_ATTESTATION: CompatibilityAttestation = Object.freeze({
|
export const EXACT_PROFILE_SELECTION: CompatibilityAttestation = Object.freeze({
|
||||||
firmware_version: "3.0.2",
|
firmware_version: "3.0.2",
|
||||||
topology: "direct-lan",
|
topology: "direct-lan",
|
||||||
operator_confirmed: true,
|
verification: "live-device-info",
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -4,13 +4,22 @@ import {
|
||||||
Checker,
|
Checker,
|
||||||
GlassSurface,
|
GlassSurface,
|
||||||
Icon,
|
Icon,
|
||||||
|
Select,
|
||||||
SegmentedControl,
|
SegmentedControl,
|
||||||
StatusBadge,
|
StatusBadge,
|
||||||
TextField,
|
TextField,
|
||||||
type StatusTone,
|
type StatusTone,
|
||||||
} from "@nodedc/ui-react";
|
} from "@nodedc/ui-react";
|
||||||
|
|
||||||
import { EXACT_PROFILE_ATTESTATION } from "../compatibility";
|
import { EXACT_PROFILE_SELECTION } from "../compatibility";
|
||||||
|
import {
|
||||||
|
SUPPORTED_GNSS_MODE,
|
||||||
|
SUPPORTED_MOUNT_TYPE,
|
||||||
|
gnssModeOptions,
|
||||||
|
mountTypeOptions,
|
||||||
|
type GnssMode,
|
||||||
|
type MountType,
|
||||||
|
} from "../configuration";
|
||||||
import { runAutomaticSpatialSourceStart } from "../automaticSourceStart";
|
import { runAutomaticSpatialSourceStart } from "../automaticSourceStart";
|
||||||
import {
|
import {
|
||||||
isConfirmedLiveState,
|
isConfirmedLiveState,
|
||||||
|
|
@ -41,12 +50,10 @@ const PHYSICAL_ACCEPTANCE = {
|
||||||
|
|
||||||
export function K1AcquisitionPipeline({
|
export function K1AcquisitionPipeline({
|
||||||
controller,
|
controller,
|
||||||
profileConfirmed,
|
|
||||||
openSpatialScene,
|
openSpatialScene,
|
||||||
activateAutomaticSpatialSource,
|
activateAutomaticSpatialSource,
|
||||||
}: {
|
}: {
|
||||||
controller: XgridsK1Controller;
|
controller: XgridsK1Controller;
|
||||||
profileConfirmed: boolean;
|
|
||||||
openSpatialScene: () => void;
|
openSpatialScene: () => void;
|
||||||
activateAutomaticSpatialSource: () => void;
|
activateAutomaticSpatialSource: () => void;
|
||||||
}) {
|
}) {
|
||||||
|
|
@ -57,7 +64,6 @@ export function K1AcquisitionPipeline({
|
||||||
startCanonicalAcquisition,
|
startCanonicalAcquisition,
|
||||||
startReplay,
|
startReplay,
|
||||||
stop,
|
stop,
|
||||||
confirmStoppedAtSteadyGreen,
|
|
||||||
abort,
|
abort,
|
||||||
} = controller;
|
} = controller;
|
||||||
const [sessionIntent, setSessionIntent] = useState<SessionIntent>("live");
|
const [sessionIntent, setSessionIntent] = useState<SessionIntent>("live");
|
||||||
|
|
@ -66,7 +72,8 @@ export function K1AcquisitionPipeline({
|
||||||
const [replayPath, setReplayPath] = useState("");
|
const [replayPath, setReplayPath] = useState("");
|
||||||
const [replaySpeed, setReplaySpeed] = useState("1");
|
const [replaySpeed, setReplaySpeed] = useState("1");
|
||||||
const [replayLoop, setReplayLoop] = useState(false);
|
const [replayLoop, setReplayLoop] = useState(false);
|
||||||
const [physicalAcceptanceConfirmed, setPhysicalAcceptanceConfirmed] = useState(false);
|
const [mountType, setMountType] = useState<MountType>(SUPPORTED_MOUNT_TYPE);
|
||||||
|
const [gnssMode, setGnssMode] = useState<GnssMode>(SUPPORTED_GNSS_MODE);
|
||||||
const hydratedAcquisitionId = useRef<string | null>(null);
|
const hydratedAcquisitionId = useRef<string | null>(null);
|
||||||
|
|
||||||
const activeAcquisition = recoverableAcquisition(state);
|
const activeAcquisition = recoverableAcquisition(state);
|
||||||
|
|
@ -125,8 +132,6 @@ export function K1AcquisitionPipeline({
|
||||||
const startLive = async () => {
|
const startLive = async () => {
|
||||||
setProjectNameTouched(true);
|
setProjectNameTouched(true);
|
||||||
if (
|
if (
|
||||||
!profileConfirmed ||
|
|
||||||
!physicalAcceptanceConfirmed ||
|
|
||||||
!state?.k1_ip ||
|
!state?.k1_ip ||
|
||||||
sourceRuntimeBusy ||
|
sourceRuntimeBusy ||
|
||||||
launchBlockedByAcquisition ||
|
launchBlockedByAcquisition ||
|
||||||
|
|
@ -142,7 +147,9 @@ export function K1AcquisitionPipeline({
|
||||||
},
|
},
|
||||||
acquisition: {
|
acquisition: {
|
||||||
project_name: projectNameValidation.value,
|
project_name: projectNameValidation.value,
|
||||||
compatibility_attestation: EXACT_PROFILE_ATTESTATION,
|
mount_type: SUPPORTED_MOUNT_TYPE,
|
||||||
|
gnss_mode: SUPPORTED_GNSS_MODE,
|
||||||
|
compatibility_attestation: EXACT_PROFILE_SELECTION,
|
||||||
},
|
},
|
||||||
physicalAcceptance: PHYSICAL_ACCEPTANCE,
|
physicalAcceptance: PHYSICAL_ACCEPTANCE,
|
||||||
}),
|
}),
|
||||||
|
|
@ -181,6 +188,30 @@ export function K1AcquisitionPipeline({
|
||||||
/>
|
/>
|
||||||
{effectiveSessionIntent === "live" ? (
|
{effectiveSessionIntent === "live" ? (
|
||||||
<div className="session-form">
|
<div className="session-form">
|
||||||
|
<div className="scan-configuration-grid">
|
||||||
|
<div className="configuration-field">
|
||||||
|
<span className="nodedc-field__description">Тип установки / носитель</span>
|
||||||
|
<Select
|
||||||
|
label="Тип установки / носитель"
|
||||||
|
value={mountType}
|
||||||
|
options={mountTypeOptions}
|
||||||
|
onChange={setMountType}
|
||||||
|
disabled={isBusy || sessionLocked}
|
||||||
|
variant="split"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="configuration-field">
|
||||||
|
<span className="nodedc-field__description">Режим GNSS</span>
|
||||||
|
<Select
|
||||||
|
label="Режим GNSS"
|
||||||
|
value={gnssMode}
|
||||||
|
options={gnssModeOptions}
|
||||||
|
onChange={setGnssMode}
|
||||||
|
disabled={isBusy || sessionLocked}
|
||||||
|
variant="split"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<TextField
|
<TextField
|
||||||
label="Название проекта"
|
label="Название проекта"
|
||||||
hint="Имя войдёт в единственный канонический START"
|
hint="Имя войдёт в единственный канонический START"
|
||||||
|
|
@ -198,19 +229,12 @@ export function K1AcquisitionPipeline({
|
||||||
: "Отдельной команды сохранения имени на K1 нет: оно отправляется только при START."}
|
: "Отдельной команды сохранения имени на K1 нет: оно отправляется только при START."}
|
||||||
placeholder="Например, TEST001"
|
placeholder="Например, TEST001"
|
||||||
/>
|
/>
|
||||||
<Checker
|
|
||||||
checked={physicalAcceptanceConfirmed}
|
|
||||||
label="Я рядом с выбранным K1; LixelGO закрыт; питание и место для записи проверены; индикатор постоянно зелёный"
|
|
||||||
onChange={setPhysicalAcceptanceConfirmed}
|
|
||||||
/>
|
|
||||||
<Button
|
<Button
|
||||||
variant="primary"
|
variant="primary"
|
||||||
icon={<Icon name="activity" />}
|
icon={<Icon name="activity" />}
|
||||||
disabled={
|
disabled={
|
||||||
isBusy ||
|
isBusy ||
|
||||||
!profileConfirmed ||
|
|
||||||
!state?.k1_ip ||
|
!state?.k1_ip ||
|
||||||
!physicalAcceptanceConfirmed ||
|
|
||||||
projectNameValidation.error !== null ||
|
projectNameValidation.error !== null ||
|
||||||
sourceRuntimeBusy ||
|
sourceRuntimeBusy ||
|
||||||
launchBlockedByAcquisition ||
|
launchBlockedByAcquisition ||
|
||||||
|
|
@ -232,6 +256,9 @@ export function K1AcquisitionPipeline({
|
||||||
? "Продолжить запуск сканирования и приёма"
|
? "Продолжить запуск сканирования и приёма"
|
||||||
: "Запустить сканирование и локальный приём"}
|
: "Запустить сканирование и локальный приём"}
|
||||||
</Button>
|
</Button>
|
||||||
|
<p className="start-confirmation-note">
|
||||||
|
Нажатие запуска — явное операторское действие для выбранного K1. Автоматических повторов START нет.
|
||||||
|
</p>
|
||||||
{control?.control_socket_open && !activeAcquisition && !isBusy ? (
|
{control?.control_socket_open && !activeAcquisition && !isBusy ? (
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
|
|
@ -242,9 +269,7 @@ export function K1AcquisitionPipeline({
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
<p className="live-instruction">
|
<p className="live-instruction">
|
||||||
{!profileConfirmed
|
{controlPhase === "failed"
|
||||||
? "Сначала вручную подтвердите FW 3.0.2 и direct-LAN. Интерфейс не аттестует устройство автоматически."
|
|
||||||
: controlPhase === "failed"
|
|
||||||
? `Диалог остановлен без автоповтора: ${control?.failure?.message || "требуется ручная проверка K1"}`
|
? `Диалог остановлен без автоповтора: ${control?.failure?.message || "требуется ручная проверка K1"}`
|
||||||
: controlPhase === "connecting"
|
: controlPhase === "connecting"
|
||||||
? "Выполняются операции 1–6 записанного диалога; следующий этап ждёт подтверждённый ответ K1."
|
? "Выполняются операции 1–6 записанного диалога; следующий этап ждёт подтверждённый ответ K1."
|
||||||
|
|
@ -256,7 +281,7 @@ export function K1AcquisitionPipeline({
|
||||||
? "Калибровка оборудования. Не перемещайте K1; временных переходов и повторных команд нет."
|
? "Калибровка оборудования. Не перемещайте K1; временных переходов и повторных команд нет."
|
||||||
: controlPhase === "scanning"
|
: controlPhase === "scanning"
|
||||||
? "K1 подтвердил SCANNING и инициализацию. Остановка доступна в пространственной сцене."
|
? "K1 подтвердил SCANNING и инициализацию. Остановка доступна в пространственной сцене."
|
||||||
: "Одна кнопка выражает намерение запустить сканирование. Внутри этапы идут строго по записанному порядку и только после ответов K1; человеческие паузы из capture не воспроизводятся."}
|
: "Одна кнопка выражает намерение запустить сканирование. Совместимость подтверждается живым DeviceInfo; этапы идут строго по записанному порядку и только после ответов K1."}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
|
@ -282,15 +307,6 @@ export function K1AcquisitionPipeline({
|
||||||
: "Остановка завершает только локальный приём и сохранение. Физическое состояние сканера остаётся неизвестным."
|
: "Остановка завершает только локальный приём и сохранение. Физическое состояние сканера остаётся неизвестным."
|
||||||
: "Активного источника сейчас нет."}
|
: "Активного источника сейчас нет."}
|
||||||
</p>
|
</p>
|
||||||
{control?.can_confirm_standby ? (
|
|
||||||
<Button
|
|
||||||
variant="primary"
|
|
||||||
disabled={isBusy}
|
|
||||||
onClick={() => void confirmStoppedAtSteadyGreen()}
|
|
||||||
>
|
|
||||||
Индикатор постоянно зелёный — завершить запись
|
|
||||||
</Button>
|
|
||||||
) : null}
|
|
||||||
<Button
|
<Button
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
disabled={isBusy || (!sourceRuntimeBusy && preparedAcquisition !== null) || (!sourceRuntimeBusy && activeAcquisition === null)}
|
disabled={isBusy || (!sourceRuntimeBusy && preparedAcquisition !== null) || (!sourceRuntimeBusy && activeAcquisition === null)}
|
||||||
|
|
|
||||||
|
|
@ -4,13 +4,19 @@ import {
|
||||||
Checker,
|
Checker,
|
||||||
GlassSurface,
|
GlassSurface,
|
||||||
Icon,
|
Icon,
|
||||||
|
Select,
|
||||||
StatusBadge,
|
StatusBadge,
|
||||||
TextField,
|
TextField,
|
||||||
type StatusTone,
|
type StatusTone,
|
||||||
} from "@nodedc/ui-react";
|
} from "@nodedc/ui-react";
|
||||||
|
|
||||||
import type { BleDevice } from "../api";
|
import type { BleDevice } from "../api";
|
||||||
import { EXACT_PROFILE_ATTESTATION } from "../compatibility";
|
import { EXACT_PROFILE_SELECTION } from "../compatibility";
|
||||||
|
import {
|
||||||
|
SUPPORTED_CONNECTION_MODE,
|
||||||
|
connectionModeOptions,
|
||||||
|
type ConnectionMode,
|
||||||
|
} from "../configuration";
|
||||||
import { provisioningIntentKey } from "../lifecycle";
|
import { provisioningIntentKey } from "../lifecycle";
|
||||||
import { finiteMetric } from "../presentation";
|
import { finiteMetric } from "../presentation";
|
||||||
import type { XgridsK1Controller } from "../runtimeContext";
|
import type { XgridsK1Controller } from "../runtimeContext";
|
||||||
|
|
@ -79,30 +85,28 @@ export function K1ProvisioningPipeline({
|
||||||
controller,
|
controller,
|
||||||
phaseLabel,
|
phaseLabel,
|
||||||
phaseTone,
|
phaseTone,
|
||||||
profileConfirmed,
|
|
||||||
onProfileConfirmedChange,
|
|
||||||
}: {
|
}: {
|
||||||
controller: XgridsK1Controller;
|
controller: XgridsK1Controller;
|
||||||
phaseLabel: string;
|
phaseLabel: string;
|
||||||
phaseTone: StatusTone;
|
phaseTone: StatusTone;
|
||||||
profileConfirmed: boolean;
|
|
||||||
onProfileConfirmedChange: (confirmed: boolean) => void;
|
|
||||||
}) {
|
}) {
|
||||||
const { state, pendingAction, scan, connect } = controller;
|
const { state, pendingAction, scan, connect } = controller;
|
||||||
const [powerConfirmed, setPowerConfirmed] = useState(false);
|
const [powerConfirmed, setPowerConfirmed] = useState(false);
|
||||||
const [selectedDeviceId, setSelectedDeviceId] = useState("");
|
const [selectedDeviceId, setSelectedDeviceId] = useState("");
|
||||||
const [ssid, setSsid] = useState("");
|
const [ssid, setSsid] = useState("");
|
||||||
const [password, setPassword] = useState("");
|
const [password, setPassword] = useState("");
|
||||||
|
const [connectionMode, setConnectionMode] = useState<ConnectionMode>(
|
||||||
|
SUPPORTED_CONNECTION_MODE,
|
||||||
|
);
|
||||||
const provisioningIntentRef = useRef<string | null>(null);
|
const provisioningIntentRef = useRef<string | null>(null);
|
||||||
const devices = state?.devices ?? [];
|
const devices = state?.devices ?? [];
|
||||||
const isBusy = pendingAction !== null;
|
const isBusy = pendingAction !== null;
|
||||||
const credentialsReady = ssid.trim().length > 0 && password.length > 0;
|
const credentialsReady = ssid.trim().length > 0 && password.length > 0;
|
||||||
const canConnect = powerConfirmed && profileConfirmed && selectedDeviceId.length > 0 && credentialsReady && !isBusy;
|
const canConnect = powerConfirmed && selectedDeviceId.length > 0 && credentialsReady && !isBusy;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (state?.selected_device_id) {
|
if (state?.selected_device_id) {
|
||||||
if (state.selected_device_id !== selectedDeviceId) {
|
if (state.selected_device_id !== selectedDeviceId) {
|
||||||
onProfileConfirmedChange(false);
|
|
||||||
provisioningIntentRef.current = null;
|
provisioningIntentRef.current = null;
|
||||||
}
|
}
|
||||||
setSelectedDeviceId(state.selected_device_id);
|
setSelectedDeviceId(state.selected_device_id);
|
||||||
|
|
@ -111,15 +115,14 @@ export function K1ProvisioningPipeline({
|
||||||
if (selectedDeviceId && state?.devices && !state.devices.some((device) => device.device_id === selectedDeviceId)) {
|
if (selectedDeviceId && state?.devices && !state.devices.some((device) => device.device_id === selectedDeviceId)) {
|
||||||
setSelectedDeviceId("");
|
setSelectedDeviceId("");
|
||||||
}
|
}
|
||||||
}, [onProfileConfirmedChange, selectedDeviceId, state?.devices, state?.selected_device_id]);
|
}, [selectedDeviceId, state?.devices, state?.selected_device_id]);
|
||||||
|
|
||||||
const deviceSummary = useMemo(
|
const deviceSummary = useMemo(
|
||||||
() => devices.find((device) => device.device_id === selectedDeviceId),
|
() => devices.find((device) => device.device_id === selectedDeviceId),
|
||||||
[devices, selectedDeviceId],
|
[devices, selectedDeviceId],
|
||||||
);
|
);
|
||||||
|
|
||||||
const resetProfile = () => {
|
const resetProvisioningIntent = () => {
|
||||||
onProfileConfirmedChange(false);
|
|
||||||
provisioningIntentRef.current = null;
|
provisioningIntentRef.current = null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -131,7 +134,8 @@ export function K1ProvisioningPipeline({
|
||||||
device_id: selectedDeviceId,
|
device_id: selectedDeviceId,
|
||||||
ssid: ssid.trim(),
|
ssid: ssid.trim(),
|
||||||
password,
|
password,
|
||||||
compatibility_attestation: EXACT_PROFILE_ATTESTATION,
|
connection_mode: SUPPORTED_CONNECTION_MODE,
|
||||||
|
compatibility_attestation: EXACT_PROFILE_SELECTION,
|
||||||
idempotency_key: idempotencyKey,
|
idempotency_key: idempotencyKey,
|
||||||
});
|
});
|
||||||
if (succeeded) {
|
if (succeeded) {
|
||||||
|
|
@ -146,6 +150,22 @@ export function K1ProvisioningPipeline({
|
||||||
<div><span className="section-eyebrow">ПОДКЛЮЧЕНИЕ · ШАГИ 01–03</span><h2>Подключите устройство к сети</h2></div>
|
<div><span className="section-eyebrow">ПОДКЛЮЧЕНИЕ · ШАГИ 01–03</span><h2>Подключите устройство к сети</h2></div>
|
||||||
<StatusBadge tone={phaseTone}>{phaseLabel}</StatusBadge>
|
<StatusBadge tone={phaseTone}>{phaseLabel}</StatusBadge>
|
||||||
</header>
|
</header>
|
||||||
|
<div className="configuration-anchor">
|
||||||
|
<span className="nodedc-field__description">
|
||||||
|
Неподтверждённые сетевые топологии уже отражены в интерфейсе, но не могут быть выбраны до отдельной приёмки.
|
||||||
|
</span>
|
||||||
|
<Select
|
||||||
|
label="Способ подключения"
|
||||||
|
value={connectionMode}
|
||||||
|
options={connectionModeOptions}
|
||||||
|
onChange={(value) => {
|
||||||
|
setConnectionMode(value);
|
||||||
|
resetProvisioningIntent();
|
||||||
|
}}
|
||||||
|
disabled={isBusy}
|
||||||
|
variant="split"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<div className="wizard-list">
|
<div className="wizard-list">
|
||||||
<WizardStep number="01" title="Включите устройство" status={powerConfirmed ? "Подтверждено" : "Ожидает"} tone={powerConfirmed ? "success" : "warning"}>
|
<WizardStep number="01" title="Включите устройство" status={powerConfirmed ? "Подтверждено" : "Ожидает"} tone={powerConfirmed ? "success" : "warning"}>
|
||||||
<div className="nodedc-field">
|
<div className="nodedc-field">
|
||||||
|
|
@ -153,7 +173,7 @@ export function K1ProvisioningPipeline({
|
||||||
<Checker
|
<Checker
|
||||||
checked={powerConfirmed}
|
checked={powerConfirmed}
|
||||||
label="Устройство включено, индикатор стабилен"
|
label="Устройство включено, индикатор стабилен"
|
||||||
onChange={(checked) => { setPowerConfirmed(checked); if (!checked) resetProfile(); }}
|
onChange={(checked) => { setPowerConfirmed(checked); if (!checked) resetProvisioningIntent(); }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</WizardStep>
|
</WizardStep>
|
||||||
|
|
@ -163,13 +183,13 @@ export function K1ProvisioningPipeline({
|
||||||
status={pendingAction === "scan" ? "Поиск…" : selectedDeviceId ? "Устройство выбрано" : `Найдено: ${devices.length}`}
|
status={pendingAction === "scan" ? "Поиск…" : selectedDeviceId ? "Устройство выбрано" : `Найдено: ${devices.length}`}
|
||||||
tone={pendingAction === "scan" ? "accent" : selectedDeviceId ? "success" : "neutral"}
|
tone={pendingAction === "scan" ? "accent" : selectedDeviceId ? "success" : "neutral"}
|
||||||
>
|
>
|
||||||
<p className="step-copy">Поиск занимает 6 секунд и показывает все видимые BLE-устройства. Метка кандидата основана только на имени; модель и прошивку подтверждает оператор.</p>
|
<p className="step-copy">Поиск занимает 6 секунд и показывает все видимые BLE-устройства. Метка кандидата основана только на имени; точные модель, platform type и прошивка будут проверены по живому DeviceInfo перед START.</p>
|
||||||
<Button
|
<Button
|
||||||
width="full"
|
width="full"
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
icon={<Icon name="search" />}
|
icon={<Icon name="search" />}
|
||||||
disabled={!powerConfirmed || isBusy}
|
disabled={!powerConfirmed || isBusy}
|
||||||
onClick={() => { setSelectedDeviceId(""); resetProfile(); void scan(); }}
|
onClick={() => { setSelectedDeviceId(""); resetProvisioningIntent(); void scan(); }}
|
||||||
>
|
>
|
||||||
{pendingAction === "scan" ? "Сканируем Bluetooth — 6 секунд…" : "Показать все BLE-устройства"}
|
{pendingAction === "scan" ? "Сканируем Bluetooth — 6 секунд…" : "Показать все BLE-устройства"}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
@ -179,21 +199,18 @@ export function K1ProvisioningPipeline({
|
||||||
key={device.device_id}
|
key={device.device_id}
|
||||||
device={device}
|
device={device}
|
||||||
selected={device.device_id === selectedDeviceId}
|
selected={device.device_id === selectedDeviceId}
|
||||||
onSelect={() => { setSelectedDeviceId(device.device_id); resetProfile(); }}
|
onSelect={() => { setSelectedDeviceId(device.device_id); resetProvisioningIntent(); }}
|
||||||
/>
|
/>
|
||||||
)) : <div className="empty-device-list">Устройства пока не найдены. Проверьте питание и повторите поиск.</div>}
|
)) : <div className="empty-device-list">Устройства пока не найдены. Проверьте питание и повторите поиск.</div>}
|
||||||
</div>
|
</div>
|
||||||
</WizardStep>
|
</WizardStep>
|
||||||
<WizardStep number="03" title="Передайте настройки 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="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="Название сети Wi‑Fi" hint="SSID" value={ssid} onChange={(event) => { setSsid(event.target.value); provisioningIntentRef.current = null; }} autoComplete="off" spellCheck={false} placeholder="Сеть локального контура" />
|
||||||
<TextField label="Пароль Wi‑Fi" hint="Только в оперативной памяти" type="password" value={password} onChange={(event) => { setPassword(event.target.value); provisioningIntentRef.current = null; }} autoComplete="off" placeholder="Введите пароль" />
|
<TextField label="Пароль Wi‑Fi" hint="Только в оперативной памяти" type="password" value={password} onChange={(event) => { setPassword(event.target.value); provisioningIntentRef.current = null; }} autoComplete="off" placeholder="Введите пароль" />
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -201,7 +218,7 @@ export function K1ProvisioningPipeline({
|
||||||
<Button width="full" variant="primary" icon={<Icon name="network" />} disabled={!canConnect} onClick={() => void submitConnect()}>
|
<Button width="full" variant="primary" icon={<Icon name="network" />} disabled={!canConnect} onClick={() => void submitConnect()}>
|
||||||
{pendingAction === "connect" ? "Подключаем…" : "Подключить устройство к Wi‑Fi"}
|
{pendingAction === "connect" ? "Подключаем…" : "Подключить устройство к Wi‑Fi"}
|
||||||
</Button>
|
</Button>
|
||||||
<p className="safety-note">Пароль передаётся только локальному сервису, не сохраняется в браузере и удаляется из формы после успеха.</p>
|
<p className="safety-note">Наличие адреса подтверждает результат предыдущей настройки, но не текущее соединение. Пароль передаётся только локальному сервису, не сохраняется в браузере и удаляется из формы после успеха.</p>
|
||||||
</WizardStep>
|
</WizardStep>
|
||||||
</div>
|
</div>
|
||||||
</GlassSurface>
|
</GlassSurface>
|
||||||
|
|
|
||||||
|
|
@ -72,7 +72,7 @@ function phasePresentation(
|
||||||
awaiting_external_stop: {
|
awaiting_external_stop: {
|
||||||
label: softwareCommanded ? "K1 завершает и сохраняет" : "Ожидание остановки на устройстве",
|
label: softwareCommanded ? "K1 завершает и сохраняет" : "Ожидание остановки на устройстве",
|
||||||
detail: softwareCommanded
|
detail: softwareCommanded
|
||||||
? "STOP не повторяется. Дождитесь READY и постоянного зелёного индикатора."
|
? "STOP не повторяется. Mission Core завершит запись после READY от K1."
|
||||||
: "Mission Core ждёт подтверждения физической остановки K1.",
|
: "Mission Core ждёт подтверждения физической остановки K1.",
|
||||||
busy: true,
|
busy: true,
|
||||||
},
|
},
|
||||||
|
|
@ -108,7 +108,7 @@ function formatDuration(seconds: number): string {
|
||||||
|
|
||||||
export function K1SpatialControls(_props: DevicePluginConnectionProps) {
|
export function K1SpatialControls(_props: DevicePluginConnectionProps) {
|
||||||
const controller = useXgridsK1Controller();
|
const controller = useXgridsK1Controller();
|
||||||
const { state, pendingAction, stop, confirmStoppedAtSteadyGreen } = controller;
|
const { state, pendingAction, stop } = controller;
|
||||||
const acquisition = state?.acquisition;
|
const acquisition = state?.acquisition;
|
||||||
const cleanupPending = acquisition?.cleanup_pending === true;
|
const cleanupPending = acquisition?.cleanup_pending === true;
|
||||||
if (!acquisition || !shouldRenderSpatialControls(state)) {
|
if (!acquisition || !shouldRenderSpatialControls(state)) {
|
||||||
|
|
@ -179,16 +179,6 @@ export function K1SpatialControls(_props: DevicePluginConnectionProps) {
|
||||||
? "Повторить остановку"
|
? "Повторить остановку"
|
||||||
: softwareCommanded ? "Остановить устройство и запись" : "Остановить локальный приём"}
|
: softwareCommanded ? "Остановить устройство и запись" : "Остановить локальный приём"}
|
||||||
</Button>
|
</Button>
|
||||||
{state?.application_control_session?.can_confirm_standby ? (
|
|
||||||
<Button
|
|
||||||
size="compact"
|
|
||||||
variant="primary"
|
|
||||||
disabled={pendingAction !== null}
|
|
||||||
onClick={() => void confirmStoppedAtSteadyGreen()}
|
|
||||||
>
|
|
||||||
Индикатор постоянно зелёный — завершить запись
|
|
||||||
</Button>
|
|
||||||
) : null}
|
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
@ -2,6 +2,7 @@ import type { RuntimePhase, SourceMode as RuntimeSourceMode } from "@mission-cor
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
AcquisitionState,
|
AcquisitionState,
|
||||||
|
XgridsApplicationControlPhase,
|
||||||
XgridsAcquisition,
|
XgridsAcquisition,
|
||||||
XgridsK1State,
|
XgridsK1State,
|
||||||
XgridsOperation,
|
XgridsOperation,
|
||||||
|
|
@ -22,6 +23,25 @@ const FAILED_OPERATION_STATUSES = new Set([
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export type LiveStartPlan = "prepare" | "resume-prepared" | "already-running" | "blocked";
|
export type LiveStartPlan = "prepare" | "resume-prepared" | "already-running" | "blocked";
|
||||||
|
export type ControlSessionEntryPlan =
|
||||||
|
| "open"
|
||||||
|
| "continue"
|
||||||
|
| "failed"
|
||||||
|
| "duplicate-open";
|
||||||
|
|
||||||
|
export function controlSessionEntryPlan(
|
||||||
|
phase: XgridsApplicationControlPhase,
|
||||||
|
openedByCurrentOperatorAction: boolean,
|
||||||
|
canOpen: boolean,
|
||||||
|
): ControlSessionEntryPlan {
|
||||||
|
if (phase === "failed") {
|
||||||
|
return !openedByCurrentOperatorAction && canOpen ? "open" : "failed";
|
||||||
|
}
|
||||||
|
if (["idle", "closed", "completed"].includes(phase)) {
|
||||||
|
return openedByCurrentOperatorAction ? "duplicate-open" : "open";
|
||||||
|
}
|
||||||
|
return "continue";
|
||||||
|
}
|
||||||
|
|
||||||
export function isTerminalAcquisitionState(
|
export function isTerminalAcquisitionState(
|
||||||
state: AcquisitionState | null | undefined,
|
state: AcquisitionState | null | undefined,
|
||||||
|
|
@ -119,7 +139,12 @@ export function normalizeRuntimePhase(
|
||||||
}
|
}
|
||||||
if (acquisitionState === "prepared") return "connected";
|
if (acquisitionState === "prepared") return "connected";
|
||||||
if (phase === "error") return "error";
|
if (phase === "error") return "error";
|
||||||
if (phase === "connected") return "connected";
|
if (phase === "connected") {
|
||||||
|
const controlPhase = state?.application_control_session?.state;
|
||||||
|
return controlPhase && !["idle", "connecting", "closed", "completed", "failed"].includes(controlPhase)
|
||||||
|
? "connected"
|
||||||
|
: "configuring";
|
||||||
|
}
|
||||||
if (phase === "starting_live") return "starting";
|
if (phase === "starting_live") return "starting";
|
||||||
if (phase === "live") return "streaming";
|
if (phase === "live") return "streaming";
|
||||||
if (phase === "replay") return "replaying";
|
if (phase === "replay") return "replaying";
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,45 @@
|
||||||
// Vendor message normalization belongs to the XGRIDS frontend contribution.
|
// Vendor message normalization belongs to the XGRIDS frontend contribution.
|
||||||
const runtimeMessageReplacements: Array<[RegExp, string]> = [
|
const runtimeMessageReplacements: Array<[RegExp, string]> = [
|
||||||
|
[
|
||||||
|
/^control MQTT connect call failed(?::.*)?$/gi,
|
||||||
|
"Управляющее соединение со сканером не открылось: устройство не приняло MQTT-соединение. Команды сканирования не отправлялись.",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
/macOS Keychain authority is unavailable/gi,
|
||||||
|
"Локальный допуск управления K1 не подготовлен. Команды сканеру не отправлялись.",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
/macOS Keychain authority lookup failed/gi,
|
||||||
|
"Локальный допуск управления K1 недоступен. Команды сканеру не отправлялись.",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
/control MQTT connect call failed/gi,
|
||||||
|
"Управляющее соединение с K1 не открылось. Команды сканеру не отправлялись.",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
/^Device plugin returned an invalid non-JSON action result$/gi,
|
||||||
|
"Плагин устройства сформировал некорректное внутреннее состояние. Запрос к K1 не повторялся.",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
/^bootstrap response correlation failed.*$/gi,
|
||||||
|
"Ответ K1 на подготовительном этапе не совпал с ожидаемой операцией. Диалог остановлен без автоматического повтора.",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
/^live DeviceInfo is incompatible with the selected.*$/gi,
|
||||||
|
"Устройство ответило, но его живые данные не соответствуют выбранному профилю совместимости. START и STOP не отправлялись.",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
/^K1 did not confirm bound SCANNING initialization.*$/gi,
|
||||||
|
"После START устройство не подтвердило завершение инициализации в безопасный срок. Автоматический STOP не отправлялся.",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
/^stale control response makes the next command outcome ambiguous$/gi,
|
||||||
|
"Получен запоздавший ответ предыдущего этапа. Диалог остановлен, следующая команда не отправлялась.",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
/control MQTT connection\/subscription timed out/gi,
|
||||||
|
"Управляющее соединение с K1 не подтвердилось вовремя. Команды сканеру не отправлялись.",
|
||||||
|
],
|
||||||
[/broker connection ended/gi, "соединение с брокером завершено"],
|
[/broker connection ended/gi, "соединение с брокером завершено"],
|
||||||
[/Unspecified error/gi, "неуказанная ошибка"],
|
[/Unspecified error/gi, "неуказанная ошибка"],
|
||||||
[
|
[
|
||||||
|
|
|
||||||
|
|
@ -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: "Устройство выбрано",
|
device_selected: "Устройство выбрано",
|
||||||
provisioning: "Передача настроек Wi‑Fi",
|
provisioning: "Передача настроек Wi‑Fi",
|
||||||
connecting: "Подключение",
|
connecting: "Подключение",
|
||||||
connected: "Устройство подключено",
|
connected: "Сетевой адрес устройства получен",
|
||||||
starting_live: "Запуск потока",
|
starting_live: "Запуск потока",
|
||||||
live: "Поток в реальном времени",
|
live: "Поток в реальном времени",
|
||||||
replay: "Повтор записи",
|
replay: "Повтор записи",
|
||||||
|
|
@ -25,7 +25,8 @@ export function phaseLabel(phase: string | null | undefined): string {
|
||||||
export function phaseTone(phase: string | null | undefined): StatusTone {
|
export function phaseTone(phase: string | null | undefined): StatusTone {
|
||||||
if (!phase) return "neutral";
|
if (!phase) return "neutral";
|
||||||
if (phase === "error") return "danger";
|
if (phase === "error") return "danger";
|
||||||
if (["connected", "live", "replay"].includes(phase)) return "success";
|
if (["live", "replay"].includes(phase)) return "success";
|
||||||
|
if (phase === "connected") return "warning";
|
||||||
if (["scanning", "provisioning", "connecting", "starting_live", "stopping"].includes(phase)) return "accent";
|
if (["scanning", "provisioning", "connecting", "starting_live", "stopping"].includes(phase)) return "accent";
|
||||||
return "neutral";
|
return "neutral";
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -69,6 +69,20 @@
|
||||||
margin-top: 1.4rem;
|
margin-top: 1.4rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.configuration-anchor {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.55rem;
|
||||||
|
margin-top: 1.2rem;
|
||||||
|
border-radius: 0.95rem;
|
||||||
|
background: rgb(255 255 255 / 0.028);
|
||||||
|
padding: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.configuration-anchor .nodedc-select-anchor,
|
||||||
|
.configuration-field .nodedc-select-anchor {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
.wizard-step {
|
.wizard-step {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 2.2rem minmax(0, 1fr);
|
grid-template-columns: 2.2rem minmax(0, 1fr);
|
||||||
|
|
@ -282,6 +296,25 @@
|
||||||
margin-top: 1rem;
|
margin-top: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.scan-configuration-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.configuration-field {
|
||||||
|
display: grid;
|
||||||
|
min-width: 0;
|
||||||
|
gap: 0.45rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.start-confirmation-note {
|
||||||
|
margin: -0.25rem 0 0;
|
||||||
|
color: var(--nodedc-text-muted);
|
||||||
|
font-size: 0.6rem;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
|
||||||
.session-form--replay {
|
.session-form--replay {
|
||||||
grid-template-columns: minmax(0, 1.55fr) minmax(8rem, 0.45fr);
|
grid-template-columns: minmax(0, 1.55fr) minmax(8rem, 0.45fr);
|
||||||
}
|
}
|
||||||
|
|
@ -435,6 +468,10 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 760px) {
|
@media (max-width: 760px) {
|
||||||
|
.xgrids-k1-plugin .scan-configuration-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
.xgrids-k1-plugin .session-form--replay {
|
.xgrids-k1-plugin .session-form--replay {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ import {
|
||||||
type XgridsK1State,
|
type XgridsK1State,
|
||||||
} from "./api";
|
} from "./api";
|
||||||
import {
|
import {
|
||||||
|
controlSessionEntryPlan,
|
||||||
isTerminalAcquisitionState,
|
isTerminalAcquisitionState,
|
||||||
liveStartPlan,
|
liveStartPlan,
|
||||||
operationByIdempotencyKey,
|
operationByIdempotencyKey,
|
||||||
|
|
@ -23,6 +24,10 @@ import {
|
||||||
isSoftwareCommandedAcquisition,
|
isSoftwareCommandedAcquisition,
|
||||||
} from "./lifecycle";
|
} from "./lifecycle";
|
||||||
import { localizeRuntimeMessage } from "./messages";
|
import { localizeRuntimeMessage } from "./messages";
|
||||||
|
import {
|
||||||
|
awaitWhileIntentCurrent,
|
||||||
|
OperatorIntentGeneration,
|
||||||
|
} from "./operatorIntentGeneration";
|
||||||
import { selectMonotonicXgridsState } from "./stateOrdering";
|
import { selectMonotonicXgridsState } from "./stateOrdering";
|
||||||
|
|
||||||
export type PendingAction =
|
export type PendingAction =
|
||||||
|
|
@ -50,19 +55,103 @@ function controlPhase(state: XgridsK1State): XgridsApplicationControlPhase {
|
||||||
|
|
||||||
function controlFailure(state: XgridsK1State): ApiError {
|
function controlFailure(state: XgridsK1State): ApiError {
|
||||||
const failure = state.application_control_session?.failure;
|
const failure = state.application_control_session?.failure;
|
||||||
|
const localizedDetail = localizeRuntimeMessage(failure?.message);
|
||||||
|
const reasonLabels: Record<string, string> = {
|
||||||
|
application_authority_unavailable:
|
||||||
|
"Локальный допуск управления устройством недоступен; команды не отправлялись.",
|
||||||
|
mqtt_connect_call_failed:
|
||||||
|
"Управляющее MQTT-соединение со сканером не открылось.",
|
||||||
|
mqtt_connect_rejected:
|
||||||
|
"Сканер отклонил управляющее MQTT-соединение.",
|
||||||
|
mqtt_connection_timeout:
|
||||||
|
"Подключение или подписки MQTT не подтвердились вовремя.",
|
||||||
|
mqtt_subscription_failed:
|
||||||
|
"Сканер не подтвердил канонические MQTT-подписки.",
|
||||||
|
mqtt_response_timeout:
|
||||||
|
"Ожидаемый ответ сканера не пришёл до безопасной границы ожидания.",
|
||||||
|
response_identity_decode_failed:
|
||||||
|
"Ответ сканера не удалось безопасно разобрать и привязать к операции.",
|
||||||
|
modeling_response_decode_failed:
|
||||||
|
"Ответ START/STOP не удалось безопасно разобрать.",
|
||||||
|
duplicate_application_response:
|
||||||
|
"Сканер прислал повторный ответ на уже завершённую операцию.",
|
||||||
|
unexpected_response_identity:
|
||||||
|
"Получен ответ неизвестной операции; диалог остановлен.",
|
||||||
|
response_identity_mismatch:
|
||||||
|
"Идентичность ответа не совпала с ожидаемой операцией.",
|
||||||
|
response_session_mismatch:
|
||||||
|
"Session ответа не совпал с точной подготовительной операцией.",
|
||||||
|
response_device_identity_mismatch:
|
||||||
|
"Ответ относится не к тому экземпляру устройства.",
|
||||||
|
response_authority_mismatch:
|
||||||
|
"Ответ не совпал с локальным допуском приложения.",
|
||||||
|
response_rejected:
|
||||||
|
"Сканер отклонил подготовительную операцию.",
|
||||||
|
compatibility_profile_mismatch:
|
||||||
|
"Живой DeviceInfo не соответствует выбранному профилю модели, platform type, прошивки или активации.",
|
||||||
|
scan_initialization_timeout:
|
||||||
|
"После подтверждённого START сканер не завершил инициализацию в безопасный срок.",
|
||||||
|
operation_reuse_forbidden:
|
||||||
|
"Повтор уже использованной операции заблокирован.",
|
||||||
|
};
|
||||||
|
const reasonDetail = failure?.reason_code
|
||||||
|
? reasonLabels[failure.reason_code]
|
||||||
|
: undefined;
|
||||||
|
const stageLabels: Record<string, string> = {
|
||||||
|
connecting: "подключение и первичный диалог",
|
||||||
|
connection: "первичный диалог",
|
||||||
|
"connection-ready": "подключение подтверждено",
|
||||||
|
"workspace-requested": "вход в рабочую область",
|
||||||
|
"workspace-ready": "рабочая область готова",
|
||||||
|
"project-requested": "подготовка проекта",
|
||||||
|
"project-ready": "проект готов",
|
||||||
|
"start-requested": "подтверждение запуска",
|
||||||
|
"start-attempted": "команда запуска",
|
||||||
|
initializing: "калибровка оборудования",
|
||||||
|
scanning: "сканирование",
|
||||||
|
"stop-requested": "подтверждение остановки",
|
||||||
|
"stop-attempted": "команда остановки",
|
||||||
|
stopping: "остановка",
|
||||||
|
};
|
||||||
|
const stageCode = failure?.dialogue_stage || failure?.failed_phase;
|
||||||
|
const stage = stageCode
|
||||||
|
? stageLabels[stageCode] ?? "неопознанный этап канонического диалога"
|
||||||
|
: "этап не зафиксирован";
|
||||||
|
const commandStatus = failure?.modeling_command_attempted === true
|
||||||
|
? "Команда START или STOP могла быть отправлена; автоматический повтор запрещён."
|
||||||
|
: failure?.modeling_command_attempted === false
|
||||||
|
? "Команды START и STOP не отправлялись."
|
||||||
|
: "Факт отправки START или STOP диагностически не подтверждён; повтор запрещён.";
|
||||||
|
const retryStatus = failure?.safe_to_retry
|
||||||
|
? "Новая попытка возможна только отдельным нажатием оператора."
|
||||||
|
: "Повтор заблокирован до ручной проверки состояния.";
|
||||||
|
const exchanges = typeof failure?.publish_attempts === "number"
|
||||||
|
? `MQTT-публикаций до остановки: ${failure.publish_attempts}.`
|
||||||
|
: "Количество MQTT-публикаций не зафиксировано.";
|
||||||
|
const failedProtocolEvidence =
|
||||||
|
failure?.compatibility_failure ?? failure?.correlation_failure;
|
||||||
|
const failedOperation = failedProtocolEvidence?.operation_key
|
||||||
|
? `Шаг протокола: ${failedProtocolEvidence.operation_key}.`
|
||||||
|
: "";
|
||||||
|
const diagnostics = failure?.diagnostic_evidence_unavailable?.length
|
||||||
|
? "Часть диагностических доказательств недоступна; результат считается неизвестным."
|
||||||
|
: "";
|
||||||
return new ApiError(
|
return new ApiError(
|
||||||
failure?.message
|
`${reasonDetail || localizedDetail || "Канонический диалог K1 остановлен."} Этап: ${stage}. ${failedOperation} ${exchanges} ${commandStatus} ${diagnostics} ${retryStatus}`,
|
||||||
? `Канонический диалог K1 остановлен: ${failure.message}`
|
|
||||||
: "Канонический диалог K1 остановлен до запуска сканирования.",
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function waitForControlPhase(
|
async function waitForControlPhase(
|
||||||
expected: XgridsApplicationControlPhase,
|
expected: XgridsApplicationControlPhase,
|
||||||
acceptState: (state: XgridsK1State) => void,
|
acceptState: (state: XgridsK1State) => void,
|
||||||
|
assertOperatorIntentCurrent: () => void,
|
||||||
): Promise<XgridsK1State> {
|
): Promise<XgridsK1State> {
|
||||||
for (;;) {
|
for (;;) {
|
||||||
const nextState = await xgridsK1Api.getState();
|
assertOperatorIntentCurrent();
|
||||||
|
const nextState = await awaitWhileIntentCurrent(
|
||||||
|
assertOperatorIntentCurrent,
|
||||||
|
() => xgridsK1Api.getState(),
|
||||||
|
);
|
||||||
acceptState(nextState);
|
acceptState(nextState);
|
||||||
const phase = controlPhase(nextState);
|
const phase = controlPhase(nextState);
|
||||||
if (phase === expected) return nextState;
|
if (phase === expected) return nextState;
|
||||||
|
|
@ -74,15 +163,22 @@ async function waitForControlPhase(
|
||||||
}
|
}
|
||||||
// This cadence only reads local server state. It never schedules, retries,
|
// This cadence only reads local server state. It never schedules, retries,
|
||||||
// or times a K1 command; every next write remains gated by device response.
|
// or times a K1 command; every next write remains gated by device response.
|
||||||
await new Promise<void>((resolve) => {
|
await awaitWhileIntentCurrent(
|
||||||
window.setTimeout(resolve, CONTROL_STATE_READ_INTERVAL_MS);
|
assertOperatorIntentCurrent,
|
||||||
});
|
() => new Promise<void>((resolve) => {
|
||||||
|
window.setTimeout(resolve, CONTROL_STATE_READ_INTERVAL_MS);
|
||||||
|
}),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function messageFor(error: unknown): string {
|
function messageFor(error: unknown): string {
|
||||||
if (error instanceof ApiError) {
|
if (error instanceof ApiError) {
|
||||||
const message = localizeRuntimeMessage(error.message) ?? error.message;
|
// Domain errors are already written for the operator. Only HTTP details
|
||||||
|
// originate at the backend and need vendor/runtime normalization.
|
||||||
|
const message = error.status
|
||||||
|
? localizeRuntimeMessage(error.message) ?? error.message
|
||||||
|
: error.message;
|
||||||
return error.status
|
return error.status
|
||||||
? `${message} (HTTP ${error.status})`
|
? `${message} (HTTP ${error.status})`
|
||||||
: message;
|
: message;
|
||||||
|
|
@ -113,8 +209,12 @@ export function useXgridsK1Runtime(enabled: boolean) {
|
||||||
const [pendingAction, setPendingAction] = useState<PendingAction | null>(null);
|
const [pendingAction, setPendingAction] = useState<PendingAction | null>(null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [latencyHistory, setLatencyHistory] = useState<number[]>([]);
|
const [latencyHistory, setLatencyHistory] = useState<number[]>([]);
|
||||||
const mounted = useRef(true);
|
const operatorIntents = useRef(new OperatorIntentGeneration());
|
||||||
const actionInFlight = useRef(false);
|
const actionSequence = useRef(0);
|
||||||
|
const actionInFlight = useRef<{
|
||||||
|
runtimeGeneration: number;
|
||||||
|
actionSequence: number;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
const acceptState = useCallback((nextState: XgridsK1State) => {
|
const acceptState = useCallback((nextState: XgridsK1State) => {
|
||||||
setState((currentState) => selectMonotonicXgridsState(currentState, nextState));
|
setState((currentState) => selectMonotonicXgridsState(currentState, nextState));
|
||||||
|
|
@ -122,13 +222,14 @@ export function useXgridsK1Runtime(enabled: boolean) {
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const refresh = useCallback(async (reportErrors = true) => {
|
const refresh = useCallback(async (reportErrors = true) => {
|
||||||
if (!enabled) return;
|
const runtimeToken = operatorIntents.current.captureRuntime();
|
||||||
|
if (!enabled || !runtimeToken) return;
|
||||||
const [healthResult, stateResult] = await Promise.allSettled([
|
const [healthResult, stateResult] = await Promise.allSettled([
|
||||||
xgridsK1Api.getHealth(),
|
xgridsK1Api.getHealth(),
|
||||||
xgridsK1Api.getState(),
|
xgridsK1Api.getState(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (!mounted.current) return;
|
if (!operatorIntents.current.isRuntimeCurrent(runtimeToken)) return;
|
||||||
|
|
||||||
if (stateResult.status === "fulfilled") {
|
if (stateResult.status === "fulfilled") {
|
||||||
acceptState(stateResult.value);
|
acceptState(stateResult.value);
|
||||||
|
|
@ -150,27 +251,48 @@ export function useXgridsK1Runtime(enabled: boolean) {
|
||||||
|
|
||||||
const run = useCallback(
|
const run = useCallback(
|
||||||
async (action: PendingAction, operation: () => Promise<XgridsK1State>) => {
|
async (action: PendingAction, operation: () => Promise<XgridsK1State>) => {
|
||||||
if (!enabled) return false;
|
const runtimeToken = operatorIntents.current.captureRuntime();
|
||||||
if (actionInFlight.current) return false;
|
if (!enabled || !runtimeToken) return false;
|
||||||
actionInFlight.current = true;
|
if (
|
||||||
|
actionInFlight.current?.runtimeGeneration
|
||||||
|
=== runtimeToken.runtimeGeneration
|
||||||
|
) return false;
|
||||||
|
actionSequence.current += 1;
|
||||||
|
const actionToken = {
|
||||||
|
runtimeGeneration: runtimeToken.runtimeGeneration,
|
||||||
|
actionSequence: actionSequence.current,
|
||||||
|
};
|
||||||
|
actionInFlight.current = actionToken;
|
||||||
setPendingAction(action);
|
setPendingAction(action);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
if (!operatorIntents.current.isRuntimeCurrent(runtimeToken)) return false;
|
||||||
const nextState = await operation();
|
const nextState = await operation();
|
||||||
if (mounted.current) acceptState(nextState);
|
if (!operatorIntents.current.isRuntimeCurrent(runtimeToken)) return false;
|
||||||
|
acceptState(nextState);
|
||||||
return true;
|
return true;
|
||||||
} catch (operationError) {
|
} catch (operationError) {
|
||||||
if (mounted.current) {
|
if (operatorIntents.current.isRuntimeCurrent(runtimeToken)) {
|
||||||
setError(messageFor(operationError));
|
setError(messageFor(operationError));
|
||||||
if (operationError instanceof ApiError && operationError.status === 0) {
|
if (
|
||||||
|
operationError instanceof ApiError
|
||||||
|
&& operationError.transportUnavailable
|
||||||
|
) {
|
||||||
setBackendStatus("offline");
|
setBackendStatus("offline");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
} finally {
|
} finally {
|
||||||
actionInFlight.current = false;
|
if (
|
||||||
if (mounted.current) setPendingAction(null);
|
actionInFlight.current?.runtimeGeneration === actionToken.runtimeGeneration
|
||||||
|
&& actionInFlight.current.actionSequence === actionToken.actionSequence
|
||||||
|
) {
|
||||||
|
actionInFlight.current = null;
|
||||||
|
if (operatorIntents.current.isRuntimeCurrent(runtimeToken)) {
|
||||||
|
setPendingAction(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[acceptState, enabled],
|
[acceptState, enabled],
|
||||||
|
|
@ -237,7 +359,24 @@ export function useXgridsK1Runtime(enabled: boolean) {
|
||||||
const startCanonicalAcquisition = useCallback(
|
const startCanonicalAcquisition = useCallback(
|
||||||
(request: CanonicalLiveStartRequest) =>
|
(request: CanonicalLiveStartRequest) =>
|
||||||
run("live", async () => {
|
run("live", async () => {
|
||||||
let nextState = await xgridsK1Api.getState();
|
const intentToken = operatorIntents.current.beginOperatorIntent();
|
||||||
|
if (!intentToken) {
|
||||||
|
throw new ApiError(
|
||||||
|
"Экран управления закрыт; дальнейшие команды канонического диалога не отправлялись.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const assertOperatorIntentCurrent = () => {
|
||||||
|
if (!operatorIntents.current.isOperatorIntentCurrent(intentToken)) {
|
||||||
|
throw new ApiError(
|
||||||
|
"Операторское действие завершено или заменено; дальнейшие команды канонического диалога не отправлялись.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let nextState = await awaitWhileIntentCurrent(
|
||||||
|
assertOperatorIntentCurrent,
|
||||||
|
() => xgridsK1Api.getState(),
|
||||||
|
);
|
||||||
|
let openedControlSession = false;
|
||||||
acceptState(nextState);
|
acceptState(nextState);
|
||||||
const plan = liveStartPlan(nextState);
|
const plan = liveStartPlan(nextState);
|
||||||
if (plan === "blocked") {
|
if (plan === "blocked") {
|
||||||
|
|
@ -246,50 +385,84 @@ export function useXgridsK1Runtime(enabled: boolean) {
|
||||||
if (plan === "already-running") return nextState;
|
if (plan === "already-running") return nextState;
|
||||||
|
|
||||||
for (;;) {
|
for (;;) {
|
||||||
|
assertOperatorIntentCurrent();
|
||||||
const phase = controlPhase(nextState);
|
const phase = controlPhase(nextState);
|
||||||
const acquisition = nextState.acquisition;
|
const acquisition = nextState.acquisition;
|
||||||
|
const entryPlan = controlSessionEntryPlan(
|
||||||
|
phase,
|
||||||
|
openedControlSession,
|
||||||
|
nextState.application_control_session?.can_open === true,
|
||||||
|
);
|
||||||
|
|
||||||
if (["idle", "closed", "completed"].includes(phase)) {
|
if (entryPlan === "failed") {
|
||||||
|
// A failed dialogue always ends this operator intent. Even when
|
||||||
|
// backend reconciliation says a fresh attempt may be safe, that
|
||||||
|
// attempt requires another explicit click.
|
||||||
|
throw controlFailure(nextState);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entryPlan === "duplicate-open") {
|
||||||
|
throw new ApiError(
|
||||||
|
"Управляющая сессия завершилась сразу после открытия. Автоматический повтор заблокирован; проверьте состояние и повторите только отдельным нажатием.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entryPlan === "open") {
|
||||||
if (acquisition && !isTerminalAcquisitionState(acquisition.state)) {
|
if (acquisition && !isTerminalAcquisitionState(acquisition.state)) {
|
||||||
throw new ApiError(
|
throw new ApiError(
|
||||||
"Незавершённая подготовка не привязана к открытой control-сессии. Отмените её перед новым запуском.",
|
"Незавершённая подготовка не привязана к открытой control-сессии. Отмените её перед новым запуском.",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
nextState = await xgridsK1Api.openApplicationControlSession(request.control);
|
openedControlSession = true;
|
||||||
acceptState(nextState);
|
nextState = await awaitWhileIntentCurrent(
|
||||||
continue;
|
assertOperatorIntentCurrent,
|
||||||
}
|
() => xgridsK1Api.openApplicationControlSession(request.control),
|
||||||
|
);
|
||||||
if (phase === "failed") {
|
|
||||||
if (nextState.application_control_session?.can_open !== true) {
|
|
||||||
throw controlFailure(nextState);
|
|
||||||
}
|
|
||||||
nextState = await xgridsK1Api.openApplicationControlSession(request.control);
|
|
||||||
acceptState(nextState);
|
acceptState(nextState);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (phase === "connecting") {
|
if (phase === "connecting") {
|
||||||
nextState = await waitForControlPhase("connection-ready", acceptState);
|
nextState = await awaitWhileIntentCurrent(
|
||||||
|
assertOperatorIntentCurrent,
|
||||||
|
() => waitForControlPhase(
|
||||||
|
"connection-ready",
|
||||||
|
acceptState,
|
||||||
|
assertOperatorIntentCurrent,
|
||||||
|
),
|
||||||
|
);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (phase === "connection-ready") {
|
if (phase === "connection-ready") {
|
||||||
nextState = await xgridsK1Api.enterApplicationWorkspace({
|
nextState = await awaitWhileIntentCurrent(
|
||||||
operator_confirmed: true,
|
assertOperatorIntentCurrent,
|
||||||
});
|
() => xgridsK1Api.enterApplicationWorkspace({
|
||||||
|
operator_confirmed: true,
|
||||||
|
}),
|
||||||
|
);
|
||||||
acceptState(nextState);
|
acceptState(nextState);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (phase === "workspace-requested") {
|
if (phase === "workspace-requested") {
|
||||||
nextState = await waitForControlPhase("workspace-ready", acceptState);
|
nextState = await awaitWhileIntentCurrent(
|
||||||
|
assertOperatorIntentCurrent,
|
||||||
|
() => waitForControlPhase(
|
||||||
|
"workspace-ready",
|
||||||
|
acceptState,
|
||||||
|
assertOperatorIntentCurrent,
|
||||||
|
),
|
||||||
|
);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (phase === "workspace-ready") {
|
if (phase === "workspace-ready") {
|
||||||
if (!acquisition || isTerminalAcquisitionState(acquisition.state)) {
|
if (!acquisition || isTerminalAcquisitionState(acquisition.state)) {
|
||||||
nextState = await xgridsK1Api.prepareAcquisition(request.acquisition);
|
nextState = await awaitWhileIntentCurrent(
|
||||||
|
assertOperatorIntentCurrent,
|
||||||
|
() => xgridsK1Api.prepareAcquisition(request.acquisition),
|
||||||
|
);
|
||||||
acceptState(nextState);
|
acceptState(nextState);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
@ -298,12 +471,26 @@ export function useXgridsK1Runtime(enabled: boolean) {
|
||||||
"Текущая подготовка не принадлежит канонической control-сессии K1.",
|
"Текущая подготовка не принадлежит канонической control-сессии K1.",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
nextState = await waitForControlPhase("project-ready", acceptState);
|
nextState = await awaitWhileIntentCurrent(
|
||||||
|
assertOperatorIntentCurrent,
|
||||||
|
() => waitForControlPhase(
|
||||||
|
"project-ready",
|
||||||
|
acceptState,
|
||||||
|
assertOperatorIntentCurrent,
|
||||||
|
),
|
||||||
|
);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (phase === "project-requested") {
|
if (phase === "project-requested") {
|
||||||
nextState = await waitForControlPhase("project-ready", acceptState);
|
nextState = await awaitWhileIntentCurrent(
|
||||||
|
assertOperatorIntentCurrent,
|
||||||
|
() => waitForControlPhase(
|
||||||
|
"project-ready",
|
||||||
|
acceptState,
|
||||||
|
assertOperatorIntentCurrent,
|
||||||
|
),
|
||||||
|
);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -311,11 +498,14 @@ export function useXgridsK1Runtime(enabled: boolean) {
|
||||||
if (!acquisition || acquisition.state !== "prepared") {
|
if (!acquisition || acquisition.state !== "prepared") {
|
||||||
throw new ApiError("Локальный приём не подготовлен к каноническому START.");
|
throw new ApiError("Локальный приём не подготовлен к каноническому START.");
|
||||||
}
|
}
|
||||||
nextState = await xgridsK1Api.startAcquisition({
|
nextState = await awaitWhileIntentCurrent(
|
||||||
acquisition_id: acquisition.acquisition_id,
|
assertOperatorIntentCurrent,
|
||||||
expected_state_revision: acquisition.state_revision,
|
() => xgridsK1Api.startAcquisition({
|
||||||
physical_acceptance: request.physicalAcceptance,
|
acquisition_id: acquisition.acquisition_id,
|
||||||
});
|
expected_state_revision: acquisition.state_revision,
|
||||||
|
physical_acceptance: request.physicalAcceptance,
|
||||||
|
}),
|
||||||
|
);
|
||||||
acceptState(nextState);
|
acceptState(nextState);
|
||||||
return nextState;
|
return nextState;
|
||||||
}
|
}
|
||||||
|
|
@ -396,33 +586,6 @@ export function useXgridsK1Runtime(enabled: boolean) {
|
||||||
[run, state],
|
[run, state],
|
||||||
);
|
);
|
||||||
|
|
||||||
const confirmStoppedAtSteadyGreen = useCallback(
|
|
||||||
() =>
|
|
||||||
run("stop", () => {
|
|
||||||
const acquisition = state?.acquisition;
|
|
||||||
if (!acquisition || acquisition.state !== "awaiting_external_stop") {
|
|
||||||
throw new ApiError("K1 сейчас не ожидает подтверждения завершённого STOP.");
|
|
||||||
}
|
|
||||||
const stopOperation = [...(state?.operations ?? [])]
|
|
||||||
.reverse()
|
|
||||||
.find(
|
|
||||||
(operation) =>
|
|
||||||
operation.action === "acquisition.stop" &&
|
|
||||||
operation.status === "operator_action_required",
|
|
||||||
);
|
|
||||||
if (!stopOperation) {
|
|
||||||
throw new ApiError("Не найдена исходная операция STOP; повтор команды запрещён.");
|
|
||||||
}
|
|
||||||
return xgridsK1Api.stopAcquisition({
|
|
||||||
acquisition_id: acquisition.acquisition_id,
|
|
||||||
mode: "graceful",
|
|
||||||
operator_confirmed: true,
|
|
||||||
operation_id: stopOperation.operation_id,
|
|
||||||
});
|
|
||||||
}),
|
|
||||||
[run, state],
|
|
||||||
);
|
|
||||||
|
|
||||||
const abort = useCallback(() => {
|
const abort = useCallback(() => {
|
||||||
const acquisition = state?.acquisition;
|
const acquisition = state?.acquisition;
|
||||||
if (!acquisition || isTerminalAcquisitionState(acquisition.state)) {
|
if (!acquisition || isTerminalAcquisitionState(acquisition.state)) {
|
||||||
|
|
@ -485,7 +648,7 @@ export function useXgridsK1Runtime(enabled: boolean) {
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!enabled) {
|
if (!enabled) {
|
||||||
mounted.current = true;
|
operatorIntents.current.deactivateRuntime();
|
||||||
setState(null);
|
setState(null);
|
||||||
setBackendStatus("checking");
|
setBackendStatus("checking");
|
||||||
setEventStatus("closed");
|
setEventStatus("closed");
|
||||||
|
|
@ -495,12 +658,12 @@ export function useXgridsK1Runtime(enabled: boolean) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
mounted.current = true;
|
operatorIntents.current.activateRuntime();
|
||||||
void refresh(true);
|
void refresh(true);
|
||||||
const poll = window.setInterval(() => void refresh(false), 4_000);
|
const poll = window.setInterval(() => void refresh(false), 4_000);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
mounted.current = false;
|
operatorIntents.current.deactivateRuntime();
|
||||||
window.clearInterval(poll);
|
window.clearInterval(poll);
|
||||||
};
|
};
|
||||||
}, [enabled, refresh]);
|
}, [enabled, refresh]);
|
||||||
|
|
@ -563,7 +726,6 @@ export function useXgridsK1Runtime(enabled: boolean) {
|
||||||
startPreparedAcquisition,
|
startPreparedAcquisition,
|
||||||
startReplay,
|
startReplay,
|
||||||
stop,
|
stop,
|
||||||
confirmStoppedAtSteadyGreen,
|
|
||||||
abort,
|
abort,
|
||||||
setObservationSourceActive,
|
setObservationSourceActive,
|
||||||
updateViewerSettings,
|
updateViewerSettings,
|
||||||
|
|
|
||||||
|
|
@ -366,6 +366,23 @@ def _validate_acquisition_control(profile: dict[str, Any]) -> None:
|
||||||
if control.get("mode") != "operator-manual" or control.get("write_enabled") is not False:
|
if control.get("mode") != "operator-manual" or control.get("write_enabled") is not False:
|
||||||
raise CompatibilityProfileError("acquisition control must remain operator-manual")
|
raise CompatibilityProfileError("acquisition control must remain operator-manual")
|
||||||
|
|
||||||
|
acceptance_transport = _object(
|
||||||
|
control.get("software_acceptance_transport"),
|
||||||
|
"$.acquisition_control.software_acceptance_transport",
|
||||||
|
)
|
||||||
|
if acceptance_transport != {
|
||||||
|
"status": "installed-operator-present",
|
||||||
|
"default_authority": "disabled",
|
||||||
|
"profile_gate": "live-device-info-exact-match",
|
||||||
|
"dialogue": "single-socket-canonical-start-to-stop",
|
||||||
|
"automatic_retry": False,
|
||||||
|
"supported_mount_type": "handheld",
|
||||||
|
"supported_gnss_mode": "none",
|
||||||
|
}:
|
||||||
|
raise CompatibilityProfileError(
|
||||||
|
"software acceptance transport differs from the reviewed operator-present contract"
|
||||||
|
)
|
||||||
|
|
||||||
device_control = _object(
|
device_control = _object(
|
||||||
control.get("verified_device_control"),
|
control.get("verified_device_control"),
|
||||||
"$.acquisition_control.verified_device_control",
|
"$.acquisition_control.verified_device_control",
|
||||||
|
|
@ -497,6 +514,8 @@ def validate_compatibility_profile(profile: Any) -> dict[str, Any]:
|
||||||
scope = _object(root.get("scope"), "$.scope")
|
scope = _object(root.get("scope"), "$.scope")
|
||||||
if scope.get("vendor") != "XGRIDS" or scope.get("model") != "LixelKity K1":
|
if scope.get("vendor") != "XGRIDS" or scope.get("model") != "LixelKity K1":
|
||||||
raise CompatibilityProfileError("profile vendor/model must remain XGRIDS LixelKity K1")
|
raise CompatibilityProfileError("profile vendor/model must remain XGRIDS LixelKity K1")
|
||||||
|
if scope.get("platform_type") != "A4":
|
||||||
|
raise CompatibilityProfileError("profile platform type must remain the observed A4")
|
||||||
firmware = _object(scope.get("firmware"), "$.scope.firmware")
|
firmware = _object(scope.get("firmware"), "$.scope.firmware")
|
||||||
if firmware != {"match": "exact", "version": "3.0.2"}:
|
if firmware != {"match": "exact", "version": "3.0.2"}:
|
||||||
raise CompatibilityProfileError("profile must match firmware 3.0.2 exactly")
|
raise CompatibilityProfileError("profile must match firmware 3.0.2 exactly")
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@
|
||||||
"scope": {
|
"scope": {
|
||||||
"vendor": "XGRIDS",
|
"vendor": "XGRIDS",
|
||||||
"model": "LixelKity K1",
|
"model": "LixelKity K1",
|
||||||
|
"platform_type": "A4",
|
||||||
"firmware": {
|
"firmware": {
|
||||||
"match": "exact",
|
"match": "exact",
|
||||||
"version": "3.0.2"
|
"version": "3.0.2"
|
||||||
|
|
@ -26,8 +27,8 @@
|
||||||
"request_topic_subscription_enabled": false,
|
"request_topic_subscription_enabled": false,
|
||||||
"notes": [
|
"notes": [
|
||||||
"Loading this descriptive profile does not authorize a BLE or MQTT write.",
|
"Loading this descriptive profile does not authorize a BLE or MQTT write.",
|
||||||
"The existing reviewed Wi-Fi provisioning procedure remains separately operator-confirmed and is not activated by this profile.",
|
"The reviewed Wi-Fi provisioning procedure remains a separate explicit operator action and is not activated by loading this profile.",
|
||||||
"Observed LixelGO modeling requests describe the wire contract but remain non-replayable and write-disabled.",
|
"Observed LixelGO modeling requests remain write-disabled in this descriptive profile; the separately installed acceptance transport requires a live DeviceInfo match and one operator-present action permit.",
|
||||||
"Unknown firmware, transport, topics, fields, and action responses fail closed."
|
"Unknown firmware, transport, topics, fields, and action responses fail closed."
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|
@ -316,6 +317,15 @@
|
||||||
"acquisition_control": {
|
"acquisition_control": {
|
||||||
"mode": "operator-manual",
|
"mode": "operator-manual",
|
||||||
"write_enabled": false,
|
"write_enabled": false,
|
||||||
|
"software_acceptance_transport": {
|
||||||
|
"status": "installed-operator-present",
|
||||||
|
"default_authority": "disabled",
|
||||||
|
"profile_gate": "live-device-info-exact-match",
|
||||||
|
"dialogue": "single-socket-canonical-start-to-stop",
|
||||||
|
"automatic_retry": false,
|
||||||
|
"supported_mount_type": "handheld",
|
||||||
|
"supported_gnss_mode": "none"
|
||||||
|
},
|
||||||
"verified_device_control": {
|
"verified_device_control": {
|
||||||
"gesture": "physical-double-click",
|
"gesture": "physical-double-click",
|
||||||
"state_dependent_result": "start from steady-green standby; stop during active scanning",
|
"state_dependent_result": "start from steady-green standby; stop during active scanning",
|
||||||
|
|
@ -359,7 +369,7 @@
|
||||||
},
|
},
|
||||||
"success_result_code": 302252033,
|
"success_result_code": 302252033,
|
||||||
"required_unresolved_context": [
|
"required_unresolved_context": [
|
||||||
"operator-owned Keychain item provisioning and physical acceptance of the uninstalled reviewed transport",
|
"operator-owned Keychain authority provisioning and operator-present physical acceptance",
|
||||||
"authorization policy for any setting outside the retained request",
|
"authorization policy for any setting outside the retained request",
|
||||||
"timeout, rejection and rollback contract"
|
"timeout, rejection and rollback contract"
|
||||||
],
|
],
|
||||||
|
|
@ -406,7 +416,7 @@
|
||||||
"request_fields": {},
|
"request_fields": {},
|
||||||
"success_result_code": 302252033,
|
"success_result_code": 302252033,
|
||||||
"required_unresolved_context": [
|
"required_unresolved_context": [
|
||||||
"operator-owned Keychain item provisioning and physical acceptance of the uninstalled reviewed transport",
|
"operator-owned Keychain authority provisioning and operator-present physical acceptance",
|
||||||
"save-completion and final-standby state mapping",
|
"save-completion and final-standby state mapping",
|
||||||
"timeout and rollback contract"
|
"timeout and rollback contract"
|
||||||
],
|
],
|
||||||
|
|
|
||||||
|
|
@ -90,6 +90,7 @@ class XgridsK1CameraGateway:
|
||||||
self._target_host: str | None = None
|
self._target_host: str | None = None
|
||||||
self._producer: _CameraProducer | None = None
|
self._producer: _CameraProducer | None = None
|
||||||
self._recording_root: Path | None = None
|
self._recording_root: Path | None = None
|
||||||
|
self._expected_source_end_generation: int | None = None
|
||||||
self._archive_summaries: list[dict[str, Any]] = []
|
self._archive_summaries: list[dict[str, Any]] = []
|
||||||
self._error: dict[str, str] | None = None
|
self._error: dict[str, str] | None = None
|
||||||
self._closed = False
|
self._closed = False
|
||||||
|
|
@ -124,6 +125,9 @@ class XgridsK1CameraGateway:
|
||||||
},
|
},
|
||||||
"recording": {
|
"recording": {
|
||||||
"active": self._recording_root is not None,
|
"active": self._recording_root is not None,
|
||||||
|
"source_end_expected": (
|
||||||
|
self._expected_source_end_generation is not None
|
||||||
|
),
|
||||||
"session": (
|
"session": (
|
||||||
self._recording_root.name if self._recording_root is not None else None
|
self._recording_root.name if self._recording_root is not None else None
|
||||||
),
|
),
|
||||||
|
|
@ -175,6 +179,7 @@ class XgridsK1CameraGateway:
|
||||||
self._revision += 1
|
self._revision += 1
|
||||||
self._source_id = source_id
|
self._source_id = source_id
|
||||||
self._target_host = target
|
self._target_host = target
|
||||||
|
self._expected_source_end_generation = None
|
||||||
self._phase = "selected"
|
self._phase = "selected"
|
||||||
self._error = None
|
self._error = None
|
||||||
recording_active = self._recording_root is not None
|
recording_active = self._recording_root is not None
|
||||||
|
|
@ -221,6 +226,7 @@ class XgridsK1CameraGateway:
|
||||||
self._source_id = None
|
self._source_id = None
|
||||||
self._target_host = None
|
self._target_host = None
|
||||||
self._recording_root = None
|
self._recording_root = None
|
||||||
|
self._expected_source_end_generation = None
|
||||||
self._error = None
|
self._error = None
|
||||||
self._shutdown_producer(
|
self._shutdown_producer(
|
||||||
producer,
|
producer,
|
||||||
|
|
@ -248,6 +254,7 @@ class XgridsK1CameraGateway:
|
||||||
raise RuntimeError("для camera gateway уже активна другая acquisition-сессия")
|
raise RuntimeError("для camera gateway уже активна другая acquisition-сессия")
|
||||||
producer, delivery = self._detach_producer_locked()
|
producer, delivery = self._detach_producer_locked()
|
||||||
self._recording_root = root
|
self._recording_root = root
|
||||||
|
self._expected_source_end_generation = None
|
||||||
self._archive_summaries = []
|
self._archive_summaries = []
|
||||||
selected = self._source_id is not None
|
selected = self._source_id is not None
|
||||||
if selected:
|
if selected:
|
||||||
|
|
@ -266,6 +273,29 @@ class XgridsK1CameraGateway:
|
||||||
self._spawn_selected_producer()
|
self._spawn_selected_producer()
|
||||||
return self.snapshot()
|
return self.snapshot()
|
||||||
|
|
||||||
|
def expect_source_end_for_device_stop(self) -> dict[str, Any]:
|
||||||
|
"""Classify one clean FFmpeg EOF as the camera tail of canonical STOP."""
|
||||||
|
|
||||||
|
with self._lock:
|
||||||
|
producer = self._producer
|
||||||
|
if (
|
||||||
|
self._recording_root is not None
|
||||||
|
and producer is not None
|
||||||
|
and producer.archive is not None
|
||||||
|
):
|
||||||
|
self._expected_source_end_generation = producer.generation
|
||||||
|
self._revision += 1
|
||||||
|
return self.snapshot()
|
||||||
|
|
||||||
|
def cancel_expected_source_end(self) -> dict[str, Any]:
|
||||||
|
"""Revoke a STOP expectation when the control checkpoint was not released."""
|
||||||
|
|
||||||
|
with self._lock:
|
||||||
|
if self._expected_source_end_generation is not None:
|
||||||
|
self._expected_source_end_generation = None
|
||||||
|
self._revision += 1
|
||||||
|
return self.snapshot()
|
||||||
|
|
||||||
def stop_recording(
|
def stop_recording(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
|
|
@ -279,6 +309,7 @@ class XgridsK1CameraGateway:
|
||||||
producer, delivery = self._detach_producer_locked()
|
producer, delivery = self._detach_producer_locked()
|
||||||
recording_was_active = self._recording_root is not None
|
recording_was_active = self._recording_root is not None
|
||||||
self._recording_root = None
|
self._recording_root = None
|
||||||
|
self._expected_source_end_generation = None
|
||||||
if self._source_id is not None and self._phase != "error":
|
if self._source_id is not None and self._phase != "error":
|
||||||
self._phase = "selected"
|
self._phase = "selected"
|
||||||
if recording_was_active or producer is not None:
|
if recording_was_active or producer is not None:
|
||||||
|
|
@ -361,6 +392,7 @@ class XgridsK1CameraGateway:
|
||||||
self._source_id = None
|
self._source_id = None
|
||||||
self._target_host = None
|
self._target_host = None
|
||||||
self._recording_root = None
|
self._recording_root = None
|
||||||
|
self._expected_source_end_generation = None
|
||||||
self._error = None
|
self._error = None
|
||||||
self._shutdown_producer(
|
self._shutdown_producer(
|
||||||
producer,
|
producer,
|
||||||
|
|
@ -559,29 +591,49 @@ class XgridsK1CameraGateway:
|
||||||
delivery = producer.delivery
|
delivery = producer.delivery
|
||||||
producer.delivery = None
|
producer.delivery = None
|
||||||
owns_shutdown = self._producer is producer and not producer.stop_requested
|
owns_shutdown = self._producer is producer and not producer.stop_requested
|
||||||
|
expected_clean_end = (
|
||||||
|
owns_shutdown
|
||||||
|
and producer.failure_code is None
|
||||||
|
and self._expected_source_end_generation == producer.generation
|
||||||
|
)
|
||||||
if owns_shutdown:
|
if owns_shutdown:
|
||||||
self._producer = None
|
self._producer = None
|
||||||
|
self._expected_source_end_generation = None
|
||||||
self._revision += 1
|
self._revision += 1
|
||||||
if producer.failure_code is None:
|
if expected_clean_end:
|
||||||
|
self._phase = "idle"
|
||||||
|
self._source_id = None
|
||||||
|
self._target_host = None
|
||||||
|
self._error = None
|
||||||
|
elif producer.failure_code is None:
|
||||||
producer.failure_code = "camera-source-ended"
|
producer.failure_code = "camera-source-ended"
|
||||||
self._set_error_locked(
|
self._set_error_locked(
|
||||||
"camera-source-ended",
|
"camera-source-ended",
|
||||||
_safe_ffmpeg_message(producer.stderr_tail),
|
_safe_ffmpeg_message(producer.stderr_tail),
|
||||||
)
|
)
|
||||||
if delivery is not None:
|
if delivery is not None:
|
||||||
delivery.failure_code = producer.failure_code or "camera-source-ended"
|
delivery.failure_code = (
|
||||||
|
None
|
||||||
|
if expected_clean_end
|
||||||
|
else producer.failure_code or "camera-source-ended"
|
||||||
|
)
|
||||||
_close_segment_queue(delivery)
|
_close_segment_queue(delivery)
|
||||||
if not owns_shutdown:
|
if not owns_shutdown:
|
||||||
return
|
return
|
||||||
_terminate_process(producer.process)
|
_terminate_process(producer.process)
|
||||||
status: CameraArchiveStatus = (
|
status: CameraArchiveStatus = (
|
||||||
"interrupted"
|
"complete"
|
||||||
if producer.failure_code
|
if expected_clean_end
|
||||||
in {"camera-source-ended", "incomplete-fmp4-fragment"}
|
else "interrupted"
|
||||||
|
if producer.failure_code in {"camera-source-ended", "incomplete-fmp4-fragment"}
|
||||||
else "failed"
|
else "failed"
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
self._finalize_archive(producer, status, producer.failure_code)
|
self._finalize_archive(
|
||||||
|
producer,
|
||||||
|
status,
|
||||||
|
None if expected_clean_end else producer.failure_code,
|
||||||
|
)
|
||||||
except CameraArchiveError:
|
except CameraArchiveError:
|
||||||
with self._lock:
|
with self._lock:
|
||||||
self._set_error_locked(
|
self._set_error_locked(
|
||||||
|
|
|
||||||
|
|
@ -251,7 +251,7 @@ def serve_console(
|
||||||
] = 8000,
|
] = 8000,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Serve the built Mission Core Control Station and loopback control API."""
|
"""Serve the built Mission Core Control Station and loopback control API."""
|
||||||
frontend = Path(__file__).resolve().parents[2] / "apps" / "control-station" / "dist"
|
frontend = Path(__file__).resolve().parents[4] / "apps" / "control-station" / "dist"
|
||||||
if not frontend.is_dir():
|
if not frontend.is_dir():
|
||||||
console.print(
|
console.print(
|
||||||
"[red]Frontend build is missing.[/red] Run npm install && npm run build "
|
"[red]Frontend build is missing.[/red] Run npm install && npm run build "
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import hmac
|
||||||
import importlib.util
|
import importlib.util
|
||||||
import json
|
import json
|
||||||
import secrets
|
import secrets
|
||||||
|
import socket
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
import unicodedata
|
import unicodedata
|
||||||
|
|
@ -178,17 +179,18 @@ class BleScanRequest(StrictRequest):
|
||||||
|
|
||||||
|
|
||||||
class CompatibilityAttestationRequest(StrictRequest):
|
class CompatibilityAttestationRequest(StrictRequest):
|
||||||
"""Explicit operator claim required before using the exact lab profile."""
|
"""Selected profile whose facts must be verified from live DeviceInfo."""
|
||||||
|
|
||||||
firmware_version: Literal["3.0.2"]
|
firmware_version: Literal["3.0.2"]
|
||||||
topology: Literal["direct-lan"]
|
topology: Literal["direct-lan"]
|
||||||
operator_confirmed: Literal[True]
|
verification: Literal["live-device-info"]
|
||||||
|
|
||||||
|
|
||||||
class ConnectRequest(StrictRequest):
|
class ConnectRequest(StrictRequest):
|
||||||
device_id: str = Field(min_length=1, max_length=128)
|
device_id: str = Field(min_length=1, max_length=128)
|
||||||
ssid: str = Field(min_length=1, max_length=128)
|
ssid: str = Field(min_length=1, max_length=128)
|
||||||
password: SecretStr = Field(min_length=1, max_length=256)
|
password: SecretStr = Field(min_length=1, max_length=256)
|
||||||
|
connection_mode: Literal["bridge"] = "bridge"
|
||||||
compatibility_attestation: CompatibilityAttestationRequest
|
compatibility_attestation: CompatibilityAttestationRequest
|
||||||
operation_id: str | None = Field(default=None, min_length=1, max_length=128)
|
operation_id: str | None = Field(default=None, min_length=1, max_length=128)
|
||||||
idempotency_key: str | None = Field(default=None, min_length=1, max_length=160)
|
idempotency_key: str | None = Field(default=None, min_length=1, max_length=160)
|
||||||
|
|
@ -234,6 +236,8 @@ class OperatorPresenceRequest(StrictRequest):
|
||||||
|
|
||||||
class PrepareAcquisitionRequest(OperationContextRequest):
|
class PrepareAcquisitionRequest(OperationContextRequest):
|
||||||
project_name: str = Field(min_length=1, max_length=96)
|
project_name: str = Field(min_length=1, max_length=96)
|
||||||
|
mount_type: Literal["handheld"] = "handheld"
|
||||||
|
gnss_mode: Literal["none"] = "none"
|
||||||
host: str | None = Field(default=None, max_length=15)
|
host: str | None = Field(default=None, max_length=15)
|
||||||
duration_seconds: float = Field(default=3600.0, ge=1.0, le=86_400.0)
|
duration_seconds: float = Field(default=3600.0, ge=1.0, le=86_400.0)
|
||||||
requested_streams: tuple[RequestedStreamId, ...] = DEFAULT_LIVE_STREAMS
|
requested_streams: tuple[RequestedStreamId, ...] = DEFAULT_LIVE_STREAMS
|
||||||
|
|
@ -331,6 +335,7 @@ class XgridsK1CompatibilityService:
|
||||||
self._devices: list[dict[str, Any]] = []
|
self._devices: list[dict[str, Any]] = []
|
||||||
self._selected_device_id: str | None = None
|
self._selected_device_id: str | None = None
|
||||||
self._k1_ip: str | None = None
|
self._k1_ip: str | None = None
|
||||||
|
self._connection_mode: Literal["bridge"] | None = None
|
||||||
self._device_ids_by_transport_ref: dict[str, str] = {}
|
self._device_ids_by_transport_ref: dict[str, str] = {}
|
||||||
self._device_id: str | None = None
|
self._device_id: str | None = None
|
||||||
self._device_session_id: str | None = None
|
self._device_session_id: str | None = None
|
||||||
|
|
@ -347,6 +352,8 @@ class XgridsK1CompatibilityService:
|
||||||
self._operations = OperationJournal()
|
self._operations = OperationJournal()
|
||||||
self._acquisition: AcquisitionRecord | None = None
|
self._acquisition: AcquisitionRecord | None = None
|
||||||
self._acquisition_project_name: str | None = None
|
self._acquisition_project_name: str | None = None
|
||||||
|
self._acquisition_mount_type: Literal["handheld"] | None = None
|
||||||
|
self._acquisition_gnss_mode: Literal["none"] | None = None
|
||||||
self._acquisition_out_dir: Path | None = None
|
self._acquisition_out_dir: Path | None = None
|
||||||
self._acquisition_start_operation_id: str | None = None
|
self._acquisition_start_operation_id: str | None = None
|
||||||
self._acquisition_stop_operation_id: str | None = None
|
self._acquisition_stop_operation_id: str | None = None
|
||||||
|
|
@ -379,7 +386,8 @@ class XgridsK1CompatibilityService:
|
||||||
application_control_session = self._application_control_session.snapshot()
|
application_control_session = self._application_control_session.snapshot()
|
||||||
runtime = self.runtime.snapshot()
|
runtime = self.runtime.snapshot()
|
||||||
camera_preview = self.camera_preview.snapshot()
|
camera_preview = self.camera_preview.snapshot()
|
||||||
self._reconcile_acquisition(runtime, camera_preview)
|
self._reconcile_acquisition(runtime, camera_preview, application_control_session)
|
||||||
|
runtime = self.runtime.snapshot()
|
||||||
camera_preview = self.camera_preview.snapshot()
|
camera_preview = self.camera_preview.snapshot()
|
||||||
metrics = runtime["metrics"]
|
metrics = runtime["metrics"]
|
||||||
with self._lock:
|
with self._lock:
|
||||||
|
|
@ -388,6 +396,7 @@ class XgridsK1CompatibilityService:
|
||||||
devices = list(self._devices)
|
devices = list(self._devices)
|
||||||
selected_device_id = self._selected_device_id
|
selected_device_id = self._selected_device_id
|
||||||
k1_ip = self._k1_ip
|
k1_ip = self._k1_ip
|
||||||
|
connection_mode = self._connection_mode
|
||||||
device_id = self._device_id
|
device_id = self._device_id
|
||||||
device_session_id = self._device_session_id
|
device_session_id = self._device_session_id
|
||||||
device_session_opened_at = self._device_session_opened_at
|
device_session_opened_at = self._device_session_opened_at
|
||||||
|
|
@ -400,6 +409,8 @@ class XgridsK1CompatibilityService:
|
||||||
acquisition = self._acquisition.as_dict() if self._acquisition is not None else None
|
acquisition = self._acquisition.as_dict() if self._acquisition is not None else None
|
||||||
if acquisition is not None:
|
if acquisition is not None:
|
||||||
acquisition["project_name"] = self._acquisition_project_name
|
acquisition["project_name"] = self._acquisition_project_name
|
||||||
|
acquisition["mount_type"] = self._acquisition_mount_type
|
||||||
|
acquisition["gnss_mode"] = self._acquisition_gnss_mode
|
||||||
acquisition["cleanup_pending"] = (
|
acquisition["cleanup_pending"] = (
|
||||||
self._acquisition_session_lease is not None
|
self._acquisition_session_lease is not None
|
||||||
and acquisition["state"] in TERMINAL_ACQUISITION_STATES
|
and acquisition["state"] in TERMINAL_ACQUISITION_STATES
|
||||||
|
|
@ -450,6 +461,7 @@ class XgridsK1CompatibilityService:
|
||||||
"devices": devices,
|
"devices": devices,
|
||||||
"selected_device_id": selected_device_id,
|
"selected_device_id": selected_device_id,
|
||||||
"k1_ip": k1_ip,
|
"k1_ip": k1_ip,
|
||||||
|
"connection_mode": connection_mode,
|
||||||
"compatibility": {
|
"compatibility": {
|
||||||
"profile_id": active_profile_id,
|
"profile_id": active_profile_id,
|
||||||
"decision": "limited" if active_profile_id is not None else "unknown",
|
"decision": "limited" if active_profile_id is not None else "unknown",
|
||||||
|
|
@ -461,9 +473,9 @@ class XgridsK1CompatibilityService:
|
||||||
else "evidence-only"
|
else "evidence-only"
|
||||||
),
|
),
|
||||||
"firmware_claim": (
|
"firmware_claim": (
|
||||||
"operator-attested-exact-3.0.2"
|
"live-device-info-verification-required"
|
||||||
if compatibility_attestation is not None
|
if compatibility_attestation is not None
|
||||||
else "exact-3.0.2-profile-not-attested"
|
else "exact-3.0.2-profile-not-selected"
|
||||||
),
|
),
|
||||||
"attestation": compatibility_attestation,
|
"attestation": compatibility_attestation,
|
||||||
"vendor_writes_enabled": active_control,
|
"vendor_writes_enabled": active_control,
|
||||||
|
|
@ -598,6 +610,7 @@ class XgridsK1CompatibilityService:
|
||||||
"device_id": request.device_id,
|
"device_id": request.device_id,
|
||||||
"ssid": request.ssid,
|
"ssid": request.ssid,
|
||||||
"password": password,
|
"password": password,
|
||||||
|
"connection_mode": request.connection_mode,
|
||||||
"compatibility_attestation": request.compatibility_attestation.model_dump(
|
"compatibility_attestation": request.compatibility_attestation.model_dump(
|
||||||
mode="json"
|
mode="json"
|
||||||
),
|
),
|
||||||
|
|
@ -678,6 +691,7 @@ class XgridsK1CompatibilityService:
|
||||||
)
|
)
|
||||||
write_json_atomic(session_dir / "provisioning.sensitive.json", result)
|
write_json_atomic(session_dir / "provisioning.sensitive.json", result)
|
||||||
ipv4 = _provisioned_ipv4(result)
|
ipv4 = _provisioned_ipv4(result)
|
||||||
|
local_address_conflict = ipv4 is not None and _target_is_local_ipv4(ipv4)
|
||||||
write_json_atomic(
|
write_json_atomic(
|
||||||
session_dir / "manifest.redacted.json",
|
session_dir / "manifest.redacted.json",
|
||||||
{
|
{
|
||||||
|
|
@ -688,6 +702,9 @@ class XgridsK1CompatibilityService:
|
||||||
"profile_id": result["profile_id"],
|
"profile_id": result["profile_id"],
|
||||||
"outcome": result["outcome"],
|
"outcome": result["outcome"],
|
||||||
"k1_lan_address_observed": ipv4 is not None,
|
"k1_lan_address_observed": ipv4 is not None,
|
||||||
|
"k1_lan_address_admitted": ipv4 is not None
|
||||||
|
and not local_address_conflict,
|
||||||
|
"local_address_conflict": local_address_conflict,
|
||||||
"credentials_persisted_by_connector": False,
|
"credentials_persisted_by_connector": False,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
@ -695,9 +712,15 @@ class XgridsK1CompatibilityService:
|
||||||
raise RuntimeError(
|
raise RuntimeError(
|
||||||
"Устройство не сообщило адрес в локальной сети; автоматического повтора не было"
|
"Устройство не сообщило адрес в локальной сети; автоматического повтора не было"
|
||||||
)
|
)
|
||||||
|
if local_address_conflict:
|
||||||
|
raise RuntimeError(
|
||||||
|
"Устройство сообщило IPv4-адрес, который уже принадлежит этому компьютеру; "
|
||||||
|
"адрес K1 не принят и автоматического повтора не было"
|
||||||
|
)
|
||||||
with self._lock:
|
with self._lock:
|
||||||
self._selected_device_id = request.device_id
|
self._selected_device_id = request.device_id
|
||||||
self._k1_ip = ipv4
|
self._k1_ip = ipv4
|
||||||
|
self._connection_mode = request.connection_mode
|
||||||
self._device_id = self._device_ids_by_transport_ref.setdefault(
|
self._device_id = self._device_ids_by_transport_ref.setdefault(
|
||||||
request.device_id,
|
request.device_id,
|
||||||
new_device_id(),
|
new_device_id(),
|
||||||
|
|
@ -792,7 +815,7 @@ class XgridsK1CompatibilityService:
|
||||||
attestation = self._compatibility_attestation
|
attestation = self._compatibility_attestation
|
||||||
acquisition = self._acquisition
|
acquisition = self._acquisition
|
||||||
if target is None or attestation is None:
|
if target is None or attestation is None:
|
||||||
raise RuntimeError("сначала подключите и подтвердите exact-profile K1")
|
raise RuntimeError("сначала подключите K1 и выберите exact-profile")
|
||||||
if acquisition is not None and acquisition.state not in TERMINAL_ACQUISITION_STATES:
|
if acquisition is not None and acquisition.state not in TERMINAL_ACQUISITION_STATES:
|
||||||
raise RuntimeError("control-сессия должна быть открыта до подготовки acquisition")
|
raise RuntimeError("control-сессия должна быть открыта до подготовки acquisition")
|
||||||
self._application_control.disarm()
|
self._application_control.disarm()
|
||||||
|
|
@ -830,7 +853,7 @@ class XgridsK1CompatibilityService:
|
||||||
if self._k1_ip is None or self._selected_device_id is None:
|
if self._k1_ip is None or self._selected_device_id is None:
|
||||||
raise RuntimeError("сначала выберите и подключите K1 через BLE/Wi-Fi")
|
raise RuntimeError("сначала выберите и подключите K1 через BLE/Wi-Fi")
|
||||||
if self._compatibility_attestation is None:
|
if self._compatibility_attestation is None:
|
||||||
raise RuntimeError("exact FW 3.0.2 direct-LAN profile не подтверждён")
|
raise RuntimeError("exact FW 3.0.2 direct-LAN profile не выбран")
|
||||||
acquisition = self._acquisition
|
acquisition = self._acquisition
|
||||||
if acquisition is not None and acquisition.state not in TERMINAL_ACQUISITION_STATES:
|
if acquisition is not None and acquisition.state not in TERMINAL_ACQUISITION_STATES:
|
||||||
raise RuntimeError(
|
raise RuntimeError(
|
||||||
|
|
@ -914,6 +937,8 @@ class XgridsK1CompatibilityService:
|
||||||
"requested_streams": requested_streams,
|
"requested_streams": requested_streams,
|
||||||
"evidence_policy": request.evidence_policy,
|
"evidence_policy": request.evidence_policy,
|
||||||
"project_name": request.project_name,
|
"project_name": request.project_name,
|
||||||
|
"mount_type": request.mount_type,
|
||||||
|
"gnss_mode": request.gnss_mode,
|
||||||
"compatibility_attestation": request.compatibility_attestation.model_dump(
|
"compatibility_attestation": request.compatibility_attestation.model_dump(
|
||||||
mode="json"
|
mode="json"
|
||||||
),
|
),
|
||||||
|
|
@ -974,6 +999,8 @@ class XgridsK1CompatibilityService:
|
||||||
)
|
)
|
||||||
self._acquisition = acquisition
|
self._acquisition = acquisition
|
||||||
self._acquisition_project_name = request.project_name
|
self._acquisition_project_name = request.project_name
|
||||||
|
self._acquisition_mount_type = request.mount_type
|
||||||
|
self._acquisition_gnss_mode = request.gnss_mode
|
||||||
self._acquisition_out_dir = new_live_session_dir(self.evidence_root)
|
self._acquisition_out_dir = new_live_session_dir(self.evidence_root)
|
||||||
self._acquisition_start_operation_id = None
|
self._acquisition_start_operation_id = None
|
||||||
self._acquisition_stop_operation_id = None
|
self._acquisition_stop_operation_id = None
|
||||||
|
|
@ -1136,20 +1163,27 @@ class XgridsK1CompatibilityService:
|
||||||
expected_stop_operation_id = self._acquisition_stop_operation_id
|
expected_stop_operation_id = self._acquisition_stop_operation_id
|
||||||
lease_retained = self._acquisition_session_lease is not None
|
lease_retained = self._acquisition_session_lease is not None
|
||||||
|
|
||||||
|
terminal_manual_stop_recovery = (
|
||||||
|
request.operator_confirmed
|
||||||
|
and not plugin_commanded
|
||||||
|
and acquisition_state in TERMINAL_ACQUISITION_STATES
|
||||||
|
)
|
||||||
|
|
||||||
if request.operator_confirmed:
|
if request.operator_confirmed:
|
||||||
if request.mode != "graceful":
|
if request.mode != "graceful":
|
||||||
raise ValueError("operator_confirmed допустим только для graceful stop")
|
raise ValueError("operator_confirmed допустим только для graceful stop")
|
||||||
if acquisition_state != "awaiting_external_stop":
|
if (
|
||||||
|
acquisition_state != "awaiting_external_stop"
|
||||||
|
and not terminal_manual_stop_recovery
|
||||||
|
):
|
||||||
raise ValueError("acquisition не ожидает подтверждения физической остановки")
|
raise ValueError("acquisition не ожидает подтверждения физической остановки")
|
||||||
if request.operation_id is None or request.operation_id != expected_stop_operation_id:
|
if request.operation_id is None or request.operation_id != expected_stop_operation_id:
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
"подтверждение остановки должно ссылаться на исходную stop-operation"
|
"подтверждение остановки должно ссылаться на исходную stop-operation"
|
||||||
)
|
)
|
||||||
if plugin_commanded and not self._application_control_session.snapshot()[
|
if plugin_commanded:
|
||||||
"can_confirm_standby"
|
raise ValueError(
|
||||||
]:
|
"канонический STOP завершается автоматически по READY от K1"
|
||||||
raise RuntimeError(
|
|
||||||
"сначала дождитесь READY от K1 и визуально подтвердите зелёный индикатор"
|
|
||||||
)
|
)
|
||||||
elif (
|
elif (
|
||||||
request.mode == "graceful"
|
request.mode == "graceful"
|
||||||
|
|
@ -1180,6 +1214,14 @@ class XgridsK1CompatibilityService:
|
||||||
if self._application_control_session.snapshot()["state"] != "scanning":
|
if self._application_control_session.snapshot()["state"] != "scanning":
|
||||||
raise RuntimeError("канонический диалог K1 ещё не готов принять STOP")
|
raise RuntimeError("канонический диалог K1 ещё не готов принять STOP")
|
||||||
|
|
||||||
|
if terminal_manual_stop_recovery:
|
||||||
|
if lease_retained:
|
||||||
|
self._stop_acquisition_sources(
|
||||||
|
camera_status="failed",
|
||||||
|
camera_failure_code="terminal-local-failure-after-device-stop",
|
||||||
|
)
|
||||||
|
return self.state()
|
||||||
|
|
||||||
request_fingerprint = self._request_fingerprint(
|
request_fingerprint = self._request_fingerprint(
|
||||||
ACTION_ACQUISITION_STOP,
|
ACTION_ACQUISITION_STOP,
|
||||||
{
|
{
|
||||||
|
|
@ -1271,9 +1313,14 @@ class XgridsK1CompatibilityService:
|
||||||
if request.mode == "graceful" and not request.operator_confirmed:
|
if request.mode == "graceful" and not request.operator_confirmed:
|
||||||
if plugin_commanded:
|
if plugin_commanded:
|
||||||
assert request.physical_acceptance is not None
|
assert request.physical_acceptance is not None
|
||||||
self._application_control_session.request_stop(
|
self.camera_preview.expect_source_end_for_device_stop()
|
||||||
confirmation=request.physical_acceptance.confirmation()
|
try:
|
||||||
)
|
self._application_control_session.request_stop(
|
||||||
|
confirmation=request.physical_acceptance.confirmation()
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
self.camera_preview.cancel_expected_source_end()
|
||||||
|
raise
|
||||||
with self._lock:
|
with self._lock:
|
||||||
acquisition.transition(
|
acquisition.transition(
|
||||||
"awaiting_external_stop",
|
"awaiting_external_stop",
|
||||||
|
|
@ -1282,22 +1329,17 @@ class XgridsK1CompatibilityService:
|
||||||
if plugin_commanded
|
if plugin_commanded
|
||||||
else "acquisition.stop.operator_action_required"
|
else "acquisition.stop.operator_action_required"
|
||||||
),
|
),
|
||||||
operator_instructions=(
|
operator_instructions=()
|
||||||
(
|
if plugin_commanded
|
||||||
"Канонический STOP запрошен один раз. Дождитесь READY, "
|
else (
|
||||||
"убедитесь, что индикатор постоянно зелёный, и подтвердите это."
|
"Дважды нажмите физическую кнопку устройства и "
|
||||||
if plugin_commanded
|
"подтвердите остановку.",
|
||||||
else (
|
|
||||||
"Дважды нажмите физическую кнопку устройства и "
|
|
||||||
"подтвердите остановку."
|
|
||||||
)
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
self._acquisition_stop_operation_id = operation.operation_id
|
self._acquisition_stop_operation_id = operation.operation_id
|
||||||
self._operations.transition(
|
self._operations.transition(
|
||||||
operation.operation_id,
|
operation.operation_id,
|
||||||
"operator_action_required",
|
"running" if plugin_commanded else "operator_action_required",
|
||||||
stage_code="awaiting-external-stop",
|
stage_code="awaiting-external-stop",
|
||||||
message_code=(
|
message_code=(
|
||||||
"acquisition.stop.device_stopping"
|
"acquisition.stop.device_stopping"
|
||||||
|
|
@ -1315,8 +1357,6 @@ class XgridsK1CompatibilityService:
|
||||||
message_code="acquisition.stop.stopping_receiver",
|
message_code="acquisition.stop.stopping_receiver",
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
if plugin_commanded and request.mode == "graceful" and request.operator_confirmed:
|
|
||||||
self._application_control_session.confirm_standby()
|
|
||||||
with self._lock:
|
with self._lock:
|
||||||
acquisition.transition("stopping", message_code="acquisition.stopping")
|
acquisition.transition("stopping", message_code="acquisition.stopping")
|
||||||
self._stop_acquisition_sources(
|
self._stop_acquisition_sources(
|
||||||
|
|
@ -1814,18 +1854,51 @@ class XgridsK1CompatibilityService:
|
||||||
self,
|
self,
|
||||||
runtime: Mapping[str, Any],
|
runtime: Mapping[str, Any],
|
||||||
camera: Mapping[str, Any],
|
camera: Mapping[str, Any],
|
||||||
|
application_control_session: Mapping[str, Any],
|
||||||
) -> None:
|
) -> None:
|
||||||
|
with self._lock:
|
||||||
|
current = self._acquisition
|
||||||
|
terminal_canonical_cleanup = (
|
||||||
|
current is not None
|
||||||
|
and current.state in TERMINAL_ACQUISITION_STATES
|
||||||
|
and current.control_mode == "plugin-commanded"
|
||||||
|
and application_control_session.get("state") == "completed"
|
||||||
|
and self._acquisition_session_lease is not None
|
||||||
|
)
|
||||||
|
terminal_camera_status: Literal["complete", "failed"] = (
|
||||||
|
"complete"
|
||||||
|
if current is not None and current.state == "completed"
|
||||||
|
else "failed"
|
||||||
|
)
|
||||||
|
if terminal_canonical_cleanup:
|
||||||
|
self._stop_acquisition_sources(
|
||||||
|
camera_status=terminal_camera_status,
|
||||||
|
camera_failure_code=(
|
||||||
|
None
|
||||||
|
if terminal_camera_status == "complete"
|
||||||
|
else "terminal-local-failure-after-device-standby"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
completed_operation_id: str | None = None
|
completed_operation_id: str | None = None
|
||||||
failed_operation_id: str | None = None
|
failed_operation_id: str | None = None
|
||||||
failed_stop_operation_id: str | None = None
|
failed_stop_operation_id: str | None = None
|
||||||
unconfirmed_stop_operation_id: str | None = None
|
unconfirmed_stop_operation_id: str | None = None
|
||||||
|
completed_stop_operation_id: str | None = None
|
||||||
receiver_ready_operation_id: str | None = None
|
receiver_ready_operation_id: str | None = None
|
||||||
receiver_plugin_commanded = False
|
receiver_plugin_commanded = False
|
||||||
acquisition_id: str | None = None
|
acquisition_id: str | None = None
|
||||||
camera_terminal_status: Literal["complete", "failed"] | None = None
|
camera_terminal_status: Literal["complete", "failed"] | None = None
|
||||||
camera_failure_code: str | None = None
|
camera_failure_code: str | None = None
|
||||||
stop_runtime_for_camera_failure = False
|
stop_runtime_for_camera_failure = False
|
||||||
|
stop_runtime_for_canonical_completion = False
|
||||||
complete_after_seal = False
|
complete_after_seal = False
|
||||||
|
completion_message_code = "acquisition.receiver_completed"
|
||||||
|
completion_result: dict[str, Any] = {
|
||||||
|
"receiver_stopped": True,
|
||||||
|
"device_state": "unknown",
|
||||||
|
}
|
||||||
with self._lock:
|
with self._lock:
|
||||||
acquisition = self._acquisition
|
acquisition = self._acquisition
|
||||||
if acquisition is None or acquisition.state in TERMINAL_ACQUISITION_STATES:
|
if acquisition is None or acquisition.state in TERMINAL_ACQUISITION_STATES:
|
||||||
|
|
@ -1870,6 +1943,25 @@ class XgridsK1CompatibilityService:
|
||||||
failed_stop_operation_id = self._acquisition_stop_operation_id
|
failed_stop_operation_id = self._acquisition_stop_operation_id
|
||||||
camera_terminal_status = "failed"
|
camera_terminal_status = "failed"
|
||||||
stop_runtime_for_camera_failure = True
|
stop_runtime_for_camera_failure = True
|
||||||
|
elif (
|
||||||
|
acquisition.state == "awaiting_external_stop"
|
||||||
|
and acquisition.control_mode == "plugin-commanded"
|
||||||
|
and application_control_session.get("state") == "completed"
|
||||||
|
):
|
||||||
|
acquisition.transition(
|
||||||
|
"finalizing",
|
||||||
|
message_code="acquisition.stop.device_standby_confirmed",
|
||||||
|
)
|
||||||
|
camera_terminal_status = "complete"
|
||||||
|
stop_runtime_for_canonical_completion = True
|
||||||
|
complete_after_seal = True
|
||||||
|
completed_stop_operation_id = self._acquisition_stop_operation_id
|
||||||
|
completion_message_code = "acquisition.stop.completed"
|
||||||
|
completion_result = {
|
||||||
|
"receiver_stopped": True,
|
||||||
|
"device_state": "ready",
|
||||||
|
"device_stop": "protocol-confirmed",
|
||||||
|
}
|
||||||
elif acquisition.state in {"starting", "awaiting_external_start"} and point_frames > 0:
|
elif acquisition.state in {"starting", "awaiting_external_start"} and point_frames > 0:
|
||||||
acquisition.transition("acquiring", message_code="acquisition.acquiring")
|
acquisition.transition("acquiring", message_code="acquisition.acquiring")
|
||||||
completed_operation_id = self._acquisition_start_operation_id
|
completed_operation_id = self._acquisition_start_operation_id
|
||||||
|
|
@ -1954,7 +2046,7 @@ class XgridsK1CompatibilityService:
|
||||||
)
|
)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
terminal_error = exc
|
terminal_error = exc
|
||||||
if stop_runtime_for_camera_failure:
|
if stop_runtime_for_camera_failure or stop_runtime_for_canonical_completion:
|
||||||
try:
|
try:
|
||||||
self.runtime.stop()
|
self.runtime.stop()
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
|
|
@ -1973,11 +2065,8 @@ class XgridsK1CompatibilityService:
|
||||||
if acquisition.state not in TERMINAL_ACQUISITION_STATES:
|
if acquisition.state not in TERMINAL_ACQUISITION_STATES:
|
||||||
acquisition.transition(
|
acquisition.transition(
|
||||||
"completed",
|
"completed",
|
||||||
message_code="acquisition.receiver_completed",
|
message_code=completion_message_code,
|
||||||
result={
|
result=completion_result,
|
||||||
"receiver_stopped": True,
|
|
||||||
"device_state": "unknown",
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
reconciliation_error = exc
|
reconciliation_error = exc
|
||||||
|
|
@ -2058,6 +2147,32 @@ class XgridsK1CompatibilityService:
|
||||||
"side_effect_status": "unknown",
|
"side_effect_status": "unknown",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
if completed_stop_operation_id is not None:
|
||||||
|
if reconciliation_error is None:
|
||||||
|
self._operations.transition_if_pending(
|
||||||
|
completed_stop_operation_id,
|
||||||
|
"succeeded",
|
||||||
|
stage_code="device-standby-confirmed",
|
||||||
|
message_code="acquisition.stop.completed",
|
||||||
|
result={
|
||||||
|
"acquisition_id": acquisition.acquisition_id,
|
||||||
|
"confirmation": "device-status-ready-unbound",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
self._operations.transition_if_pending(
|
||||||
|
completed_stop_operation_id,
|
||||||
|
"failed",
|
||||||
|
stage_code="local-finalization-failed",
|
||||||
|
message_code="acquisition.stop.local_finalization_failed",
|
||||||
|
error={
|
||||||
|
"category": "stream",
|
||||||
|
"code": "local-finalization-failed-after-device-standby",
|
||||||
|
"retryable": True,
|
||||||
|
"safe_to_retry": True,
|
||||||
|
"side_effect_status": "succeeded",
|
||||||
|
},
|
||||||
|
)
|
||||||
with self._lock:
|
with self._lock:
|
||||||
if self._acquisition_start_operation_id in {
|
if self._acquisition_start_operation_id in {
|
||||||
completed_operation_id,
|
completed_operation_id,
|
||||||
|
|
@ -2068,6 +2183,8 @@ class XgridsK1CompatibilityService:
|
||||||
self._acquisition_stop_operation_id = None
|
self._acquisition_stop_operation_id = None
|
||||||
if self._acquisition_stop_operation_id == unconfirmed_stop_operation_id:
|
if self._acquisition_stop_operation_id == unconfirmed_stop_operation_id:
|
||||||
self._acquisition_stop_operation_id = None
|
self._acquisition_stop_operation_id = None
|
||||||
|
if self._acquisition_stop_operation_id == completed_stop_operation_id:
|
||||||
|
self._acquisition_stop_operation_id = None
|
||||||
if reconciliation_error is not None:
|
if reconciliation_error is not None:
|
||||||
raise reconciliation_error
|
raise reconciliation_error
|
||||||
|
|
||||||
|
|
@ -2345,7 +2462,8 @@ def _attestation_snapshot(
|
||||||
return {
|
return {
|
||||||
"firmware_version": attestation.firmware_version,
|
"firmware_version": attestation.firmware_version,
|
||||||
"topology": attestation.topology,
|
"topology": attestation.topology,
|
||||||
"basis": "operator-attested",
|
"verification": attestation.verification,
|
||||||
|
"basis": "selected-profile-live-device-info-required",
|
||||||
"observed_at": _utc_now_iso(),
|
"observed_at": _utc_now_iso(),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -2394,7 +2512,7 @@ def _sensor_catalog(
|
||||||
else (
|
else (
|
||||||
"local-runtime-dependency-missing"
|
"local-runtime-dependency-missing"
|
||||||
if camera_profile_active
|
if camera_profile_active
|
||||||
else "profile-not-attested"
|
else "profile-not-selected"
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
"endpoint_label": f"RTSP · {side}",
|
"endpoint_label": f"RTSP · {side}",
|
||||||
|
|
@ -2506,6 +2624,24 @@ def _provisioned_ipv4(result: Mapping[str, Any]) -> str | None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _target_is_local_ipv4(target: str) -> bool:
|
||||||
|
"""Reject a BLE-reported target when the host route resolves back to itself.
|
||||||
|
|
||||||
|
Connecting a UDP socket only asks the kernel to select a route and source
|
||||||
|
address; it does not transmit a datagram. This keeps provisioning admission
|
||||||
|
free of a speculative device probe while preventing a host address from
|
||||||
|
being mistaken for the K1 endpoint.
|
||||||
|
"""
|
||||||
|
|
||||||
|
try:
|
||||||
|
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as route_socket:
|
||||||
|
route_socket.connect((validate_private_ipv4(target), 9))
|
||||||
|
source_address = str(route_socket.getsockname()[0])
|
||||||
|
except OSError as exc:
|
||||||
|
raise RuntimeError("не удалось безопасно проверить маршрут к адресу K1") from exc
|
||||||
|
return source_address == target
|
||||||
|
|
||||||
|
|
||||||
def _validate_installed_compatibility_profile(repository_root: Path) -> None:
|
def _validate_installed_compatibility_profile(repository_root: Path) -> None:
|
||||||
"""Run the plugin-owned, fail-closed profile validator before activation."""
|
"""Run the plugin-owned, fail-closed profile validator before activation."""
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ from typing import Literal, Protocol
|
||||||
from k1link.device_plugins.xgrids_k1.protocol.application_bootstrap import (
|
from k1link.device_plugins.xgrids_k1.protocol.application_bootstrap import (
|
||||||
MODELING_STATUS_RESPONSE_TOPIC,
|
MODELING_STATUS_RESPONSE_TOPIC,
|
||||||
ApplicationBootstrapError,
|
ApplicationBootstrapError,
|
||||||
|
ApplicationCompatibilityProfileError,
|
||||||
ApplicationControlAuthority,
|
ApplicationControlAuthority,
|
||||||
CanonicalPostStartObservation,
|
CanonicalPostStartObservation,
|
||||||
LiveDeviceControlBinding,
|
LiveDeviceControlBinding,
|
||||||
|
|
@ -39,18 +40,31 @@ MAX_ACCEPTANCE_PERMIT_SECONDS = 120.0
|
||||||
|
|
||||||
# Socket-pump quantum only. It is never used to advance a K1 dialogue stage.
|
# Socket-pump quantum only. It is never used to advance a K1 dialogue stage.
|
||||||
CONTROL_NETWORK_PUMP_QUANTUM_SECONDS = 1.0
|
CONTROL_NETWORK_PUMP_QUANTUM_SECONDS = 1.0
|
||||||
|
SCAN_INITIALIZATION_TIMEOUT_SECONDS = 120.0
|
||||||
|
|
||||||
|
|
||||||
class ApplicationAcceptanceError(RuntimeError):
|
class ApplicationAcceptanceError(RuntimeError):
|
||||||
"""The operator-present physical acceptance contract failed closed."""
|
"""The operator-present physical acceptance contract failed closed."""
|
||||||
|
|
||||||
|
|
||||||
|
class ScanInitializationTimeout(ApplicationAcceptanceError):
|
||||||
|
"""START was acknowledged but the reviewed SCANNING gate never completed."""
|
||||||
|
|
||||||
|
reason_code = "scan_initialization_timeout"
|
||||||
|
|
||||||
|
|
||||||
|
class ApplicationProfileMismatchError(ApplicationAcceptanceError):
|
||||||
|
"""A correlated live DeviceInfo response rejected the selected profile."""
|
||||||
|
|
||||||
|
reason_code = "compatibility_profile_mismatch"
|
||||||
|
|
||||||
|
|
||||||
class ApplicationBatchExchange(Protocol):
|
class ApplicationBatchExchange(Protocol):
|
||||||
def exchange_batch_once(
|
def exchange_batch_once(
|
||||||
self,
|
self,
|
||||||
envelopes: Sequence[OneShotPublishEnvelope],
|
envelopes: Sequence[OneShotPublishEnvelope],
|
||||||
*,
|
*,
|
||||||
required_response_topics: Collection[str],
|
required_response_operation_keys: Collection[str],
|
||||||
) -> dict[str, bytes]: ...
|
) -> dict[str, bytes]: ...
|
||||||
|
|
||||||
def maintain_open_for(
|
def maintain_open_for(
|
||||||
|
|
@ -182,8 +196,24 @@ class PhysicalAcceptanceDialogueExecutor:
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
transport: ApplicationBatchExchange,
|
transport: ApplicationBatchExchange,
|
||||||
|
*,
|
||||||
|
monotonic: Callable[[], float] = time.monotonic,
|
||||||
|
scan_initialization_timeout_seconds: float = SCAN_INITIALIZATION_TIMEOUT_SECONDS,
|
||||||
) -> None:
|
) -> None:
|
||||||
|
if (
|
||||||
|
not isinstance(scan_initialization_timeout_seconds, (int, float))
|
||||||
|
or isinstance(scan_initialization_timeout_seconds, bool)
|
||||||
|
or not math.isfinite(scan_initialization_timeout_seconds)
|
||||||
|
or scan_initialization_timeout_seconds <= 0
|
||||||
|
):
|
||||||
|
raise ApplicationAcceptanceError(
|
||||||
|
"scan initialization timeout must be a positive finite number"
|
||||||
|
)
|
||||||
self._transport = transport
|
self._transport = transport
|
||||||
|
self._monotonic = monotonic
|
||||||
|
self._scan_initialization_timeout_seconds = float(
|
||||||
|
scan_initialization_timeout_seconds
|
||||||
|
)
|
||||||
self._bootstrap_complete = False
|
self._bootstrap_complete = False
|
||||||
self._command_complete = False
|
self._command_complete = False
|
||||||
self._dialogue_stage = "new"
|
self._dialogue_stage = "new"
|
||||||
|
|
@ -199,6 +229,7 @@ class PhysicalAcceptanceDialogueExecutor:
|
||||||
self._stop_permit_snapshot: dict[str, object] | None = None
|
self._stop_permit_snapshot: dict[str, object] | None = None
|
||||||
self._response_evidence: list[dict[str, object]] = []
|
self._response_evidence: list[dict[str, object]] = []
|
||||||
self._correlation_failure: dict[str, object] | None = None
|
self._correlation_failure: dict[str, object] | None = None
|
||||||
|
self._compatibility_failure: dict[str, object] | None = None
|
||||||
|
|
||||||
def run_bootstrap(
|
def run_bootstrap(
|
||||||
self,
|
self,
|
||||||
|
|
@ -217,7 +248,7 @@ class PhysicalAcceptanceDialogueExecutor:
|
||||||
|
|
||||||
if self._dialogue_stage != "new" or self._bootstrap_complete or self._command_complete:
|
if self._dialogue_stage != "new" or self._bootstrap_complete or self._command_complete:
|
||||||
raise ApplicationAcceptanceError("connection stage is not admissible now")
|
raise ApplicationAcceptanceError("connection stage is not admissible now")
|
||||||
for expected_batch in (1, 2, 3):
|
for expected_batch in (1, 2):
|
||||||
self._exchange_bootstrap_batch(orchestrator, expected_batch=expected_batch)
|
self._exchange_bootstrap_batch(orchestrator, expected_batch=expected_batch)
|
||||||
binding = orchestrator.binding
|
binding = orchestrator.binding
|
||||||
if binding is None:
|
if binding is None:
|
||||||
|
|
@ -270,7 +301,7 @@ class PhysicalAcceptanceDialogueExecutor:
|
||||||
if self._dialogue_stage != "connection-ready":
|
if self._dialogue_stage != "connection-ready":
|
||||||
raise ApplicationAcceptanceError("workspace entry requires the connection stage")
|
raise ApplicationAcceptanceError("workspace entry requires the connection stage")
|
||||||
self._consume_checkpoint(checkpoint, expected="workspace-entered")
|
self._consume_checkpoint(checkpoint, expected="workspace-entered")
|
||||||
self._exchange_bootstrap_batch(orchestrator, expected_batch=4)
|
self._exchange_bootstrap_batch(orchestrator, expected_batch=3)
|
||||||
binding = orchestrator.binding
|
binding = orchestrator.binding
|
||||||
if binding is None:
|
if binding is None:
|
||||||
raise ApplicationAcceptanceError("workspace entry lost the live device binding")
|
raise ApplicationAcceptanceError("workspace entry lost the live device binding")
|
||||||
|
|
@ -287,7 +318,7 @@ class PhysicalAcceptanceDialogueExecutor:
|
||||||
if self._dialogue_stage != "workspace-ready":
|
if self._dialogue_stage != "workspace-ready":
|
||||||
raise ApplicationAcceptanceError("project prompt requires workspace entry")
|
raise ApplicationAcceptanceError("project prompt requires workspace entry")
|
||||||
self._consume_checkpoint(checkpoint, expected="project-prompt-opened")
|
self._consume_checkpoint(checkpoint, expected="project-prompt-opened")
|
||||||
self._exchange_bootstrap_batch(orchestrator, expected_batch=5)
|
self._exchange_bootstrap_batch(orchestrator, expected_batch=4)
|
||||||
if not orchestrator.snapshot().bootstrap_complete:
|
if not orchestrator.snapshot().bootstrap_complete:
|
||||||
raise ApplicationAcceptanceError("project prompt did not complete the transcript")
|
raise ApplicationAcceptanceError("project prompt did not complete the transcript")
|
||||||
binding = orchestrator.binding
|
binding = orchestrator.binding
|
||||||
|
|
@ -338,9 +369,9 @@ class PhysicalAcceptanceDialogueExecutor:
|
||||||
self._dialogue_stage = "start-attempted"
|
self._dialogue_stage = "start-attempted"
|
||||||
responses = self._transport.exchange_batch_once(
|
responses = self._transport.exchange_batch_once(
|
||||||
[OneShotPublishEnvelope.from_modeling_command(command)],
|
[OneShotPublishEnvelope.from_modeling_command(command)],
|
||||||
required_response_topics={MODELING_RESPONSE_TOPIC},
|
required_response_operation_keys={"modeling:start"},
|
||||||
)
|
)
|
||||||
payload = responses[MODELING_RESPONSE_TOPIC]
|
payload = responses["modeling:start"]
|
||||||
self._record_response_evidence(
|
self._record_response_evidence(
|
||||||
phase="modeling",
|
phase="modeling",
|
||||||
operation_key="modeling:start",
|
operation_key="modeling:start",
|
||||||
|
|
@ -363,24 +394,33 @@ class PhysicalAcceptanceDialogueExecutor:
|
||||||
immediate = post_start.immediate_modeling_status
|
immediate = post_start.immediate_modeling_status
|
||||||
self._transport.exchange_batch_once(
|
self._transport.exchange_batch_once(
|
||||||
[OneShotPublishEnvelope.from_dialogue_request(immediate)],
|
[OneShotPublishEnvelope.from_dialogue_request(immediate)],
|
||||||
required_response_topics=(),
|
required_response_operation_keys=(),
|
||||||
)
|
)
|
||||||
self._dialogue_stage = "initializing"
|
self._dialogue_stage = "initializing"
|
||||||
|
initialization_deadline = (
|
||||||
|
self._monotonic() + self._scan_initialization_timeout_seconds
|
||||||
|
)
|
||||||
while not self._transport.scan_initialization_complete(binding):
|
while not self._transport.scan_initialization_complete(binding):
|
||||||
|
if self._monotonic() >= initialization_deadline:
|
||||||
|
raise ScanInitializationTimeout(
|
||||||
|
"K1 did not confirm bound SCANNING initialization before the safety deadline"
|
||||||
|
)
|
||||||
self._transport.maintain_open_for(
|
self._transport.maintain_open_for(
|
||||||
CONTROL_NETWORK_PUMP_QUANTUM_SECONDS,
|
CONTROL_NETWORK_PUMP_QUANTUM_SECONDS,
|
||||||
allowed_response_topics={MODELING_STATUS_RESPONSE_TOPIC},
|
allowed_response_topics={MODELING_STATUS_RESPONSE_TOPIC},
|
||||||
)
|
)
|
||||||
|
|
||||||
refresh = post_start.post_initialization_refresh
|
refresh = post_start.post_initialization_refresh
|
||||||
required_topics = {request.response_topic for request in refresh}
|
required_operations = {
|
||||||
|
f"dialogue:{request.ordinal}:{request.message_type}" for request in refresh
|
||||||
|
}
|
||||||
refresh_responses = self._transport.exchange_batch_once(
|
refresh_responses = self._transport.exchange_batch_once(
|
||||||
[OneShotPublishEnvelope.from_dialogue_request(request) for request in refresh],
|
[OneShotPublishEnvelope.from_dialogue_request(request) for request in refresh],
|
||||||
required_response_topics=required_topics,
|
required_response_operation_keys=required_operations,
|
||||||
)
|
)
|
||||||
for request in refresh:
|
for request in refresh:
|
||||||
refresh_payload = refresh_responses[request.response_topic]
|
|
||||||
operation_key = f"dialogue:{request.ordinal}:{request.message_type}"
|
operation_key = f"dialogue:{request.ordinal}:{request.message_type}"
|
||||||
|
refresh_payload = refresh_responses[operation_key]
|
||||||
self._record_response_evidence(
|
self._record_response_evidence(
|
||||||
phase="post-start",
|
phase="post-start",
|
||||||
operation_key=operation_key,
|
operation_key=operation_key,
|
||||||
|
|
@ -480,9 +520,9 @@ class PhysicalAcceptanceDialogueExecutor:
|
||||||
self._dialogue_stage = "stop-attempted"
|
self._dialogue_stage = "stop-attempted"
|
||||||
responses = self._transport.exchange_batch_once(
|
responses = self._transport.exchange_batch_once(
|
||||||
[OneShotPublishEnvelope.from_modeling_command(command)],
|
[OneShotPublishEnvelope.from_modeling_command(command)],
|
||||||
required_response_topics={MODELING_RESPONSE_TOPIC},
|
required_response_operation_keys={"modeling:stop"},
|
||||||
)
|
)
|
||||||
payload = responses[MODELING_RESPONSE_TOPIC]
|
payload = responses["modeling:stop"]
|
||||||
self._record_response_evidence(
|
self._record_response_evidence(
|
||||||
phase="modeling",
|
phase="modeling",
|
||||||
operation_key="modeling:stop",
|
operation_key="modeling:stop",
|
||||||
|
|
@ -505,16 +545,13 @@ class PhysicalAcceptanceDialogueExecutor:
|
||||||
self._dialogue_stage = "stop-acknowledged"
|
self._dialogue_stage = "stop-acknowledged"
|
||||||
return response
|
return response
|
||||||
|
|
||||||
def maintain_post_stop_until_standby_confirmed(
|
def maintain_post_stop_until_standby(self) -> None:
|
||||||
self,
|
"""Keep servicing control reports through save and protocol standby.
|
||||||
standby_confirmed: Callable[[], bool],
|
|
||||||
) -> None:
|
|
||||||
"""Keep servicing control reports through save and physical standby.
|
|
||||||
|
|
||||||
The retained capture does not expose a protocol timer that proves save
|
There is no wall-clock shortcut after STOP. The dialogue remains owned
|
||||||
completion. The session therefore remains owned until an explicit
|
until the bound K1 itself reports READY with no project attached. That
|
||||||
operator/status gate confirms standby and never closes itself merely
|
live protocol state is the canonical completion evidence; a second
|
||||||
because a captured wall-clock duration elapsed.
|
operator acknowledgement would add no independent information.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
if self._dialogue_stage != "stop-acknowledged" or not self._stop_complete:
|
if self._dialogue_stage != "stop-acknowledged" or not self._stop_complete:
|
||||||
|
|
@ -524,9 +561,7 @@ class PhysicalAcceptanceDialogueExecutor:
|
||||||
binding = self._active_binding
|
binding = self._active_binding
|
||||||
if binding is None:
|
if binding is None:
|
||||||
raise ApplicationAcceptanceError("canonical START binding is no longer available")
|
raise ApplicationAcceptanceError("canonical START binding is no longer available")
|
||||||
while not (
|
while not self._transport.standby_complete(binding):
|
||||||
self._transport.standby_complete(binding) and standby_confirmed()
|
|
||||||
):
|
|
||||||
self._transport.maintain_open_for(
|
self._transport.maintain_open_for(
|
||||||
CONTROL_NETWORK_PUMP_QUANTUM_SECONDS,
|
CONTROL_NETWORK_PUMP_QUANTUM_SECONDS,
|
||||||
allowed_response_topics={MODELING_STATUS_RESPONSE_TOPIC},
|
allowed_response_topics={MODELING_STATUS_RESPONSE_TOPIC},
|
||||||
|
|
@ -555,12 +590,19 @@ class PhysicalAcceptanceDialogueExecutor:
|
||||||
if self._stop_permit_snapshot is not None
|
if self._stop_permit_snapshot is not None
|
||||||
else None
|
else None
|
||||||
),
|
),
|
||||||
"response_evidence": tuple(dict(item) for item in self._response_evidence),
|
# This snapshot crosses the plugin SDK boundary, whose JsonValue
|
||||||
|
# contract intentionally rejects Python-only container types.
|
||||||
|
"response_evidence": [dict(item) for item in self._response_evidence],
|
||||||
"correlation_failure": (
|
"correlation_failure": (
|
||||||
dict(self._correlation_failure)
|
dict(self._correlation_failure)
|
||||||
if self._correlation_failure is not None
|
if self._correlation_failure is not None
|
||||||
else None
|
else None
|
||||||
),
|
),
|
||||||
|
"compatibility_failure": (
|
||||||
|
dict(self._compatibility_failure)
|
||||||
|
if self._compatibility_failure is not None
|
||||||
|
else None
|
||||||
|
),
|
||||||
"automatic_retry": False,
|
"automatic_retry": False,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -608,18 +650,20 @@ class PhysicalAcceptanceDialogueExecutor:
|
||||||
f"canonical bootstrap expected batch {expected_batch}"
|
f"canonical bootstrap expected batch {expected_batch}"
|
||||||
)
|
)
|
||||||
batch = orchestrator.next_batch()
|
batch = orchestrator.next_batch()
|
||||||
required_topics = {
|
required_operations = {
|
||||||
request.response_topic for request in batch if request.response_required
|
f"bootstrap:{request.ordinal}:{request.message_type}"
|
||||||
|
for request in batch
|
||||||
|
if request.response_required
|
||||||
}
|
}
|
||||||
responses = self._transport.exchange_batch_once(
|
responses = self._transport.exchange_batch_once(
|
||||||
[OneShotPublishEnvelope.from_bootstrap_request(request) for request in batch],
|
[OneShotPublishEnvelope.from_bootstrap_request(request) for request in batch],
|
||||||
required_response_topics=required_topics,
|
required_response_operation_keys=required_operations,
|
||||||
)
|
)
|
||||||
for request in batch:
|
for request in batch:
|
||||||
if not request.response_required:
|
if not request.response_required:
|
||||||
continue
|
continue
|
||||||
payload = responses[request.response_topic]
|
|
||||||
operation_key = f"bootstrap:{request.ordinal}:{request.message_type}"
|
operation_key = f"bootstrap:{request.ordinal}:{request.message_type}"
|
||||||
|
payload = responses[operation_key]
|
||||||
self._record_response_evidence(
|
self._record_response_evidence(
|
||||||
phase="bootstrap",
|
phase="bootstrap",
|
||||||
operation_key=operation_key,
|
operation_key=operation_key,
|
||||||
|
|
@ -627,7 +671,25 @@ class PhysicalAcceptanceDialogueExecutor:
|
||||||
payload=payload,
|
payload=payload,
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
orchestrator.accept_response(request.response_topic, payload)
|
orchestrator.accept_response(operation_key, payload)
|
||||||
|
except ApplicationCompatibilityProfileError as exc:
|
||||||
|
self._compatibility_failure = {
|
||||||
|
"phase": "bootstrap",
|
||||||
|
"operation_key": operation_key,
|
||||||
|
"response_topic": request.response_topic,
|
||||||
|
"reason_code": exc.reason_code,
|
||||||
|
"expected": {
|
||||||
|
"device_model": "LixelKity K1",
|
||||||
|
"platform_type": "A4",
|
||||||
|
"firmware": "3.0.2",
|
||||||
|
"is_activated": True,
|
||||||
|
},
|
||||||
|
"observed": dict(exc.observed_facts),
|
||||||
|
}
|
||||||
|
raise ApplicationProfileMismatchError(
|
||||||
|
"live DeviceInfo is incompatible with the selected "
|
||||||
|
"LixelKity K1 / A4 / FW 3.0.2 profile"
|
||||||
|
) from exc
|
||||||
except ApplicationBootstrapError as exc:
|
except ApplicationBootstrapError as exc:
|
||||||
self._record_correlation_failure(
|
self._record_correlation_failure(
|
||||||
phase="bootstrap",
|
phase="bootstrap",
|
||||||
|
|
@ -670,5 +732,23 @@ class PhysicalAcceptanceDialogueExecutor:
|
||||||
"phase": phase,
|
"phase": phase,
|
||||||
"operation_key": operation_key,
|
"operation_key": operation_key,
|
||||||
"response_topic": response_topic,
|
"response_topic": response_topic,
|
||||||
|
"reason_code": self._correlation_reason_code(reason),
|
||||||
"reason": reason,
|
"reason": reason,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _correlation_reason_code(reason: str) -> str:
|
||||||
|
reason_fragments = (
|
||||||
|
("session correlation failed", "response_session_mismatch"),
|
||||||
|
("vendor identity mismatch", "response_device_identity_mismatch"),
|
||||||
|
("authority mismatch", "response_authority_mismatch"),
|
||||||
|
("result code", "response_rejected"),
|
||||||
|
("facts changed", "response_device_facts_changed"),
|
||||||
|
("invalid", "response_decode_failed"),
|
||||||
|
("missing", "response_decode_failed"),
|
||||||
|
("wrong wire type", "response_decode_failed"),
|
||||||
|
)
|
||||||
|
return next(
|
||||||
|
(code for fragment, code in reason_fragments if fragment in reason),
|
||||||
|
"response_correlation_failed",
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,12 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import importlib
|
||||||
import platform
|
import platform
|
||||||
import shutil
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Protocol
|
from typing import Any, Protocol, cast
|
||||||
|
|
||||||
from k1link.device_plugins.xgrids_k1.protocol.application_bootstrap import (
|
from k1link.device_plugins.xgrids_k1.protocol.application_bootstrap import (
|
||||||
ApplicationBootstrapError,
|
ApplicationBootstrapError,
|
||||||
|
|
@ -33,6 +34,10 @@ class CommandRunner(Protocol):
|
||||||
) -> subprocess.CompletedProcess[bytes]: ...
|
) -> subprocess.CompletedProcess[bytes]: ...
|
||||||
|
|
||||||
|
|
||||||
|
class KeychainSecretReader(Protocol):
|
||||||
|
def __call__(self, *, service: str, account: str) -> bytes: ...
|
||||||
|
|
||||||
|
|
||||||
class InteractiveCommandRunner(Protocol):
|
class InteractiveCommandRunner(Protocol):
|
||||||
def __call__(
|
def __call__(
|
||||||
self,
|
self,
|
||||||
|
|
@ -43,6 +48,63 @@ class InteractiveCommandRunner(Protocol):
|
||||||
) -> subprocess.CompletedProcess[bytes]: ...
|
) -> subprocess.CompletedProcess[bytes]: ...
|
||||||
|
|
||||||
|
|
||||||
|
def _read_keychain_secret_via_security_framework(*, service: str, account: str) -> bytes:
|
||||||
|
"""Read one fixed generic-password item without spawning a UI/CLI process."""
|
||||||
|
|
||||||
|
try:
|
||||||
|
objc = importlib.import_module("objc")
|
||||||
|
foundation = importlib.import_module("Foundation")
|
||||||
|
bundle = foundation.NSBundle.bundleWithPath_(
|
||||||
|
"/System/Library/Frameworks/Security.framework"
|
||||||
|
)
|
||||||
|
if bundle is None or not bundle.load():
|
||||||
|
raise RuntimeError("Security.framework is unavailable")
|
||||||
|
|
||||||
|
symbols: dict[str, Any] = {}
|
||||||
|
objc.loadBundleVariables(
|
||||||
|
bundle,
|
||||||
|
symbols,
|
||||||
|
[
|
||||||
|
("kSecClass", b"@"),
|
||||||
|
("kSecClassGenericPassword", b"@"),
|
||||||
|
("kSecAttrService", b"@"),
|
||||||
|
("kSecAttrAccount", b"@"),
|
||||||
|
("kSecReturnData", b"@"),
|
||||||
|
("kSecMatchLimit", b"@"),
|
||||||
|
("kSecMatchLimitOne", b"@"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
functions: dict[str, Any] = {}
|
||||||
|
objc.loadBundleFunctions(
|
||||||
|
bundle,
|
||||||
|
functions,
|
||||||
|
[("SecItemCopyMatching", b"i@o^@")],
|
||||||
|
)
|
||||||
|
query = {
|
||||||
|
symbols["kSecClass"]: symbols["kSecClassGenericPassword"],
|
||||||
|
symbols["kSecAttrService"]: service,
|
||||||
|
symbols["kSecAttrAccount"]: account,
|
||||||
|
symbols["kSecReturnData"]: True,
|
||||||
|
symbols["kSecMatchLimit"]: symbols["kSecMatchLimitOne"],
|
||||||
|
}
|
||||||
|
copy_matching = cast(Any, functions["SecItemCopyMatching"])
|
||||||
|
result = copy_matching(query, None)
|
||||||
|
except ApplicationAuthorityLoadError:
|
||||||
|
raise
|
||||||
|
except Exception as exc:
|
||||||
|
raise ApplicationAuthorityLoadError("macOS Keychain authority lookup failed") from exc
|
||||||
|
|
||||||
|
if not isinstance(result, tuple) or len(result) != 2:
|
||||||
|
raise ApplicationAuthorityLoadError("macOS Keychain authority lookup failed")
|
||||||
|
status, secret_data = result
|
||||||
|
if int(status) != 0 or secret_data is None:
|
||||||
|
raise ApplicationAuthorityLoadError("macOS Keychain authority is unavailable")
|
||||||
|
try:
|
||||||
|
return bytes(secret_data)
|
||||||
|
except Exception as exc:
|
||||||
|
raise ApplicationAuthorityLoadError("macOS Keychain authority lookup failed") from exc
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
class ApplicationAuthoritySourceSnapshot:
|
class ApplicationAuthoritySourceSnapshot:
|
||||||
provider: str = "macos-keychain"
|
provider: str = "macos-keychain"
|
||||||
|
|
@ -69,12 +131,19 @@ class MacOSKeychainApplicationAuthorityLoader:
|
||||||
"""Read the exact-profile authority from the current user's Keychain.
|
"""Read the exact-profile authority from the current user's Keychain.
|
||||||
|
|
||||||
There is deliberately no environment, plaintext-file, browser or API
|
There is deliberately no environment, plaintext-file, browser or API
|
||||||
fallback. The secret is requested through the absolute ``security`` binary
|
fallback. Runtime reads call Apple's Security.framework in-process and
|
||||||
and is never interpolated into an exception, repr or subprocess argument.
|
never launch Keychain Access or the ``security`` CLI. The optional command
|
||||||
|
runner exists only as a compatibility seam for the reviewed test harness.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, *, runner: CommandRunner = subprocess.run) -> None:
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
runner: CommandRunner | None = None,
|
||||||
|
framework_reader: KeychainSecretReader = _read_keychain_secret_via_security_framework,
|
||||||
|
) -> None:
|
||||||
self._runner = runner
|
self._runner = runner
|
||||||
|
self._framework_reader = framework_reader
|
||||||
|
|
||||||
def snapshot(self) -> ApplicationAuthoritySourceSnapshot:
|
def snapshot(self) -> ApplicationAuthoritySourceSnapshot:
|
||||||
return ApplicationAuthoritySourceSnapshot()
|
return ApplicationAuthoritySourceSnapshot()
|
||||||
|
|
@ -84,32 +153,48 @@ class MacOSKeychainApplicationAuthorityLoader:
|
||||||
raise ApplicationAuthorityLoadError(
|
raise ApplicationAuthorityLoadError(
|
||||||
"application authority loading is supported only through macOS Keychain"
|
"application authority loading is supported only through macOS Keychain"
|
||||||
)
|
)
|
||||||
security = shutil.which("security")
|
if self._runner is None:
|
||||||
if security != "/usr/bin/security":
|
try:
|
||||||
raise ApplicationAuthorityLoadError("trusted macOS security binary is unavailable")
|
secret_bytes = self._framework_reader(
|
||||||
|
service=KEYCHAIN_SERVICE,
|
||||||
|
account=KEYCHAIN_ACCOUNT,
|
||||||
|
)
|
||||||
|
except ApplicationAuthorityLoadError:
|
||||||
|
raise
|
||||||
|
except Exception as exc:
|
||||||
|
raise ApplicationAuthorityLoadError(
|
||||||
|
"macOS Keychain authority lookup failed"
|
||||||
|
) from exc
|
||||||
|
else:
|
||||||
|
security = shutil.which("security")
|
||||||
|
if security != "/usr/bin/security":
|
||||||
|
raise ApplicationAuthorityLoadError(
|
||||||
|
"trusted macOS security binary is unavailable"
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
completed = self._runner(
|
||||||
|
[
|
||||||
|
security,
|
||||||
|
"find-generic-password",
|
||||||
|
"-s",
|
||||||
|
KEYCHAIN_SERVICE,
|
||||||
|
"-a",
|
||||||
|
KEYCHAIN_ACCOUNT,
|
||||||
|
"-w",
|
||||||
|
],
|
||||||
|
capture_output=True,
|
||||||
|
check=False,
|
||||||
|
timeout=KEYCHAIN_TIMEOUT_SECONDS,
|
||||||
|
)
|
||||||
|
except (OSError, subprocess.SubprocessError) as exc:
|
||||||
|
raise ApplicationAuthorityLoadError(
|
||||||
|
"macOS Keychain authority lookup failed"
|
||||||
|
) from exc
|
||||||
|
if completed.returncode != 0:
|
||||||
|
raise ApplicationAuthorityLoadError("macOS Keychain authority is unavailable")
|
||||||
|
secret_bytes = completed.stdout
|
||||||
|
|
||||||
try:
|
secret_buffer = bytearray(secret_bytes)
|
||||||
completed = self._runner(
|
|
||||||
[
|
|
||||||
security,
|
|
||||||
"find-generic-password",
|
|
||||||
"-s",
|
|
||||||
KEYCHAIN_SERVICE,
|
|
||||||
"-a",
|
|
||||||
KEYCHAIN_ACCOUNT,
|
|
||||||
"-w",
|
|
||||||
],
|
|
||||||
capture_output=True,
|
|
||||||
check=False,
|
|
||||||
timeout=KEYCHAIN_TIMEOUT_SECONDS,
|
|
||||||
)
|
|
||||||
except (OSError, subprocess.SubprocessError) as exc:
|
|
||||||
raise ApplicationAuthorityLoadError("macOS Keychain authority lookup failed") from exc
|
|
||||||
|
|
||||||
if completed.returncode != 0:
|
|
||||||
raise ApplicationAuthorityLoadError("macOS Keychain authority is unavailable")
|
|
||||||
|
|
||||||
secret_buffer = bytearray(completed.stdout)
|
|
||||||
try:
|
try:
|
||||||
while secret_buffer.endswith((b"\n", b"\r")):
|
while secret_buffer.endswith((b"\n", b"\r")):
|
||||||
secret_buffer.pop()
|
secret_buffer.pop()
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ from k1link.device_plugins.xgrids_k1.protocol.modeling_control import OPENAPI_SU
|
||||||
from k1link.device_plugins.xgrids_k1.protocol.protobuf_wire import (
|
from k1link.device_plugins.xgrids_k1.protocol.protobuf_wire import (
|
||||||
ProtobufWireError,
|
ProtobufWireError,
|
||||||
ProtoField,
|
ProtoField,
|
||||||
|
encode_zigzag64,
|
||||||
iter_fields,
|
iter_fields,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -19,6 +20,12 @@ MAX_HEADER_BYTES = 4 * 1024
|
||||||
MAX_TEXT_BYTES = 4 * 1024
|
MAX_TEXT_BYTES = 4 * 1024
|
||||||
APPLICATION_AUTHORITY_SOURCE = "owner-captured-lixelgo-application"
|
APPLICATION_AUTHORITY_SOURCE = "owner-captured-lixelgo-application"
|
||||||
COMPATIBILITY_PROFILE_ID = "xgrids.lixelkity-k1.fw-3.0.2.direct-lan.v1"
|
COMPATIBILITY_PROFILE_ID = "xgrids.lixelkity-k1.fw-3.0.2.direct-lan.v1"
|
||||||
|
REVIEWED_DEVICE_MODEL = "LixelKity K1"
|
||||||
|
REVIEWED_DEVICE_TYPE = "A4"
|
||||||
|
REVIEWED_PROFILE_ATTESTATION_FAILURE = (
|
||||||
|
"live DeviceInfo does not attest the reviewed activated "
|
||||||
|
"LixelKity K1 (platform type A4) FW 3.0.2 profile"
|
||||||
|
)
|
||||||
DEVICE_CONFIG_TIME_CONTEXT = "Publish_Proto_DeviceConfig_SetTime"
|
DEVICE_CONFIG_TIME_CONTEXT = "Publish_Proto_DeviceConfig_SetTime"
|
||||||
SHADOW_BLOCKERS = ("vendor-writes-disabled", "publisher-not-installed")
|
SHADOW_BLOCKERS = ("vendor-writes-disabled", "publisher-not-installed")
|
||||||
|
|
||||||
|
|
@ -40,6 +47,22 @@ class ApplicationBootstrapError(ValueError):
|
||||||
"""The recovered K1 application bootstrap contract was violated."""
|
"""The recovered K1 application bootstrap contract was violated."""
|
||||||
|
|
||||||
|
|
||||||
|
class ApplicationCompatibilityProfileError(ApplicationBootstrapError):
|
||||||
|
"""A correlated DeviceInfo response does not match the selected profile."""
|
||||||
|
|
||||||
|
reason_code = "compatibility_profile_mismatch"
|
||||||
|
|
||||||
|
def __init__(self, binding: LiveDeviceControlBinding) -> None:
|
||||||
|
self.observed_facts = {
|
||||||
|
"device_model": binding.device_model,
|
||||||
|
"platform_type": binding.device_type,
|
||||||
|
"software_version": binding.software_version,
|
||||||
|
"system_version": binding.system_version,
|
||||||
|
"is_activated": binding.is_activated,
|
||||||
|
}
|
||||||
|
super().__init__(REVIEWED_PROFILE_ATTESTATION_FAILURE)
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
class ApplicationControlAuthority:
|
class ApplicationControlAuthority:
|
||||||
"""Private authority belonging to the reviewed LixelGO application profile.
|
"""Private authority belonging to the reviewed LixelGO application profile.
|
||||||
|
|
@ -98,9 +121,22 @@ class LiveDeviceControlBinding:
|
||||||
self.system_version, "3.0.2"
|
self.system_version, "3.0.2"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def matches_reviewed_device(self) -> bool:
|
||||||
|
"""Match the exact model/type strings retained from the reviewed K1."""
|
||||||
|
|
||||||
|
return (
|
||||||
|
self.device_model == REVIEWED_DEVICE_MODEL
|
||||||
|
and self.device_type == REVIEWED_DEVICE_TYPE
|
||||||
|
)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def ready_for_reviewed_profile(self) -> bool:
|
def ready_for_reviewed_profile(self) -> bool:
|
||||||
return self.is_activated and self.matches_reviewed_firmware
|
return (
|
||||||
|
self.is_activated
|
||||||
|
and self.matches_reviewed_device
|
||||||
|
and self.matches_reviewed_firmware
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
|
|
@ -213,6 +249,15 @@ class ApplicationResponse:
|
||||||
result_code: int
|
result_code: int
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class ApplicationMessageIdentity:
|
||||||
|
"""Redacted identity used to route one protobuf request/response safely."""
|
||||||
|
|
||||||
|
vendor_device_id: str | None = field(repr=False)
|
||||||
|
session_id: str = field(repr=False)
|
||||||
|
openapi_key: str = field(repr=False)
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
class ApplicationBootstrapSnapshot:
|
class ApplicationBootstrapSnapshot:
|
||||||
current_batch: int
|
current_batch: int
|
||||||
|
|
@ -253,9 +298,7 @@ def build_shadow_bootstrap(
|
||||||
"""
|
"""
|
||||||
|
|
||||||
if not binding.ready_for_reviewed_profile:
|
if not binding.ready_for_reviewed_profile:
|
||||||
raise ApplicationBootstrapError(
|
raise ApplicationCompatibilityProfileError(binding)
|
||||||
"live DeviceInfo does not attest the reviewed activated FW 3.0.2 profile"
|
|
||||||
)
|
|
||||||
if not isinstance(epoch_seconds, int) or isinstance(epoch_seconds, bool):
|
if not isinstance(epoch_seconds, int) or isinstance(epoch_seconds, bool):
|
||||||
raise ApplicationBootstrapError("epoch_seconds must be an integer")
|
raise ApplicationBootstrapError("epoch_seconds must be an integer")
|
||||||
if epoch_seconds < 0 or epoch_seconds > 0x7FFF_FFFF_FFFF_FFFF:
|
if epoch_seconds < 0 or epoch_seconds > 0x7FFF_FFFF_FFFF_FFFF:
|
||||||
|
|
@ -373,7 +416,9 @@ def build_shadow_bootstrap(
|
||||||
)
|
)
|
||||||
body = b""
|
body = b""
|
||||||
if message_type == "DeviceConfigRequest":
|
if message_type == "DeviceConfigRequest":
|
||||||
time_config = _varint_field(1, epoch_seconds) + _text_field(2, timezone_name)
|
time_config = _varint_field(1, encode_zigzag64(epoch_seconds)) + _text_field(
|
||||||
|
2, timezone_name
|
||||||
|
)
|
||||||
body = _bytes_field(4, time_config)
|
body = _bytes_field(4, time_config)
|
||||||
payload = _bytes_field(1, _encode_header(header)) + body
|
payload = _bytes_field(1, _encode_header(header)) + body
|
||||||
_check_payload(payload, "application request")
|
_check_payload(payload, "application request")
|
||||||
|
|
@ -406,9 +451,7 @@ def build_canonical_post_start_observation(
|
||||||
"""Build retained operations 12-14 without granting publish authority."""
|
"""Build retained operations 12-14 without granting publish authority."""
|
||||||
|
|
||||||
if not binding.ready_for_reviewed_profile:
|
if not binding.ready_for_reviewed_profile:
|
||||||
raise ApplicationBootstrapError(
|
raise ApplicationCompatibilityProfileError(binding)
|
||||||
"live DeviceInfo does not attest the reviewed activated FW 3.0.2 profile"
|
|
||||||
)
|
|
||||||
|
|
||||||
def build(
|
def build(
|
||||||
ordinal: Literal[12, 13, 14],
|
ordinal: Literal[12, 13, 14],
|
||||||
|
|
@ -577,15 +620,43 @@ def correlate_application_response(
|
||||||
return ApplicationResponse(session_id, device_id, openapi_key, result_code)
|
return ApplicationResponse(session_id, device_id, openapi_key, result_code)
|
||||||
|
|
||||||
|
|
||||||
|
def decode_application_message_identity(payload: bytes) -> ApplicationMessageIdentity:
|
||||||
|
"""Decode only the common header identity without exposing message bodies."""
|
||||||
|
|
||||||
|
_check_payload(payload, "application message")
|
||||||
|
top = _selected_unique_fields(
|
||||||
|
payload,
|
||||||
|
"application message",
|
||||||
|
max_fields=64,
|
||||||
|
selected={1},
|
||||||
|
)
|
||||||
|
header = _selected_unique_fields(
|
||||||
|
_required_bytes(top, 1, "message.header"),
|
||||||
|
"header",
|
||||||
|
max_fields=32,
|
||||||
|
selected={4, 5, 6},
|
||||||
|
)
|
||||||
|
return ApplicationMessageIdentity(
|
||||||
|
vendor_device_id=(
|
||||||
|
_identity_field(header, 4, "header.device_id") if 4 in header else None
|
||||||
|
),
|
||||||
|
session_id=_identity_field(header, 5, "header.session_id"),
|
||||||
|
openapi_key=_identity_field(header, 6, "header.openapi_key"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class ShadowApplicationBootstrapOrchestrator:
|
class ShadowApplicationBootstrapOrchestrator:
|
||||||
"""Advance the retained dialogue only across observed response barriers.
|
"""Advance the retained dialogue only across observed response barriers.
|
||||||
|
|
||||||
The clean cycle emitted five batches. ``ModelingStatusRequest`` in the
|
DeviceInfo is the sole identity-binding barrier. After it succeeds, the
|
||||||
second batch had no required synchronous response before preparation
|
retained captures publish ordinals 2-6 in order without inventing a barrier
|
||||||
continued; readiness remains a separate live DeviceStatus gate.
|
between unbound ordinal 3 and bound ordinals 4-6. Their exact responses
|
||||||
|
are correlated by operation/session identity before the operator may
|
||||||
|
advance. ``ModelingStatusRequest`` ordinal 2 remains response-free;
|
||||||
|
readiness is a separate live DeviceStatus gate.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
_BATCH_ORDINALS = ((1,), (2, 3), (4, 5, 6), (7,), (8, 9, 10))
|
_BATCH_ORDINALS = ((1,), (2, 3, 4, 5, 6), (7,), (8, 9, 10))
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
|
|
@ -612,7 +683,9 @@ class ShadowApplicationBootstrapOrchestrator:
|
||||||
current_batch=min(self._batch_index + 1, len(self._BATCH_ORDINALS)),
|
current_batch=min(self._batch_index + 1, len(self._BATCH_ORDINALS)),
|
||||||
total_batches=len(self._BATCH_ORDINALS),
|
total_batches=len(self._BATCH_ORDINALS),
|
||||||
batch_issued=self._batch_issued,
|
batch_issued=self._batch_issued,
|
||||||
pending_response_topics=tuple(sorted(self._pending)),
|
pending_response_topics=tuple(
|
||||||
|
sorted(request.response_topic for request in self._pending.values())
|
||||||
|
),
|
||||||
live_binding_observed=self._binding is not None,
|
live_binding_observed=self._binding is not None,
|
||||||
bootstrap_complete=self._complete,
|
bootstrap_complete=self._complete,
|
||||||
)
|
)
|
||||||
|
|
@ -643,25 +716,23 @@ class ShadowApplicationBootstrapOrchestrator:
|
||||||
for ordinal in self._BATCH_ORDINALS[self._batch_index]
|
for ordinal in self._BATCH_ORDINALS[self._batch_index]
|
||||||
)
|
)
|
||||||
pending = {
|
pending = {
|
||||||
request.response_topic: request for request in requests if request.response_required
|
self._operation_key(request): request
|
||||||
|
for request in requests
|
||||||
|
if request.response_required
|
||||||
}
|
}
|
||||||
if len(pending) != sum(request.response_required for request in requests):
|
|
||||||
raise ApplicationBootstrapError(
|
|
||||||
"bootstrap batch contains ambiguous required response topics"
|
|
||||||
)
|
|
||||||
if not pending:
|
if not pending:
|
||||||
raise ApplicationBootstrapError("bootstrap batch has no recovered response barrier")
|
raise ApplicationBootstrapError("bootstrap batch has no recovered response barrier")
|
||||||
self._pending = pending
|
self._pending = pending
|
||||||
self._batch_issued = True
|
self._batch_issued = True
|
||||||
return requests
|
return requests
|
||||||
|
|
||||||
def accept_response(self, topic: str, payload: bytes) -> None:
|
def accept_response(self, operation_key: str, payload: bytes) -> None:
|
||||||
"""Correlate one required response and unlock only the next batch."""
|
"""Correlate one required response and unlock only the next batch."""
|
||||||
|
|
||||||
with self._lock:
|
with self._lock:
|
||||||
if not self._batch_issued:
|
if not self._batch_issued:
|
||||||
raise ApplicationBootstrapError("no application bootstrap batch is in flight")
|
raise ApplicationBootstrapError("no application bootstrap batch is in flight")
|
||||||
expected = self._pending.get(topic)
|
expected = self._pending.get(operation_key)
|
||||||
if expected is None:
|
if expected is None:
|
||||||
raise ApplicationBootstrapError(
|
raise ApplicationBootstrapError(
|
||||||
"application response is not required by the current batch"
|
"application response is not required by the current batch"
|
||||||
|
|
@ -704,7 +775,7 @@ class ShadowApplicationBootstrapOrchestrator:
|
||||||
live_binding=self._binding,
|
live_binding=self._binding,
|
||||||
)
|
)
|
||||||
|
|
||||||
del self._pending[topic]
|
del self._pending[operation_key]
|
||||||
if self._pending:
|
if self._pending:
|
||||||
return
|
return
|
||||||
self._batch_issued = False
|
self._batch_issued = False
|
||||||
|
|
@ -712,6 +783,10 @@ class ShadowApplicationBootstrapOrchestrator:
|
||||||
if self._batch_index == len(self._BATCH_ORDINALS):
|
if self._batch_index == len(self._BATCH_ORDINALS):
|
||||||
self._complete = True
|
self._complete = True
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _operation_key(request: EncodedApplicationRequest) -> str:
|
||||||
|
return f"bootstrap:{request.ordinal}:{request.message_type}"
|
||||||
|
|
||||||
|
|
||||||
def _build_initial_device_info_request(
|
def _build_initial_device_info_request(
|
||||||
authority: ApplicationControlAuthority,
|
authority: ApplicationControlAuthority,
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hmac
|
||||||
import math
|
import math
|
||||||
import secrets
|
import secrets
|
||||||
import threading
|
import threading
|
||||||
|
|
@ -16,19 +17,31 @@ from paho.mqtt.reasoncodes import ReasonCode
|
||||||
from k1link.device_plugins.xgrids_k1.ble.wifi_provisioning import AP_FALLBACK_IPV4
|
from k1link.device_plugins.xgrids_k1.ble.wifi_provisioning import AP_FALLBACK_IPV4
|
||||||
from k1link.device_plugins.xgrids_k1.mqtt import validate_private_ipv4
|
from k1link.device_plugins.xgrids_k1.mqtt import validate_private_ipv4
|
||||||
from k1link.device_plugins.xgrids_k1.protocol.application_bootstrap import (
|
from k1link.device_plugins.xgrids_k1.protocol.application_bootstrap import (
|
||||||
|
DEVICE_CONFIG_REQUEST_TOPIC,
|
||||||
DEVICE_CONFIG_RESPONSE_TOPIC,
|
DEVICE_CONFIG_RESPONSE_TOPIC,
|
||||||
|
DEVICE_INFO_REQUEST_TOPIC,
|
||||||
DEVICE_INFO_RESPONSE_TOPIC,
|
DEVICE_INFO_RESPONSE_TOPIC,
|
||||||
|
GET_CLOUD_CONFIG_REQUEST_TOPIC,
|
||||||
GET_CLOUD_CONFIG_RESPONSE_TOPIC,
|
GET_CLOUD_CONFIG_RESPONSE_TOPIC,
|
||||||
|
GET_NTRIP_PROFILE_REQUEST_TOPIC,
|
||||||
GET_NTRIP_PROFILE_RESPONSE_TOPIC,
|
GET_NTRIP_PROFILE_RESPONSE_TOPIC,
|
||||||
|
GET_RTK_ADVANCE_REQUEST_TOPIC,
|
||||||
GET_RTK_ADVANCE_RESPONSE_TOPIC,
|
GET_RTK_ADVANCE_RESPONSE_TOPIC,
|
||||||
|
MODELING_STATUS_REQUEST_TOPIC,
|
||||||
MODELING_STATUS_RESPONSE_TOPIC,
|
MODELING_STATUS_RESPONSE_TOPIC,
|
||||||
|
ApplicationBootstrapError,
|
||||||
|
ApplicationMessageIdentity,
|
||||||
LiveDeviceControlBinding,
|
LiveDeviceControlBinding,
|
||||||
|
decode_application_message_identity,
|
||||||
)
|
)
|
||||||
from k1link.device_plugins.xgrids_k1.protocol.application_publish import (
|
from k1link.device_plugins.xgrids_k1.protocol.application_publish import (
|
||||||
OneShotPublishEnvelope,
|
OneShotPublishEnvelope,
|
||||||
)
|
)
|
||||||
from k1link.device_plugins.xgrids_k1.protocol.modeling_control import (
|
from k1link.device_plugins.xgrids_k1.protocol.modeling_control import (
|
||||||
|
ModelingAction,
|
||||||
|
ModelingProtocolError,
|
||||||
decode_device_status_report,
|
decode_device_status_report,
|
||||||
|
decode_modeling_response,
|
||||||
decode_system_error_report,
|
decode_system_error_report,
|
||||||
)
|
)
|
||||||
from k1link.device_plugins.xgrids_k1.protocol.modeling_safety import (
|
from k1link.device_plugins.xgrids_k1.protocol.modeling_safety import (
|
||||||
|
|
@ -45,6 +58,16 @@ MAX_CONTROL_MAINTAIN_SECONDS = 30.0
|
||||||
MAX_CONTROL_RESPONSE_BYTES = 64 * 1024
|
MAX_CONTROL_RESPONSE_BYTES = 64 * 1024
|
||||||
SYSTEM_ERROR_TOPIC = "lixel/application/report/system_error"
|
SYSTEM_ERROR_TOPIC = "lixel/application/report/system_error"
|
||||||
|
|
||||||
|
APPLICATION_RESPONSE_TOPIC_BY_REQUEST_TOPIC = {
|
||||||
|
DEVICE_INFO_REQUEST_TOPIC: DEVICE_INFO_RESPONSE_TOPIC,
|
||||||
|
MODELING_STATUS_REQUEST_TOPIC: MODELING_STATUS_RESPONSE_TOPIC,
|
||||||
|
GET_RTK_ADVANCE_REQUEST_TOPIC: GET_RTK_ADVANCE_RESPONSE_TOPIC,
|
||||||
|
DEVICE_CONFIG_REQUEST_TOPIC: DEVICE_CONFIG_RESPONSE_TOPIC,
|
||||||
|
GET_NTRIP_PROFILE_REQUEST_TOPIC: GET_NTRIP_PROFILE_RESPONSE_TOPIC,
|
||||||
|
GET_CLOUD_CONFIG_REQUEST_TOPIC: GET_CLOUD_CONFIG_RESPONSE_TOPIC,
|
||||||
|
MODELING_REQUEST_TOPIC: MODELING_RESPONSE_TOPIC,
|
||||||
|
}
|
||||||
|
|
||||||
# The retained LixelGO control connection issued these three SUBSCRIBE packets
|
# The retained LixelGO control connection issued these three SUBSCRIBE packets
|
||||||
# in this exact order. Point-cloud subscriptions belonged to a separate client.
|
# in this exact order. Point-cloud subscriptions belonged to a separate client.
|
||||||
CONTROL_SUBSCRIPTION_GROUPS: tuple[tuple[tuple[str, int], ...], ...] = (
|
CONTROL_SUBSCRIPTION_GROUPS: tuple[tuple[tuple[str, int], ...], ...] = (
|
||||||
|
|
@ -147,14 +170,60 @@ APPLICATION_REQUEST_ALLOWLIST = frozenset(
|
||||||
class ApplicationMqttTransportError(RuntimeError):
|
class ApplicationMqttTransportError(RuntimeError):
|
||||||
"""The reviewed control transport failed before a request was attempted."""
|
"""The reviewed control transport failed before a request was attempted."""
|
||||||
|
|
||||||
|
def __init__(self, message: str, *, reason_code: str = "transport_failure") -> None:
|
||||||
|
super().__init__(message)
|
||||||
|
self.reason_code = reason_code
|
||||||
|
|
||||||
|
|
||||||
class ApplicationCommandOutcomeUnknown(RuntimeError):
|
class ApplicationCommandOutcomeUnknown(RuntimeError):
|
||||||
"""A request may have reached the K1 and must never be retried automatically."""
|
"""A request may have reached the K1 and must never be retried automatically."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
message: str,
|
||||||
|
*,
|
||||||
|
reason_code: str = "command_outcome_unknown",
|
||||||
|
) -> None:
|
||||||
|
super().__init__(message)
|
||||||
|
self.reason_code = reason_code
|
||||||
|
|
||||||
|
|
||||||
class ApplicationControlDeviceFault(RuntimeError):
|
class ApplicationControlDeviceFault(RuntimeError):
|
||||||
"""Live status made further automatic control inadmissible."""
|
"""Live status made further automatic control inadmissible."""
|
||||||
|
|
||||||
|
def __init__(self, message: str, *, reason_code: str = "device_status_fault") -> None:
|
||||||
|
super().__init__(message)
|
||||||
|
self.reason_code = reason_code
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class _ApplicationResponseExpectation:
|
||||||
|
operation_key: str
|
||||||
|
topic: str
|
||||||
|
identity: ApplicationMessageIdentity
|
||||||
|
modeling_action: ModelingAction | None = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def correlation_key(self) -> tuple[str, str, ModelingAction | None]:
|
||||||
|
return (self.topic, self.identity.session_id, self.modeling_action)
|
||||||
|
|
||||||
|
def matches(
|
||||||
|
self,
|
||||||
|
observed: ApplicationMessageIdentity,
|
||||||
|
observed_modeling_action: ModelingAction | None,
|
||||||
|
) -> bool:
|
||||||
|
if self.modeling_action is not observed_modeling_action:
|
||||||
|
return False
|
||||||
|
if not hmac.compare_digest(self.identity.session_id, observed.session_id):
|
||||||
|
return False
|
||||||
|
if not hmac.compare_digest(self.identity.openapi_key, observed.openapi_key):
|
||||||
|
return False
|
||||||
|
expected_device_id = self.identity.vendor_device_id
|
||||||
|
return expected_device_id is None or (
|
||||||
|
observed.vendor_device_id is not None
|
||||||
|
and hmac.compare_digest(expected_device_id, observed.vendor_device_id)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
class ApplicationMqttTransportSnapshot:
|
class ApplicationMqttTransportSnapshot:
|
||||||
|
|
@ -165,6 +234,7 @@ class ApplicationMqttTransportSnapshot:
|
||||||
qos2_completions: int
|
qos2_completions: int
|
||||||
correlated_responses: int
|
correlated_responses: int
|
||||||
ignored_known_responses: int
|
ignored_known_responses: int
|
||||||
|
late_known_responses: int
|
||||||
device_status_reports: int
|
device_status_reports: int
|
||||||
system_error_reports: int
|
system_error_reports: int
|
||||||
report_decode_errors: int
|
report_decode_errors: int
|
||||||
|
|
@ -189,6 +259,7 @@ class ApplicationMqttTransportSnapshot:
|
||||||
"qos2_completions": self.qos2_completions,
|
"qos2_completions": self.qos2_completions,
|
||||||
"correlated_responses": self.correlated_responses,
|
"correlated_responses": self.correlated_responses,
|
||||||
"ignored_known_responses": self.ignored_known_responses,
|
"ignored_known_responses": self.ignored_known_responses,
|
||||||
|
"late_known_responses": self.late_known_responses,
|
||||||
"device_status_reports": self.device_status_reports,
|
"device_status_reports": self.device_status_reports,
|
||||||
"system_error_reports": self.system_error_reports,
|
"system_error_reports": self.system_error_reports,
|
||||||
"report_decode_errors": self.report_decode_errors,
|
"report_decode_errors": self.report_decode_errors,
|
||||||
|
|
@ -208,10 +279,10 @@ class ApplicationMqttTransportSnapshot:
|
||||||
class ReviewedApplicationMqttTransport:
|
class ReviewedApplicationMqttTransport:
|
||||||
"""Exact-profile MQTT exchange for an operator-present physical acceptance.
|
"""Exact-profile MQTT exchange for an operator-present physical acceptance.
|
||||||
|
|
||||||
This type is deliberately not installed in the facade or plugin runtime.
|
Plugin v0.5.0 installs this type only behind the operator-driven continuous
|
||||||
It performs one connection attempt, never reconnects, consumes every
|
session owner. It performs one connection attempt, never reconnects,
|
||||||
operation key before calling ``publish``, and permanently poisons itself
|
consumes every operation key before calling ``publish``, and permanently
|
||||||
after any unknown post-publish outcome.
|
poisons itself after any unknown post-publish outcome.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
|
|
@ -252,12 +323,19 @@ class ReviewedApplicationMqttTransport:
|
||||||
self._messages: deque[tuple[str, bytes]] = deque()
|
self._messages: deque[tuple[str, bytes]] = deque()
|
||||||
self._callback_error: str | None = None
|
self._callback_error: str | None = None
|
||||||
self._consumed_operation_keys: set[str] = set()
|
self._consumed_operation_keys: set[str] = set()
|
||||||
|
self._known_response_expectations: dict[str, _ApplicationResponseExpectation] = {}
|
||||||
|
self._response_operations_by_correlation: dict[
|
||||||
|
tuple[str, str, ModelingAction | None], list[str]
|
||||||
|
] = {}
|
||||||
|
self._optional_response_operations: set[str] = set()
|
||||||
|
self._observed_response_operations: set[str] = set()
|
||||||
self._connect_attempts = 0
|
self._connect_attempts = 0
|
||||||
self._subscribe_attempts = 0
|
self._subscribe_attempts = 0
|
||||||
self._publish_attempts = 0
|
self._publish_attempts = 0
|
||||||
self._qos2_completions = 0
|
self._qos2_completions = 0
|
||||||
self._correlated_responses = 0
|
self._correlated_responses = 0
|
||||||
self._ignored_known_responses = 0
|
self._ignored_known_responses = 0
|
||||||
|
self._late_known_responses = 0
|
||||||
self._device_status_reports = 0
|
self._device_status_reports = 0
|
||||||
self._system_error_reports = 0
|
self._system_error_reports = 0
|
||||||
self._report_decode_errors = 0
|
self._report_decode_errors = 0
|
||||||
|
|
@ -273,10 +351,14 @@ class ReviewedApplicationMqttTransport:
|
||||||
def open(self) -> ApplicationMqttTransportSnapshot:
|
def open(self) -> ApplicationMqttTransportSnapshot:
|
||||||
with self._lock:
|
with self._lock:
|
||||||
if self._state != "new":
|
if self._state != "new":
|
||||||
raise ApplicationMqttTransportError("control transport can be opened only once")
|
raise ApplicationMqttTransportError(
|
||||||
|
"control transport can be opened only once",
|
||||||
|
reason_code="transport_already_opened",
|
||||||
|
)
|
||||||
self._state = "connecting"
|
self._state = "connecting"
|
||||||
self._connect_attempts = 1
|
self._connect_attempts = 1
|
||||||
client = self._new_client()
|
client = self._new_client()
|
||||||
|
client.connect_timeout = self._connect_timeout_seconds
|
||||||
self._install_callbacks(client)
|
self._install_callbacks(client)
|
||||||
self._client = client
|
self._client = client
|
||||||
try:
|
try:
|
||||||
|
|
@ -300,10 +382,10 @@ class ReviewedApplicationMqttTransport:
|
||||||
self,
|
self,
|
||||||
envelopes: Sequence[OneShotPublishEnvelope],
|
envelopes: Sequence[OneShotPublishEnvelope],
|
||||||
*,
|
*,
|
||||||
required_response_topics: Collection[str],
|
required_response_operation_keys: Collection[str],
|
||||||
) -> dict[str, bytes]:
|
) -> dict[str, bytes]:
|
||||||
batch = tuple(envelopes)
|
batch = tuple(envelopes)
|
||||||
required = frozenset(required_response_topics)
|
required = frozenset(required_response_operation_keys)
|
||||||
if not batch:
|
if not batch:
|
||||||
raise ValueError("control exchange batch must not be empty")
|
raise ValueError("control exchange batch must not be empty")
|
||||||
if not required and any(
|
if not required and any(
|
||||||
|
|
@ -311,37 +393,100 @@ class ReviewedApplicationMqttTransport:
|
||||||
for envelope in batch
|
for envelope in batch
|
||||||
):
|
):
|
||||||
raise ValueError("response-free exchange is limited to retained ModelingStatus reads")
|
raise ValueError("response-free exchange is limited to retained ModelingStatus reads")
|
||||||
if not required <= APPLICATION_RESPONSE_TOPICS:
|
|
||||||
raise ValueError("required response topics exceed the reviewed allowlist")
|
|
||||||
operation_keys = tuple(envelope.operation_key for envelope in batch)
|
operation_keys = tuple(envelope.operation_key for envelope in batch)
|
||||||
if len(set(operation_keys)) != len(operation_keys):
|
if len(set(operation_keys)) != len(operation_keys):
|
||||||
raise ValueError("control exchange batch repeats an operation key")
|
raise ValueError("control exchange batch repeats an operation key")
|
||||||
|
if not required <= frozenset(operation_keys):
|
||||||
|
raise ValueError("required responses exceed the issued operation keys")
|
||||||
if any(envelope.topic not in APPLICATION_REQUEST_ALLOWLIST for envelope in batch):
|
if any(envelope.topic not in APPLICATION_REQUEST_ALLOWLIST for envelope in batch):
|
||||||
raise ValueError("control exchange batch exceeds the reviewed request allowlist")
|
raise ValueError("control exchange batch exceeds the reviewed request allowlist")
|
||||||
allowed_non_barrier_responses = (
|
expectations = tuple(self._expectation_for_envelope(envelope) for envelope in batch)
|
||||||
frozenset({MODELING_STATUS_RESPONSE_TOPIC})
|
pending = {
|
||||||
if any(
|
expectation.correlation_key: expectation
|
||||||
envelope.topic == "lixel/application/request/modeling_status" for envelope in batch
|
for expectation in expectations
|
||||||
|
if expectation.operation_key in required
|
||||||
|
}
|
||||||
|
if len(pending) != len(required):
|
||||||
|
raise ValueError(
|
||||||
|
"required responses do not identify one exact batch operation each"
|
||||||
)
|
)
|
||||||
else frozenset()
|
|
||||||
)
|
# Classify packets already delivered by the manual Paho loop before
|
||||||
|
# consuming this batch. An exact response to an earlier issued
|
||||||
|
# operation is harmless; an unknown session/identity remains fatal.
|
||||||
|
self._drain_responses({}, {})
|
||||||
|
if self._consumed_operation_keys:
|
||||||
|
# Service packets already readable on the retained socket before
|
||||||
|
# admitting another operation. This is a zero-wait network pump,
|
||||||
|
# not a timer or retry; it prevents a queued protocol deviation
|
||||||
|
# from being hidden behind the next explicit operator action.
|
||||||
|
self._service_once(post_publish=True)
|
||||||
|
self._drain_responses({}, {})
|
||||||
|
|
||||||
with self._lock:
|
with self._lock:
|
||||||
if self._consumed_operation_keys.intersection(operation_keys):
|
if self._consumed_operation_keys.intersection(operation_keys):
|
||||||
raise ApplicationCommandOutcomeUnknown(
|
raise ApplicationCommandOutcomeUnknown(
|
||||||
"control operation key was already consumed; retry is forbidden"
|
"control operation key was already consumed; retry is forbidden",
|
||||||
|
reason_code="operation_reuse_forbidden",
|
||||||
)
|
)
|
||||||
if self._state != "ready" or not self._connected or not self._subscribed:
|
if self._state != "ready" or not self._connected or not self._subscribed:
|
||||||
raise ApplicationMqttTransportError("control transport is not ready")
|
raise ApplicationMqttTransportError(
|
||||||
stale_response = bool(self._messages)
|
"control transport is not ready",
|
||||||
if stale_response:
|
reason_code="transport_not_ready",
|
||||||
self._poison_locked("stale response preceded the request batch")
|
)
|
||||||
|
for expectation in expectations:
|
||||||
|
existing_operations = self._response_operations_by_correlation.get(
|
||||||
|
expectation.correlation_key,
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
if any(
|
||||||
|
not self._known_response_expectations[operation_key].matches(
|
||||||
|
expectation.identity,
|
||||||
|
expectation.modeling_action,
|
||||||
|
)
|
||||||
|
for operation_key in existing_operations
|
||||||
|
):
|
||||||
|
raise ApplicationCommandOutcomeUnknown(
|
||||||
|
"control response identity changed for an issued session",
|
||||||
|
reason_code="response_identity_changed",
|
||||||
|
)
|
||||||
|
if expectation.operation_key in required:
|
||||||
|
# A response-free read owns its correlation only until the
|
||||||
|
# next exact same-identity operation is admitted. We have
|
||||||
|
# already drained callbacks and given the retained socket
|
||||||
|
# one zero-wait service turn above, so any response already
|
||||||
|
# available for the optional operation has been observed.
|
||||||
|
# Once the newer required operation is issued, the first
|
||||||
|
# response after that issuance belongs to the newer
|
||||||
|
# operation; the unanswered optional read must not reserve
|
||||||
|
# FIFO ownership and swallow it.
|
||||||
|
superseded_optional = {
|
||||||
|
operation_key
|
||||||
|
for operation_key in existing_operations
|
||||||
|
if operation_key in self._optional_response_operations
|
||||||
|
and operation_key not in self._observed_response_operations
|
||||||
|
}
|
||||||
|
if superseded_optional:
|
||||||
|
existing_operations[:] = [
|
||||||
|
operation_key
|
||||||
|
for operation_key in existing_operations
|
||||||
|
if operation_key not in superseded_optional
|
||||||
|
]
|
||||||
|
self._optional_response_operations.difference_update(
|
||||||
|
superseded_optional
|
||||||
|
)
|
||||||
|
for operation_key in superseded_optional:
|
||||||
|
del self._known_response_expectations[operation_key]
|
||||||
|
self._known_response_expectations[
|
||||||
|
expectation.operation_key
|
||||||
|
] = expectation
|
||||||
|
self._response_operations_by_correlation.setdefault(
|
||||||
|
expectation.correlation_key,
|
||||||
|
[],
|
||||||
|
).append(expectation.operation_key)
|
||||||
|
if expectation.operation_key not in required:
|
||||||
|
self._optional_response_operations.add(expectation.operation_key)
|
||||||
self._consumed_operation_keys.update(operation_keys)
|
self._consumed_operation_keys.update(operation_keys)
|
||||||
if stale_response:
|
|
||||||
self.close()
|
|
||||||
raise ApplicationCommandOutcomeUnknown(
|
|
||||||
"stale control response makes the next command outcome ambiguous"
|
|
||||||
)
|
|
||||||
|
|
||||||
client = self._require_client()
|
client = self._require_client()
|
||||||
publish_mids: set[int] = set()
|
publish_mids: set[int] = set()
|
||||||
|
|
@ -367,12 +512,17 @@ class ReviewedApplicationMqttTransport:
|
||||||
deadline = self._monotonic() + self._exchange_timeout_seconds
|
deadline = self._monotonic() + self._exchange_timeout_seconds
|
||||||
|
|
||||||
def complete() -> bool:
|
def complete() -> bool:
|
||||||
self._drain_responses(required, allowed_non_barrier_responses, responses)
|
self._drain_responses(pending, responses)
|
||||||
with self._lock:
|
with self._lock:
|
||||||
return publish_mids <= self._completed_publish_mids and required <= responses.keys()
|
return publish_mids <= self._completed_publish_mids and required <= responses.keys()
|
||||||
|
|
||||||
self._drive_until(complete, deadline, post_publish=True)
|
self._drive_until(complete, deadline, post_publish=True)
|
||||||
self._drain_responses(required, allowed_non_barrier_responses, responses)
|
# The completion predicate can become true immediately after the first
|
||||||
|
# required response callback. Give an already-readable second packet
|
||||||
|
# one zero-wait service turn so a duplicate application response fails
|
||||||
|
# closed instead of leaking into the next operator checkpoint.
|
||||||
|
self._service_once(post_publish=True)
|
||||||
|
self._drain_responses(pending, responses)
|
||||||
with self._lock:
|
with self._lock:
|
||||||
self._correlated_responses += len(responses)
|
self._correlated_responses += len(responses)
|
||||||
return responses
|
return responses
|
||||||
|
|
@ -422,9 +572,15 @@ class ReviewedApplicationMqttTransport:
|
||||||
client = self._client
|
client = self._client
|
||||||
if client is not None:
|
if client is not None:
|
||||||
try:
|
try:
|
||||||
if self._subscribed:
|
with self._lock:
|
||||||
|
accepted_group_count = self._subscription_group_index
|
||||||
|
if accepted_group_count:
|
||||||
client.unsubscribe(
|
client.unsubscribe(
|
||||||
[topic for group in CONTROL_SUBSCRIPTION_GROUPS for topic, _qos in group]
|
[
|
||||||
|
topic
|
||||||
|
for group in CONTROL_SUBSCRIPTION_GROUPS[:accepted_group_count]
|
||||||
|
for topic, _qos in group
|
||||||
|
]
|
||||||
)
|
)
|
||||||
client.disconnect()
|
client.disconnect()
|
||||||
except (OSError, RuntimeError, ValueError):
|
except (OSError, RuntimeError, ValueError):
|
||||||
|
|
@ -483,6 +639,7 @@ class ReviewedApplicationMqttTransport:
|
||||||
qos2_completions=self._qos2_completions,
|
qos2_completions=self._qos2_completions,
|
||||||
correlated_responses=self._correlated_responses,
|
correlated_responses=self._correlated_responses,
|
||||||
ignored_known_responses=self._ignored_known_responses,
|
ignored_known_responses=self._ignored_known_responses,
|
||||||
|
late_known_responses=self._late_known_responses,
|
||||||
device_status_reports=self._device_status_reports,
|
device_status_reports=self._device_status_reports,
|
||||||
system_error_reports=self._system_error_reports,
|
system_error_reports=self._system_error_reports,
|
||||||
report_decode_errors=self._report_decode_errors,
|
report_decode_errors=self._report_decode_errors,
|
||||||
|
|
@ -725,48 +882,144 @@ class ReviewedApplicationMqttTransport:
|
||||||
self._fail_after_publish("control MQTT network loop returned an error")
|
self._fail_after_publish("control MQTT network loop returned an error")
|
||||||
self._fail_before_publish("control MQTT network loop returned an error")
|
self._fail_before_publish("control MQTT network loop returned an error")
|
||||||
|
|
||||||
|
def _service_once(self, *, post_publish: bool) -> None:
|
||||||
|
with self._lock:
|
||||||
|
callback_error = self._callback_error
|
||||||
|
if callback_error is not None:
|
||||||
|
if post_publish:
|
||||||
|
self._fail_after_publish(callback_error)
|
||||||
|
self._fail_before_publish(callback_error)
|
||||||
|
client = self._require_client()
|
||||||
|
try:
|
||||||
|
result = client.loop(timeout=0.0)
|
||||||
|
except (OSError, RuntimeError, ValueError) as exc:
|
||||||
|
if post_publish:
|
||||||
|
self._fail_after_publish("control MQTT network loop failed", exc)
|
||||||
|
self._fail_before_publish("control MQTT network loop failed", exc)
|
||||||
|
if result != mqtt.MQTT_ERR_SUCCESS:
|
||||||
|
if post_publish:
|
||||||
|
self._fail_after_publish("control MQTT network loop returned an error")
|
||||||
|
self._fail_before_publish("control MQTT network loop returned an error")
|
||||||
|
|
||||||
def _drain_responses(
|
def _drain_responses(
|
||||||
self,
|
self,
|
||||||
required: frozenset[str],
|
pending: dict[
|
||||||
allowed_non_barrier: frozenset[str],
|
tuple[str, str, ModelingAction | None],
|
||||||
|
_ApplicationResponseExpectation,
|
||||||
|
],
|
||||||
responses: dict[str, bytes],
|
responses: dict[str, bytes],
|
||||||
) -> None:
|
) -> None:
|
||||||
ambiguous_message: str | None = None
|
ambiguous_message: str | None = None
|
||||||
|
ambiguous_reason_code: str | None = None
|
||||||
with self._lock:
|
with self._lock:
|
||||||
while self._messages:
|
while self._messages:
|
||||||
topic, payload = self._messages.popleft()
|
topic, payload = self._messages.popleft()
|
||||||
if topic not in required:
|
try:
|
||||||
if topic in allowed_non_barrier:
|
observed = decode_application_message_identity(payload)
|
||||||
self._ignored_known_responses += 1
|
except ApplicationBootstrapError:
|
||||||
continue
|
self._poison_locked("response identity decoding failed")
|
||||||
self._poison_locked("unexpected response made correlation ambiguous")
|
ambiguous_message = (
|
||||||
ambiguous_message = "unexpected control response made command outcome ambiguous"
|
"control response identity could not be decoded safely"
|
||||||
|
)
|
||||||
|
ambiguous_reason_code = "response_identity_decode_failed"
|
||||||
break
|
break
|
||||||
if topic in responses:
|
observed_modeling_action: ModelingAction | None = None
|
||||||
self._poison_locked("duplicate required response made correlation ambiguous")
|
if topic == MODELING_RESPONSE_TOPIC:
|
||||||
ambiguous_message = "duplicate control response made command outcome ambiguous"
|
try:
|
||||||
break
|
observed_modeling_action = decode_modeling_response(
|
||||||
responses[topic] = payload
|
payload
|
||||||
if ambiguous_message is not None:
|
).action
|
||||||
self.close()
|
except ModelingProtocolError:
|
||||||
raise ApplicationCommandOutcomeUnknown(ambiguous_message)
|
self._poison_locked("modeling response action decoding failed")
|
||||||
|
ambiguous_message = (
|
||||||
def _discard_allowed_responses(self, allowed: frozenset[str]) -> None:
|
"control modeling response could not be decoded safely"
|
||||||
ambiguous_message: str | None = None
|
)
|
||||||
with self._lock:
|
ambiguous_reason_code = "modeling_response_decode_failed"
|
||||||
while self._messages:
|
break
|
||||||
topic, _payload = self._messages.popleft()
|
key = (topic, observed.session_id, observed_modeling_action)
|
||||||
if topic in allowed:
|
expectation = pending.get(key)
|
||||||
self._ignored_known_responses += 1
|
known_operations = self._response_operations_by_correlation.get(key, [])
|
||||||
continue
|
matching_operations = [
|
||||||
self._poison_locked("unexpected response during retained control hold")
|
operation_key
|
||||||
ambiguous_message = (
|
for operation_key in known_operations
|
||||||
"unexpected control response during retained control-session hold"
|
if self._known_response_expectations[operation_key].matches(
|
||||||
|
observed,
|
||||||
|
observed_modeling_action,
|
||||||
|
)
|
||||||
|
]
|
||||||
|
next_unobserved = next(
|
||||||
|
(
|
||||||
|
operation_key
|
||||||
|
for operation_key in matching_operations
|
||||||
|
if operation_key not in self._observed_response_operations
|
||||||
|
),
|
||||||
|
None,
|
||||||
)
|
)
|
||||||
|
if next_unobserved is not None:
|
||||||
|
self._observed_response_operations.add(next_unobserved)
|
||||||
|
if (
|
||||||
|
expectation is not None
|
||||||
|
and expectation.operation_key == next_unobserved
|
||||||
|
):
|
||||||
|
responses[next_unobserved] = payload
|
||||||
|
continue
|
||||||
|
self._ignored_known_responses += 1
|
||||||
|
self._late_known_responses += 1
|
||||||
|
continue
|
||||||
|
if matching_operations:
|
||||||
|
self._poison_locked("duplicate application response")
|
||||||
|
ambiguous_message = (
|
||||||
|
"duplicate application response made command outcome ambiguous"
|
||||||
|
)
|
||||||
|
ambiguous_reason_code = "duplicate_application_response"
|
||||||
|
break
|
||||||
|
self._poison_locked("unexpected response identity made correlation ambiguous")
|
||||||
|
ambiguous_message = (
|
||||||
|
"unexpected control response session or identity made command outcome ambiguous"
|
||||||
|
)
|
||||||
|
ambiguous_reason_code = "unexpected_response_identity"
|
||||||
|
if expectation is not None or known_operations:
|
||||||
|
ambiguous_message = (
|
||||||
|
"control response identity mismatch made command outcome ambiguous"
|
||||||
|
)
|
||||||
|
ambiguous_reason_code = "response_identity_mismatch"
|
||||||
break
|
break
|
||||||
if ambiguous_message is not None:
|
if ambiguous_message is not None:
|
||||||
self.close()
|
self.close()
|
||||||
raise ApplicationCommandOutcomeUnknown(ambiguous_message)
|
raise ApplicationCommandOutcomeUnknown(
|
||||||
|
ambiguous_message,
|
||||||
|
reason_code=ambiguous_reason_code or "response_correlation_failed",
|
||||||
|
)
|
||||||
|
|
||||||
|
def _discard_allowed_responses(self, allowed: frozenset[str]) -> None:
|
||||||
|
del allowed
|
||||||
|
self._drain_responses({}, {})
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _expectation_for_envelope(
|
||||||
|
envelope: OneShotPublishEnvelope,
|
||||||
|
) -> _ApplicationResponseExpectation:
|
||||||
|
response_topic = APPLICATION_RESPONSE_TOPIC_BY_REQUEST_TOPIC.get(envelope.topic)
|
||||||
|
if response_topic is None:
|
||||||
|
raise ValueError("control request has no reviewed response topic")
|
||||||
|
try:
|
||||||
|
identity = decode_application_message_identity(envelope.payload)
|
||||||
|
except ApplicationBootstrapError as exc:
|
||||||
|
raise ValueError("control request identity is not decodable") from exc
|
||||||
|
modeling_action: ModelingAction | None = None
|
||||||
|
if response_topic == MODELING_RESPONSE_TOPIC:
|
||||||
|
if envelope.operation_key == "modeling:start":
|
||||||
|
modeling_action = ModelingAction.START
|
||||||
|
elif envelope.operation_key == "modeling:stop":
|
||||||
|
modeling_action = ModelingAction.STOP
|
||||||
|
else:
|
||||||
|
raise ValueError("modeling request operation key has no exact action")
|
||||||
|
return _ApplicationResponseExpectation(
|
||||||
|
operation_key=envelope.operation_key,
|
||||||
|
topic=response_topic,
|
||||||
|
identity=identity,
|
||||||
|
modeling_action=modeling_action,
|
||||||
|
)
|
||||||
|
|
||||||
def _set_callback_error(self, message: str) -> None:
|
def _set_callback_error(self, message: str) -> None:
|
||||||
with self._lock:
|
with self._lock:
|
||||||
|
|
@ -777,7 +1030,10 @@ class ReviewedApplicationMqttTransport:
|
||||||
with self._lock:
|
with self._lock:
|
||||||
self._state = "failed"
|
self._state = "failed"
|
||||||
self.close()
|
self.close()
|
||||||
error = ApplicationMqttTransportError(message)
|
error = ApplicationMqttTransportError(
|
||||||
|
message,
|
||||||
|
reason_code=self._transport_failure_reason_code(message),
|
||||||
|
)
|
||||||
if cause is not None:
|
if cause is not None:
|
||||||
raise error from cause
|
raise error from cause
|
||||||
raise error
|
raise error
|
||||||
|
|
@ -787,7 +1043,8 @@ class ReviewedApplicationMqttTransport:
|
||||||
self._poison_locked(message)
|
self._poison_locked(message)
|
||||||
self.close()
|
self.close()
|
||||||
error = ApplicationCommandOutcomeUnknown(
|
error = ApplicationCommandOutcomeUnknown(
|
||||||
f"{message}; automatic retry is forbidden until physical/status reconciliation"
|
f"{message}; automatic retry is forbidden until physical/status reconciliation",
|
||||||
|
reason_code=self._transport_failure_reason_code(message),
|
||||||
)
|
)
|
||||||
if cause is not None:
|
if cause is not None:
|
||||||
raise error from cause
|
raise error from cause
|
||||||
|
|
@ -799,5 +1056,33 @@ class ReviewedApplicationMqttTransport:
|
||||||
def _require_client(self) -> mqtt.Client:
|
def _require_client(self) -> mqtt.Client:
|
||||||
client = self._client
|
client = self._client
|
||||||
if client is None:
|
if client is None:
|
||||||
raise ApplicationMqttTransportError("control MQTT client is not installed")
|
raise ApplicationMqttTransportError(
|
||||||
|
"control MQTT client is not installed",
|
||||||
|
reason_code="mqtt_client_unavailable",
|
||||||
|
)
|
||||||
return client
|
return client
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _transport_failure_reason_code(message: str) -> str:
|
||||||
|
reason_fragments = (
|
||||||
|
("connect call failed", "mqtt_connect_call_failed"),
|
||||||
|
("connect call was rejected", "mqtt_connect_rejected"),
|
||||||
|
("broker rejected connection", "mqtt_broker_rejected_connection"),
|
||||||
|
("connection/subscription timed out", "mqtt_connection_timeout"),
|
||||||
|
("response subscription", "mqtt_subscription_failed"),
|
||||||
|
("SUBACK", "mqtt_subscription_protocol_error"),
|
||||||
|
("publish call failed", "mqtt_publish_call_failed"),
|
||||||
|
("publish call returned", "mqtt_publish_result_unsafe"),
|
||||||
|
("reused a packet identifier", "mqtt_packet_identifier_reused"),
|
||||||
|
("QoS2 transaction failed", "mqtt_qos2_failed"),
|
||||||
|
("response barrier timed out", "mqtt_response_timeout"),
|
||||||
|
("network loop failed", "mqtt_network_loop_failed"),
|
||||||
|
("network loop returned", "mqtt_network_loop_failed"),
|
||||||
|
("connection ended unexpectedly", "mqtt_connection_ended"),
|
||||||
|
("unreviewed subscribed topic", "mqtt_unreviewed_topic"),
|
||||||
|
("response exceeds", "mqtt_response_too_large"),
|
||||||
|
)
|
||||||
|
return next(
|
||||||
|
(code for fragment, code in reason_fragments if fragment in message),
|
||||||
|
"mqtt_transport_failure",
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
|
|
@ -57,6 +58,8 @@ ApplicationControlPhase = Literal[
|
||||||
"failed",
|
"failed",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
TransportFactory = Callable[[str], ReviewedApplicationMqttTransport]
|
TransportFactory = Callable[[str], ReviewedApplicationMqttTransport]
|
||||||
|
|
||||||
|
|
@ -108,7 +111,6 @@ class InteractiveApplicationControlSession:
|
||||||
self._project_requested = threading.Event()
|
self._project_requested = threading.Event()
|
||||||
self._start_requested = threading.Event()
|
self._start_requested = threading.Event()
|
||||||
self._stop_requested = threading.Event()
|
self._stop_requested = threading.Event()
|
||||||
self._standby_confirmed = threading.Event()
|
|
||||||
self._cancel_requested = False
|
self._cancel_requested = False
|
||||||
self._start_confirmation: OperatorPresenceConfirmation | None = None
|
self._start_confirmation: OperatorPresenceConfirmation | None = None
|
||||||
self._stop_confirmation: OperatorPresenceConfirmation | None = None
|
self._stop_confirmation: OperatorPresenceConfirmation | None = None
|
||||||
|
|
@ -117,6 +119,7 @@ class InteractiveApplicationControlSession:
|
||||||
self._dialogue_snapshot: dict[str, object] | None = None
|
self._dialogue_snapshot: dict[str, object] | None = None
|
||||||
self._transport_snapshot: dict[str, object] | None = None
|
self._transport_snapshot: dict[str, object] | None = None
|
||||||
self._outcome_unknown = False
|
self._outcome_unknown = False
|
||||||
|
self._run_generation = 0
|
||||||
|
|
||||||
def open(
|
def open(
|
||||||
self,
|
self,
|
||||||
|
|
@ -128,21 +131,28 @@ class InteractiveApplicationControlSession:
|
||||||
# Validate every explicit confirmation before creating a network owner.
|
# Validate every explicit confirmation before creating a network owner.
|
||||||
confirmation.checklist(ModelingAction.START)
|
confirmation.checklist(ModelingAction.START)
|
||||||
with self._lock:
|
with self._lock:
|
||||||
if self._phase in {"completed", "closed"} or (
|
if self._phase != "idle" and self._can_open_locked():
|
||||||
self._phase == "failed"
|
|
||||||
and self._failure is not None
|
|
||||||
and self._failure.get("safe_to_retry") is True
|
|
||||||
):
|
|
||||||
self._reset_locked()
|
self._reset_locked()
|
||||||
if self._phase != "idle":
|
if self._phase != "idle":
|
||||||
raise ApplicationAcceptanceError(
|
raise ApplicationAcceptanceError(
|
||||||
"application control session is already open or requires manual recovery"
|
"application control session is already open or requires manual recovery"
|
||||||
)
|
)
|
||||||
|
# A previous worker may already have published a terminal phase but
|
||||||
|
# still own its local MQTT socket while its ``finally`` block runs.
|
||||||
|
# Never create the next network owner until that thread has exited
|
||||||
|
# and cleared the closed transport.
|
||||||
|
if not self._worker_retired_locked():
|
||||||
|
raise ApplicationAcceptanceError(
|
||||||
|
"previous application control worker is still retiring"
|
||||||
|
)
|
||||||
self._host = host
|
self._host = host
|
||||||
self._timezone_name = timezone_name
|
self._timezone_name = timezone_name
|
||||||
self._set_phase_locked("connecting")
|
self._set_phase_locked("connecting")
|
||||||
|
self._run_generation += 1
|
||||||
|
generation = self._run_generation
|
||||||
self._thread = threading.Thread(
|
self._thread = threading.Thread(
|
||||||
target=self._run,
|
target=self._run,
|
||||||
|
args=(generation,),
|
||||||
name="xgrids-k1-canonical-control",
|
name="xgrids-k1-canonical-control",
|
||||||
daemon=True,
|
daemon=True,
|
||||||
)
|
)
|
||||||
|
|
@ -189,16 +199,6 @@ class InteractiveApplicationControlSession:
|
||||||
self._stop_requested.set()
|
self._stop_requested.set()
|
||||||
return self.snapshot()
|
return self.snapshot()
|
||||||
|
|
||||||
def confirm_standby(self) -> dict[str, object]:
|
|
||||||
with self._lock:
|
|
||||||
self._require_phase_locked("awaiting-standby-confirmation")
|
|
||||||
if not self._device_ready_for_standby_confirmation_locked():
|
|
||||||
raise ApplicationAcceptanceError(
|
|
||||||
"K1 has not reported unbound READY after canonical STOP"
|
|
||||||
)
|
|
||||||
self._standby_confirmed.set()
|
|
||||||
return self.snapshot()
|
|
||||||
|
|
||||||
def close_prestart(self) -> dict[str, object]:
|
def close_prestart(self) -> dict[str, object]:
|
||||||
with self._lock:
|
with self._lock:
|
||||||
if self._phase not in {
|
if self._phase not in {
|
||||||
|
|
@ -234,33 +234,27 @@ class InteractiveApplicationControlSession:
|
||||||
return {
|
return {
|
||||||
"mode": "interactive-canonical",
|
"mode": "interactive-canonical",
|
||||||
"state": phase,
|
"state": phase,
|
||||||
"control_socket_open": phase
|
"control_socket_open": phase not in {"idle", "completed", "closed", "failed"},
|
||||||
not in {"idle", "completed", "closed", "failed"},
|
"can_open": self._can_open_locked(),
|
||||||
"can_open": phase in {"idle", "completed", "closed"}
|
|
||||||
or (
|
|
||||||
phase == "failed"
|
|
||||||
and self._failure is not None
|
|
||||||
and self._failure.get("safe_to_retry") is True
|
|
||||||
),
|
|
||||||
"can_enter_workspace": phase == "connection-ready",
|
"can_enter_workspace": phase == "connection-ready",
|
||||||
"can_prepare_project": phase == "workspace-ready",
|
"can_prepare_project": phase == "workspace-ready",
|
||||||
"can_start": phase == "project-ready",
|
"can_start": phase == "project-ready",
|
||||||
"can_stop": phase == "scanning",
|
"can_stop": phase == "scanning",
|
||||||
"can_confirm_standby": self._device_ready_for_standby_confirmation_locked(),
|
# Retained for wire compatibility with the v1alpha2 snapshot.
|
||||||
|
# Standby is now concluded solely from live K1 protocol state.
|
||||||
|
"can_confirm_standby": False,
|
||||||
"pending_operator_action": self._pending_operator_action_locked(),
|
"pending_operator_action": self._pending_operator_action_locked(),
|
||||||
"scripted_transitions": False,
|
"scripted_transitions": False,
|
||||||
"automatic_retry": False,
|
"automatic_retry": False,
|
||||||
"outcome_unknown": self._outcome_unknown,
|
"outcome_unknown": self._outcome_unknown,
|
||||||
"failure": dict(self._failure) if self._failure is not None else None,
|
"failure": dict(self._failure) if self._failure is not None else None,
|
||||||
"dialogue": (
|
"dialogue": (
|
||||||
dict(self._dialogue_snapshot)
|
dict(self._dialogue_snapshot) if self._dialogue_snapshot is not None else None
|
||||||
if self._dialogue_snapshot is not None
|
|
||||||
else None
|
|
||||||
),
|
),
|
||||||
"transport": transport_snapshot,
|
"transport": transport_snapshot,
|
||||||
}
|
}
|
||||||
|
|
||||||
def _run(self) -> None:
|
def _run(self, generation: int) -> None:
|
||||||
executor: PhysicalAcceptanceDialogueExecutor | None = None
|
executor: PhysicalAcceptanceDialogueExecutor | None = None
|
||||||
transport: ReviewedApplicationMqttTransport | None = None
|
transport: ReviewedApplicationMqttTransport | None = None
|
||||||
try:
|
try:
|
||||||
|
|
@ -319,64 +313,195 @@ class InteractiveApplicationControlSession:
|
||||||
|
|
||||||
executor.maintain_active_until_stop_requested(self._stop_requested.is_set)
|
executor.maintain_active_until_stop_requested(self._stop_requested.is_set)
|
||||||
stop_confirmation = self._stop_request()
|
stop_confirmation = self._stop_request()
|
||||||
stop_permit = PhysicalAcceptancePermit(
|
stop_permit = PhysicalAcceptancePermit(stop_confirmation.checklist(ModelingAction.STOP))
|
||||||
stop_confirmation.checklist(ModelingAction.STOP)
|
|
||||||
)
|
|
||||||
self._set_phase("stopping")
|
self._set_phase("stopping")
|
||||||
executor.execute_canonical_stop(
|
executor.execute_canonical_stop(
|
||||||
self._stop_command(authority, binding),
|
self._stop_command(authority, binding),
|
||||||
stop_permit,
|
stop_permit,
|
||||||
)
|
)
|
||||||
self._set_phase("awaiting-standby-confirmation")
|
self._set_phase("awaiting-standby-confirmation")
|
||||||
executor.maintain_post_stop_until_standby_confirmed(
|
executor.maintain_post_stop_until_standby()
|
||||||
self._standby_confirmed.is_set
|
|
||||||
)
|
|
||||||
self._set_phase("completed")
|
self._set_phase("completed")
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
|
dialogue_snapshot, dialogue_snapshot_available = self._executor_snapshot_safely(
|
||||||
|
executor
|
||||||
|
)
|
||||||
|
transport_snapshot, transport_snapshot_available = self._transport_snapshot_safely(
|
||||||
|
transport
|
||||||
|
)
|
||||||
with self._lock:
|
with self._lock:
|
||||||
if self._cancel_requested:
|
if self._cancel_requested:
|
||||||
self._set_phase_locked("closed")
|
self._set_phase_locked("closed")
|
||||||
else:
|
else:
|
||||||
publish_attempts = 0
|
failed_phase = self._phase
|
||||||
if transport is not None:
|
transport_created = transport is not None
|
||||||
observed_attempts = transport.snapshot().as_dict().get(
|
dialogue_executor_created = executor is not None
|
||||||
"publish_attempts",
|
publish_attempts = (
|
||||||
0,
|
self._json_int_or_none(transport_snapshot.get("publish_attempts"))
|
||||||
|
if transport_snapshot_available
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
dialogue_stage = self._json_string(dialogue_snapshot.get("dialogue_stage"))
|
||||||
|
correlation_failure = dialogue_snapshot.get("correlation_failure")
|
||||||
|
compatibility_failure = dialogue_snapshot.get("compatibility_failure")
|
||||||
|
failure_reason_code = self._failure_reason_code(
|
||||||
|
exc,
|
||||||
|
correlation_failure=correlation_failure,
|
||||||
|
)
|
||||||
|
modeling_command_attempted: bool | None = (
|
||||||
|
(
|
||||||
|
dialogue_snapshot.get("start_attempted") is True
|
||||||
|
or dialogue_snapshot.get("stop_attempted") is True
|
||||||
)
|
)
|
||||||
if isinstance(observed_attempts, int) and not isinstance(
|
if dialogue_snapshot_available
|
||||||
observed_attempts,
|
else (False if not dialogue_executor_created else None)
|
||||||
bool,
|
)
|
||||||
):
|
diagnostic_snapshot_unavailable = [
|
||||||
publish_attempts = observed_attempts
|
component
|
||||||
outcome_unknown = isinstance(
|
for component, created, available in (
|
||||||
exc, ApplicationCommandOutcomeUnknown
|
(
|
||||||
) or self._phase in {
|
"transport",
|
||||||
"start-requested",
|
transport_created,
|
||||||
"initializing",
|
transport_snapshot_available,
|
||||||
"scanning",
|
),
|
||||||
"stop-requested",
|
(
|
||||||
"stopping",
|
"dialogue",
|
||||||
"awaiting-standby-confirmation",
|
dialogue_executor_created,
|
||||||
}
|
dialogue_snapshot_available,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if created and not available
|
||||||
|
]
|
||||||
|
diagnostic_evidence_unavailable = [
|
||||||
|
evidence
|
||||||
|
for evidence, unavailable in (
|
||||||
|
(
|
||||||
|
"transport.publish_attempts",
|
||||||
|
transport_created and publish_attempts is None,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"dialogue.modeling_command_attempted",
|
||||||
|
dialogue_executor_created and modeling_command_attempted is None,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if unavailable
|
||||||
|
]
|
||||||
|
outcome_unknown = (
|
||||||
|
isinstance(exc, ApplicationCommandOutcomeUnknown)
|
||||||
|
or modeling_command_attempted is True
|
||||||
|
or bool(diagnostic_evidence_unavailable)
|
||||||
|
or failed_phase
|
||||||
|
in {
|
||||||
|
"scanning",
|
||||||
|
"stop-requested",
|
||||||
|
"stopping",
|
||||||
|
"awaiting-standby-confirmation",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
# A correlated ordinal-1 DeviceInfo profile mismatch is a
|
||||||
|
# completed read-only exchange. It cannot have started or
|
||||||
|
# stopped modeling, so a later explicit operator click may
|
||||||
|
# open a fresh dialogue after the software/profile issue is
|
||||||
|
# corrected. No other post-publish failure is promoted.
|
||||||
|
correlated_read_only_profile_mismatch = (
|
||||||
|
failure_reason_code == "compatibility_profile_mismatch"
|
||||||
|
and modeling_command_attempted is False
|
||||||
|
and publish_attempts == 1
|
||||||
|
and self._json_int_or_none(
|
||||||
|
transport_snapshot.get("correlated_responses")
|
||||||
|
)
|
||||||
|
== 1
|
||||||
|
)
|
||||||
|
safe_to_retry = not outcome_unknown and (
|
||||||
|
not transport_created
|
||||||
|
or (transport_snapshot_available and publish_attempts == 0)
|
||||||
|
or correlated_read_only_profile_mismatch
|
||||||
|
)
|
||||||
self._failure = {
|
self._failure = {
|
||||||
"code": type(exc).__name__,
|
"code": type(exc).__name__,
|
||||||
|
"reason_code": failure_reason_code,
|
||||||
"message": str(exc),
|
"message": str(exc),
|
||||||
"safe_to_retry": publish_attempts == 0 and not outcome_unknown,
|
"failed_phase": failed_phase,
|
||||||
|
"dialogue_stage": dialogue_stage,
|
||||||
|
"transport_state": self._json_string(transport_snapshot.get("state")),
|
||||||
|
"publish_attempts": publish_attempts,
|
||||||
|
"qos2_completions": self._json_int_or_none(
|
||||||
|
transport_snapshot.get("qos2_completions")
|
||||||
|
),
|
||||||
|
"correlated_responses": self._json_int_or_none(
|
||||||
|
transport_snapshot.get("correlated_responses")
|
||||||
|
),
|
||||||
|
"ignored_known_responses": self._json_int_or_none(
|
||||||
|
transport_snapshot.get("ignored_known_responses")
|
||||||
|
),
|
||||||
|
"late_known_responses": self._json_int_or_none(
|
||||||
|
transport_snapshot.get("late_known_responses")
|
||||||
|
),
|
||||||
|
"modeling_command_attempted": modeling_command_attempted,
|
||||||
|
"diagnostic_snapshot_unavailable": (diagnostic_snapshot_unavailable),
|
||||||
|
"diagnostic_evidence_unavailable": (diagnostic_evidence_unavailable),
|
||||||
|
"correlation_failure": (
|
||||||
|
dict(correlation_failure)
|
||||||
|
if isinstance(correlation_failure, dict)
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
"compatibility_failure": (
|
||||||
|
dict(compatibility_failure)
|
||||||
|
if isinstance(compatibility_failure, dict)
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
"safe_to_retry": safe_to_retry,
|
||||||
}
|
}
|
||||||
self._outcome_unknown = outcome_unknown
|
self._outcome_unknown = outcome_unknown
|
||||||
|
self._dialogue_snapshot = dialogue_snapshot or None
|
||||||
|
self._transport_snapshot = transport_snapshot or None
|
||||||
self._set_phase_locked("failed")
|
self._set_phase_locked("failed")
|
||||||
|
logger.error(
|
||||||
|
"K1 application control session failed: code=%s reason_code=%s "
|
||||||
|
"phase=%s dialogue_stage=%s transport_state=%s "
|
||||||
|
"publish_attempts=%s modeling_command_attempted=%s "
|
||||||
|
"diagnostic_snapshot_unavailable=%s "
|
||||||
|
"diagnostic_evidence_unavailable=%s outcome_unknown=%s "
|
||||||
|
"safe_to_retry=%s",
|
||||||
|
type(exc).__name__,
|
||||||
|
failure_reason_code,
|
||||||
|
failed_phase,
|
||||||
|
dialogue_stage,
|
||||||
|
self._json_string(transport_snapshot.get("state")),
|
||||||
|
publish_attempts,
|
||||||
|
modeling_command_attempted,
|
||||||
|
diagnostic_snapshot_unavailable,
|
||||||
|
diagnostic_evidence_unavailable,
|
||||||
|
outcome_unknown,
|
||||||
|
safe_to_retry,
|
||||||
|
)
|
||||||
finally:
|
finally:
|
||||||
|
final_dialogue_snapshot: dict[str, object] | None = None
|
||||||
|
final_transport_snapshot: dict[str, object] | None = None
|
||||||
if executor is not None:
|
if executor is not None:
|
||||||
with self._lock:
|
captured_dialogue, dialogue_available = self._executor_snapshot_safely(executor)
|
||||||
self._dialogue_snapshot = executor.snapshot()
|
if dialogue_available:
|
||||||
|
final_dialogue_snapshot = captured_dialogue
|
||||||
if transport is not None:
|
if transport is not None:
|
||||||
try:
|
try:
|
||||||
with self._lock:
|
captured_transport, transport_available = self._transport_snapshot_safely(
|
||||||
self._transport_snapshot = transport.snapshot().as_dict()
|
transport
|
||||||
|
)
|
||||||
|
if transport_available:
|
||||||
|
final_transport_snapshot = captured_transport
|
||||||
finally:
|
finally:
|
||||||
transport.close()
|
transport.close()
|
||||||
with self._lock:
|
with self._lock:
|
||||||
self._transport = None
|
# The generation guard remains defense in depth for shutdown
|
||||||
|
# races, although open() also requires the prior worker to be
|
||||||
|
# fully retired before a new generation can be created.
|
||||||
|
if generation == self._run_generation:
|
||||||
|
if final_dialogue_snapshot is not None:
|
||||||
|
self._dialogue_snapshot = final_dialogue_snapshot
|
||||||
|
if final_transport_snapshot is not None:
|
||||||
|
self._transport_snapshot = final_transport_snapshot
|
||||||
|
if self._transport is transport:
|
||||||
|
self._transport = None
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _start_command(
|
def _start_command(
|
||||||
|
|
@ -427,6 +552,62 @@ class InteractiveApplicationControlSession:
|
||||||
with self._lock:
|
with self._lock:
|
||||||
self._set_phase_locked(phase)
|
self._set_phase_locked(phase)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _executor_snapshot_safely(
|
||||||
|
executor: PhysicalAcceptanceDialogueExecutor | None,
|
||||||
|
) -> tuple[dict[str, object], bool]:
|
||||||
|
if executor is None:
|
||||||
|
return {}, False
|
||||||
|
try:
|
||||||
|
return dict(executor.snapshot()), True
|
||||||
|
except Exception:
|
||||||
|
logger.error("K1 application dialogue snapshot failed")
|
||||||
|
return {}, False
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _transport_snapshot_safely(
|
||||||
|
transport: ReviewedApplicationMqttTransport | None,
|
||||||
|
) -> tuple[dict[str, object], bool]:
|
||||||
|
if transport is None:
|
||||||
|
return {}, False
|
||||||
|
try:
|
||||||
|
return dict(transport.snapshot().as_dict()), True
|
||||||
|
except Exception:
|
||||||
|
logger.error("K1 application transport snapshot failed")
|
||||||
|
return {}, False
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _json_int_or_none(value: object) -> int | None:
|
||||||
|
return value if isinstance(value, int) and not isinstance(value, bool) else None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _json_string(value: object) -> str | None:
|
||||||
|
return value if isinstance(value, str) else None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _failure_reason_code(
|
||||||
|
exc: Exception,
|
||||||
|
*,
|
||||||
|
correlation_failure: object,
|
||||||
|
) -> str:
|
||||||
|
explicit = getattr(exc, "reason_code", None)
|
||||||
|
if isinstance(explicit, str) and explicit:
|
||||||
|
return explicit
|
||||||
|
if isinstance(correlation_failure, dict):
|
||||||
|
correlated = correlation_failure.get("reason_code")
|
||||||
|
if isinstance(correlated, str) and correlated:
|
||||||
|
return correlated
|
||||||
|
exception_name = type(exc).__name__
|
||||||
|
if "Authority" in exception_name or "Keychain" in exception_name:
|
||||||
|
return "application_authority_unavailable"
|
||||||
|
if isinstance(exc, TimeoutError):
|
||||||
|
return "control_checkpoint_timeout"
|
||||||
|
if isinstance(exc, ApplicationAcceptanceError):
|
||||||
|
return "application_acceptance_failed"
|
||||||
|
if isinstance(exc, RuntimeError):
|
||||||
|
return "unexpected_runtime_error"
|
||||||
|
return "internal_control_error"
|
||||||
|
|
||||||
def _set_phase_locked(self, phase: ApplicationControlPhase) -> None:
|
def _set_phase_locked(self, phase: ApplicationControlPhase) -> None:
|
||||||
self._phase = phase
|
self._phase = phase
|
||||||
|
|
||||||
|
|
@ -436,18 +617,17 @@ class InteractiveApplicationControlSession:
|
||||||
f"control action requires {expected}; current state is {self._phase}"
|
f"control action requires {expected}; current state is {self._phase}"
|
||||||
)
|
)
|
||||||
|
|
||||||
def _device_ready_for_standby_confirmation_locked(self) -> bool:
|
|
||||||
if self._phase != "awaiting-standby-confirmation":
|
|
||||||
return False
|
|
||||||
snapshot = self._live_transport_snapshot_locked()
|
|
||||||
return (
|
|
||||||
snapshot.get("latest_device_session_state") == "ready"
|
|
||||||
and snapshot.get("latest_device_project_bound") is False
|
|
||||||
)
|
|
||||||
|
|
||||||
def _live_transport_snapshot_locked(self) -> dict[str, object]:
|
def _live_transport_snapshot_locked(self) -> dict[str, object]:
|
||||||
if self._transport is not None:
|
if self._transport is not None:
|
||||||
return self._transport.snapshot().as_dict()
|
snapshot, available = self._transport_snapshot_safely(self._transport)
|
||||||
|
if available:
|
||||||
|
return snapshot
|
||||||
|
return {
|
||||||
|
"state": "snapshot-unavailable",
|
||||||
|
"diagnostic_snapshot_available": False,
|
||||||
|
"automatic_retry": False,
|
||||||
|
"automatic_reconnect": False,
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
dict(self._transport_snapshot)
|
dict(self._transport_snapshot)
|
||||||
if self._transport_snapshot is not None
|
if self._transport_snapshot is not None
|
||||||
|
|
@ -464,9 +644,20 @@ class InteractiveApplicationControlSession:
|
||||||
"workspace-ready": "prepare-project",
|
"workspace-ready": "prepare-project",
|
||||||
"project-ready": "start",
|
"project-ready": "start",
|
||||||
"scanning": "stop",
|
"scanning": "stop",
|
||||||
"awaiting-standby-confirmation": "confirm-steady-green",
|
|
||||||
}.get(self._phase)
|
}.get(self._phase)
|
||||||
|
|
||||||
|
def _can_open_locked(self) -> bool:
|
||||||
|
reopenable_phase = self._phase in {"idle", "completed", "closed"} or (
|
||||||
|
self._phase == "failed"
|
||||||
|
and self._failure is not None
|
||||||
|
and self._failure.get("safe_to_retry") is True
|
||||||
|
)
|
||||||
|
return reopenable_phase and self._worker_retired_locked()
|
||||||
|
|
||||||
|
def _worker_retired_locked(self) -> bool:
|
||||||
|
thread = self._thread
|
||||||
|
return (thread is None or not thread.is_alive()) and self._transport is None
|
||||||
|
|
||||||
def _reset_locked(self) -> None:
|
def _reset_locked(self) -> None:
|
||||||
self._phase = "idle"
|
self._phase = "idle"
|
||||||
self._host = None
|
self._host = None
|
||||||
|
|
@ -477,7 +668,6 @@ class InteractiveApplicationControlSession:
|
||||||
self._project_requested = threading.Event()
|
self._project_requested = threading.Event()
|
||||||
self._start_requested = threading.Event()
|
self._start_requested = threading.Event()
|
||||||
self._stop_requested = threading.Event()
|
self._stop_requested = threading.Event()
|
||||||
self._standby_confirmed = threading.Event()
|
|
||||||
self._cancel_requested = False
|
self._cancel_requested = False
|
||||||
self._start_confirmation = None
|
self._start_confirmation = None
|
||||||
self._stop_confirmation = None
|
self._stop_confirmation = None
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ from dataclasses import dataclass, field
|
||||||
from typing import Literal
|
from typing import Literal
|
||||||
|
|
||||||
from k1link.device_plugins.xgrids_k1.protocol.application_bootstrap import (
|
from k1link.device_plugins.xgrids_k1.protocol.application_bootstrap import (
|
||||||
|
REVIEWED_PROFILE_ATTESTATION_FAILURE,
|
||||||
ApplicationControlAuthority,
|
ApplicationControlAuthority,
|
||||||
LiveDeviceControlBinding,
|
LiveDeviceControlBinding,
|
||||||
)
|
)
|
||||||
|
|
@ -217,9 +218,7 @@ class LiveModelingControlSafety:
|
||||||
"DeviceInfo binding does not match the live K1 status stream"
|
"DeviceInfo binding does not match the live K1 status stream"
|
||||||
)
|
)
|
||||||
if not binding.ready_for_reviewed_profile:
|
if not binding.ready_for_reviewed_profile:
|
||||||
raise ModelingControlSafetyError(
|
raise ModelingControlSafetyError(REVIEWED_PROFILE_ATTESTATION_FAILURE)
|
||||||
"live DeviceInfo does not attest the reviewed activated FW 3.0.2 profile"
|
|
||||||
)
|
|
||||||
if report.session_state is not required_state:
|
if report.session_state is not required_state:
|
||||||
raise ModelingControlSafetyError(
|
raise ModelingControlSafetyError(
|
||||||
f"live K1 must be {required_state.name.casefold()} for this shadow plan"
|
f"live K1 must be {required_state.name.casefold()} for this shadow plan"
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,13 @@ def decode_zigzag64(value: int) -> int:
|
||||||
return (value >> 1) ^ -(value & 1)
|
return (value >> 1) ^ -(value & 1)
|
||||||
|
|
||||||
|
|
||||||
|
def encode_zigzag64(value: int) -> int:
|
||||||
|
"""Encode a signed 64-bit integer as protobuf sint64 ZigZag."""
|
||||||
|
if value < -(1 << 63) or value > (1 << 63) - 1:
|
||||||
|
raise ProtobufWireError("ZigZag input is outside int64")
|
||||||
|
return ((value << 1) ^ (value >> 63)) & 0xFFFFFFFFFFFFFFFF
|
||||||
|
|
||||||
|
|
||||||
def iter_fields(data: bytes, *, max_fields: int = 1_000_000) -> Iterator[ProtoField]:
|
def iter_fields(data: bytes, *, max_fields: int = 1_000_000) -> Iterator[ProtoField]:
|
||||||
"""Iterate supported protobuf fields without recursion or unbounded allocation."""
|
"""Iterate supported protobuf fields without recursion or unbounded allocation."""
|
||||||
if max_fields < 1:
|
if max_fields < 1:
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
from collections.abc import Callable, Mapping
|
from collections.abc import Callable, Mapping
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
|
|
@ -16,12 +17,14 @@ from missioncore_plugin_sdk.v0alpha2 import (
|
||||||
RuntimeHealthSnapshot,
|
RuntimeHealthSnapshot,
|
||||||
RuntimePluginDescriptor,
|
RuntimePluginDescriptor,
|
||||||
)
|
)
|
||||||
from pydantic import BaseModel, ConfigDict, Field
|
from pydantic import BaseModel, ConfigDict, Field, ValidationError
|
||||||
|
|
||||||
from k1link.sessions.plugin_contract import ObservationRuntimeContribution
|
from k1link.sessions.plugin_contract import ObservationRuntimeContribution
|
||||||
|
|
||||||
STATE_READ_ACTION_ID = "state.read"
|
STATE_READ_ACTION_ID = "state.read"
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class DevicePluginActionRequest(BaseModel):
|
class DevicePluginActionRequest(BaseModel):
|
||||||
model_config = ConfigDict(extra="forbid")
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
@ -162,13 +165,28 @@ class InProcessDevicePluginRuntime:
|
||||||
f"{invocation.action_id}"
|
f"{invocation.action_id}"
|
||||||
)
|
)
|
||||||
output = await self._adapter.invoke(invocation)
|
output = await self._adapter.invoke(invocation)
|
||||||
return RuntimeActionResult(
|
try:
|
||||||
invocation_id=invocation.invocation_id,
|
return RuntimeActionResult(
|
||||||
plugin_id=invocation.plugin_id,
|
invocation_id=invocation.invocation_id,
|
||||||
action_id=invocation.action_id,
|
plugin_id=invocation.plugin_id,
|
||||||
completed_at=datetime.now(UTC),
|
action_id=invocation.action_id,
|
||||||
output=dict(output),
|
completed_at=datetime.now(UTC),
|
||||||
)
|
output=dict(output),
|
||||||
|
)
|
||||||
|
except ValidationError as exc:
|
||||||
|
# Never allow a malformed plugin result to masquerade as invalid
|
||||||
|
# operator input (HTTP 422). Do not log the output or validation
|
||||||
|
# input: device snapshots may contain retained evidence.
|
||||||
|
logger.error(
|
||||||
|
"device plugin returned a non-JSON action result: "
|
||||||
|
"plugin_id=%s action_id=%s validation_errors=%d",
|
||||||
|
invocation.plugin_id,
|
||||||
|
invocation.action_id,
|
||||||
|
exc.error_count(),
|
||||||
|
)
|
||||||
|
raise PluginExecutionError(
|
||||||
|
"Device plugin returned an invalid non-JSON action result"
|
||||||
|
) from exc
|
||||||
|
|
||||||
def close(self) -> None:
|
def close(self) -> None:
|
||||||
with self._lock:
|
with self._lock:
|
||||||
|
|
|
||||||
|
|
@ -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."
|
||||||
|
}
|
||||||
|
|
@ -4,6 +4,7 @@ from typing import Any
|
||||||
|
|
||||||
from typer.testing import CliRunner
|
from typer.testing import CliRunner
|
||||||
|
|
||||||
|
from k1link.device_plugins.xgrids_k1 import cli
|
||||||
from k1link.device_plugins.xgrids_k1.cli import app
|
from k1link.device_plugins.xgrids_k1.cli import app
|
||||||
|
|
||||||
runner = CliRunner()
|
runner = CliRunner()
|
||||||
|
|
@ -25,6 +26,26 @@ def test_doctor_json() -> None:
|
||||||
assert any(item["name"] == "tcpdump" for item in payload["tools"])
|
assert any(item["name"] == "tcpdump" for item in payload["tools"])
|
||||||
|
|
||||||
|
|
||||||
|
def test_serve_resolves_frontend_from_repository_root(monkeypatch: Any) -> None:
|
||||||
|
captured: dict[str, object] = {}
|
||||||
|
|
||||||
|
def fake_run(application: str, **kwargs: object) -> None:
|
||||||
|
captured.update({"application": application, **kwargs})
|
||||||
|
|
||||||
|
monkeypatch.setattr(cli.uvicorn, "run", fake_run)
|
||||||
|
|
||||||
|
result = runner.invoke(app, ["serve"])
|
||||||
|
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert captured == {
|
||||||
|
"application": "k1link.web.app:app",
|
||||||
|
"host": "127.0.0.1",
|
||||||
|
"port": 8000,
|
||||||
|
"log_level": "info",
|
||||||
|
"access_log": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def test_authority_provision_requires_explicit_reviewed_value_confirmation() -> None:
|
def test_authority_provision_requires_explicit_reviewed_value_confirmation() -> None:
|
||||||
result = runner.invoke(app, ["authority", "provision"])
|
result = runner.invoke(app, ["authority", "provision"])
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -361,6 +361,37 @@ def test_dispatcher_routes_allowlisted_action_to_xgrids_facade() -> None:
|
||||||
assert service.calls == [("scan", 6.0)]
|
assert service.calls == [("scan", 6.0)]
|
||||||
|
|
||||||
|
|
||||||
|
def test_runtime_classifies_non_json_plugin_output_as_execution_failure(
|
||||||
|
caplog: pytest.LogCaptureFixture,
|
||||||
|
) -> None:
|
||||||
|
retained_marker = "must-not-appear-in-the-server-log"
|
||||||
|
|
||||||
|
class NonJsonAdapter:
|
||||||
|
plugin_id = "example.non-json"
|
||||||
|
action_ids = frozenset({"state.read"})
|
||||||
|
|
||||||
|
async def invoke(
|
||||||
|
self,
|
||||||
|
_invocation: RuntimeActionInvocation,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
return {"dialogue": {"response_evidence": (retained_marker,)}}
|
||||||
|
|
||||||
|
dispatcher = DevicePluginDispatcher(
|
||||||
|
[
|
||||||
|
_in_process_runtime(
|
||||||
|
NonJsonAdapter(),
|
||||||
|
plugin_version="0.1.0",
|
||||||
|
host_api_version="missioncore.nodedc/v1alpha2",
|
||||||
|
)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(PluginExecutionError, match="non-JSON"):
|
||||||
|
asyncio.run(dispatcher.invoke("example.non-json", "state.read", {}))
|
||||||
|
|
||||||
|
assert retained_marker not in caplog.text
|
||||||
|
|
||||||
|
|
||||||
def test_dispatcher_rejects_unknown_plugin_and_action() -> None:
|
def test_dispatcher_rejects_unknown_plugin_and_action() -> None:
|
||||||
dispatcher = DevicePluginDispatcher(
|
dispatcher = DevicePluginDispatcher(
|
||||||
[_in_process_runtime(XgridsK1PluginFacade(FakeXgridsService()))]
|
[_in_process_runtime(XgridsK1PluginFacade(FakeXgridsService()))]
|
||||||
|
|
@ -404,7 +435,7 @@ def test_facade_validates_payload_before_calling_service() -> None:
|
||||||
"compatibility_attestation": {
|
"compatibility_attestation": {
|
||||||
"firmware_version": "3.0.2",
|
"firmware_version": "3.0.2",
|
||||||
"topology": "direct-lan",
|
"topology": "direct-lan",
|
||||||
"operator_confirmed": True,
|
"verification": "live-device-info",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"live",
|
"live",
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ from k1link.device_plugins.xgrids_k1.protocol.modeling import (
|
||||||
from k1link.device_plugins.xgrids_k1.protocol.protobuf_wire import (
|
from k1link.device_plugins.xgrids_k1.protocol.protobuf_wire import (
|
||||||
ProtobufWireError,
|
ProtobufWireError,
|
||||||
decode_zigzag64,
|
decode_zigzag64,
|
||||||
|
encode_zigzag64,
|
||||||
iter_fields,
|
iter_fields,
|
||||||
)
|
)
|
||||||
from k1link.device_plugins.xgrids_k1.protocol.streams import (
|
from k1link.device_plugins.xgrids_k1.protocol.streams import (
|
||||||
|
|
@ -94,6 +95,13 @@ def test_protobuf_wire_zigzag_and_bounds() -> None:
|
||||||
assert decode_zigzag64(0) == 0
|
assert decode_zigzag64(0) == 0
|
||||||
assert decode_zigzag64(1) == -1
|
assert decode_zigzag64(1) == -1
|
||||||
assert decode_zigzag64(2) == 1
|
assert decode_zigzag64(2) == 1
|
||||||
|
assert encode_zigzag64(0) == 0
|
||||||
|
assert encode_zigzag64(-1) == 1
|
||||||
|
assert encode_zigzag64(1) == 2
|
||||||
|
assert decode_zigzag64(encode_zigzag64(-(1 << 63))) == -(1 << 63)
|
||||||
|
assert decode_zigzag64(encode_zigzag64((1 << 63) - 1)) == (1 << 63) - 1
|
||||||
|
with pytest.raises(ProtobufWireError, match="outside int64"):
|
||||||
|
encode_zigzag64(1 << 63)
|
||||||
with pytest.raises(ProtobufWireError, match="truncated"):
|
with pytest.raises(ProtobufWireError, match="truncated"):
|
||||||
list(iter_fields(b"\x0a\x02\x01"))
|
list(iter_fields(b"\x0a\x02\x01"))
|
||||||
with pytest.raises(ProtobufWireError, match="unsupported"):
|
with pytest.raises(ProtobufWireError, match="unsupported"):
|
||||||
|
|
|
||||||
|
|
@ -77,7 +77,7 @@ def test_validation_errors_do_not_echo_sensitive_request_values(
|
||||||
"compatibility_attestation": {
|
"compatibility_attestation": {
|
||||||
"firmware_version": "3.0.2",
|
"firmware_version": "3.0.2",
|
||||||
"topology": "direct-lan",
|
"topology": "direct-lan",
|
||||||
"operator_confirmed": True,
|
"verification": "live-device-info",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
payload = {"input": action_input} if wrap_input else action_input
|
payload = {"input": action_input} if wrap_input else action_input
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,7 @@ from k1link.device_plugins.xgrids_k1.protocol.application_bootstrap import (
|
||||||
ATTESTATION = CompatibilityAttestationRequest(
|
ATTESTATION = CompatibilityAttestationRequest(
|
||||||
firmware_version="3.0.2",
|
firmware_version="3.0.2",
|
||||||
topology="direct-lan",
|
topology="direct-lan",
|
||||||
operator_confirmed=True,
|
verification="live-device-info",
|
||||||
)
|
)
|
||||||
PRIMARY_TEST_CREDENTIAL = "x" * 24
|
PRIMARY_TEST_CREDENTIAL = "x" * 24
|
||||||
SECONDARY_TEST_CREDENTIAL = "y" * 24
|
SECONDARY_TEST_CREDENTIAL = "y" * 24
|
||||||
|
|
@ -120,12 +120,11 @@ class FakeInteractiveControlSession:
|
||||||
self.state = "workspace-ready"
|
self.state = "workspace-ready"
|
||||||
self.start_projects: list[str] = []
|
self.start_projects: list[str] = []
|
||||||
self.stop_calls = 0
|
self.stop_calls = 0
|
||||||
self.confirm_calls = 0
|
|
||||||
|
|
||||||
def snapshot(self) -> dict[str, object]:
|
def snapshot(self) -> dict[str, object]:
|
||||||
return {
|
return {
|
||||||
"state": self.state,
|
"state": self.state,
|
||||||
"can_confirm_standby": self.state == "awaiting-standby-confirmation",
|
"can_confirm_standby": False,
|
||||||
}
|
}
|
||||||
|
|
||||||
def open_project_prompt(self) -> dict[str, object]:
|
def open_project_prompt(self) -> dict[str, object]:
|
||||||
|
|
@ -147,12 +146,6 @@ class FakeInteractiveControlSession:
|
||||||
self.state = "awaiting-standby-confirmation"
|
self.state = "awaiting-standby-confirmation"
|
||||||
return self.snapshot()
|
return self.snapshot()
|
||||||
|
|
||||||
def confirm_standby(self) -> dict[str, object]:
|
|
||||||
assert self.state == "awaiting-standby-confirmation"
|
|
||||||
self.confirm_calls += 1
|
|
||||||
self.state = "completed"
|
|
||||||
return self.snapshot()
|
|
||||||
|
|
||||||
def close_prestart(self) -> dict[str, object]:
|
def close_prestart(self) -> dict[str, object]:
|
||||||
self.state = "closed"
|
self.state = "closed"
|
||||||
return self.snapshot()
|
return self.snapshot()
|
||||||
|
|
@ -211,6 +204,8 @@ def test_prepare_creates_provisional_device_session_and_profiled_acquisition(
|
||||||
assert state["device_ref"]["device_id"] != state["device_session"]["device_session_id"]
|
assert state["device_ref"]["device_id"] != state["device_session"]["device_session_id"]
|
||||||
assert state["acquisition"]["state"] == "prepared"
|
assert state["acquisition"]["state"] == "prepared"
|
||||||
assert state["acquisition"]["project_name"] == PROJECT_NAME
|
assert state["acquisition"]["project_name"] == PROJECT_NAME
|
||||||
|
assert state["acquisition"]["mount_type"] == "handheld"
|
||||||
|
assert state["acquisition"]["gnss_mode"] == "none"
|
||||||
assert state["acquisition"]["compatibility_profile_id"] == (XGRIDS_K1_COMPATIBILITY_PROFILE_ID)
|
assert state["acquisition"]["compatibility_profile_id"] == (XGRIDS_K1_COMPATIBILITY_PROFILE_ID)
|
||||||
assert state["compatibility"]["vendor_writes_enabled"] is False
|
assert state["compatibility"]["vendor_writes_enabled"] is False
|
||||||
|
|
||||||
|
|
@ -230,6 +225,29 @@ def test_project_name_is_normalized_and_control_characters_are_rejected() -> Non
|
||||||
host="192.168.1.20",
|
host="192.168.1.20",
|
||||||
compatibility_attestation=ATTESTATION,
|
compatibility_attestation=ATTESTATION,
|
||||||
)
|
)
|
||||||
|
def test_only_physically_accepted_configuration_values_are_admitted() -> None:
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
ConnectRequest(
|
||||||
|
device_id="synthetic-device",
|
||||||
|
ssid="synthetic-network",
|
||||||
|
password=SecretStr(PRIMARY_TEST_CREDENTIAL),
|
||||||
|
connection_mode="quick-connect", # type: ignore[arg-type]
|
||||||
|
compatibility_attestation=ATTESTATION,
|
||||||
|
)
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
PrepareAcquisitionRequest(
|
||||||
|
project_name=PROJECT_NAME,
|
||||||
|
host="192.168.1.20",
|
||||||
|
mount_type="uav", # type: ignore[arg-type]
|
||||||
|
compatibility_attestation=ATTESTATION,
|
||||||
|
)
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
PrepareAcquisitionRequest(
|
||||||
|
project_name=PROJECT_NAME,
|
||||||
|
host="192.168.1.20",
|
||||||
|
gnss_mode="rtk", # type: ignore[arg-type]
|
||||||
|
compatibility_attestation=ATTESTATION,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_facade_arms_bounded_shadow_lease_without_installing_publish_transport(
|
def test_facade_arms_bounded_shadow_lease_without_installing_publish_transport(
|
||||||
|
|
@ -247,7 +265,8 @@ def test_facade_arms_bounded_shadow_lease_without_installing_publish_transport(
|
||||||
service._compatibility_attestation = { # noqa: SLF001
|
service._compatibility_attestation = { # noqa: SLF001
|
||||||
"firmware_version": "3.0.2",
|
"firmware_version": "3.0.2",
|
||||||
"topology": "direct-lan",
|
"topology": "direct-lan",
|
||||||
"basis": "operator-attested",
|
"verification": "live-device-info",
|
||||||
|
"basis": "selected-profile-live-device-info-required",
|
||||||
"observed_at": "2026-07-18T00:00:00Z",
|
"observed_at": "2026-07-18T00:00:00Z",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -274,7 +293,7 @@ def test_facade_arms_bounded_shadow_lease_without_installing_publish_transport(
|
||||||
assert disarmed["application_control_execution"]["lease"] is None
|
assert disarmed["application_control_execution"]["lease"] is None
|
||||||
|
|
||||||
|
|
||||||
def test_shadow_arm_requires_idle_connected_attested_device_before_keychain_read(
|
def test_shadow_arm_requires_idle_connected_profile_selected_device_before_keychain_read(
|
||||||
tmp_path: Path,
|
tmp_path: Path,
|
||||||
) -> None:
|
) -> None:
|
||||||
loader = FakeApplicationAuthorityLoader()
|
loader = FakeApplicationAuthorityLoader()
|
||||||
|
|
@ -405,21 +424,82 @@ def test_plugin_commanded_acquisition_keeps_start_and_stop_as_explicit_actions(
|
||||||
physical_acceptance=PHYSICAL_ACCEPTANCE,
|
physical_acceptance=PHYSICAL_ACCEPTANCE,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
stop_operation = stopping["last_operation"]
|
|
||||||
assert control.stop_calls == 1
|
assert control.stop_calls == 1
|
||||||
assert stopping["acquisition"]["state"] == "awaiting_external_stop"
|
assert stopping["acquisition"]["state"] == "awaiting_external_stop"
|
||||||
|
assert stopping["last_operation"]["status"] == "running"
|
||||||
|
|
||||||
completed = service.stop_acquisition(
|
control.state = "completed"
|
||||||
|
completed = service.state()
|
||||||
|
assert control.stop_calls == 1
|
||||||
|
assert completed["acquisition"]["state"] == "completed"
|
||||||
|
assert completed["acquisition"]["result"]["device_state"] == "ready"
|
||||||
|
assert completed["last_operation"]["status"] == "succeeded"
|
||||||
|
assert runtime.stop_calls == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_device_standby_retires_sources_after_terminal_local_stop_failure(
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
service, runtime = service_with_fake_runtime(tmp_path)
|
||||||
|
control = FakeInteractiveControlSession()
|
||||||
|
service._application_control_session = control # type: ignore[assignment] # noqa: SLF001
|
||||||
|
service._k1_ip = "192.168.1.20" # noqa: SLF001
|
||||||
|
service._compatibility_attestation = {"profile": "exact"} # noqa: SLF001
|
||||||
|
|
||||||
|
prepared = service.prepare_acquisition(
|
||||||
|
PrepareAcquisitionRequest(
|
||||||
|
project_name="TEST001",
|
||||||
|
host="192.168.1.20",
|
||||||
|
compatibility_attestation=ATTESTATION,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
acquisition_id = prepared["acquisition"]["acquisition_id"]
|
||||||
|
service.start_acquisition(
|
||||||
|
StartAcquisitionRequest(
|
||||||
|
acquisition_id=acquisition_id,
|
||||||
|
physical_acceptance=PHYSICAL_ACCEPTANCE,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
runtime.mark_ready()
|
||||||
|
runtime.pcl_frames = 1
|
||||||
|
service.state()
|
||||||
|
|
||||||
|
stopping = service.stop_acquisition(
|
||||||
StopAcquisitionRequest(
|
StopAcquisitionRequest(
|
||||||
acquisition_id=acquisition_id,
|
acquisition_id=acquisition_id,
|
||||||
mode="graceful",
|
mode="graceful",
|
||||||
operator_confirmed=True,
|
physical_acceptance=PHYSICAL_ACCEPTANCE,
|
||||||
operation_id=stop_operation["operation_id"],
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
stop_operation = stopping["last_operation"]
|
||||||
|
with service._lock: # noqa: SLF001
|
||||||
|
assert service._acquisition is not None # noqa: SLF001
|
||||||
|
service._acquisition.transition( # noqa: SLF001
|
||||||
|
"failed",
|
||||||
|
message_code="acquisition.camera_failed",
|
||||||
|
result={"camera_failure_code": "camera-source-ended"},
|
||||||
|
)
|
||||||
|
service._operations.transition( # noqa: SLF001
|
||||||
|
stop_operation["operation_id"],
|
||||||
|
"failed",
|
||||||
|
stage_code="runtime-failed",
|
||||||
|
message_code="acquisition.stop.runtime_failed",
|
||||||
|
error={
|
||||||
|
"category": "stream",
|
||||||
|
"code": "runtime-failed",
|
||||||
|
"retryable": False,
|
||||||
|
"safe_to_retry": False,
|
||||||
|
"side_effect_status": "unknown",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
control.state = "completed"
|
||||||
|
recovered = service.state()
|
||||||
|
|
||||||
assert control.stop_calls == 1
|
assert control.stop_calls == 1
|
||||||
assert control.confirm_calls == 1
|
assert control.state == "completed"
|
||||||
assert completed["acquisition"]["state"] == "completed"
|
assert recovered["acquisition"]["state"] == "failed"
|
||||||
|
assert recovered["application_control_session"]["state"] == "completed"
|
||||||
assert runtime.stop_calls == 1
|
assert runtime.stop_calls == 1
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -1257,7 +1337,7 @@ def test_prepare_rejects_stream_subsets_and_duplicates(
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_exact_profile_is_inactive_until_explicit_operator_attestation(
|
def test_exact_profile_is_inactive_until_selected_for_live_device_info_verification(
|
||||||
tmp_path: Path,
|
tmp_path: Path,
|
||||||
) -> None:
|
) -> None:
|
||||||
service, _ = service_with_fake_runtime(tmp_path)
|
service, _ = service_with_fake_runtime(tmp_path)
|
||||||
|
|
@ -1267,7 +1347,7 @@ def test_exact_profile_is_inactive_until_explicit_operator_attestation(
|
||||||
"profile_id": None,
|
"profile_id": None,
|
||||||
"decision": "unknown",
|
"decision": "unknown",
|
||||||
"permitted_mode": "evidence-only",
|
"permitted_mode": "evidence-only",
|
||||||
"firmware_claim": "exact-3.0.2-profile-not-attested",
|
"firmware_claim": "exact-3.0.2-profile-not-selected",
|
||||||
"attestation": None,
|
"attestation": None,
|
||||||
"vendor_writes_enabled": False,
|
"vendor_writes_enabled": False,
|
||||||
"camera_preview": "unverified",
|
"camera_preview": "unverified",
|
||||||
|
|
@ -1284,7 +1364,9 @@ def test_exact_profile_is_inactive_until_explicit_operator_attestation(
|
||||||
|
|
||||||
assert attested["compatibility"]["profile_id"] == XGRIDS_K1_COMPATIBILITY_PROFILE_ID
|
assert attested["compatibility"]["profile_id"] == XGRIDS_K1_COMPATIBILITY_PROFILE_ID
|
||||||
assert attested["compatibility"]["decision"] == "limited"
|
assert attested["compatibility"]["decision"] == "limited"
|
||||||
assert attested["compatibility"]["attestation"]["basis"] == "operator-attested"
|
assert attested["compatibility"]["attestation"]["basis"] == (
|
||||||
|
"selected-profile-live-device-info-required"
|
||||||
|
)
|
||||||
assert attested["device_session"]["compatibility_profile_id"] == (
|
assert attested["device_session"]["compatibility_profile_id"] == (
|
||||||
XGRIDS_K1_COMPATIBILITY_PROFILE_ID
|
XGRIDS_K1_COMPATIBILITY_PROFILE_ID
|
||||||
)
|
)
|
||||||
|
|
@ -1722,6 +1804,7 @@ def test_network_provisioning_is_single_flight_and_secret_is_unwrapped_only_at_b
|
||||||
|
|
||||||
assert boundary_calls == [("k1-a", "lab-network", PRIMARY_TEST_CREDENTIAL)]
|
assert boundary_calls == [("k1-a", "lab-network", PRIMARY_TEST_CREDENTIAL)]
|
||||||
assert connected["k1_ip"] == "192.168.1.20"
|
assert connected["k1_ip"] == "192.168.1.20"
|
||||||
|
assert connected["connection_mode"] == "bridge"
|
||||||
assert PRIMARY_TEST_CREDENTIAL not in str(connected)
|
assert PRIMARY_TEST_CREDENTIAL not in str(connected)
|
||||||
provision_operations = [
|
provision_operations = [
|
||||||
item for item in connected["operations"] if item["action"] == "network.provision"
|
item for item in connected["operations"] if item["action"] == "network.provision"
|
||||||
|
|
@ -1729,6 +1812,48 @@ def test_network_provisioning_is_single_flight_and_secret_is_unwrapped_only_at_b
|
||||||
assert {item["status"] for item in provision_operations} == {"succeeded", "failed"}
|
assert {item["status"] for item in provision_operations} == {"succeeded", "failed"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_network_provisioning_rejects_an_ipv4_owned_by_the_local_host(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
service, _ = service_with_fake_runtime(tmp_path)
|
||||||
|
service._devices = [{"device_id": "k1-a"}] # noqa: SLF001
|
||||||
|
|
||||||
|
async def fake_provision(*_: object, **__: object) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"started_at_utc": "2026-07-18T15:19:25Z",
|
||||||
|
"completed_at_utc": "2026-07-18T15:19:26Z",
|
||||||
|
"profile_id": "xgrids-k1-fw3-wifi-v1",
|
||||||
|
"outcome": "lan_address_observed",
|
||||||
|
"observations": [{"status": {"ipv4": "192.168.68.51"}}],
|
||||||
|
}
|
||||||
|
|
||||||
|
monkeypatch.setattr(facade_module, "provision_wifi_once", fake_provision)
|
||||||
|
monkeypatch.setattr(facade_module, "_target_is_local_ipv4", lambda _target: True)
|
||||||
|
|
||||||
|
with pytest.raises(RuntimeError, match="уже принадлежит этому компьютеру"):
|
||||||
|
asyncio.run(
|
||||||
|
service.connect(
|
||||||
|
ConnectRequest(
|
||||||
|
device_id="k1-a",
|
||||||
|
ssid="lab-network",
|
||||||
|
password=SecretStr(PRIMARY_TEST_CREDENTIAL),
|
||||||
|
compatibility_attestation=ATTESTATION,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
state = service.state()
|
||||||
|
assert state["selected_device_id"] is None
|
||||||
|
assert state["k1_ip"] is None
|
||||||
|
operation = next(
|
||||||
|
item for item in state["operations"] if item["action"] == "network.provision"
|
||||||
|
)
|
||||||
|
assert operation["status"] == "failed"
|
||||||
|
assert operation["error"]["safe_to_retry"] is False
|
||||||
|
assert operation["error"]["side_effect_status"] == "unknown"
|
||||||
|
|
||||||
|
|
||||||
def test_provisioning_cannot_switch_device_during_active_acquisition(
|
def test_provisioning_cannot_switch_device_during_active_acquisition(
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
tmp_path: Path,
|
tmp_path: Path,
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import hashlib
|
||||||
from collections.abc import Collection, Sequence
|
from collections.abc import Collection, Sequence
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
from pydantic import JsonValue, TypeAdapter
|
||||||
|
|
||||||
from k1link.device_plugins.xgrids_k1.protocol.application_acceptance import (
|
from k1link.device_plugins.xgrids_k1.protocol.application_acceptance import (
|
||||||
ApplicationAcceptanceError,
|
ApplicationAcceptanceError,
|
||||||
|
|
@ -11,6 +12,7 @@ from k1link.device_plugins.xgrids_k1.protocol.application_acceptance import (
|
||||||
PhysicalAcceptanceChecklist,
|
PhysicalAcceptanceChecklist,
|
||||||
PhysicalAcceptanceDialogueExecutor,
|
PhysicalAcceptanceDialogueExecutor,
|
||||||
PhysicalAcceptancePermit,
|
PhysicalAcceptancePermit,
|
||||||
|
ScanInitializationTimeout,
|
||||||
)
|
)
|
||||||
from k1link.device_plugins.xgrids_k1.protocol.application_bootstrap import (
|
from k1link.device_plugins.xgrids_k1.protocol.application_bootstrap import (
|
||||||
DEVICE_CONFIG_RESPONSE_TOPIC,
|
DEVICE_CONFIG_RESPONSE_TOPIC,
|
||||||
|
|
@ -18,9 +20,6 @@ from k1link.device_plugins.xgrids_k1.protocol.application_bootstrap import (
|
||||||
ShadowApplicationBootstrapOrchestrator,
|
ShadowApplicationBootstrapOrchestrator,
|
||||||
build_canonical_post_start_observation,
|
build_canonical_post_start_observation,
|
||||||
)
|
)
|
||||||
from k1link.device_plugins.xgrids_k1.protocol.application_mqtt import (
|
|
||||||
MODELING_RESPONSE_TOPIC,
|
|
||||||
)
|
|
||||||
from k1link.device_plugins.xgrids_k1.protocol.application_publish import (
|
from k1link.device_plugins.xgrids_k1.protocol.application_publish import (
|
||||||
OneShotPublishEnvelope,
|
OneShotPublishEnvelope,
|
||||||
)
|
)
|
||||||
|
|
@ -67,14 +66,19 @@ def _header(session_id: str) -> bytes:
|
||||||
return _text(4, VENDOR_DEVICE_ID) + _text(5, session_id) + _text(6, APPLICATION_KEY)
|
return _text(4, VENDOR_DEVICE_ID) + _text(5, session_id) + _text(6, APPLICATION_KEY)
|
||||||
|
|
||||||
|
|
||||||
def _device_info_response(session_id: str) -> bytes:
|
def _device_info_response(
|
||||||
|
session_id: str,
|
||||||
|
*,
|
||||||
|
device_model: str = "LixelKity K1",
|
||||||
|
device_type: str = "A4",
|
||||||
|
) -> bytes:
|
||||||
base_info = b"".join(
|
base_info = b"".join(
|
||||||
(
|
(
|
||||||
_text(2, "V3.0.2-20260101-release"),
|
_text(2, "V3.0.2_20250624.122658"),
|
||||||
_text(3, "V3.0.2"),
|
_text(3, "V3.0.2"),
|
||||||
_text(6, "LixelKity K1"),
|
_text(6, device_model),
|
||||||
_text(7, "K1SERIAL01"),
|
_text(7, "K1SERIAL01"),
|
||||||
_text(8, "K1"),
|
_text(8, device_type),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
working_status = _uint(1, 1)
|
working_status = _uint(1, 1)
|
||||||
|
|
@ -93,7 +97,13 @@ def _modeling_response(action: ModelingAction) -> bytes:
|
||||||
|
|
||||||
|
|
||||||
class SyntheticAcceptanceTransport:
|
class SyntheticAcceptanceTransport:
|
||||||
def __init__(self, clock: FakeClock | None = None) -> None:
|
def __init__(
|
||||||
|
self,
|
||||||
|
clock: FakeClock | None = None,
|
||||||
|
*,
|
||||||
|
device_model: str = "LixelKity K1",
|
||||||
|
device_type: str = "A4",
|
||||||
|
) -> None:
|
||||||
self.batches: list[tuple[str, ...]] = []
|
self.batches: list[tuple[str, ...]] = []
|
||||||
self.maintain_calls: list[tuple[float, tuple[str, ...]]] = []
|
self.maintain_calls: list[tuple[float, tuple[str, ...]]] = []
|
||||||
self.clock = clock
|
self.clock = clock
|
||||||
|
|
@ -102,22 +112,32 @@ class SyntheticAcceptanceTransport:
|
||||||
self.post_stop_maintain_calls = 0
|
self.post_stop_maintain_calls = 0
|
||||||
self.start_emitted = False
|
self.start_emitted = False
|
||||||
self.stop_emitted = False
|
self.stop_emitted = False
|
||||||
|
self.device_model = device_model
|
||||||
|
self.device_type = device_type
|
||||||
|
|
||||||
def exchange_batch_once(
|
def exchange_batch_once(
|
||||||
self,
|
self,
|
||||||
envelopes: Sequence[OneShotPublishEnvelope],
|
envelopes: Sequence[OneShotPublishEnvelope],
|
||||||
*,
|
*,
|
||||||
required_response_topics: Collection[str],
|
required_response_operation_keys: Collection[str],
|
||||||
) -> dict[str, bytes]:
|
) -> dict[str, bytes]:
|
||||||
self.batches.append(tuple(envelope.operation_key for envelope in envelopes))
|
self.batches.append(tuple(envelope.operation_key for envelope in envelopes))
|
||||||
responses: dict[str, bytes] = {}
|
responses: dict[str, bytes] = {}
|
||||||
if MODELING_RESPONSE_TOPIC in required_response_topics:
|
modeling_operation = next(
|
||||||
|
(
|
||||||
|
operation_key
|
||||||
|
for operation_key in required_response_operation_keys
|
||||||
|
if operation_key in {"modeling:start", "modeling:stop"}
|
||||||
|
),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
if modeling_operation is not None:
|
||||||
action = (
|
action = (
|
||||||
ModelingAction.START
|
ModelingAction.START
|
||||||
if envelopes[0].operation_key == "modeling:start"
|
if modeling_operation == "modeling:start"
|
||||||
else ModelingAction.STOP
|
else ModelingAction.STOP
|
||||||
)
|
)
|
||||||
responses[MODELING_RESPONSE_TOPIC] = _modeling_response(action)
|
responses[modeling_operation] = _modeling_response(action)
|
||||||
if action is ModelingAction.START:
|
if action is ModelingAction.START:
|
||||||
self.start_emitted = True
|
self.start_emitted = True
|
||||||
else:
|
else:
|
||||||
|
|
@ -134,7 +154,11 @@ class SyntheticAcceptanceTransport:
|
||||||
if ordinal == 1
|
if ordinal == 1
|
||||||
else f"{VENDOR_DEVICE_ID}:DeviceInfoRequest"
|
else f"{VENDOR_DEVICE_ID}:DeviceInfoRequest"
|
||||||
)
|
)
|
||||||
response = _device_info_response(session)
|
response = _device_info_response(
|
||||||
|
session,
|
||||||
|
device_model=self.device_model,
|
||||||
|
device_type=self.device_type,
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
session = (
|
session = (
|
||||||
f":{message_type}" if ordinal <= 3 else f"{VENDOR_DEVICE_ID}:{message_type}"
|
f":{message_type}" if ordinal <= 3 else f"{VENDOR_DEVICE_ID}:{message_type}"
|
||||||
|
|
@ -142,9 +166,8 @@ class SyntheticAcceptanceTransport:
|
||||||
if message_type == "DeviceConfigRequest":
|
if message_type == "DeviceConfigRequest":
|
||||||
session += ":Publish_Proto_DeviceConfig_SetTime"
|
session += ":Publish_Proto_DeviceConfig_SetTime"
|
||||||
response = _generic_response(session)
|
response = _generic_response(session)
|
||||||
for topic in required_response_topics:
|
if envelope.operation_key in required_response_operation_keys:
|
||||||
if topic.endswith(_response_suffix(message_type)):
|
responses[envelope.operation_key] = response
|
||||||
responses[topic] = response
|
|
||||||
return responses
|
return responses
|
||||||
|
|
||||||
def maintain_open_for(
|
def maintain_open_for(
|
||||||
|
|
@ -178,18 +201,6 @@ class SyntheticAcceptanceTransport:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _response_suffix(message_type: str) -> str:
|
|
||||||
values = {
|
|
||||||
"DeviceInfoRequest": "device_info",
|
|
||||||
"ModelingStatusRequest": "modeling_status",
|
|
||||||
"GetRtkAdvanceRequest": "get_rtk_advance",
|
|
||||||
"DeviceConfigRequest": "device_config",
|
|
||||||
"GetNtripProfileRequest": "get_ntrip_profile",
|
|
||||||
"GetCloudServerConfigRequest": "get_cloud_server_config",
|
|
||||||
}
|
|
||||||
return values[message_type]
|
|
||||||
|
|
||||||
|
|
||||||
def _checklist(action: ModelingAction) -> PhysicalAcceptanceChecklist:
|
def _checklist(action: ModelingAction) -> PhysicalAcceptanceChecklist:
|
||||||
return PhysicalAcceptanceChecklist(
|
return PhysicalAcceptanceChecklist(
|
||||||
action=action,
|
action=action,
|
||||||
|
|
@ -232,7 +243,14 @@ def test_canonical_session_owns_start_active_scan_stop_and_save_boundary() -> No
|
||||||
owner_token=object(),
|
owner_token=object(),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
assert [len(batch) for batch in transport.batches] == [1, 2, 3]
|
assert [len(batch) for batch in transport.batches] == [1, 5]
|
||||||
|
assert transport.batches[1] == (
|
||||||
|
"bootstrap:2:ModelingStatusRequest",
|
||||||
|
"bootstrap:3:GetRtkAdvanceRequest",
|
||||||
|
"bootstrap:4:DeviceConfigRequest",
|
||||||
|
"bootstrap:5:DeviceInfoRequest",
|
||||||
|
"bootstrap:6:GetRtkAdvanceRequest",
|
||||||
|
)
|
||||||
workspace_checks = iter((False, False, True))
|
workspace_checks = iter((False, False, True))
|
||||||
workspace_checkpoint = executor.wait_for_operator_checkpoint(
|
workspace_checkpoint = executor.wait_for_operator_checkpoint(
|
||||||
"workspace-entered",
|
"workspace-entered",
|
||||||
|
|
@ -313,14 +331,11 @@ def test_canonical_session_owns_start_active_scan_stop_and_save_boundary() -> No
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
stop_response = executor.execute_canonical_stop(stop_command, stop_permit)
|
stop_response = executor.execute_canonical_stop(stop_command, stop_permit)
|
||||||
standby_checks = iter((False, False, True))
|
executor.maintain_post_stop_until_standby()
|
||||||
executor.maintain_post_stop_until_standby_confirmed(
|
|
||||||
lambda: next(standby_checks)
|
|
||||||
)
|
|
||||||
|
|
||||||
assert stop_response.action is ModelingAction.STOP
|
assert stop_response.action is ModelingAction.STOP
|
||||||
assert [len(batch) for batch in transport.batches] == [1, 2, 3, 1, 3, 1, 1, 2, 1]
|
assert [len(batch) for batch in transport.batches] == [1, 5, 1, 3, 1, 1, 2, 1]
|
||||||
assert len(transport.maintain_calls) == 214
|
assert len(transport.maintain_calls) == 212
|
||||||
assert set(transport.maintain_calls) == {
|
assert set(transport.maintain_calls) == {
|
||||||
(1.0, ("lixel/application/response/modeling_status",))
|
(1.0, ("lixel/application/response/modeling_status",))
|
||||||
}
|
}
|
||||||
|
|
@ -332,13 +347,120 @@ def test_canonical_session_owns_start_active_scan_stop_and_save_boundary() -> No
|
||||||
assert executor.snapshot()["stop_complete"] is True
|
assert executor.snapshot()["stop_complete"] is True
|
||||||
assert executor.snapshot()["dialogue_stage"] == "standby-confirmed"
|
assert executor.snapshot()["dialogue_stage"] == "standby-confirmed"
|
||||||
response_evidence = executor.snapshot()["response_evidence"]
|
response_evidence = executor.snapshot()["response_evidence"]
|
||||||
assert isinstance(response_evidence, tuple)
|
assert isinstance(response_evidence, list)
|
||||||
assert len(response_evidence) == 13
|
assert len(response_evidence) == 13
|
||||||
assert response_evidence[0]["operation_key"] == "bootstrap:1:DeviceInfoRequest"
|
assert response_evidence[0]["operation_key"] == "bootstrap:1:DeviceInfoRequest"
|
||||||
assert response_evidence[-1]["operation_key"] == "modeling:stop"
|
assert response_evidence[-1]["operation_key"] == "modeling:stop"
|
||||||
assert all("payload" not in item for item in response_evidence)
|
assert all("payload" not in item for item in response_evidence)
|
||||||
assert APPLICATION_KEY not in str(executor.snapshot())
|
assert APPLICATION_KEY not in str(executor.snapshot())
|
||||||
assert VENDOR_DEVICE_ID not in str(executor.snapshot())
|
assert VENDOR_DEVICE_ID not in str(executor.snapshot())
|
||||||
|
TypeAdapter(JsonValue).validate_python(executor.snapshot())
|
||||||
|
|
||||||
|
|
||||||
|
def test_start_initialization_wait_has_fail_closed_watchdog() -> None:
|
||||||
|
class NeverInitializedTransport(SyntheticAcceptanceTransport):
|
||||||
|
def scan_initialization_complete(self, _binding: object) -> bool:
|
||||||
|
return False
|
||||||
|
|
||||||
|
clock = FakeClock()
|
||||||
|
transport = NeverInitializedTransport(clock)
|
||||||
|
executor = PhysicalAcceptanceDialogueExecutor(
|
||||||
|
transport,
|
||||||
|
monotonic=clock,
|
||||||
|
scan_initialization_timeout_seconds=2.0,
|
||||||
|
)
|
||||||
|
authority = ApplicationControlAuthority(openapi_key=APPLICATION_KEY)
|
||||||
|
orchestrator = ShadowApplicationBootstrapOrchestrator(
|
||||||
|
authority,
|
||||||
|
epoch_seconds=1_752_680_000,
|
||||||
|
timezone_name="Europe/Moscow",
|
||||||
|
)
|
||||||
|
binding = executor.run_connection_stage(orchestrator)
|
||||||
|
executor.run_workspace_entry_stage(
|
||||||
|
orchestrator,
|
||||||
|
executor.wait_for_operator_checkpoint("workspace-entered", lambda: True),
|
||||||
|
)
|
||||||
|
executor.run_project_prompt_stage(
|
||||||
|
orchestrator,
|
||||||
|
executor.wait_for_operator_checkpoint("project-prompt-opened", lambda: True),
|
||||||
|
)
|
||||||
|
start_checkpoint = executor.wait_for_operator_checkpoint(
|
||||||
|
"start-confirmed",
|
||||||
|
lambda: True,
|
||||||
|
)
|
||||||
|
command = ShadowModelingCommand.from_command(
|
||||||
|
encode_modeling_start(
|
||||||
|
CommandHeaderIdentity(
|
||||||
|
device_id=binding.vendor_device_id,
|
||||||
|
openapi_key=APPLICATION_KEY,
|
||||||
|
),
|
||||||
|
project_name="SAFE_PROJECT",
|
||||||
|
record_mode=RecordMode.RECORD_AND_CALCULATE,
|
||||||
|
scan_mode=ScanMode.LCC,
|
||||||
|
mount_type=MountType.HANDHELD,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
permit = PhysicalAcceptancePermit(
|
||||||
|
_checklist(ModelingAction.START),
|
||||||
|
monotonic=clock,
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(ScanInitializationTimeout):
|
||||||
|
executor.execute_canonical_start(
|
||||||
|
command,
|
||||||
|
build_canonical_post_start_observation(authority, binding),
|
||||||
|
authority=authority,
|
||||||
|
binding=binding,
|
||||||
|
permit=permit,
|
||||||
|
checkpoint=start_checkpoint,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert transport.start_emitted
|
||||||
|
assert not transport.stop_emitted
|
||||||
|
assert executor.snapshot()["dialogue_stage"] == "initializing"
|
||||||
|
assert executor.snapshot()["start_attempted"] is True
|
||||||
|
assert executor.snapshot()["start_complete"] is False
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("device_model", "device_type"),
|
||||||
|
(
|
||||||
|
("LixelKity K2", "A4"),
|
||||||
|
("LixelKity K1", "K1"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
def test_connection_stage_rejects_other_device_model_or_type_before_preparation(
|
||||||
|
device_model: str,
|
||||||
|
device_type: str,
|
||||||
|
) -> None:
|
||||||
|
transport = SyntheticAcceptanceTransport(
|
||||||
|
device_model=device_model,
|
||||||
|
device_type=device_type,
|
||||||
|
)
|
||||||
|
executor = PhysicalAcceptanceDialogueExecutor(transport)
|
||||||
|
orchestrator = ShadowApplicationBootstrapOrchestrator(
|
||||||
|
ApplicationControlAuthority(openapi_key=APPLICATION_KEY),
|
||||||
|
epoch_seconds=1_752_680_000,
|
||||||
|
timezone_name="Europe/Moscow",
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(ApplicationAcceptanceError, match="incompatible with the selected"):
|
||||||
|
executor.run_connection_stage(orchestrator)
|
||||||
|
|
||||||
|
assert transport.batches == [("bootstrap:1:DeviceInfoRequest",)]
|
||||||
|
assert not transport.start_emitted
|
||||||
|
assert orchestrator.binding is None
|
||||||
|
snapshot = executor.snapshot()
|
||||||
|
assert snapshot["correlation_failure"] is None
|
||||||
|
compatibility_failure = snapshot["compatibility_failure"]
|
||||||
|
assert isinstance(compatibility_failure, dict)
|
||||||
|
assert compatibility_failure["reason_code"] == "compatibility_profile_mismatch"
|
||||||
|
assert compatibility_failure["expected"] == {
|
||||||
|
"device_model": "LixelKity K1",
|
||||||
|
"platform_type": "A4",
|
||||||
|
"firmware": "3.0.2",
|
||||||
|
"is_activated": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def test_bootstrap_correlation_failure_records_only_redacted_response_evidence() -> None:
|
def test_bootstrap_correlation_failure_records_only_redacted_response_evidence() -> None:
|
||||||
|
|
@ -347,14 +469,15 @@ def test_bootstrap_correlation_failure_records_only_redacted_response_evidence()
|
||||||
self,
|
self,
|
||||||
envelopes: Sequence[OneShotPublishEnvelope],
|
envelopes: Sequence[OneShotPublishEnvelope],
|
||||||
*,
|
*,
|
||||||
required_response_topics: Collection[str],
|
required_response_operation_keys: Collection[str],
|
||||||
) -> dict[str, bytes]:
|
) -> dict[str, bytes]:
|
||||||
responses = super().exchange_batch_once(
|
responses = super().exchange_batch_once(
|
||||||
envelopes,
|
envelopes,
|
||||||
required_response_topics=required_response_topics,
|
required_response_operation_keys=required_response_operation_keys,
|
||||||
)
|
)
|
||||||
if DEVICE_CONFIG_RESPONSE_TOPIC in responses:
|
operation_key = "bootstrap:4:DeviceConfigRequest"
|
||||||
responses[DEVICE_CONFIG_RESPONSE_TOPIC] = _generic_response(
|
if operation_key in responses:
|
||||||
|
responses[operation_key] = _generic_response(
|
||||||
f"{VENDOR_DEVICE_ID}:wrong-session"
|
f"{VENDOR_DEVICE_ID}:wrong-session"
|
||||||
)
|
)
|
||||||
return responses
|
return responses
|
||||||
|
|
@ -380,10 +503,11 @@ def test_bootstrap_correlation_failure_records_only_redacted_response_evidence()
|
||||||
"phase": "bootstrap",
|
"phase": "bootstrap",
|
||||||
"operation_key": "bootstrap:4:DeviceConfigRequest",
|
"operation_key": "bootstrap:4:DeviceConfigRequest",
|
||||||
"response_topic": DEVICE_CONFIG_RESPONSE_TOPIC,
|
"response_topic": DEVICE_CONFIG_RESPONSE_TOPIC,
|
||||||
|
"reason_code": "response_session_mismatch",
|
||||||
"reason": "application response session correlation failed",
|
"reason": "application response session correlation failed",
|
||||||
}
|
}
|
||||||
evidence = snapshot["response_evidence"]
|
evidence = snapshot["response_evidence"]
|
||||||
assert isinstance(evidence, tuple)
|
assert isinstance(evidence, list)
|
||||||
assert evidence[-1]["payload_sha256"] == hashlib.sha256(
|
assert evidence[-1]["payload_sha256"] == hashlib.sha256(
|
||||||
_generic_response(f"{VENDOR_DEVICE_ID}:wrong-session")
|
_generic_response(f"{VENDOR_DEVICE_ID}:wrong-session")
|
||||||
).hexdigest()
|
).hexdigest()
|
||||||
|
|
@ -391,6 +515,7 @@ def test_bootstrap_correlation_failure_records_only_redacted_response_evidence()
|
||||||
assert all("payload" not in item for item in evidence)
|
assert all("payload" not in item for item in evidence)
|
||||||
assert APPLICATION_KEY not in str(snapshot)
|
assert APPLICATION_KEY not in str(snapshot)
|
||||||
assert VENDOR_DEVICE_ID not in str(snapshot)
|
assert VENDOR_DEVICE_ID not in str(snapshot)
|
||||||
|
TypeAdapter(JsonValue).validate_python(snapshot)
|
||||||
|
|
||||||
|
|
||||||
def test_standalone_start_and_stop_are_both_disabled() -> None:
|
def test_standalone_start_and_stop_are_both_disabled() -> None:
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,23 @@ from k1link.device_plugins.xgrids_k1.protocol.application_authority import (
|
||||||
PRIVATE_AUTHORITY = b"11111111-2222-3333-4444-555555555555\n"
|
PRIVATE_AUTHORITY = b"11111111-2222-3333-4444-555555555555\n"
|
||||||
|
|
||||||
|
|
||||||
|
@patch("platform.system", return_value="Darwin")
|
||||||
|
def test_runtime_authority_loads_through_security_framework_without_subprocess(
|
||||||
|
_system: object,
|
||||||
|
) -> None:
|
||||||
|
calls: list[tuple[str, str]] = []
|
||||||
|
|
||||||
|
def framework_reader(*, service: str, account: str) -> bytes:
|
||||||
|
calls.append((service, account))
|
||||||
|
return PRIVATE_AUTHORITY
|
||||||
|
|
||||||
|
loader = MacOSKeychainApplicationAuthorityLoader(framework_reader=framework_reader)
|
||||||
|
authority = loader.load()
|
||||||
|
|
||||||
|
assert calls == [(KEYCHAIN_SERVICE, KEYCHAIN_ACCOUNT)]
|
||||||
|
assert PRIVATE_AUTHORITY.decode().strip() not in repr(authority)
|
||||||
|
|
||||||
|
|
||||||
@patch("platform.system", return_value="Darwin")
|
@patch("platform.system", return_value="Darwin")
|
||||||
@patch("shutil.which", return_value="/usr/bin/security")
|
@patch("shutil.which", return_value="/usr/bin/security")
|
||||||
def test_authority_loads_only_from_fixed_macos_keychain_item(
|
def test_authority_loads_only_from_fixed_macos_keychain_item(
|
||||||
|
|
@ -111,6 +128,21 @@ def test_authority_loader_rejects_wrong_profile_length_without_reflection(
|
||||||
assert invalid_secret.decode() not in str(error.value)
|
assert invalid_secret.decode() not in str(error.value)
|
||||||
|
|
||||||
|
|
||||||
|
@patch("platform.system", return_value="Darwin")
|
||||||
|
def test_framework_authority_failure_is_redacted(_system: object) -> None:
|
||||||
|
private_error = "private-framework-diagnostic"
|
||||||
|
|
||||||
|
def framework_reader(*, service: str, account: str) -> bytes:
|
||||||
|
assert service == KEYCHAIN_SERVICE
|
||||||
|
assert account == KEYCHAIN_ACCOUNT
|
||||||
|
raise RuntimeError(private_error)
|
||||||
|
|
||||||
|
with pytest.raises(ApplicationAuthorityLoadError) as error:
|
||||||
|
MacOSKeychainApplicationAuthorityLoader(framework_reader=framework_reader).load()
|
||||||
|
|
||||||
|
assert private_error not in str(error.value)
|
||||||
|
|
||||||
|
|
||||||
@patch("sys.stdout.isatty", return_value=True)
|
@patch("sys.stdout.isatty", return_value=True)
|
||||||
@patch("sys.stdin.isatty", return_value=True)
|
@patch("sys.stdin.isatty", return_value=True)
|
||||||
@patch("platform.system", return_value="Darwin")
|
@patch("platform.system", return_value="Darwin")
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,8 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from k1link.device_plugins.xgrids_k1.protocol.application_bootstrap import (
|
from k1link.device_plugins.xgrids_k1.protocol.application_bootstrap import (
|
||||||
|
|
@ -16,6 +19,14 @@ from k1link.device_plugins.xgrids_k1.protocol.protobuf_wire import iter_fields
|
||||||
|
|
||||||
APPLICATION_KEY = "11111111-2222-3333-4444-555555555555"
|
APPLICATION_KEY = "11111111-2222-3333-4444-555555555555"
|
||||||
VENDOR_DEVICE_ID = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"
|
VENDOR_DEVICE_ID = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"
|
||||||
|
CAPTURE_FACTS = json.loads(
|
||||||
|
(
|
||||||
|
Path(__file__).parent
|
||||||
|
/ "fixtures"
|
||||||
|
/ "xgrids_k1"
|
||||||
|
/ "device_info_fw3_0_2_semantics.json"
|
||||||
|
).read_text(encoding="utf-8")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _varint(value: int) -> bytes:
|
def _varint(value: int) -> bytes:
|
||||||
|
|
@ -47,11 +58,11 @@ def _binding(**changes: object) -> LiveDeviceControlBinding:
|
||||||
values = {
|
values = {
|
||||||
"vendor_device_id": VENDOR_DEVICE_ID,
|
"vendor_device_id": VENDOR_DEVICE_ID,
|
||||||
"device_serial": "K1SERIAL01",
|
"device_serial": "K1SERIAL01",
|
||||||
"software_version": "V3.0.2-20260101-release",
|
"software_version": CAPTURE_FACTS["software_version"],
|
||||||
"system_version": "V3.0.2",
|
"system_version": CAPTURE_FACTS["system_version"],
|
||||||
"device_model": "LixelKity K1",
|
"device_model": CAPTURE_FACTS["device_model"],
|
||||||
"device_type": "K1",
|
"device_type": CAPTURE_FACTS["platform_type"],
|
||||||
"is_activated": True,
|
"is_activated": CAPTURE_FACTS["is_activated"],
|
||||||
}
|
}
|
||||||
values.update(changes)
|
values.update(changes)
|
||||||
return LiveDeviceControlBinding(**values) # type: ignore[arg-type]
|
return LiveDeviceControlBinding(**values) # type: ignore[arg-type]
|
||||||
|
|
@ -62,13 +73,13 @@ def _device_info_response(*, session_id: str = ":DeviceInfoRequest") -> bytes:
|
||||||
base_info = b"".join(
|
base_info = b"".join(
|
||||||
(
|
(
|
||||||
_text(1, "2026-01-01T00:00:00"),
|
_text(1, "2026-01-01T00:00:00"),
|
||||||
_text(2, "V3.0.2-20260101-release"),
|
_text(2, CAPTURE_FACTS["software_version"]),
|
||||||
_text(3, "V3.0.2"),
|
_text(3, CAPTURE_FACTS["system_version"]),
|
||||||
_text(4, "V1.2.3"),
|
_text(4, "V1.2.3"),
|
||||||
_text(5, "V1.0"),
|
_text(5, "V1.0"),
|
||||||
_text(6, "LixelKity K1"),
|
_text(6, CAPTURE_FACTS["device_model"]),
|
||||||
_text(7, "K1SERIAL01"),
|
_text(7, "K1SERIAL01"),
|
||||||
_text(8, "K1"),
|
_text(8, CAPTURE_FACTS["platform_type"]),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
working_status = _uint(1, 1) + _uint(2, 0)
|
working_status = _uint(1, 1) + _uint(2, 0)
|
||||||
|
|
@ -149,6 +160,27 @@ def test_bootstrap_headers_switch_from_unbound_to_live_device_identity() -> None
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_device_config_timestamp_uses_captured_sint64_zigzag_wire_semantics() -> None:
|
||||||
|
captured_epoch_seconds = 1_784_215_030
|
||||||
|
plan = build_shadow_bootstrap(
|
||||||
|
_authority(),
|
||||||
|
_binding(),
|
||||||
|
epoch_seconds=captured_epoch_seconds,
|
||||||
|
timezone_name="Europe/Moscow",
|
||||||
|
)
|
||||||
|
|
||||||
|
config_request = plan.requests[3]
|
||||||
|
top_fields = {field.number: field.value for field in iter_fields(config_request.payload)}
|
||||||
|
time_config = top_fields[4]
|
||||||
|
assert isinstance(time_config, bytes)
|
||||||
|
time_fields = {field.number: field.value for field in iter_fields(time_config)}
|
||||||
|
|
||||||
|
assert time_fields == {
|
||||||
|
1: 3_568_430_060,
|
||||||
|
2: b"Europe/Moscow",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def test_canonical_post_start_observation_preserves_retained_operations_12_to_14() -> None:
|
def test_canonical_post_start_observation_preserves_retained_operations_12_to_14() -> None:
|
||||||
plan = build_canonical_post_start_observation(_authority(), _binding())
|
plan = build_canonical_post_start_observation(_authority(), _binding())
|
||||||
|
|
||||||
|
|
@ -169,6 +201,7 @@ def test_device_info_response_produces_live_binding_without_a_saved_device_profi
|
||||||
|
|
||||||
assert response.result_code == OPENAPI_SUCCESS
|
assert response.result_code == OPENAPI_SUCCESS
|
||||||
assert response.binding.ready_for_reviewed_profile
|
assert response.binding.ready_for_reviewed_profile
|
||||||
|
assert response.binding.matches_reviewed_device
|
||||||
assert response.binding.matches_reviewed_firmware
|
assert response.binding.matches_reviewed_firmware
|
||||||
assert response.binding.is_activated
|
assert response.binding.is_activated
|
||||||
assert VENDOR_DEVICE_ID not in repr(response)
|
assert VENDOR_DEVICE_ID not in repr(response)
|
||||||
|
|
@ -181,7 +214,6 @@ def test_device_info_response_correlation_and_profile_attestation_fail_closed()
|
||||||
_device_info_response(session_id=":AnotherRequest"),
|
_device_info_response(session_id=":AnotherRequest"),
|
||||||
_authority(),
|
_authority(),
|
||||||
)
|
)
|
||||||
|
|
||||||
with pytest.raises(ApplicationBootstrapError, match="does not attest"):
|
with pytest.raises(ApplicationBootstrapError, match="does not attest"):
|
||||||
build_shadow_bootstrap(
|
build_shadow_bootstrap(
|
||||||
_authority(),
|
_authority(),
|
||||||
|
|
@ -191,6 +223,37 @@ def test_device_info_response_correlation_and_profile_attestation_fail_closed()
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("device_model", "device_type"),
|
||||||
|
(
|
||||||
|
("lixelkity K1", "A4"),
|
||||||
|
("LixelKity K1 ", "A4"),
|
||||||
|
("LixelKity K11", "A4"),
|
||||||
|
("LixelKity K1", "a4"),
|
||||||
|
("LixelKity K1", " A4"),
|
||||||
|
("LixelKity K1", "K1"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
def test_reviewed_profile_rejects_non_exact_device_model_and_type(
|
||||||
|
device_model: str,
|
||||||
|
device_type: str,
|
||||||
|
) -> None:
|
||||||
|
binding = _binding(device_model=device_model, device_type=device_type)
|
||||||
|
|
||||||
|
assert binding.matches_reviewed_firmware
|
||||||
|
assert not binding.matches_reviewed_device
|
||||||
|
assert not binding.ready_for_reviewed_profile
|
||||||
|
with pytest.raises(ApplicationBootstrapError, match="does not attest"):
|
||||||
|
build_shadow_bootstrap(
|
||||||
|
_authority(),
|
||||||
|
binding,
|
||||||
|
epoch_seconds=1_752_680_000,
|
||||||
|
timezone_name="Europe/Moscow",
|
||||||
|
)
|
||||||
|
with pytest.raises(ApplicationBootstrapError, match="does not attest"):
|
||||||
|
build_canonical_post_start_observation(_authority(), binding)
|
||||||
|
|
||||||
|
|
||||||
def test_response_barrier_orchestrator_emits_each_retained_batch_once() -> None:
|
def test_response_barrier_orchestrator_emits_each_retained_batch_once() -> None:
|
||||||
orchestrator = ShadowApplicationBootstrapOrchestrator(
|
orchestrator = ShadowApplicationBootstrapOrchestrator(
|
||||||
_authority(),
|
_authority(),
|
||||||
|
|
@ -202,35 +265,37 @@ def test_response_barrier_orchestrator_emits_each_retained_batch_once() -> None:
|
||||||
assert [request.ordinal for request in identity] == [1]
|
assert [request.ordinal for request in identity] == [1]
|
||||||
with pytest.raises(ApplicationBootstrapError, match="already issued"):
|
with pytest.raises(ApplicationBootstrapError, match="already issued"):
|
||||||
orchestrator.next_batch()
|
orchestrator.next_batch()
|
||||||
orchestrator.accept_response(identity[0].response_topic, _device_info_response())
|
|
||||||
|
|
||||||
initial_reads = orchestrator.next_batch()
|
|
||||||
assert [request.ordinal for request in initial_reads] == [2, 3]
|
|
||||||
assert [request.response_required for request in initial_reads] == [False, True]
|
|
||||||
orchestrator.accept_response(
|
orchestrator.accept_response(
|
||||||
initial_reads[1].response_topic,
|
"bootstrap:1:DeviceInfoRequest",
|
||||||
_generic_response(initial_reads[1].session_id),
|
_device_info_response(),
|
||||||
)
|
)
|
||||||
|
|
||||||
preparation = orchestrator.next_batch()
|
initial_reads = orchestrator.next_batch()
|
||||||
assert [request.ordinal for request in preparation] == [4, 5, 6]
|
assert [request.ordinal for request in initial_reads] == [2, 3, 4, 5, 6]
|
||||||
device_info = preparation[1]
|
assert [request.response_required for request in initial_reads] == [
|
||||||
|
False,
|
||||||
|
True,
|
||||||
|
True,
|
||||||
|
True,
|
||||||
|
True,
|
||||||
|
]
|
||||||
|
device_info = initial_reads[3]
|
||||||
orchestrator.accept_response(
|
orchestrator.accept_response(
|
||||||
device_info.response_topic,
|
"bootstrap:5:DeviceInfoRequest",
|
||||||
_device_info_response(session_id=device_info.session_id),
|
_device_info_response(session_id=device_info.session_id),
|
||||||
)
|
)
|
||||||
with pytest.raises(ApplicationBootstrapError, match="already issued"):
|
with pytest.raises(ApplicationBootstrapError, match="already issued"):
|
||||||
orchestrator.next_batch()
|
orchestrator.next_batch()
|
||||||
for request in (preparation[2], preparation[0]):
|
for request in (initial_reads[4], initial_reads[1], initial_reads[2]):
|
||||||
orchestrator.accept_response(
|
orchestrator.accept_response(
|
||||||
request.response_topic,
|
f"bootstrap:{request.ordinal}:{request.message_type}",
|
||||||
_generic_response(request.session_id),
|
_generic_response(request.session_id),
|
||||||
)
|
)
|
||||||
|
|
||||||
ntrip = orchestrator.next_batch()
|
ntrip = orchestrator.next_batch()
|
||||||
assert [request.ordinal for request in ntrip] == [7]
|
assert [request.ordinal for request in ntrip] == [7]
|
||||||
orchestrator.accept_response(
|
orchestrator.accept_response(
|
||||||
ntrip[0].response_topic,
|
"bootstrap:7:GetNtripProfileRequest",
|
||||||
_generic_response(ntrip[0].session_id),
|
_generic_response(ntrip[0].session_id),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -242,7 +307,10 @@ def test_response_barrier_orchestrator_emits_each_retained_batch_once() -> None:
|
||||||
if request.message_type == "DeviceInfoRequest"
|
if request.message_type == "DeviceInfoRequest"
|
||||||
else _generic_response(request.session_id)
|
else _generic_response(request.session_id)
|
||||||
)
|
)
|
||||||
orchestrator.accept_response(request.response_topic, payload)
|
orchestrator.accept_response(
|
||||||
|
f"bootstrap:{request.ordinal}:{request.message_type}",
|
||||||
|
payload,
|
||||||
|
)
|
||||||
|
|
||||||
snapshot = orchestrator.snapshot()
|
snapshot = orchestrator.snapshot()
|
||||||
assert snapshot.bootstrap_complete
|
assert snapshot.bootstrap_complete
|
||||||
|
|
@ -264,7 +332,7 @@ def test_orchestrator_rejects_unexpected_response_without_advancing() -> None:
|
||||||
|
|
||||||
with pytest.raises(ApplicationBootstrapError, match="not required"):
|
with pytest.raises(ApplicationBootstrapError, match="not required"):
|
||||||
orchestrator.accept_response(
|
orchestrator.accept_response(
|
||||||
"lixel/application/response/get_rtk_advance",
|
"bootstrap:3:GetRtkAdvanceRequest",
|
||||||
_generic_response(":GetRtkAdvanceRequest"),
|
_generic_response(":GetRtkAdvanceRequest"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,11 +10,14 @@ import pytest
|
||||||
|
|
||||||
from k1link.device_plugins.xgrids_k1.protocol.application_bootstrap import (
|
from k1link.device_plugins.xgrids_k1.protocol.application_bootstrap import (
|
||||||
DEVICE_INFO_RESPONSE_TOPIC,
|
DEVICE_INFO_RESPONSE_TOPIC,
|
||||||
|
GET_RTK_ADVANCE_REQUEST_TOPIC,
|
||||||
|
GET_RTK_ADVANCE_RESPONSE_TOPIC,
|
||||||
MODELING_STATUS_RESPONSE_TOPIC,
|
MODELING_STATUS_RESPONSE_TOPIC,
|
||||||
LiveDeviceControlBinding,
|
LiveDeviceControlBinding,
|
||||||
)
|
)
|
||||||
from k1link.device_plugins.xgrids_k1.protocol.application_mqtt import (
|
from k1link.device_plugins.xgrids_k1.protocol.application_mqtt import (
|
||||||
CONTROL_SUBSCRIPTION_GROUPS,
|
CONTROL_SUBSCRIPTION_GROUPS,
|
||||||
|
MODELING_RESPONSE_TOPIC,
|
||||||
SYSTEM_ERROR_TOPIC,
|
SYSTEM_ERROR_TOPIC,
|
||||||
ApplicationCommandOutcomeUnknown,
|
ApplicationCommandOutcomeUnknown,
|
||||||
ApplicationControlDeviceFault,
|
ApplicationControlDeviceFault,
|
||||||
|
|
@ -26,12 +29,17 @@ from k1link.device_plugins.xgrids_k1.protocol.application_publish import (
|
||||||
from k1link.device_plugins.xgrids_k1.protocol.modeling_control import (
|
from k1link.device_plugins.xgrids_k1.protocol.modeling_control import (
|
||||||
MODELING_STATE_BASE,
|
MODELING_STATE_BASE,
|
||||||
SYSTEM_ERROR_STATE_BASE,
|
SYSTEM_ERROR_STATE_BASE,
|
||||||
|
ModelingAction,
|
||||||
SessionState,
|
SessionState,
|
||||||
)
|
)
|
||||||
from k1link.device_plugins.xgrids_k1.protocol.modeling_safety import DEVICE_STATUS_TOPIC
|
from k1link.device_plugins.xgrids_k1.protocol.modeling_safety import (
|
||||||
|
DEVICE_STATUS_TOPIC,
|
||||||
|
MODELING_REQUEST_TOPIC,
|
||||||
|
)
|
||||||
|
|
||||||
VENDOR_DEVICE_ID = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"
|
VENDOR_DEVICE_ID = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"
|
||||||
DEVICE_SERIAL = "K1SERIAL01"
|
DEVICE_SERIAL = "K1SERIAL01"
|
||||||
|
APPLICATION_KEY = "11111111-2222-3333-4444-555555555555"
|
||||||
|
|
||||||
|
|
||||||
class FakeReasonCode:
|
class FakeReasonCode:
|
||||||
|
|
@ -70,6 +78,7 @@ class FakeControlClient:
|
||||||
self.unsubscribe_calls: list[list[str]] = []
|
self.unsubscribe_calls: list[list[str]] = []
|
||||||
self.disconnect_calls = 0
|
self.disconnect_calls = 0
|
||||||
self.next_mid = 20
|
self.next_mid = 20
|
||||||
|
self.connect_timeout = 5.0
|
||||||
|
|
||||||
def connect(self, host: str, port: int, keepalive: int) -> mqtt.MQTTErrorCode:
|
def connect(self, host: str, port: int, keepalive: int) -> mqtt.MQTTErrorCode:
|
||||||
self.connect_calls.append((host, port, keepalive))
|
self.connect_calls.append((host, port, keepalive))
|
||||||
|
|
@ -94,7 +103,7 @@ class FakeControlClient:
|
||||||
self.next_mid += 1
|
self.next_mid += 1
|
||||||
if self.emit_exchange:
|
if self.emit_exchange:
|
||||||
self.events.append(("publish", mid))
|
self.events.append(("publish", mid))
|
||||||
self.events.append(("message", (self.response_topic, b"response")))
|
self.events.append(("message", (self.response_topic, payload)))
|
||||||
return SimpleNamespace(rc=mqtt.MQTT_ERR_SUCCESS, mid=mid)
|
return SimpleNamespace(rc=mqtt.MQTT_ERR_SUCCESS, mid=mid)
|
||||||
|
|
||||||
def loop(self, timeout: float) -> mqtt.MQTTErrorCode:
|
def loop(self, timeout: float) -> mqtt.MQTTErrorCode:
|
||||||
|
|
@ -131,11 +140,32 @@ class FakeControlClient:
|
||||||
return mqtt.MQTT_ERR_SUCCESS
|
return mqtt.MQTT_ERR_SUCCESS
|
||||||
|
|
||||||
|
|
||||||
def _envelope(operation_key: str = "bootstrap:1:DeviceInfoRequest") -> OneShotPublishEnvelope:
|
def _application_message(
|
||||||
payload = b"synthetic-reviewed-request"
|
session_id: str,
|
||||||
|
*,
|
||||||
|
device_id: str | None = None,
|
||||||
|
) -> bytes:
|
||||||
|
header = b"".join(
|
||||||
|
(
|
||||||
|
_text(4, device_id) if device_id is not None else b"",
|
||||||
|
_text(5, session_id),
|
||||||
|
_text(6, APPLICATION_KEY),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return _bytes(1, header)
|
||||||
|
|
||||||
|
|
||||||
|
def _envelope(
|
||||||
|
operation_key: str = "bootstrap:1:DeviceInfoRequest",
|
||||||
|
*,
|
||||||
|
topic: str = "lixel/application/request/device_info",
|
||||||
|
session_id: str = ":DeviceInfoRequest",
|
||||||
|
device_id: str | None = None,
|
||||||
|
) -> OneShotPublishEnvelope:
|
||||||
|
payload = _application_message(session_id, device_id=device_id)
|
||||||
return OneShotPublishEnvelope(
|
return OneShotPublishEnvelope(
|
||||||
operation_key=operation_key,
|
operation_key=operation_key,
|
||||||
topic="lixel/application/request/device_info",
|
topic=topic,
|
||||||
payload=payload,
|
payload=payload,
|
||||||
payload_sha256=hashlib.sha256(payload).hexdigest(),
|
payload_sha256=hashlib.sha256(payload).hexdigest(),
|
||||||
payload_bytes=len(payload),
|
payload_bytes=len(payload),
|
||||||
|
|
@ -144,10 +174,15 @@ def _envelope(operation_key: str = "bootstrap:1:DeviceInfoRequest") -> OneShotPu
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _modeling_status_envelope() -> OneShotPublishEnvelope:
|
def _modeling_status_envelope(
|
||||||
payload = b"synthetic-reviewed-modeling-status"
|
operation_key: str = "dialogue:12:ModelingStatusRequest",
|
||||||
|
) -> OneShotPublishEnvelope:
|
||||||
|
payload = _application_message(
|
||||||
|
f"{VENDOR_DEVICE_ID}:ModelingStatusRequest",
|
||||||
|
device_id=VENDOR_DEVICE_ID,
|
||||||
|
)
|
||||||
return OneShotPublishEnvelope(
|
return OneShotPublishEnvelope(
|
||||||
operation_key="dialogue:12:ModelingStatusRequest",
|
operation_key=operation_key,
|
||||||
topic="lixel/application/request/modeling_status",
|
topic="lixel/application/request/modeling_status",
|
||||||
payload=payload,
|
payload=payload,
|
||||||
payload_sha256=hashlib.sha256(payload).hexdigest(),
|
payload_sha256=hashlib.sha256(payload).hexdigest(),
|
||||||
|
|
@ -157,6 +192,33 @@ def _modeling_status_envelope() -> OneShotPublishEnvelope:
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _modeling_envelope(action: ModelingAction) -> OneShotPublishEnvelope:
|
||||||
|
payload = _application_message(
|
||||||
|
f"{VENDOR_DEVICE_ID}:ModelingRequest",
|
||||||
|
device_id=VENDOR_DEVICE_ID,
|
||||||
|
) + _uint(2, action)
|
||||||
|
return OneShotPublishEnvelope(
|
||||||
|
operation_key=f"modeling:{action.name.casefold()}",
|
||||||
|
topic=MODELING_REQUEST_TOPIC,
|
||||||
|
payload=payload,
|
||||||
|
payload_sha256=hashlib.sha256(payload).hexdigest(),
|
||||||
|
payload_bytes=len(payload),
|
||||||
|
qos=2,
|
||||||
|
retain=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _modeling_response(action: ModelingAction) -> bytes:
|
||||||
|
return (
|
||||||
|
_application_message(
|
||||||
|
f"{VENDOR_DEVICE_ID}:ModelingRequest",
|
||||||
|
device_id=VENDOR_DEVICE_ID,
|
||||||
|
)
|
||||||
|
+ _uint(2, action)
|
||||||
|
+ _bytes(15, _uint(1, 0))
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _varint(value: int) -> bytes:
|
def _varint(value: int) -> bytes:
|
||||||
encoded = bytearray()
|
encoded = bytearray()
|
||||||
while value > 0x7F:
|
while value > 0x7F:
|
||||||
|
|
@ -203,7 +265,7 @@ def _binding() -> LiveDeviceControlBinding:
|
||||||
software_version="V3.0.2-20260101-release",
|
software_version="V3.0.2-20260101-release",
|
||||||
system_version="V3.0.2",
|
system_version="V3.0.2",
|
||||||
device_model="LixelKity K1",
|
device_model="LixelKity K1",
|
||||||
device_type="K1",
|
device_type="A4",
|
||||||
is_activated=True,
|
is_activated=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -234,21 +296,24 @@ def test_acceptance_transport_connects_once_and_completes_one_qos2_exchange() ->
|
||||||
opened = transport.open().as_dict()
|
opened = transport.open().as_dict()
|
||||||
responses = transport.exchange_batch_once(
|
responses = transport.exchange_batch_once(
|
||||||
[_envelope()],
|
[_envelope()],
|
||||||
required_response_topics={DEVICE_INFO_RESPONSE_TOPIC},
|
required_response_operation_keys={"bootstrap:1:DeviceInfoRequest"},
|
||||||
)
|
)
|
||||||
transport.close()
|
transport.close()
|
||||||
|
|
||||||
assert fake.connect_calls == [("192.168.1.20", 1883, 60)]
|
assert fake.connect_calls == [("192.168.1.20", 1883, 60)]
|
||||||
|
assert fake.connect_timeout == 10.0
|
||||||
assert fake.subscribe_calls == [list(group) for group in CONTROL_SUBSCRIPTION_GROUPS]
|
assert fake.subscribe_calls == [list(group) for group in CONTROL_SUBSCRIPTION_GROUPS]
|
||||||
assert fake.publish_calls == [
|
assert fake.publish_calls == [
|
||||||
(
|
(
|
||||||
"lixel/application/request/device_info",
|
"lixel/application/request/device_info",
|
||||||
b"synthetic-reviewed-request",
|
_application_message(":DeviceInfoRequest"),
|
||||||
2,
|
2,
|
||||||
False,
|
False,
|
||||||
)
|
)
|
||||||
]
|
]
|
||||||
assert responses == {DEVICE_INFO_RESPONSE_TOPIC: b"response"}
|
assert responses == {
|
||||||
|
"bootstrap:1:DeviceInfoRequest": _application_message(":DeviceInfoRequest")
|
||||||
|
}
|
||||||
assert opened["clean_session"] is False
|
assert opened["clean_session"] is False
|
||||||
assert opened["automatic_reconnect"] is False
|
assert opened["automatic_reconnect"] is False
|
||||||
assert opened["automatic_retry"] is False
|
assert opened["automatic_retry"] is False
|
||||||
|
|
@ -270,18 +335,325 @@ def test_consumed_operation_key_can_never_be_published_again() -> None:
|
||||||
transport.open()
|
transport.open()
|
||||||
transport.exchange_batch_once(
|
transport.exchange_batch_once(
|
||||||
[_envelope()],
|
[_envelope()],
|
||||||
required_response_topics={DEVICE_INFO_RESPONSE_TOPIC},
|
required_response_operation_keys={"bootstrap:1:DeviceInfoRequest"},
|
||||||
)
|
)
|
||||||
|
|
||||||
with pytest.raises(ApplicationCommandOutcomeUnknown, match="already consumed"):
|
with pytest.raises(ApplicationCommandOutcomeUnknown, match="already consumed"):
|
||||||
transport.exchange_batch_once(
|
transport.exchange_batch_once(
|
||||||
[_envelope()],
|
[_envelope()],
|
||||||
required_response_topics={DEVICE_INFO_RESPONSE_TOPIC},
|
required_response_operation_keys={"bootstrap:1:DeviceInfoRequest"},
|
||||||
)
|
)
|
||||||
|
|
||||||
assert len(fake.publish_calls) == 1
|
assert len(fake.publish_calls) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_same_topic_inflight_responses_are_routed_by_exact_session_without_retry() -> None:
|
||||||
|
fake = FakeControlClient(response_topic=GET_RTK_ADVANCE_RESPONSE_TOPIC)
|
||||||
|
transport = ReviewedApplicationMqttTransport(
|
||||||
|
"192.168.1.20",
|
||||||
|
client_factory=lambda: cast(mqtt.Client, fake),
|
||||||
|
)
|
||||||
|
transport.open()
|
||||||
|
unbound = _envelope(
|
||||||
|
"bootstrap:3:GetRtkAdvanceRequest",
|
||||||
|
topic=GET_RTK_ADVANCE_REQUEST_TOPIC,
|
||||||
|
session_id=":GetRtkAdvanceRequest",
|
||||||
|
)
|
||||||
|
bound_session = f"{VENDOR_DEVICE_ID}:GetRtkAdvanceRequest"
|
||||||
|
bound = _envelope(
|
||||||
|
"bootstrap:6:GetRtkAdvanceRequest",
|
||||||
|
topic=GET_RTK_ADVANCE_REQUEST_TOPIC,
|
||||||
|
session_id=bound_session,
|
||||||
|
device_id=VENDOR_DEVICE_ID,
|
||||||
|
)
|
||||||
|
|
||||||
|
responses = transport.exchange_batch_once(
|
||||||
|
[unbound, bound],
|
||||||
|
required_response_operation_keys={unbound.operation_key, bound.operation_key},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert responses[unbound.operation_key] == _application_message(
|
||||||
|
":GetRtkAdvanceRequest"
|
||||||
|
)
|
||||||
|
assert responses[bound.operation_key] == _application_message(
|
||||||
|
bound_session,
|
||||||
|
device_id=VENDOR_DEVICE_ID,
|
||||||
|
)
|
||||||
|
snapshot = transport.snapshot()
|
||||||
|
assert snapshot.publish_attempts == 2
|
||||||
|
assert snapshot.qos2_completions == 2
|
||||||
|
assert snapshot.correlated_responses == 2
|
||||||
|
assert snapshot.late_known_responses == 0
|
||||||
|
assert snapshot.automatic_retry is False
|
||||||
|
assert len(fake.publish_calls) == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_duplicate_start_response_blocks_stop_before_stop_is_published() -> None:
|
||||||
|
class ModelingControlClient(FakeControlClient):
|
||||||
|
def __init__(self) -> None:
|
||||||
|
super().__init__(
|
||||||
|
emit_exchange=False,
|
||||||
|
response_topic=MODELING_RESPONSE_TOPIC,
|
||||||
|
)
|
||||||
|
|
||||||
|
def publish(
|
||||||
|
self,
|
||||||
|
topic: str,
|
||||||
|
payload: bytes,
|
||||||
|
qos: int,
|
||||||
|
retain: bool,
|
||||||
|
) -> SimpleNamespace:
|
||||||
|
info = super().publish(topic, payload, qos, retain)
|
||||||
|
action = (
|
||||||
|
ModelingAction.START
|
||||||
|
if len(self.publish_calls) == 1
|
||||||
|
else ModelingAction.STOP
|
||||||
|
)
|
||||||
|
self.events.append(("publish", info.mid))
|
||||||
|
self.events.append(
|
||||||
|
(
|
||||||
|
"message",
|
||||||
|
(MODELING_RESPONSE_TOPIC, _modeling_response(action)),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return info
|
||||||
|
|
||||||
|
fake = ModelingControlClient()
|
||||||
|
transport = ReviewedApplicationMqttTransport(
|
||||||
|
"192.168.1.20",
|
||||||
|
client_factory=lambda: cast(mqtt.Client, fake),
|
||||||
|
)
|
||||||
|
transport.open()
|
||||||
|
|
||||||
|
transport.exchange_batch_once(
|
||||||
|
[_modeling_envelope(ModelingAction.START)],
|
||||||
|
required_response_operation_keys={"modeling:start"},
|
||||||
|
)
|
||||||
|
fake.events.append(
|
||||||
|
(
|
||||||
|
"message",
|
||||||
|
(
|
||||||
|
MODELING_RESPONSE_TOPIC,
|
||||||
|
_modeling_response(ModelingAction.START),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
with pytest.raises(ApplicationCommandOutcomeUnknown, match="duplicate application response"):
|
||||||
|
transport.exchange_batch_once(
|
||||||
|
[_modeling_envelope(ModelingAction.STOP)],
|
||||||
|
required_response_operation_keys={"modeling:stop"},
|
||||||
|
)
|
||||||
|
|
||||||
|
snapshot = transport.snapshot()
|
||||||
|
assert snapshot.publish_attempts == 1
|
||||||
|
assert snapshot.correlated_responses == 1
|
||||||
|
assert snapshot.late_known_responses == 0
|
||||||
|
assert snapshot.state == "poisoned"
|
||||||
|
assert len(fake.publish_calls) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_unanswered_optional_status_does_not_steal_same_identity_required_refresh() -> None:
|
||||||
|
class ReusedStatusClient(FakeControlClient):
|
||||||
|
def __init__(self) -> None:
|
||||||
|
super().__init__(
|
||||||
|
emit_exchange=False,
|
||||||
|
response_topic=MODELING_STATUS_RESPONSE_TOPIC,
|
||||||
|
)
|
||||||
|
|
||||||
|
def publish(
|
||||||
|
self,
|
||||||
|
topic: str,
|
||||||
|
payload: bytes,
|
||||||
|
qos: int,
|
||||||
|
retain: bool,
|
||||||
|
) -> SimpleNamespace:
|
||||||
|
info = super().publish(topic, payload, qos, retain)
|
||||||
|
self.events.append(("publish", info.mid))
|
||||||
|
if len(self.publish_calls) == 2:
|
||||||
|
# Ordinals 12 and 14 intentionally reuse the same protocol
|
||||||
|
# identity. The capture has only one response after ordinal
|
||||||
|
# 14; unanswered optional ordinal 12 must not swallow it.
|
||||||
|
self.events.append(
|
||||||
|
("message", (MODELING_STATUS_RESPONSE_TOPIC, payload))
|
||||||
|
)
|
||||||
|
return info
|
||||||
|
|
||||||
|
fake = ReusedStatusClient()
|
||||||
|
transport = ReviewedApplicationMqttTransport(
|
||||||
|
"192.168.1.20",
|
||||||
|
client_factory=lambda: cast(mqtt.Client, fake),
|
||||||
|
)
|
||||||
|
transport.open()
|
||||||
|
immediate = _modeling_status_envelope()
|
||||||
|
refresh = _modeling_status_envelope("dialogue:14:ModelingStatusRequest")
|
||||||
|
|
||||||
|
transport.exchange_batch_once(
|
||||||
|
[immediate],
|
||||||
|
required_response_operation_keys=(),
|
||||||
|
)
|
||||||
|
responses = transport.exchange_batch_once(
|
||||||
|
[refresh],
|
||||||
|
required_response_operation_keys={refresh.operation_key},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert responses[refresh.operation_key] == refresh.payload
|
||||||
|
snapshot = transport.snapshot()
|
||||||
|
assert snapshot.publish_attempts == 2
|
||||||
|
assert snapshot.correlated_responses == 1
|
||||||
|
assert snapshot.ignored_known_responses == 0
|
||||||
|
assert snapshot.late_known_responses == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_already_arrived_optional_status_is_consumed_before_required_refresh() -> None:
|
||||||
|
class ReusedStatusClient(FakeControlClient):
|
||||||
|
def __init__(self) -> None:
|
||||||
|
super().__init__(
|
||||||
|
emit_exchange=False,
|
||||||
|
response_topic=MODELING_STATUS_RESPONSE_TOPIC,
|
||||||
|
)
|
||||||
|
|
||||||
|
def publish(
|
||||||
|
self,
|
||||||
|
topic: str,
|
||||||
|
payload: bytes,
|
||||||
|
qos: int,
|
||||||
|
retain: bool,
|
||||||
|
) -> SimpleNamespace:
|
||||||
|
info = super().publish(topic, payload, qos, retain)
|
||||||
|
self.events.append(("publish", info.mid))
|
||||||
|
if len(self.publish_calls) == 2:
|
||||||
|
self.events.append(
|
||||||
|
("message", (MODELING_STATUS_RESPONSE_TOPIC, payload))
|
||||||
|
)
|
||||||
|
return info
|
||||||
|
|
||||||
|
fake = ReusedStatusClient()
|
||||||
|
transport = ReviewedApplicationMqttTransport(
|
||||||
|
"192.168.1.20",
|
||||||
|
client_factory=lambda: cast(mqtt.Client, fake),
|
||||||
|
)
|
||||||
|
transport.open()
|
||||||
|
immediate = _modeling_status_envelope()
|
||||||
|
refresh = _modeling_status_envelope("dialogue:14:ModelingStatusRequest")
|
||||||
|
|
||||||
|
transport.exchange_batch_once(
|
||||||
|
[immediate],
|
||||||
|
required_response_operation_keys=(),
|
||||||
|
)
|
||||||
|
# This callback is already available before ordinal 14 is admitted. The
|
||||||
|
# retained socket's zero-wait service turn must consume it as ordinal 12.
|
||||||
|
fake.events.append(
|
||||||
|
("message", (MODELING_STATUS_RESPONSE_TOPIC, immediate.payload))
|
||||||
|
)
|
||||||
|
responses = transport.exchange_batch_once(
|
||||||
|
[refresh],
|
||||||
|
required_response_operation_keys={refresh.operation_key},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert responses[refresh.operation_key] == refresh.payload
|
||||||
|
snapshot = transport.snapshot()
|
||||||
|
assert snapshot.publish_attempts == 2
|
||||||
|
assert snapshot.correlated_responses == 1
|
||||||
|
assert snapshot.ignored_known_responses == 1
|
||||||
|
assert snapshot.late_known_responses == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_unbound_optional_status_remains_distinct_from_bound_required_refresh() -> None:
|
||||||
|
class DistinctStatusClient(FakeControlClient):
|
||||||
|
def __init__(self) -> None:
|
||||||
|
super().__init__(
|
||||||
|
emit_exchange=False,
|
||||||
|
response_topic=MODELING_STATUS_RESPONSE_TOPIC,
|
||||||
|
)
|
||||||
|
|
||||||
|
def publish(
|
||||||
|
self,
|
||||||
|
topic: str,
|
||||||
|
payload: bytes,
|
||||||
|
qos: int,
|
||||||
|
retain: bool,
|
||||||
|
) -> SimpleNamespace:
|
||||||
|
info = super().publish(topic, payload, qos, retain)
|
||||||
|
self.events.append(("publish", info.mid))
|
||||||
|
if len(self.publish_calls) == 2:
|
||||||
|
self.events.append(
|
||||||
|
(
|
||||||
|
"message",
|
||||||
|
(
|
||||||
|
MODELING_STATUS_RESPONSE_TOPIC,
|
||||||
|
_application_message(":ModelingStatusRequest"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self.events.append(
|
||||||
|
("message", (MODELING_STATUS_RESPONSE_TOPIC, payload))
|
||||||
|
)
|
||||||
|
return info
|
||||||
|
|
||||||
|
fake = DistinctStatusClient()
|
||||||
|
transport = ReviewedApplicationMqttTransport(
|
||||||
|
"192.168.1.20",
|
||||||
|
client_factory=lambda: cast(mqtt.Client, fake),
|
||||||
|
)
|
||||||
|
transport.open()
|
||||||
|
initial = _envelope(
|
||||||
|
"bootstrap:2:ModelingStatusRequest",
|
||||||
|
topic="lixel/application/request/modeling_status",
|
||||||
|
session_id=":ModelingStatusRequest",
|
||||||
|
)
|
||||||
|
refresh = _modeling_status_envelope("dialogue:14:ModelingStatusRequest")
|
||||||
|
|
||||||
|
transport.exchange_batch_once(
|
||||||
|
[initial],
|
||||||
|
required_response_operation_keys=(),
|
||||||
|
)
|
||||||
|
responses = transport.exchange_batch_once(
|
||||||
|
[refresh],
|
||||||
|
required_response_operation_keys={refresh.operation_key},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert responses[refresh.operation_key] == refresh.payload
|
||||||
|
snapshot = transport.snapshot()
|
||||||
|
assert snapshot.publish_attempts == 2
|
||||||
|
assert snapshot.correlated_responses == 1
|
||||||
|
assert snapshot.ignored_known_responses == 1
|
||||||
|
assert snapshot.late_known_responses == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_duplicate_current_application_response_fails_closed() -> None:
|
||||||
|
class DuplicateResponseClient(FakeControlClient):
|
||||||
|
def publish(
|
||||||
|
self,
|
||||||
|
topic: str,
|
||||||
|
payload: bytes,
|
||||||
|
qos: int,
|
||||||
|
retain: bool,
|
||||||
|
) -> SimpleNamespace:
|
||||||
|
info = super().publish(topic, payload, qos, retain)
|
||||||
|
self.events.append(("message", (self.response_topic, payload)))
|
||||||
|
return info
|
||||||
|
|
||||||
|
fake = DuplicateResponseClient()
|
||||||
|
transport = ReviewedApplicationMqttTransport(
|
||||||
|
"192.168.1.20",
|
||||||
|
client_factory=lambda: cast(mqtt.Client, fake),
|
||||||
|
)
|
||||||
|
transport.open()
|
||||||
|
|
||||||
|
with pytest.raises(
|
||||||
|
ApplicationCommandOutcomeUnknown,
|
||||||
|
match="duplicate application response",
|
||||||
|
) as raised:
|
||||||
|
transport.exchange_batch_once(
|
||||||
|
[_envelope()],
|
||||||
|
required_response_operation_keys={"bootstrap:1:DeviceInfoRequest"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert raised.value.reason_code == "duplicate_application_response"
|
||||||
|
assert transport.snapshot().state == "poisoned"
|
||||||
|
assert len(fake.publish_calls) == 1
|
||||||
|
|
||||||
|
|
||||||
def test_post_start_status_can_be_response_free_and_socket_is_continuously_serviced() -> None:
|
def test_post_start_status_can_be_response_free_and_socket_is_continuously_serviced() -> None:
|
||||||
clock = FakeClock()
|
clock = FakeClock()
|
||||||
fake = FakeControlClient(
|
fake = FakeControlClient(
|
||||||
|
|
@ -297,7 +669,7 @@ def test_post_start_status_can_be_response_free_and_socket_is_continuously_servi
|
||||||
|
|
||||||
responses = transport.exchange_batch_once(
|
responses = transport.exchange_batch_once(
|
||||||
[_modeling_status_envelope()],
|
[_modeling_status_envelope()],
|
||||||
required_response_topics=(),
|
required_response_operation_keys=(),
|
||||||
)
|
)
|
||||||
transport.maintain_open_for(
|
transport.maintain_open_for(
|
||||||
2.0,
|
2.0,
|
||||||
|
|
@ -386,7 +758,7 @@ def test_post_publish_timeout_poisoned_transport_never_retries() -> None:
|
||||||
with pytest.raises(ApplicationCommandOutcomeUnknown, match="automatic retry is forbidden"):
|
with pytest.raises(ApplicationCommandOutcomeUnknown, match="automatic retry is forbidden"):
|
||||||
transport.exchange_batch_once(
|
transport.exchange_batch_once(
|
||||||
[_envelope()],
|
[_envelope()],
|
||||||
required_response_topics={DEVICE_INFO_RESPONSE_TOPIC},
|
required_response_operation_keys={"bootstrap:1:DeviceInfoRequest"},
|
||||||
)
|
)
|
||||||
|
|
||||||
assert transport.snapshot().state == "poisoned"
|
assert transport.snapshot().state == "poisoned"
|
||||||
|
|
@ -394,6 +766,6 @@ def test_post_publish_timeout_poisoned_transport_never_retries() -> None:
|
||||||
with pytest.raises(ApplicationCommandOutcomeUnknown, match="already consumed"):
|
with pytest.raises(ApplicationCommandOutcomeUnknown, match="already consumed"):
|
||||||
transport.exchange_batch_once(
|
transport.exchange_batch_once(
|
||||||
[_envelope()],
|
[_envelope()],
|
||||||
required_response_topics={DEVICE_INFO_RESPONSE_TOPIC},
|
required_response_operation_keys={"bootstrap:1:DeviceInfoRequest"},
|
||||||
)
|
)
|
||||||
assert len(fake.publish_calls) == 1
|
assert len(fake.publish_calls) == 1
|
||||||
|
|
|
||||||
|
|
@ -40,7 +40,7 @@ def _envelope() -> OneShotPublishEnvelope:
|
||||||
software_version="V3.0.2-build.1",
|
software_version="V3.0.2-build.1",
|
||||||
system_version="V3.0.2",
|
system_version="V3.0.2",
|
||||||
device_model="LixelKity K1",
|
device_model="LixelKity K1",
|
||||||
device_type="K1",
|
device_type="A4",
|
||||||
is_activated=True,
|
is_activated=True,
|
||||||
)
|
)
|
||||||
request = build_shadow_bootstrap(
|
request = build_shadow_bootstrap(
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
|
@ -37,6 +38,11 @@ class FakeTransportSnapshot:
|
||||||
def as_dict(self) -> dict[str, object]:
|
def as_dict(self) -> dict[str, object]:
|
||||||
return {
|
return {
|
||||||
"state": self.state,
|
"state": self.state,
|
||||||
|
"publish_attempts": 0,
|
||||||
|
"qos2_completions": 0,
|
||||||
|
"correlated_responses": 0,
|
||||||
|
"ignored_known_responses": 0,
|
||||||
|
"late_known_responses": 0,
|
||||||
"latest_device_session_state": "ready" if self.ready else "scanning",
|
"latest_device_session_state": "ready" if self.ready else "scanning",
|
||||||
"latest_device_project_bound": not self.ready,
|
"latest_device_project_bound": not self.ready,
|
||||||
"automatic_retry": False,
|
"automatic_retry": False,
|
||||||
|
|
@ -71,8 +77,8 @@ class FakeExecutor:
|
||||||
device_serial="serial-id",
|
device_serial="serial-id",
|
||||||
software_version="3.0.2",
|
software_version="3.0.2",
|
||||||
system_version="3.0.2",
|
system_version="3.0.2",
|
||||||
device_model="K1",
|
device_model="LixelKity K1",
|
||||||
device_type="scanner",
|
device_type="A4",
|
||||||
is_activated=True,
|
is_activated=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -124,13 +130,17 @@ class FakeExecutor:
|
||||||
self.transport.ready = True
|
self.transport.ready = True
|
||||||
return object()
|
return object()
|
||||||
|
|
||||||
def maintain_post_stop_until_standby_confirmed(self, observed: Any) -> None:
|
def maintain_post_stop_until_standby(self) -> None:
|
||||||
self.records.append("wait:steady-green")
|
self.records.append("wait:device-standby")
|
||||||
while not observed():
|
|
||||||
threading.Event().wait(0.005)
|
|
||||||
|
|
||||||
def snapshot(self) -> dict[str, object]:
|
def snapshot(self) -> dict[str, object]:
|
||||||
return {"records": tuple(self.records), "automatic_retry": False}
|
return {
|
||||||
|
"records": list(self.records),
|
||||||
|
"dialogue_stage": "test-stage",
|
||||||
|
"start_attempted": False,
|
||||||
|
"stop_attempted": False,
|
||||||
|
"automatic_retry": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def _confirmation() -> OperatorPresenceConfirmation:
|
def _confirmation() -> OperatorPresenceConfirmation:
|
||||||
|
|
@ -156,7 +166,7 @@ def _wait_phase(
|
||||||
raise AssertionError(f"session did not reach {expected}: {session.snapshot()}")
|
raise AssertionError(f"session did not reach {expected}: {session.snapshot()}")
|
||||||
|
|
||||||
|
|
||||||
def test_every_canonical_stage_requires_a_separate_operator_event(
|
def test_canonical_stages_require_operator_events_but_device_standby_does_not(
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
) -> None:
|
) -> None:
|
||||||
FakeExecutor.records = []
|
FakeExecutor.records = []
|
||||||
|
|
@ -195,12 +205,9 @@ def test_every_canonical_stage_requires_a_separate_operator_event(
|
||||||
assert FakeExecutor.records[-1] == "wait:stop"
|
assert FakeExecutor.records[-1] == "wait:stop"
|
||||||
|
|
||||||
session.request_stop(confirmation=_confirmation())
|
session.request_stop(confirmation=_confirmation())
|
||||||
standby = _wait_phase(session, "awaiting-standby-confirmation")
|
|
||||||
assert standby["can_confirm_standby"] is True
|
|
||||||
assert FakeExecutor.records[-1] == "wait:steady-green"
|
|
||||||
|
|
||||||
session.confirm_standby()
|
|
||||||
completed = _wait_phase(session, "completed")
|
completed = _wait_phase(session, "completed")
|
||||||
|
assert completed["can_confirm_standby"] is False
|
||||||
|
assert completed["pending_operator_action"] is None
|
||||||
assert loader.calls == 1
|
assert loader.calls == 1
|
||||||
assert completed["automatic_retry"] is False
|
assert completed["automatic_retry"] is False
|
||||||
assert completed["scripted_transitions"] is False
|
assert completed["scripted_transitions"] is False
|
||||||
|
|
@ -214,5 +221,508 @@ def test_every_canonical_stage_requires_a_separate_operator_event(
|
||||||
"start:11-14",
|
"start:11-14",
|
||||||
"wait:stop",
|
"wait:stop",
|
||||||
"stop",
|
"stop",
|
||||||
"wait:steady-green",
|
"wait:device-standby",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_prestart_failure_is_reported_and_requires_a_new_operator_click(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
caplog: pytest.LogCaptureFixture,
|
||||||
|
) -> None:
|
||||||
|
caplog.set_level(logging.ERROR)
|
||||||
|
|
||||||
|
class BootstrapFailureExecutor(FakeExecutor):
|
||||||
|
def run_connection_stage(
|
||||||
|
self,
|
||||||
|
_orchestrator: object,
|
||||||
|
) -> LiveDeviceControlBinding:
|
||||||
|
raise RuntimeError("bootstrap correlation failed")
|
||||||
|
|
||||||
|
def snapshot(self) -> dict[str, object]:
|
||||||
|
return {
|
||||||
|
"dialogue_stage": "connection",
|
||||||
|
"start_attempted": False,
|
||||||
|
"stop_attempted": False,
|
||||||
|
"response_evidence": [],
|
||||||
|
"automatic_retry": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
session_module,
|
||||||
|
"PhysicalAcceptanceDialogueExecutor",
|
||||||
|
BootstrapFailureExecutor,
|
||||||
|
)
|
||||||
|
session = InteractiveApplicationControlSession(
|
||||||
|
FakeAuthorityLoader(),
|
||||||
|
transport_factory=lambda host: FakeTransport(host), # type: ignore[arg-type]
|
||||||
|
)
|
||||||
|
|
||||||
|
session.open(
|
||||||
|
host="192.168.1.20",
|
||||||
|
timezone_name="Europe/Moscow",
|
||||||
|
confirmation=_confirmation(),
|
||||||
|
)
|
||||||
|
_wait_phase(session, "failed")
|
||||||
|
failed_thread = session._thread # noqa: SLF001
|
||||||
|
assert failed_thread is not None
|
||||||
|
failed_thread.join(timeout=2.0)
|
||||||
|
assert not failed_thread.is_alive()
|
||||||
|
failed = session.snapshot()
|
||||||
|
|
||||||
|
assert failed["outcome_unknown"] is False
|
||||||
|
assert failed["can_open"] is True
|
||||||
|
assert failed["automatic_retry"] is False
|
||||||
|
assert failed["failure"] == {
|
||||||
|
"code": "RuntimeError",
|
||||||
|
"reason_code": "unexpected_runtime_error",
|
||||||
|
"message": "bootstrap correlation failed",
|
||||||
|
"failed_phase": "connecting",
|
||||||
|
"dialogue_stage": "connection",
|
||||||
|
"transport_state": "ready",
|
||||||
|
"publish_attempts": 0,
|
||||||
|
"qos2_completions": 0,
|
||||||
|
"correlated_responses": 0,
|
||||||
|
"ignored_known_responses": 0,
|
||||||
|
"late_known_responses": 0,
|
||||||
|
"modeling_command_attempted": False,
|
||||||
|
"diagnostic_snapshot_unavailable": [],
|
||||||
|
"diagnostic_evidence_unavailable": [],
|
||||||
|
"correlation_failure": None,
|
||||||
|
"compatibility_failure": None,
|
||||||
|
"safe_to_retry": True,
|
||||||
|
}
|
||||||
|
assert "reason_code=unexpected_runtime_error" in caplog.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_correlated_read_only_profile_mismatch_allows_only_a_fresh_explicit_attempt(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
class ProfileMismatchError(RuntimeError):
|
||||||
|
reason_code = "compatibility_profile_mismatch"
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class CorrelatedReadSnapshot:
|
||||||
|
state: str
|
||||||
|
|
||||||
|
def as_dict(self) -> dict[str, object]:
|
||||||
|
return {
|
||||||
|
"state": self.state,
|
||||||
|
"publish_attempts": 1,
|
||||||
|
"qos2_completions": 1,
|
||||||
|
"correlated_responses": 1,
|
||||||
|
"ignored_known_responses": 0,
|
||||||
|
"late_known_responses": 0,
|
||||||
|
"automatic_retry": False,
|
||||||
|
"automatic_reconnect": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
class CorrelatedReadTransport(FakeTransport):
|
||||||
|
def snapshot(self) -> CorrelatedReadSnapshot:
|
||||||
|
return CorrelatedReadSnapshot(self.state)
|
||||||
|
|
||||||
|
class ProfileMismatchExecutor(FakeExecutor):
|
||||||
|
def run_connection_stage(
|
||||||
|
self,
|
||||||
|
_orchestrator: object,
|
||||||
|
) -> LiveDeviceControlBinding:
|
||||||
|
raise ProfileMismatchError("live DeviceInfo profile mismatch")
|
||||||
|
|
||||||
|
def snapshot(self) -> dict[str, object]:
|
||||||
|
return {
|
||||||
|
"dialogue_stage": "new",
|
||||||
|
"start_attempted": False,
|
||||||
|
"stop_attempted": False,
|
||||||
|
"response_evidence": [],
|
||||||
|
"correlation_failure": None,
|
||||||
|
"compatibility_failure": {
|
||||||
|
"operation_key": "bootstrap:1:DeviceInfoRequest",
|
||||||
|
"reason_code": "compatibility_profile_mismatch",
|
||||||
|
},
|
||||||
|
"automatic_retry": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
session_module,
|
||||||
|
"PhysicalAcceptanceDialogueExecutor",
|
||||||
|
ProfileMismatchExecutor,
|
||||||
|
)
|
||||||
|
session = InteractiveApplicationControlSession(
|
||||||
|
FakeAuthorityLoader(),
|
||||||
|
transport_factory=lambda host: CorrelatedReadTransport(host), # type: ignore[arg-type]
|
||||||
|
)
|
||||||
|
|
||||||
|
session.open(
|
||||||
|
host="192.168.1.20",
|
||||||
|
timezone_name="Europe/Moscow",
|
||||||
|
confirmation=_confirmation(),
|
||||||
|
)
|
||||||
|
failed_thread = session._thread # noqa: SLF001
|
||||||
|
assert failed_thread is not None
|
||||||
|
failed_thread.join(timeout=2.0)
|
||||||
|
assert not failed_thread.is_alive()
|
||||||
|
|
||||||
|
failed = session.snapshot()
|
||||||
|
assert failed["state"] == "failed"
|
||||||
|
assert failed["outcome_unknown"] is False
|
||||||
|
assert failed["automatic_retry"] is False
|
||||||
|
assert failed["can_open"] is True
|
||||||
|
failure = failed["failure"]
|
||||||
|
assert isinstance(failure, dict)
|
||||||
|
assert failure["reason_code"] == "compatibility_profile_mismatch"
|
||||||
|
assert failure["modeling_command_attempted"] is False
|
||||||
|
assert failure["safe_to_retry"] is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_new_explicit_session_waits_for_old_worker_transport_retirement(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
old_close_entered = threading.Event()
|
||||||
|
release_old_close = threading.Event()
|
||||||
|
transports: list[TaggedTransport] = []
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class TaggedTransportSnapshot:
|
||||||
|
state: str
|
||||||
|
ready: bool
|
||||||
|
tag: str
|
||||||
|
|
||||||
|
def as_dict(self) -> dict[str, object]:
|
||||||
|
return {
|
||||||
|
"state": self.state,
|
||||||
|
"tag": self.tag,
|
||||||
|
"publish_attempts": 0,
|
||||||
|
"qos2_completions": 0,
|
||||||
|
"correlated_responses": 0,
|
||||||
|
"ignored_known_responses": 0,
|
||||||
|
"late_known_responses": 0,
|
||||||
|
"latest_device_session_state": "ready" if self.ready else "scanning",
|
||||||
|
"latest_device_project_bound": not self.ready,
|
||||||
|
"automatic_retry": False,
|
||||||
|
"automatic_reconnect": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
class TaggedTransport(FakeTransport):
|
||||||
|
def __init__(self, host: str, tag: str) -> None:
|
||||||
|
super().__init__(host)
|
||||||
|
self.tag = tag
|
||||||
|
self.close_calls = 0
|
||||||
|
|
||||||
|
def close(self) -> None:
|
||||||
|
if self.tag == "generation-1":
|
||||||
|
old_close_entered.set()
|
||||||
|
assert release_old_close.wait(timeout=2.0)
|
||||||
|
self.close_calls += 1
|
||||||
|
super().close()
|
||||||
|
|
||||||
|
def snapshot(self) -> TaggedTransportSnapshot:
|
||||||
|
return TaggedTransportSnapshot(self.state, self.ready, self.tag)
|
||||||
|
|
||||||
|
class RacingExecutor(FakeExecutor):
|
||||||
|
instances = 0
|
||||||
|
|
||||||
|
def __init__(self, transport: TaggedTransport) -> None:
|
||||||
|
super().__init__(transport)
|
||||||
|
type(self).instances += 1
|
||||||
|
self.instance = type(self).instances
|
||||||
|
|
||||||
|
def run_connection_stage(
|
||||||
|
self,
|
||||||
|
orchestrator: object,
|
||||||
|
) -> LiveDeviceControlBinding:
|
||||||
|
if self.instance == 1:
|
||||||
|
raise RuntimeError("first generation failed before publish")
|
||||||
|
return super().run_connection_stage(orchestrator)
|
||||||
|
|
||||||
|
def transport_factory(host: str) -> TaggedTransport:
|
||||||
|
transport = TaggedTransport(host, f"generation-{len(transports) + 1}")
|
||||||
|
transports.append(transport)
|
||||||
|
return transport
|
||||||
|
|
||||||
|
FakeExecutor.records = []
|
||||||
|
monkeypatch.setattr(
|
||||||
|
session_module,
|
||||||
|
"PhysicalAcceptanceDialogueExecutor",
|
||||||
|
RacingExecutor,
|
||||||
|
)
|
||||||
|
session = InteractiveApplicationControlSession(
|
||||||
|
FakeAuthorityLoader(),
|
||||||
|
transport_factory=transport_factory, # type: ignore[arg-type]
|
||||||
|
)
|
||||||
|
|
||||||
|
session.open(
|
||||||
|
host="192.168.1.20",
|
||||||
|
timezone_name="Europe/Moscow",
|
||||||
|
confirmation=_confirmation(),
|
||||||
|
)
|
||||||
|
_wait_phase(session, "failed")
|
||||||
|
assert old_close_entered.wait(timeout=2.0)
|
||||||
|
old_thread = session._thread # noqa: SLF001
|
||||||
|
assert old_thread is not None
|
||||||
|
|
||||||
|
# The failure is safe to retry at the protocol level, but the old worker
|
||||||
|
# still owns its socket. A new explicit click must not construct or open a
|
||||||
|
# second transport until close() completes and the worker exits.
|
||||||
|
retiring = session.snapshot()
|
||||||
|
assert retiring["failure"]["safe_to_retry"] is True # type: ignore[index]
|
||||||
|
assert retiring["can_open"] is False
|
||||||
|
with pytest.raises(
|
||||||
|
session_module.ApplicationAcceptanceError,
|
||||||
|
match="already open or requires manual recovery",
|
||||||
|
):
|
||||||
|
session.open(
|
||||||
|
host="192.168.1.20",
|
||||||
|
timezone_name="Europe/Moscow",
|
||||||
|
confirmation=_confirmation(),
|
||||||
|
)
|
||||||
|
assert len(transports) == 1
|
||||||
|
assert transports[0].close_calls == 0
|
||||||
|
|
||||||
|
release_old_close.set()
|
||||||
|
old_thread.join(timeout=2.0)
|
||||||
|
assert not old_thread.is_alive()
|
||||||
|
retired = session.snapshot()
|
||||||
|
assert retired["state"] == "failed"
|
||||||
|
assert retired["can_open"] is True
|
||||||
|
assert transports[0].close_calls == 1
|
||||||
|
|
||||||
|
# Only a fresh explicit action after retirement creates generation 2.
|
||||||
|
session.open(
|
||||||
|
host="192.168.1.20",
|
||||||
|
timezone_name="Europe/Moscow",
|
||||||
|
confirmation=_confirmation(),
|
||||||
|
)
|
||||||
|
new_thread = session._thread # noqa: SLF001
|
||||||
|
assert new_thread is not None
|
||||||
|
current = _wait_phase(session, "connection-ready")
|
||||||
|
assert current["transport"]["tag"] == "generation-2" # type: ignore[index]
|
||||||
|
assert session._transport is transports[1] # noqa: SLF001
|
||||||
|
assert transports[1].close_calls == 0
|
||||||
|
|
||||||
|
# Complete the second synthetic session so no background waiter remains.
|
||||||
|
session.enter_workspace()
|
||||||
|
_wait_phase(session, "workspace-ready")
|
||||||
|
session.open_project_prompt()
|
||||||
|
_wait_phase(session, "project-ready")
|
||||||
|
session.request_start(project_name="TEST002", confirmation=_confirmation())
|
||||||
|
_wait_phase(session, "scanning")
|
||||||
|
session.request_stop(confirmation=_confirmation())
|
||||||
|
_wait_phase(session, "completed")
|
||||||
|
new_thread.join(timeout=2.0)
|
||||||
|
assert not new_thread.is_alive()
|
||||||
|
assert transports[1].close_calls == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_start_outcome_unknown_blocks_reopen_even_when_transport_is_closed(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
class StartFailureExecutor(FakeExecutor):
|
||||||
|
start_attempted = False
|
||||||
|
|
||||||
|
def execute_canonical_start(self, *_args: object, **_kwargs: object) -> object:
|
||||||
|
type(self).start_attempted = True
|
||||||
|
raise session_module.ApplicationCommandOutcomeUnknown(
|
||||||
|
"START response was not correlated"
|
||||||
|
)
|
||||||
|
|
||||||
|
def snapshot(self) -> dict[str, object]:
|
||||||
|
return {
|
||||||
|
"dialogue_stage": "start-attempted",
|
||||||
|
"start_attempted": type(self).start_attempted,
|
||||||
|
"stop_attempted": False,
|
||||||
|
"response_evidence": [],
|
||||||
|
"automatic_retry": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
FakeExecutor.records = []
|
||||||
|
StartFailureExecutor.start_attempted = False
|
||||||
|
monkeypatch.setattr(
|
||||||
|
session_module,
|
||||||
|
"PhysicalAcceptanceDialogueExecutor",
|
||||||
|
StartFailureExecutor,
|
||||||
|
)
|
||||||
|
session = InteractiveApplicationControlSession(
|
||||||
|
FakeAuthorityLoader(),
|
||||||
|
transport_factory=lambda host: FakeTransport(host), # type: ignore[arg-type]
|
||||||
|
)
|
||||||
|
|
||||||
|
session.open(
|
||||||
|
host="192.168.1.20",
|
||||||
|
timezone_name="Europe/Moscow",
|
||||||
|
confirmation=_confirmation(),
|
||||||
|
)
|
||||||
|
_wait_phase(session, "connection-ready")
|
||||||
|
session.enter_workspace()
|
||||||
|
_wait_phase(session, "workspace-ready")
|
||||||
|
session.open_project_prompt()
|
||||||
|
_wait_phase(session, "project-ready")
|
||||||
|
session.request_start(project_name="TEST001", confirmation=_confirmation())
|
||||||
|
failed = _wait_phase(session, "failed")
|
||||||
|
|
||||||
|
assert failed["outcome_unknown"] is True
|
||||||
|
assert failed["can_open"] is False
|
||||||
|
assert failed["failure"]["modeling_command_attempted"] is True # type: ignore[index]
|
||||||
|
assert failed["failure"]["safe_to_retry"] is False # type: ignore[index]
|
||||||
|
|
||||||
|
|
||||||
|
def test_unavailable_transport_snapshot_after_publish_blocks_reopen(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
executor_entered = threading.Event()
|
||||||
|
release_failure = threading.Event()
|
||||||
|
|
||||||
|
class SnapshotFailureTransport(FakeTransport):
|
||||||
|
def __init__(self, host: str) -> None:
|
||||||
|
super().__init__(host)
|
||||||
|
self.simulated_publish_attempts = 0
|
||||||
|
self.snapshot_unavailable = False
|
||||||
|
|
||||||
|
def snapshot(self) -> FakeTransportSnapshot:
|
||||||
|
if self.snapshot_unavailable:
|
||||||
|
raise RuntimeError("simulated transport snapshot failure")
|
||||||
|
return super().snapshot()
|
||||||
|
|
||||||
|
class PublishedThenFailedExecutor(FakeExecutor):
|
||||||
|
def run_connection_stage(
|
||||||
|
self,
|
||||||
|
_orchestrator: object,
|
||||||
|
) -> LiveDeviceControlBinding:
|
||||||
|
executor_entered.set()
|
||||||
|
assert release_failure.wait(timeout=2.0)
|
||||||
|
assert isinstance(self.transport, SnapshotFailureTransport)
|
||||||
|
self.transport.simulated_publish_attempts = 1
|
||||||
|
self.transport.snapshot_unavailable = True
|
||||||
|
raise RuntimeError("bootstrap failed after simulated publish")
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
session_module,
|
||||||
|
"PhysicalAcceptanceDialogueExecutor",
|
||||||
|
PublishedThenFailedExecutor,
|
||||||
|
)
|
||||||
|
transport = SnapshotFailureTransport("192.168.1.20")
|
||||||
|
session = InteractiveApplicationControlSession(
|
||||||
|
FakeAuthorityLoader(),
|
||||||
|
transport_factory=lambda _host: transport, # type: ignore[arg-type]
|
||||||
|
)
|
||||||
|
|
||||||
|
session.open(
|
||||||
|
host="192.168.1.20",
|
||||||
|
timezone_name="Europe/Moscow",
|
||||||
|
confirmation=_confirmation(),
|
||||||
|
)
|
||||||
|
assert executor_entered.wait(timeout=2.0)
|
||||||
|
failed_thread = session._thread # noqa: SLF001
|
||||||
|
assert failed_thread is not None
|
||||||
|
release_failure.set()
|
||||||
|
failed_thread.join(timeout=2.0)
|
||||||
|
assert not failed_thread.is_alive()
|
||||||
|
failed = session.snapshot()
|
||||||
|
|
||||||
|
assert transport.simulated_publish_attempts == 1
|
||||||
|
assert failed["state"] == "failed"
|
||||||
|
assert failed["outcome_unknown"] is True
|
||||||
|
assert failed["can_open"] is False
|
||||||
|
failure = failed["failure"]
|
||||||
|
assert isinstance(failure, dict)
|
||||||
|
assert failure["transport_state"] is None
|
||||||
|
assert failure["publish_attempts"] is None
|
||||||
|
assert failure["diagnostic_snapshot_unavailable"] == ["transport"]
|
||||||
|
assert failure["diagnostic_evidence_unavailable"] == ["transport.publish_attempts"]
|
||||||
|
assert failure["modeling_command_attempted"] is False
|
||||||
|
assert failure["safe_to_retry"] is False
|
||||||
|
with pytest.raises(
|
||||||
|
session_module.ApplicationAcceptanceError,
|
||||||
|
match="already open or requires manual recovery",
|
||||||
|
):
|
||||||
|
session.open(
|
||||||
|
host="192.168.1.20",
|
||||||
|
timezone_name="Europe/Moscow",
|
||||||
|
confirmation=_confirmation(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_unavailable_dialogue_snapshot_is_outcome_unknown(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
class DialogueSnapshotFailureExecutor(FakeExecutor):
|
||||||
|
def run_connection_stage(
|
||||||
|
self,
|
||||||
|
_orchestrator: object,
|
||||||
|
) -> LiveDeviceControlBinding:
|
||||||
|
raise RuntimeError("bootstrap failed with unavailable dialogue evidence")
|
||||||
|
|
||||||
|
def snapshot(self) -> dict[str, object]:
|
||||||
|
raise RuntimeError("simulated dialogue snapshot failure")
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
session_module,
|
||||||
|
"PhysicalAcceptanceDialogueExecutor",
|
||||||
|
DialogueSnapshotFailureExecutor,
|
||||||
|
)
|
||||||
|
session = InteractiveApplicationControlSession(
|
||||||
|
FakeAuthorityLoader(),
|
||||||
|
transport_factory=lambda host: FakeTransport(host), # type: ignore[arg-type]
|
||||||
|
)
|
||||||
|
|
||||||
|
session.open(
|
||||||
|
host="192.168.1.20",
|
||||||
|
timezone_name="Europe/Moscow",
|
||||||
|
confirmation=_confirmation(),
|
||||||
|
)
|
||||||
|
failed_thread = session._thread # noqa: SLF001
|
||||||
|
assert failed_thread is not None
|
||||||
|
failed_thread.join(timeout=2.0)
|
||||||
|
assert not failed_thread.is_alive()
|
||||||
|
failed = session.snapshot()
|
||||||
|
|
||||||
|
assert failed["state"] == "failed"
|
||||||
|
assert failed["outcome_unknown"] is True
|
||||||
|
assert failed["can_open"] is False
|
||||||
|
failure = failed["failure"]
|
||||||
|
assert isinstance(failure, dict)
|
||||||
|
assert failure["publish_attempts"] == 0
|
||||||
|
assert failure["modeling_command_attempted"] is None
|
||||||
|
assert failure["diagnostic_snapshot_unavailable"] == ["dialogue"]
|
||||||
|
assert failure["diagnostic_evidence_unavailable"] == ["dialogue.modeling_command_attempted"]
|
||||||
|
assert failure["safe_to_retry"] is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_pretransport_authority_failure_remains_safe_after_worker_retirement() -> None:
|
||||||
|
class FailingAuthorityLoader(FakeAuthorityLoader):
|
||||||
|
def load(self) -> ApplicationControlAuthority:
|
||||||
|
self.calls += 1
|
||||||
|
raise RuntimeError("authority unavailable before transport creation")
|
||||||
|
|
||||||
|
transport_factory_calls = 0
|
||||||
|
|
||||||
|
def transport_factory(host: str) -> FakeTransport:
|
||||||
|
nonlocal transport_factory_calls
|
||||||
|
transport_factory_calls += 1
|
||||||
|
return FakeTransport(host)
|
||||||
|
|
||||||
|
session = InteractiveApplicationControlSession(
|
||||||
|
FailingAuthorityLoader(),
|
||||||
|
transport_factory=transport_factory, # type: ignore[arg-type]
|
||||||
|
)
|
||||||
|
|
||||||
|
session.open(
|
||||||
|
host="192.168.1.20",
|
||||||
|
timezone_name="Europe/Moscow",
|
||||||
|
confirmation=_confirmation(),
|
||||||
|
)
|
||||||
|
failed_thread = session._thread # noqa: SLF001
|
||||||
|
assert failed_thread is not None
|
||||||
|
failed_thread.join(timeout=2.0)
|
||||||
|
assert not failed_thread.is_alive()
|
||||||
|
failed = session.snapshot()
|
||||||
|
|
||||||
|
assert transport_factory_calls == 0
|
||||||
|
assert failed["state"] == "failed"
|
||||||
|
assert failed["outcome_unknown"] is False
|
||||||
|
assert failed["can_open"] is True
|
||||||
|
failure = failed["failure"]
|
||||||
|
assert isinstance(failure, dict)
|
||||||
|
assert failure["publish_attempts"] is None
|
||||||
|
assert failure["modeling_command_attempted"] is False
|
||||||
|
assert failure["diagnostic_snapshot_unavailable"] == []
|
||||||
|
assert failure["diagnostic_evidence_unavailable"] == []
|
||||||
|
assert failure["safe_to_retry"] is True
|
||||||
|
|
|
||||||
|
|
@ -64,6 +64,23 @@ def _burst_ffmpeg(tmp_path: Path) -> Path:
|
||||||
return executable
|
return executable
|
||||||
|
|
||||||
|
|
||||||
|
def _clean_source_end_ffmpeg(tmp_path: Path) -> Path:
|
||||||
|
executable = tmp_path / "clean-source-end-ffmpeg"
|
||||||
|
executable.write_text(
|
||||||
|
f"#!{sys.executable}\n"
|
||||||
|
"import sys, time\n"
|
||||||
|
"def box(kind, payload=b''):\n"
|
||||||
|
" return (8 + len(payload)).to_bytes(4, 'big') + kind + payload\n"
|
||||||
|
"sys.stdout.buffer.write(box(b'ftyp', b'isom') + box(b'moov'))\n"
|
||||||
|
"sys.stdout.buffer.write(box(b'moof') + box(b'mdat', b'final-frame'))\n"
|
||||||
|
"sys.stdout.buffer.flush()\n"
|
||||||
|
"time.sleep(0.15)\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
executable.chmod(0o700)
|
||||||
|
return executable
|
||||||
|
|
||||||
|
|
||||||
def _buffered_tail_ffmpeg(tmp_path: Path, sentinel: Path, *, fragments: int) -> Path:
|
def _buffered_tail_ffmpeg(tmp_path: Path, sentinel: Path, *, fragments: int) -> Path:
|
||||||
executable = tmp_path / "buffered-tail-ffmpeg"
|
executable = tmp_path / "buffered-tail-ffmpeg"
|
||||||
executable.write_text(
|
executable.write_text(
|
||||||
|
|
@ -289,6 +306,76 @@ def test_acquisition_records_without_browser_and_source_switch_seals_epochs(
|
||||||
gateway.close()
|
gateway.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_expected_camera_source_end_during_device_stop_seals_complete_epoch(
|
||||||
|
tmp_path: Path,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
monkeypatch.setenv(
|
||||||
|
"MISSIONCORE_FFMPEG_BINARY",
|
||||||
|
str(_clean_source_end_ffmpeg(tmp_path)),
|
||||||
|
)
|
||||||
|
gateway = XgridsK1CameraGateway(tmp_path, XGRIDS_K1_PLUGIN_ID)
|
||||||
|
session = tmp_path / "session"
|
||||||
|
session.mkdir()
|
||||||
|
try:
|
||||||
|
selected = gateway.select("sensor.camera.left", "192.168.1.20")
|
||||||
|
gateway.start_recording(session)
|
||||||
|
gateway.expect_source_end_for_device_stop()
|
||||||
|
|
||||||
|
_wait_until(
|
||||||
|
lambda: gateway.snapshot()["recording"]["completed_epochs"] == 1,
|
||||||
|
)
|
||||||
|
state = gateway.snapshot()
|
||||||
|
assert state["phase"] == "idle"
|
||||||
|
assert state["error"] is None
|
||||||
|
assert state["delivery"] is None
|
||||||
|
assert state["recording"]["active"] is True
|
||||||
|
assert state["recording"]["source_end_expected"] is False
|
||||||
|
|
||||||
|
gateway.stop_recording(status="complete")
|
||||||
|
epoch = (
|
||||||
|
session
|
||||||
|
/ "media"
|
||||||
|
/ "sensor.camera.left"
|
||||||
|
/ f"epoch-{selected['generation']}"
|
||||||
|
)
|
||||||
|
summary = json.loads((epoch / "summary.json").read_text(encoding="utf-8"))
|
||||||
|
assert summary["status"] == "complete"
|
||||||
|
assert summary["failure_code"] is None
|
||||||
|
assert summary["media_segment_count"] == 1
|
||||||
|
finally:
|
||||||
|
gateway.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_unexpected_camera_source_end_remains_a_recording_failure(
|
||||||
|
tmp_path: Path,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
monkeypatch.setenv(
|
||||||
|
"MISSIONCORE_FFMPEG_BINARY",
|
||||||
|
str(_clean_source_end_ffmpeg(tmp_path)),
|
||||||
|
)
|
||||||
|
gateway = XgridsK1CameraGateway(tmp_path, XGRIDS_K1_PLUGIN_ID)
|
||||||
|
session = tmp_path / "session"
|
||||||
|
session.mkdir()
|
||||||
|
try:
|
||||||
|
selected = gateway.select("sensor.camera.left", "192.168.1.20")
|
||||||
|
gateway.start_recording(session)
|
||||||
|
|
||||||
|
_wait_until(lambda: gateway.snapshot()["phase"] == "error")
|
||||||
|
epoch = (
|
||||||
|
session
|
||||||
|
/ "media"
|
||||||
|
/ "sensor.camera.left"
|
||||||
|
/ f"epoch-{selected['generation']}"
|
||||||
|
)
|
||||||
|
summary = json.loads((epoch / "summary.json").read_text(encoding="utf-8"))
|
||||||
|
assert summary["status"] == "interrupted"
|
||||||
|
assert summary["failure_code"] == "camera-source-ended"
|
||||||
|
finally:
|
||||||
|
gateway.close()
|
||||||
|
|
||||||
|
|
||||||
def test_camera_storage_open_failure_is_loud_and_never_starts_ffmpeg(
|
def test_camera_storage_open_failure_is_loud_and_never_starts_ffmpeg(
|
||||||
tmp_path: Path,
|
tmp_path: Path,
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
|
@ -501,7 +588,8 @@ def test_service_publishes_two_dynamic_camera_rows_and_stale_stop_is_safe(
|
||||||
service._compatibility_attestation = {
|
service._compatibility_attestation = {
|
||||||
"firmware_version": "3.0.2",
|
"firmware_version": "3.0.2",
|
||||||
"topology": "direct-lan",
|
"topology": "direct-lan",
|
||||||
"basis": "operator-attested",
|
"verification": "live-device-info",
|
||||||
|
"basis": "selected-profile-live-device-info-required",
|
||||||
"observed_at": "2026-07-16T20:00:00Z",
|
"observed_at": "2026-07-16T20:00:00Z",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -48,6 +48,7 @@ def test_xgrids_compatibility_profile_loads_exact_firmware_and_sources() -> None
|
||||||
assert profile["profile_id"] == "xgrids.lixelkity-k1.fw-3.0.2.direct-lan.v1"
|
assert profile["profile_id"] == "xgrids.lixelkity-k1.fw-3.0.2.direct-lan.v1"
|
||||||
assert profile["scope"]["vendor"] == "XGRIDS"
|
assert profile["scope"]["vendor"] == "XGRIDS"
|
||||||
assert profile["scope"]["model"] == "LixelKity K1"
|
assert profile["scope"]["model"] == "LixelKity K1"
|
||||||
|
assert profile["scope"]["platform_type"] == "A4"
|
||||||
assert profile["scope"]["firmware"] == {"match": "exact", "version": "3.0.2"}
|
assert profile["scope"]["firmware"] == {"match": "exact", "version": "3.0.2"}
|
||||||
assert profile["scope"]["topology"] == "direct-lan"
|
assert profile["scope"]["topology"] == "direct-lan"
|
||||||
assert LOADER.matches_target(profile, firmware="3.0.2", topology="direct-lan")
|
assert LOADER.matches_target(profile, firmware="3.0.2", topology="direct-lan")
|
||||||
|
|
@ -146,6 +147,15 @@ def test_xgrids_compatibility_profile_maps_actions_without_enabling_writes() ->
|
||||||
assert profile["safety"]["default_mode"] == "read-only"
|
assert profile["safety"]["default_mode"] == "read-only"
|
||||||
assert profile["safety"]["vendor_writes_enabled"] is False
|
assert profile["safety"]["vendor_writes_enabled"] is False
|
||||||
assert control["mode"] == "operator-manual"
|
assert control["mode"] == "operator-manual"
|
||||||
|
assert control["software_acceptance_transport"] == {
|
||||||
|
"status": "installed-operator-present",
|
||||||
|
"default_authority": "disabled",
|
||||||
|
"profile_gate": "live-device-info-exact-match",
|
||||||
|
"dialogue": "single-socket-canonical-start-to-stop",
|
||||||
|
"automatic_retry": False,
|
||||||
|
"supported_mount_type": "handheld",
|
||||||
|
"supported_gnss_mode": "none",
|
||||||
|
}
|
||||||
assert control["verified_device_control"]["gesture"] == "physical-double-click"
|
assert control["verified_device_control"]["gesture"] == "physical-double-click"
|
||||||
|
|
||||||
for action_id, action_code in (("acquisition.start", 1), ("acquisition.stop", 2)):
|
for action_id, action_code in (("acquisition.start", 1), ("acquisition.stop", 2)):
|
||||||
|
|
|
||||||
|
|
@ -75,7 +75,7 @@ def _binding(**changes: object) -> LiveDeviceControlBinding:
|
||||||
"software_version": "V3.0.2-build.1",
|
"software_version": "V3.0.2-build.1",
|
||||||
"system_version": "V3.0.2",
|
"system_version": "V3.0.2",
|
||||||
"device_model": "LixelKity K1",
|
"device_model": "LixelKity K1",
|
||||||
"device_type": "K1",
|
"device_type": "A4",
|
||||||
"is_activated": True,
|
"is_activated": True,
|
||||||
}
|
}
|
||||||
values.update(changes)
|
values.update(changes)
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue