Compare commits
No commits in common. "fecb5885d0663a7d0d15239c6afa608693a49172" and "d7a2c22faf2c8d8b5348a1503efb4ff056f2fb98" have entirely different histories.
fecb5885d0
...
d7a2c22faf
36
README.md
36
README.md
|
|
@ -27,9 +27,8 @@ The K1 acquisition now also requires an operator project name. Frontend and
|
|||
backend normalize it with NFKC plus surrounding-whitespace trimming, reject
|
||||
control/surrogate characters and values above 96 Unicode characters, and
|
||||
preserve it as local session/catalog display metadata. It is never used as a
|
||||
filesystem path. The physically accepted interactive control path carries the
|
||||
same validated value in its single canonical START; there is no separate
|
||||
project-name write.
|
||||
filesystem path. Because K1 command publishing is disabled, the current runtime
|
||||
does not claim that this value has been transmitted to the scanner.
|
||||
|
||||
Plugin SDK v0alpha2 now provides executable, vendor-neutral identity, session,
|
||||
operation, runtime-action, stream, evidence and compatibility contracts. Every
|
||||
|
|
@ -43,10 +42,10 @@ normalizer to produce transport-neutral local consumer views; portable SDK
|
|||
stream envelopes, process isolation, durable operations, multi-device routing
|
||||
and the remote Edge split remain later gates.
|
||||
|
||||
The application-control bootstrap reads and correlates live `DeviceInfo` before
|
||||
START. The exact profile requires model `LixelKity K1`, observed platform type
|
||||
`A4`, activated state, firmware `3.0.2`, and direct-LAN topology. The UI selects
|
||||
that profile but does not ask the operator to attest firmware manually.
|
||||
The current runtime cannot read K1 firmware automatically. It keeps the exact
|
||||
profile inactive until the operator explicitly attests firmware `3.0.2` and
|
||||
direct-LAN topology; state records that basis as `operator-attested`, not as
|
||||
device-derived evidence.
|
||||
|
||||
The repository now contains one narrowly gated state-changing command:
|
||||
`ble wifi-configure`. It accepts only the reviewed firmware-3 provisioning
|
||||
|
|
@ -139,8 +138,7 @@ local reception; plugin-commanded v0.5.0 acquisition uses the separately gated
|
|||
canonical K1 START/STOP dialogue.
|
||||
|
||||
The exact recovered `ModelingRequest` start/stop encoder, response correlator
|
||||
and device-status state machine are installed in the operator-gated interactive
|
||||
control path.
|
||||
and device-status state machine are implemented as inert protocol components.
|
||||
The plugin also contains the exact ten-request pre-START bootstrap recovered
|
||||
from retained LixelGO traffic. Its first `DeviceInfoRequest` has no device ID;
|
||||
the response supplies live vendor identity, serial, model, activation and
|
||||
|
|
@ -159,7 +157,7 @@ keepalive 60, exact
|
|||
response subscriptions, QoS2 completion and the five response-gated batches.
|
||||
It consumes operation keys before publish and poisons unknown outcomes without
|
||||
retry. One background owner services that socket through response-gated
|
||||
connection, workspace, project, START, STOP and device-standby stages. The normal
|
||||
connection, workspace, project, START, STOP and steady-green stages. The normal
|
||||
UI collapses only the pre-START controls into one explicit operator intent;
|
||||
navigation cannot emit a command. The first operator-present physical attempt on 2026-07-18 emitted only
|
||||
the first six bootstrap requests, then failed closed while correlating the third
|
||||
|
|
@ -174,13 +172,11 @@ workspace-entry request 7, project-prompt reads 8–10, user-confirmed START 11,
|
|||
immediate status read 12 and state-gated reads 13–14 onto one continuously owned
|
||||
MQTT session. No captured operator delay is a protocol timer: the final reads
|
||||
wait for live `SCANNING`, project binding and `init_ready`, while STOP/save waits
|
||||
for live unbound `READY`. Standalone START and STOP
|
||||
for live `READY` plus physical standby confirmation. Standalone START and STOP
|
||||
are both rejected. The same socket is serviced throughout launch and STOP
|
||||
confirmation, and no captured human delay is replayed as a protocol timer.
|
||||
Plugin v0.5.0 wires this executor to the facade and plugin UI. The full staged
|
||||
START/live/STOP/save cycle is physically accepted on the reviewed unit. START
|
||||
and STOP each retain an explicit operator action; final local sealing now follows
|
||||
the scanner's protocol-reported unbound READY without a redundant second click.
|
||||
Plugin v0.5.0 wires this executor to the facade and plugin UI; physical command
|
||||
acceptance remains an operator-run gate.
|
||||
|
||||
This locked bootstrap is repeatable in the current workspace, not yet a
|
||||
standalone release install. The frontend consumes sibling `file:` packages from
|
||||
|
|
@ -318,9 +314,7 @@ present.
|
|||
- [Device-plugin observation runtime and K1 extraction](docs/adr/0009-device-plugin-observation-runtime-and-k1-extraction.md)
|
||||
- [Plugin-owned frontend device workflows](docs/adr/0010-plugin-owned-frontend-device-workflows.md)
|
||||
- [Laboratory plugin runtime handshake and transport seam](docs/adr/0011-laboratory-plugin-runtime-handshake-and-transport-seam.md)
|
||||
- [Device-bound K1 command authority](docs/adr/0012-device-bound-k1-command-authority.md)
|
||||
- [Redacted live lab report](docs/lab/001_K1_LIVE_MQTT_20260715.redacted.md)
|
||||
- [Canonical control and durable archive milestone](docs/lab/004_K1_CANONICAL_CONTROL_ARCHIVE_20260719.redacted.md)
|
||||
- [Session manifest schema](schemas/session-manifest.schema.json)
|
||||
- [Reference input provenance](docs/reference/README.md)
|
||||
|
||||
|
|
@ -336,11 +330,9 @@ capture of traffic to or from the confirmed K1 address, and offline analysis of
|
|||
owned artifacts.
|
||||
|
||||
The reviewed provisioning write requires its named profile and explicit operator
|
||||
confirmation. The interactive application-control publisher is enabled only for
|
||||
the exact K1/A4/FW 3.0.2 direct-LAN profile and only inside one operator-opened,
|
||||
response-gated session; physical double-click remains the independent fallback.
|
||||
The legacy shadow publisher remains structurally disabled. Any broader firmware,
|
||||
topology, scanner-family or command surface requires its own evidence and
|
||||
confirmation. Application command publishing remains disabled: physical
|
||||
double-click is the verified start/stop mechanism. Any future MQTT publisher,
|
||||
router configuration change or new BLE write requires its own evidence and
|
||||
reviewed step. Random writes, fuzzing, brute force, firmware operations,
|
||||
destructive file access and credential guessing remain out of scope.
|
||||
|
||||
|
|
|
|||
|
|
@ -40,10 +40,7 @@ import {
|
|||
} from "./core/runtime/latestAsyncCommitter";
|
||||
import { useObservationLayout } from "./core/observation/useObservationLayout";
|
||||
import { recordedObservationSources } from "./core/observation/recordedObservationSources";
|
||||
import type {
|
||||
ObservationSessionReplayLaunch,
|
||||
ObservationSessionSummary,
|
||||
} from "./core/observation/sessionArchive";
|
||||
import type { ObservationSessionReplayLaunch } from "./core/observation/sessionArchive";
|
||||
import { useRecordedSessionAdmission } from "./core/observation/useRecordedSessionAdmission";
|
||||
import { useWorkspaceLayoutProfile } from "./core/observation/useWorkspaceLayoutProfile";
|
||||
import {
|
||||
|
|
@ -139,7 +136,6 @@ export default function App() {
|
|||
const [activeRoot, setActiveRoot] = useState<RootId | null>(null);
|
||||
const [sourceUrl, setSourceUrl] = useState("");
|
||||
const [recordedReplay, setRecordedReplay] = useState<ObservationSessionReplayLaunch | null>(null);
|
||||
const [recordedReplayLabel, setRecordedReplayLabel] = useState<string | null>(null);
|
||||
const [replayTransitioning, setReplayTransitioning] = useState(false);
|
||||
const [sourceDraft, setSourceDraft] = useState("");
|
||||
const [sourceWindowOpen, setSourceWindowOpen] = useState(false);
|
||||
|
|
@ -451,7 +447,6 @@ export default function App() {
|
|||
// accepting the already-ready archive.
|
||||
setReplayTransitioning(true);
|
||||
setRecordedReplay(null);
|
||||
setRecordedReplayLabel(null);
|
||||
setSourceUrl("");
|
||||
setSourceDraft("");
|
||||
await new Promise<void>((resolve) => {
|
||||
|
|
@ -459,15 +454,11 @@ export default function App() {
|
|||
});
|
||||
}, []);
|
||||
|
||||
const acceptRecordedReplay = useCallback((
|
||||
session: ObservationSessionSummary,
|
||||
launch: ObservationSessionReplayLaunch,
|
||||
) => {
|
||||
const acceptRecordedReplay = useCallback((launch: ObservationSessionReplayLaunch) => {
|
||||
if (sourceSwitchBlockedRef.current) {
|
||||
throw new Error(SPATIAL_SOURCE_SWITCH_BLOCKED_REASON);
|
||||
}
|
||||
setRecordedReplay(launch);
|
||||
setRecordedReplayLabel(session.label);
|
||||
setSourceUrl(launch.sourceUrl);
|
||||
setSourceDraft(launch.sourceUrl);
|
||||
setReplayTransitioning(false);
|
||||
|
|
@ -484,7 +475,6 @@ export default function App() {
|
|||
// switch, so it intentionally does not consult the acquisition guard.
|
||||
setReplayTransitioning(false);
|
||||
setRecordedReplay(null);
|
||||
setRecordedReplayLabel(null);
|
||||
setSourceUrl("");
|
||||
setSourceDraft("");
|
||||
}, []);
|
||||
|
|
@ -644,11 +634,7 @@ export default function App() {
|
|||
<ApplicationPanel
|
||||
key={activeDefinition.id}
|
||||
eyebrow={activeDefinition.eyebrow}
|
||||
title={
|
||||
activeDefinition.kind === "spatial" && replayActive && recordedReplayLabel
|
||||
? `${activeDefinition.title}: ${recordedReplayLabel}`
|
||||
: activeDefinition.title
|
||||
}
|
||||
title={activeDefinition.title}
|
||||
description={activeDefinition.description}
|
||||
expanded={workspace.contentExpanded}
|
||||
onExpandedChange={workspace.setContentExpanded}
|
||||
|
|
@ -660,10 +646,11 @@ export default function App() {
|
|||
) : activeDefinition.kind === "spatial" ? (
|
||||
<div className="observation-header-tools">
|
||||
<ObservationSessionSelect
|
||||
limit={3}
|
||||
disabled={runtime.pendingAction !== null || sourceSwitchBlocked}
|
||||
blockedReason={sourceSwitchBlockedReason}
|
||||
onReplayBegin={beginRecordedReplaySwitch}
|
||||
onReplayAccepted={acceptRecordedReplay}
|
||||
onReplayAccepted={(_session, launch) => acceptRecordedReplay(launch)}
|
||||
onReplaySettled={(_session, outcome) => settleRecordedReplaySwitch(outcome)}
|
||||
/>
|
||||
{layoutSaveNotice || workspaceLayoutProfile.error ? (
|
||||
|
|
@ -752,7 +739,6 @@ export default function App() {
|
|||
setSourceDraft("");
|
||||
setSourceUrl("");
|
||||
setRecordedReplay(null);
|
||||
setRecordedReplayLabel(null);
|
||||
}}
|
||||
>
|
||||
Сбросить адрес
|
||||
|
|
@ -766,7 +752,6 @@ export default function App() {
|
|||
if (sourceSwitchBlockedRef.current) return;
|
||||
setSourceUrl(sourceDraft.trim());
|
||||
setRecordedReplay(null);
|
||||
setRecordedReplayLabel(null);
|
||||
}}
|
||||
>
|
||||
Применить адрес
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
import { useState } from "react";
|
||||
import { ConfirmationModal, Dropdown, Icon } from "@nodedc/ui-react";
|
||||
import { Dropdown, Icon } from "@nodedc/ui-react";
|
||||
|
||||
import {
|
||||
type ObservationSessionReplayLaunch,
|
||||
|
|
@ -94,7 +93,7 @@ function sessionDescription(session: ObservationSessionSummary): string {
|
|||
}
|
||||
|
||||
export function ObservationSessionSelect({
|
||||
limit = 100,
|
||||
limit = 3,
|
||||
disabled = false,
|
||||
blockedReason = null,
|
||||
onReplayBegin,
|
||||
|
|
@ -117,7 +116,6 @@ export function ObservationSessionSelect({
|
|||
outcome: ObservationReplayOutcome,
|
||||
) => void | Promise<void>;
|
||||
}) {
|
||||
const [deleteTarget, setDeleteTarget] = useState<ObservationSessionSummary | null>(null);
|
||||
const sessions = useObservationSessions({
|
||||
limit,
|
||||
replayEnabled: blockedReason === null,
|
||||
|
|
@ -132,7 +130,7 @@ export function ObservationSessionSelect({
|
|||
: "Сохранённые сессии";
|
||||
const presentedTriggerCopy = blockedReason ?? triggerCopy;
|
||||
|
||||
return <>
|
||||
return (
|
||||
<Dropdown
|
||||
className="observation-session-select"
|
||||
placement="bottom-end"
|
||||
|
|
@ -186,48 +184,33 @@ export function ObservationSessionSelect({
|
|||
<div className="observation-session-menu__list">
|
||||
{sessions.items.map((session) => {
|
||||
const pending = sessions.replayingSessionId === session.id;
|
||||
const deleting = sessions.deletingSessionId === session.id;
|
||||
const failed = sessions.failedSessionId === session.id;
|
||||
const visualState = observationSessionVisualState(session, { pending, failed });
|
||||
return (
|
||||
<div
|
||||
<button
|
||||
key={session.id}
|
||||
className="observation-session-option"
|
||||
data-deleting={deleting ? "true" : undefined}
|
||||
type="button"
|
||||
className="nodedc-dropdown-option observation-session-option"
|
||||
disabled={pending || !session.replayable}
|
||||
onClick={() => {
|
||||
void sessions.replay(session.id).then((accepted) => {
|
||||
if (accepted) close();
|
||||
});
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="nodedc-dropdown-option observation-session-option__open"
|
||||
disabled={pending || deleting || !session.replayable}
|
||||
onClick={() => {
|
||||
void sessions.replay(session.id).then((accepted) => {
|
||||
if (accepted) close();
|
||||
});
|
||||
}}
|
||||
>
|
||||
<span className="nodedc-dropdown-option__icon">
|
||||
<i data-session-visual-state={visualState} aria-hidden="true" />
|
||||
<span className="nodedc-dropdown-option__icon">
|
||||
<i data-session-visual-state={visualState} aria-hidden="true" />
|
||||
</span>
|
||||
<span className="nodedc-dropdown-option__body">
|
||||
<span className="nodedc-dropdown-option__label">{session.label}</span>
|
||||
<span className="nodedc-dropdown-option__description">
|
||||
{sessionDescription(session)}
|
||||
</span>
|
||||
<span className="nodedc-dropdown-option__body">
|
||||
<span className="nodedc-dropdown-option__label">{session.label}</span>
|
||||
<span className="nodedc-dropdown-option__description">
|
||||
{sessionDescription(session)}
|
||||
</span>
|
||||
</span>
|
||||
<span className="observation-session-option__state">
|
||||
{deleting ? "Удаление…" : observationSessionVisualLabel(visualState)}
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="observation-session-option__delete"
|
||||
aria-label={`Удалить сохранённую сессию ${session.label}`}
|
||||
disabled={pending || deleting}
|
||||
onClick={() => setDeleteTarget(session)}
|
||||
>
|
||||
<Icon name="trash" size={15} />
|
||||
</button>
|
||||
</div>
|
||||
</span>
|
||||
<span className="observation-session-option__state">
|
||||
{observationSessionVisualLabel(visualState)}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
|
@ -259,29 +242,5 @@ export function ObservationSessionSelect({
|
|||
</div>
|
||||
)}
|
||||
</Dropdown>
|
||||
<ConfirmationModal
|
||||
open={deleteTarget !== null}
|
||||
title="Удалить сохранённую сессию?"
|
||||
description={deleteTarget ? <>
|
||||
<strong>{deleteTarget.label}</strong>
|
||||
<p>
|
||||
Сессия, исходные данные наблюдения, подготовленная Rerun-запись и
|
||||
видеоматериалы будут удалены с этого сервера без возможности восстановления.
|
||||
</p>
|
||||
{sessions.error && sessions.deletingSessionId === null ? (
|
||||
<p className="observation-session-delete-error" role="alert">{sessions.error}</p>
|
||||
) : null}
|
||||
</> : null}
|
||||
confirmLabel="Удалить сессию"
|
||||
pendingLabel="Удаление…"
|
||||
danger
|
||||
onClose={() => {
|
||||
if (sessions.deletingSessionId === null) setDeleteTarget(null);
|
||||
}}
|
||||
onConfirm={async () => {
|
||||
if (!deleteTarget) return;
|
||||
if (await sessions.remove(deleteTarget.id)) setDeleteTarget(null);
|
||||
}}
|
||||
/>
|
||||
</>;
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -135,17 +135,13 @@ export function ObservationMedia({
|
|||
return (
|
||||
<div className="observation-media__empty">
|
||||
<Icon name={sourceIcon[source.modality]} size={20} />
|
||||
<strong>
|
||||
{source.modality === "video" && source.activation && !source.activation.selected
|
||||
? "Источник отключён"
|
||||
: observationSourceStatusLabel(source)}
|
||||
</strong>
|
||||
<strong>{observationSourceStatusLabel(source)}</strong>
|
||||
<span>
|
||||
{source.modality === "video"
|
||||
? source.activation?.controllable
|
||||
? source.activation.selected
|
||||
? "Повторите подключение — локальный адаптер перезапустит выбранную камеру"
|
||||
: "Канал остановлен оператором; при необходимости его можно открыть снова"
|
||||
: "Откройте канал — локальный адаптер подключит выбранную камеру"
|
||||
: "Канал известен, browser-preview сейчас недоступен"
|
||||
: source.description}
|
||||
</span>
|
||||
|
|
|
|||
|
|
@ -393,27 +393,15 @@ async function mountVerifiedRecordedEpoch(
|
|||
signal: AbortSignal,
|
||||
): Promise<() => void> {
|
||||
const descriptor = epoch.descriptor;
|
||||
if (!descriptor.mediaType.startsWith("video/mp4;") || !video.canPlayType(descriptor.mediaType)) {
|
||||
if (
|
||||
descriptor.mediaType === "video/mp4" ||
|
||||
!globalThis.MediaSource ||
|
||||
!MediaSource.isTypeSupported(descriptor.mediaType)
|
||||
) {
|
||||
throw new Error("Archived camera codec is not supported");
|
||||
}
|
||||
if (signal.aborted) throw new DOMException("Aborted", "AbortError");
|
||||
|
||||
// A sealed fragmented-MP4 generation is already complete and immutable.
|
||||
// Present it as one Blob so Chromium can index and seek the whole archive
|
||||
// without retaining the same 166+ MiB epoch in a quota-limited MSE
|
||||
// SourceBuffer. Every constituent byte was fetched and SHA-256 verified
|
||||
// before this boundary, and Blob preserves their canonical order.
|
||||
const payload = new Blob([epoch.init, ...epoch.segments], {
|
||||
type: descriptor.mediaType,
|
||||
});
|
||||
const expectedByteLength = epoch.init.byteLength + epoch.segments.reduce(
|
||||
(total, segment) => total + segment.byteLength,
|
||||
0,
|
||||
);
|
||||
if (payload.size !== expectedByteLength) {
|
||||
throw new Error("Archived camera Blob is incomplete");
|
||||
}
|
||||
const objectUrl = URL.createObjectURL(payload);
|
||||
const mediaSource = new MediaSource();
|
||||
const objectUrl = URL.createObjectURL(mediaSource);
|
||||
const cleanup = () => {
|
||||
video.pause();
|
||||
video.removeAttribute("src");
|
||||
|
|
@ -421,8 +409,39 @@ async function mountVerifiedRecordedEpoch(
|
|||
URL.revokeObjectURL(objectUrl);
|
||||
};
|
||||
video.src = objectUrl;
|
||||
video.load();
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const onOpen = () => {
|
||||
cleanupListeners();
|
||||
resolve();
|
||||
};
|
||||
const onAbort = () => {
|
||||
cleanupListeners();
|
||||
reject(new DOMException("Aborted", "AbortError"));
|
||||
};
|
||||
const cleanupListeners = () => {
|
||||
mediaSource.removeEventListener("sourceopen", onOpen);
|
||||
signal.removeEventListener("abort", onAbort);
|
||||
};
|
||||
if (signal.aborted) {
|
||||
onAbort();
|
||||
return;
|
||||
}
|
||||
mediaSource.addEventListener("sourceopen", onOpen, { once: true });
|
||||
signal.addEventListener("abort", onAbort, { once: true });
|
||||
});
|
||||
if (signal.aborted || mediaSource.readyState !== "open") {
|
||||
throw new DOMException("Aborted", "AbortError");
|
||||
}
|
||||
const sourceBuffer = mediaSource.addSourceBuffer(descriptor.mediaType);
|
||||
await appendRecordedMediaBuffer(sourceBuffer, epoch.init, signal);
|
||||
for (const segment of epoch.segments) {
|
||||
await appendRecordedMediaBuffer(sourceBuffer, segment, signal);
|
||||
}
|
||||
if (signal.aborted || mediaSource.readyState !== "open" || sourceBuffer.updating) {
|
||||
throw new Error("Archived camera MediaSource closed before full append");
|
||||
}
|
||||
mediaSource.endOfStream();
|
||||
await waitForSeekableArchive(
|
||||
video,
|
||||
descriptor.timelineEndSeconds - descriptor.timelineStartSeconds,
|
||||
|
|
|
|||
|
|
@ -1,11 +1,9 @@
|
|||
// A device-agnostic frontend safety policy. Sixteen channels covers multi-rig
|
||||
// vehicles while the independent byte/concurrency limits keep admission
|
||||
// bounded. One real accepted archive is ~163 MiB for a single camera, so the
|
||||
// old 128 MiB laboratory ceiling rejected a valid sealed generation before the
|
||||
// first manifest request. OPFS-backed sealed generations remain the scaling
|
||||
// path beyond this in-memory policy.
|
||||
// bounded. OPFS-backed sealed generations are the planned scaling path beyond
|
||||
// this in-memory laboratory policy.
|
||||
export const MAX_RECORDED_CAMERA_SOURCES = 16;
|
||||
export const MAX_RECORDED_MEDIA_SOURCE_BYTES = 256 * 1024 * 1024;
|
||||
export const MAX_RECORDED_MEDIA_SOURCE_BYTES = 128 * 1024 * 1024;
|
||||
export const MAX_RECORDED_SESSION_CAMERA_BYTES = 512 * 1024 * 1024;
|
||||
export const MAX_CONCURRENT_RECORDED_CAMERA_PREPARATIONS = 1;
|
||||
|
||||
|
|
|
|||
|
|
@ -1110,46 +1110,6 @@ export async function fetchObservationSessionCatalog({
|
|||
return decodeObservationSessionCatalog(body);
|
||||
}
|
||||
|
||||
export async function deleteObservationSession(
|
||||
sessionId: string,
|
||||
{
|
||||
signal,
|
||||
fetcher = globalThis.fetch,
|
||||
}: { signal?: AbortSignal; fetcher?: ObservationSessionFetch } = {},
|
||||
): Promise<void> {
|
||||
if (!SAFE_ID.test(sessionId)) {
|
||||
throw new ObservationSessionContractError(
|
||||
"Идентификатор удаляемой сессии имеет недопустимый формат.",
|
||||
);
|
||||
}
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetcher(
|
||||
`/api/v1/observation-sessions/${encodeURIComponent(sessionId)}`,
|
||||
{
|
||||
method: "DELETE",
|
||||
headers: { Accept: "application/json" },
|
||||
signal,
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof DOMException && error.name === "AbortError") throw error;
|
||||
throw new ObservationSessionApiError("Не удалось удалить сохранённую сессию.");
|
||||
}
|
||||
const body = await responseBody(response);
|
||||
if (!response.ok || response.status !== 204) {
|
||||
throw new ObservationSessionApiError(
|
||||
apiErrorMessage(body, `Удаление сессии вернуло HTTP ${response.status}.`),
|
||||
response.status,
|
||||
);
|
||||
}
|
||||
if (body !== undefined) {
|
||||
throw new ObservationSessionContractError(
|
||||
"Сервер вернул данные после подтверждённого удаления сессии.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function replayObservationSession(
|
||||
sessionId: string,
|
||||
{
|
||||
|
|
|
|||
|
|
@ -27,18 +27,6 @@ export function observationPresentationSourceAfterLayoutApply(
|
|||
return mode === "preserve" ? currentSourceId : null;
|
||||
}
|
||||
|
||||
export function visibleSourceIdsAfterRecordedCatalogActivation(
|
||||
currentIds: readonly string[],
|
||||
sources: readonly ObservationSourceDescriptor[],
|
||||
): string[] {
|
||||
let visibleIds = [...currentIds];
|
||||
for (const source of sources) {
|
||||
if (source.transport !== "recording" || !canOpenByDefault(source)) continue;
|
||||
visibleIds = openObservationSource(visibleIds, source.id, sources).visibleIds;
|
||||
}
|
||||
return visibleIds;
|
||||
}
|
||||
|
||||
export interface ObservationLayoutController {
|
||||
visibleSourceIds: ReadonlySet<string>;
|
||||
focusedSourceId: string | null;
|
||||
|
|
@ -242,25 +230,7 @@ export function useObservationLayout(
|
|||
}
|
||||
const desired = desiredSnapshotRef.current;
|
||||
if (desired) {
|
||||
const previousIdentity = initializedCatalog.current;
|
||||
initializedCatalog.current = identity;
|
||||
if (
|
||||
previousIdentity !== identity &&
|
||||
sources.some((source) => source.transport === "recording")
|
||||
) {
|
||||
const nextVisible = visibleSourceIdsAfterRecordedCatalogActivation(
|
||||
visibleIdsRef.current,
|
||||
sources,
|
||||
);
|
||||
commitVisibleIds(nextVisible);
|
||||
const firstRecordedOverlay = sources.find((source) => (
|
||||
source.transport === "recording" &&
|
||||
source.capabilities.overlay &&
|
||||
nextVisible.includes(source.id)
|
||||
));
|
||||
commitActiveFloatingSourceId(firstRecordedOverlay?.id ?? null);
|
||||
persistLiveLayout();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (initializedCatalog.current === identity) return;
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@ import { useCallback, useEffect, useRef, useState } from "react";
|
|||
|
||||
import {
|
||||
decodeObservationSessionPreparation,
|
||||
deleteObservationSession,
|
||||
fetchObservationSessionCatalog,
|
||||
fetchObservationSessionPreparation,
|
||||
replayObservationSession,
|
||||
|
|
@ -33,11 +32,9 @@ export interface ObservationSessionsController {
|
|||
preparation: ObservationSessionPreparation | null;
|
||||
replayProgress: ObservationReplayProgress | null;
|
||||
failedSessionId: string | null;
|
||||
deletingSessionId: string | null;
|
||||
refresh: () => Promise<boolean>;
|
||||
replay: (sessionId: string) => Promise<boolean>;
|
||||
retry: () => Promise<boolean>;
|
||||
remove: (sessionId: string) => Promise<boolean>;
|
||||
}
|
||||
|
||||
export interface ObservationReplayAttempt {
|
||||
|
|
@ -363,7 +360,7 @@ export function clearObservationReplayPreparation(
|
|||
}
|
||||
|
||||
export function useObservationSessions({
|
||||
limit = 100,
|
||||
limit = 3,
|
||||
replayEnabled = true,
|
||||
onReplayBegin,
|
||||
onReplayAccepted,
|
||||
|
|
@ -392,12 +389,12 @@ export function useObservationSessions({
|
|||
const [preparation, setPreparation] = useState<ObservationSessionPreparation | null>(null);
|
||||
const [replayProgress, setReplayProgress] = useState<ObservationReplayProgress | null>(null);
|
||||
const [failedSessionId, setFailedSessionId] = useState<string | null>(null);
|
||||
const [deletingSessionId, setDeletingSessionId] = useState<string | null>(null);
|
||||
const mounted = useRef(true);
|
||||
const catalogSequence = useRef(0);
|
||||
const reattachStarted = useRef(false);
|
||||
const replayEnabledRef = useRef(replayEnabled);
|
||||
replayEnabledRef.current = replayEnabled;
|
||||
const preparationPollSequence = useRef(0);
|
||||
const replayCoordinator = useRef<ObservationReplayCoordinator | null>(null);
|
||||
if (replayCoordinator.current === null) {
|
||||
replayCoordinator.current = createObservationReplayCoordinator();
|
||||
|
|
@ -412,30 +409,26 @@ export function useObservationSessions({
|
|||
setReplayingSessionId(null);
|
||||
setReplayProgress(null);
|
||||
}, [replayEnabled]);
|
||||
const safeLimit = Number.isFinite(limit)
|
||||
? Math.min(100, Math.max(1, Math.floor(limit)))
|
||||
: 100;
|
||||
const safeLimit = Number.isFinite(limit) ? Math.max(0, Math.floor(limit)) : 3;
|
||||
|
||||
const loadCatalog = useCallback(async (foreground: boolean) => {
|
||||
const refresh = useCallback(async () => {
|
||||
const sequence = ++catalogSequence.current;
|
||||
if (foreground) setState("loading");
|
||||
setState("loading");
|
||||
setError(null);
|
||||
try {
|
||||
const catalog = await fetchObservationSessionCatalog({ limit: safeLimit });
|
||||
if (!mounted.current || sequence !== catalogSequence.current) return false;
|
||||
setItems(catalog.items.slice(0, safeLimit));
|
||||
setState("ready");
|
||||
setError(null);
|
||||
return true;
|
||||
} catch (loadError) {
|
||||
if (!mounted.current || sequence !== catalogSequence.current) return false;
|
||||
if (foreground) setState("error");
|
||||
setState("error");
|
||||
setError(errorMessage(loadError));
|
||||
return false;
|
||||
}
|
||||
}, [safeLimit]);
|
||||
|
||||
const refresh = useCallback(() => loadCatalog(true), [loadCatalog]);
|
||||
|
||||
useEffect(() => {
|
||||
mounted.current = true;
|
||||
void refresh();
|
||||
|
|
@ -446,21 +439,44 @@ export function useObservationSessions({
|
|||
};
|
||||
}, [refresh]);
|
||||
|
||||
const catalogHasActivePreparation = items.some((item) => (
|
||||
item.preparation !== null &&
|
||||
["queued", "validating", "exporting", "finalizing"].includes(item.preparation.state)
|
||||
));
|
||||
|
||||
useEffect(() => {
|
||||
if (state !== "ready") return;
|
||||
let disposed = false;
|
||||
let timer = 0;
|
||||
const poll = async () => {
|
||||
if (disposed) return;
|
||||
await loadCatalog(false);
|
||||
if (!disposed) timer = window.setTimeout(() => void poll(), 2_000);
|
||||
};
|
||||
timer = window.setTimeout(() => void poll(), 2_000);
|
||||
if (state !== "ready" || !catalogHasActivePreparation) return;
|
||||
const sequence = ++preparationPollSequence.current;
|
||||
const controller = new AbortController();
|
||||
const timer = window.setTimeout(async () => {
|
||||
try {
|
||||
const catalog = await fetchObservationSessionCatalog({
|
||||
limit: safeLimit,
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (
|
||||
mounted.current &&
|
||||
!controller.signal.aborted &&
|
||||
sequence === preparationPollSequence.current
|
||||
) {
|
||||
setItems(catalog.items.slice(0, safeLimit));
|
||||
}
|
||||
} catch (pollError) {
|
||||
if (
|
||||
mounted.current &&
|
||||
!controller.signal.aborted &&
|
||||
sequence === preparationPollSequence.current
|
||||
) {
|
||||
setError(errorMessage(pollError));
|
||||
}
|
||||
}
|
||||
}, 1_500);
|
||||
return () => {
|
||||
disposed = true;
|
||||
preparationPollSequence.current += 1;
|
||||
window.clearTimeout(timer);
|
||||
controller.abort();
|
||||
};
|
||||
}, [loadCatalog, state]);
|
||||
}, [catalogHasActivePreparation, items, safeLimit, state]);
|
||||
|
||||
const executeReplay = useCallback(async (
|
||||
session: ObservationSessionSummary,
|
||||
|
|
@ -590,24 +606,6 @@ export function useObservationSessions({
|
|||
return replay(failedSessionId);
|
||||
}, [failedSessionId, replay]);
|
||||
|
||||
const remove = useCallback(async (sessionId: string) => {
|
||||
if (deletingSessionId !== null || replayingSessionId === sessionId) return false;
|
||||
setDeletingSessionId(sessionId);
|
||||
setError(null);
|
||||
try {
|
||||
await deleteObservationSession(sessionId);
|
||||
if (!mounted.current) return false;
|
||||
setItems((current) => current.filter((item) => item.id !== sessionId));
|
||||
setFailedSessionId((current) => current === sessionId ? null : current);
|
||||
return true;
|
||||
} catch (deleteError) {
|
||||
if (mounted.current) setError(errorMessage(deleteError));
|
||||
return false;
|
||||
} finally {
|
||||
if (mounted.current) setDeletingSessionId(null);
|
||||
}
|
||||
}, [deletingSessionId, replayingSessionId]);
|
||||
|
||||
return {
|
||||
items,
|
||||
state,
|
||||
|
|
@ -616,10 +614,8 @@ export function useObservationSessions({
|
|||
preparation,
|
||||
replayProgress,
|
||||
failedSessionId,
|
||||
deletingSessionId,
|
||||
refresh,
|
||||
replay,
|
||||
retry,
|
||||
remove,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -117,42 +117,9 @@
|
|||
}
|
||||
|
||||
.observation-session-option {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 2.35rem;
|
||||
align-items: stretch;
|
||||
gap: 0.2rem;
|
||||
}
|
||||
|
||||
.observation-session-option__open {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||
}
|
||||
|
||||
.observation-session-option__delete {
|
||||
display: grid;
|
||||
min-width: 2.35rem;
|
||||
place-items: center;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
color: var(--nodedc-text-muted);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.observation-session-option__delete:hover:not(:disabled),
|
||||
.observation-session-option__delete:focus-visible {
|
||||
background: transparent;
|
||||
color: rgb(var(--nodedc-danger-rgb));
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.observation-session-option__delete:disabled,
|
||||
.observation-session-option[data-deleting="true"] {
|
||||
opacity: 0.55;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.observation-session-option i {
|
||||
display: block;
|
||||
width: 0.45rem;
|
||||
|
|
@ -250,10 +217,6 @@
|
|||
cursor: pointer;
|
||||
}
|
||||
|
||||
.observation-session-delete-error {
|
||||
color: rgb(var(--nodedc-danger-rgb));
|
||||
}
|
||||
|
||||
@media (max-width: 860px) {
|
||||
.workspace-layout-feedback {
|
||||
display: none;
|
||||
|
|
|
|||
|
|
@ -323,19 +323,11 @@ function SpatialWorkspace({
|
|||
const floatingSourceMaximized = Boolean(observationLayout.maximizedFloatingSourceId);
|
||||
const timeline = state?.observationTimeline;
|
||||
const viewportRef = useRef<HTMLDivElement>(null);
|
||||
const intentionalSourceEnd = !recordedSource && [
|
||||
"awaiting_external_stop",
|
||||
"stopping",
|
||||
"finalizing",
|
||||
"completed",
|
||||
].includes(state?.acquisition?.state ?? "");
|
||||
const presentedViewerStatus = intentionalSourceEnd
|
||||
? "idle"
|
||||
: rerunPresentationStatus(
|
||||
viewerStatus,
|
||||
recordedSessionGate,
|
||||
recordedSource,
|
||||
);
|
||||
const presentedViewerStatus = rerunPresentationStatus(
|
||||
viewerStatus,
|
||||
recordedSessionGate,
|
||||
recordedSource,
|
||||
);
|
||||
|
||||
const onStatusChange = useCallback((status: RerunViewportStatus, message?: string) => {
|
||||
setViewerStatus(status);
|
||||
|
|
@ -397,7 +389,7 @@ function SpatialWorkspace({
|
|||
}, [observationLayout.setViewportSize]);
|
||||
|
||||
const viewerStatusLabel = {
|
||||
idle: intentionalSourceEnd ? "Источник отключён" : "Источник не назначен",
|
||||
idle: "Источник не назначен",
|
||||
loading: "Подключение",
|
||||
ready: "Визуализатор готов",
|
||||
error: "Ошибка источника",
|
||||
|
|
@ -508,7 +500,7 @@ function SpatialWorkspace({
|
|||
>
|
||||
<span className="section-eyebrow">ВИЗУАЛЬНЫЙ ДВИЖОК</span>
|
||||
<StatusBadge tone={viewerStatusTone}>{viewerStatusLabel}</StatusBadge>
|
||||
{!intentionalSourceEnd && viewerMessage ? <small>{viewerMessage}</small> : null}
|
||||
{viewerMessage ? <small>{viewerMessage}</small> : null}
|
||||
</div>
|
||||
|
||||
<div
|
||||
|
|
@ -687,7 +679,6 @@ function CameraSourceCard({
|
|||
: selectedPeer
|
||||
? "Переключить"
|
||||
: "Открыть канал";
|
||||
const sourceDisconnected = Boolean(source.activation && !source.activation.selected && visible);
|
||||
|
||||
return (
|
||||
<article className="camera-slot" data-focused={focused ? "true" : undefined}>
|
||||
|
|
@ -695,11 +686,8 @@ function CameraSourceCard({
|
|||
<span>{source.label}</span>
|
||||
<div className="camera-slot__head-actions">
|
||||
<span className="camera-slot__status">
|
||||
<i
|
||||
data-availability={sourceDisconnected ? "unverified" : source.availability}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{sourceDisconnected ? "Источник отключён" : observationSourceStatusLabel(source)}
|
||||
<i data-availability={source.availability} aria-hidden="true" />
|
||||
{observationSourceStatusLabel(source)}
|
||||
</span>
|
||||
{source.activation && !focused ? (
|
||||
<button
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
import assert from "node:assert/strict";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { after, before, test } from "node:test";
|
||||
|
||||
import { createServer } from "vite";
|
||||
|
|
@ -10,14 +9,10 @@ let createDevicePluginRegistry;
|
|||
let xgridsK1Manifest;
|
||||
let xgridsK1Actions;
|
||||
let xgridsK1Api;
|
||||
let ApiError;
|
||||
let localizeRuntimeMessage;
|
||||
let lifecycle;
|
||||
let projectName;
|
||||
let automaticSourceStart;
|
||||
let presentation;
|
||||
let operatorIntentGeneration;
|
||||
let configuration;
|
||||
|
||||
before(async () => {
|
||||
server = await createServer({
|
||||
|
|
@ -46,18 +41,9 @@ before(async () => {
|
|||
presentation = await server.ssrLoadModule(
|
||||
"@xgrids-k1/frontend/presentation.ts",
|
||||
);
|
||||
operatorIntentGeneration = await server.ssrLoadModule(
|
||||
"@xgrids-k1/frontend/operatorIntentGeneration.ts",
|
||||
);
|
||||
configuration = await server.ssrLoadModule(
|
||||
"@xgrids-k1/frontend/configuration.ts",
|
||||
);
|
||||
({ xgridsK1Api, ApiError } = await server.ssrLoadModule(
|
||||
({ xgridsK1Api } = await server.ssrLoadModule(
|
||||
"@xgrids-k1/frontend/api.ts",
|
||||
));
|
||||
({ localizeRuntimeMessage } = await server.ssrLoadModule(
|
||||
"@xgrids-k1/frontend/messages.ts",
|
||||
));
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
|
|
@ -214,151 +200,6 @@ test("installed XGRIDS frontend manifest exposes the semantic v1alpha2 actions",
|
|||
assert.equal(xgridsK1Actions.acquisitionStop, "acquisition.stop");
|
||||
});
|
||||
|
||||
test("one operator action can open at most one K1 control session", () => {
|
||||
assert.equal(lifecycle.controlSessionEntryPlan("idle", false, true), "open");
|
||||
assert.equal(lifecycle.controlSessionEntryPlan("failed", false, true), "open");
|
||||
assert.equal(lifecycle.controlSessionEntryPlan("failed", false, false), "failed");
|
||||
assert.equal(lifecycle.controlSessionEntryPlan("failed", true, true), "failed");
|
||||
assert.equal(
|
||||
lifecycle.controlSessionEntryPlan("idle", true, true),
|
||||
"duplicate-open",
|
||||
);
|
||||
assert.equal(
|
||||
lifecycle.controlSessionEntryPlan("connection-ready", true, false),
|
||||
"continue",
|
||||
);
|
||||
});
|
||||
|
||||
test("K1 operator intent stays invalid after runtime deactivate and reactivate", () => {
|
||||
const generation = new operatorIntentGeneration.OperatorIntentGeneration();
|
||||
generation.activateRuntime();
|
||||
const oldIntent = generation.beginOperatorIntent();
|
||||
|
||||
assert.ok(oldIntent);
|
||||
assert.equal(generation.isOperatorIntentCurrent(oldIntent), true);
|
||||
|
||||
generation.deactivateRuntime();
|
||||
generation.activateRuntime();
|
||||
const freshIntent = generation.beginOperatorIntent();
|
||||
|
||||
assert.ok(freshIntent);
|
||||
assert.notEqual(freshIntent.runtimeGeneration, oldIntent.runtimeGeneration);
|
||||
assert.equal(generation.isOperatorIntentCurrent(oldIntent), false);
|
||||
assert.equal(generation.isOperatorIntentCurrent(freshIntent), true);
|
||||
});
|
||||
|
||||
test("stale K1 intent cannot continue past an await after runtime reactivation", async () => {
|
||||
const generation = new operatorIntentGeneration.OperatorIntentGeneration();
|
||||
generation.activateRuntime();
|
||||
const oldIntent = generation.beginOperatorIntent();
|
||||
assert.ok(oldIntent);
|
||||
|
||||
let resolveOldRead;
|
||||
const oldRead = new Promise((resolve) => {
|
||||
resolveOldRead = resolve;
|
||||
});
|
||||
const writes = [];
|
||||
const assertOldIntent = () => {
|
||||
if (!generation.isOperatorIntentCurrent(oldIntent)) {
|
||||
throw new Error("stale operator intent");
|
||||
}
|
||||
};
|
||||
const oldContinuation = operatorIntentGeneration.awaitWhileIntentCurrent(
|
||||
assertOldIntent,
|
||||
() => oldRead,
|
||||
).then(() => writes.push("old-checkpoint"));
|
||||
|
||||
generation.deactivateRuntime();
|
||||
generation.activateRuntime();
|
||||
const freshIntent = generation.beginOperatorIntent();
|
||||
assert.ok(freshIntent);
|
||||
resolveOldRead({ phase: "connection-ready" });
|
||||
|
||||
await assert.rejects(oldContinuation, /stale operator intent/);
|
||||
assert.deepEqual(writes, []);
|
||||
|
||||
const assertFreshIntent = () => {
|
||||
if (!generation.isOperatorIntentCurrent(freshIntent)) {
|
||||
throw new Error("stale fresh intent");
|
||||
}
|
||||
};
|
||||
await operatorIntentGeneration.awaitWhileIntentCurrent(
|
||||
assertFreshIntent,
|
||||
async () => ({ phase: "connection-ready" }),
|
||||
);
|
||||
writes.push("fresh-checkpoint");
|
||||
assert.deepEqual(writes, ["fresh-checkpoint"]);
|
||||
});
|
||||
|
||||
test("a fresh explicit K1 intent supersedes the previous intent in one runtime", () => {
|
||||
const generation = new operatorIntentGeneration.OperatorIntentGeneration();
|
||||
generation.activateRuntime();
|
||||
const first = generation.beginOperatorIntent();
|
||||
const second = generation.beginOperatorIntent();
|
||||
|
||||
assert.ok(first);
|
||||
assert.ok(second);
|
||||
assert.equal(first.runtimeGeneration, second.runtimeGeneration);
|
||||
assert.notEqual(first.intentGeneration, second.intentGeneration);
|
||||
assert.equal(generation.isOperatorIntentCurrent(first), false);
|
||||
assert.equal(generation.isOperatorIntentCurrent(second), true);
|
||||
});
|
||||
|
||||
test("canonical K1 launch wires the generation guard through every async stage", async () => {
|
||||
const hookSource = await readFile(
|
||||
new URL(
|
||||
"../../../plugins/xgrids-k1/frontend/src/useXgridsK1Runtime.ts",
|
||||
import.meta.url,
|
||||
),
|
||||
"utf8",
|
||||
);
|
||||
const canonicalStart = hookSource.slice(
|
||||
hookSource.indexOf("const startCanonicalAcquisition"),
|
||||
hookSource.indexOf("const prepareAcquisition"),
|
||||
);
|
||||
const pollingLoop = hookSource.slice(
|
||||
hookSource.indexOf("async function waitForControlPhase"),
|
||||
hookSource.indexOf("function messageFor"),
|
||||
);
|
||||
|
||||
assert.doesNotMatch(hookSource, /mounted\.current/);
|
||||
assert.match(canonicalStart, /beginOperatorIntent\(\)/);
|
||||
assert.match(canonicalStart, /isOperatorIntentCurrent\(intentToken\)/);
|
||||
assert.doesNotMatch(canonicalStart, /await xgridsK1Api\./);
|
||||
assert.doesNotMatch(canonicalStart, /await waitForControlPhase\(/);
|
||||
assert.ok(
|
||||
canonicalStart.match(/awaitWhileIntentCurrent\(/g)?.length >= 8,
|
||||
"each canonical REST/checkpoint boundary must use the intent guard",
|
||||
);
|
||||
assert.match(pollingLoop, /for \(;;\) \{\s*assertOperatorIntentCurrent\(\)/);
|
||||
assert.equal(pollingLoop.match(/awaitWhileIntentCurrent\(/g)?.length, 2);
|
||||
});
|
||||
|
||||
test("K1 control errors stay informative and only fetch failures mark transport unavailable", async () => {
|
||||
assert.equal(
|
||||
localizeRuntimeMessage(
|
||||
"control MQTT connect call failed: [Errno 61] Connection refused",
|
||||
),
|
||||
"Управляющее соединение со сканером не открылось: устройство не приняло MQTT-соединение. Команды сканирования не отправлялись.",
|
||||
);
|
||||
|
||||
const domainError = new ApiError("Диалог остановлен до команды START.");
|
||||
assert.equal(domainError.transportUnavailable, false);
|
||||
|
||||
const originalFetch = globalThis.fetch;
|
||||
globalThis.fetch = async () => {
|
||||
throw new TypeError("synthetic network failure");
|
||||
};
|
||||
try {
|
||||
await assert.rejects(
|
||||
() => xgridsK1Api.getState(),
|
||||
(error) => error instanceof ApiError && error.transportUnavailable === true,
|
||||
);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test("v1alpha2 compatibility profile is exact-key and rejects duplicated profile status", () => {
|
||||
const document = manifestDocument({ apiVersion: "missioncore.nodedc/v1alpha2" });
|
||||
document.spec.compatibilityProfiles[0].status = "verified";
|
||||
|
|
@ -563,48 +404,6 @@ test("replay ignores a stale failed live acquisition", () => {
|
|||
);
|
||||
});
|
||||
|
||||
test("K1 configuration anchors expose future modes without enabling them", () => {
|
||||
assert.equal(configuration.SUPPORTED_CONNECTION_MODE, "bridge");
|
||||
assert.equal(configuration.SUPPORTED_MOUNT_TYPE, "handheld");
|
||||
assert.equal(configuration.SUPPORTED_GNSS_MODE, "none");
|
||||
assert.deepEqual(
|
||||
configuration.connectionModeOptions.map(({ value, disabled = false }) => ({ value, disabled })),
|
||||
[
|
||||
{ value: "bridge", disabled: false },
|
||||
{ value: "quick-connect", disabled: true },
|
||||
{ value: "direct-connect", disabled: true },
|
||||
],
|
||||
);
|
||||
assert.deepEqual(
|
||||
configuration.mountTypeOptions.map(({ value, disabled = false }) => ({ value, disabled })),
|
||||
[
|
||||
{ value: "handheld", disabled: false },
|
||||
{ value: "vehicle-mounted", disabled: true },
|
||||
{ value: "uav", disabled: true },
|
||||
{ value: "backpack", disabled: true },
|
||||
],
|
||||
);
|
||||
assert.deepEqual(
|
||||
configuration.gnssModeOptions.map(({ value, disabled = false }) => ({ value, disabled })),
|
||||
[
|
||||
{ value: "none", disabled: false },
|
||||
{ value: "rtk", disabled: true },
|
||||
{ value: "ppk", disabled: true },
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test("a provisioned address is not presented as a verified device connection", () => {
|
||||
assert.equal(lifecycle.normalizeRuntimePhase({
|
||||
phase: "connected",
|
||||
application_control_session: { state: "idle" },
|
||||
}), "configuring");
|
||||
assert.equal(lifecycle.normalizeRuntimePhase({
|
||||
phase: "connected",
|
||||
application_control_session: { state: "connection-ready" },
|
||||
}), "connected");
|
||||
});
|
||||
|
||||
test("provisioning intent keeps one idempotency key and exposes unsafe outcomes", () => {
|
||||
let created = 0;
|
||||
const createUuid = () => {
|
||||
|
|
@ -638,7 +437,7 @@ test("device mutations send explicit nested compatibility attestation", async ()
|
|||
const attestation = {
|
||||
firmware_version: "3.0.2",
|
||||
topology: "direct-lan",
|
||||
verification: "live-device-info",
|
||||
operator_confirmed: true,
|
||||
};
|
||||
|
||||
try {
|
||||
|
|
@ -646,14 +445,11 @@ test("device mutations send explicit nested compatibility attestation", async ()
|
|||
device_id: "ble-device",
|
||||
ssid: "lab-network",
|
||||
password: syntheticCredential,
|
||||
connection_mode: "bridge",
|
||||
compatibility_attestation: attestation,
|
||||
idempotency_key: "network-provision:test",
|
||||
});
|
||||
await xgridsK1Api.prepareAcquisition({
|
||||
project_name: "Mission 01",
|
||||
mount_type: "handheld",
|
||||
gnss_mode: "none",
|
||||
compatibility_attestation: attestation,
|
||||
});
|
||||
} finally {
|
||||
|
|
|
|||
|
|
@ -9,7 +9,6 @@ let decodeObservationSessionCatalog;
|
|||
let decodeObservationSessionReplay;
|
||||
let decodeObservationSessionPreparation;
|
||||
let fetchObservationSessionCatalog;
|
||||
let deleteObservationSession;
|
||||
let replayObservationSession;
|
||||
let fetchObservationSessionPreparation;
|
||||
let cancelObservationSessionPreparation;
|
||||
|
|
@ -35,7 +34,6 @@ before(async () => {
|
|||
decodeObservationSessionReplay,
|
||||
decodeObservationSessionPreparation,
|
||||
fetchObservationSessionCatalog,
|
||||
deleteObservationSession,
|
||||
replayObservationSession,
|
||||
fetchObservationSessionPreparation,
|
||||
cancelObservationSessionPreparation,
|
||||
|
|
@ -138,39 +136,6 @@ test("session catalog decodes canonical snake_case into a path-free camelCase mo
|
|||
assert.equal("path" in catalog.items[0], false);
|
||||
});
|
||||
|
||||
test("opened archive is named in the scene header and trash hover has no pill", async () => {
|
||||
const appSource = await readFile(new URL("../src/App.tsx", import.meta.url), "utf8");
|
||||
const styles = await readFile(
|
||||
new URL("../src/styles/observation-sessions.css", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
const spatialControls = await readFile(
|
||||
new URL(
|
||||
"../../../plugins/xgrids-k1/frontend/src/components/K1SpatialControls.tsx",
|
||||
import.meta.url,
|
||||
),
|
||||
"utf8",
|
||||
);
|
||||
const acquisitionPipeline = await readFile(
|
||||
new URL(
|
||||
"../../../plugins/xgrids-k1/frontend/src/components/K1AcquisitionPipeline.tsx",
|
||||
import.meta.url,
|
||||
),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
assert.match(appSource, /setRecordedReplayLabel\(session\.label\)/);
|
||||
assert.match(appSource, /`\$\{activeDefinition\.title\}: \$\{recordedReplayLabel\}`/);
|
||||
const deleteStyle = styles.slice(
|
||||
styles.indexOf(".observation-session-option__delete"),
|
||||
styles.indexOf(".observation-session-option__delete:disabled"),
|
||||
);
|
||||
assert.match(deleteStyle, /background:\s*transparent/);
|
||||
assert.doesNotMatch(deleteStyle, /danger-rgb\) \/ 0\.12/);
|
||||
assert.doesNotMatch(spatialControls, /Индикатор постоянно зелёный/);
|
||||
assert.doesNotMatch(acquisitionPipeline, /Индикатор постоянно зелёный/);
|
||||
});
|
||||
|
||||
test("session catalog exposes authoritative background preparation state", () => {
|
||||
const catalog = decodeObservationSessionCatalog({
|
||||
items: [session({
|
||||
|
|
@ -337,37 +302,6 @@ test("catalog API preserves HTTP detail without accepting a malformed success bo
|
|||
);
|
||||
});
|
||||
|
||||
test("delete API uses one opaque same-origin target and requires an empty 204", async () => {
|
||||
const calls = [];
|
||||
await deleteObservationSession("session-20260716T205632Z", {
|
||||
fetcher: async (input, init) => {
|
||||
calls.push({ input: String(input), init });
|
||||
return new Response(null, { status: 204 });
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(calls.length, 1);
|
||||
assert.equal(
|
||||
calls[0].input,
|
||||
"/api/v1/observation-sessions/session-20260716T205632Z",
|
||||
);
|
||||
assert.equal(calls[0].init.method, "DELETE");
|
||||
await assert.rejects(
|
||||
deleteObservationSession("../private", { fetcher: async () => new Response(null) }),
|
||||
ObservationSessionContractError,
|
||||
);
|
||||
await assert.rejects(
|
||||
deleteObservationSession("session-20260716T205632Z", {
|
||||
fetcher: async () => new Response(JSON.stringify({ detail: "recording is open" }), {
|
||||
status: 409,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}),
|
||||
}),
|
||||
(error) => error instanceof ObservationSessionApiError &&
|
||||
error.status === 409 && error.message === "recording is open",
|
||||
);
|
||||
});
|
||||
|
||||
test("replay API accepts only a same-origin seekable recording descriptor", async () => {
|
||||
const calls = [];
|
||||
const launch = await replayObservationSession("session-20260716T205632Z", {
|
||||
|
|
|
|||
|
|
@ -78,7 +78,7 @@ test("any RRD or camera failure closes the complete recorded session", () => {
|
|||
);
|
||||
});
|
||||
|
||||
test("camera admission enforces independent 16/256/512 MiB limits before fetching", () => {
|
||||
test("camera admission enforces independent 16/128/512 MiB limits before fetching", () => {
|
||||
const {
|
||||
MAX_RECORDED_CAMERA_SOURCES,
|
||||
MAX_RECORDED_MEDIA_SOURCE_BYTES,
|
||||
|
|
@ -86,10 +86,10 @@ test("camera admission enforces independent 16/256/512 MiB limits before fetchin
|
|||
recordedCameraDescriptorPreflight,
|
||||
} = admission;
|
||||
assert.equal(MAX_RECORDED_CAMERA_SOURCES, 16);
|
||||
assert.equal(MAX_RECORDED_MEDIA_SOURCE_BYTES, 256 * 1024 * 1024);
|
||||
assert.equal(MAX_RECORDED_MEDIA_SOURCE_BYTES, 128 * 1024 * 1024);
|
||||
assert.equal(MAX_RECORDED_SESSION_CAMERA_BYTES, 512 * 1024 * 1024);
|
||||
assert.equal(recordedCameraDescriptorPreflight(
|
||||
Array.from({ length: 2 }, (_, index) => ({
|
||||
Array.from({ length: 4 }, (_, index) => ({
|
||||
id: `camera.${index}`,
|
||||
byteLength: MAX_RECORDED_MEDIA_SOURCE_BYTES,
|
||||
})),
|
||||
|
|
|
|||
|
|
@ -14,7 +14,6 @@ let saveObservationWorkspaceLayoutProfile;
|
|||
let WorkspaceLayoutApiError;
|
||||
let WorkspaceLayoutContractError;
|
||||
let observationPresentationSourceAfterLayoutApply;
|
||||
let visibleSourceIdsAfterRecordedCatalogActivation;
|
||||
|
||||
before(async () => {
|
||||
server = await createServer({
|
||||
|
|
@ -32,10 +31,9 @@ before(async () => {
|
|||
WorkspaceLayoutApiError,
|
||||
WorkspaceLayoutContractError,
|
||||
} = await server.ssrLoadModule("/src/core/observation/workspaceLayout.ts"));
|
||||
({
|
||||
observationPresentationSourceAfterLayoutApply,
|
||||
visibleSourceIdsAfterRecordedCatalogActivation,
|
||||
} = await server.ssrLoadModule("/src/core/observation/useObservationLayout.ts"));
|
||||
({ observationPresentationSourceAfterLayoutApply } = await server.ssrLoadModule(
|
||||
"/src/core/observation/useObservationLayout.ts",
|
||||
));
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
|
|
@ -183,36 +181,6 @@ test("fullscreen presentation survives the viewport resize it causes", () => {
|
|||
);
|
||||
});
|
||||
|
||||
test("opening a recorded catalog reveals its sealed cameras beside the point cloud", () => {
|
||||
const source = (id, modality, transport = "recording") => ({
|
||||
id,
|
||||
modality,
|
||||
transport,
|
||||
availability: "available",
|
||||
previewUrl: null,
|
||||
delivery: modality === "video" ? { kind: "recorded-fmp4-manifest" } : null,
|
||||
activation: null,
|
||||
capabilities: {
|
||||
defaultVisible: true,
|
||||
overlay: modality === "video",
|
||||
},
|
||||
});
|
||||
const sources = [
|
||||
source("recorded.spatial.primary", "point-cloud"),
|
||||
source("recorded.camera.left", "video"),
|
||||
source("recorded.camera.right", "video"),
|
||||
source("live.camera", "video", "websocket"),
|
||||
];
|
||||
|
||||
assert.deepEqual(
|
||||
visibleSourceIdsAfterRecordedCatalogActivation(
|
||||
["recorded.spatial.primary"],
|
||||
sources,
|
||||
),
|
||||
["recorded.spatial.primary", "recorded.camera.left", "recorded.camera.right"],
|
||||
);
|
||||
});
|
||||
|
||||
test("workspace layout API uses the canonical endpoint and optimistic revision", async () => {
|
||||
const calls = [];
|
||||
const current = decodeObservationWorkspaceLayoutProfile(wireProfile());
|
||||
|
|
|
|||
|
|
@ -19,47 +19,46 @@ Each gate produces evidence and an explicit GO, PAUSE or BLOCKED result.
|
|||
| Stage 5 camera | GO (live) — left/right RTSP/H.264 preview observed, read-only runtime adapter and physical UI acceptance completed |
|
||||
| Stage 6 live viewer | GO — React Control Station, embedded self-hosted Rerun cloud/trajectory, plugin-owned spatial controls and live device/Mac metrics |
|
||||
| Stage 7 observation archive | GO (point/pose/telemetry contract) — durable catalog, recovery, capture-clock-bounded RRD preparation, archived metric time series, saved-session timeline and atomic playback are implemented |
|
||||
| Stage 7 recorded cameras | GO — the accepted physical session sealed real fMP4 camera data, produced two digest-bound recorded sources and passed manifest/init/segment range admission alongside a 107 MB RRD; browser QA opened real frames from the 170 MB camera epoch at multiple shared-timeline positions |
|
||||
| Stage 7 recorded cameras | GO (contract), acceptance pending — acquisition-owned fMP4 archive and player are implemented/tested; one real archived K1 camera plus point-cloud session has not passed playback yet |
|
||||
| Plugin isolation | GO (laboratory control plane) — vendor backend/frontend and optional scene controls are plugin-owned; manifest/runtime descriptor parity, versioned handshake, lifecycle health and transport correlation fail closed while execution remains in-process |
|
||||
| K1 application control | GO (physical staged cycle) — after fixing the PCAP-proven `sint64` time field, one explicit UI launch completed all 14 canonical operations on one control session, reached live `SCANNING + project + init_ready`, displayed real points, then one explicit STOP returned K1 to unbound `READY`. No retry or fallback command was sent. Native-project reuse through LixelGO/USB remains an independent verification |
|
||||
| K1 application control | OPERATOR ACCEPTANCE READY — the failed protocol-collapsed path and standalone START/STOP remain disabled. Plugin v0.5.0 installs one continuous control-session owner; one explicit UI launch intent advances connection, workspace, project and START only across live response barriers, while STOP and steady-green confirmation remain separate. No wall-clock transition, automatic retry, navigation-triggered command or repository-test equipment I/O exists. A full operator-run physical cycle and native-project verification remain open |
|
||||
| Stage 8 product storage | PAUSE — retention, replication, encryption, capacity monitoring and long-run browser/WASM stress remain deployment gates |
|
||||
|
||||
USB project copying remains optional ground truth rather than a blocker for the
|
||||
now-verified network path. Owner-operated LixelGO traffic verifies the MQTT
|
||||
start/stop mapping and RTSP camera transport. The exact start/stop protobuf
|
||||
encoder, response correlator and device-status state machine now run in the
|
||||
separately gated interactive publisher; the legacy shadow publisher remains
|
||||
disabled. Retained PCAP plus client static analysis prove that OpenAPI is one
|
||||
encoder, response correlator and device-status state machine now exist without a
|
||||
publisher. Retained PCAP plus client static analysis prove that OpenAPI is one
|
||||
private application-level value in this LixelGO build, not a per-scanner
|
||||
credential. The selected BLE peripheral returns its own LAN IP; the initial
|
||||
unbound `DeviceInfoRequest` then returns vendor ID, serial, model, activation and
|
||||
version facts for that live K1. Mission Core's shadow bootstrap reproduces the
|
||||
10 observed pre-START requests byte-for-byte across four response windows and
|
||||
five observed publish groups: initial DeviceInfo binding; then the ordered
|
||||
ModelingStatus/RTK and time-sync/DeviceInfo/RTK groups without an invented
|
||||
response barrier between them; NTRIP read; then cloud/RTK/DeviceInfo reads. The first
|
||||
10 observed pre-START requests byte-for-byte across five recovered batches:
|
||||
initial DeviceInfo binding; ModelingStatus plus RTK read; time sync plus
|
||||
DeviceInfo/RTK; NTRIP read; then cloud/RTK/DeviceInfo reads. The first
|
||||
ModelingStatus request has no mandatory synchronous response barrier; readiness
|
||||
is separately attested through live DeviceStatus. A fixed macOS Keychain loader
|
||||
now reads the exact 36-byte authority without environment/file/browser fallback.
|
||||
The response orchestrator correlates required headers/results and rejects
|
||||
identity/profile drift. The legacy shadow publisher remains a separate
|
||||
write-disabled type that cannot call its injected sink. Its dormant coordinator
|
||||
is wired into the XGRIDS facade only for redacted planning/state. Separately,
|
||||
plugin v0.5.0 installs the reviewed physical-acceptance transport behind explicit
|
||||
operator actions and one continuous background session owner. Each operation
|
||||
key is consumed before publish, automatic reconnect and application retry are
|
||||
forbidden, and any unknown outcome poisons the transport. Public state contains
|
||||
only bounded lifecycle, counter and failure metadata; authority and device
|
||||
identity remain private.
|
||||
identity/profile drift. The installed publish boundary is a separate
|
||||
write-disabled type that cannot call its injected sink. A dormant coordinator
|
||||
is now wired into the XGRIDS facade and plugin manifest. Explicit operator arm
|
||||
loads the Keychain value into a bounded 15–300 second process-memory lease;
|
||||
expiry, disarm, reprovision, acquisition preparation and shutdown drop both the
|
||||
lease and orchestrator. Public state contains only lease timing/booleans. There
|
||||
is deliberately no batch-emission method, live MQTT sink or UI button in the
|
||||
installed runtime. A separate physical-acceptance-only transport and dialogue
|
||||
executor now implement the reviewed MQTT exchange without facade/UI wiring.
|
||||
Each operation key is consumed before publish, automatic reconnect and
|
||||
application retry are forbidden, and any unknown outcome poisons the transport.
|
||||
The one-time `k1link authority provision` command delegates secret entry to the
|
||||
macOS Keychain TTY prompt and validates through the production loader without
|
||||
receiving the value in argv/environment/file/browser state. During the first
|
||||
physical attempt that CLI loader did not receive macOS approval, so a temporary
|
||||
process-local Security.framework lab adapter supplied the already reviewed
|
||||
value. The transport emitted bootstrap ordinals 1–6 and received all five
|
||||
required responses through the old third window, but topic-only routing
|
||||
misassociated the unbound and bound RTK responses and the orchestrator raised
|
||||
`ApplicationBootstrapError`. No START was emitted;
|
||||
required responses through batch 3, but the orchestrator raised
|
||||
`ApplicationBootstrapError` while correlating that batch. No START was emitted;
|
||||
live DeviceStatus stayed READY and the local point/pose counts remained zero.
|
||||
The local capture was sealed and the temporary Keychain item was deleted.
|
||||
The second physical attempt then correlated all ten bootstrap requests and one
|
||||
|
|
@ -78,15 +77,10 @@ START confirmation. Captured human delays are not replayed or treated as minimum
|
|||
timeouts. After the immediate operation-12 read, operations 13–14 wait for the
|
||||
same bound K1 to report `SCANNING`, a bound project and `init_ready=true`.
|
||||
The same transport is then serviced until an explicit STOP request; post-STOP
|
||||
ownership waits for live unbound `READY`. System-error
|
||||
and status reports are decoded into redacted safety state. The next staged
|
||||
physical attempt, after correcting the protobuf `sint64` time field, completed
|
||||
all 14 canonical operations, reached live scanning with real point/pose data and
|
||||
then completed one explicit STOP. K1 returned to unbound `READY`; no retry or fallback device command was
|
||||
sent. The durable local session is catalogued as ready/replayable, its RRD and
|
||||
two camera sources pass digest-bound range admission, and physical double-click
|
||||
remains the fallback. Reuse of the vendor-native project is still verified
|
||||
separately through LixelGO or USB.
|
||||
ownership waits for live `READY` plus physical standby confirmation. System-error
|
||||
and status reports are decoded into redacted safety state. This path is not
|
||||
wired into the plugin runtime/UI and has only synthetic regression coverage;
|
||||
physical STOP/save remains untested and the button is the safe fallback.
|
||||
|
||||
The Stage 6 live path uses a bounded raw-first bridge: loss in the visualization
|
||||
queue cannot discard MQTT evidence. The queue holds four preview messages;
|
||||
|
|
@ -116,9 +110,8 @@ saved-session switching, persisted replay reattach and manual source
|
|||
input/apply/reset until that acquisition has been finalized. The successful
|
||||
automatic-source action is an internal start-result boundary and intentionally
|
||||
does not masquerade as an operator source switch. The K1 scene-level stop
|
||||
control is plugin-owned. A plugin-commanded acquisition sends one canonical STOP
|
||||
and seals locally after protocol-reported standby; an operator-manual acquisition
|
||||
still stops and seals only local reception and reports scanner state as unknown.
|
||||
control is plugin-owned; with the active read-only profile it stops and seals
|
||||
local reception only and never claims to stop the scanner.
|
||||
|
||||
## Stage 0 — repository and host baseline
|
||||
|
||||
|
|
@ -304,10 +297,11 @@ useful stream is decoded or structurally identified.
|
|||
- automated scan-button electronics;
|
||||
- OpenWrt/monitor-mode infrastructure;
|
||||
- firmware or internal-Linux analysis;
|
||||
- browser-visible end-to-end shared-timeline playback of the accepted archive
|
||||
containing point cloud plus one selected K1 camera (backend preparation and
|
||||
digest-bound byte admission are already accepted);
|
||||
- physical end-to-end shared-timeline playback for a newly archived session
|
||||
containing point cloud plus one selected K1 camera;
|
||||
- OS-independent plugin/edge authority provider, PCAP-to-executor transcript
|
||||
regression and runtime ownership integration;
|
||||
regression, runtime ownership integration, a separately reviewed and newly
|
||||
permitted operator-present START/STOP attempt, and stable-artifact proof after
|
||||
STOP;
|
||||
- long-running large-session WebViewer/WASM memory telemetry;
|
||||
- production retention, replication, encryption and cross-platform packaging.
|
||||
|
|
|
|||
|
|
@ -186,16 +186,10 @@ special time-sync session is exactly
|
|||
`${device_id}:DeviceConfigRequest:Publish_Proto_DeviceConfig_SetTime`. The
|
||||
repository shadow bootstrap reproduces all ten retained payloads and their
|
||||
topic order byte-for-byte. Time sync is the only mutation before START; the
|
||||
other nine requests are reads. Its Unix timestamp is protobuf `sint64`, not
|
||||
`int64`/`uint64`: the retained value `3568430060` ZigZag-decodes to
|
||||
`1784215030` (`2026-07-16 15:17:10 UTC`). The runtime encoder must therefore
|
||||
ZigZag-encode the current epoch before writing field 1 of the nested time
|
||||
configuration.
|
||||
other nine requests are reads.
|
||||
|
||||
The first ten requests contain five observed publish groups but only four
|
||||
application-response windows. The initial DeviceInfo response is the identity
|
||||
binding barrier; ordinals 2–6 then retain their exact publish order without an
|
||||
invented response barrier between ordinals 3 and 4. Two independent owner-operated START
|
||||
The first ten requests use five response-barrier batches, but those barriers are
|
||||
not the whole application lifecycle. Two independent owner-operated START
|
||||
captures contain the same 14-operation order: the ten requests above, START,
|
||||
one bound `ModelingStatus` read, then a bound `DeviceInfo` plus
|
||||
`ModelingStatus` refresh about 24–25 seconds after START. The first capture
|
||||
|
|
@ -204,15 +198,11 @@ reached it after 54.130 seconds. This variation is operator/UI time, while the
|
|||
stage order is invariant. In both captures the original control MQTT connection
|
||||
remained open throughout initialization and the later refresh; no DISCONNECT or
|
||||
UNSUBSCRIBE occurred. DeviceInfo response establishes identity before later
|
||||
bound requests. Required response barriers are correlated by operation key, topic, exact
|
||||
bound requests. Required response barriers are correlated by topic, exact
|
||||
session, vendor identity, application authority and numeric success. The
|
||||
initial and immediate post-START ModelingStatus reads are intentionally not
|
||||
treated as mandatory synchronous responses. In both retained captures operation
|
||||
12 has no application response; the single bound ModelingStatus response in
|
||||
that part of the lifecycle arrives only after operation 14 and belongs to it.
|
||||
An unanswered optional operation therefore cannot reserve FIFO ownership over
|
||||
the required operation-14 response. Readiness belongs to live DeviceStatus plus
|
||||
point/pose observation.
|
||||
treated as mandatory synchronous responses. Readiness belongs to live
|
||||
DeviceStatus plus point/pose observation.
|
||||
|
||||
The UI/event correlation is also fixed by the retained capture. Requests 1–6
|
||||
follow successful data connection. Entering the scan workspace causes request
|
||||
|
|
@ -231,7 +221,7 @@ from `SCANNING` with a bound project. It emits only a non-executable digest and
|
|||
wire metadata, uses QoS 2 with retain false, and declares automatic retry
|
||||
forbidden. It has no MQTT publish dependency and cannot send a command.
|
||||
|
||||
Legacy shadow-plan publishing remains deliberately disabled. The exact 36-byte authority has a
|
||||
Publishing remains deliberately disabled. The exact 36-byte authority has a
|
||||
read-only macOS Keychain loader with no environment, file, browser or API
|
||||
fallback. A facade-owned dormant coordinator can arm it only after explicit
|
||||
operator confirmation, a connected/attested K1, idle runtime and no active
|
||||
|
|
@ -240,9 +230,8 @@ seconds and is revoked by expiry, disarm, reprovision, acquisition preparation
|
|||
or shutdown. The current one-shot boundary is structurally write-disabled,
|
||||
exposes no emission method and cannot touch its injected sink.
|
||||
|
||||
A separate operator-driven physical-acceptance implementation is installed in
|
||||
plugin v0.5.0 through the facade/runtime. Retained-PCAP re-audit fixes its MQTT
|
||||
contract at 3.1.1,
|
||||
A separate physical-acceptance-only implementation now exists outside the
|
||||
facade/runtime. Retained-PCAP re-audit fixes its MQTT contract at 3.1.1,
|
||||
`clean_session=false`, keepalive 60, the three ordered 9/5/42-topic control
|
||||
subscription groups, separation from the point-cloud client, QoS 2
|
||||
request/PUBCOMP completion and
|
||||
|
|
@ -251,14 +240,12 @@ consumes every ordinal/action operation key before publish, never reconnects or
|
|||
retries, rejects stale/duplicate/unexpected responses and poisons every unknown
|
||||
post-publish outcome. A 15–120 second permit requires explicit operator-present,
|
||||
owner-controlled-device, LixelGO-closed, battery/storage and expected-state
|
||||
confirmations. START additionally requires the complete four-window bootstrap;
|
||||
STOP requires its own separate permit. No plugin state read, poll, navigation or
|
||||
automatic lifecycle event may invoke either action. On 2026-07-18 its first
|
||||
operator-present physical attempt made one
|
||||
confirmations. START additionally requires the complete five-batch bootstrap;
|
||||
STOP requires its own separate permit. This code is not installed as a plugin
|
||||
action. On 2026-07-18 its first operator-present physical attempt made one
|
||||
connection and emitted bootstrap ordinals 1–6. All six QoS 2 publishes completed
|
||||
and five required responses were collected, but the old topic-only collector
|
||||
misrouted one of the two `GetRtkAdvance` responses in its third window. The
|
||||
executor stopped before building or publishing START;
|
||||
and five required responses were collected, but exact correlation rejected one
|
||||
response in batch 3. The executor stopped before building or publishing START;
|
||||
K1 remained `READY`, project binding stayed absent and point/pose remained zero.
|
||||
No automatic retry was attempted. A second explicitly permitted attempt then
|
||||
correlated all ten bootstrap responses and one START success response, with 11
|
||||
|
|
@ -278,9 +265,8 @@ observed connection, workspace-entry and project-prompt checkpoints instead of
|
|||
wall-clock floors, sends the immediate post-START ModelingStatus read, services
|
||||
the same MQTT socket until live `SCANNING + project + init_ready`, then emits the
|
||||
DeviceInfo/ModelingStatus refresh. It continues servicing that socket until an
|
||||
explicit STOP request and afterwards until live unbound `READY`. That device
|
||||
status is the canonical standby evidence; no second visual acknowledgement is
|
||||
required. System-error and DeviceStatus reports are decoded into redacted safety
|
||||
explicit STOP request and afterwards until live `READY` plus operator-confirmed
|
||||
standby. System-error and DeviceStatus reports are decoded into redacted safety
|
||||
state; a fault forbids further automatic action. Standalone START/STOP are
|
||||
disabled. MQTT keepalive/report processing also continues while the operator is
|
||||
between UI actions; the 15–120-second command permit is created at the actual
|
||||
|
|
@ -298,18 +284,13 @@ one-shot process and deleted immediately afterward. It is not the product
|
|||
authority architecture. The acceptance executor now records only ordinal,
|
||||
message type, response topic, payload size, SHA-256 and a safe correlation
|
||||
reason; it never exposes response bytes or authority in diagnostics. The
|
||||
retained clean-cycle now completes all nine bootstrap barriers offline while
|
||||
ordinals 3 and 6 are simultaneously in flight and routes them by exact
|
||||
session/device identity. After repairing the PCAP-proven protobuf `sint64` time
|
||||
field, the next operator-run physical cycle completed all 14 canonical requests
|
||||
on one socket, reached `SCANNING + project + init_ready`, delivered real
|
||||
point/pose data and completed one correlated STOP. Streams quiesced, K1 returned
|
||||
to unbound `READY`; no retry or fallback STOP was sent. The operator also
|
||||
observed stable green, but that observation is not a second protocol gate. The
|
||||
local archive sealed and passed digest-bound RRD and recorded-
|
||||
camera admission. Appearance and reuse of the vendor-native project through
|
||||
LixelGO or USB remains a separate gate. Physical double-click remains the
|
||||
operational fallback.
|
||||
retained clean-cycle still completes all nine bootstrap barriers offline, so
|
||||
the open issue is a live batch-3 response divergence. The complete stop/save
|
||||
sequence has not physically proved a durable vendor project. A
|
||||
correlated STOP success, stream quiescence, return to
|
||||
`READY`/steady-green hardware state and appearance of a reusable native project
|
||||
are separate gates. The verified physical double-click remains the acquisition
|
||||
control until a separately reviewed one-shot write gate closes.
|
||||
|
||||
## Decoder and raw-capture bounds
|
||||
|
||||
|
|
|
|||
|
|
@ -134,10 +134,8 @@ directly and use their sibling metadata receive timestamps when present.
|
|||
from a browser timer or accumulated across a device counter reset.
|
||||
10. Choose **Остановить устройство и запись** in the scene. The
|
||||
plugin-commanded path emits one canonical STOP on the original socket,
|
||||
waits for live unbound READY, then finalizes capture, camera archive and
|
||||
summaries automatically. The UI does not ask for a redundant green-indicator
|
||||
confirmation because READY plus cleared project binding is already the
|
||||
scanner's protocol evidence.
|
||||
waits for live unbound READY, and asks the operator to confirm the constant
|
||||
green indicator before capture, camera archive and summaries are finalized.
|
||||
|
||||
Each new live run creates a direct child below `MISSIONCORE_EVIDENCE_DIR`, or
|
||||
`.runtime/mission-core/evidence/sessions/` by default, with raw MQTT frames,
|
||||
|
|
@ -338,10 +336,9 @@ listener and its process memory must be closed unconditionally.
|
|||
left/right compressed RTSP preview and acquisition-owned fMP4 archive use a
|
||||
separate generic media path. Historical sessions predating that archive have
|
||||
no recoverable video.
|
||||
- Physical double-click remains the independently verified device-local fallback.
|
||||
Mission Core also contains the installed response-gated operator-present
|
||||
acceptance transport for the exact K1/A4/FW 3.0.2 profile; its full
|
||||
START-to-STOP durable-save cycle was physically accepted on 2026-07-18/19.
|
||||
- Physical double-click remains the K1 scan start/stop control. Any MQTT command
|
||||
promotion needs reviewed Keychain item provisioning, operator-present use of
|
||||
the uninstalled response-gated transport and durable-save proof.
|
||||
- No terrain map, elevation model, obstacle segmentation, localization fusion,
|
||||
mission planner or vehicle control is implemented by this viewer milestone.
|
||||
- Exact coordinate axes and the scanner-to-vehicle transform remain a mounting
|
||||
|
|
|
|||
|
|
@ -128,14 +128,15 @@ requires every model to be covered by a reviewed, plugin-local, path-confined
|
|||
compatibility profile. The current XGRIDS profile matches exactly:
|
||||
|
||||
- XGRIDS LixelKity K1;
|
||||
- wire-level platform type `A4`;
|
||||
- firmware `3.0.2`;
|
||||
- direct-LAN topology;
|
||||
- one retained physical laboratory evidence scope.
|
||||
|
||||
Unknown or mismatched firmware fails closed. The UI selects the profile and the
|
||||
canonical bootstrap verifies model, platform type, activation and firmware from
|
||||
a correlated live `DeviceInfo` before START. The profile independently records observation, decode, replay,
|
||||
Unknown or unattested firmware fails closed. The current runtime does not read
|
||||
firmware from the device: it activates this limited, read-only profile only
|
||||
after explicit operator attestation of firmware `3.0.2` and direct-LAN topology,
|
||||
and records the basis as `operator-attested`. That claim is not device-derived
|
||||
proof. The profile independently records observation, decode, replay,
|
||||
physical-verification and write evidence. Loading it cannot authorize a
|
||||
transport mutation. BLE Wi-Fi provisioning and application control retain
|
||||
separate explicit operator gates.
|
||||
|
|
@ -148,30 +149,30 @@ is one private application-level authority; vendor ID and serial come from the
|
|||
BLE-selected live K1 and are cross-checked against status. Plugin v0.5.0 owns a
|
||||
single interactive MQTT session from connection through STOP/standby, with one
|
||||
explicit UI action per recorded lifecycle boundary and no automatic retry. A
|
||||
fixed Keychain loader and dormant shadow coordinator remain present. The staged
|
||||
physical START/live/STOP/unbound-READY cycle is accepted on the reviewed unit;
|
||||
write capability is exposed only while that interactive socket owner is active.
|
||||
fixed Keychain loader and dormant shadow coordinator remain present. Stable
|
||||
physical proof after STOP remains unresolved; write capability is exposed only
|
||||
while that interactive socket owner is active.
|
||||
|
||||
## Semantic lifecycle
|
||||
|
||||
The transitional facade now creates separate provisional device,
|
||||
device-session, acquisition and operation IDs. Acquisition can be prepared,
|
||||
wait for receiver readiness, then wait for an external physical start, and become acquiring only after real point
|
||||
data. Plugin-commanded STOP finalizes automatically after the same bound control
|
||||
session reports unbound READY. Operator-manual capture-only stop still reports
|
||||
the K1 physical state as unknown.
|
||||
data, wait for an externally confirmed stop, finalize and complete. Capture-only
|
||||
stop reports the K1 physical state as unknown.
|
||||
|
||||
Preparation requires a project name. The frontend and backend both apply NFKC
|
||||
normalization plus surrounding-whitespace trimming, reject control/surrogate
|
||||
characters and names above 96 Unicode characters, and store the result as
|
||||
session/catalog display metadata. It is not a path component. The accepted
|
||||
interactive START carries this exact validated value to K1.
|
||||
session/catalog display metadata. It is not a path component. Because command
|
||||
publishing is off, the current integration does not claim that the name reaches
|
||||
K1.
|
||||
|
||||
The K1 contribution may mount a `SpatialControlsView` beside the host-owned
|
||||
viewport. It presents plugin lifecycle wording, the stop action and live
|
||||
`ModelingReport` scan time, route distance and speed. In plugin-commanded mode
|
||||
the stop action sends the canonical device STOP; in operator-manual mode it
|
||||
seals only local reception. `ModelingReport` is consumed before the visual preview queue through
|
||||
viewport. It presents plugin lifecycle wording, the local stop action and live
|
||||
`ModelingReport` scan time, route distance and speed. Under the current
|
||||
capture-only profile the stop action seals local reception; it does not claim a
|
||||
device stop. `ModelingReport` is consumed before the visual preview queue through
|
||||
an injected observer, preserving the generic normalizer/Rerun boundary.
|
||||
|
||||
The operation journal is bounded and in memory. It records IDs, idempotency,
|
||||
|
|
@ -179,9 +180,9 @@ declared deadlines, progress and terminal results without action parameters or s
|
|||
It is not durable, distributed or recoverable after process restart.
|
||||
|
||||
Semantic acquisition actions coexist with legacy `stream.*` actions for
|
||||
v1alpha1/UI compatibility. On the exact accepted profile, a plugin-commanded
|
||||
acquisition owns canonical START and STOP plus local reception. Physical
|
||||
double-click remains the independently verified device-local fallback.
|
||||
v1alpha1/UI compatibility. On this profile, start and stop operate the local
|
||||
receiver and operator workflow only. K1 scanning is still started and stopped
|
||||
by physical double-click; no modeling request is published.
|
||||
|
||||
Left/right camera preview transport, endpoint paths and H.264 framing are now
|
||||
observed under the exact compatibility profile. The local read-only adapter
|
||||
|
|
@ -189,8 +190,8 @@ copy-remuxes one selected RTSP producer into bounded fMP4/WebSocket delivery for
|
|||
the generic MSE UI. New acquisitions archive selected-camera init/segments/index
|
||||
independently of browser delivery. The generic recorded player, manifest-v2
|
||||
validation and shared `session_time` controls are connected; historical sessions
|
||||
created before this archive contract contain no recoverable video. The TEST007
|
||||
recorded-camera package and browser playback are physically accepted. Portable FFmpeg packaging, disk-backed
|
||||
created before this archive contract contain no recoverable video, so physical
|
||||
recorded-camera acceptance remains open. Portable FFmpeg packaging, disk-backed
|
||||
browser buffering, fan-out and remote delivery also remain open. Device
|
||||
calibration command and sensor-to-vehicle extrinsics are unavailable.
|
||||
|
||||
|
|
@ -222,9 +223,9 @@ views.
|
|||
4. Physically accept a newly archived left/right K1 session, then package the
|
||||
read-only RTSP/H.264 adapter for each target OS, add disk-backed sealed media
|
||||
caching and evolve same-host MSE delivery toward an authenticated Edge media
|
||||
plane. Keep modeling commands limited to the installed operator-present,
|
||||
exact-profile acceptance transport until durable-save confirmation is
|
||||
physically accepted.
|
||||
plane. Keep the modeling-command publisher disabled until operator-owned
|
||||
Keychain item provisioning, physical acceptance of the reviewed uninstalled
|
||||
transport and durable-save confirmation gate.
|
||||
|
||||
Complex equipment will likely be assembled from separately useful component
|
||||
plugins into configured hardware packs, while standalone equipment remains
|
||||
|
|
|
|||
|
|
@ -41,9 +41,7 @@ K1 application control is exact-profile, live-bound and fail-closed.
|
|||
boundaries and one continuous control MQTT session. The first ten operations
|
||||
are pre-START; operation 11 is START; operation 12 is a bound ModelingStatus
|
||||
read; operations 13–14 are the post-initialization DeviceInfo/ModelingStatus
|
||||
refresh. Operation 12 is response-free in both retained captures; the sole
|
||||
later ModelingStatus response follows and belongs to required operation 14.
|
||||
The only preparatory mutation is time/timezone sync. No stage is
|
||||
refresh. The only preparatory mutation is time/timezone sync. No stage is
|
||||
collapsed, skipped or automatically retried. Captured wall-clock gaps are
|
||||
operator/UI timing, not protocol delays. Stage progression uses explicit UI
|
||||
actions and live K1 state reports.
|
||||
|
|
@ -57,10 +55,9 @@ K1 application control is exact-profile, live-bound and fail-closed.
|
|||
new operator-authorized attempt.
|
||||
8. No firmware, activation, account, update or vendor-cloud mutation belongs to
|
||||
this path. `GetCloudServerConfig` is a local K1 read.
|
||||
9. STOP completion has separate gates: correlated result, protocol-reported
|
||||
unbound READY, then local stream quiescence/evidence sealing and independent
|
||||
native-project verification. Stable green is useful physical corroboration,
|
||||
not a second operator acknowledgement in the application lifecycle.
|
||||
9. STOP completion has separate gates: correlated result, local stream
|
||||
quiescence/evidence sealing, READY plus steady green, then independent native
|
||||
project verification.
|
||||
|
||||
## Recovered START sequence
|
||||
|
||||
|
|
@ -88,9 +85,7 @@ captured special relation
|
|||
## Current implementation boundary
|
||||
|
||||
`application_bootstrap.py` provides DeviceInfo decoding, the exact ten-request
|
||||
shadow plan and a four-window response-barrier orchestrator. The initial
|
||||
DeviceInfo response binds identity; ordinals 2–6 then remain ordered in one
|
||||
window so ordinal 3 does not create a barrier absent from the captures. Its private retained
|
||||
shadow plan and a five-batch response-barrier orchestrator. Its private retained
|
||||
regression completes with 10/10 request payload/topic matches and correlated
|
||||
required responses. `ModelingStatusRequest` remains non-blocking exactly where
|
||||
the capture had no synchronous response. `application_authority.py` reads the
|
||||
|
|
@ -106,11 +101,11 @@ its injected transport sink can be called. Both shadow paths remain blocked by
|
|||
`vendor-writes-disabled` and `publisher-not-installed`; no MQTT publisher or
|
||||
automatic retry path exists.
|
||||
|
||||
`application_execution.py` defines the legacy dormant shadow boundary. An
|
||||
`application_execution.py` adds the dormant facade boundary. An
|
||||
operator-confirmed arm may retain authority for 15–300 seconds in a
|
||||
non-exportable process-memory lease and construct the shadow orchestrator. The
|
||||
lease and orchestrator are dropped together on expiry/disarm and also before
|
||||
reprovision, acquisition preparation or shutdown. The prior plugin v0.4.0 exposed only
|
||||
reprovision, acquisition preparation or shutdown. Plugin v0.4.0 exposes only
|
||||
shadow arm/disarm/state actions and redacted snapshots. The coordinator has no
|
||||
request-emission method and the installed sink is explicitly uninstalled; this
|
||||
runtime wiring does not widen write authority.
|
||||
|
|
@ -128,14 +123,15 @@ the same operation key can never be attempted again by that transport.
|
|||
|
||||
The acceptance executor requires a 15–120 second single-action permit backed by
|
||||
five explicit operator confirmations. After the failed 2026-07-18 physical
|
||||
attempt exposed topic-only response routing and an invented ordinal-3 barrier, the collapsed
|
||||
bootstrap entry point and standalone START/STOP were disabled. Their staged
|
||||
replacement, installed in plugin v0.5.0, binds requests 1–6 to connection, request 7 to scan-workspace entry,
|
||||
attempt proved that five response batches alone are insufficient, the collapsed
|
||||
bootstrap entry point and standalone START/STOP were disabled. The uninstalled
|
||||
replacement binds requests 1–6 to connection, request 7 to scan-workspace entry,
|
||||
requests 8–10 to project-prompt entry and START to a separate operator permit.
|
||||
There is no separate project-name mutation; the validated name is carried by
|
||||
START. Operations 13–14 are gated by bound `SCANNING`, project presence and
|
||||
`init_ready`, not by the observed 24–25-second duration. The same owner services
|
||||
MQTT through explicit STOP and waits for live unbound READY. Fault/status observations are redacted and any fault forbids automatic
|
||||
MQTT through explicit STOP and waits for live READY plus operator-confirmed
|
||||
standby. Fault/status observations are redacted and any fault forbids automatic
|
||||
continuation. The socket remains serviced during operator/UI waits, and each
|
||||
short action permit is created at its button confirmation rather than at
|
||||
connection time.
|
||||
|
|
@ -146,10 +142,10 @@ touch the control MQTT client. The standard UI turns one explicit launch intent
|
|||
into response-gated REST actions for session open (1–6), workspace entry (7),
|
||||
project preparation (8–10) and START. This is control compression, not protocol
|
||||
compression: each next action waits for the exact server/device phase, and
|
||||
local polling never retries or times a K1 write. STOP remains a separate explicit
|
||||
operator action; the subsequent READY/unbound transition completes without a
|
||||
second click. Preparing local reception happens before the START event is released.
|
||||
After STOP, local capture remains owned until that live protocol state. Page close, model switching,
|
||||
local polling never retries or times a K1 write. STOP and steady-green
|
||||
confirmation remain separate operator actions. Preparing local reception happens before the
|
||||
START event is released. After STOP, local capture remains owned until live
|
||||
unbound READY and explicit visual confirmation. Page close, model switching,
|
||||
polling and service state reads cannot emit START/STOP; model switching is
|
||||
blocked while the socket is open. Repository tests replace the transport with
|
||||
in-memory fakes and perform no equipment I/O.
|
||||
|
|
@ -163,8 +159,8 @@ command has not been executed in this milestone.
|
|||
|
||||
## Promotion gate
|
||||
|
||||
Promotion now requires operator-present physical acceptance of the installed,
|
||||
explicitly staged transport:
|
||||
Promotion now requires operator-present physical acceptance of the uninstalled
|
||||
transport:
|
||||
|
||||
1. provision the reviewed private authority into the fixed local Keychain item;
|
||||
2. confirm battery/storage and select one K1 over BLE;
|
||||
|
|
@ -175,8 +171,7 @@ explicitly staged transport:
|
|||
reads and observe calibration plus first point/pose/camera data;
|
||||
7. send one STOP without retry and correlate its response;
|
||||
8. seal local evidence while accepting the bounded stream tail;
|
||||
9. wait for protocol-reported unbound READY and record stable green as physical
|
||||
corroboration;
|
||||
9. wait for READY and steady green;
|
||||
10. verify the native project through the vendor-supported workflow.
|
||||
|
||||
Any unknown response, identity change, fault, low battery/storage or unexpected
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
- Date: 2026-07-18
|
||||
- Device scope: one owner-controlled LixelKity K1, firmware `3.0.2`
|
||||
- Result: **GO — staged START, live acquisition, STOP and READY recovery accepted**
|
||||
- Result: **PAUSE — START acknowledgement is not lifecycle acceptance**
|
||||
|
||||
This report contains no device identity, application authority, address,
|
||||
credential, raw MQTT payload or project contents. Private evidence remains in
|
||||
|
|
@ -22,12 +22,6 @@ completed and five required responses arrived, but the batch-3 response did not
|
|||
pass exact correlation. The executor stopped before START. K1 remained READY
|
||||
and steady green; point/pose counts stayed zero.
|
||||
|
||||
Subsequent comparison with the independent fast capture proved that ordinal 3
|
||||
is not an application-response barrier: ordinals 4–6 may be published before
|
||||
its response. Ordinals 3 and 6 share a response topic but use distinct unbound
|
||||
and bound sessions. The repaired collector therefore routes by operation plus
|
||||
session/device identity and waits for the combined ordinal 2–6 response window.
|
||||
|
||||
## Attempt 2
|
||||
|
||||
After adding redacted live-response diagnostics, one new explicit permit was
|
||||
|
|
@ -43,65 +37,6 @@ The bounded decoder maps the observed namespace/value as
|
|||
`0x32040000 + 307 = ALGORITHM_ERROR`. A normal operator power cycle restored
|
||||
steady green. No guessed STOP or repeated START was sent.
|
||||
|
||||
## Attempt 3
|
||||
|
||||
The production plugin UI completed BLE selection and one reviewed Wi-Fi
|
||||
provisioning operation, then opened the staged control session from one explicit
|
||||
operator launch intent. The MQTT owner completed six QoS2 publishes and
|
||||
correlated five required responses. K1 remained in `READY`; no ModelingRequest,
|
||||
START or STOP was emitted. The session failed closed on ordinal 4
|
||||
`DeviceConfigRequest`, whose correlated response carried result `0x32040002`.
|
||||
Automatic and same-session retry remained disabled.
|
||||
|
||||
Re-reading the original clean-cycle PCAP exposed an encoder defect rather than
|
||||
a lifecycle-order failure. The captured time value is protobuf `sint64`: wire
|
||||
varint `3568430060` ZigZag-decodes to Unix epoch `1784215030` (`2026-07-16
|
||||
15:17:10 UTC`). Mission Core had encoded the current positive epoch directly as
|
||||
an unsigned varint. The timezone and surrounding request structure matched, but
|
||||
the device therefore received the wrong timestamp semantics and rejected the
|
||||
time-sync request. The encoder now applies canonical `sint64` ZigZag encoding,
|
||||
with an exact captured-value regression test. A new physical attempt remains
|
||||
operator-gated and requires a fresh steady-green check.
|
||||
|
||||
## Attempt 4
|
||||
|
||||
After fresh provisioning and an operator-confirmed steady-green check, one
|
||||
explicit plugin UI launch used the repaired staged session. All 14 canonical
|
||||
requests completed on one MQTT connection: 14 QoS2 transactions, 12 correlated
|
||||
application responses and the two evidence-backed optional-response cases. K1
|
||||
reported `SCANNING`, a bound project and `init_ready=true`; the live receiver
|
||||
decoded point cloud and pose data and the operator saw the scene on screen. No
|
||||
device system error was reported.
|
||||
|
||||
The operator then issued exactly one STOP from the plugin UI. Its QoS2 and
|
||||
correlated application response completed on the same control session. K1
|
||||
reported `READY`, cleared its project binding and initialization flag, stopped
|
||||
the live streams, saved, blinked during finalization and returned to a stable
|
||||
green indicator. No retry, fallback STOP or second device command was sent.
|
||||
|
||||
The local evidence session sealed with 6,088 raw MQTT messages and 73,260,759
|
||||
payload bytes, including 2,529 point-cloud and 2,680 pose messages. The durable
|
||||
manifest recorded 7,670,356 decoded points, zero decode errors, 36.029 m of
|
||||
route and a 302.677 s observation interval. Background preparation produced a
|
||||
107,002,392-byte digest-bound Rerun recording and admitted two recorded-camera
|
||||
sources. Ranged reads of the RRD, camera manifests, MP4 initialization data and
|
||||
media segments all passed their generation and content-digest checks.
|
||||
|
||||
Natural RTSP closure during K1 STOP exposed a local finalization race: the
|
||||
camera gateway initially classified the expected source end as an acquisition
|
||||
failure before the control owner could enter its standby-confirmation phase.
|
||||
The raw capture and all committed camera segments were already durable, and the
|
||||
device STOP had succeeded. The gateway now marks device-STOP source termination
|
||||
as expected, closes that camera generation as complete and preserves the
|
||||
separate unexpected-EOF failure path. A recovery boundary also permits the
|
||||
already-observed unbound READY state to retire an already-terminal local
|
||||
acquisition without ever sending a second STOP. Synthetic regression coverage
|
||||
exercises both paths.
|
||||
|
||||
One independent product check remains outside this control acceptance: confirm
|
||||
that the vendor-native project is reusable through LixelGO or USB. The
|
||||
application START/STOP, local archive and offline byte-serving gates are closed.
|
||||
|
||||
## Canonical-diff finding
|
||||
|
||||
Re-audit of two independent owner-operated LixelGO START captures found the
|
||||
|
|
@ -127,11 +62,7 @@ The first retained capture reached START at `+202.423 s`; the faster one at
|
|||
Both preserved the stage order, sent operation 12 within about one second of
|
||||
START, sent operations 13–14 about 24–25 seconds after START and kept the same
|
||||
control MQTT connection open throughout. Neither capture disconnected or
|
||||
unsubscribed during initialization. Neither capture contains a response to
|
||||
operation 12; each contains exactly one bound ModelingStatus response in this
|
||||
interval, immediately after operation 14. The repaired correlator therefore
|
||||
retires an unanswered optional operation-12 owner before admitting required
|
||||
operation 14.
|
||||
unsubscribed during initialization.
|
||||
|
||||
The same re-audit shows that those wall-clock gaps are not protocol timers.
|
||||
Requests 1–6 follow connection, request 7 follows explicit workspace entry,
|
||||
|
|
@ -152,14 +83,9 @@ The legacy collapsed `run_bootstrap()` executor now rejects before emission.
|
|||
The replacement acceptance-only executor uses explicit connection/workspace/
|
||||
project-prompt checkpoints with no captured-delay replay. It owns operations
|
||||
1–14, gates the final refresh on live K1 state, surfaces redacted system errors,
|
||||
keeps the same socket serviced through explicit STOP and waits for unbound
|
||||
READY. The socket is also serviced between every UI action;
|
||||
keeps the same socket serviced through explicit STOP and waits for READY plus
|
||||
operator-confirmed standby. The socket is also serviced between every UI action;
|
||||
START/STOP permits begin only at their respective confirmations. Standalone
|
||||
START/STOP are rejected. Plugin v0.5.0 installs the staged boundary behind
|
||||
explicit operator UI actions; no state read, poll, navigation event or test may
|
||||
authorize a physical attempt. Attempt 4 proved that path through live scanning,
|
||||
one explicit STOP and stable-green standby. A 2026-07-19 UX review removed the
|
||||
post-READY button because the live READY/unbound report already proves the state;
|
||||
the explicit pre-STOP operator action remains. Physical double-click remains the
|
||||
operational fallback; native-project reuse is still checked separately. The
|
||||
follow-up archive/UX findings are recorded in Lab 004.
|
||||
START/STOP are rejected. This remains an
|
||||
uninstalled synthetic-test-only boundary; no new physical attempt is authorized
|
||||
by this implementation. Physical double-click remains the canonical fallback.
|
||||
|
|
|
|||
|
|
@ -1,106 +0,0 @@
|
|||
# Lab 004 — canonical K1 control and durable archive milestone
|
||||
|
||||
- Date: 2026-07-19
|
||||
- Device scope: one owner-controlled LixelKity K1, platform `A4`, firmware `3.0.2`
|
||||
- Topology: direct LAN after BLE Wi-Fi provisioning
|
||||
- Result: **GO — Mission Core START/live/STOP/save and recorded replay accepted**
|
||||
|
||||
This milestone contains no device identity, application authority, Wi-Fi
|
||||
credential, address, raw MQTT payload, image or project contents. Private raw
|
||||
evidence remains in ignored runtime storage. Lab 003 retains the failed attempts
|
||||
and the exact protocol corrections that preceded this result.
|
||||
|
||||
## Accepted operator path
|
||||
|
||||
The operator selected the BLE-visible K1, provisioned it onto the existing LAN,
|
||||
and launched one project from the plugin UI. One continuously owned MQTT 3.1.1
|
||||
session performed the staged 14-operation dialogue. Every required response was
|
||||
correlated to its operation, session, device binding and authority. START was
|
||||
sent exactly once with QoS 2 and retain false; automatic retry and reconnect were
|
||||
disabled. K1 reached bound `SCANNING` with `init_ready=true`, and Mission Core
|
||||
displayed real point-cloud and pose data.
|
||||
|
||||
The operator then selected **Остановить устройство и запись** exactly once. STOP
|
||||
was sent on the same control session and its response was correlated. K1 blinked
|
||||
while saving, stopped its live streams, cleared the project binding, reported
|
||||
`READY`, and returned to stable green. No fallback or repeated device command
|
||||
was sent. TEST008 repeated the complete product path after the earlier TEST007
|
||||
acceptance and again produced a catalogued, viewable session.
|
||||
|
||||
The explicit STOP click remains a safety boundary. The former post-STOP button
|
||||
“Индикатор постоянно зелёный — завершить запись” was redundant: it was rendered
|
||||
only after the same bound K1 had already reported `READY` with no project. The
|
||||
current lifecycle therefore finalizes local receiver, camera and capture clock
|
||||
automatically on that protocol evidence. Stable green remains useful physical
|
||||
corroboration, not a second source of application state.
|
||||
|
||||
## Durable TEST007 package
|
||||
|
||||
The accepted TEST007 session contains a `107,002,392`-byte digest-bound RRD and
|
||||
two recorded-camera sources. The larger camera source contains `170,457,493`
|
||||
bytes across two source epochs: approximately `5.497 s` and `206.073 s`, with
|
||||
`2,117` committed media segments in total (`56 + 2,061`). The second camera
|
||||
source contains `5,373,206` bytes over approximately `6.695 s`.
|
||||
|
||||
The browser admitted the RRD plus every declared camera as one generation. Real
|
||||
frames from the large camera source were presented at shared-session positions
|
||||
approximately `82.58 s` and `98.68 s`; the native player reported ready state 4
|
||||
and the expected seekable ranges. This closes the earlier transport-only camera
|
||||
qualification. Historical captures created before camera archival still cannot
|
||||
acquire video retroactively.
|
||||
|
||||
## Archive lifecycle corrections
|
||||
|
||||
The saved-session catalog now lists the complete bounded page rather than three
|
||||
hard-coded rows, refreshes while the menu is open, prepares cold recordings in a
|
||||
single backend worker, and exposes the ready session without a manual server
|
||||
repair. Deletion uses the standard Foundry confirmation modal and removes both
|
||||
the catalog entry and server evidence/cache.
|
||||
|
||||
One launch lease originally remained pinned for a fixed 120 seconds after the
|
||||
launch document. This caused a false “recording is open” conflict when the
|
||||
operator opened TEST008, switched to another session, and immediately deleted
|
||||
TEST008. The launch-to-GET lease is now consumed by the first matching RRD GET
|
||||
after that response acquires its own lifetime pin. A completed transfer releases
|
||||
the response pin normally; deletion may also release an unused launch lease.
|
||||
An actually streaming response remains protected. Regression tests cover both
|
||||
the manager pin handoff and the complete HTTP replay/open/delete sequence.
|
||||
|
||||
The session header now names the active archive, for example
|
||||
`Пространственная сцена: CAM_REC_TEST`. Trash controls retain a transparent
|
||||
background on hover/focus instead of a red pill. An intentional canonical STOP
|
||||
is presented as a neutral disconnected source rather than a source error.
|
||||
|
||||
## Browser and memory finding
|
||||
|
||||
The first product attempt to open TEST007 rejected its valid 170 MB camera epoch
|
||||
because the frontend had a 128 MiB per-source admission cap. The reviewed caps
|
||||
are now 256 MiB per camera and 512 MiB per session, still bounded before fetch.
|
||||
The complete epoch then exceeded practical Media Source Extensions buffering on
|
||||
this browser. Because the archive is immutable and already fully digest-verified,
|
||||
the player now presents it as one fMP4 Blob while keeping bounded admission and
|
||||
generation checks. This is a local browser presentation decision, not a change
|
||||
to captured evidence.
|
||||
|
||||
## Current supported choices
|
||||
|
||||
- Connection: direct LAN is active; alternative connection modes remain disabled
|
||||
UI anchors until independently accepted.
|
||||
- Mount/scan carrier: handheld is active. Ground UGV and aerial UAV choices are
|
||||
visible but disabled anchors.
|
||||
- GNSS: no RTK is the active default. RTK is visible but disabled and optional;
|
||||
unanswered optional RTK reads do not authorize guessed configuration.
|
||||
- Hardware: one K1/A4/FW 3.0.2 unit is physically accepted. Live DeviceInfo still
|
||||
binds vendor ID/serial dynamically so the implementation is not hard-coded to
|
||||
this unit, but a second scanner and other firmware remain unaccepted.
|
||||
- Camera: one live camera producer is selected at a time. A recording may contain
|
||||
multiple camera sources or epochs after operator switching.
|
||||
- Native vendor project reuse through LixelGO or USB remains an independent check.
|
||||
|
||||
## Verification boundary
|
||||
|
||||
Repository verification covers protocol correlation, no-retry safety, automatic
|
||||
READY/unbound completion, expected RTSP end, archive sealing, digest/range
|
||||
admission, launch-lease handoff, immediate deletion, session naming and the
|
||||
absence of the redundant standby control. It does not simulate equipment I/O.
|
||||
The physical claims above come only from the owner-operated TEST007/TEST008 runs.
|
||||
|
|
@ -34,7 +34,7 @@ The plugin owns:
|
|||
acquisition pipeline UI.
|
||||
- the interactive canonical application-control session: one socket owner, one
|
||||
operator launch intent, response-gated workspace/project/START stages, a
|
||||
separate explicit STOP action, live status gates and no automatic retry.
|
||||
separate STOP confirmation, live status gates and no automatic retry.
|
||||
|
||||
The plugin does not own:
|
||||
|
||||
|
|
@ -74,10 +74,11 @@ in-process and its health is lifecycle-only; process isolation, crash/restart
|
|||
containment and signed deployment remain later supervisor gates.
|
||||
|
||||
The compatibility profile is descriptive and cannot itself authorize a vendor
|
||||
write. The UI selects the exact firmware `3.0.2` / direct-LAN profile; the
|
||||
canonical bootstrap then requires a correlated live `DeviceInfo` match for
|
||||
model `LixelKity K1`, platform type `A4`, activation and firmware before START.
|
||||
Validate the current exact-match profile without device I/O with:
|
||||
write. The current runtime cannot inspect K1 firmware: it keeps the profile
|
||||
inactive until the operator explicitly attests firmware `3.0.2` and direct-LAN
|
||||
topology, and records that basis as `operator-attested` rather than
|
||||
device-derived evidence. Validate the current exact-match profile without
|
||||
device I/O with:
|
||||
|
||||
```bash
|
||||
uv run python plugins/xgrids-k1/profile_loader.py
|
||||
|
|
@ -91,13 +92,12 @@ START carrying the project name. Local state polling controls only when the
|
|||
next reviewed action may be requested; it never schedules a device command by
|
||||
elapsed time. START waits for bound `SCANNING + project + init_ready` before
|
||||
operations 13–14. STOP is separately permitted, never retried, and keeps the same socket until K1
|
||||
reports unbound READY. That protocol state automatically completes local sealing;
|
||||
stable green is physical corroboration rather than a second UI gate. The
|
||||
reports unbound READY and the operator confirms a steady green indicator. The
|
||||
admin CLI still provisions the fixed Keychain item through Apple's hidden
|
||||
prompt without accepting the private value as an argument. No physical command
|
||||
is emitted merely by loading the plugin, opening the page, navigating, polling
|
||||
state or running repository tests. The full v0.5.0 START/live/STOP/save path is
|
||||
physically accepted on the reviewed K1/A4/FW 3.0.2 unit.
|
||||
state or running repository tests. Full v0.5.0 physical acceptance remains an
|
||||
operator-run gate.
|
||||
|
||||
The optional owner-controlled iPhone/LixelGO observation tool lives under
|
||||
[`lab/iphone-capture/`](lab/iphone-capture/). It pins `pymobiledevice3` in a
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { useCallback, useState } from "react";
|
||||
import { Button, StatusBadge, type StatusTone } from "@nodedc/ui-react";
|
||||
|
||||
import type { DevicePluginConnectionProps } from "@mission-core/plugin-sdk";
|
||||
|
|
@ -18,6 +19,10 @@ import { useXgridsK1Controller } from "./runtimeContext";
|
|||
export function XgridsK1Connection({ model, host }: DevicePluginConnectionProps) {
|
||||
const controller = useXgridsK1Controller();
|
||||
const { state, error, refresh, clearError } = controller;
|
||||
const [profileConfirmed, setProfileConfirmed] = useState(false);
|
||||
const updateProfileConfirmation = useCallback((confirmed: boolean) => {
|
||||
setProfileConfirmed(confirmed);
|
||||
}, []);
|
||||
|
||||
const confirmedLive = isConfirmedLiveState(state);
|
||||
const sourceRuntimeBusy = isSourceRuntimeBusy(state);
|
||||
|
|
@ -46,7 +51,7 @@ export function XgridsK1Connection({ model, host }: DevicePluginConnectionProps)
|
|||
<span className="error-banner__dot" aria-hidden="true" />
|
||||
<div>
|
||||
<strong>Локальная операция завершилась ошибкой</strong>
|
||||
<p>{error}</p>
|
||||
<p>{localizeRuntimeMessage(error)}</p>
|
||||
</div>
|
||||
<div className="error-banner__actions">
|
||||
<Button size="compact" variant="secondary" onClick={() => void refresh()}>Обновить состояние</Button>
|
||||
|
|
@ -74,10 +79,13 @@ export function XgridsK1Connection({ model, host }: DevicePluginConnectionProps)
|
|||
controller={controller}
|
||||
phaseLabel={connectionPhaseLabel}
|
||||
phaseTone={connectionPhaseTone}
|
||||
profileConfirmed={profileConfirmed}
|
||||
onProfileConfirmedChange={updateProfileConfirmation}
|
||||
/>
|
||||
<div className="device-workspace__side">
|
||||
<K1AcquisitionPipeline
|
||||
controller={controller}
|
||||
profileConfirmed={profileConfirmed}
|
||||
openSpatialScene={host.openSpatialScene}
|
||||
activateAutomaticSpatialSource={host.activateAutomaticSpatialSource}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -138,34 +138,7 @@ export interface XgridsApplicationControlSession {
|
|||
outcome_unknown: boolean;
|
||||
failure?: {
|
||||
code?: string;
|
||||
reason_code?: string;
|
||||
message?: string;
|
||||
failed_phase?: string | null;
|
||||
dialogue_stage?: string | null;
|
||||
transport_state?: string | null;
|
||||
publish_attempts?: number | null;
|
||||
qos2_completions?: number | null;
|
||||
correlated_responses?: number | null;
|
||||
ignored_known_responses?: number | null;
|
||||
late_known_responses?: number | null;
|
||||
modeling_command_attempted?: boolean | null;
|
||||
diagnostic_snapshot_unavailable?: string[];
|
||||
diagnostic_evidence_unavailable?: string[];
|
||||
correlation_failure?: {
|
||||
phase?: string;
|
||||
operation_key?: string;
|
||||
response_topic?: string;
|
||||
reason_code?: string;
|
||||
reason?: string;
|
||||
} | null;
|
||||
compatibility_failure?: {
|
||||
phase?: string;
|
||||
operation_key?: string;
|
||||
response_topic?: string;
|
||||
reason_code?: string;
|
||||
expected?: Record<string, unknown>;
|
||||
observed?: Record<string, unknown>;
|
||||
} | null;
|
||||
safe_to_retry?: boolean;
|
||||
} | null;
|
||||
dialogue?: Record<string, unknown> | null;
|
||||
|
|
@ -180,8 +153,6 @@ export interface XgridsAcquisition {
|
|||
compatibility_profile_id: string;
|
||||
control_mode: "operator-manual" | "plugin-commanded" | "observe-only";
|
||||
project_name?: string | null;
|
||||
mount_type?: "handheld" | null;
|
||||
gnss_mode?: "none" | null;
|
||||
cleanup_pending?: boolean;
|
||||
requested_streams: string[];
|
||||
target_host: string;
|
||||
|
|
@ -289,7 +260,6 @@ export interface XgridsK1State {
|
|||
devices?: BleDevice[];
|
||||
selected_device_id?: string | null;
|
||||
k1_ip?: string | null;
|
||||
connection_mode?: "bridge" | null;
|
||||
foxglove_ws_url?: string | null;
|
||||
foxglove_viewer_url?: string | null;
|
||||
rerun_grpc_url?: string | null;
|
||||
|
|
@ -323,14 +293,13 @@ export interface ScanRequest {
|
|||
export interface CompatibilityAttestation {
|
||||
firmware_version: "3.0.2";
|
||||
topology: "direct-lan";
|
||||
verification: "live-device-info";
|
||||
operator_confirmed: true;
|
||||
}
|
||||
|
||||
export interface ConnectRequest {
|
||||
device_id: string;
|
||||
ssid: string;
|
||||
password: string;
|
||||
connection_mode: "bridge";
|
||||
compatibility_attestation: CompatibilityAttestation;
|
||||
operation_id?: string;
|
||||
idempotency_key?: string;
|
||||
|
|
@ -338,8 +307,6 @@ export interface ConnectRequest {
|
|||
|
||||
export interface PrepareAcquisitionRequest {
|
||||
project_name: string;
|
||||
mount_type: "handheld";
|
||||
gnss_mode: "none";
|
||||
host?: string;
|
||||
duration_seconds?: number;
|
||||
requested_streams?: RequestedStreamId[];
|
||||
|
|
@ -431,13 +398,11 @@ export interface EnterApplicationWorkspaceRequest {
|
|||
|
||||
export class ApiError extends Error {
|
||||
readonly status: number;
|
||||
readonly transportUnavailable: boolean;
|
||||
|
||||
constructor(message: string, status = 0, transportUnavailable = false) {
|
||||
constructor(message: string, status = 0) {
|
||||
super(message);
|
||||
this.name = "ApiError";
|
||||
this.status = status;
|
||||
this.transportUnavailable = transportUnavailable;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -468,11 +433,7 @@ async function requestJson(path: string, init?: RequestInit): Promise<unknown> {
|
|||
},
|
||||
});
|
||||
} catch {
|
||||
throw new ApiError(
|
||||
"Не удалось подключиться к локальному сервису устройства.",
|
||||
0,
|
||||
true,
|
||||
);
|
||||
throw new ApiError("Не удалось подключиться к локальному сервису устройства.");
|
||||
}
|
||||
|
||||
const bodyText = await response.text();
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import type { CompatibilityAttestation } from "./api";
|
||||
|
||||
export const EXACT_PROFILE_SELECTION: CompatibilityAttestation = Object.freeze({
|
||||
export const EXACT_PROFILE_ATTESTATION: CompatibilityAttestation = Object.freeze({
|
||||
firmware_version: "3.0.2",
|
||||
topology: "direct-lan",
|
||||
verification: "live-device-info",
|
||||
operator_confirmed: true,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -4,22 +4,13 @@ import {
|
|||
Checker,
|
||||
GlassSurface,
|
||||
Icon,
|
||||
Select,
|
||||
SegmentedControl,
|
||||
StatusBadge,
|
||||
TextField,
|
||||
type StatusTone,
|
||||
} from "@nodedc/ui-react";
|
||||
|
||||
import { EXACT_PROFILE_SELECTION } from "../compatibility";
|
||||
import {
|
||||
SUPPORTED_GNSS_MODE,
|
||||
SUPPORTED_MOUNT_TYPE,
|
||||
gnssModeOptions,
|
||||
mountTypeOptions,
|
||||
type GnssMode,
|
||||
type MountType,
|
||||
} from "../configuration";
|
||||
import { EXACT_PROFILE_ATTESTATION } from "../compatibility";
|
||||
import { runAutomaticSpatialSourceStart } from "../automaticSourceStart";
|
||||
import {
|
||||
isConfirmedLiveState,
|
||||
|
|
@ -50,10 +41,12 @@ const PHYSICAL_ACCEPTANCE = {
|
|||
|
||||
export function K1AcquisitionPipeline({
|
||||
controller,
|
||||
profileConfirmed,
|
||||
openSpatialScene,
|
||||
activateAutomaticSpatialSource,
|
||||
}: {
|
||||
controller: XgridsK1Controller;
|
||||
profileConfirmed: boolean;
|
||||
openSpatialScene: () => void;
|
||||
activateAutomaticSpatialSource: () => void;
|
||||
}) {
|
||||
|
|
@ -64,6 +57,7 @@ export function K1AcquisitionPipeline({
|
|||
startCanonicalAcquisition,
|
||||
startReplay,
|
||||
stop,
|
||||
confirmStoppedAtSteadyGreen,
|
||||
abort,
|
||||
} = controller;
|
||||
const [sessionIntent, setSessionIntent] = useState<SessionIntent>("live");
|
||||
|
|
@ -72,8 +66,7 @@ export function K1AcquisitionPipeline({
|
|||
const [replayPath, setReplayPath] = useState("");
|
||||
const [replaySpeed, setReplaySpeed] = useState("1");
|
||||
const [replayLoop, setReplayLoop] = useState(false);
|
||||
const [mountType, setMountType] = useState<MountType>(SUPPORTED_MOUNT_TYPE);
|
||||
const [gnssMode, setGnssMode] = useState<GnssMode>(SUPPORTED_GNSS_MODE);
|
||||
const [physicalAcceptanceConfirmed, setPhysicalAcceptanceConfirmed] = useState(false);
|
||||
const hydratedAcquisitionId = useRef<string | null>(null);
|
||||
|
||||
const activeAcquisition = recoverableAcquisition(state);
|
||||
|
|
@ -132,6 +125,8 @@ export function K1AcquisitionPipeline({
|
|||
const startLive = async () => {
|
||||
setProjectNameTouched(true);
|
||||
if (
|
||||
!profileConfirmed ||
|
||||
!physicalAcceptanceConfirmed ||
|
||||
!state?.k1_ip ||
|
||||
sourceRuntimeBusy ||
|
||||
launchBlockedByAcquisition ||
|
||||
|
|
@ -147,9 +142,7 @@ export function K1AcquisitionPipeline({
|
|||
},
|
||||
acquisition: {
|
||||
project_name: projectNameValidation.value,
|
||||
mount_type: SUPPORTED_MOUNT_TYPE,
|
||||
gnss_mode: SUPPORTED_GNSS_MODE,
|
||||
compatibility_attestation: EXACT_PROFILE_SELECTION,
|
||||
compatibility_attestation: EXACT_PROFILE_ATTESTATION,
|
||||
},
|
||||
physicalAcceptance: PHYSICAL_ACCEPTANCE,
|
||||
}),
|
||||
|
|
@ -188,30 +181,6 @@ export function K1AcquisitionPipeline({
|
|||
/>
|
||||
{effectiveSessionIntent === "live" ? (
|
||||
<div className="session-form">
|
||||
<div className="scan-configuration-grid">
|
||||
<div className="configuration-field">
|
||||
<span className="nodedc-field__description">Тип установки / носитель</span>
|
||||
<Select
|
||||
label="Тип установки / носитель"
|
||||
value={mountType}
|
||||
options={mountTypeOptions}
|
||||
onChange={setMountType}
|
||||
disabled={isBusy || sessionLocked}
|
||||
variant="split"
|
||||
/>
|
||||
</div>
|
||||
<div className="configuration-field">
|
||||
<span className="nodedc-field__description">Режим GNSS</span>
|
||||
<Select
|
||||
label="Режим GNSS"
|
||||
value={gnssMode}
|
||||
options={gnssModeOptions}
|
||||
onChange={setGnssMode}
|
||||
disabled={isBusy || sessionLocked}
|
||||
variant="split"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<TextField
|
||||
label="Название проекта"
|
||||
hint="Имя войдёт в единственный канонический START"
|
||||
|
|
@ -229,12 +198,19 @@ export function K1AcquisitionPipeline({
|
|||
: "Отдельной команды сохранения имени на K1 нет: оно отправляется только при START."}
|
||||
placeholder="Например, TEST001"
|
||||
/>
|
||||
<Checker
|
||||
checked={physicalAcceptanceConfirmed}
|
||||
label="Я рядом с выбранным K1; LixelGO закрыт; питание и место для записи проверены; индикатор постоянно зелёный"
|
||||
onChange={setPhysicalAcceptanceConfirmed}
|
||||
/>
|
||||
<Button
|
||||
variant="primary"
|
||||
icon={<Icon name="activity" />}
|
||||
disabled={
|
||||
isBusy ||
|
||||
!profileConfirmed ||
|
||||
!state?.k1_ip ||
|
||||
!physicalAcceptanceConfirmed ||
|
||||
projectNameValidation.error !== null ||
|
||||
sourceRuntimeBusy ||
|
||||
launchBlockedByAcquisition ||
|
||||
|
|
@ -256,9 +232,6 @@ export function K1AcquisitionPipeline({
|
|||
? "Продолжить запуск сканирования и приёма"
|
||||
: "Запустить сканирование и локальный приём"}
|
||||
</Button>
|
||||
<p className="start-confirmation-note">
|
||||
Нажатие запуска — явное операторское действие для выбранного K1. Автоматических повторов START нет.
|
||||
</p>
|
||||
{control?.control_socket_open && !activeAcquisition && !isBusy ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
|
|
@ -269,7 +242,9 @@ export function K1AcquisitionPipeline({
|
|||
</Button>
|
||||
) : null}
|
||||
<p className="live-instruction">
|
||||
{controlPhase === "failed"
|
||||
{!profileConfirmed
|
||||
? "Сначала вручную подтвердите FW 3.0.2 и direct-LAN. Интерфейс не аттестует устройство автоматически."
|
||||
: controlPhase === "failed"
|
||||
? `Диалог остановлен без автоповтора: ${control?.failure?.message || "требуется ручная проверка K1"}`
|
||||
: controlPhase === "connecting"
|
||||
? "Выполняются операции 1–6 записанного диалога; следующий этап ждёт подтверждённый ответ K1."
|
||||
|
|
@ -281,7 +256,7 @@ export function K1AcquisitionPipeline({
|
|||
? "Калибровка оборудования. Не перемещайте K1; временных переходов и повторных команд нет."
|
||||
: controlPhase === "scanning"
|
||||
? "K1 подтвердил SCANNING и инициализацию. Остановка доступна в пространственной сцене."
|
||||
: "Одна кнопка выражает намерение запустить сканирование. Совместимость подтверждается живым DeviceInfo; этапы идут строго по записанному порядку и только после ответов K1."}
|
||||
: "Одна кнопка выражает намерение запустить сканирование. Внутри этапы идут строго по записанному порядку и только после ответов K1; человеческие паузы из capture не воспроизводятся."}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
|
|
@ -307,6 +282,15 @@ export function K1AcquisitionPipeline({
|
|||
: "Остановка завершает только локальный приём и сохранение. Физическое состояние сканера остаётся неизвестным."
|
||||
: "Активного источника сейчас нет."}
|
||||
</p>
|
||||
{control?.can_confirm_standby ? (
|
||||
<Button
|
||||
variant="primary"
|
||||
disabled={isBusy}
|
||||
onClick={() => void confirmStoppedAtSteadyGreen()}
|
||||
>
|
||||
Индикатор постоянно зелёный — завершить запись
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
variant="secondary"
|
||||
disabled={isBusy || (!sourceRuntimeBusy && preparedAcquisition !== null) || (!sourceRuntimeBusy && activeAcquisition === null)}
|
||||
|
|
|
|||
|
|
@ -4,19 +4,13 @@ import {
|
|||
Checker,
|
||||
GlassSurface,
|
||||
Icon,
|
||||
Select,
|
||||
StatusBadge,
|
||||
TextField,
|
||||
type StatusTone,
|
||||
} from "@nodedc/ui-react";
|
||||
|
||||
import type { BleDevice } from "../api";
|
||||
import { EXACT_PROFILE_SELECTION } from "../compatibility";
|
||||
import {
|
||||
SUPPORTED_CONNECTION_MODE,
|
||||
connectionModeOptions,
|
||||
type ConnectionMode,
|
||||
} from "../configuration";
|
||||
import { EXACT_PROFILE_ATTESTATION } from "../compatibility";
|
||||
import { provisioningIntentKey } from "../lifecycle";
|
||||
import { finiteMetric } from "../presentation";
|
||||
import type { XgridsK1Controller } from "../runtimeContext";
|
||||
|
|
@ -85,28 +79,30 @@ export function K1ProvisioningPipeline({
|
|||
controller,
|
||||
phaseLabel,
|
||||
phaseTone,
|
||||
profileConfirmed,
|
||||
onProfileConfirmedChange,
|
||||
}: {
|
||||
controller: XgridsK1Controller;
|
||||
phaseLabel: string;
|
||||
phaseTone: StatusTone;
|
||||
profileConfirmed: boolean;
|
||||
onProfileConfirmedChange: (confirmed: boolean) => void;
|
||||
}) {
|
||||
const { state, pendingAction, scan, connect } = controller;
|
||||
const [powerConfirmed, setPowerConfirmed] = useState(false);
|
||||
const [selectedDeviceId, setSelectedDeviceId] = useState("");
|
||||
const [ssid, setSsid] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [connectionMode, setConnectionMode] = useState<ConnectionMode>(
|
||||
SUPPORTED_CONNECTION_MODE,
|
||||
);
|
||||
const provisioningIntentRef = useRef<string | null>(null);
|
||||
const devices = state?.devices ?? [];
|
||||
const isBusy = pendingAction !== null;
|
||||
const credentialsReady = ssid.trim().length > 0 && password.length > 0;
|
||||
const canConnect = powerConfirmed && selectedDeviceId.length > 0 && credentialsReady && !isBusy;
|
||||
const canConnect = powerConfirmed && profileConfirmed && selectedDeviceId.length > 0 && credentialsReady && !isBusy;
|
||||
|
||||
useEffect(() => {
|
||||
if (state?.selected_device_id) {
|
||||
if (state.selected_device_id !== selectedDeviceId) {
|
||||
onProfileConfirmedChange(false);
|
||||
provisioningIntentRef.current = null;
|
||||
}
|
||||
setSelectedDeviceId(state.selected_device_id);
|
||||
|
|
@ -115,14 +111,15 @@ export function K1ProvisioningPipeline({
|
|||
if (selectedDeviceId && state?.devices && !state.devices.some((device) => device.device_id === selectedDeviceId)) {
|
||||
setSelectedDeviceId("");
|
||||
}
|
||||
}, [selectedDeviceId, state?.devices, state?.selected_device_id]);
|
||||
}, [onProfileConfirmedChange, selectedDeviceId, state?.devices, state?.selected_device_id]);
|
||||
|
||||
const deviceSummary = useMemo(
|
||||
() => devices.find((device) => device.device_id === selectedDeviceId),
|
||||
[devices, selectedDeviceId],
|
||||
);
|
||||
|
||||
const resetProvisioningIntent = () => {
|
||||
const resetProfile = () => {
|
||||
onProfileConfirmedChange(false);
|
||||
provisioningIntentRef.current = null;
|
||||
};
|
||||
|
||||
|
|
@ -134,8 +131,7 @@ export function K1ProvisioningPipeline({
|
|||
device_id: selectedDeviceId,
|
||||
ssid: ssid.trim(),
|
||||
password,
|
||||
connection_mode: SUPPORTED_CONNECTION_MODE,
|
||||
compatibility_attestation: EXACT_PROFILE_SELECTION,
|
||||
compatibility_attestation: EXACT_PROFILE_ATTESTATION,
|
||||
idempotency_key: idempotencyKey,
|
||||
});
|
||||
if (succeeded) {
|
||||
|
|
@ -150,22 +146,6 @@ export function K1ProvisioningPipeline({
|
|||
<div><span className="section-eyebrow">ПОДКЛЮЧЕНИЕ · ШАГИ 01–03</span><h2>Подключите устройство к сети</h2></div>
|
||||
<StatusBadge tone={phaseTone}>{phaseLabel}</StatusBadge>
|
||||
</header>
|
||||
<div className="configuration-anchor">
|
||||
<span className="nodedc-field__description">
|
||||
Неподтверждённые сетевые топологии уже отражены в интерфейсе, но не могут быть выбраны до отдельной приёмки.
|
||||
</span>
|
||||
<Select
|
||||
label="Способ подключения"
|
||||
value={connectionMode}
|
||||
options={connectionModeOptions}
|
||||
onChange={(value) => {
|
||||
setConnectionMode(value);
|
||||
resetProvisioningIntent();
|
||||
}}
|
||||
disabled={isBusy}
|
||||
variant="split"
|
||||
/>
|
||||
</div>
|
||||
<div className="wizard-list">
|
||||
<WizardStep number="01" title="Включите устройство" status={powerConfirmed ? "Подтверждено" : "Ожидает"} tone={powerConfirmed ? "success" : "warning"}>
|
||||
<div className="nodedc-field">
|
||||
|
|
@ -173,7 +153,7 @@ export function K1ProvisioningPipeline({
|
|||
<Checker
|
||||
checked={powerConfirmed}
|
||||
label="Устройство включено, индикатор стабилен"
|
||||
onChange={(checked) => { setPowerConfirmed(checked); if (!checked) resetProvisioningIntent(); }}
|
||||
onChange={(checked) => { setPowerConfirmed(checked); if (!checked) resetProfile(); }}
|
||||
/>
|
||||
</div>
|
||||
</WizardStep>
|
||||
|
|
@ -183,13 +163,13 @@ export function K1ProvisioningPipeline({
|
|||
status={pendingAction === "scan" ? "Поиск…" : selectedDeviceId ? "Устройство выбрано" : `Найдено: ${devices.length}`}
|
||||
tone={pendingAction === "scan" ? "accent" : selectedDeviceId ? "success" : "neutral"}
|
||||
>
|
||||
<p className="step-copy">Поиск занимает 6 секунд и показывает все видимые BLE-устройства. Метка кандидата основана только на имени; точные модель, platform type и прошивка будут проверены по живому DeviceInfo перед START.</p>
|
||||
<p className="step-copy">Поиск занимает 6 секунд и показывает все видимые BLE-устройства. Метка кандидата основана только на имени; модель и прошивку подтверждает оператор.</p>
|
||||
<Button
|
||||
width="full"
|
||||
variant="secondary"
|
||||
icon={<Icon name="search" />}
|
||||
disabled={!powerConfirmed || isBusy}
|
||||
onClick={() => { setSelectedDeviceId(""); resetProvisioningIntent(); void scan(); }}
|
||||
onClick={() => { setSelectedDeviceId(""); resetProfile(); void scan(); }}
|
||||
>
|
||||
{pendingAction === "scan" ? "Сканируем Bluetooth — 6 секунд…" : "Показать все BLE-устройства"}
|
||||
</Button>
|
||||
|
|
@ -199,18 +179,21 @@ export function K1ProvisioningPipeline({
|
|||
key={device.device_id}
|
||||
device={device}
|
||||
selected={device.device_id === selectedDeviceId}
|
||||
onSelect={() => { setSelectedDeviceId(device.device_id); resetProvisioningIntent(); }}
|
||||
onSelect={() => { setSelectedDeviceId(device.device_id); resetProfile(); }}
|
||||
/>
|
||||
)) : <div className="empty-device-list">Устройства пока не найдены. Проверьте питание и повторите поиск.</div>}
|
||||
</div>
|
||||
</WizardStep>
|
||||
<WizardStep
|
||||
number="03"
|
||||
title="Передайте настройки общей сети"
|
||||
status={state?.k1_ip ? "Адрес ранее получен" : "Настройки не переданы"}
|
||||
tone={state?.k1_ip ? "warning" : "neutral"}
|
||||
>
|
||||
<WizardStep number="03" title="Передайте настройки Wi‑Fi" status={state?.k1_ip ? "Подключено" : "Не подключено"} tone={state?.k1_ip ? "success" : "neutral"}>
|
||||
<div className="field-stack">
|
||||
<div className="nodedc-field">
|
||||
<span className="nodedc-field__description">Mission Core не определяет прошивку автоматически. Подтвердите только точное соответствие профилю.</span>
|
||||
<Checker
|
||||
checked={profileConfirmed}
|
||||
label="Я вручную подтвердил FW 3.0.2 и direct-LAN"
|
||||
onChange={(checked) => { onProfileConfirmedChange(checked); provisioningIntentRef.current = null; }}
|
||||
/>
|
||||
</div>
|
||||
<TextField label="Название сети Wi‑Fi" hint="SSID" value={ssid} onChange={(event) => { setSsid(event.target.value); provisioningIntentRef.current = null; }} autoComplete="off" spellCheck={false} placeholder="Сеть локального контура" />
|
||||
<TextField label="Пароль Wi‑Fi" hint="Только в оперативной памяти" type="password" value={password} onChange={(event) => { setPassword(event.target.value); provisioningIntentRef.current = null; }} autoComplete="off" placeholder="Введите пароль" />
|
||||
</div>
|
||||
|
|
@ -218,7 +201,7 @@ export function K1ProvisioningPipeline({
|
|||
<Button width="full" variant="primary" icon={<Icon name="network" />} disabled={!canConnect} onClick={() => void submitConnect()}>
|
||||
{pendingAction === "connect" ? "Подключаем…" : "Подключить устройство к Wi‑Fi"}
|
||||
</Button>
|
||||
<p className="safety-note">Наличие адреса подтверждает результат предыдущей настройки, но не текущее соединение. Пароль передаётся только локальному сервису, не сохраняется в браузере и удаляется из формы после успеха.</p>
|
||||
<p className="safety-note">Пароль передаётся только локальному сервису, не сохраняется в браузере и удаляется из формы после успеха.</p>
|
||||
</WizardStep>
|
||||
</div>
|
||||
</GlassSurface>
|
||||
|
|
|
|||
|
|
@ -72,7 +72,7 @@ function phasePresentation(
|
|||
awaiting_external_stop: {
|
||||
label: softwareCommanded ? "K1 завершает и сохраняет" : "Ожидание остановки на устройстве",
|
||||
detail: softwareCommanded
|
||||
? "STOP не повторяется. Mission Core завершит запись после READY от K1."
|
||||
? "STOP не повторяется. Дождитесь READY и постоянного зелёного индикатора."
|
||||
: "Mission Core ждёт подтверждения физической остановки K1.",
|
||||
busy: true,
|
||||
},
|
||||
|
|
@ -108,7 +108,7 @@ function formatDuration(seconds: number): string {
|
|||
|
||||
export function K1SpatialControls(_props: DevicePluginConnectionProps) {
|
||||
const controller = useXgridsK1Controller();
|
||||
const { state, pendingAction, stop } = controller;
|
||||
const { state, pendingAction, stop, confirmStoppedAtSteadyGreen } = controller;
|
||||
const acquisition = state?.acquisition;
|
||||
const cleanupPending = acquisition?.cleanup_pending === true;
|
||||
if (!acquisition || !shouldRenderSpatialControls(state)) {
|
||||
|
|
@ -179,6 +179,16 @@ export function K1SpatialControls(_props: DevicePluginConnectionProps) {
|
|||
? "Повторить остановку"
|
||||
: softwareCommanded ? "Остановить устройство и запись" : "Остановить локальный приём"}
|
||||
</Button>
|
||||
{state?.application_control_session?.can_confirm_standby ? (
|
||||
<Button
|
||||
size="compact"
|
||||
variant="primary"
|
||||
disabled={pendingAction !== null}
|
||||
onClick={() => void confirmStoppedAtSteadyGreen()}
|
||||
>
|
||||
Индикатор постоянно зелёный — завершить запись
|
||||
</Button>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,75 +0,0 @@
|
|||
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,7 +2,6 @@ import type { RuntimePhase, SourceMode as RuntimeSourceMode } from "@mission-cor
|
|||
|
||||
import type {
|
||||
AcquisitionState,
|
||||
XgridsApplicationControlPhase,
|
||||
XgridsAcquisition,
|
||||
XgridsK1State,
|
||||
XgridsOperation,
|
||||
|
|
@ -23,25 +22,6 @@ const FAILED_OPERATION_STATUSES = new Set([
|
|||
]);
|
||||
|
||||
export type LiveStartPlan = "prepare" | "resume-prepared" | "already-running" | "blocked";
|
||||
export type ControlSessionEntryPlan =
|
||||
| "open"
|
||||
| "continue"
|
||||
| "failed"
|
||||
| "duplicate-open";
|
||||
|
||||
export function controlSessionEntryPlan(
|
||||
phase: XgridsApplicationControlPhase,
|
||||
openedByCurrentOperatorAction: boolean,
|
||||
canOpen: boolean,
|
||||
): ControlSessionEntryPlan {
|
||||
if (phase === "failed") {
|
||||
return !openedByCurrentOperatorAction && canOpen ? "open" : "failed";
|
||||
}
|
||||
if (["idle", "closed", "completed"].includes(phase)) {
|
||||
return openedByCurrentOperatorAction ? "duplicate-open" : "open";
|
||||
}
|
||||
return "continue";
|
||||
}
|
||||
|
||||
export function isTerminalAcquisitionState(
|
||||
state: AcquisitionState | null | undefined,
|
||||
|
|
@ -139,12 +119,7 @@ export function normalizeRuntimePhase(
|
|||
}
|
||||
if (acquisitionState === "prepared") return "connected";
|
||||
if (phase === "error") return "error";
|
||||
if (phase === "connected") {
|
||||
const controlPhase = state?.application_control_session?.state;
|
||||
return controlPhase && !["idle", "connecting", "closed", "completed", "failed"].includes(controlPhase)
|
||||
? "connected"
|
||||
: "configuring";
|
||||
}
|
||||
if (phase === "connected") return "connected";
|
||||
if (phase === "starting_live") return "starting";
|
||||
if (phase === "live") return "streaming";
|
||||
if (phase === "replay") return "replaying";
|
||||
|
|
|
|||
|
|
@ -1,45 +1,5 @@
|
|||
// Vendor message normalization belongs to the XGRIDS frontend contribution.
|
||||
const runtimeMessageReplacements: Array<[RegExp, string]> = [
|
||||
[
|
||||
/^control MQTT connect call failed(?::.*)?$/gi,
|
||||
"Управляющее соединение со сканером не открылось: устройство не приняло MQTT-соединение. Команды сканирования не отправлялись.",
|
||||
],
|
||||
[
|
||||
/macOS Keychain authority is unavailable/gi,
|
||||
"Локальный допуск управления K1 не подготовлен. Команды сканеру не отправлялись.",
|
||||
],
|
||||
[
|
||||
/macOS Keychain authority lookup failed/gi,
|
||||
"Локальный допуск управления K1 недоступен. Команды сканеру не отправлялись.",
|
||||
],
|
||||
[
|
||||
/control MQTT connect call failed/gi,
|
||||
"Управляющее соединение с K1 не открылось. Команды сканеру не отправлялись.",
|
||||
],
|
||||
[
|
||||
/^Device plugin returned an invalid non-JSON action result$/gi,
|
||||
"Плагин устройства сформировал некорректное внутреннее состояние. Запрос к K1 не повторялся.",
|
||||
],
|
||||
[
|
||||
/^bootstrap response correlation failed.*$/gi,
|
||||
"Ответ K1 на подготовительном этапе не совпал с ожидаемой операцией. Диалог остановлен без автоматического повтора.",
|
||||
],
|
||||
[
|
||||
/^live DeviceInfo is incompatible with the selected.*$/gi,
|
||||
"Устройство ответило, но его живые данные не соответствуют выбранному профилю совместимости. START и STOP не отправлялись.",
|
||||
],
|
||||
[
|
||||
/^K1 did not confirm bound SCANNING initialization.*$/gi,
|
||||
"После START устройство не подтвердило завершение инициализации в безопасный срок. Автоматический STOP не отправлялся.",
|
||||
],
|
||||
[
|
||||
/^stale control response makes the next command outcome ambiguous$/gi,
|
||||
"Получен запоздавший ответ предыдущего этапа. Диалог остановлен, следующая команда не отправлялась.",
|
||||
],
|
||||
[
|
||||
/control MQTT connection\/subscription timed out/gi,
|
||||
"Управляющее соединение с K1 не подтвердилось вовремя. Команды сканеру не отправлялись.",
|
||||
],
|
||||
[/broker connection ended/gi, "соединение с брокером завершено"],
|
||||
[/Unspecified error/gi, "неуказанная ошибка"],
|
||||
[
|
||||
|
|
|
|||
|
|
@ -1,67 +0,0 @@
|
|||
export interface RuntimeGenerationToken {
|
||||
readonly runtimeGeneration: number;
|
||||
}
|
||||
|
||||
export interface OperatorIntentToken extends RuntimeGenerationToken {
|
||||
readonly intentGeneration: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidates asynchronous UI work across both plugin activation changes and
|
||||
* successive explicit operator intents.
|
||||
*
|
||||
* A boolean "mounted" flag is insufficient here: an operation started before
|
||||
* active=true -> false -> true would otherwise observe true again and resume.
|
||||
*/
|
||||
export class OperatorIntentGeneration {
|
||||
private runtimeGeneration = 0;
|
||||
private intentGeneration = 0;
|
||||
private active = false;
|
||||
|
||||
activateRuntime(): RuntimeGenerationToken {
|
||||
this.runtimeGeneration += 1;
|
||||
this.active = true;
|
||||
return Object.freeze({ runtimeGeneration: this.runtimeGeneration });
|
||||
}
|
||||
|
||||
deactivateRuntime(): void {
|
||||
if (!this.active) return;
|
||||
this.runtimeGeneration += 1;
|
||||
this.active = false;
|
||||
}
|
||||
|
||||
captureRuntime(): RuntimeGenerationToken | null {
|
||||
if (!this.active) return null;
|
||||
return Object.freeze({ runtimeGeneration: this.runtimeGeneration });
|
||||
}
|
||||
|
||||
beginOperatorIntent(): OperatorIntentToken | null {
|
||||
if (!this.active) return null;
|
||||
this.intentGeneration += 1;
|
||||
return Object.freeze({
|
||||
runtimeGeneration: this.runtimeGeneration,
|
||||
intentGeneration: this.intentGeneration,
|
||||
});
|
||||
}
|
||||
|
||||
isRuntimeCurrent(token: RuntimeGenerationToken): boolean {
|
||||
return this.active && token.runtimeGeneration === this.runtimeGeneration;
|
||||
}
|
||||
|
||||
isOperatorIntentCurrent(token: OperatorIntentToken): boolean {
|
||||
return (
|
||||
this.isRuntimeCurrent(token)
|
||||
&& token.intentGeneration === this.intentGeneration
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function awaitWhileIntentCurrent<T>(
|
||||
assertCurrent: () => void,
|
||||
operation: () => Promise<T>,
|
||||
): Promise<T> {
|
||||
assertCurrent();
|
||||
const result = await operation();
|
||||
assertCurrent();
|
||||
return result;
|
||||
}
|
||||
|
|
@ -9,7 +9,7 @@ const phaseLabels: Record<string, string> = {
|
|||
device_selected: "Устройство выбрано",
|
||||
provisioning: "Передача настроек Wi‑Fi",
|
||||
connecting: "Подключение",
|
||||
connected: "Сетевой адрес устройства получен",
|
||||
connected: "Устройство подключено",
|
||||
starting_live: "Запуск потока",
|
||||
live: "Поток в реальном времени",
|
||||
replay: "Повтор записи",
|
||||
|
|
@ -25,8 +25,7 @@ export function phaseLabel(phase: string | null | undefined): string {
|
|||
export function phaseTone(phase: string | null | undefined): StatusTone {
|
||||
if (!phase) return "neutral";
|
||||
if (phase === "error") return "danger";
|
||||
if (["live", "replay"].includes(phase)) return "success";
|
||||
if (phase === "connected") return "warning";
|
||||
if (["connected", "live", "replay"].includes(phase)) return "success";
|
||||
if (["scanning", "provisioning", "connecting", "starting_live", "stopping"].includes(phase)) return "accent";
|
||||
return "neutral";
|
||||
}
|
||||
|
|
|
|||
|
|
@ -69,20 +69,6 @@
|
|||
margin-top: 1.4rem;
|
||||
}
|
||||
|
||||
.configuration-anchor {
|
||||
display: grid;
|
||||
gap: 0.55rem;
|
||||
margin-top: 1.2rem;
|
||||
border-radius: 0.95rem;
|
||||
background: rgb(255 255 255 / 0.028);
|
||||
padding: 0.8rem;
|
||||
}
|
||||
|
||||
.configuration-anchor .nodedc-select-anchor,
|
||||
.configuration-field .nodedc-select-anchor {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.wizard-step {
|
||||
display: grid;
|
||||
grid-template-columns: 2.2rem minmax(0, 1fr);
|
||||
|
|
@ -296,25 +282,6 @@
|
|||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.scan-configuration-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.configuration-field {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
|
||||
.start-confirmation-note {
|
||||
margin: -0.25rem 0 0;
|
||||
color: var(--nodedc-text-muted);
|
||||
font-size: 0.6rem;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.session-form--replay {
|
||||
grid-template-columns: minmax(0, 1.55fr) minmax(8rem, 0.45fr);
|
||||
}
|
||||
|
|
@ -468,10 +435,6 @@
|
|||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.xgrids-k1-plugin .scan-configuration-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.xgrids-k1-plugin .session-form--replay {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,7 +16,6 @@ import {
|
|||
type XgridsK1State,
|
||||
} from "./api";
|
||||
import {
|
||||
controlSessionEntryPlan,
|
||||
isTerminalAcquisitionState,
|
||||
liveStartPlan,
|
||||
operationByIdempotencyKey,
|
||||
|
|
@ -24,10 +23,6 @@ import {
|
|||
isSoftwareCommandedAcquisition,
|
||||
} from "./lifecycle";
|
||||
import { localizeRuntimeMessage } from "./messages";
|
||||
import {
|
||||
awaitWhileIntentCurrent,
|
||||
OperatorIntentGeneration,
|
||||
} from "./operatorIntentGeneration";
|
||||
import { selectMonotonicXgridsState } from "./stateOrdering";
|
||||
|
||||
export type PendingAction =
|
||||
|
|
@ -55,103 +50,19 @@ function controlPhase(state: XgridsK1State): XgridsApplicationControlPhase {
|
|||
|
||||
function controlFailure(state: XgridsK1State): ApiError {
|
||||
const failure = state.application_control_session?.failure;
|
||||
const localizedDetail = localizeRuntimeMessage(failure?.message);
|
||||
const reasonLabels: Record<string, string> = {
|
||||
application_authority_unavailable:
|
||||
"Локальный допуск управления устройством недоступен; команды не отправлялись.",
|
||||
mqtt_connect_call_failed:
|
||||
"Управляющее MQTT-соединение со сканером не открылось.",
|
||||
mqtt_connect_rejected:
|
||||
"Сканер отклонил управляющее MQTT-соединение.",
|
||||
mqtt_connection_timeout:
|
||||
"Подключение или подписки MQTT не подтвердились вовремя.",
|
||||
mqtt_subscription_failed:
|
||||
"Сканер не подтвердил канонические MQTT-подписки.",
|
||||
mqtt_response_timeout:
|
||||
"Ожидаемый ответ сканера не пришёл до безопасной границы ожидания.",
|
||||
response_identity_decode_failed:
|
||||
"Ответ сканера не удалось безопасно разобрать и привязать к операции.",
|
||||
modeling_response_decode_failed:
|
||||
"Ответ START/STOP не удалось безопасно разобрать.",
|
||||
duplicate_application_response:
|
||||
"Сканер прислал повторный ответ на уже завершённую операцию.",
|
||||
unexpected_response_identity:
|
||||
"Получен ответ неизвестной операции; диалог остановлен.",
|
||||
response_identity_mismatch:
|
||||
"Идентичность ответа не совпала с ожидаемой операцией.",
|
||||
response_session_mismatch:
|
||||
"Session ответа не совпал с точной подготовительной операцией.",
|
||||
response_device_identity_mismatch:
|
||||
"Ответ относится не к тому экземпляру устройства.",
|
||||
response_authority_mismatch:
|
||||
"Ответ не совпал с локальным допуском приложения.",
|
||||
response_rejected:
|
||||
"Сканер отклонил подготовительную операцию.",
|
||||
compatibility_profile_mismatch:
|
||||
"Живой DeviceInfo не соответствует выбранному профилю модели, platform type, прошивки или активации.",
|
||||
scan_initialization_timeout:
|
||||
"После подтверждённого START сканер не завершил инициализацию в безопасный срок.",
|
||||
operation_reuse_forbidden:
|
||||
"Повтор уже использованной операции заблокирован.",
|
||||
};
|
||||
const reasonDetail = failure?.reason_code
|
||||
? reasonLabels[failure.reason_code]
|
||||
: undefined;
|
||||
const stageLabels: Record<string, string> = {
|
||||
connecting: "подключение и первичный диалог",
|
||||
connection: "первичный диалог",
|
||||
"connection-ready": "подключение подтверждено",
|
||||
"workspace-requested": "вход в рабочую область",
|
||||
"workspace-ready": "рабочая область готова",
|
||||
"project-requested": "подготовка проекта",
|
||||
"project-ready": "проект готов",
|
||||
"start-requested": "подтверждение запуска",
|
||||
"start-attempted": "команда запуска",
|
||||
initializing: "калибровка оборудования",
|
||||
scanning: "сканирование",
|
||||
"stop-requested": "подтверждение остановки",
|
||||
"stop-attempted": "команда остановки",
|
||||
stopping: "остановка",
|
||||
};
|
||||
const stageCode = failure?.dialogue_stage || failure?.failed_phase;
|
||||
const stage = stageCode
|
||||
? stageLabels[stageCode] ?? "неопознанный этап канонического диалога"
|
||||
: "этап не зафиксирован";
|
||||
const commandStatus = failure?.modeling_command_attempted === true
|
||||
? "Команда START или STOP могла быть отправлена; автоматический повтор запрещён."
|
||||
: failure?.modeling_command_attempted === false
|
||||
? "Команды START и STOP не отправлялись."
|
||||
: "Факт отправки START или STOP диагностически не подтверждён; повтор запрещён.";
|
||||
const retryStatus = failure?.safe_to_retry
|
||||
? "Новая попытка возможна только отдельным нажатием оператора."
|
||||
: "Повтор заблокирован до ручной проверки состояния.";
|
||||
const exchanges = typeof failure?.publish_attempts === "number"
|
||||
? `MQTT-публикаций до остановки: ${failure.publish_attempts}.`
|
||||
: "Количество MQTT-публикаций не зафиксировано.";
|
||||
const failedProtocolEvidence =
|
||||
failure?.compatibility_failure ?? failure?.correlation_failure;
|
||||
const failedOperation = failedProtocolEvidence?.operation_key
|
||||
? `Шаг протокола: ${failedProtocolEvidence.operation_key}.`
|
||||
: "";
|
||||
const diagnostics = failure?.diagnostic_evidence_unavailable?.length
|
||||
? "Часть диагностических доказательств недоступна; результат считается неизвестным."
|
||||
: "";
|
||||
return new ApiError(
|
||||
`${reasonDetail || localizedDetail || "Канонический диалог K1 остановлен."} Этап: ${stage}. ${failedOperation} ${exchanges} ${commandStatus} ${diagnostics} ${retryStatus}`,
|
||||
failure?.message
|
||||
? `Канонический диалог K1 остановлен: ${failure.message}`
|
||||
: "Канонический диалог K1 остановлен до запуска сканирования.",
|
||||
);
|
||||
}
|
||||
|
||||
async function waitForControlPhase(
|
||||
expected: XgridsApplicationControlPhase,
|
||||
acceptState: (state: XgridsK1State) => void,
|
||||
assertOperatorIntentCurrent: () => void,
|
||||
): Promise<XgridsK1State> {
|
||||
for (;;) {
|
||||
assertOperatorIntentCurrent();
|
||||
const nextState = await awaitWhileIntentCurrent(
|
||||
assertOperatorIntentCurrent,
|
||||
() => xgridsK1Api.getState(),
|
||||
);
|
||||
const nextState = await xgridsK1Api.getState();
|
||||
acceptState(nextState);
|
||||
const phase = controlPhase(nextState);
|
||||
if (phase === expected) return nextState;
|
||||
|
|
@ -163,22 +74,15 @@ async function waitForControlPhase(
|
|||
}
|
||||
// This cadence only reads local server state. It never schedules, retries,
|
||||
// or times a K1 command; every next write remains gated by device response.
|
||||
await awaitWhileIntentCurrent(
|
||||
assertOperatorIntentCurrent,
|
||||
() => new Promise<void>((resolve) => {
|
||||
window.setTimeout(resolve, CONTROL_STATE_READ_INTERVAL_MS);
|
||||
}),
|
||||
);
|
||||
await new Promise<void>((resolve) => {
|
||||
window.setTimeout(resolve, CONTROL_STATE_READ_INTERVAL_MS);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function messageFor(error: unknown): string {
|
||||
if (error instanceof ApiError) {
|
||||
// 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;
|
||||
const message = localizeRuntimeMessage(error.message) ?? error.message;
|
||||
return error.status
|
||||
? `${message} (HTTP ${error.status})`
|
||||
: message;
|
||||
|
|
@ -209,12 +113,8 @@ export function useXgridsK1Runtime(enabled: boolean) {
|
|||
const [pendingAction, setPendingAction] = useState<PendingAction | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [latencyHistory, setLatencyHistory] = useState<number[]>([]);
|
||||
const operatorIntents = useRef(new OperatorIntentGeneration());
|
||||
const actionSequence = useRef(0);
|
||||
const actionInFlight = useRef<{
|
||||
runtimeGeneration: number;
|
||||
actionSequence: number;
|
||||
} | null>(null);
|
||||
const mounted = useRef(true);
|
||||
const actionInFlight = useRef(false);
|
||||
|
||||
const acceptState = useCallback((nextState: XgridsK1State) => {
|
||||
setState((currentState) => selectMonotonicXgridsState(currentState, nextState));
|
||||
|
|
@ -222,14 +122,13 @@ export function useXgridsK1Runtime(enabled: boolean) {
|
|||
}, []);
|
||||
|
||||
const refresh = useCallback(async (reportErrors = true) => {
|
||||
const runtimeToken = operatorIntents.current.captureRuntime();
|
||||
if (!enabled || !runtimeToken) return;
|
||||
if (!enabled) return;
|
||||
const [healthResult, stateResult] = await Promise.allSettled([
|
||||
xgridsK1Api.getHealth(),
|
||||
xgridsK1Api.getState(),
|
||||
]);
|
||||
|
||||
if (!operatorIntents.current.isRuntimeCurrent(runtimeToken)) return;
|
||||
if (!mounted.current) return;
|
||||
|
||||
if (stateResult.status === "fulfilled") {
|
||||
acceptState(stateResult.value);
|
||||
|
|
@ -251,48 +150,27 @@ export function useXgridsK1Runtime(enabled: boolean) {
|
|||
|
||||
const run = useCallback(
|
||||
async (action: PendingAction, operation: () => Promise<XgridsK1State>) => {
|
||||
const runtimeToken = operatorIntents.current.captureRuntime();
|
||||
if (!enabled || !runtimeToken) return false;
|
||||
if (
|
||||
actionInFlight.current?.runtimeGeneration
|
||||
=== runtimeToken.runtimeGeneration
|
||||
) return false;
|
||||
actionSequence.current += 1;
|
||||
const actionToken = {
|
||||
runtimeGeneration: runtimeToken.runtimeGeneration,
|
||||
actionSequence: actionSequence.current,
|
||||
};
|
||||
actionInFlight.current = actionToken;
|
||||
if (!enabled) return false;
|
||||
if (actionInFlight.current) return false;
|
||||
actionInFlight.current = true;
|
||||
setPendingAction(action);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
if (!operatorIntents.current.isRuntimeCurrent(runtimeToken)) return false;
|
||||
const nextState = await operation();
|
||||
if (!operatorIntents.current.isRuntimeCurrent(runtimeToken)) return false;
|
||||
acceptState(nextState);
|
||||
if (mounted.current) acceptState(nextState);
|
||||
return true;
|
||||
} catch (operationError) {
|
||||
if (operatorIntents.current.isRuntimeCurrent(runtimeToken)) {
|
||||
if (mounted.current) {
|
||||
setError(messageFor(operationError));
|
||||
if (
|
||||
operationError instanceof ApiError
|
||||
&& operationError.transportUnavailable
|
||||
) {
|
||||
if (operationError instanceof ApiError && operationError.status === 0) {
|
||||
setBackendStatus("offline");
|
||||
}
|
||||
}
|
||||
return false;
|
||||
} finally {
|
||||
if (
|
||||
actionInFlight.current?.runtimeGeneration === actionToken.runtimeGeneration
|
||||
&& actionInFlight.current.actionSequence === actionToken.actionSequence
|
||||
) {
|
||||
actionInFlight.current = null;
|
||||
if (operatorIntents.current.isRuntimeCurrent(runtimeToken)) {
|
||||
setPendingAction(null);
|
||||
}
|
||||
}
|
||||
actionInFlight.current = false;
|
||||
if (mounted.current) setPendingAction(null);
|
||||
}
|
||||
},
|
||||
[acceptState, enabled],
|
||||
|
|
@ -359,24 +237,7 @@ export function useXgridsK1Runtime(enabled: boolean) {
|
|||
const startCanonicalAcquisition = useCallback(
|
||||
(request: CanonicalLiveStartRequest) =>
|
||||
run("live", async () => {
|
||||
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;
|
||||
let nextState = await xgridsK1Api.getState();
|
||||
acceptState(nextState);
|
||||
const plan = liveStartPlan(nextState);
|
||||
if (plan === "blocked") {
|
||||
|
|
@ -385,84 +246,50 @@ export function useXgridsK1Runtime(enabled: boolean) {
|
|||
if (plan === "already-running") return nextState;
|
||||
|
||||
for (;;) {
|
||||
assertOperatorIntentCurrent();
|
||||
const phase = controlPhase(nextState);
|
||||
const acquisition = nextState.acquisition;
|
||||
const entryPlan = controlSessionEntryPlan(
|
||||
phase,
|
||||
openedControlSession,
|
||||
nextState.application_control_session?.can_open === true,
|
||||
);
|
||||
|
||||
if (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 (["idle", "closed", "completed"].includes(phase)) {
|
||||
if (acquisition && !isTerminalAcquisitionState(acquisition.state)) {
|
||||
throw new ApiError(
|
||||
"Незавершённая подготовка не привязана к открытой control-сессии. Отмените её перед новым запуском.",
|
||||
);
|
||||
}
|
||||
openedControlSession = true;
|
||||
nextState = await awaitWhileIntentCurrent(
|
||||
assertOperatorIntentCurrent,
|
||||
() => xgridsK1Api.openApplicationControlSession(request.control),
|
||||
);
|
||||
nextState = await xgridsK1Api.openApplicationControlSession(request.control);
|
||||
acceptState(nextState);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (phase === "failed") {
|
||||
if (nextState.application_control_session?.can_open !== true) {
|
||||
throw controlFailure(nextState);
|
||||
}
|
||||
nextState = await xgridsK1Api.openApplicationControlSession(request.control);
|
||||
acceptState(nextState);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (phase === "connecting") {
|
||||
nextState = await awaitWhileIntentCurrent(
|
||||
assertOperatorIntentCurrent,
|
||||
() => waitForControlPhase(
|
||||
"connection-ready",
|
||||
acceptState,
|
||||
assertOperatorIntentCurrent,
|
||||
),
|
||||
);
|
||||
nextState = await waitForControlPhase("connection-ready", acceptState);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (phase === "connection-ready") {
|
||||
nextState = await awaitWhileIntentCurrent(
|
||||
assertOperatorIntentCurrent,
|
||||
() => xgridsK1Api.enterApplicationWorkspace({
|
||||
operator_confirmed: true,
|
||||
}),
|
||||
);
|
||||
nextState = await xgridsK1Api.enterApplicationWorkspace({
|
||||
operator_confirmed: true,
|
||||
});
|
||||
acceptState(nextState);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (phase === "workspace-requested") {
|
||||
nextState = await awaitWhileIntentCurrent(
|
||||
assertOperatorIntentCurrent,
|
||||
() => waitForControlPhase(
|
||||
"workspace-ready",
|
||||
acceptState,
|
||||
assertOperatorIntentCurrent,
|
||||
),
|
||||
);
|
||||
nextState = await waitForControlPhase("workspace-ready", acceptState);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (phase === "workspace-ready") {
|
||||
if (!acquisition || isTerminalAcquisitionState(acquisition.state)) {
|
||||
nextState = await awaitWhileIntentCurrent(
|
||||
assertOperatorIntentCurrent,
|
||||
() => xgridsK1Api.prepareAcquisition(request.acquisition),
|
||||
);
|
||||
nextState = await xgridsK1Api.prepareAcquisition(request.acquisition);
|
||||
acceptState(nextState);
|
||||
continue;
|
||||
}
|
||||
|
|
@ -471,26 +298,12 @@ export function useXgridsK1Runtime(enabled: boolean) {
|
|||
"Текущая подготовка не принадлежит канонической control-сессии K1.",
|
||||
);
|
||||
}
|
||||
nextState = await awaitWhileIntentCurrent(
|
||||
assertOperatorIntentCurrent,
|
||||
() => waitForControlPhase(
|
||||
"project-ready",
|
||||
acceptState,
|
||||
assertOperatorIntentCurrent,
|
||||
),
|
||||
);
|
||||
nextState = await waitForControlPhase("project-ready", acceptState);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (phase === "project-requested") {
|
||||
nextState = await awaitWhileIntentCurrent(
|
||||
assertOperatorIntentCurrent,
|
||||
() => waitForControlPhase(
|
||||
"project-ready",
|
||||
acceptState,
|
||||
assertOperatorIntentCurrent,
|
||||
),
|
||||
);
|
||||
nextState = await waitForControlPhase("project-ready", acceptState);
|
||||
continue;
|
||||
}
|
||||
|
||||
|
|
@ -498,14 +311,11 @@ export function useXgridsK1Runtime(enabled: boolean) {
|
|||
if (!acquisition || acquisition.state !== "prepared") {
|
||||
throw new ApiError("Локальный приём не подготовлен к каноническому START.");
|
||||
}
|
||||
nextState = await awaitWhileIntentCurrent(
|
||||
assertOperatorIntentCurrent,
|
||||
() => xgridsK1Api.startAcquisition({
|
||||
acquisition_id: acquisition.acquisition_id,
|
||||
expected_state_revision: acquisition.state_revision,
|
||||
physical_acceptance: request.physicalAcceptance,
|
||||
}),
|
||||
);
|
||||
nextState = await xgridsK1Api.startAcquisition({
|
||||
acquisition_id: acquisition.acquisition_id,
|
||||
expected_state_revision: acquisition.state_revision,
|
||||
physical_acceptance: request.physicalAcceptance,
|
||||
});
|
||||
acceptState(nextState);
|
||||
return nextState;
|
||||
}
|
||||
|
|
@ -586,6 +396,33 @@ export function useXgridsK1Runtime(enabled: boolean) {
|
|||
[run, state],
|
||||
);
|
||||
|
||||
const confirmStoppedAtSteadyGreen = useCallback(
|
||||
() =>
|
||||
run("stop", () => {
|
||||
const acquisition = state?.acquisition;
|
||||
if (!acquisition || acquisition.state !== "awaiting_external_stop") {
|
||||
throw new ApiError("K1 сейчас не ожидает подтверждения завершённого STOP.");
|
||||
}
|
||||
const stopOperation = [...(state?.operations ?? [])]
|
||||
.reverse()
|
||||
.find(
|
||||
(operation) =>
|
||||
operation.action === "acquisition.stop" &&
|
||||
operation.status === "operator_action_required",
|
||||
);
|
||||
if (!stopOperation) {
|
||||
throw new ApiError("Не найдена исходная операция STOP; повтор команды запрещён.");
|
||||
}
|
||||
return xgridsK1Api.stopAcquisition({
|
||||
acquisition_id: acquisition.acquisition_id,
|
||||
mode: "graceful",
|
||||
operator_confirmed: true,
|
||||
operation_id: stopOperation.operation_id,
|
||||
});
|
||||
}),
|
||||
[run, state],
|
||||
);
|
||||
|
||||
const abort = useCallback(() => {
|
||||
const acquisition = state?.acquisition;
|
||||
if (!acquisition || isTerminalAcquisitionState(acquisition.state)) {
|
||||
|
|
@ -648,7 +485,7 @@ export function useXgridsK1Runtime(enabled: boolean) {
|
|||
|
||||
useEffect(() => {
|
||||
if (!enabled) {
|
||||
operatorIntents.current.deactivateRuntime();
|
||||
mounted.current = true;
|
||||
setState(null);
|
||||
setBackendStatus("checking");
|
||||
setEventStatus("closed");
|
||||
|
|
@ -658,12 +495,12 @@ export function useXgridsK1Runtime(enabled: boolean) {
|
|||
return;
|
||||
}
|
||||
|
||||
operatorIntents.current.activateRuntime();
|
||||
mounted.current = true;
|
||||
void refresh(true);
|
||||
const poll = window.setInterval(() => void refresh(false), 4_000);
|
||||
|
||||
return () => {
|
||||
operatorIntents.current.deactivateRuntime();
|
||||
mounted.current = false;
|
||||
window.clearInterval(poll);
|
||||
};
|
||||
}, [enabled, refresh]);
|
||||
|
|
@ -726,6 +563,7 @@ export function useXgridsK1Runtime(enabled: boolean) {
|
|||
startPreparedAcquisition,
|
||||
startReplay,
|
||||
stop,
|
||||
confirmStoppedAtSteadyGreen,
|
||||
abort,
|
||||
setObservationSourceActive,
|
||||
updateViewerSettings,
|
||||
|
|
|
|||
|
|
@ -366,23 +366,6 @@ def _validate_acquisition_control(profile: dict[str, Any]) -> None:
|
|||
if control.get("mode") != "operator-manual" or control.get("write_enabled") is not False:
|
||||
raise CompatibilityProfileError("acquisition control must remain operator-manual")
|
||||
|
||||
acceptance_transport = _object(
|
||||
control.get("software_acceptance_transport"),
|
||||
"$.acquisition_control.software_acceptance_transport",
|
||||
)
|
||||
if acceptance_transport != {
|
||||
"status": "installed-operator-present",
|
||||
"default_authority": "disabled",
|
||||
"profile_gate": "live-device-info-exact-match",
|
||||
"dialogue": "single-socket-canonical-start-to-stop",
|
||||
"automatic_retry": False,
|
||||
"supported_mount_type": "handheld",
|
||||
"supported_gnss_mode": "none",
|
||||
}:
|
||||
raise CompatibilityProfileError(
|
||||
"software acceptance transport differs from the reviewed operator-present contract"
|
||||
)
|
||||
|
||||
device_control = _object(
|
||||
control.get("verified_device_control"),
|
||||
"$.acquisition_control.verified_device_control",
|
||||
|
|
@ -514,8 +497,6 @@ def validate_compatibility_profile(profile: Any) -> dict[str, Any]:
|
|||
scope = _object(root.get("scope"), "$.scope")
|
||||
if scope.get("vendor") != "XGRIDS" or scope.get("model") != "LixelKity K1":
|
||||
raise CompatibilityProfileError("profile vendor/model must remain XGRIDS LixelKity K1")
|
||||
if scope.get("platform_type") != "A4":
|
||||
raise CompatibilityProfileError("profile platform type must remain the observed A4")
|
||||
firmware = _object(scope.get("firmware"), "$.scope.firmware")
|
||||
if firmware != {"match": "exact", "version": "3.0.2"}:
|
||||
raise CompatibilityProfileError("profile must match firmware 3.0.2 exactly")
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@
|
|||
"scope": {
|
||||
"vendor": "XGRIDS",
|
||||
"model": "LixelKity K1",
|
||||
"platform_type": "A4",
|
||||
"firmware": {
|
||||
"match": "exact",
|
||||
"version": "3.0.2"
|
||||
|
|
@ -27,8 +26,8 @@
|
|||
"request_topic_subscription_enabled": false,
|
||||
"notes": [
|
||||
"Loading this descriptive profile does not authorize a BLE or MQTT write.",
|
||||
"The reviewed Wi-Fi provisioning procedure remains a separate explicit operator action and is not activated by loading this profile.",
|
||||
"Observed LixelGO modeling requests remain write-disabled in this descriptive profile; the separately installed acceptance transport requires a live DeviceInfo match and one operator-present action permit.",
|
||||
"The existing reviewed Wi-Fi provisioning procedure remains separately operator-confirmed and is not activated by this profile.",
|
||||
"Observed LixelGO modeling requests describe the wire contract but remain non-replayable and write-disabled.",
|
||||
"Unknown firmware, transport, topics, fields, and action responses fail closed."
|
||||
]
|
||||
},
|
||||
|
|
@ -317,15 +316,6 @@
|
|||
"acquisition_control": {
|
||||
"mode": "operator-manual",
|
||||
"write_enabled": false,
|
||||
"software_acceptance_transport": {
|
||||
"status": "installed-operator-present",
|
||||
"default_authority": "disabled",
|
||||
"profile_gate": "live-device-info-exact-match",
|
||||
"dialogue": "single-socket-canonical-start-to-stop",
|
||||
"automatic_retry": false,
|
||||
"supported_mount_type": "handheld",
|
||||
"supported_gnss_mode": "none"
|
||||
},
|
||||
"verified_device_control": {
|
||||
"gesture": "physical-double-click",
|
||||
"state_dependent_result": "start from steady-green standby; stop during active scanning",
|
||||
|
|
@ -369,7 +359,7 @@
|
|||
},
|
||||
"success_result_code": 302252033,
|
||||
"required_unresolved_context": [
|
||||
"operator-owned Keychain authority provisioning and operator-present physical acceptance",
|
||||
"operator-owned Keychain item provisioning and physical acceptance of the uninstalled reviewed transport",
|
||||
"authorization policy for any setting outside the retained request",
|
||||
"timeout, rejection and rollback contract"
|
||||
],
|
||||
|
|
@ -416,7 +406,7 @@
|
|||
"request_fields": {},
|
||||
"success_result_code": 302252033,
|
||||
"required_unresolved_context": [
|
||||
"operator-owned Keychain authority provisioning and operator-present physical acceptance",
|
||||
"operator-owned Keychain item provisioning and physical acceptance of the uninstalled reviewed transport",
|
||||
"save-completion and final-standby state mapping",
|
||||
"timeout and rollback contract"
|
||||
],
|
||||
|
|
|
|||
|
|
@ -90,7 +90,6 @@ class XgridsK1CameraGateway:
|
|||
self._target_host: str | None = None
|
||||
self._producer: _CameraProducer | None = None
|
||||
self._recording_root: Path | None = None
|
||||
self._expected_source_end_generation: int | None = None
|
||||
self._archive_summaries: list[dict[str, Any]] = []
|
||||
self._error: dict[str, str] | None = None
|
||||
self._closed = False
|
||||
|
|
@ -125,9 +124,6 @@ class XgridsK1CameraGateway:
|
|||
},
|
||||
"recording": {
|
||||
"active": self._recording_root is not None,
|
||||
"source_end_expected": (
|
||||
self._expected_source_end_generation is not None
|
||||
),
|
||||
"session": (
|
||||
self._recording_root.name if self._recording_root is not None else None
|
||||
),
|
||||
|
|
@ -179,7 +175,6 @@ class XgridsK1CameraGateway:
|
|||
self._revision += 1
|
||||
self._source_id = source_id
|
||||
self._target_host = target
|
||||
self._expected_source_end_generation = None
|
||||
self._phase = "selected"
|
||||
self._error = None
|
||||
recording_active = self._recording_root is not None
|
||||
|
|
@ -226,7 +221,6 @@ class XgridsK1CameraGateway:
|
|||
self._source_id = None
|
||||
self._target_host = None
|
||||
self._recording_root = None
|
||||
self._expected_source_end_generation = None
|
||||
self._error = None
|
||||
self._shutdown_producer(
|
||||
producer,
|
||||
|
|
@ -254,7 +248,6 @@ class XgridsK1CameraGateway:
|
|||
raise RuntimeError("для camera gateway уже активна другая acquisition-сессия")
|
||||
producer, delivery = self._detach_producer_locked()
|
||||
self._recording_root = root
|
||||
self._expected_source_end_generation = None
|
||||
self._archive_summaries = []
|
||||
selected = self._source_id is not None
|
||||
if selected:
|
||||
|
|
@ -273,29 +266,6 @@ class XgridsK1CameraGateway:
|
|||
self._spawn_selected_producer()
|
||||
return self.snapshot()
|
||||
|
||||
def expect_source_end_for_device_stop(self) -> dict[str, Any]:
|
||||
"""Classify one clean FFmpeg EOF as the camera tail of canonical STOP."""
|
||||
|
||||
with self._lock:
|
||||
producer = self._producer
|
||||
if (
|
||||
self._recording_root is not None
|
||||
and producer is not None
|
||||
and producer.archive is not None
|
||||
):
|
||||
self._expected_source_end_generation = producer.generation
|
||||
self._revision += 1
|
||||
return self.snapshot()
|
||||
|
||||
def cancel_expected_source_end(self) -> dict[str, Any]:
|
||||
"""Revoke a STOP expectation when the control checkpoint was not released."""
|
||||
|
||||
with self._lock:
|
||||
if self._expected_source_end_generation is not None:
|
||||
self._expected_source_end_generation = None
|
||||
self._revision += 1
|
||||
return self.snapshot()
|
||||
|
||||
def stop_recording(
|
||||
self,
|
||||
*,
|
||||
|
|
@ -309,7 +279,6 @@ class XgridsK1CameraGateway:
|
|||
producer, delivery = self._detach_producer_locked()
|
||||
recording_was_active = self._recording_root is not None
|
||||
self._recording_root = None
|
||||
self._expected_source_end_generation = None
|
||||
if self._source_id is not None and self._phase != "error":
|
||||
self._phase = "selected"
|
||||
if recording_was_active or producer is not None:
|
||||
|
|
@ -392,7 +361,6 @@ class XgridsK1CameraGateway:
|
|||
self._source_id = None
|
||||
self._target_host = None
|
||||
self._recording_root = None
|
||||
self._expected_source_end_generation = None
|
||||
self._error = None
|
||||
self._shutdown_producer(
|
||||
producer,
|
||||
|
|
@ -591,49 +559,29 @@ class XgridsK1CameraGateway:
|
|||
delivery = producer.delivery
|
||||
producer.delivery = None
|
||||
owns_shutdown = self._producer is producer and not producer.stop_requested
|
||||
expected_clean_end = (
|
||||
owns_shutdown
|
||||
and producer.failure_code is None
|
||||
and self._expected_source_end_generation == producer.generation
|
||||
)
|
||||
if owns_shutdown:
|
||||
self._producer = None
|
||||
self._expected_source_end_generation = None
|
||||
self._revision += 1
|
||||
if expected_clean_end:
|
||||
self._phase = "idle"
|
||||
self._source_id = None
|
||||
self._target_host = None
|
||||
self._error = None
|
||||
elif producer.failure_code is None:
|
||||
if producer.failure_code is None:
|
||||
producer.failure_code = "camera-source-ended"
|
||||
self._set_error_locked(
|
||||
"camera-source-ended",
|
||||
_safe_ffmpeg_message(producer.stderr_tail),
|
||||
)
|
||||
if delivery is not None:
|
||||
delivery.failure_code = (
|
||||
None
|
||||
if expected_clean_end
|
||||
else producer.failure_code or "camera-source-ended"
|
||||
)
|
||||
delivery.failure_code = producer.failure_code or "camera-source-ended"
|
||||
_close_segment_queue(delivery)
|
||||
if not owns_shutdown:
|
||||
return
|
||||
_terminate_process(producer.process)
|
||||
status: CameraArchiveStatus = (
|
||||
"complete"
|
||||
if expected_clean_end
|
||||
else "interrupted"
|
||||
if producer.failure_code in {"camera-source-ended", "incomplete-fmp4-fragment"}
|
||||
"interrupted"
|
||||
if producer.failure_code
|
||||
in {"camera-source-ended", "incomplete-fmp4-fragment"}
|
||||
else "failed"
|
||||
)
|
||||
try:
|
||||
self._finalize_archive(
|
||||
producer,
|
||||
status,
|
||||
None if expected_clean_end else producer.failure_code,
|
||||
)
|
||||
self._finalize_archive(producer, status, producer.failure_code)
|
||||
except CameraArchiveError:
|
||||
with self._lock:
|
||||
self._set_error_locked(
|
||||
|
|
|
|||
|
|
@ -251,7 +251,7 @@ def serve_console(
|
|||
] = 8000,
|
||||
) -> None:
|
||||
"""Serve the built Mission Core Control Station and loopback control API."""
|
||||
frontend = Path(__file__).resolve().parents[4] / "apps" / "control-station" / "dist"
|
||||
frontend = Path(__file__).resolve().parents[2] / "apps" / "control-station" / "dist"
|
||||
if not frontend.is_dir():
|
||||
console.print(
|
||||
"[red]Frontend build is missing.[/red] Run npm install && npm run build "
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@ import hmac
|
|||
import importlib.util
|
||||
import json
|
||||
import secrets
|
||||
import socket
|
||||
import threading
|
||||
import time
|
||||
import unicodedata
|
||||
|
|
@ -179,18 +178,17 @@ class BleScanRequest(StrictRequest):
|
|||
|
||||
|
||||
class CompatibilityAttestationRequest(StrictRequest):
|
||||
"""Selected profile whose facts must be verified from live DeviceInfo."""
|
||||
"""Explicit operator claim required before using the exact lab profile."""
|
||||
|
||||
firmware_version: Literal["3.0.2"]
|
||||
topology: Literal["direct-lan"]
|
||||
verification: Literal["live-device-info"]
|
||||
operator_confirmed: Literal[True]
|
||||
|
||||
|
||||
class ConnectRequest(StrictRequest):
|
||||
device_id: str = Field(min_length=1, max_length=128)
|
||||
ssid: str = Field(min_length=1, max_length=128)
|
||||
password: SecretStr = Field(min_length=1, max_length=256)
|
||||
connection_mode: Literal["bridge"] = "bridge"
|
||||
compatibility_attestation: CompatibilityAttestationRequest
|
||||
operation_id: str | None = Field(default=None, min_length=1, max_length=128)
|
||||
idempotency_key: str | None = Field(default=None, min_length=1, max_length=160)
|
||||
|
|
@ -236,8 +234,6 @@ class OperatorPresenceRequest(StrictRequest):
|
|||
|
||||
class PrepareAcquisitionRequest(OperationContextRequest):
|
||||
project_name: str = Field(min_length=1, max_length=96)
|
||||
mount_type: Literal["handheld"] = "handheld"
|
||||
gnss_mode: Literal["none"] = "none"
|
||||
host: str | None = Field(default=None, max_length=15)
|
||||
duration_seconds: float = Field(default=3600.0, ge=1.0, le=86_400.0)
|
||||
requested_streams: tuple[RequestedStreamId, ...] = DEFAULT_LIVE_STREAMS
|
||||
|
|
@ -335,7 +331,6 @@ class XgridsK1CompatibilityService:
|
|||
self._devices: list[dict[str, Any]] = []
|
||||
self._selected_device_id: str | None = None
|
||||
self._k1_ip: str | None = None
|
||||
self._connection_mode: Literal["bridge"] | None = None
|
||||
self._device_ids_by_transport_ref: dict[str, str] = {}
|
||||
self._device_id: str | None = None
|
||||
self._device_session_id: str | None = None
|
||||
|
|
@ -352,8 +347,6 @@ class XgridsK1CompatibilityService:
|
|||
self._operations = OperationJournal()
|
||||
self._acquisition: AcquisitionRecord | None = None
|
||||
self._acquisition_project_name: str | None = None
|
||||
self._acquisition_mount_type: Literal["handheld"] | None = None
|
||||
self._acquisition_gnss_mode: Literal["none"] | None = None
|
||||
self._acquisition_out_dir: Path | None = None
|
||||
self._acquisition_start_operation_id: str | None = None
|
||||
self._acquisition_stop_operation_id: str | None = None
|
||||
|
|
@ -386,8 +379,7 @@ class XgridsK1CompatibilityService:
|
|||
application_control_session = self._application_control_session.snapshot()
|
||||
runtime = self.runtime.snapshot()
|
||||
camera_preview = self.camera_preview.snapshot()
|
||||
self._reconcile_acquisition(runtime, camera_preview, application_control_session)
|
||||
runtime = self.runtime.snapshot()
|
||||
self._reconcile_acquisition(runtime, camera_preview)
|
||||
camera_preview = self.camera_preview.snapshot()
|
||||
metrics = runtime["metrics"]
|
||||
with self._lock:
|
||||
|
|
@ -396,7 +388,6 @@ class XgridsK1CompatibilityService:
|
|||
devices = list(self._devices)
|
||||
selected_device_id = self._selected_device_id
|
||||
k1_ip = self._k1_ip
|
||||
connection_mode = self._connection_mode
|
||||
device_id = self._device_id
|
||||
device_session_id = self._device_session_id
|
||||
device_session_opened_at = self._device_session_opened_at
|
||||
|
|
@ -409,8 +400,6 @@ class XgridsK1CompatibilityService:
|
|||
acquisition = self._acquisition.as_dict() if self._acquisition is not None else None
|
||||
if acquisition is not None:
|
||||
acquisition["project_name"] = self._acquisition_project_name
|
||||
acquisition["mount_type"] = self._acquisition_mount_type
|
||||
acquisition["gnss_mode"] = self._acquisition_gnss_mode
|
||||
acquisition["cleanup_pending"] = (
|
||||
self._acquisition_session_lease is not None
|
||||
and acquisition["state"] in TERMINAL_ACQUISITION_STATES
|
||||
|
|
@ -461,7 +450,6 @@ class XgridsK1CompatibilityService:
|
|||
"devices": devices,
|
||||
"selected_device_id": selected_device_id,
|
||||
"k1_ip": k1_ip,
|
||||
"connection_mode": connection_mode,
|
||||
"compatibility": {
|
||||
"profile_id": active_profile_id,
|
||||
"decision": "limited" if active_profile_id is not None else "unknown",
|
||||
|
|
@ -473,9 +461,9 @@ class XgridsK1CompatibilityService:
|
|||
else "evidence-only"
|
||||
),
|
||||
"firmware_claim": (
|
||||
"live-device-info-verification-required"
|
||||
"operator-attested-exact-3.0.2"
|
||||
if compatibility_attestation is not None
|
||||
else "exact-3.0.2-profile-not-selected"
|
||||
else "exact-3.0.2-profile-not-attested"
|
||||
),
|
||||
"attestation": compatibility_attestation,
|
||||
"vendor_writes_enabled": active_control,
|
||||
|
|
@ -610,7 +598,6 @@ class XgridsK1CompatibilityService:
|
|||
"device_id": request.device_id,
|
||||
"ssid": request.ssid,
|
||||
"password": password,
|
||||
"connection_mode": request.connection_mode,
|
||||
"compatibility_attestation": request.compatibility_attestation.model_dump(
|
||||
mode="json"
|
||||
),
|
||||
|
|
@ -691,7 +678,6 @@ class XgridsK1CompatibilityService:
|
|||
)
|
||||
write_json_atomic(session_dir / "provisioning.sensitive.json", result)
|
||||
ipv4 = _provisioned_ipv4(result)
|
||||
local_address_conflict = ipv4 is not None and _target_is_local_ipv4(ipv4)
|
||||
write_json_atomic(
|
||||
session_dir / "manifest.redacted.json",
|
||||
{
|
||||
|
|
@ -702,9 +688,6 @@ class XgridsK1CompatibilityService:
|
|||
"profile_id": result["profile_id"],
|
||||
"outcome": result["outcome"],
|
||||
"k1_lan_address_observed": ipv4 is not None,
|
||||
"k1_lan_address_admitted": ipv4 is not None
|
||||
and not local_address_conflict,
|
||||
"local_address_conflict": local_address_conflict,
|
||||
"credentials_persisted_by_connector": False,
|
||||
},
|
||||
)
|
||||
|
|
@ -712,15 +695,9 @@ class XgridsK1CompatibilityService:
|
|||
raise RuntimeError(
|
||||
"Устройство не сообщило адрес в локальной сети; автоматического повтора не было"
|
||||
)
|
||||
if local_address_conflict:
|
||||
raise RuntimeError(
|
||||
"Устройство сообщило IPv4-адрес, который уже принадлежит этому компьютеру; "
|
||||
"адрес K1 не принят и автоматического повтора не было"
|
||||
)
|
||||
with self._lock:
|
||||
self._selected_device_id = request.device_id
|
||||
self._k1_ip = ipv4
|
||||
self._connection_mode = request.connection_mode
|
||||
self._device_id = self._device_ids_by_transport_ref.setdefault(
|
||||
request.device_id,
|
||||
new_device_id(),
|
||||
|
|
@ -815,7 +792,7 @@ class XgridsK1CompatibilityService:
|
|||
attestation = self._compatibility_attestation
|
||||
acquisition = self._acquisition
|
||||
if target is None or attestation is None:
|
||||
raise RuntimeError("сначала подключите K1 и выберите exact-profile")
|
||||
raise RuntimeError("сначала подключите и подтвердите exact-profile K1")
|
||||
if acquisition is not None and acquisition.state not in TERMINAL_ACQUISITION_STATES:
|
||||
raise RuntimeError("control-сессия должна быть открыта до подготовки acquisition")
|
||||
self._application_control.disarm()
|
||||
|
|
@ -853,7 +830,7 @@ class XgridsK1CompatibilityService:
|
|||
if self._k1_ip is None or self._selected_device_id is None:
|
||||
raise RuntimeError("сначала выберите и подключите K1 через BLE/Wi-Fi")
|
||||
if self._compatibility_attestation is None:
|
||||
raise RuntimeError("exact FW 3.0.2 direct-LAN profile не выбран")
|
||||
raise RuntimeError("exact FW 3.0.2 direct-LAN profile не подтверждён")
|
||||
acquisition = self._acquisition
|
||||
if acquisition is not None and acquisition.state not in TERMINAL_ACQUISITION_STATES:
|
||||
raise RuntimeError(
|
||||
|
|
@ -937,8 +914,6 @@ class XgridsK1CompatibilityService:
|
|||
"requested_streams": requested_streams,
|
||||
"evidence_policy": request.evidence_policy,
|
||||
"project_name": request.project_name,
|
||||
"mount_type": request.mount_type,
|
||||
"gnss_mode": request.gnss_mode,
|
||||
"compatibility_attestation": request.compatibility_attestation.model_dump(
|
||||
mode="json"
|
||||
),
|
||||
|
|
@ -999,8 +974,6 @@ class XgridsK1CompatibilityService:
|
|||
)
|
||||
self._acquisition = acquisition
|
||||
self._acquisition_project_name = request.project_name
|
||||
self._acquisition_mount_type = request.mount_type
|
||||
self._acquisition_gnss_mode = request.gnss_mode
|
||||
self._acquisition_out_dir = new_live_session_dir(self.evidence_root)
|
||||
self._acquisition_start_operation_id = None
|
||||
self._acquisition_stop_operation_id = None
|
||||
|
|
@ -1163,27 +1136,20 @@ class XgridsK1CompatibilityService:
|
|||
expected_stop_operation_id = self._acquisition_stop_operation_id
|
||||
lease_retained = self._acquisition_session_lease is not None
|
||||
|
||||
terminal_manual_stop_recovery = (
|
||||
request.operator_confirmed
|
||||
and not plugin_commanded
|
||||
and acquisition_state in TERMINAL_ACQUISITION_STATES
|
||||
)
|
||||
|
||||
if request.operator_confirmed:
|
||||
if request.mode != "graceful":
|
||||
raise ValueError("operator_confirmed допустим только для graceful stop")
|
||||
if (
|
||||
acquisition_state != "awaiting_external_stop"
|
||||
and not terminal_manual_stop_recovery
|
||||
):
|
||||
if acquisition_state != "awaiting_external_stop":
|
||||
raise ValueError("acquisition не ожидает подтверждения физической остановки")
|
||||
if request.operation_id is None or request.operation_id != expected_stop_operation_id:
|
||||
raise ValueError(
|
||||
"подтверждение остановки должно ссылаться на исходную stop-operation"
|
||||
)
|
||||
if plugin_commanded:
|
||||
raise ValueError(
|
||||
"канонический STOP завершается автоматически по READY от K1"
|
||||
if plugin_commanded and not self._application_control_session.snapshot()[
|
||||
"can_confirm_standby"
|
||||
]:
|
||||
raise RuntimeError(
|
||||
"сначала дождитесь READY от K1 и визуально подтвердите зелёный индикатор"
|
||||
)
|
||||
elif (
|
||||
request.mode == "graceful"
|
||||
|
|
@ -1214,14 +1180,6 @@ class XgridsK1CompatibilityService:
|
|||
if self._application_control_session.snapshot()["state"] != "scanning":
|
||||
raise RuntimeError("канонический диалог K1 ещё не готов принять STOP")
|
||||
|
||||
if terminal_manual_stop_recovery:
|
||||
if lease_retained:
|
||||
self._stop_acquisition_sources(
|
||||
camera_status="failed",
|
||||
camera_failure_code="terminal-local-failure-after-device-stop",
|
||||
)
|
||||
return self.state()
|
||||
|
||||
request_fingerprint = self._request_fingerprint(
|
||||
ACTION_ACQUISITION_STOP,
|
||||
{
|
||||
|
|
@ -1313,14 +1271,9 @@ class XgridsK1CompatibilityService:
|
|||
if request.mode == "graceful" and not request.operator_confirmed:
|
||||
if plugin_commanded:
|
||||
assert request.physical_acceptance is not None
|
||||
self.camera_preview.expect_source_end_for_device_stop()
|
||||
try:
|
||||
self._application_control_session.request_stop(
|
||||
confirmation=request.physical_acceptance.confirmation()
|
||||
)
|
||||
except Exception:
|
||||
self.camera_preview.cancel_expected_source_end()
|
||||
raise
|
||||
self._application_control_session.request_stop(
|
||||
confirmation=request.physical_acceptance.confirmation()
|
||||
)
|
||||
with self._lock:
|
||||
acquisition.transition(
|
||||
"awaiting_external_stop",
|
||||
|
|
@ -1329,17 +1282,22 @@ class XgridsK1CompatibilityService:
|
|||
if plugin_commanded
|
||||
else "acquisition.stop.operator_action_required"
|
||||
),
|
||||
operator_instructions=()
|
||||
if plugin_commanded
|
||||
else (
|
||||
"Дважды нажмите физическую кнопку устройства и "
|
||||
"подтвердите остановку.",
|
||||
operator_instructions=(
|
||||
(
|
||||
"Канонический STOP запрошен один раз. Дождитесь READY, "
|
||||
"убедитесь, что индикатор постоянно зелёный, и подтвердите это."
|
||||
if plugin_commanded
|
||||
else (
|
||||
"Дважды нажмите физическую кнопку устройства и "
|
||||
"подтвердите остановку."
|
||||
)
|
||||
),
|
||||
),
|
||||
)
|
||||
self._acquisition_stop_operation_id = operation.operation_id
|
||||
self._operations.transition(
|
||||
operation.operation_id,
|
||||
"running" if plugin_commanded else "operator_action_required",
|
||||
"operator_action_required",
|
||||
stage_code="awaiting-external-stop",
|
||||
message_code=(
|
||||
"acquisition.stop.device_stopping"
|
||||
|
|
@ -1357,6 +1315,8 @@ class XgridsK1CompatibilityService:
|
|||
message_code="acquisition.stop.stopping_receiver",
|
||||
)
|
||||
try:
|
||||
if plugin_commanded and request.mode == "graceful" and request.operator_confirmed:
|
||||
self._application_control_session.confirm_standby()
|
||||
with self._lock:
|
||||
acquisition.transition("stopping", message_code="acquisition.stopping")
|
||||
self._stop_acquisition_sources(
|
||||
|
|
@ -1854,51 +1814,18 @@ class XgridsK1CompatibilityService:
|
|||
self,
|
||||
runtime: Mapping[str, Any],
|
||||
camera: Mapping[str, Any],
|
||||
application_control_session: Mapping[str, Any],
|
||||
) -> None:
|
||||
with self._lock:
|
||||
current = self._acquisition
|
||||
terminal_canonical_cleanup = (
|
||||
current is not None
|
||||
and current.state in TERMINAL_ACQUISITION_STATES
|
||||
and current.control_mode == "plugin-commanded"
|
||||
and application_control_session.get("state") == "completed"
|
||||
and self._acquisition_session_lease is not None
|
||||
)
|
||||
terminal_camera_status: Literal["complete", "failed"] = (
|
||||
"complete"
|
||||
if current is not None and current.state == "completed"
|
||||
else "failed"
|
||||
)
|
||||
if terminal_canonical_cleanup:
|
||||
self._stop_acquisition_sources(
|
||||
camera_status=terminal_camera_status,
|
||||
camera_failure_code=(
|
||||
None
|
||||
if terminal_camera_status == "complete"
|
||||
else "terminal-local-failure-after-device-standby"
|
||||
),
|
||||
)
|
||||
return
|
||||
|
||||
completed_operation_id: str | None = None
|
||||
failed_operation_id: str | None = None
|
||||
failed_stop_operation_id: str | None = None
|
||||
unconfirmed_stop_operation_id: str | None = None
|
||||
completed_stop_operation_id: str | None = None
|
||||
receiver_ready_operation_id: str | None = None
|
||||
receiver_plugin_commanded = False
|
||||
acquisition_id: str | None = None
|
||||
camera_terminal_status: Literal["complete", "failed"] | None = None
|
||||
camera_failure_code: str | None = None
|
||||
stop_runtime_for_camera_failure = False
|
||||
stop_runtime_for_canonical_completion = False
|
||||
complete_after_seal = False
|
||||
completion_message_code = "acquisition.receiver_completed"
|
||||
completion_result: dict[str, Any] = {
|
||||
"receiver_stopped": True,
|
||||
"device_state": "unknown",
|
||||
}
|
||||
with self._lock:
|
||||
acquisition = self._acquisition
|
||||
if acquisition is None or acquisition.state in TERMINAL_ACQUISITION_STATES:
|
||||
|
|
@ -1943,25 +1870,6 @@ class XgridsK1CompatibilityService:
|
|||
failed_stop_operation_id = self._acquisition_stop_operation_id
|
||||
camera_terminal_status = "failed"
|
||||
stop_runtime_for_camera_failure = True
|
||||
elif (
|
||||
acquisition.state == "awaiting_external_stop"
|
||||
and acquisition.control_mode == "plugin-commanded"
|
||||
and application_control_session.get("state") == "completed"
|
||||
):
|
||||
acquisition.transition(
|
||||
"finalizing",
|
||||
message_code="acquisition.stop.device_standby_confirmed",
|
||||
)
|
||||
camera_terminal_status = "complete"
|
||||
stop_runtime_for_canonical_completion = True
|
||||
complete_after_seal = True
|
||||
completed_stop_operation_id = self._acquisition_stop_operation_id
|
||||
completion_message_code = "acquisition.stop.completed"
|
||||
completion_result = {
|
||||
"receiver_stopped": True,
|
||||
"device_state": "ready",
|
||||
"device_stop": "protocol-confirmed",
|
||||
}
|
||||
elif acquisition.state in {"starting", "awaiting_external_start"} and point_frames > 0:
|
||||
acquisition.transition("acquiring", message_code="acquisition.acquiring")
|
||||
completed_operation_id = self._acquisition_start_operation_id
|
||||
|
|
@ -2046,7 +1954,7 @@ class XgridsK1CompatibilityService:
|
|||
)
|
||||
except Exception as exc:
|
||||
terminal_error = exc
|
||||
if stop_runtime_for_camera_failure or stop_runtime_for_canonical_completion:
|
||||
if stop_runtime_for_camera_failure:
|
||||
try:
|
||||
self.runtime.stop()
|
||||
except Exception as exc:
|
||||
|
|
@ -2065,8 +1973,11 @@ class XgridsK1CompatibilityService:
|
|||
if acquisition.state not in TERMINAL_ACQUISITION_STATES:
|
||||
acquisition.transition(
|
||||
"completed",
|
||||
message_code=completion_message_code,
|
||||
result=completion_result,
|
||||
message_code="acquisition.receiver_completed",
|
||||
result={
|
||||
"receiver_stopped": True,
|
||||
"device_state": "unknown",
|
||||
},
|
||||
)
|
||||
except Exception as exc:
|
||||
reconciliation_error = exc
|
||||
|
|
@ -2147,32 +2058,6 @@ class XgridsK1CompatibilityService:
|
|||
"side_effect_status": "unknown",
|
||||
},
|
||||
)
|
||||
if completed_stop_operation_id is not None:
|
||||
if reconciliation_error is None:
|
||||
self._operations.transition_if_pending(
|
||||
completed_stop_operation_id,
|
||||
"succeeded",
|
||||
stage_code="device-standby-confirmed",
|
||||
message_code="acquisition.stop.completed",
|
||||
result={
|
||||
"acquisition_id": acquisition.acquisition_id,
|
||||
"confirmation": "device-status-ready-unbound",
|
||||
},
|
||||
)
|
||||
else:
|
||||
self._operations.transition_if_pending(
|
||||
completed_stop_operation_id,
|
||||
"failed",
|
||||
stage_code="local-finalization-failed",
|
||||
message_code="acquisition.stop.local_finalization_failed",
|
||||
error={
|
||||
"category": "stream",
|
||||
"code": "local-finalization-failed-after-device-standby",
|
||||
"retryable": True,
|
||||
"safe_to_retry": True,
|
||||
"side_effect_status": "succeeded",
|
||||
},
|
||||
)
|
||||
with self._lock:
|
||||
if self._acquisition_start_operation_id in {
|
||||
completed_operation_id,
|
||||
|
|
@ -2183,8 +2068,6 @@ class XgridsK1CompatibilityService:
|
|||
self._acquisition_stop_operation_id = None
|
||||
if self._acquisition_stop_operation_id == unconfirmed_stop_operation_id:
|
||||
self._acquisition_stop_operation_id = None
|
||||
if self._acquisition_stop_operation_id == completed_stop_operation_id:
|
||||
self._acquisition_stop_operation_id = None
|
||||
if reconciliation_error is not None:
|
||||
raise reconciliation_error
|
||||
|
||||
|
|
@ -2462,8 +2345,7 @@ def _attestation_snapshot(
|
|||
return {
|
||||
"firmware_version": attestation.firmware_version,
|
||||
"topology": attestation.topology,
|
||||
"verification": attestation.verification,
|
||||
"basis": "selected-profile-live-device-info-required",
|
||||
"basis": "operator-attested",
|
||||
"observed_at": _utc_now_iso(),
|
||||
}
|
||||
|
||||
|
|
@ -2512,7 +2394,7 @@ def _sensor_catalog(
|
|||
else (
|
||||
"local-runtime-dependency-missing"
|
||||
if camera_profile_active
|
||||
else "profile-not-selected"
|
||||
else "profile-not-attested"
|
||||
)
|
||||
),
|
||||
"endpoint_label": f"RTSP · {side}",
|
||||
|
|
@ -2624,24 +2506,6 @@ def _provisioned_ipv4(result: Mapping[str, Any]) -> str | None:
|
|||
return None
|
||||
|
||||
|
||||
def _target_is_local_ipv4(target: str) -> bool:
|
||||
"""Reject a BLE-reported target when the host route resolves back to itself.
|
||||
|
||||
Connecting a UDP socket only asks the kernel to select a route and source
|
||||
address; it does not transmit a datagram. This keeps provisioning admission
|
||||
free of a speculative device probe while preventing a host address from
|
||||
being mistaken for the K1 endpoint.
|
||||
"""
|
||||
|
||||
try:
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as route_socket:
|
||||
route_socket.connect((validate_private_ipv4(target), 9))
|
||||
source_address = str(route_socket.getsockname()[0])
|
||||
except OSError as exc:
|
||||
raise RuntimeError("не удалось безопасно проверить маршрут к адресу K1") from exc
|
||||
return source_address == target
|
||||
|
||||
|
||||
def _validate_installed_compatibility_profile(repository_root: Path) -> None:
|
||||
"""Run the plugin-owned, fail-closed profile validator before activation."""
|
||||
|
||||
|
|
|
|||
|
|
@ -11,7 +11,6 @@ from typing import Literal, Protocol
|
|||
from k1link.device_plugins.xgrids_k1.protocol.application_bootstrap import (
|
||||
MODELING_STATUS_RESPONSE_TOPIC,
|
||||
ApplicationBootstrapError,
|
||||
ApplicationCompatibilityProfileError,
|
||||
ApplicationControlAuthority,
|
||||
CanonicalPostStartObservation,
|
||||
LiveDeviceControlBinding,
|
||||
|
|
@ -40,31 +39,18 @@ MAX_ACCEPTANCE_PERMIT_SECONDS = 120.0
|
|||
|
||||
# Socket-pump quantum only. It is never used to advance a K1 dialogue stage.
|
||||
CONTROL_NETWORK_PUMP_QUANTUM_SECONDS = 1.0
|
||||
SCAN_INITIALIZATION_TIMEOUT_SECONDS = 120.0
|
||||
|
||||
|
||||
class ApplicationAcceptanceError(RuntimeError):
|
||||
"""The operator-present physical acceptance contract failed closed."""
|
||||
|
||||
|
||||
class ScanInitializationTimeout(ApplicationAcceptanceError):
|
||||
"""START was acknowledged but the reviewed SCANNING gate never completed."""
|
||||
|
||||
reason_code = "scan_initialization_timeout"
|
||||
|
||||
|
||||
class ApplicationProfileMismatchError(ApplicationAcceptanceError):
|
||||
"""A correlated live DeviceInfo response rejected the selected profile."""
|
||||
|
||||
reason_code = "compatibility_profile_mismatch"
|
||||
|
||||
|
||||
class ApplicationBatchExchange(Protocol):
|
||||
def exchange_batch_once(
|
||||
self,
|
||||
envelopes: Sequence[OneShotPublishEnvelope],
|
||||
*,
|
||||
required_response_operation_keys: Collection[str],
|
||||
required_response_topics: Collection[str],
|
||||
) -> dict[str, bytes]: ...
|
||||
|
||||
def maintain_open_for(
|
||||
|
|
@ -196,24 +182,8 @@ class PhysicalAcceptanceDialogueExecutor:
|
|||
def __init__(
|
||||
self,
|
||||
transport: ApplicationBatchExchange,
|
||||
*,
|
||||
monotonic: Callable[[], float] = time.monotonic,
|
||||
scan_initialization_timeout_seconds: float = SCAN_INITIALIZATION_TIMEOUT_SECONDS,
|
||||
) -> None:
|
||||
if (
|
||||
not isinstance(scan_initialization_timeout_seconds, (int, float))
|
||||
or isinstance(scan_initialization_timeout_seconds, bool)
|
||||
or not math.isfinite(scan_initialization_timeout_seconds)
|
||||
or scan_initialization_timeout_seconds <= 0
|
||||
):
|
||||
raise ApplicationAcceptanceError(
|
||||
"scan initialization timeout must be a positive finite number"
|
||||
)
|
||||
self._transport = transport
|
||||
self._monotonic = monotonic
|
||||
self._scan_initialization_timeout_seconds = float(
|
||||
scan_initialization_timeout_seconds
|
||||
)
|
||||
self._bootstrap_complete = False
|
||||
self._command_complete = False
|
||||
self._dialogue_stage = "new"
|
||||
|
|
@ -229,7 +199,6 @@ class PhysicalAcceptanceDialogueExecutor:
|
|||
self._stop_permit_snapshot: dict[str, object] | None = None
|
||||
self._response_evidence: list[dict[str, object]] = []
|
||||
self._correlation_failure: dict[str, object] | None = None
|
||||
self._compatibility_failure: dict[str, object] | None = None
|
||||
|
||||
def run_bootstrap(
|
||||
self,
|
||||
|
|
@ -248,7 +217,7 @@ class PhysicalAcceptanceDialogueExecutor:
|
|||
|
||||
if self._dialogue_stage != "new" or self._bootstrap_complete or self._command_complete:
|
||||
raise ApplicationAcceptanceError("connection stage is not admissible now")
|
||||
for expected_batch in (1, 2):
|
||||
for expected_batch in (1, 2, 3):
|
||||
self._exchange_bootstrap_batch(orchestrator, expected_batch=expected_batch)
|
||||
binding = orchestrator.binding
|
||||
if binding is None:
|
||||
|
|
@ -301,7 +270,7 @@ class PhysicalAcceptanceDialogueExecutor:
|
|||
if self._dialogue_stage != "connection-ready":
|
||||
raise ApplicationAcceptanceError("workspace entry requires the connection stage")
|
||||
self._consume_checkpoint(checkpoint, expected="workspace-entered")
|
||||
self._exchange_bootstrap_batch(orchestrator, expected_batch=3)
|
||||
self._exchange_bootstrap_batch(orchestrator, expected_batch=4)
|
||||
binding = orchestrator.binding
|
||||
if binding is None:
|
||||
raise ApplicationAcceptanceError("workspace entry lost the live device binding")
|
||||
|
|
@ -318,7 +287,7 @@ class PhysicalAcceptanceDialogueExecutor:
|
|||
if self._dialogue_stage != "workspace-ready":
|
||||
raise ApplicationAcceptanceError("project prompt requires workspace entry")
|
||||
self._consume_checkpoint(checkpoint, expected="project-prompt-opened")
|
||||
self._exchange_bootstrap_batch(orchestrator, expected_batch=4)
|
||||
self._exchange_bootstrap_batch(orchestrator, expected_batch=5)
|
||||
if not orchestrator.snapshot().bootstrap_complete:
|
||||
raise ApplicationAcceptanceError("project prompt did not complete the transcript")
|
||||
binding = orchestrator.binding
|
||||
|
|
@ -369,9 +338,9 @@ class PhysicalAcceptanceDialogueExecutor:
|
|||
self._dialogue_stage = "start-attempted"
|
||||
responses = self._transport.exchange_batch_once(
|
||||
[OneShotPublishEnvelope.from_modeling_command(command)],
|
||||
required_response_operation_keys={"modeling:start"},
|
||||
required_response_topics={MODELING_RESPONSE_TOPIC},
|
||||
)
|
||||
payload = responses["modeling:start"]
|
||||
payload = responses[MODELING_RESPONSE_TOPIC]
|
||||
self._record_response_evidence(
|
||||
phase="modeling",
|
||||
operation_key="modeling:start",
|
||||
|
|
@ -394,33 +363,24 @@ class PhysicalAcceptanceDialogueExecutor:
|
|||
immediate = post_start.immediate_modeling_status
|
||||
self._transport.exchange_batch_once(
|
||||
[OneShotPublishEnvelope.from_dialogue_request(immediate)],
|
||||
required_response_operation_keys=(),
|
||||
required_response_topics=(),
|
||||
)
|
||||
self._dialogue_stage = "initializing"
|
||||
initialization_deadline = (
|
||||
self._monotonic() + self._scan_initialization_timeout_seconds
|
||||
)
|
||||
while not self._transport.scan_initialization_complete(binding):
|
||||
if self._monotonic() >= initialization_deadline:
|
||||
raise ScanInitializationTimeout(
|
||||
"K1 did not confirm bound SCANNING initialization before the safety deadline"
|
||||
)
|
||||
self._transport.maintain_open_for(
|
||||
CONTROL_NETWORK_PUMP_QUANTUM_SECONDS,
|
||||
allowed_response_topics={MODELING_STATUS_RESPONSE_TOPIC},
|
||||
)
|
||||
|
||||
refresh = post_start.post_initialization_refresh
|
||||
required_operations = {
|
||||
f"dialogue:{request.ordinal}:{request.message_type}" for request in refresh
|
||||
}
|
||||
required_topics = {request.response_topic for request in refresh}
|
||||
refresh_responses = self._transport.exchange_batch_once(
|
||||
[OneShotPublishEnvelope.from_dialogue_request(request) for request in refresh],
|
||||
required_response_operation_keys=required_operations,
|
||||
required_response_topics=required_topics,
|
||||
)
|
||||
for request in refresh:
|
||||
refresh_payload = refresh_responses[request.response_topic]
|
||||
operation_key = f"dialogue:{request.ordinal}:{request.message_type}"
|
||||
refresh_payload = refresh_responses[operation_key]
|
||||
self._record_response_evidence(
|
||||
phase="post-start",
|
||||
operation_key=operation_key,
|
||||
|
|
@ -520,9 +480,9 @@ class PhysicalAcceptanceDialogueExecutor:
|
|||
self._dialogue_stage = "stop-attempted"
|
||||
responses = self._transport.exchange_batch_once(
|
||||
[OneShotPublishEnvelope.from_modeling_command(command)],
|
||||
required_response_operation_keys={"modeling:stop"},
|
||||
required_response_topics={MODELING_RESPONSE_TOPIC},
|
||||
)
|
||||
payload = responses["modeling:stop"]
|
||||
payload = responses[MODELING_RESPONSE_TOPIC]
|
||||
self._record_response_evidence(
|
||||
phase="modeling",
|
||||
operation_key="modeling:stop",
|
||||
|
|
@ -545,13 +505,16 @@ class PhysicalAcceptanceDialogueExecutor:
|
|||
self._dialogue_stage = "stop-acknowledged"
|
||||
return response
|
||||
|
||||
def maintain_post_stop_until_standby(self) -> None:
|
||||
"""Keep servicing control reports through save and protocol standby.
|
||||
def maintain_post_stop_until_standby_confirmed(
|
||||
self,
|
||||
standby_confirmed: Callable[[], bool],
|
||||
) -> None:
|
||||
"""Keep servicing control reports through save and physical standby.
|
||||
|
||||
There is no wall-clock shortcut after STOP. The dialogue remains owned
|
||||
until the bound K1 itself reports READY with no project attached. That
|
||||
live protocol state is the canonical completion evidence; a second
|
||||
operator acknowledgement would add no independent information.
|
||||
The retained capture does not expose a protocol timer that proves save
|
||||
completion. The session therefore remains owned until an explicit
|
||||
operator/status gate confirms standby and never closes itself merely
|
||||
because a captured wall-clock duration elapsed.
|
||||
"""
|
||||
|
||||
if self._dialogue_stage != "stop-acknowledged" or not self._stop_complete:
|
||||
|
|
@ -561,7 +524,9 @@ class PhysicalAcceptanceDialogueExecutor:
|
|||
binding = self._active_binding
|
||||
if binding is None:
|
||||
raise ApplicationAcceptanceError("canonical START binding is no longer available")
|
||||
while not self._transport.standby_complete(binding):
|
||||
while not (
|
||||
self._transport.standby_complete(binding) and standby_confirmed()
|
||||
):
|
||||
self._transport.maintain_open_for(
|
||||
CONTROL_NETWORK_PUMP_QUANTUM_SECONDS,
|
||||
allowed_response_topics={MODELING_STATUS_RESPONSE_TOPIC},
|
||||
|
|
@ -590,19 +555,12 @@ class PhysicalAcceptanceDialogueExecutor:
|
|||
if self._stop_permit_snapshot is not None
|
||||
else None
|
||||
),
|
||||
# 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],
|
||||
"response_evidence": tuple(dict(item) for item in self._response_evidence),
|
||||
"correlation_failure": (
|
||||
dict(self._correlation_failure)
|
||||
if self._correlation_failure is not None
|
||||
else None
|
||||
),
|
||||
"compatibility_failure": (
|
||||
dict(self._compatibility_failure)
|
||||
if self._compatibility_failure is not None
|
||||
else None
|
||||
),
|
||||
"automatic_retry": False,
|
||||
}
|
||||
|
||||
|
|
@ -650,20 +608,18 @@ class PhysicalAcceptanceDialogueExecutor:
|
|||
f"canonical bootstrap expected batch {expected_batch}"
|
||||
)
|
||||
batch = orchestrator.next_batch()
|
||||
required_operations = {
|
||||
f"bootstrap:{request.ordinal}:{request.message_type}"
|
||||
for request in batch
|
||||
if request.response_required
|
||||
required_topics = {
|
||||
request.response_topic for request in batch if request.response_required
|
||||
}
|
||||
responses = self._transport.exchange_batch_once(
|
||||
[OneShotPublishEnvelope.from_bootstrap_request(request) for request in batch],
|
||||
required_response_operation_keys=required_operations,
|
||||
required_response_topics=required_topics,
|
||||
)
|
||||
for request in batch:
|
||||
if not request.response_required:
|
||||
continue
|
||||
payload = responses[request.response_topic]
|
||||
operation_key = f"bootstrap:{request.ordinal}:{request.message_type}"
|
||||
payload = responses[operation_key]
|
||||
self._record_response_evidence(
|
||||
phase="bootstrap",
|
||||
operation_key=operation_key,
|
||||
|
|
@ -671,25 +627,7 @@ class PhysicalAcceptanceDialogueExecutor:
|
|||
payload=payload,
|
||||
)
|
||||
try:
|
||||
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
|
||||
orchestrator.accept_response(request.response_topic, payload)
|
||||
except ApplicationBootstrapError as exc:
|
||||
self._record_correlation_failure(
|
||||
phase="bootstrap",
|
||||
|
|
@ -732,23 +670,5 @@ class PhysicalAcceptanceDialogueExecutor:
|
|||
"phase": phase,
|
||||
"operation_key": operation_key,
|
||||
"response_topic": response_topic,
|
||||
"reason_code": self._correlation_reason_code(reason),
|
||||
"reason": reason,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _correlation_reason_code(reason: str) -> str:
|
||||
reason_fragments = (
|
||||
("session correlation failed", "response_session_mismatch"),
|
||||
("vendor identity mismatch", "response_device_identity_mismatch"),
|
||||
("authority mismatch", "response_authority_mismatch"),
|
||||
("result code", "response_rejected"),
|
||||
("facts changed", "response_device_facts_changed"),
|
||||
("invalid", "response_decode_failed"),
|
||||
("missing", "response_decode_failed"),
|
||||
("wrong wire type", "response_decode_failed"),
|
||||
)
|
||||
return next(
|
||||
(code for fragment, code in reason_fragments if fragment in reason),
|
||||
"response_correlation_failed",
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,12 +1,11 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import platform
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Protocol, cast
|
||||
from typing import Protocol
|
||||
|
||||
from k1link.device_plugins.xgrids_k1.protocol.application_bootstrap import (
|
||||
ApplicationBootstrapError,
|
||||
|
|
@ -34,10 +33,6 @@ class CommandRunner(Protocol):
|
|||
) -> subprocess.CompletedProcess[bytes]: ...
|
||||
|
||||
|
||||
class KeychainSecretReader(Protocol):
|
||||
def __call__(self, *, service: str, account: str) -> bytes: ...
|
||||
|
||||
|
||||
class InteractiveCommandRunner(Protocol):
|
||||
def __call__(
|
||||
self,
|
||||
|
|
@ -48,63 +43,6 @@ class InteractiveCommandRunner(Protocol):
|
|||
) -> subprocess.CompletedProcess[bytes]: ...
|
||||
|
||||
|
||||
def _read_keychain_secret_via_security_framework(*, service: str, account: str) -> bytes:
|
||||
"""Read one fixed generic-password item without spawning a UI/CLI process."""
|
||||
|
||||
try:
|
||||
objc = importlib.import_module("objc")
|
||||
foundation = importlib.import_module("Foundation")
|
||||
bundle = foundation.NSBundle.bundleWithPath_(
|
||||
"/System/Library/Frameworks/Security.framework"
|
||||
)
|
||||
if bundle is None or not bundle.load():
|
||||
raise RuntimeError("Security.framework is unavailable")
|
||||
|
||||
symbols: dict[str, Any] = {}
|
||||
objc.loadBundleVariables(
|
||||
bundle,
|
||||
symbols,
|
||||
[
|
||||
("kSecClass", b"@"),
|
||||
("kSecClassGenericPassword", b"@"),
|
||||
("kSecAttrService", b"@"),
|
||||
("kSecAttrAccount", b"@"),
|
||||
("kSecReturnData", b"@"),
|
||||
("kSecMatchLimit", b"@"),
|
||||
("kSecMatchLimitOne", b"@"),
|
||||
],
|
||||
)
|
||||
functions: dict[str, Any] = {}
|
||||
objc.loadBundleFunctions(
|
||||
bundle,
|
||||
functions,
|
||||
[("SecItemCopyMatching", b"i@o^@")],
|
||||
)
|
||||
query = {
|
||||
symbols["kSecClass"]: symbols["kSecClassGenericPassword"],
|
||||
symbols["kSecAttrService"]: service,
|
||||
symbols["kSecAttrAccount"]: account,
|
||||
symbols["kSecReturnData"]: True,
|
||||
symbols["kSecMatchLimit"]: symbols["kSecMatchLimitOne"],
|
||||
}
|
||||
copy_matching = cast(Any, functions["SecItemCopyMatching"])
|
||||
result = copy_matching(query, None)
|
||||
except ApplicationAuthorityLoadError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
raise ApplicationAuthorityLoadError("macOS Keychain authority lookup failed") from exc
|
||||
|
||||
if not isinstance(result, tuple) or len(result) != 2:
|
||||
raise ApplicationAuthorityLoadError("macOS Keychain authority lookup failed")
|
||||
status, secret_data = result
|
||||
if int(status) != 0 or secret_data is None:
|
||||
raise ApplicationAuthorityLoadError("macOS Keychain authority is unavailable")
|
||||
try:
|
||||
return bytes(secret_data)
|
||||
except Exception as exc:
|
||||
raise ApplicationAuthorityLoadError("macOS Keychain authority lookup failed") from exc
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ApplicationAuthoritySourceSnapshot:
|
||||
provider: str = "macos-keychain"
|
||||
|
|
@ -131,19 +69,12 @@ class MacOSKeychainApplicationAuthorityLoader:
|
|||
"""Read the exact-profile authority from the current user's Keychain.
|
||||
|
||||
There is deliberately no environment, plaintext-file, browser or API
|
||||
fallback. Runtime reads call Apple's Security.framework in-process and
|
||||
never launch Keychain Access or the ``security`` CLI. The optional command
|
||||
runner exists only as a compatibility seam for the reviewed test harness.
|
||||
fallback. The secret is requested through the absolute ``security`` binary
|
||||
and is never interpolated into an exception, repr or subprocess argument.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
runner: CommandRunner | None = None,
|
||||
framework_reader: KeychainSecretReader = _read_keychain_secret_via_security_framework,
|
||||
) -> None:
|
||||
def __init__(self, *, runner: CommandRunner = subprocess.run) -> None:
|
||||
self._runner = runner
|
||||
self._framework_reader = framework_reader
|
||||
|
||||
def snapshot(self) -> ApplicationAuthoritySourceSnapshot:
|
||||
return ApplicationAuthoritySourceSnapshot()
|
||||
|
|
@ -153,48 +84,32 @@ class MacOSKeychainApplicationAuthorityLoader:
|
|||
raise ApplicationAuthorityLoadError(
|
||||
"application authority loading is supported only through macOS Keychain"
|
||||
)
|
||||
if self._runner is None:
|
||||
try:
|
||||
secret_bytes = self._framework_reader(
|
||||
service=KEYCHAIN_SERVICE,
|
||||
account=KEYCHAIN_ACCOUNT,
|
||||
)
|
||||
except ApplicationAuthorityLoadError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
raise ApplicationAuthorityLoadError(
|
||||
"macOS Keychain authority lookup failed"
|
||||
) from exc
|
||||
else:
|
||||
security = shutil.which("security")
|
||||
if security != "/usr/bin/security":
|
||||
raise ApplicationAuthorityLoadError(
|
||||
"trusted macOS security binary is unavailable"
|
||||
)
|
||||
try:
|
||||
completed = self._runner(
|
||||
[
|
||||
security,
|
||||
"find-generic-password",
|
||||
"-s",
|
||||
KEYCHAIN_SERVICE,
|
||||
"-a",
|
||||
KEYCHAIN_ACCOUNT,
|
||||
"-w",
|
||||
],
|
||||
capture_output=True,
|
||||
check=False,
|
||||
timeout=KEYCHAIN_TIMEOUT_SECONDS,
|
||||
)
|
||||
except (OSError, subprocess.SubprocessError) as exc:
|
||||
raise ApplicationAuthorityLoadError(
|
||||
"macOS Keychain authority lookup failed"
|
||||
) from exc
|
||||
if completed.returncode != 0:
|
||||
raise ApplicationAuthorityLoadError("macOS Keychain authority is unavailable")
|
||||
secret_bytes = completed.stdout
|
||||
security = shutil.which("security")
|
||||
if security != "/usr/bin/security":
|
||||
raise ApplicationAuthorityLoadError("trusted macOS security binary is unavailable")
|
||||
|
||||
secret_buffer = bytearray(secret_bytes)
|
||||
try:
|
||||
completed = self._runner(
|
||||
[
|
||||
security,
|
||||
"find-generic-password",
|
||||
"-s",
|
||||
KEYCHAIN_SERVICE,
|
||||
"-a",
|
||||
KEYCHAIN_ACCOUNT,
|
||||
"-w",
|
||||
],
|
||||
capture_output=True,
|
||||
check=False,
|
||||
timeout=KEYCHAIN_TIMEOUT_SECONDS,
|
||||
)
|
||||
except (OSError, subprocess.SubprocessError) as exc:
|
||||
raise ApplicationAuthorityLoadError("macOS Keychain authority lookup failed") from exc
|
||||
|
||||
if completed.returncode != 0:
|
||||
raise ApplicationAuthorityLoadError("macOS Keychain authority is unavailable")
|
||||
|
||||
secret_buffer = bytearray(completed.stdout)
|
||||
try:
|
||||
while secret_buffer.endswith((b"\n", b"\r")):
|
||||
secret_buffer.pop()
|
||||
|
|
|
|||
|
|
@ -11,7 +11,6 @@ from k1link.device_plugins.xgrids_k1.protocol.modeling_control import OPENAPI_SU
|
|||
from k1link.device_plugins.xgrids_k1.protocol.protobuf_wire import (
|
||||
ProtobufWireError,
|
||||
ProtoField,
|
||||
encode_zigzag64,
|
||||
iter_fields,
|
||||
)
|
||||
|
||||
|
|
@ -20,12 +19,6 @@ MAX_HEADER_BYTES = 4 * 1024
|
|||
MAX_TEXT_BYTES = 4 * 1024
|
||||
APPLICATION_AUTHORITY_SOURCE = "owner-captured-lixelgo-application"
|
||||
COMPATIBILITY_PROFILE_ID = "xgrids.lixelkity-k1.fw-3.0.2.direct-lan.v1"
|
||||
REVIEWED_DEVICE_MODEL = "LixelKity K1"
|
||||
REVIEWED_DEVICE_TYPE = "A4"
|
||||
REVIEWED_PROFILE_ATTESTATION_FAILURE = (
|
||||
"live DeviceInfo does not attest the reviewed activated "
|
||||
"LixelKity K1 (platform type A4) FW 3.0.2 profile"
|
||||
)
|
||||
DEVICE_CONFIG_TIME_CONTEXT = "Publish_Proto_DeviceConfig_SetTime"
|
||||
SHADOW_BLOCKERS = ("vendor-writes-disabled", "publisher-not-installed")
|
||||
|
||||
|
|
@ -47,22 +40,6 @@ class ApplicationBootstrapError(ValueError):
|
|||
"""The recovered K1 application bootstrap contract was violated."""
|
||||
|
||||
|
||||
class ApplicationCompatibilityProfileError(ApplicationBootstrapError):
|
||||
"""A correlated DeviceInfo response does not match the selected profile."""
|
||||
|
||||
reason_code = "compatibility_profile_mismatch"
|
||||
|
||||
def __init__(self, binding: LiveDeviceControlBinding) -> None:
|
||||
self.observed_facts = {
|
||||
"device_model": binding.device_model,
|
||||
"platform_type": binding.device_type,
|
||||
"software_version": binding.software_version,
|
||||
"system_version": binding.system_version,
|
||||
"is_activated": binding.is_activated,
|
||||
}
|
||||
super().__init__(REVIEWED_PROFILE_ATTESTATION_FAILURE)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ApplicationControlAuthority:
|
||||
"""Private authority belonging to the reviewed LixelGO application profile.
|
||||
|
|
@ -121,22 +98,9 @@ class LiveDeviceControlBinding:
|
|||
self.system_version, "3.0.2"
|
||||
)
|
||||
|
||||
@property
|
||||
def matches_reviewed_device(self) -> bool:
|
||||
"""Match the exact model/type strings retained from the reviewed K1."""
|
||||
|
||||
return (
|
||||
self.device_model == REVIEWED_DEVICE_MODEL
|
||||
and self.device_type == REVIEWED_DEVICE_TYPE
|
||||
)
|
||||
|
||||
@property
|
||||
def ready_for_reviewed_profile(self) -> bool:
|
||||
return (
|
||||
self.is_activated
|
||||
and self.matches_reviewed_device
|
||||
and self.matches_reviewed_firmware
|
||||
)
|
||||
return self.is_activated and self.matches_reviewed_firmware
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
|
|
@ -249,15 +213,6 @@ class ApplicationResponse:
|
|||
result_code: int
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ApplicationMessageIdentity:
|
||||
"""Redacted identity used to route one protobuf request/response safely."""
|
||||
|
||||
vendor_device_id: str | None = field(repr=False)
|
||||
session_id: str = field(repr=False)
|
||||
openapi_key: str = field(repr=False)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ApplicationBootstrapSnapshot:
|
||||
current_batch: int
|
||||
|
|
@ -298,7 +253,9 @@ def build_shadow_bootstrap(
|
|||
"""
|
||||
|
||||
if not binding.ready_for_reviewed_profile:
|
||||
raise ApplicationCompatibilityProfileError(binding)
|
||||
raise ApplicationBootstrapError(
|
||||
"live DeviceInfo does not attest the reviewed activated FW 3.0.2 profile"
|
||||
)
|
||||
if not isinstance(epoch_seconds, int) or isinstance(epoch_seconds, bool):
|
||||
raise ApplicationBootstrapError("epoch_seconds must be an integer")
|
||||
if epoch_seconds < 0 or epoch_seconds > 0x7FFF_FFFF_FFFF_FFFF:
|
||||
|
|
@ -416,9 +373,7 @@ def build_shadow_bootstrap(
|
|||
)
|
||||
body = b""
|
||||
if message_type == "DeviceConfigRequest":
|
||||
time_config = _varint_field(1, encode_zigzag64(epoch_seconds)) + _text_field(
|
||||
2, timezone_name
|
||||
)
|
||||
time_config = _varint_field(1, epoch_seconds) + _text_field(2, timezone_name)
|
||||
body = _bytes_field(4, time_config)
|
||||
payload = _bytes_field(1, _encode_header(header)) + body
|
||||
_check_payload(payload, "application request")
|
||||
|
|
@ -451,7 +406,9 @@ def build_canonical_post_start_observation(
|
|||
"""Build retained operations 12-14 without granting publish authority."""
|
||||
|
||||
if not binding.ready_for_reviewed_profile:
|
||||
raise ApplicationCompatibilityProfileError(binding)
|
||||
raise ApplicationBootstrapError(
|
||||
"live DeviceInfo does not attest the reviewed activated FW 3.0.2 profile"
|
||||
)
|
||||
|
||||
def build(
|
||||
ordinal: Literal[12, 13, 14],
|
||||
|
|
@ -620,43 +577,15 @@ def correlate_application_response(
|
|||
return ApplicationResponse(session_id, device_id, openapi_key, result_code)
|
||||
|
||||
|
||||
def decode_application_message_identity(payload: bytes) -> ApplicationMessageIdentity:
|
||||
"""Decode only the common header identity without exposing message bodies."""
|
||||
|
||||
_check_payload(payload, "application message")
|
||||
top = _selected_unique_fields(
|
||||
payload,
|
||||
"application message",
|
||||
max_fields=64,
|
||||
selected={1},
|
||||
)
|
||||
header = _selected_unique_fields(
|
||||
_required_bytes(top, 1, "message.header"),
|
||||
"header",
|
||||
max_fields=32,
|
||||
selected={4, 5, 6},
|
||||
)
|
||||
return ApplicationMessageIdentity(
|
||||
vendor_device_id=(
|
||||
_identity_field(header, 4, "header.device_id") if 4 in header else None
|
||||
),
|
||||
session_id=_identity_field(header, 5, "header.session_id"),
|
||||
openapi_key=_identity_field(header, 6, "header.openapi_key"),
|
||||
)
|
||||
|
||||
|
||||
class ShadowApplicationBootstrapOrchestrator:
|
||||
"""Advance the retained dialogue only across observed response barriers.
|
||||
|
||||
DeviceInfo is the sole identity-binding barrier. After it succeeds, the
|
||||
retained captures publish ordinals 2-6 in order without inventing a barrier
|
||||
between unbound ordinal 3 and bound ordinals 4-6. Their exact responses
|
||||
are correlated by operation/session identity before the operator may
|
||||
advance. ``ModelingStatusRequest`` ordinal 2 remains response-free;
|
||||
readiness is a separate live DeviceStatus gate.
|
||||
The clean cycle emitted five batches. ``ModelingStatusRequest`` in the
|
||||
second batch had no required synchronous response before preparation
|
||||
continued; readiness remains a separate live DeviceStatus gate.
|
||||
"""
|
||||
|
||||
_BATCH_ORDINALS = ((1,), (2, 3, 4, 5, 6), (7,), (8, 9, 10))
|
||||
_BATCH_ORDINALS = ((1,), (2, 3), (4, 5, 6), (7,), (8, 9, 10))
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
|
|
@ -683,9 +612,7 @@ class ShadowApplicationBootstrapOrchestrator:
|
|||
current_batch=min(self._batch_index + 1, len(self._BATCH_ORDINALS)),
|
||||
total_batches=len(self._BATCH_ORDINALS),
|
||||
batch_issued=self._batch_issued,
|
||||
pending_response_topics=tuple(
|
||||
sorted(request.response_topic for request in self._pending.values())
|
||||
),
|
||||
pending_response_topics=tuple(sorted(self._pending)),
|
||||
live_binding_observed=self._binding is not None,
|
||||
bootstrap_complete=self._complete,
|
||||
)
|
||||
|
|
@ -716,23 +643,25 @@ class ShadowApplicationBootstrapOrchestrator:
|
|||
for ordinal in self._BATCH_ORDINALS[self._batch_index]
|
||||
)
|
||||
pending = {
|
||||
self._operation_key(request): request
|
||||
for request in requests
|
||||
if request.response_required
|
||||
request.response_topic: request for request in requests if request.response_required
|
||||
}
|
||||
if len(pending) != sum(request.response_required for request in requests):
|
||||
raise ApplicationBootstrapError(
|
||||
"bootstrap batch contains ambiguous required response topics"
|
||||
)
|
||||
if not pending:
|
||||
raise ApplicationBootstrapError("bootstrap batch has no recovered response barrier")
|
||||
self._pending = pending
|
||||
self._batch_issued = True
|
||||
return requests
|
||||
|
||||
def accept_response(self, operation_key: str, payload: bytes) -> None:
|
||||
def accept_response(self, topic: str, payload: bytes) -> None:
|
||||
"""Correlate one required response and unlock only the next batch."""
|
||||
|
||||
with self._lock:
|
||||
if not self._batch_issued:
|
||||
raise ApplicationBootstrapError("no application bootstrap batch is in flight")
|
||||
expected = self._pending.get(operation_key)
|
||||
expected = self._pending.get(topic)
|
||||
if expected is None:
|
||||
raise ApplicationBootstrapError(
|
||||
"application response is not required by the current batch"
|
||||
|
|
@ -775,7 +704,7 @@ class ShadowApplicationBootstrapOrchestrator:
|
|||
live_binding=self._binding,
|
||||
)
|
||||
|
||||
del self._pending[operation_key]
|
||||
del self._pending[topic]
|
||||
if self._pending:
|
||||
return
|
||||
self._batch_issued = False
|
||||
|
|
@ -783,10 +712,6 @@ class ShadowApplicationBootstrapOrchestrator:
|
|||
if self._batch_index == len(self._BATCH_ORDINALS):
|
||||
self._complete = True
|
||||
|
||||
@staticmethod
|
||||
def _operation_key(request: EncodedApplicationRequest) -> str:
|
||||
return f"bootstrap:{request.ordinal}:{request.message_type}"
|
||||
|
||||
|
||||
def _build_initial_device_info_request(
|
||||
authority: ApplicationControlAuthority,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import hmac
|
||||
import math
|
||||
import secrets
|
||||
import threading
|
||||
|
|
@ -17,31 +16,19 @@ from paho.mqtt.reasoncodes import ReasonCode
|
|||
from k1link.device_plugins.xgrids_k1.ble.wifi_provisioning import AP_FALLBACK_IPV4
|
||||
from k1link.device_plugins.xgrids_k1.mqtt import validate_private_ipv4
|
||||
from k1link.device_plugins.xgrids_k1.protocol.application_bootstrap import (
|
||||
DEVICE_CONFIG_REQUEST_TOPIC,
|
||||
DEVICE_CONFIG_RESPONSE_TOPIC,
|
||||
DEVICE_INFO_REQUEST_TOPIC,
|
||||
DEVICE_INFO_RESPONSE_TOPIC,
|
||||
GET_CLOUD_CONFIG_REQUEST_TOPIC,
|
||||
GET_CLOUD_CONFIG_RESPONSE_TOPIC,
|
||||
GET_NTRIP_PROFILE_REQUEST_TOPIC,
|
||||
GET_NTRIP_PROFILE_RESPONSE_TOPIC,
|
||||
GET_RTK_ADVANCE_REQUEST_TOPIC,
|
||||
GET_RTK_ADVANCE_RESPONSE_TOPIC,
|
||||
MODELING_STATUS_REQUEST_TOPIC,
|
||||
MODELING_STATUS_RESPONSE_TOPIC,
|
||||
ApplicationBootstrapError,
|
||||
ApplicationMessageIdentity,
|
||||
LiveDeviceControlBinding,
|
||||
decode_application_message_identity,
|
||||
)
|
||||
from k1link.device_plugins.xgrids_k1.protocol.application_publish import (
|
||||
OneShotPublishEnvelope,
|
||||
)
|
||||
from k1link.device_plugins.xgrids_k1.protocol.modeling_control import (
|
||||
ModelingAction,
|
||||
ModelingProtocolError,
|
||||
decode_device_status_report,
|
||||
decode_modeling_response,
|
||||
decode_system_error_report,
|
||||
)
|
||||
from k1link.device_plugins.xgrids_k1.protocol.modeling_safety import (
|
||||
|
|
@ -58,16 +45,6 @@ MAX_CONTROL_MAINTAIN_SECONDS = 30.0
|
|||
MAX_CONTROL_RESPONSE_BYTES = 64 * 1024
|
||||
SYSTEM_ERROR_TOPIC = "lixel/application/report/system_error"
|
||||
|
||||
APPLICATION_RESPONSE_TOPIC_BY_REQUEST_TOPIC = {
|
||||
DEVICE_INFO_REQUEST_TOPIC: DEVICE_INFO_RESPONSE_TOPIC,
|
||||
MODELING_STATUS_REQUEST_TOPIC: MODELING_STATUS_RESPONSE_TOPIC,
|
||||
GET_RTK_ADVANCE_REQUEST_TOPIC: GET_RTK_ADVANCE_RESPONSE_TOPIC,
|
||||
DEVICE_CONFIG_REQUEST_TOPIC: DEVICE_CONFIG_RESPONSE_TOPIC,
|
||||
GET_NTRIP_PROFILE_REQUEST_TOPIC: GET_NTRIP_PROFILE_RESPONSE_TOPIC,
|
||||
GET_CLOUD_CONFIG_REQUEST_TOPIC: GET_CLOUD_CONFIG_RESPONSE_TOPIC,
|
||||
MODELING_REQUEST_TOPIC: MODELING_RESPONSE_TOPIC,
|
||||
}
|
||||
|
||||
# The retained LixelGO control connection issued these three SUBSCRIBE packets
|
||||
# in this exact order. Point-cloud subscriptions belonged to a separate client.
|
||||
CONTROL_SUBSCRIPTION_GROUPS: tuple[tuple[tuple[str, int], ...], ...] = (
|
||||
|
|
@ -170,60 +147,14 @@ APPLICATION_REQUEST_ALLOWLIST = frozenset(
|
|||
class ApplicationMqttTransportError(RuntimeError):
|
||||
"""The reviewed control transport failed before a request was attempted."""
|
||||
|
||||
def __init__(self, message: str, *, reason_code: str = "transport_failure") -> None:
|
||||
super().__init__(message)
|
||||
self.reason_code = reason_code
|
||||
|
||||
|
||||
class ApplicationCommandOutcomeUnknown(RuntimeError):
|
||||
"""A request may have reached the K1 and must never be retried automatically."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
message: str,
|
||||
*,
|
||||
reason_code: str = "command_outcome_unknown",
|
||||
) -> None:
|
||||
super().__init__(message)
|
||||
self.reason_code = reason_code
|
||||
|
||||
|
||||
class ApplicationControlDeviceFault(RuntimeError):
|
||||
"""Live status made further automatic control inadmissible."""
|
||||
|
||||
def __init__(self, message: str, *, reason_code: str = "device_status_fault") -> None:
|
||||
super().__init__(message)
|
||||
self.reason_code = reason_code
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _ApplicationResponseExpectation:
|
||||
operation_key: str
|
||||
topic: str
|
||||
identity: ApplicationMessageIdentity
|
||||
modeling_action: ModelingAction | None = None
|
||||
|
||||
@property
|
||||
def correlation_key(self) -> tuple[str, str, ModelingAction | None]:
|
||||
return (self.topic, self.identity.session_id, self.modeling_action)
|
||||
|
||||
def matches(
|
||||
self,
|
||||
observed: ApplicationMessageIdentity,
|
||||
observed_modeling_action: ModelingAction | None,
|
||||
) -> bool:
|
||||
if self.modeling_action is not observed_modeling_action:
|
||||
return False
|
||||
if not hmac.compare_digest(self.identity.session_id, observed.session_id):
|
||||
return False
|
||||
if not hmac.compare_digest(self.identity.openapi_key, observed.openapi_key):
|
||||
return False
|
||||
expected_device_id = self.identity.vendor_device_id
|
||||
return expected_device_id is None or (
|
||||
observed.vendor_device_id is not None
|
||||
and hmac.compare_digest(expected_device_id, observed.vendor_device_id)
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ApplicationMqttTransportSnapshot:
|
||||
|
|
@ -234,7 +165,6 @@ class ApplicationMqttTransportSnapshot:
|
|||
qos2_completions: int
|
||||
correlated_responses: int
|
||||
ignored_known_responses: int
|
||||
late_known_responses: int
|
||||
device_status_reports: int
|
||||
system_error_reports: int
|
||||
report_decode_errors: int
|
||||
|
|
@ -259,7 +189,6 @@ class ApplicationMqttTransportSnapshot:
|
|||
"qos2_completions": self.qos2_completions,
|
||||
"correlated_responses": self.correlated_responses,
|
||||
"ignored_known_responses": self.ignored_known_responses,
|
||||
"late_known_responses": self.late_known_responses,
|
||||
"device_status_reports": self.device_status_reports,
|
||||
"system_error_reports": self.system_error_reports,
|
||||
"report_decode_errors": self.report_decode_errors,
|
||||
|
|
@ -279,10 +208,10 @@ class ApplicationMqttTransportSnapshot:
|
|||
class ReviewedApplicationMqttTransport:
|
||||
"""Exact-profile MQTT exchange for an operator-present physical acceptance.
|
||||
|
||||
Plugin v0.5.0 installs this type only behind the operator-driven continuous
|
||||
session owner. It performs one connection attempt, never reconnects,
|
||||
consumes every operation key before calling ``publish``, and permanently
|
||||
poisons itself after any unknown post-publish outcome.
|
||||
This type is deliberately not installed in the facade or plugin runtime.
|
||||
It performs one connection attempt, never reconnects, consumes every
|
||||
operation key before calling ``publish``, and permanently poisons itself
|
||||
after any unknown post-publish outcome.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
|
|
@ -323,19 +252,12 @@ class ReviewedApplicationMqttTransport:
|
|||
self._messages: deque[tuple[str, bytes]] = deque()
|
||||
self._callback_error: str | None = None
|
||||
self._consumed_operation_keys: set[str] = set()
|
||||
self._known_response_expectations: dict[str, _ApplicationResponseExpectation] = {}
|
||||
self._response_operations_by_correlation: dict[
|
||||
tuple[str, str, ModelingAction | None], list[str]
|
||||
] = {}
|
||||
self._optional_response_operations: set[str] = set()
|
||||
self._observed_response_operations: set[str] = set()
|
||||
self._connect_attempts = 0
|
||||
self._subscribe_attempts = 0
|
||||
self._publish_attempts = 0
|
||||
self._qos2_completions = 0
|
||||
self._correlated_responses = 0
|
||||
self._ignored_known_responses = 0
|
||||
self._late_known_responses = 0
|
||||
self._device_status_reports = 0
|
||||
self._system_error_reports = 0
|
||||
self._report_decode_errors = 0
|
||||
|
|
@ -351,14 +273,10 @@ class ReviewedApplicationMqttTransport:
|
|||
def open(self) -> ApplicationMqttTransportSnapshot:
|
||||
with self._lock:
|
||||
if self._state != "new":
|
||||
raise ApplicationMqttTransportError(
|
||||
"control transport can be opened only once",
|
||||
reason_code="transport_already_opened",
|
||||
)
|
||||
raise ApplicationMqttTransportError("control transport can be opened only once")
|
||||
self._state = "connecting"
|
||||
self._connect_attempts = 1
|
||||
client = self._new_client()
|
||||
client.connect_timeout = self._connect_timeout_seconds
|
||||
self._install_callbacks(client)
|
||||
self._client = client
|
||||
try:
|
||||
|
|
@ -382,10 +300,10 @@ class ReviewedApplicationMqttTransport:
|
|||
self,
|
||||
envelopes: Sequence[OneShotPublishEnvelope],
|
||||
*,
|
||||
required_response_operation_keys: Collection[str],
|
||||
required_response_topics: Collection[str],
|
||||
) -> dict[str, bytes]:
|
||||
batch = tuple(envelopes)
|
||||
required = frozenset(required_response_operation_keys)
|
||||
required = frozenset(required_response_topics)
|
||||
if not batch:
|
||||
raise ValueError("control exchange batch must not be empty")
|
||||
if not required and any(
|
||||
|
|
@ -393,100 +311,37 @@ class ReviewedApplicationMqttTransport:
|
|||
for envelope in batch
|
||||
):
|
||||
raise ValueError("response-free exchange is limited to retained ModelingStatus reads")
|
||||
if not required <= APPLICATION_RESPONSE_TOPICS:
|
||||
raise ValueError("required response topics exceed the reviewed allowlist")
|
||||
operation_keys = tuple(envelope.operation_key for envelope in batch)
|
||||
if len(set(operation_keys)) != len(operation_keys):
|
||||
raise ValueError("control exchange batch repeats an operation key")
|
||||
if not required <= frozenset(operation_keys):
|
||||
raise ValueError("required responses exceed the issued operation keys")
|
||||
if any(envelope.topic not in APPLICATION_REQUEST_ALLOWLIST for envelope in batch):
|
||||
raise ValueError("control exchange batch exceeds the reviewed request allowlist")
|
||||
expectations = tuple(self._expectation_for_envelope(envelope) for envelope in batch)
|
||||
pending = {
|
||||
expectation.correlation_key: expectation
|
||||
for expectation in expectations
|
||||
if expectation.operation_key in required
|
||||
}
|
||||
if len(pending) != len(required):
|
||||
raise ValueError(
|
||||
"required responses do not identify one exact batch operation each"
|
||||
allowed_non_barrier_responses = (
|
||||
frozenset({MODELING_STATUS_RESPONSE_TOPIC})
|
||||
if any(
|
||||
envelope.topic == "lixel/application/request/modeling_status" for envelope in batch
|
||||
)
|
||||
|
||||
# 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({}, {})
|
||||
else frozenset()
|
||||
)
|
||||
|
||||
with self._lock:
|
||||
if self._consumed_operation_keys.intersection(operation_keys):
|
||||
raise ApplicationCommandOutcomeUnknown(
|
||||
"control operation key was already consumed; retry is forbidden",
|
||||
reason_code="operation_reuse_forbidden",
|
||||
"control operation key was already consumed; retry is forbidden"
|
||||
)
|
||||
if self._state != "ready" or not self._connected or not self._subscribed:
|
||||
raise ApplicationMqttTransportError(
|
||||
"control transport is not ready",
|
||||
reason_code="transport_not_ready",
|
||||
)
|
||||
for expectation in expectations:
|
||||
existing_operations = self._response_operations_by_correlation.get(
|
||||
expectation.correlation_key,
|
||||
[],
|
||||
)
|
||||
if any(
|
||||
not self._known_response_expectations[operation_key].matches(
|
||||
expectation.identity,
|
||||
expectation.modeling_action,
|
||||
)
|
||||
for operation_key in existing_operations
|
||||
):
|
||||
raise ApplicationCommandOutcomeUnknown(
|
||||
"control response identity changed for an issued session",
|
||||
reason_code="response_identity_changed",
|
||||
)
|
||||
if expectation.operation_key in required:
|
||||
# A response-free read owns its correlation only until the
|
||||
# next exact same-identity operation is admitted. We have
|
||||
# already drained callbacks and given the retained socket
|
||||
# one zero-wait service turn above, so any response already
|
||||
# available for the optional operation has been observed.
|
||||
# Once the newer required operation is issued, the first
|
||||
# response after that issuance belongs to the newer
|
||||
# operation; the unanswered optional read must not reserve
|
||||
# FIFO ownership and swallow it.
|
||||
superseded_optional = {
|
||||
operation_key
|
||||
for operation_key in existing_operations
|
||||
if operation_key in self._optional_response_operations
|
||||
and operation_key not in self._observed_response_operations
|
||||
}
|
||||
if superseded_optional:
|
||||
existing_operations[:] = [
|
||||
operation_key
|
||||
for operation_key in existing_operations
|
||||
if operation_key not in superseded_optional
|
||||
]
|
||||
self._optional_response_operations.difference_update(
|
||||
superseded_optional
|
||||
)
|
||||
for operation_key in superseded_optional:
|
||||
del self._known_response_expectations[operation_key]
|
||||
self._known_response_expectations[
|
||||
expectation.operation_key
|
||||
] = expectation
|
||||
self._response_operations_by_correlation.setdefault(
|
||||
expectation.correlation_key,
|
||||
[],
|
||||
).append(expectation.operation_key)
|
||||
if expectation.operation_key not in required:
|
||||
self._optional_response_operations.add(expectation.operation_key)
|
||||
raise ApplicationMqttTransportError("control transport is not ready")
|
||||
stale_response = bool(self._messages)
|
||||
if stale_response:
|
||||
self._poison_locked("stale response preceded the request batch")
|
||||
self._consumed_operation_keys.update(operation_keys)
|
||||
if stale_response:
|
||||
self.close()
|
||||
raise ApplicationCommandOutcomeUnknown(
|
||||
"stale control response makes the next command outcome ambiguous"
|
||||
)
|
||||
|
||||
client = self._require_client()
|
||||
publish_mids: set[int] = set()
|
||||
|
|
@ -512,17 +367,12 @@ class ReviewedApplicationMqttTransport:
|
|||
deadline = self._monotonic() + self._exchange_timeout_seconds
|
||||
|
||||
def complete() -> bool:
|
||||
self._drain_responses(pending, responses)
|
||||
self._drain_responses(required, allowed_non_barrier_responses, responses)
|
||||
with self._lock:
|
||||
return publish_mids <= self._completed_publish_mids and required <= responses.keys()
|
||||
|
||||
self._drive_until(complete, deadline, post_publish=True)
|
||||
# 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)
|
||||
self._drain_responses(required, allowed_non_barrier_responses, responses)
|
||||
with self._lock:
|
||||
self._correlated_responses += len(responses)
|
||||
return responses
|
||||
|
|
@ -572,15 +422,9 @@ class ReviewedApplicationMqttTransport:
|
|||
client = self._client
|
||||
if client is not None:
|
||||
try:
|
||||
with self._lock:
|
||||
accepted_group_count = self._subscription_group_index
|
||||
if accepted_group_count:
|
||||
if self._subscribed:
|
||||
client.unsubscribe(
|
||||
[
|
||||
topic
|
||||
for group in CONTROL_SUBSCRIPTION_GROUPS[:accepted_group_count]
|
||||
for topic, _qos in group
|
||||
]
|
||||
[topic for group in CONTROL_SUBSCRIPTION_GROUPS for topic, _qos in group]
|
||||
)
|
||||
client.disconnect()
|
||||
except (OSError, RuntimeError, ValueError):
|
||||
|
|
@ -639,7 +483,6 @@ class ReviewedApplicationMqttTransport:
|
|||
qos2_completions=self._qos2_completions,
|
||||
correlated_responses=self._correlated_responses,
|
||||
ignored_known_responses=self._ignored_known_responses,
|
||||
late_known_responses=self._late_known_responses,
|
||||
device_status_reports=self._device_status_reports,
|
||||
system_error_reports=self._system_error_reports,
|
||||
report_decode_errors=self._report_decode_errors,
|
||||
|
|
@ -882,144 +725,48 @@ class ReviewedApplicationMqttTransport:
|
|||
self._fail_after_publish("control MQTT network loop returned an error")
|
||||
self._fail_before_publish("control MQTT network loop returned an error")
|
||||
|
||||
def _service_once(self, *, post_publish: bool) -> None:
|
||||
with self._lock:
|
||||
callback_error = self._callback_error
|
||||
if callback_error is not None:
|
||||
if post_publish:
|
||||
self._fail_after_publish(callback_error)
|
||||
self._fail_before_publish(callback_error)
|
||||
client = self._require_client()
|
||||
try:
|
||||
result = client.loop(timeout=0.0)
|
||||
except (OSError, RuntimeError, ValueError) as exc:
|
||||
if post_publish:
|
||||
self._fail_after_publish("control MQTT network loop failed", exc)
|
||||
self._fail_before_publish("control MQTT network loop failed", exc)
|
||||
if result != mqtt.MQTT_ERR_SUCCESS:
|
||||
if post_publish:
|
||||
self._fail_after_publish("control MQTT network loop returned an error")
|
||||
self._fail_before_publish("control MQTT network loop returned an error")
|
||||
|
||||
def _drain_responses(
|
||||
self,
|
||||
pending: dict[
|
||||
tuple[str, str, ModelingAction | None],
|
||||
_ApplicationResponseExpectation,
|
||||
],
|
||||
required: frozenset[str],
|
||||
allowed_non_barrier: frozenset[str],
|
||||
responses: dict[str, bytes],
|
||||
) -> None:
|
||||
ambiguous_message: str | None = None
|
||||
ambiguous_reason_code: str | None = None
|
||||
with self._lock:
|
||||
while self._messages:
|
||||
topic, payload = self._messages.popleft()
|
||||
try:
|
||||
observed = decode_application_message_identity(payload)
|
||||
except ApplicationBootstrapError:
|
||||
self._poison_locked("response identity decoding failed")
|
||||
ambiguous_message = (
|
||||
"control response identity could not be decoded safely"
|
||||
)
|
||||
ambiguous_reason_code = "response_identity_decode_failed"
|
||||
break
|
||||
observed_modeling_action: ModelingAction | None = None
|
||||
if topic == MODELING_RESPONSE_TOPIC:
|
||||
try:
|
||||
observed_modeling_action = decode_modeling_response(
|
||||
payload
|
||||
).action
|
||||
except ModelingProtocolError:
|
||||
self._poison_locked("modeling response action decoding failed")
|
||||
ambiguous_message = (
|
||||
"control modeling response could not be decoded safely"
|
||||
)
|
||||
ambiguous_reason_code = "modeling_response_decode_failed"
|
||||
break
|
||||
key = (topic, observed.session_id, observed_modeling_action)
|
||||
expectation = pending.get(key)
|
||||
known_operations = self._response_operations_by_correlation.get(key, [])
|
||||
matching_operations = [
|
||||
operation_key
|
||||
for operation_key in known_operations
|
||||
if self._known_response_expectations[operation_key].matches(
|
||||
observed,
|
||||
observed_modeling_action,
|
||||
)
|
||||
]
|
||||
next_unobserved = next(
|
||||
(
|
||||
operation_key
|
||||
for operation_key in matching_operations
|
||||
if operation_key not in self._observed_response_operations
|
||||
),
|
||||
None,
|
||||
)
|
||||
if next_unobserved is not None:
|
||||
self._observed_response_operations.add(next_unobserved)
|
||||
if (
|
||||
expectation is not None
|
||||
and expectation.operation_key == next_unobserved
|
||||
):
|
||||
responses[next_unobserved] = payload
|
||||
if topic not in required:
|
||||
if topic in allowed_non_barrier:
|
||||
self._ignored_known_responses += 1
|
||||
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"
|
||||
self._poison_locked("unexpected response made correlation ambiguous")
|
||||
ambiguous_message = "unexpected control response made command outcome ambiguous"
|
||||
break
|
||||
self._poison_locked("unexpected response identity made correlation ambiguous")
|
||||
if topic in responses:
|
||||
self._poison_locked("duplicate required response made correlation ambiguous")
|
||||
ambiguous_message = "duplicate control response made command outcome ambiguous"
|
||||
break
|
||||
responses[topic] = payload
|
||||
if ambiguous_message is not None:
|
||||
self.close()
|
||||
raise ApplicationCommandOutcomeUnknown(ambiguous_message)
|
||||
|
||||
def _discard_allowed_responses(self, allowed: frozenset[str]) -> None:
|
||||
ambiguous_message: str | None = None
|
||||
with self._lock:
|
||||
while self._messages:
|
||||
topic, _payload = self._messages.popleft()
|
||||
if topic in allowed:
|
||||
self._ignored_known_responses += 1
|
||||
continue
|
||||
self._poison_locked("unexpected response during retained control hold")
|
||||
ambiguous_message = (
|
||||
"unexpected control response session or identity made command outcome ambiguous"
|
||||
"unexpected control response during retained control-session hold"
|
||||
)
|
||||
ambiguous_reason_code = "unexpected_response_identity"
|
||||
if expectation is not None or known_operations:
|
||||
ambiguous_message = (
|
||||
"control response identity mismatch made command outcome ambiguous"
|
||||
)
|
||||
ambiguous_reason_code = "response_identity_mismatch"
|
||||
break
|
||||
if ambiguous_message is not None:
|
||||
self.close()
|
||||
raise ApplicationCommandOutcomeUnknown(
|
||||
ambiguous_message,
|
||||
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,
|
||||
)
|
||||
raise ApplicationCommandOutcomeUnknown(ambiguous_message)
|
||||
|
||||
def _set_callback_error(self, message: str) -> None:
|
||||
with self._lock:
|
||||
|
|
@ -1030,10 +777,7 @@ class ReviewedApplicationMqttTransport:
|
|||
with self._lock:
|
||||
self._state = "failed"
|
||||
self.close()
|
||||
error = ApplicationMqttTransportError(
|
||||
message,
|
||||
reason_code=self._transport_failure_reason_code(message),
|
||||
)
|
||||
error = ApplicationMqttTransportError(message)
|
||||
if cause is not None:
|
||||
raise error from cause
|
||||
raise error
|
||||
|
|
@ -1043,8 +787,7 @@ class ReviewedApplicationMqttTransport:
|
|||
self._poison_locked(message)
|
||||
self.close()
|
||||
error = ApplicationCommandOutcomeUnknown(
|
||||
f"{message}; automatic retry is forbidden until physical/status reconciliation",
|
||||
reason_code=self._transport_failure_reason_code(message),
|
||||
f"{message}; automatic retry is forbidden until physical/status reconciliation"
|
||||
)
|
||||
if cause is not None:
|
||||
raise error from cause
|
||||
|
|
@ -1056,33 +799,5 @@ class ReviewedApplicationMqttTransport:
|
|||
def _require_client(self) -> mqtt.Client:
|
||||
client = self._client
|
||||
if client is None:
|
||||
raise ApplicationMqttTransportError(
|
||||
"control MQTT client is not installed",
|
||||
reason_code="mqtt_client_unavailable",
|
||||
)
|
||||
raise ApplicationMqttTransportError("control MQTT client is not installed")
|
||||
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,6 +1,5 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
from collections.abc import Callable
|
||||
|
|
@ -58,8 +57,6 @@ ApplicationControlPhase = Literal[
|
|||
"failed",
|
||||
]
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
TransportFactory = Callable[[str], ReviewedApplicationMqttTransport]
|
||||
|
||||
|
|
@ -111,6 +108,7 @@ class InteractiveApplicationControlSession:
|
|||
self._project_requested = threading.Event()
|
||||
self._start_requested = threading.Event()
|
||||
self._stop_requested = threading.Event()
|
||||
self._standby_confirmed = threading.Event()
|
||||
self._cancel_requested = False
|
||||
self._start_confirmation: OperatorPresenceConfirmation | None = None
|
||||
self._stop_confirmation: OperatorPresenceConfirmation | None = None
|
||||
|
|
@ -119,7 +117,6 @@ class InteractiveApplicationControlSession:
|
|||
self._dialogue_snapshot: dict[str, object] | None = None
|
||||
self._transport_snapshot: dict[str, object] | None = None
|
||||
self._outcome_unknown = False
|
||||
self._run_generation = 0
|
||||
|
||||
def open(
|
||||
self,
|
||||
|
|
@ -131,28 +128,21 @@ class InteractiveApplicationControlSession:
|
|||
# Validate every explicit confirmation before creating a network owner.
|
||||
confirmation.checklist(ModelingAction.START)
|
||||
with self._lock:
|
||||
if self._phase != "idle" and self._can_open_locked():
|
||||
if self._phase in {"completed", "closed"} or (
|
||||
self._phase == "failed"
|
||||
and self._failure is not None
|
||||
and self._failure.get("safe_to_retry") is True
|
||||
):
|
||||
self._reset_locked()
|
||||
if self._phase != "idle":
|
||||
raise ApplicationAcceptanceError(
|
||||
"application control session is already open or requires manual recovery"
|
||||
)
|
||||
# A previous worker may already have published a terminal phase but
|
||||
# still own its local MQTT socket while its ``finally`` block runs.
|
||||
# Never create the next network owner until that thread has exited
|
||||
# and cleared the closed transport.
|
||||
if not self._worker_retired_locked():
|
||||
raise ApplicationAcceptanceError(
|
||||
"previous application control worker is still retiring"
|
||||
)
|
||||
self._host = host
|
||||
self._timezone_name = timezone_name
|
||||
self._set_phase_locked("connecting")
|
||||
self._run_generation += 1
|
||||
generation = self._run_generation
|
||||
self._thread = threading.Thread(
|
||||
target=self._run,
|
||||
args=(generation,),
|
||||
name="xgrids-k1-canonical-control",
|
||||
daemon=True,
|
||||
)
|
||||
|
|
@ -199,6 +189,16 @@ class InteractiveApplicationControlSession:
|
|||
self._stop_requested.set()
|
||||
return self.snapshot()
|
||||
|
||||
def confirm_standby(self) -> dict[str, object]:
|
||||
with self._lock:
|
||||
self._require_phase_locked("awaiting-standby-confirmation")
|
||||
if not self._device_ready_for_standby_confirmation_locked():
|
||||
raise ApplicationAcceptanceError(
|
||||
"K1 has not reported unbound READY after canonical STOP"
|
||||
)
|
||||
self._standby_confirmed.set()
|
||||
return self.snapshot()
|
||||
|
||||
def close_prestart(self) -> dict[str, object]:
|
||||
with self._lock:
|
||||
if self._phase not in {
|
||||
|
|
@ -234,27 +234,33 @@ class InteractiveApplicationControlSession:
|
|||
return {
|
||||
"mode": "interactive-canonical",
|
||||
"state": phase,
|
||||
"control_socket_open": phase not in {"idle", "completed", "closed", "failed"},
|
||||
"can_open": self._can_open_locked(),
|
||||
"control_socket_open": phase
|
||||
not in {"idle", "completed", "closed", "failed"},
|
||||
"can_open": phase in {"idle", "completed", "closed"}
|
||||
or (
|
||||
phase == "failed"
|
||||
and self._failure is not None
|
||||
and self._failure.get("safe_to_retry") is True
|
||||
),
|
||||
"can_enter_workspace": phase == "connection-ready",
|
||||
"can_prepare_project": phase == "workspace-ready",
|
||||
"can_start": phase == "project-ready",
|
||||
"can_stop": phase == "scanning",
|
||||
# Retained for wire compatibility with the v1alpha2 snapshot.
|
||||
# Standby is now concluded solely from live K1 protocol state.
|
||||
"can_confirm_standby": False,
|
||||
"can_confirm_standby": self._device_ready_for_standby_confirmation_locked(),
|
||||
"pending_operator_action": self._pending_operator_action_locked(),
|
||||
"scripted_transitions": False,
|
||||
"automatic_retry": False,
|
||||
"outcome_unknown": self._outcome_unknown,
|
||||
"failure": dict(self._failure) if self._failure is not None else None,
|
||||
"dialogue": (
|
||||
dict(self._dialogue_snapshot) if self._dialogue_snapshot is not None else None
|
||||
dict(self._dialogue_snapshot)
|
||||
if self._dialogue_snapshot is not None
|
||||
else None
|
||||
),
|
||||
"transport": transport_snapshot,
|
||||
}
|
||||
|
||||
def _run(self, generation: int) -> None:
|
||||
def _run(self) -> None:
|
||||
executor: PhysicalAcceptanceDialogueExecutor | None = None
|
||||
transport: ReviewedApplicationMqttTransport | None = None
|
||||
try:
|
||||
|
|
@ -313,195 +319,64 @@ class InteractiveApplicationControlSession:
|
|||
|
||||
executor.maintain_active_until_stop_requested(self._stop_requested.is_set)
|
||||
stop_confirmation = self._stop_request()
|
||||
stop_permit = PhysicalAcceptancePermit(stop_confirmation.checklist(ModelingAction.STOP))
|
||||
stop_permit = PhysicalAcceptancePermit(
|
||||
stop_confirmation.checklist(ModelingAction.STOP)
|
||||
)
|
||||
self._set_phase("stopping")
|
||||
executor.execute_canonical_stop(
|
||||
self._stop_command(authority, binding),
|
||||
stop_permit,
|
||||
)
|
||||
self._set_phase("awaiting-standby-confirmation")
|
||||
executor.maintain_post_stop_until_standby()
|
||||
executor.maintain_post_stop_until_standby_confirmed(
|
||||
self._standby_confirmed.is_set
|
||||
)
|
||||
self._set_phase("completed")
|
||||
except Exception as exc:
|
||||
dialogue_snapshot, dialogue_snapshot_available = self._executor_snapshot_safely(
|
||||
executor
|
||||
)
|
||||
transport_snapshot, transport_snapshot_available = self._transport_snapshot_safely(
|
||||
transport
|
||||
)
|
||||
with self._lock:
|
||||
if self._cancel_requested:
|
||||
self._set_phase_locked("closed")
|
||||
else:
|
||||
failed_phase = self._phase
|
||||
transport_created = transport is not None
|
||||
dialogue_executor_created = executor is not None
|
||||
publish_attempts = (
|
||||
self._json_int_or_none(transport_snapshot.get("publish_attempts"))
|
||||
if transport_snapshot_available
|
||||
else None
|
||||
)
|
||||
dialogue_stage = self._json_string(dialogue_snapshot.get("dialogue_stage"))
|
||||
correlation_failure = dialogue_snapshot.get("correlation_failure")
|
||||
compatibility_failure = dialogue_snapshot.get("compatibility_failure")
|
||||
failure_reason_code = self._failure_reason_code(
|
||||
exc,
|
||||
correlation_failure=correlation_failure,
|
||||
)
|
||||
modeling_command_attempted: bool | None = (
|
||||
(
|
||||
dialogue_snapshot.get("start_attempted") is True
|
||||
or dialogue_snapshot.get("stop_attempted") is True
|
||||
publish_attempts = 0
|
||||
if transport is not None:
|
||||
observed_attempts = transport.snapshot().as_dict().get(
|
||||
"publish_attempts",
|
||||
0,
|
||||
)
|
||||
if dialogue_snapshot_available
|
||||
else (False if not dialogue_executor_created else None)
|
||||
)
|
||||
diagnostic_snapshot_unavailable = [
|
||||
component
|
||||
for component, created, available in (
|
||||
(
|
||||
"transport",
|
||||
transport_created,
|
||||
transport_snapshot_available,
|
||||
),
|
||||
(
|
||||
"dialogue",
|
||||
dialogue_executor_created,
|
||||
dialogue_snapshot_available,
|
||||
),
|
||||
)
|
||||
if created and not available
|
||||
]
|
||||
diagnostic_evidence_unavailable = [
|
||||
evidence
|
||||
for evidence, unavailable in (
|
||||
(
|
||||
"transport.publish_attempts",
|
||||
transport_created and publish_attempts is None,
|
||||
),
|
||||
(
|
||||
"dialogue.modeling_command_attempted",
|
||||
dialogue_executor_created and modeling_command_attempted is None,
|
||||
),
|
||||
)
|
||||
if unavailable
|
||||
]
|
||||
outcome_unknown = (
|
||||
isinstance(exc, ApplicationCommandOutcomeUnknown)
|
||||
or modeling_command_attempted is True
|
||||
or bool(diagnostic_evidence_unavailable)
|
||||
or failed_phase
|
||||
in {
|
||||
"scanning",
|
||||
"stop-requested",
|
||||
"stopping",
|
||||
"awaiting-standby-confirmation",
|
||||
}
|
||||
)
|
||||
# A correlated ordinal-1 DeviceInfo profile mismatch is a
|
||||
# completed read-only exchange. It cannot have started or
|
||||
# stopped modeling, so a later explicit operator click may
|
||||
# open a fresh dialogue after the software/profile issue is
|
||||
# corrected. No other post-publish failure is promoted.
|
||||
correlated_read_only_profile_mismatch = (
|
||||
failure_reason_code == "compatibility_profile_mismatch"
|
||||
and modeling_command_attempted is False
|
||||
and publish_attempts == 1
|
||||
and self._json_int_or_none(
|
||||
transport_snapshot.get("correlated_responses")
|
||||
)
|
||||
== 1
|
||||
)
|
||||
safe_to_retry = not outcome_unknown and (
|
||||
not transport_created
|
||||
or (transport_snapshot_available and publish_attempts == 0)
|
||||
or correlated_read_only_profile_mismatch
|
||||
)
|
||||
if isinstance(observed_attempts, int) and not isinstance(
|
||||
observed_attempts,
|
||||
bool,
|
||||
):
|
||||
publish_attempts = observed_attempts
|
||||
outcome_unknown = isinstance(
|
||||
exc, ApplicationCommandOutcomeUnknown
|
||||
) or self._phase in {
|
||||
"start-requested",
|
||||
"initializing",
|
||||
"scanning",
|
||||
"stop-requested",
|
||||
"stopping",
|
||||
"awaiting-standby-confirmation",
|
||||
}
|
||||
self._failure = {
|
||||
"code": type(exc).__name__,
|
||||
"reason_code": failure_reason_code,
|
||||
"message": str(exc),
|
||||
"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,
|
||||
"safe_to_retry": publish_attempts == 0 and not 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")
|
||||
logger.error(
|
||||
"K1 application control session failed: code=%s reason_code=%s "
|
||||
"phase=%s dialogue_stage=%s transport_state=%s "
|
||||
"publish_attempts=%s modeling_command_attempted=%s "
|
||||
"diagnostic_snapshot_unavailable=%s "
|
||||
"diagnostic_evidence_unavailable=%s outcome_unknown=%s "
|
||||
"safe_to_retry=%s",
|
||||
type(exc).__name__,
|
||||
failure_reason_code,
|
||||
failed_phase,
|
||||
dialogue_stage,
|
||||
self._json_string(transport_snapshot.get("state")),
|
||||
publish_attempts,
|
||||
modeling_command_attempted,
|
||||
diagnostic_snapshot_unavailable,
|
||||
diagnostic_evidence_unavailable,
|
||||
outcome_unknown,
|
||||
safe_to_retry,
|
||||
)
|
||||
finally:
|
||||
final_dialogue_snapshot: dict[str, object] | None = None
|
||||
final_transport_snapshot: dict[str, object] | None = None
|
||||
if executor is not None:
|
||||
captured_dialogue, dialogue_available = self._executor_snapshot_safely(executor)
|
||||
if dialogue_available:
|
||||
final_dialogue_snapshot = captured_dialogue
|
||||
with self._lock:
|
||||
self._dialogue_snapshot = executor.snapshot()
|
||||
if transport is not None:
|
||||
try:
|
||||
captured_transport, transport_available = self._transport_snapshot_safely(
|
||||
transport
|
||||
)
|
||||
if transport_available:
|
||||
final_transport_snapshot = captured_transport
|
||||
with self._lock:
|
||||
self._transport_snapshot = transport.snapshot().as_dict()
|
||||
finally:
|
||||
transport.close()
|
||||
with self._lock:
|
||||
# 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
|
||||
self._transport = None
|
||||
|
||||
@staticmethod
|
||||
def _start_command(
|
||||
|
|
@ -552,62 +427,6 @@ class InteractiveApplicationControlSession:
|
|||
with self._lock:
|
||||
self._set_phase_locked(phase)
|
||||
|
||||
@staticmethod
|
||||
def _executor_snapshot_safely(
|
||||
executor: PhysicalAcceptanceDialogueExecutor | None,
|
||||
) -> tuple[dict[str, object], bool]:
|
||||
if executor is None:
|
||||
return {}, False
|
||||
try:
|
||||
return dict(executor.snapshot()), True
|
||||
except Exception:
|
||||
logger.error("K1 application dialogue snapshot failed")
|
||||
return {}, False
|
||||
|
||||
@staticmethod
|
||||
def _transport_snapshot_safely(
|
||||
transport: ReviewedApplicationMqttTransport | None,
|
||||
) -> tuple[dict[str, object], bool]:
|
||||
if transport is None:
|
||||
return {}, False
|
||||
try:
|
||||
return dict(transport.snapshot().as_dict()), True
|
||||
except Exception:
|
||||
logger.error("K1 application transport snapshot failed")
|
||||
return {}, False
|
||||
|
||||
@staticmethod
|
||||
def _json_int_or_none(value: object) -> int | None:
|
||||
return value if isinstance(value, int) and not isinstance(value, bool) else None
|
||||
|
||||
@staticmethod
|
||||
def _json_string(value: object) -> str | None:
|
||||
return value if isinstance(value, str) else None
|
||||
|
||||
@staticmethod
|
||||
def _failure_reason_code(
|
||||
exc: Exception,
|
||||
*,
|
||||
correlation_failure: object,
|
||||
) -> str:
|
||||
explicit = getattr(exc, "reason_code", None)
|
||||
if isinstance(explicit, str) and explicit:
|
||||
return explicit
|
||||
if isinstance(correlation_failure, dict):
|
||||
correlated = correlation_failure.get("reason_code")
|
||||
if isinstance(correlated, str) and correlated:
|
||||
return correlated
|
||||
exception_name = type(exc).__name__
|
||||
if "Authority" in exception_name or "Keychain" in exception_name:
|
||||
return "application_authority_unavailable"
|
||||
if isinstance(exc, TimeoutError):
|
||||
return "control_checkpoint_timeout"
|
||||
if isinstance(exc, ApplicationAcceptanceError):
|
||||
return "application_acceptance_failed"
|
||||
if isinstance(exc, RuntimeError):
|
||||
return "unexpected_runtime_error"
|
||||
return "internal_control_error"
|
||||
|
||||
def _set_phase_locked(self, phase: ApplicationControlPhase) -> None:
|
||||
self._phase = phase
|
||||
|
||||
|
|
@ -617,17 +436,18 @@ class InteractiveApplicationControlSession:
|
|||
f"control action requires {expected}; current state is {self._phase}"
|
||||
)
|
||||
|
||||
def _device_ready_for_standby_confirmation_locked(self) -> bool:
|
||||
if self._phase != "awaiting-standby-confirmation":
|
||||
return False
|
||||
snapshot = self._live_transport_snapshot_locked()
|
||||
return (
|
||||
snapshot.get("latest_device_session_state") == "ready"
|
||||
and snapshot.get("latest_device_project_bound") is False
|
||||
)
|
||||
|
||||
def _live_transport_snapshot_locked(self) -> dict[str, object]:
|
||||
if self._transport is not None:
|
||||
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 self._transport.snapshot().as_dict()
|
||||
return (
|
||||
dict(self._transport_snapshot)
|
||||
if self._transport_snapshot is not None
|
||||
|
|
@ -644,20 +464,9 @@ class InteractiveApplicationControlSession:
|
|||
"workspace-ready": "prepare-project",
|
||||
"project-ready": "start",
|
||||
"scanning": "stop",
|
||||
"awaiting-standby-confirmation": "confirm-steady-green",
|
||||
}.get(self._phase)
|
||||
|
||||
def _can_open_locked(self) -> bool:
|
||||
reopenable_phase = self._phase in {"idle", "completed", "closed"} or (
|
||||
self._phase == "failed"
|
||||
and self._failure is not None
|
||||
and self._failure.get("safe_to_retry") is True
|
||||
)
|
||||
return reopenable_phase and self._worker_retired_locked()
|
||||
|
||||
def _worker_retired_locked(self) -> bool:
|
||||
thread = self._thread
|
||||
return (thread is None or not thread.is_alive()) and self._transport is None
|
||||
|
||||
def _reset_locked(self) -> None:
|
||||
self._phase = "idle"
|
||||
self._host = None
|
||||
|
|
@ -668,6 +477,7 @@ class InteractiveApplicationControlSession:
|
|||
self._project_requested = threading.Event()
|
||||
self._start_requested = threading.Event()
|
||||
self._stop_requested = threading.Event()
|
||||
self._standby_confirmed = threading.Event()
|
||||
self._cancel_requested = False
|
||||
self._start_confirmation = None
|
||||
self._stop_confirmation = None
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@ from dataclasses import dataclass, field
|
|||
from typing import Literal
|
||||
|
||||
from k1link.device_plugins.xgrids_k1.protocol.application_bootstrap import (
|
||||
REVIEWED_PROFILE_ATTESTATION_FAILURE,
|
||||
ApplicationControlAuthority,
|
||||
LiveDeviceControlBinding,
|
||||
)
|
||||
|
|
@ -218,7 +217,9 @@ class LiveModelingControlSafety:
|
|||
"DeviceInfo binding does not match the live K1 status stream"
|
||||
)
|
||||
if not binding.ready_for_reviewed_profile:
|
||||
raise ModelingControlSafetyError(REVIEWED_PROFILE_ATTESTATION_FAILURE)
|
||||
raise ModelingControlSafetyError(
|
||||
"live DeviceInfo does not attest the reviewed activated FW 3.0.2 profile"
|
||||
)
|
||||
if report.session_state is not required_state:
|
||||
raise ModelingControlSafetyError(
|
||||
f"live K1 must be {required_state.name.casefold()} for this shadow plan"
|
||||
|
|
|
|||
|
|
@ -38,13 +38,6 @@ def decode_zigzag64(value: int) -> int:
|
|||
return (value >> 1) ^ -(value & 1)
|
||||
|
||||
|
||||
def encode_zigzag64(value: int) -> int:
|
||||
"""Encode a signed 64-bit integer as protobuf sint64 ZigZag."""
|
||||
if value < -(1 << 63) or value > (1 << 63) - 1:
|
||||
raise ProtobufWireError("ZigZag input is outside int64")
|
||||
return ((value << 1) ^ (value >> 63)) & 0xFFFFFFFFFFFFFFFF
|
||||
|
||||
|
||||
def iter_fields(data: bytes, *, max_fields: int = 1_000_000) -> Iterator[ProtoField]:
|
||||
"""Iterate supported protobuf fields without recursion or unbounded allocation."""
|
||||
if max_fields < 1:
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import re
|
|||
import secrets
|
||||
import stat
|
||||
import threading
|
||||
from collections.abc import Iterable, Iterator
|
||||
from collections.abc import Iterator
|
||||
from contextlib import suppress
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
|
@ -175,36 +175,6 @@ class RecordedMediaInspector:
|
|||
self._cache[key] = _CachedManifest(identity=identity, manifest=manifest)
|
||||
return manifest
|
||||
|
||||
def delete_prepared(self, session_id: str, artifact_ids: Iterable[str]) -> None:
|
||||
"""Forget and remove path-free preparation sidecars for one session."""
|
||||
|
||||
unique_artifact_ids = tuple(dict.fromkeys(artifact_ids))
|
||||
with self._lock:
|
||||
for artifact_id in unique_artifact_ids:
|
||||
self._cache.pop((session_id, artifact_id), None)
|
||||
if self._cache_root is None:
|
||||
return
|
||||
for artifact_id in unique_artifact_ids:
|
||||
sidecar = self._cache_root / _sidecar_name(session_id, artifact_id)
|
||||
try:
|
||||
metadata = sidecar.lstat()
|
||||
except FileNotFoundError:
|
||||
continue
|
||||
except OSError as exc:
|
||||
raise SessionIntegrityError(
|
||||
"recorded media preparation could not be inspected"
|
||||
) from exc
|
||||
if stat.S_ISLNK(metadata.st_mode) or not stat.S_ISREG(metadata.st_mode):
|
||||
raise SessionIntegrityError(
|
||||
"recorded media preparation is not a regular file"
|
||||
)
|
||||
try:
|
||||
sidecar.unlink()
|
||||
except OSError as exc:
|
||||
raise SessionIntegrityError(
|
||||
"recorded media preparation could not be deleted"
|
||||
) from exc
|
||||
|
||||
def restore_prepared(
|
||||
self,
|
||||
artifact: RecordedMediaArtifact,
|
||||
|
|
|
|||
|
|
@ -87,13 +87,6 @@ class _WorkerGeneration:
|
|||
worker: threading.Thread | None = None
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class _LaunchReservation:
|
||||
preparation_id: str
|
||||
release: Callable[[], None]
|
||||
timer: threading.Timer
|
||||
|
||||
|
||||
class SessionRecordingPreparationManager:
|
||||
"""One bounded, process-owned conversion worker for durable recordings.
|
||||
|
||||
|
|
@ -138,7 +131,6 @@ class SessionRecordingPreparationManager:
|
|||
self._ready_restorer = ready_restorer
|
||||
self._guard = threading.RLock()
|
||||
self._current_by_session: dict[str, _PreparationJob] = {}
|
||||
self._launch_reservations: dict[str, _LaunchReservation] = {}
|
||||
self._closed = True
|
||||
self._generation_counter = 0
|
||||
self._active_generation: _WorkerGeneration | None = None
|
||||
|
|
@ -387,7 +379,10 @@ class SessionRecordingPreparationManager:
|
|||
if pinned is None:
|
||||
return None
|
||||
snapshot, release = pinned
|
||||
self._reserve_launch_lease(snapshot, release, lease_seconds)
|
||||
timer = threading.Timer(lease_seconds, release)
|
||||
timer.name = f"missioncore-recording-launch-lease-{snapshot.preparation_id}"
|
||||
timer.daemon = True
|
||||
timer.start()
|
||||
return snapshot
|
||||
|
||||
def pin_ready(
|
||||
|
|
@ -398,7 +393,6 @@ class SessionRecordingPreparationManager:
|
|||
) -> tuple[RecordingPreparationSnapshot, Callable[[], None]] | None:
|
||||
"""Cheaply lease the exact already-validated ready generation."""
|
||||
|
||||
launch_release: Callable[[], None] | None = None
|
||||
with self._guard:
|
||||
job = self._current_by_session.get(session_id)
|
||||
if (
|
||||
|
|
@ -415,17 +409,7 @@ class SessionRecordingPreparationManager:
|
|||
self._current_by_session.pop(session_id, None)
|
||||
return None
|
||||
snapshot = self._snapshot_locked(job)
|
||||
reservation = self._launch_reservations.get(session_id)
|
||||
if (
|
||||
reservation is not None
|
||||
and reservation.preparation_id == snapshot.preparation_id
|
||||
):
|
||||
self._launch_reservations.pop(session_id, None)
|
||||
reservation.timer.cancel()
|
||||
launch_release = reservation.release
|
||||
if launch_release is not None:
|
||||
launch_release()
|
||||
return snapshot, pinned
|
||||
return snapshot, pinned
|
||||
|
||||
def reserve_ready(
|
||||
self,
|
||||
|
|
@ -442,26 +426,12 @@ class SessionRecordingPreparationManager:
|
|||
if pinned is None:
|
||||
return None
|
||||
snapshot, release = pinned
|
||||
self._reserve_launch_lease(snapshot, release, lease_seconds)
|
||||
timer = threading.Timer(lease_seconds, release)
|
||||
timer.name = f"missioncore-recording-launch-lease-{snapshot.preparation_id}"
|
||||
timer.daemon = True
|
||||
timer.start()
|
||||
return snapshot
|
||||
|
||||
def release_launch_reservation(self, session_id: str) -> bool:
|
||||
"""Release an unused launch-to-GET lease for one session.
|
||||
|
||||
The first matching recording GET consumes this reservation after it
|
||||
acquires its own response-lifetime pin. Deletion may release a launch
|
||||
reservation that was never consumed; an active response pin remains
|
||||
independently protected by the materializer.
|
||||
"""
|
||||
|
||||
with self._guard:
|
||||
reservation = self._launch_reservations.pop(session_id, None)
|
||||
if reservation is None:
|
||||
return False
|
||||
reservation.timer.cancel()
|
||||
reservation.release()
|
||||
return True
|
||||
|
||||
def cancel(self, session_id: str, *, preparation_id: str | None = None) -> bool:
|
||||
with self._guard:
|
||||
job = self._current_by_session.get(session_id)
|
||||
|
|
@ -477,30 +447,11 @@ class SessionRecordingPreparationManager:
|
|||
self._transition_locked(job, "cancelled", job.progress)
|
||||
return True
|
||||
|
||||
def discard(self, session_id: str) -> bool:
|
||||
"""Forget one non-active job before its source session is deleted."""
|
||||
|
||||
with self._guard:
|
||||
job = self._current_by_session.get(session_id)
|
||||
if job is not None and job.state in ACTIVE_PREPARATION_STATES:
|
||||
return False
|
||||
self._current_by_session.pop(session_id, None)
|
||||
reservation = self._launch_reservations.pop(session_id, None)
|
||||
if reservation is not None:
|
||||
reservation.timer.cancel()
|
||||
if reservation is not None:
|
||||
reservation.release()
|
||||
return True
|
||||
|
||||
def close(self, *, timeout: float = 5.0) -> None:
|
||||
with self._guard:
|
||||
if self._closed:
|
||||
return
|
||||
self._closed = True
|
||||
reservations = tuple(self._launch_reservations.values())
|
||||
self._launch_reservations.clear()
|
||||
for reservation in reservations:
|
||||
reservation.timer.cancel()
|
||||
for job in self._current_by_session.values():
|
||||
if job.state in ACTIVE_PREPARATION_STATES:
|
||||
job.interrupted_by_restart = not job.cancelled_by_operator
|
||||
|
|
@ -515,46 +466,9 @@ class SessionRecordingPreparationManager:
|
|||
pending.stop_event.set()
|
||||
self._pending_generation = None
|
||||
worker = None if active is None else active.worker
|
||||
for reservation in reservations:
|
||||
reservation.release()
|
||||
if worker is not None:
|
||||
worker.join(timeout=max(0.0, timeout))
|
||||
|
||||
def _reserve_launch_lease(
|
||||
self,
|
||||
snapshot: RecordingPreparationSnapshot,
|
||||
release: Callable[[], None],
|
||||
lease_seconds: float,
|
||||
) -> None:
|
||||
timer = threading.Timer(
|
||||
lease_seconds,
|
||||
self._expire_launch_reservation,
|
||||
args=(snapshot.session_id, snapshot.preparation_id),
|
||||
)
|
||||
timer.name = f"missioncore-recording-launch-lease-{snapshot.preparation_id}"
|
||||
timer.daemon = True
|
||||
reservation = _LaunchReservation(
|
||||
preparation_id=snapshot.preparation_id,
|
||||
release=release,
|
||||
timer=timer,
|
||||
)
|
||||
with self._guard:
|
||||
previous = self._launch_reservations.pop(snapshot.session_id, None)
|
||||
if previous is not None:
|
||||
previous.timer.cancel()
|
||||
self._launch_reservations[snapshot.session_id] = reservation
|
||||
if previous is not None:
|
||||
previous.release()
|
||||
timer.start()
|
||||
|
||||
def _expire_launch_reservation(self, session_id: str, preparation_id: str) -> None:
|
||||
with self._guard:
|
||||
reservation = self._launch_reservations.get(session_id)
|
||||
if reservation is None or reservation.preparation_id != preparation_id:
|
||||
return
|
||||
self._launch_reservations.pop(session_id, None)
|
||||
reservation.release()
|
||||
|
||||
def _run_generation(self, generation: _WorkerGeneration) -> None:
|
||||
work_queue = generation.work_queue
|
||||
try:
|
||||
|
|
|
|||
|
|
@ -218,30 +218,6 @@ class SessionRecordingMaterializer:
|
|||
self._increment_pin_locked(recording.session_id)
|
||||
return self._release_callback(recording.session_id)
|
||||
|
||||
def delete_cached(self, session_id: str) -> bool:
|
||||
"""Delete one exact derived RRD cache unless a response still leases it."""
|
||||
|
||||
if SESSION_ID_PATTERN.fullmatch(session_id) is None:
|
||||
raise ValueError("recording cache session id is invalid")
|
||||
with self._lock_for(session_id), self._cache_guard:
|
||||
if self._pinned_sessions.get(session_id, 0) > 0:
|
||||
return False
|
||||
session_root = self.recordings_root / session_id
|
||||
if session_root.exists() or session_root.is_symlink():
|
||||
metadata = session_root.lstat()
|
||||
if stat.S_ISLNK(metadata.st_mode) or not stat.S_ISDIR(metadata.st_mode):
|
||||
raise RecordingMaterializationError(
|
||||
"session recording cache is not a real directory"
|
||||
)
|
||||
if session_root.parent.resolve(strict=True) != self.recordings_root:
|
||||
raise RecordingMaterializationError(
|
||||
"session recording cache escapes the private data root"
|
||||
)
|
||||
shutil.rmtree(session_root)
|
||||
with self._memory_guard:
|
||||
self._validated_memory.pop(session_id, None)
|
||||
return True
|
||||
|
||||
def __call__(self, command: ReplayCommand) -> MaterializedRecording:
|
||||
"""Alias for :meth:`materialize`, suitable for the web API protocol."""
|
||||
|
||||
|
|
|
|||
|
|
@ -3,15 +3,12 @@ from __future__ import annotations
|
|||
import json
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import sqlite3
|
||||
import stat
|
||||
import threading
|
||||
from collections.abc import Iterator
|
||||
from contextlib import contextmanager
|
||||
from pathlib import Path
|
||||
from typing import Any, cast
|
||||
from uuid import uuid4
|
||||
|
||||
from k1link.artifacts import utc_now_iso
|
||||
|
||||
|
|
@ -145,18 +142,11 @@ class SessionStore:
|
|||
candidates = source.discover(allowed_root)
|
||||
imported: list[str] = []
|
||||
for candidate in candidates:
|
||||
try:
|
||||
self._upsert_candidate(source, candidate)
|
||||
except SessionIntegrityError:
|
||||
# One incomplete/corrupt evidence directory must not starve a
|
||||
# later valid session in the same plugin archive. Keep any
|
||||
# previously indexed row because the candidate is still
|
||||
# physically present, but never admit its current artifacts.
|
||||
continue
|
||||
self._upsert_candidate(source, candidate)
|
||||
imported.append(candidate.session_id)
|
||||
with self._lock, self._connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
discovered = {candidate.session_id for candidate in candidates}
|
||||
discovered = set(imported)
|
||||
indexed = connection.execute(
|
||||
"SELECT session_id FROM observation_sessions "
|
||||
"WHERE plugin_id = ? AND archive_id = ? AND allowed_root = ?",
|
||||
|
|
@ -242,60 +232,6 @@ class SessionStore:
|
|||
)
|
||||
return SessionDetail(summary=_summary_from_row(row), sources=sources, artifacts=artifacts)
|
||||
|
||||
def delete_session(self, session_id: str) -> None:
|
||||
"""Permanently delete one exact catalogued evidence directory and row."""
|
||||
|
||||
_validate_identifier(session_id, "session id")
|
||||
with self._lock:
|
||||
with self._connect() as connection:
|
||||
row = connection.execute(
|
||||
"SELECT allowed_root, session_root FROM observation_sessions "
|
||||
"WHERE session_id = ?",
|
||||
(session_id,),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
raise SessionNotFoundError("observation session was not found")
|
||||
|
||||
allowed_root = Path(row["allowed_root"]).expanduser().resolve()
|
||||
session_root = Path(row["session_root"]).expanduser()
|
||||
try:
|
||||
unresolved_parent = session_root.parent.resolve(strict=True)
|
||||
except OSError as exc:
|
||||
raise SessionIntegrityError("session deletion root is unavailable") from exc
|
||||
if (
|
||||
unresolved_parent != allowed_root
|
||||
or session_root.name != session_id
|
||||
or session_root == allowed_root
|
||||
):
|
||||
raise SessionIntegrityError("session deletion target escapes its allowed root")
|
||||
|
||||
if session_root.exists() or session_root.is_symlink():
|
||||
try:
|
||||
metadata = session_root.lstat()
|
||||
except OSError as exc:
|
||||
raise SessionIntegrityError("session deletion target is unavailable") from exc
|
||||
if stat.S_ISLNK(metadata.st_mode) or not stat.S_ISDIR(metadata.st_mode):
|
||||
raise SessionIntegrityError("session deletion target is not a real directory")
|
||||
tombstone = allowed_root / f".{session_id}.{uuid4().hex}.deleting"
|
||||
try:
|
||||
os.replace(session_root, tombstone)
|
||||
shutil.rmtree(tombstone)
|
||||
except OSError as exc:
|
||||
if tombstone.exists() and not session_root.exists():
|
||||
with _ignore_os_error():
|
||||
os.replace(tombstone, session_root)
|
||||
raise SessionIntegrityError("session evidence could not be deleted") from exc
|
||||
|
||||
with self._connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
deleted = connection.execute(
|
||||
"DELETE FROM observation_sessions WHERE session_id = ?",
|
||||
(session_id,),
|
||||
).rowcount
|
||||
connection.commit()
|
||||
if deleted != 1:
|
||||
raise SessionNotFoundError("observation session was not found")
|
||||
|
||||
def prepare_replay(
|
||||
self,
|
||||
session_id: str,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from collections.abc import Callable, Mapping
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime
|
||||
|
|
@ -17,14 +16,12 @@ from missioncore_plugin_sdk.v0alpha2 import (
|
|||
RuntimeHealthSnapshot,
|
||||
RuntimePluginDescriptor,
|
||||
)
|
||||
from pydantic import BaseModel, ConfigDict, Field, ValidationError
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from k1link.sessions.plugin_contract import ObservationRuntimeContribution
|
||||
|
||||
STATE_READ_ACTION_ID = "state.read"
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class DevicePluginActionRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
|
@ -165,28 +162,13 @@ class InProcessDevicePluginRuntime:
|
|||
f"{invocation.action_id}"
|
||||
)
|
||||
output = await self._adapter.invoke(invocation)
|
||||
try:
|
||||
return RuntimeActionResult(
|
||||
invocation_id=invocation.invocation_id,
|
||||
plugin_id=invocation.plugin_id,
|
||||
action_id=invocation.action_id,
|
||||
completed_at=datetime.now(UTC),
|
||||
output=dict(output),
|
||||
)
|
||||
except ValidationError as exc:
|
||||
# Never allow a malformed plugin result to masquerade as invalid
|
||||
# operator input (HTTP 422). Do not log the output or validation
|
||||
# input: device snapshots may contain retained evidence.
|
||||
logger.error(
|
||||
"device plugin returned a non-JSON action result: "
|
||||
"plugin_id=%s action_id=%s validation_errors=%d",
|
||||
invocation.plugin_id,
|
||||
invocation.action_id,
|
||||
exc.error_count(),
|
||||
)
|
||||
raise PluginExecutionError(
|
||||
"Device plugin returned an invalid non-JSON action result"
|
||||
) from exc
|
||||
return RuntimeActionResult(
|
||||
invocation_id=invocation.invocation_id,
|
||||
plugin_id=invocation.plugin_id,
|
||||
action_id=invocation.action_id,
|
||||
completed_at=datetime.now(UTC),
|
||||
output=dict(output),
|
||||
)
|
||||
|
||||
def close(self) -> None:
|
||||
with self._lock:
|
||||
|
|
|
|||
|
|
@ -303,79 +303,6 @@ def build_session_router(
|
|||
detail="Некорректный идентификатор сессии.",
|
||||
) from exc
|
||||
|
||||
@router.delete(
|
||||
"/api/v1/observation-sessions/{session_id}",
|
||||
status_code=204,
|
||||
)
|
||||
async def delete_observation_session(session_id: str) -> Response:
|
||||
try:
|
||||
store.get_session(session_id)
|
||||
recorded_artifacts = store.list_recorded_media(session_id)
|
||||
except SessionNotFoundError as exc:
|
||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||
except ValueError as exc:
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail="Некорректный идентификатор сессии.",
|
||||
) from exc
|
||||
|
||||
if recording_preparation_manager is not None:
|
||||
snapshot = recording_preparation_manager.status(session_id)
|
||||
if snapshot is not None and snapshot.state in {
|
||||
"queued",
|
||||
"validating",
|
||||
"exporting",
|
||||
"finalizing",
|
||||
}:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail="Дождитесь завершения подготовки записи перед удалением.",
|
||||
)
|
||||
await run_in_threadpool(
|
||||
recording_preparation_manager.release_launch_reservation,
|
||||
session_id,
|
||||
)
|
||||
|
||||
cache_deleter = getattr(recording_materializer, "delete_cached", None)
|
||||
if callable(cache_deleter):
|
||||
try:
|
||||
cache_deleted = await run_in_threadpool(cache_deleter, session_id)
|
||||
except (OSError, RecordingMaterializationError) as exc:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail="Подготовленную запись пока нельзя удалить.",
|
||||
) from exc
|
||||
if cache_deleted is not True:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail="Запись сейчас открыта в интерфейсе. Закройте её и повторите удаление.",
|
||||
)
|
||||
|
||||
if (
|
||||
recording_preparation_manager is not None
|
||||
and not recording_preparation_manager.discard(session_id)
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail="Подготовка записи ещё выполняется.",
|
||||
)
|
||||
|
||||
try:
|
||||
await run_in_threadpool(
|
||||
recorded_media_inspector.delete_prepared,
|
||||
session_id,
|
||||
(artifact.artifact_id for artifact in recorded_artifacts),
|
||||
)
|
||||
await run_in_threadpool(store.delete_session, session_id)
|
||||
except SessionNotFoundError as exc:
|
||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||
except (OSError, SessionIntegrityError) as exc:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail="Сервер не смог безопасно удалить файлы этой сессии.",
|
||||
) from exc
|
||||
return Response(status_code=204)
|
||||
|
||||
@router.post("/api/v1/observation-sessions/{session_id}/replay")
|
||||
async def replay_observation_session(
|
||||
session_id: str,
|
||||
|
|
|
|||
|
|
@ -1,10 +0,0 @@
|
|||
{
|
||||
"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,7 +4,6 @@ from typing import Any
|
|||
|
||||
from typer.testing import CliRunner
|
||||
|
||||
from k1link.device_plugins.xgrids_k1 import cli
|
||||
from k1link.device_plugins.xgrids_k1.cli import app
|
||||
|
||||
runner = CliRunner()
|
||||
|
|
@ -26,26 +25,6 @@ def test_doctor_json() -> None:
|
|||
assert any(item["name"] == "tcpdump" for item in payload["tools"])
|
||||
|
||||
|
||||
def test_serve_resolves_frontend_from_repository_root(monkeypatch: Any) -> None:
|
||||
captured: dict[str, object] = {}
|
||||
|
||||
def fake_run(application: str, **kwargs: object) -> None:
|
||||
captured.update({"application": application, **kwargs})
|
||||
|
||||
monkeypatch.setattr(cli.uvicorn, "run", fake_run)
|
||||
|
||||
result = runner.invoke(app, ["serve"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert captured == {
|
||||
"application": "k1link.web.app:app",
|
||||
"host": "127.0.0.1",
|
||||
"port": 8000,
|
||||
"log_level": "info",
|
||||
"access_log": True,
|
||||
}
|
||||
|
||||
|
||||
def test_authority_provision_requires_explicit_reviewed_value_confirmation() -> None:
|
||||
result = runner.invoke(app, ["authority", "provision"])
|
||||
|
||||
|
|
|
|||
|
|
@ -361,37 +361,6 @@ def test_dispatcher_routes_allowlisted_action_to_xgrids_facade() -> None:
|
|||
assert service.calls == [("scan", 6.0)]
|
||||
|
||||
|
||||
def test_runtime_classifies_non_json_plugin_output_as_execution_failure(
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
retained_marker = "must-not-appear-in-the-server-log"
|
||||
|
||||
class NonJsonAdapter:
|
||||
plugin_id = "example.non-json"
|
||||
action_ids = frozenset({"state.read"})
|
||||
|
||||
async def invoke(
|
||||
self,
|
||||
_invocation: RuntimeActionInvocation,
|
||||
) -> dict[str, Any]:
|
||||
return {"dialogue": {"response_evidence": (retained_marker,)}}
|
||||
|
||||
dispatcher = DevicePluginDispatcher(
|
||||
[
|
||||
_in_process_runtime(
|
||||
NonJsonAdapter(),
|
||||
plugin_version="0.1.0",
|
||||
host_api_version="missioncore.nodedc/v1alpha2",
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
with pytest.raises(PluginExecutionError, match="non-JSON"):
|
||||
asyncio.run(dispatcher.invoke("example.non-json", "state.read", {}))
|
||||
|
||||
assert retained_marker not in caplog.text
|
||||
|
||||
|
||||
def test_dispatcher_rejects_unknown_plugin_and_action() -> None:
|
||||
dispatcher = DevicePluginDispatcher(
|
||||
[_in_process_runtime(XgridsK1PluginFacade(FakeXgridsService()))]
|
||||
|
|
@ -435,7 +404,7 @@ def test_facade_validates_payload_before_calling_service() -> None:
|
|||
"compatibility_attestation": {
|
||||
"firmware_version": "3.0.2",
|
||||
"topology": "direct-lan",
|
||||
"verification": "live-device-info",
|
||||
"operator_confirmed": True,
|
||||
},
|
||||
},
|
||||
"live",
|
||||
|
|
|
|||
|
|
@ -23,7 +23,6 @@ from k1link.sessions import (
|
|||
RecordedMediaInspector,
|
||||
ReplayCommand,
|
||||
SessionIntegrityError,
|
||||
SessionNotFoundError,
|
||||
SessionRecordingMaterializer,
|
||||
SessionRecordingPreparationManager,
|
||||
SessionStore,
|
||||
|
|
@ -247,116 +246,6 @@ def test_session_router_lists_details_and_dispatches_opaque_replay(tmp_path: Pat
|
|||
assert_no_local_paths(value, repository)
|
||||
|
||||
|
||||
def test_delete_session_removes_evidence_and_cache_but_refuses_an_open_recording(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
repository = tmp_path / "repo"
|
||||
sessions = repository / "sessions"
|
||||
session = make_legacy_session(sessions, "20260716T205632Z_viewer_live")
|
||||
store = SessionStore(repository, data_dir=tmp_path / "data")
|
||||
store.reconcile_archive(xgrids_k1_archive_source(sessions))
|
||||
|
||||
def export_recording(source: Path, destination: Path) -> dict[str, object]:
|
||||
payload = b"deletable-recording"
|
||||
destination.write_bytes(payload)
|
||||
return {
|
||||
"source_sha256": hashlib.sha256(source.read_bytes()).hexdigest(),
|
||||
"rrd_sha256": hashlib.sha256(payload).hexdigest(),
|
||||
"rrd_bytes": len(payload),
|
||||
"timeline": "session_time",
|
||||
"timeline_start_ns": 0,
|
||||
"timeline_end_ns": 1,
|
||||
}
|
||||
|
||||
materializer = SessionRecordingMaterializer(
|
||||
store.data_dir,
|
||||
exporter=export_recording,
|
||||
)
|
||||
recording, release = materializer.materialize_pinned(store.prepare_replay(session.name))
|
||||
router = build_session_router(store, recording_materializer=materializer)
|
||||
delete_route = endpoint(router, "/api/v1/observation-sessions/{session_id}", "DELETE")
|
||||
|
||||
with pytest.raises(HTTPException) as conflict:
|
||||
asyncio.run(delete_route(session_id=session.name))
|
||||
assert conflict.value.status_code == 409
|
||||
assert session.is_dir()
|
||||
assert recording.path.is_file()
|
||||
|
||||
release()
|
||||
response = asyncio.run(delete_route(session_id=session.name))
|
||||
|
||||
assert response.status_code == 204
|
||||
assert not session.exists()
|
||||
assert not recording.path.parent.exists()
|
||||
with pytest.raises(SessionNotFoundError):
|
||||
store.get_session(session.name)
|
||||
|
||||
|
||||
def test_completed_recording_get_does_not_hold_delete_for_launch_lease(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
repository = tmp_path / "repo"
|
||||
sessions = repository / "sessions"
|
||||
session = make_legacy_session(sessions, "20260716T205632Z_viewer_live")
|
||||
store = SessionStore(repository, data_dir=tmp_path / "data")
|
||||
store.reconcile_archive(xgrids_k1_archive_source(sessions))
|
||||
payload = b"recording-opened-and-closed"
|
||||
|
||||
def export_recording(source: Path, destination: Path) -> dict[str, object]:
|
||||
destination.write_bytes(payload)
|
||||
return {
|
||||
"source_sha256": hashlib.sha256(source.read_bytes()).hexdigest(),
|
||||
"rrd_sha256": hashlib.sha256(payload).hexdigest(),
|
||||
"rrd_bytes": len(payload),
|
||||
"timeline": "session_time",
|
||||
"timeline_start_ns": 0,
|
||||
"timeline_end_ns": 1_000_000_000,
|
||||
}
|
||||
|
||||
materializer = SessionRecordingMaterializer(store.data_dir, exporter=export_recording)
|
||||
command = store.prepare_replay(session.name)
|
||||
materializer.materialize(command)
|
||||
manager = SessionRecordingPreparationManager(materializer)
|
||||
resolved = manager.resolve_cached(command)
|
||||
assert resolved is not None and resolved.recording is not None
|
||||
router = build_session_router(
|
||||
store,
|
||||
recording_materializer=materializer,
|
||||
recording_preparation_manager=manager,
|
||||
)
|
||||
replay_route = endpoint(
|
||||
router,
|
||||
"/api/v1/observation-sessions/{session_id}/replay",
|
||||
"POST",
|
||||
)
|
||||
recording_route = endpoint(
|
||||
router,
|
||||
"/api/v1/observation-sessions/{session_id}/recording.rrd",
|
||||
"GET",
|
||||
)
|
||||
delete_route = endpoint(router, "/api/v1/observation-sessions/{session_id}", "DELETE")
|
||||
try:
|
||||
replay = asyncio.run(replay_route(session_id=session.name, request=None))
|
||||
generation = replay["launch"]["sha256"]
|
||||
file_response = asyncio.run(
|
||||
recording_route(
|
||||
session_id=session.name,
|
||||
generation=generation,
|
||||
if_match=None,
|
||||
if_none_match=None,
|
||||
)
|
||||
)
|
||||
_, body = asyncio.run(render_file_response(file_response, range_header=None))
|
||||
assert body == payload
|
||||
|
||||
deleted = asyncio.run(delete_route(session_id=session.name))
|
||||
|
||||
assert deleted.status_code == 204
|
||||
assert not session.exists()
|
||||
finally:
|
||||
manager.close()
|
||||
|
||||
|
||||
def test_session_router_returns_seekable_recording_and_serves_byte_ranges(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
|
|
|
|||
|
|
@ -433,38 +433,3 @@ def test_launch_reservation_blocks_eviction_until_lease_expires(tmp_path: Path)
|
|||
assert not first_recording.path.exists()
|
||||
finally:
|
||||
manager.close()
|
||||
|
||||
|
||||
def test_first_recording_get_pin_consumes_launch_reservation(tmp_path: Path) -> None:
|
||||
command = _command(tmp_path / "session")
|
||||
|
||||
def exporter(source: Path, destination: Path) -> dict[str, object]:
|
||||
return _summary(source, destination, b"recording")
|
||||
|
||||
materializer = SessionRecordingMaterializer(
|
||||
tmp_path / "private",
|
||||
exporter=exporter,
|
||||
free_space_reserve_bytes=0,
|
||||
)
|
||||
manager = SessionRecordingPreparationManager(materializer)
|
||||
try:
|
||||
materializer.materialize(command)
|
||||
resolved = manager.resolve_cached(command)
|
||||
assert resolved is not None and resolved.state == "ready"
|
||||
reserved = manager.reserve_cached(command, lease_seconds=60.0)
|
||||
assert reserved is not None
|
||||
|
||||
pinned = manager.pin_ready(
|
||||
command.session_id,
|
||||
preparation_id=reserved.preparation_id,
|
||||
)
|
||||
|
||||
assert pinned is not None
|
||||
_, release_response = pinned
|
||||
assert manager.release_launch_reservation(command.session_id) is False
|
||||
assert materializer.delete_cached(command.session_id) is False
|
||||
|
||||
release_response()
|
||||
assert materializer.delete_cached(command.session_id) is True
|
||||
finally:
|
||||
manager.close()
|
||||
|
|
|
|||
|
|
@ -129,25 +129,6 @@ def test_materializer_reuses_only_a_digest_validated_private_cache(tmp_path: Pat
|
|||
assert exporter.calls == 1
|
||||
|
||||
|
||||
def test_delete_cached_refuses_a_live_lease_then_removes_the_exact_cache(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
command = _command(tmp_path / "source")
|
||||
materializer = SessionRecordingMaterializer(
|
||||
tmp_path / "private",
|
||||
exporter=FakeExporter(),
|
||||
)
|
||||
recording, release = materializer.materialize_pinned(command)
|
||||
|
||||
assert materializer.delete_cached(command.session_id) is False
|
||||
assert recording.path.is_file()
|
||||
|
||||
release()
|
||||
assert materializer.delete_cached(command.session_id) is True
|
||||
assert not recording.path.parent.exists()
|
||||
assert command.primary_artifact.path.is_file()
|
||||
|
||||
|
||||
def test_materializer_rejects_changed_digest_bound_secondary_artifact(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
|
|
|
|||
|
|
@ -178,39 +178,6 @@ def test_catalog_reconciles_sessions_removed_from_one_evidence_root(tmp_path: Pa
|
|||
assert store.list_recent().items == ()
|
||||
|
||||
|
||||
def test_corrupt_candidate_does_not_starve_later_valid_session(tmp_path: Path) -> None:
|
||||
repository = tmp_path / "repo"
|
||||
sessions = repository / "sessions"
|
||||
corrupt = sessions / "20260716T125502Z_viewer_live"
|
||||
corrupt.mkdir(parents=True)
|
||||
(corrupt / "manifest.redacted.json").write_text("{}", encoding="utf-8")
|
||||
valid = make_legacy_session(sessions, "20260718T201659Z_viewer_live")
|
||||
store = SessionStore(repository, data_dir=tmp_path / "data")
|
||||
|
||||
assert store.reconcile_archive(xgrids_k1_archive_source(sessions)) == (valid.name,)
|
||||
assert [item.session_id for item in store.list_recent().items] == [valid.name]
|
||||
|
||||
|
||||
def test_present_corrupt_candidate_does_not_delete_its_last_valid_catalog_row(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
repository = tmp_path / "repo"
|
||||
sessions = repository / "sessions"
|
||||
retained = make_legacy_session(sessions, "20260716T125502Z_viewer_live")
|
||||
later = make_legacy_session(sessions, "20260718T201659Z_viewer_live")
|
||||
store = SessionStore(repository, data_dir=tmp_path / "data")
|
||||
source = xgrids_k1_archive_source(sessions)
|
||||
assert store.reconcile_archive(source) == (retained.name, later.name)
|
||||
|
||||
shutil.rmtree(retained / "captures")
|
||||
|
||||
assert store.reconcile_archive(source) == (later.name,)
|
||||
assert {item.session_id for item in store.list_recent().items} == {
|
||||
retained.name,
|
||||
later.name,
|
||||
}
|
||||
|
||||
|
||||
def test_catalog_uses_valid_project_name_as_session_display_name(tmp_path: Path) -> None:
|
||||
sessions = tmp_path / "sessions"
|
||||
session = make_legacy_session(sessions, "20260716T205632Z_viewer_live")
|
||||
|
|
@ -704,45 +671,6 @@ def test_current_session_marker_keeps_active_capture_out_of_replay(tmp_path: Pat
|
|||
assert store.get_session(session.name).summary.replayable is True
|
||||
|
||||
|
||||
def test_delete_session_removes_only_the_exact_evidence_tree_and_catalog_row(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
repository = tmp_path / "repo"
|
||||
sessions = repository / "sessions"
|
||||
deleted = make_legacy_session(sessions, "20260716T205632Z_viewer_live")
|
||||
retained = make_legacy_session(sessions, "20260716T205633Z_viewer_live")
|
||||
store = SessionStore(repository, data_dir=tmp_path / "data")
|
||||
store.reconcile_archive(xgrids_k1_archive_source(sessions))
|
||||
|
||||
store.delete_session(deleted.name)
|
||||
|
||||
assert not deleted.exists()
|
||||
assert retained.is_dir()
|
||||
with pytest.raises(SessionNotFoundError):
|
||||
store.get_session(deleted.name)
|
||||
assert store.get_session(retained.name).summary.session_id == retained.name
|
||||
|
||||
|
||||
def test_delete_session_rejects_a_catalog_target_outside_its_allowed_root(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
repository = tmp_path / "repo"
|
||||
sessions = repository / "sessions"
|
||||
session = make_legacy_session(sessions, "20260716T205632Z_viewer_live")
|
||||
store = SessionStore(repository, data_dir=tmp_path / "data")
|
||||
store.reconcile_archive(xgrids_k1_archive_source(sessions))
|
||||
with sqlite3.connect(store.database_path) as connection:
|
||||
connection.execute(
|
||||
"UPDATE observation_sessions SET session_root = ? WHERE session_id = ?",
|
||||
(str(repository), session.name),
|
||||
)
|
||||
|
||||
with pytest.raises(SessionIntegrityError, match="escapes"):
|
||||
store.delete_session(session.name)
|
||||
|
||||
assert session.is_dir()
|
||||
|
||||
|
||||
def test_interrupted_capture_replays_only_committed_prefix_before_partial_raw_tail(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
|
|
|
|||
|
|
@ -17,7 +17,6 @@ from k1link.device_plugins.xgrids_k1.protocol.modeling import (
|
|||
from k1link.device_plugins.xgrids_k1.protocol.protobuf_wire import (
|
||||
ProtobufWireError,
|
||||
decode_zigzag64,
|
||||
encode_zigzag64,
|
||||
iter_fields,
|
||||
)
|
||||
from k1link.device_plugins.xgrids_k1.protocol.streams import (
|
||||
|
|
@ -95,13 +94,6 @@ def test_protobuf_wire_zigzag_and_bounds() -> None:
|
|||
assert decode_zigzag64(0) == 0
|
||||
assert decode_zigzag64(1) == -1
|
||||
assert decode_zigzag64(2) == 1
|
||||
assert encode_zigzag64(0) == 0
|
||||
assert encode_zigzag64(-1) == 1
|
||||
assert encode_zigzag64(1) == 2
|
||||
assert decode_zigzag64(encode_zigzag64(-(1 << 63))) == -(1 << 63)
|
||||
assert decode_zigzag64(encode_zigzag64((1 << 63) - 1)) == (1 << 63) - 1
|
||||
with pytest.raises(ProtobufWireError, match="outside int64"):
|
||||
encode_zigzag64(1 << 63)
|
||||
with pytest.raises(ProtobufWireError, match="truncated"):
|
||||
list(iter_fields(b"\x0a\x02\x01"))
|
||||
with pytest.raises(ProtobufWireError, match="unsupported"):
|
||||
|
|
|
|||
|
|
@ -77,7 +77,7 @@ def test_validation_errors_do_not_echo_sensitive_request_values(
|
|||
"compatibility_attestation": {
|
||||
"firmware_version": "3.0.2",
|
||||
"topology": "direct-lan",
|
||||
"verification": "live-device-info",
|
||||
"operator_confirmed": True,
|
||||
},
|
||||
}
|
||||
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(
|
||||
firmware_version="3.0.2",
|
||||
topology="direct-lan",
|
||||
verification="live-device-info",
|
||||
operator_confirmed=True,
|
||||
)
|
||||
PRIMARY_TEST_CREDENTIAL = "x" * 24
|
||||
SECONDARY_TEST_CREDENTIAL = "y" * 24
|
||||
|
|
@ -120,11 +120,12 @@ class FakeInteractiveControlSession:
|
|||
self.state = "workspace-ready"
|
||||
self.start_projects: list[str] = []
|
||||
self.stop_calls = 0
|
||||
self.confirm_calls = 0
|
||||
|
||||
def snapshot(self) -> dict[str, object]:
|
||||
return {
|
||||
"state": self.state,
|
||||
"can_confirm_standby": False,
|
||||
"can_confirm_standby": self.state == "awaiting-standby-confirmation",
|
||||
}
|
||||
|
||||
def open_project_prompt(self) -> dict[str, object]:
|
||||
|
|
@ -146,6 +147,12 @@ class FakeInteractiveControlSession:
|
|||
self.state = "awaiting-standby-confirmation"
|
||||
return self.snapshot()
|
||||
|
||||
def confirm_standby(self) -> dict[str, object]:
|
||||
assert self.state == "awaiting-standby-confirmation"
|
||||
self.confirm_calls += 1
|
||||
self.state = "completed"
|
||||
return self.snapshot()
|
||||
|
||||
def close_prestart(self) -> dict[str, object]:
|
||||
self.state = "closed"
|
||||
return self.snapshot()
|
||||
|
|
@ -204,8 +211,6 @@ def test_prepare_creates_provisional_device_session_and_profiled_acquisition(
|
|||
assert state["device_ref"]["device_id"] != state["device_session"]["device_session_id"]
|
||||
assert state["acquisition"]["state"] == "prepared"
|
||||
assert state["acquisition"]["project_name"] == PROJECT_NAME
|
||||
assert state["acquisition"]["mount_type"] == "handheld"
|
||||
assert state["acquisition"]["gnss_mode"] == "none"
|
||||
assert state["acquisition"]["compatibility_profile_id"] == (XGRIDS_K1_COMPATIBILITY_PROFILE_ID)
|
||||
assert state["compatibility"]["vendor_writes_enabled"] is False
|
||||
|
||||
|
|
@ -225,29 +230,6 @@ def test_project_name_is_normalized_and_control_characters_are_rejected() -> Non
|
|||
host="192.168.1.20",
|
||||
compatibility_attestation=ATTESTATION,
|
||||
)
|
||||
def test_only_physically_accepted_configuration_values_are_admitted() -> None:
|
||||
with pytest.raises(ValidationError):
|
||||
ConnectRequest(
|
||||
device_id="synthetic-device",
|
||||
ssid="synthetic-network",
|
||||
password=SecretStr(PRIMARY_TEST_CREDENTIAL),
|
||||
connection_mode="quick-connect", # type: ignore[arg-type]
|
||||
compatibility_attestation=ATTESTATION,
|
||||
)
|
||||
with pytest.raises(ValidationError):
|
||||
PrepareAcquisitionRequest(
|
||||
project_name=PROJECT_NAME,
|
||||
host="192.168.1.20",
|
||||
mount_type="uav", # type: ignore[arg-type]
|
||||
compatibility_attestation=ATTESTATION,
|
||||
)
|
||||
with pytest.raises(ValidationError):
|
||||
PrepareAcquisitionRequest(
|
||||
project_name=PROJECT_NAME,
|
||||
host="192.168.1.20",
|
||||
gnss_mode="rtk", # type: ignore[arg-type]
|
||||
compatibility_attestation=ATTESTATION,
|
||||
)
|
||||
|
||||
|
||||
def test_facade_arms_bounded_shadow_lease_without_installing_publish_transport(
|
||||
|
|
@ -265,8 +247,7 @@ def test_facade_arms_bounded_shadow_lease_without_installing_publish_transport(
|
|||
service._compatibility_attestation = { # noqa: SLF001
|
||||
"firmware_version": "3.0.2",
|
||||
"topology": "direct-lan",
|
||||
"verification": "live-device-info",
|
||||
"basis": "selected-profile-live-device-info-required",
|
||||
"basis": "operator-attested",
|
||||
"observed_at": "2026-07-18T00:00:00Z",
|
||||
}
|
||||
|
||||
|
|
@ -293,7 +274,7 @@ def test_facade_arms_bounded_shadow_lease_without_installing_publish_transport(
|
|||
assert disarmed["application_control_execution"]["lease"] is None
|
||||
|
||||
|
||||
def test_shadow_arm_requires_idle_connected_profile_selected_device_before_keychain_read(
|
||||
def test_shadow_arm_requires_idle_connected_attested_device_before_keychain_read(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
loader = FakeApplicationAuthorityLoader()
|
||||
|
|
@ -424,82 +405,21 @@ def test_plugin_commanded_acquisition_keeps_start_and_stop_as_explicit_actions(
|
|||
physical_acceptance=PHYSICAL_ACCEPTANCE,
|
||||
)
|
||||
)
|
||||
stop_operation = stopping["last_operation"]
|
||||
assert control.stop_calls == 1
|
||||
assert stopping["acquisition"]["state"] == "awaiting_external_stop"
|
||||
assert stopping["last_operation"]["status"] == "running"
|
||||
|
||||
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(
|
||||
completed = service.stop_acquisition(
|
||||
StopAcquisitionRequest(
|
||||
acquisition_id=acquisition_id,
|
||||
mode="graceful",
|
||||
physical_acceptance=PHYSICAL_ACCEPTANCE,
|
||||
operator_confirmed=True,
|
||||
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.state == "completed"
|
||||
assert recovered["acquisition"]["state"] == "failed"
|
||||
assert recovered["application_control_session"]["state"] == "completed"
|
||||
assert control.confirm_calls == 1
|
||||
assert completed["acquisition"]["state"] == "completed"
|
||||
assert runtime.stop_calls == 1
|
||||
|
||||
|
||||
|
|
@ -1337,7 +1257,7 @@ def test_prepare_rejects_stream_subsets_and_duplicates(
|
|||
)
|
||||
|
||||
|
||||
def test_exact_profile_is_inactive_until_selected_for_live_device_info_verification(
|
||||
def test_exact_profile_is_inactive_until_explicit_operator_attestation(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
service, _ = service_with_fake_runtime(tmp_path)
|
||||
|
|
@ -1347,7 +1267,7 @@ def test_exact_profile_is_inactive_until_selected_for_live_device_info_verificat
|
|||
"profile_id": None,
|
||||
"decision": "unknown",
|
||||
"permitted_mode": "evidence-only",
|
||||
"firmware_claim": "exact-3.0.2-profile-not-selected",
|
||||
"firmware_claim": "exact-3.0.2-profile-not-attested",
|
||||
"attestation": None,
|
||||
"vendor_writes_enabled": False,
|
||||
"camera_preview": "unverified",
|
||||
|
|
@ -1364,9 +1284,7 @@ def test_exact_profile_is_inactive_until_selected_for_live_device_info_verificat
|
|||
|
||||
assert attested["compatibility"]["profile_id"] == XGRIDS_K1_COMPATIBILITY_PROFILE_ID
|
||||
assert attested["compatibility"]["decision"] == "limited"
|
||||
assert attested["compatibility"]["attestation"]["basis"] == (
|
||||
"selected-profile-live-device-info-required"
|
||||
)
|
||||
assert attested["compatibility"]["attestation"]["basis"] == "operator-attested"
|
||||
assert attested["device_session"]["compatibility_profile_id"] == (
|
||||
XGRIDS_K1_COMPATIBILITY_PROFILE_ID
|
||||
)
|
||||
|
|
@ -1804,7 +1722,6 @@ def test_network_provisioning_is_single_flight_and_secret_is_unwrapped_only_at_b
|
|||
|
||||
assert boundary_calls == [("k1-a", "lab-network", PRIMARY_TEST_CREDENTIAL)]
|
||||
assert connected["k1_ip"] == "192.168.1.20"
|
||||
assert connected["connection_mode"] == "bridge"
|
||||
assert PRIMARY_TEST_CREDENTIAL not in str(connected)
|
||||
provision_operations = [
|
||||
item for item in connected["operations"] if item["action"] == "network.provision"
|
||||
|
|
@ -1812,48 +1729,6 @@ def test_network_provisioning_is_single_flight_and_secret_is_unwrapped_only_at_b
|
|||
assert {item["status"] for item in provision_operations} == {"succeeded", "failed"}
|
||||
|
||||
|
||||
def test_network_provisioning_rejects_an_ipv4_owned_by_the_local_host(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
service, _ = service_with_fake_runtime(tmp_path)
|
||||
service._devices = [{"device_id": "k1-a"}] # noqa: SLF001
|
||||
|
||||
async def fake_provision(*_: object, **__: object) -> dict[str, Any]:
|
||||
return {
|
||||
"started_at_utc": "2026-07-18T15:19:25Z",
|
||||
"completed_at_utc": "2026-07-18T15:19:26Z",
|
||||
"profile_id": "xgrids-k1-fw3-wifi-v1",
|
||||
"outcome": "lan_address_observed",
|
||||
"observations": [{"status": {"ipv4": "192.168.68.51"}}],
|
||||
}
|
||||
|
||||
monkeypatch.setattr(facade_module, "provision_wifi_once", fake_provision)
|
||||
monkeypatch.setattr(facade_module, "_target_is_local_ipv4", lambda _target: True)
|
||||
|
||||
with pytest.raises(RuntimeError, match="уже принадлежит этому компьютеру"):
|
||||
asyncio.run(
|
||||
service.connect(
|
||||
ConnectRequest(
|
||||
device_id="k1-a",
|
||||
ssid="lab-network",
|
||||
password=SecretStr(PRIMARY_TEST_CREDENTIAL),
|
||||
compatibility_attestation=ATTESTATION,
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
state = service.state()
|
||||
assert state["selected_device_id"] is None
|
||||
assert state["k1_ip"] is None
|
||||
operation = next(
|
||||
item for item in state["operations"] if item["action"] == "network.provision"
|
||||
)
|
||||
assert operation["status"] == "failed"
|
||||
assert operation["error"]["safe_to_retry"] is False
|
||||
assert operation["error"]["side_effect_status"] == "unknown"
|
||||
|
||||
|
||||
def test_provisioning_cannot_switch_device_during_active_acquisition(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ import hashlib
|
|||
from collections.abc import Collection, Sequence
|
||||
|
||||
import pytest
|
||||
from pydantic import JsonValue, TypeAdapter
|
||||
|
||||
from k1link.device_plugins.xgrids_k1.protocol.application_acceptance import (
|
||||
ApplicationAcceptanceError,
|
||||
|
|
@ -12,7 +11,6 @@ from k1link.device_plugins.xgrids_k1.protocol.application_acceptance import (
|
|||
PhysicalAcceptanceChecklist,
|
||||
PhysicalAcceptanceDialogueExecutor,
|
||||
PhysicalAcceptancePermit,
|
||||
ScanInitializationTimeout,
|
||||
)
|
||||
from k1link.device_plugins.xgrids_k1.protocol.application_bootstrap import (
|
||||
DEVICE_CONFIG_RESPONSE_TOPIC,
|
||||
|
|
@ -20,6 +18,9 @@ from k1link.device_plugins.xgrids_k1.protocol.application_bootstrap import (
|
|||
ShadowApplicationBootstrapOrchestrator,
|
||||
build_canonical_post_start_observation,
|
||||
)
|
||||
from k1link.device_plugins.xgrids_k1.protocol.application_mqtt import (
|
||||
MODELING_RESPONSE_TOPIC,
|
||||
)
|
||||
from k1link.device_plugins.xgrids_k1.protocol.application_publish import (
|
||||
OneShotPublishEnvelope,
|
||||
)
|
||||
|
|
@ -66,19 +67,14 @@ def _header(session_id: str) -> bytes:
|
|||
return _text(4, VENDOR_DEVICE_ID) + _text(5, session_id) + _text(6, APPLICATION_KEY)
|
||||
|
||||
|
||||
def _device_info_response(
|
||||
session_id: str,
|
||||
*,
|
||||
device_model: str = "LixelKity K1",
|
||||
device_type: str = "A4",
|
||||
) -> bytes:
|
||||
def _device_info_response(session_id: str) -> bytes:
|
||||
base_info = b"".join(
|
||||
(
|
||||
_text(2, "V3.0.2_20250624.122658"),
|
||||
_text(2, "V3.0.2-20260101-release"),
|
||||
_text(3, "V3.0.2"),
|
||||
_text(6, device_model),
|
||||
_text(6, "LixelKity K1"),
|
||||
_text(7, "K1SERIAL01"),
|
||||
_text(8, device_type),
|
||||
_text(8, "K1"),
|
||||
)
|
||||
)
|
||||
working_status = _uint(1, 1)
|
||||
|
|
@ -97,13 +93,7 @@ def _modeling_response(action: ModelingAction) -> bytes:
|
|||
|
||||
|
||||
class SyntheticAcceptanceTransport:
|
||||
def __init__(
|
||||
self,
|
||||
clock: FakeClock | None = None,
|
||||
*,
|
||||
device_model: str = "LixelKity K1",
|
||||
device_type: str = "A4",
|
||||
) -> None:
|
||||
def __init__(self, clock: FakeClock | None = None) -> None:
|
||||
self.batches: list[tuple[str, ...]] = []
|
||||
self.maintain_calls: list[tuple[float, tuple[str, ...]]] = []
|
||||
self.clock = clock
|
||||
|
|
@ -112,32 +102,22 @@ class SyntheticAcceptanceTransport:
|
|||
self.post_stop_maintain_calls = 0
|
||||
self.start_emitted = False
|
||||
self.stop_emitted = False
|
||||
self.device_model = device_model
|
||||
self.device_type = device_type
|
||||
|
||||
def exchange_batch_once(
|
||||
self,
|
||||
envelopes: Sequence[OneShotPublishEnvelope],
|
||||
*,
|
||||
required_response_operation_keys: Collection[str],
|
||||
required_response_topics: Collection[str],
|
||||
) -> dict[str, bytes]:
|
||||
self.batches.append(tuple(envelope.operation_key for envelope in envelopes))
|
||||
responses: dict[str, bytes] = {}
|
||||
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:
|
||||
if MODELING_RESPONSE_TOPIC in required_response_topics:
|
||||
action = (
|
||||
ModelingAction.START
|
||||
if modeling_operation == "modeling:start"
|
||||
if envelopes[0].operation_key == "modeling:start"
|
||||
else ModelingAction.STOP
|
||||
)
|
||||
responses[modeling_operation] = _modeling_response(action)
|
||||
responses[MODELING_RESPONSE_TOPIC] = _modeling_response(action)
|
||||
if action is ModelingAction.START:
|
||||
self.start_emitted = True
|
||||
else:
|
||||
|
|
@ -154,11 +134,7 @@ class SyntheticAcceptanceTransport:
|
|||
if ordinal == 1
|
||||
else f"{VENDOR_DEVICE_ID}:DeviceInfoRequest"
|
||||
)
|
||||
response = _device_info_response(
|
||||
session,
|
||||
device_model=self.device_model,
|
||||
device_type=self.device_type,
|
||||
)
|
||||
response = _device_info_response(session)
|
||||
else:
|
||||
session = (
|
||||
f":{message_type}" if ordinal <= 3 else f"{VENDOR_DEVICE_ID}:{message_type}"
|
||||
|
|
@ -166,8 +142,9 @@ class SyntheticAcceptanceTransport:
|
|||
if message_type == "DeviceConfigRequest":
|
||||
session += ":Publish_Proto_DeviceConfig_SetTime"
|
||||
response = _generic_response(session)
|
||||
if envelope.operation_key in required_response_operation_keys:
|
||||
responses[envelope.operation_key] = response
|
||||
for topic in required_response_topics:
|
||||
if topic.endswith(_response_suffix(message_type)):
|
||||
responses[topic] = response
|
||||
return responses
|
||||
|
||||
def maintain_open_for(
|
||||
|
|
@ -201,6 +178,18 @@ class SyntheticAcceptanceTransport:
|
|||
return None
|
||||
|
||||
|
||||
def _response_suffix(message_type: str) -> str:
|
||||
values = {
|
||||
"DeviceInfoRequest": "device_info",
|
||||
"ModelingStatusRequest": "modeling_status",
|
||||
"GetRtkAdvanceRequest": "get_rtk_advance",
|
||||
"DeviceConfigRequest": "device_config",
|
||||
"GetNtripProfileRequest": "get_ntrip_profile",
|
||||
"GetCloudServerConfigRequest": "get_cloud_server_config",
|
||||
}
|
||||
return values[message_type]
|
||||
|
||||
|
||||
def _checklist(action: ModelingAction) -> PhysicalAcceptanceChecklist:
|
||||
return PhysicalAcceptanceChecklist(
|
||||
action=action,
|
||||
|
|
@ -243,14 +232,7 @@ def test_canonical_session_owns_start_active_scan_stop_and_save_boundary() -> No
|
|||
owner_token=object(),
|
||||
),
|
||||
)
|
||||
assert [len(batch) for batch in transport.batches] == [1, 5]
|
||||
assert transport.batches[1] == (
|
||||
"bootstrap:2:ModelingStatusRequest",
|
||||
"bootstrap:3:GetRtkAdvanceRequest",
|
||||
"bootstrap:4:DeviceConfigRequest",
|
||||
"bootstrap:5:DeviceInfoRequest",
|
||||
"bootstrap:6:GetRtkAdvanceRequest",
|
||||
)
|
||||
assert [len(batch) for batch in transport.batches] == [1, 2, 3]
|
||||
workspace_checks = iter((False, False, True))
|
||||
workspace_checkpoint = executor.wait_for_operator_checkpoint(
|
||||
"workspace-entered",
|
||||
|
|
@ -331,11 +313,14 @@ def test_canonical_session_owns_start_active_scan_stop_and_save_boundary() -> No
|
|||
)
|
||||
)
|
||||
stop_response = executor.execute_canonical_stop(stop_command, stop_permit)
|
||||
executor.maintain_post_stop_until_standby()
|
||||
standby_checks = iter((False, False, True))
|
||||
executor.maintain_post_stop_until_standby_confirmed(
|
||||
lambda: next(standby_checks)
|
||||
)
|
||||
|
||||
assert stop_response.action is ModelingAction.STOP
|
||||
assert [len(batch) for batch in transport.batches] == [1, 5, 1, 3, 1, 1, 2, 1]
|
||||
assert len(transport.maintain_calls) == 212
|
||||
assert [len(batch) for batch in transport.batches] == [1, 2, 3, 1, 3, 1, 1, 2, 1]
|
||||
assert len(transport.maintain_calls) == 214
|
||||
assert set(transport.maintain_calls) == {
|
||||
(1.0, ("lixel/application/response/modeling_status",))
|
||||
}
|
||||
|
|
@ -347,120 +332,13 @@ def test_canonical_session_owns_start_active_scan_stop_and_save_boundary() -> No
|
|||
assert executor.snapshot()["stop_complete"] is True
|
||||
assert executor.snapshot()["dialogue_stage"] == "standby-confirmed"
|
||||
response_evidence = executor.snapshot()["response_evidence"]
|
||||
assert isinstance(response_evidence, list)
|
||||
assert isinstance(response_evidence, tuple)
|
||||
assert len(response_evidence) == 13
|
||||
assert response_evidence[0]["operation_key"] == "bootstrap:1:DeviceInfoRequest"
|
||||
assert response_evidence[-1]["operation_key"] == "modeling:stop"
|
||||
assert all("payload" not in item for item in response_evidence)
|
||||
assert APPLICATION_KEY not in str(executor.snapshot())
|
||||
assert VENDOR_DEVICE_ID not in str(executor.snapshot())
|
||||
TypeAdapter(JsonValue).validate_python(executor.snapshot())
|
||||
|
||||
|
||||
def test_start_initialization_wait_has_fail_closed_watchdog() -> None:
|
||||
class NeverInitializedTransport(SyntheticAcceptanceTransport):
|
||||
def scan_initialization_complete(self, _binding: object) -> bool:
|
||||
return False
|
||||
|
||||
clock = FakeClock()
|
||||
transport = NeverInitializedTransport(clock)
|
||||
executor = PhysicalAcceptanceDialogueExecutor(
|
||||
transport,
|
||||
monotonic=clock,
|
||||
scan_initialization_timeout_seconds=2.0,
|
||||
)
|
||||
authority = ApplicationControlAuthority(openapi_key=APPLICATION_KEY)
|
||||
orchestrator = ShadowApplicationBootstrapOrchestrator(
|
||||
authority,
|
||||
epoch_seconds=1_752_680_000,
|
||||
timezone_name="Europe/Moscow",
|
||||
)
|
||||
binding = executor.run_connection_stage(orchestrator)
|
||||
executor.run_workspace_entry_stage(
|
||||
orchestrator,
|
||||
executor.wait_for_operator_checkpoint("workspace-entered", lambda: True),
|
||||
)
|
||||
executor.run_project_prompt_stage(
|
||||
orchestrator,
|
||||
executor.wait_for_operator_checkpoint("project-prompt-opened", lambda: True),
|
||||
)
|
||||
start_checkpoint = executor.wait_for_operator_checkpoint(
|
||||
"start-confirmed",
|
||||
lambda: True,
|
||||
)
|
||||
command = ShadowModelingCommand.from_command(
|
||||
encode_modeling_start(
|
||||
CommandHeaderIdentity(
|
||||
device_id=binding.vendor_device_id,
|
||||
openapi_key=APPLICATION_KEY,
|
||||
),
|
||||
project_name="SAFE_PROJECT",
|
||||
record_mode=RecordMode.RECORD_AND_CALCULATE,
|
||||
scan_mode=ScanMode.LCC,
|
||||
mount_type=MountType.HANDHELD,
|
||||
)
|
||||
)
|
||||
permit = PhysicalAcceptancePermit(
|
||||
_checklist(ModelingAction.START),
|
||||
monotonic=clock,
|
||||
)
|
||||
|
||||
with pytest.raises(ScanInitializationTimeout):
|
||||
executor.execute_canonical_start(
|
||||
command,
|
||||
build_canonical_post_start_observation(authority, binding),
|
||||
authority=authority,
|
||||
binding=binding,
|
||||
permit=permit,
|
||||
checkpoint=start_checkpoint,
|
||||
)
|
||||
|
||||
assert transport.start_emitted
|
||||
assert not transport.stop_emitted
|
||||
assert executor.snapshot()["dialogue_stage"] == "initializing"
|
||||
assert executor.snapshot()["start_attempted"] is True
|
||||
assert executor.snapshot()["start_complete"] is False
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("device_model", "device_type"),
|
||||
(
|
||||
("LixelKity K2", "A4"),
|
||||
("LixelKity K1", "K1"),
|
||||
),
|
||||
)
|
||||
def test_connection_stage_rejects_other_device_model_or_type_before_preparation(
|
||||
device_model: str,
|
||||
device_type: str,
|
||||
) -> None:
|
||||
transport = SyntheticAcceptanceTransport(
|
||||
device_model=device_model,
|
||||
device_type=device_type,
|
||||
)
|
||||
executor = PhysicalAcceptanceDialogueExecutor(transport)
|
||||
orchestrator = ShadowApplicationBootstrapOrchestrator(
|
||||
ApplicationControlAuthority(openapi_key=APPLICATION_KEY),
|
||||
epoch_seconds=1_752_680_000,
|
||||
timezone_name="Europe/Moscow",
|
||||
)
|
||||
|
||||
with pytest.raises(ApplicationAcceptanceError, match="incompatible with the selected"):
|
||||
executor.run_connection_stage(orchestrator)
|
||||
|
||||
assert transport.batches == [("bootstrap:1:DeviceInfoRequest",)]
|
||||
assert not transport.start_emitted
|
||||
assert orchestrator.binding is None
|
||||
snapshot = executor.snapshot()
|
||||
assert snapshot["correlation_failure"] is None
|
||||
compatibility_failure = snapshot["compatibility_failure"]
|
||||
assert isinstance(compatibility_failure, dict)
|
||||
assert compatibility_failure["reason_code"] == "compatibility_profile_mismatch"
|
||||
assert compatibility_failure["expected"] == {
|
||||
"device_model": "LixelKity K1",
|
||||
"platform_type": "A4",
|
||||
"firmware": "3.0.2",
|
||||
"is_activated": True,
|
||||
}
|
||||
|
||||
|
||||
def test_bootstrap_correlation_failure_records_only_redacted_response_evidence() -> None:
|
||||
|
|
@ -469,15 +347,14 @@ def test_bootstrap_correlation_failure_records_only_redacted_response_evidence()
|
|||
self,
|
||||
envelopes: Sequence[OneShotPublishEnvelope],
|
||||
*,
|
||||
required_response_operation_keys: Collection[str],
|
||||
required_response_topics: Collection[str],
|
||||
) -> dict[str, bytes]:
|
||||
responses = super().exchange_batch_once(
|
||||
envelopes,
|
||||
required_response_operation_keys=required_response_operation_keys,
|
||||
required_response_topics=required_response_topics,
|
||||
)
|
||||
operation_key = "bootstrap:4:DeviceConfigRequest"
|
||||
if operation_key in responses:
|
||||
responses[operation_key] = _generic_response(
|
||||
if DEVICE_CONFIG_RESPONSE_TOPIC in responses:
|
||||
responses[DEVICE_CONFIG_RESPONSE_TOPIC] = _generic_response(
|
||||
f"{VENDOR_DEVICE_ID}:wrong-session"
|
||||
)
|
||||
return responses
|
||||
|
|
@ -503,11 +380,10 @@ def test_bootstrap_correlation_failure_records_only_redacted_response_evidence()
|
|||
"phase": "bootstrap",
|
||||
"operation_key": "bootstrap:4:DeviceConfigRequest",
|
||||
"response_topic": DEVICE_CONFIG_RESPONSE_TOPIC,
|
||||
"reason_code": "response_session_mismatch",
|
||||
"reason": "application response session correlation failed",
|
||||
}
|
||||
evidence = snapshot["response_evidence"]
|
||||
assert isinstance(evidence, list)
|
||||
assert isinstance(evidence, tuple)
|
||||
assert evidence[-1]["payload_sha256"] == hashlib.sha256(
|
||||
_generic_response(f"{VENDOR_DEVICE_ID}:wrong-session")
|
||||
).hexdigest()
|
||||
|
|
@ -515,7 +391,6 @@ def test_bootstrap_correlation_failure_records_only_redacted_response_evidence()
|
|||
assert all("payload" not in item for item in evidence)
|
||||
assert APPLICATION_KEY not in str(snapshot)
|
||||
assert VENDOR_DEVICE_ID not in str(snapshot)
|
||||
TypeAdapter(JsonValue).validate_python(snapshot)
|
||||
|
||||
|
||||
def test_standalone_start_and_stop_are_both_disabled() -> None:
|
||||
|
|
|
|||
|
|
@ -16,23 +16,6 @@ from k1link.device_plugins.xgrids_k1.protocol.application_authority import (
|
|||
PRIVATE_AUTHORITY = b"11111111-2222-3333-4444-555555555555\n"
|
||||
|
||||
|
||||
@patch("platform.system", return_value="Darwin")
|
||||
def test_runtime_authority_loads_through_security_framework_without_subprocess(
|
||||
_system: object,
|
||||
) -> None:
|
||||
calls: list[tuple[str, str]] = []
|
||||
|
||||
def framework_reader(*, service: str, account: str) -> bytes:
|
||||
calls.append((service, account))
|
||||
return PRIVATE_AUTHORITY
|
||||
|
||||
loader = MacOSKeychainApplicationAuthorityLoader(framework_reader=framework_reader)
|
||||
authority = loader.load()
|
||||
|
||||
assert calls == [(KEYCHAIN_SERVICE, KEYCHAIN_ACCOUNT)]
|
||||
assert PRIVATE_AUTHORITY.decode().strip() not in repr(authority)
|
||||
|
||||
|
||||
@patch("platform.system", return_value="Darwin")
|
||||
@patch("shutil.which", return_value="/usr/bin/security")
|
||||
def test_authority_loads_only_from_fixed_macos_keychain_item(
|
||||
|
|
@ -128,21 +111,6 @@ def test_authority_loader_rejects_wrong_profile_length_without_reflection(
|
|||
assert invalid_secret.decode() not in str(error.value)
|
||||
|
||||
|
||||
@patch("platform.system", return_value="Darwin")
|
||||
def test_framework_authority_failure_is_redacted(_system: object) -> None:
|
||||
private_error = "private-framework-diagnostic"
|
||||
|
||||
def framework_reader(*, service: str, account: str) -> bytes:
|
||||
assert service == KEYCHAIN_SERVICE
|
||||
assert account == KEYCHAIN_ACCOUNT
|
||||
raise RuntimeError(private_error)
|
||||
|
||||
with pytest.raises(ApplicationAuthorityLoadError) as error:
|
||||
MacOSKeychainApplicationAuthorityLoader(framework_reader=framework_reader).load()
|
||||
|
||||
assert private_error not in str(error.value)
|
||||
|
||||
|
||||
@patch("sys.stdout.isatty", return_value=True)
|
||||
@patch("sys.stdin.isatty", return_value=True)
|
||||
@patch("platform.system", return_value="Darwin")
|
||||
|
|
|
|||
|
|
@ -1,8 +1,5 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from k1link.device_plugins.xgrids_k1.protocol.application_bootstrap import (
|
||||
|
|
@ -19,14 +16,6 @@ from k1link.device_plugins.xgrids_k1.protocol.protobuf_wire import iter_fields
|
|||
|
||||
APPLICATION_KEY = "11111111-2222-3333-4444-555555555555"
|
||||
VENDOR_DEVICE_ID = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"
|
||||
CAPTURE_FACTS = json.loads(
|
||||
(
|
||||
Path(__file__).parent
|
||||
/ "fixtures"
|
||||
/ "xgrids_k1"
|
||||
/ "device_info_fw3_0_2_semantics.json"
|
||||
).read_text(encoding="utf-8")
|
||||
)
|
||||
|
||||
|
||||
def _varint(value: int) -> bytes:
|
||||
|
|
@ -58,11 +47,11 @@ def _binding(**changes: object) -> LiveDeviceControlBinding:
|
|||
values = {
|
||||
"vendor_device_id": VENDOR_DEVICE_ID,
|
||||
"device_serial": "K1SERIAL01",
|
||||
"software_version": CAPTURE_FACTS["software_version"],
|
||||
"system_version": CAPTURE_FACTS["system_version"],
|
||||
"device_model": CAPTURE_FACTS["device_model"],
|
||||
"device_type": CAPTURE_FACTS["platform_type"],
|
||||
"is_activated": CAPTURE_FACTS["is_activated"],
|
||||
"software_version": "V3.0.2-20260101-release",
|
||||
"system_version": "V3.0.2",
|
||||
"device_model": "LixelKity K1",
|
||||
"device_type": "K1",
|
||||
"is_activated": True,
|
||||
}
|
||||
values.update(changes)
|
||||
return LiveDeviceControlBinding(**values) # type: ignore[arg-type]
|
||||
|
|
@ -73,13 +62,13 @@ def _device_info_response(*, session_id: str = ":DeviceInfoRequest") -> bytes:
|
|||
base_info = b"".join(
|
||||
(
|
||||
_text(1, "2026-01-01T00:00:00"),
|
||||
_text(2, CAPTURE_FACTS["software_version"]),
|
||||
_text(3, CAPTURE_FACTS["system_version"]),
|
||||
_text(2, "V3.0.2-20260101-release"),
|
||||
_text(3, "V3.0.2"),
|
||||
_text(4, "V1.2.3"),
|
||||
_text(5, "V1.0"),
|
||||
_text(6, CAPTURE_FACTS["device_model"]),
|
||||
_text(6, "LixelKity K1"),
|
||||
_text(7, "K1SERIAL01"),
|
||||
_text(8, CAPTURE_FACTS["platform_type"]),
|
||||
_text(8, "K1"),
|
||||
)
|
||||
)
|
||||
working_status = _uint(1, 1) + _uint(2, 0)
|
||||
|
|
@ -160,27 +149,6 @@ def test_bootstrap_headers_switch_from_unbound_to_live_device_identity() -> None
|
|||
)
|
||||
|
||||
|
||||
def test_device_config_timestamp_uses_captured_sint64_zigzag_wire_semantics() -> None:
|
||||
captured_epoch_seconds = 1_784_215_030
|
||||
plan = build_shadow_bootstrap(
|
||||
_authority(),
|
||||
_binding(),
|
||||
epoch_seconds=captured_epoch_seconds,
|
||||
timezone_name="Europe/Moscow",
|
||||
)
|
||||
|
||||
config_request = plan.requests[3]
|
||||
top_fields = {field.number: field.value for field in iter_fields(config_request.payload)}
|
||||
time_config = top_fields[4]
|
||||
assert isinstance(time_config, bytes)
|
||||
time_fields = {field.number: field.value for field in iter_fields(time_config)}
|
||||
|
||||
assert time_fields == {
|
||||
1: 3_568_430_060,
|
||||
2: b"Europe/Moscow",
|
||||
}
|
||||
|
||||
|
||||
def test_canonical_post_start_observation_preserves_retained_operations_12_to_14() -> None:
|
||||
plan = build_canonical_post_start_observation(_authority(), _binding())
|
||||
|
||||
|
|
@ -201,7 +169,6 @@ def test_device_info_response_produces_live_binding_without_a_saved_device_profi
|
|||
|
||||
assert response.result_code == OPENAPI_SUCCESS
|
||||
assert response.binding.ready_for_reviewed_profile
|
||||
assert response.binding.matches_reviewed_device
|
||||
assert response.binding.matches_reviewed_firmware
|
||||
assert response.binding.is_activated
|
||||
assert VENDOR_DEVICE_ID not in repr(response)
|
||||
|
|
@ -214,6 +181,7 @@ def test_device_info_response_correlation_and_profile_attestation_fail_closed()
|
|||
_device_info_response(session_id=":AnotherRequest"),
|
||||
_authority(),
|
||||
)
|
||||
|
||||
with pytest.raises(ApplicationBootstrapError, match="does not attest"):
|
||||
build_shadow_bootstrap(
|
||||
_authority(),
|
||||
|
|
@ -223,37 +191,6 @@ def test_device_info_response_correlation_and_profile_attestation_fail_closed()
|
|||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("device_model", "device_type"),
|
||||
(
|
||||
("lixelkity K1", "A4"),
|
||||
("LixelKity K1 ", "A4"),
|
||||
("LixelKity K11", "A4"),
|
||||
("LixelKity K1", "a4"),
|
||||
("LixelKity K1", " A4"),
|
||||
("LixelKity K1", "K1"),
|
||||
),
|
||||
)
|
||||
def test_reviewed_profile_rejects_non_exact_device_model_and_type(
|
||||
device_model: str,
|
||||
device_type: str,
|
||||
) -> None:
|
||||
binding = _binding(device_model=device_model, device_type=device_type)
|
||||
|
||||
assert binding.matches_reviewed_firmware
|
||||
assert not binding.matches_reviewed_device
|
||||
assert not binding.ready_for_reviewed_profile
|
||||
with pytest.raises(ApplicationBootstrapError, match="does not attest"):
|
||||
build_shadow_bootstrap(
|
||||
_authority(),
|
||||
binding,
|
||||
epoch_seconds=1_752_680_000,
|
||||
timezone_name="Europe/Moscow",
|
||||
)
|
||||
with pytest.raises(ApplicationBootstrapError, match="does not attest"):
|
||||
build_canonical_post_start_observation(_authority(), binding)
|
||||
|
||||
|
||||
def test_response_barrier_orchestrator_emits_each_retained_batch_once() -> None:
|
||||
orchestrator = ShadowApplicationBootstrapOrchestrator(
|
||||
_authority(),
|
||||
|
|
@ -265,37 +202,35 @@ def test_response_barrier_orchestrator_emits_each_retained_batch_once() -> None:
|
|||
assert [request.ordinal for request in identity] == [1]
|
||||
with pytest.raises(ApplicationBootstrapError, match="already issued"):
|
||||
orchestrator.next_batch()
|
||||
orchestrator.accept_response(
|
||||
"bootstrap:1:DeviceInfoRequest",
|
||||
_device_info_response(),
|
||||
)
|
||||
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, 4, 5, 6]
|
||||
assert [request.response_required for request in initial_reads] == [
|
||||
False,
|
||||
True,
|
||||
True,
|
||||
True,
|
||||
True,
|
||||
]
|
||||
device_info = initial_reads[3]
|
||||
assert [request.ordinal for request in initial_reads] == [2, 3]
|
||||
assert [request.response_required for request in initial_reads] == [False, True]
|
||||
orchestrator.accept_response(
|
||||
"bootstrap:5:DeviceInfoRequest",
|
||||
initial_reads[1].response_topic,
|
||||
_generic_response(initial_reads[1].session_id),
|
||||
)
|
||||
|
||||
preparation = orchestrator.next_batch()
|
||||
assert [request.ordinal for request in preparation] == [4, 5, 6]
|
||||
device_info = preparation[1]
|
||||
orchestrator.accept_response(
|
||||
device_info.response_topic,
|
||||
_device_info_response(session_id=device_info.session_id),
|
||||
)
|
||||
with pytest.raises(ApplicationBootstrapError, match="already issued"):
|
||||
orchestrator.next_batch()
|
||||
for request in (initial_reads[4], initial_reads[1], initial_reads[2]):
|
||||
for request in (preparation[2], preparation[0]):
|
||||
orchestrator.accept_response(
|
||||
f"bootstrap:{request.ordinal}:{request.message_type}",
|
||||
request.response_topic,
|
||||
_generic_response(request.session_id),
|
||||
)
|
||||
|
||||
ntrip = orchestrator.next_batch()
|
||||
assert [request.ordinal for request in ntrip] == [7]
|
||||
orchestrator.accept_response(
|
||||
"bootstrap:7:GetNtripProfileRequest",
|
||||
ntrip[0].response_topic,
|
||||
_generic_response(ntrip[0].session_id),
|
||||
)
|
||||
|
||||
|
|
@ -307,10 +242,7 @@ def test_response_barrier_orchestrator_emits_each_retained_batch_once() -> None:
|
|||
if request.message_type == "DeviceInfoRequest"
|
||||
else _generic_response(request.session_id)
|
||||
)
|
||||
orchestrator.accept_response(
|
||||
f"bootstrap:{request.ordinal}:{request.message_type}",
|
||||
payload,
|
||||
)
|
||||
orchestrator.accept_response(request.response_topic, payload)
|
||||
|
||||
snapshot = orchestrator.snapshot()
|
||||
assert snapshot.bootstrap_complete
|
||||
|
|
@ -332,7 +264,7 @@ def test_orchestrator_rejects_unexpected_response_without_advancing() -> None:
|
|||
|
||||
with pytest.raises(ApplicationBootstrapError, match="not required"):
|
||||
orchestrator.accept_response(
|
||||
"bootstrap:3:GetRtkAdvanceRequest",
|
||||
"lixel/application/response/get_rtk_advance",
|
||||
_generic_response(":GetRtkAdvanceRequest"),
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -10,14 +10,11 @@ import pytest
|
|||
|
||||
from k1link.device_plugins.xgrids_k1.protocol.application_bootstrap import (
|
||||
DEVICE_INFO_RESPONSE_TOPIC,
|
||||
GET_RTK_ADVANCE_REQUEST_TOPIC,
|
||||
GET_RTK_ADVANCE_RESPONSE_TOPIC,
|
||||
MODELING_STATUS_RESPONSE_TOPIC,
|
||||
LiveDeviceControlBinding,
|
||||
)
|
||||
from k1link.device_plugins.xgrids_k1.protocol.application_mqtt import (
|
||||
CONTROL_SUBSCRIPTION_GROUPS,
|
||||
MODELING_RESPONSE_TOPIC,
|
||||
SYSTEM_ERROR_TOPIC,
|
||||
ApplicationCommandOutcomeUnknown,
|
||||
ApplicationControlDeviceFault,
|
||||
|
|
@ -29,17 +26,12 @@ from k1link.device_plugins.xgrids_k1.protocol.application_publish import (
|
|||
from k1link.device_plugins.xgrids_k1.protocol.modeling_control import (
|
||||
MODELING_STATE_BASE,
|
||||
SYSTEM_ERROR_STATE_BASE,
|
||||
ModelingAction,
|
||||
SessionState,
|
||||
)
|
||||
from k1link.device_plugins.xgrids_k1.protocol.modeling_safety import (
|
||||
DEVICE_STATUS_TOPIC,
|
||||
MODELING_REQUEST_TOPIC,
|
||||
)
|
||||
from k1link.device_plugins.xgrids_k1.protocol.modeling_safety import DEVICE_STATUS_TOPIC
|
||||
|
||||
VENDOR_DEVICE_ID = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"
|
||||
DEVICE_SERIAL = "K1SERIAL01"
|
||||
APPLICATION_KEY = "11111111-2222-3333-4444-555555555555"
|
||||
|
||||
|
||||
class FakeReasonCode:
|
||||
|
|
@ -78,7 +70,6 @@ class FakeControlClient:
|
|||
self.unsubscribe_calls: list[list[str]] = []
|
||||
self.disconnect_calls = 0
|
||||
self.next_mid = 20
|
||||
self.connect_timeout = 5.0
|
||||
|
||||
def connect(self, host: str, port: int, keepalive: int) -> mqtt.MQTTErrorCode:
|
||||
self.connect_calls.append((host, port, keepalive))
|
||||
|
|
@ -103,7 +94,7 @@ class FakeControlClient:
|
|||
self.next_mid += 1
|
||||
if self.emit_exchange:
|
||||
self.events.append(("publish", mid))
|
||||
self.events.append(("message", (self.response_topic, payload)))
|
||||
self.events.append(("message", (self.response_topic, b"response")))
|
||||
return SimpleNamespace(rc=mqtt.MQTT_ERR_SUCCESS, mid=mid)
|
||||
|
||||
def loop(self, timeout: float) -> mqtt.MQTTErrorCode:
|
||||
|
|
@ -140,32 +131,11 @@ class FakeControlClient:
|
|||
return mqtt.MQTT_ERR_SUCCESS
|
||||
|
||||
|
||||
def _application_message(
|
||||
session_id: str,
|
||||
*,
|
||||
device_id: str | None = None,
|
||||
) -> bytes:
|
||||
header = b"".join(
|
||||
(
|
||||
_text(4, device_id) if device_id is not None else b"",
|
||||
_text(5, session_id),
|
||||
_text(6, APPLICATION_KEY),
|
||||
)
|
||||
)
|
||||
return _bytes(1, header)
|
||||
|
||||
|
||||
def _envelope(
|
||||
operation_key: str = "bootstrap:1:DeviceInfoRequest",
|
||||
*,
|
||||
topic: str = "lixel/application/request/device_info",
|
||||
session_id: str = ":DeviceInfoRequest",
|
||||
device_id: str | None = None,
|
||||
) -> OneShotPublishEnvelope:
|
||||
payload = _application_message(session_id, device_id=device_id)
|
||||
def _envelope(operation_key: str = "bootstrap:1:DeviceInfoRequest") -> OneShotPublishEnvelope:
|
||||
payload = b"synthetic-reviewed-request"
|
||||
return OneShotPublishEnvelope(
|
||||
operation_key=operation_key,
|
||||
topic=topic,
|
||||
topic="lixel/application/request/device_info",
|
||||
payload=payload,
|
||||
payload_sha256=hashlib.sha256(payload).hexdigest(),
|
||||
payload_bytes=len(payload),
|
||||
|
|
@ -174,15 +144,10 @@ def _envelope(
|
|||
)
|
||||
|
||||
|
||||
def _modeling_status_envelope(
|
||||
operation_key: str = "dialogue:12:ModelingStatusRequest",
|
||||
) -> OneShotPublishEnvelope:
|
||||
payload = _application_message(
|
||||
f"{VENDOR_DEVICE_ID}:ModelingStatusRequest",
|
||||
device_id=VENDOR_DEVICE_ID,
|
||||
)
|
||||
def _modeling_status_envelope() -> OneShotPublishEnvelope:
|
||||
payload = b"synthetic-reviewed-modeling-status"
|
||||
return OneShotPublishEnvelope(
|
||||
operation_key=operation_key,
|
||||
operation_key="dialogue:12:ModelingStatusRequest",
|
||||
topic="lixel/application/request/modeling_status",
|
||||
payload=payload,
|
||||
payload_sha256=hashlib.sha256(payload).hexdigest(),
|
||||
|
|
@ -192,33 +157,6 @@ def _modeling_status_envelope(
|
|||
)
|
||||
|
||||
|
||||
def _modeling_envelope(action: ModelingAction) -> OneShotPublishEnvelope:
|
||||
payload = _application_message(
|
||||
f"{VENDOR_DEVICE_ID}:ModelingRequest",
|
||||
device_id=VENDOR_DEVICE_ID,
|
||||
) + _uint(2, action)
|
||||
return OneShotPublishEnvelope(
|
||||
operation_key=f"modeling:{action.name.casefold()}",
|
||||
topic=MODELING_REQUEST_TOPIC,
|
||||
payload=payload,
|
||||
payload_sha256=hashlib.sha256(payload).hexdigest(),
|
||||
payload_bytes=len(payload),
|
||||
qos=2,
|
||||
retain=False,
|
||||
)
|
||||
|
||||
|
||||
def _modeling_response(action: ModelingAction) -> bytes:
|
||||
return (
|
||||
_application_message(
|
||||
f"{VENDOR_DEVICE_ID}:ModelingRequest",
|
||||
device_id=VENDOR_DEVICE_ID,
|
||||
)
|
||||
+ _uint(2, action)
|
||||
+ _bytes(15, _uint(1, 0))
|
||||
)
|
||||
|
||||
|
||||
def _varint(value: int) -> bytes:
|
||||
encoded = bytearray()
|
||||
while value > 0x7F:
|
||||
|
|
@ -265,7 +203,7 @@ def _binding() -> LiveDeviceControlBinding:
|
|||
software_version="V3.0.2-20260101-release",
|
||||
system_version="V3.0.2",
|
||||
device_model="LixelKity K1",
|
||||
device_type="A4",
|
||||
device_type="K1",
|
||||
is_activated=True,
|
||||
)
|
||||
|
||||
|
|
@ -296,24 +234,21 @@ def test_acceptance_transport_connects_once_and_completes_one_qos2_exchange() ->
|
|||
opened = transport.open().as_dict()
|
||||
responses = transport.exchange_batch_once(
|
||||
[_envelope()],
|
||||
required_response_operation_keys={"bootstrap:1:DeviceInfoRequest"},
|
||||
required_response_topics={DEVICE_INFO_RESPONSE_TOPIC},
|
||||
)
|
||||
transport.close()
|
||||
|
||||
assert fake.connect_calls == [("192.168.1.20", 1883, 60)]
|
||||
assert fake.connect_timeout == 10.0
|
||||
assert fake.subscribe_calls == [list(group) for group in CONTROL_SUBSCRIPTION_GROUPS]
|
||||
assert fake.publish_calls == [
|
||||
(
|
||||
"lixel/application/request/device_info",
|
||||
_application_message(":DeviceInfoRequest"),
|
||||
b"synthetic-reviewed-request",
|
||||
2,
|
||||
False,
|
||||
)
|
||||
]
|
||||
assert responses == {
|
||||
"bootstrap:1:DeviceInfoRequest": _application_message(":DeviceInfoRequest")
|
||||
}
|
||||
assert responses == {DEVICE_INFO_RESPONSE_TOPIC: b"response"}
|
||||
assert opened["clean_session"] is False
|
||||
assert opened["automatic_reconnect"] is False
|
||||
assert opened["automatic_retry"] is False
|
||||
|
|
@ -335,325 +270,18 @@ def test_consumed_operation_key_can_never_be_published_again() -> None:
|
|||
transport.open()
|
||||
transport.exchange_batch_once(
|
||||
[_envelope()],
|
||||
required_response_operation_keys={"bootstrap:1:DeviceInfoRequest"},
|
||||
required_response_topics={DEVICE_INFO_RESPONSE_TOPIC},
|
||||
)
|
||||
|
||||
with pytest.raises(ApplicationCommandOutcomeUnknown, match="already consumed"):
|
||||
transport.exchange_batch_once(
|
||||
[_envelope()],
|
||||
required_response_operation_keys={"bootstrap:1:DeviceInfoRequest"},
|
||||
required_response_topics={DEVICE_INFO_RESPONSE_TOPIC},
|
||||
)
|
||||
|
||||
assert len(fake.publish_calls) == 1
|
||||
|
||||
|
||||
def test_same_topic_inflight_responses_are_routed_by_exact_session_without_retry() -> None:
|
||||
fake = FakeControlClient(response_topic=GET_RTK_ADVANCE_RESPONSE_TOPIC)
|
||||
transport = ReviewedApplicationMqttTransport(
|
||||
"192.168.1.20",
|
||||
client_factory=lambda: cast(mqtt.Client, fake),
|
||||
)
|
||||
transport.open()
|
||||
unbound = _envelope(
|
||||
"bootstrap:3:GetRtkAdvanceRequest",
|
||||
topic=GET_RTK_ADVANCE_REQUEST_TOPIC,
|
||||
session_id=":GetRtkAdvanceRequest",
|
||||
)
|
||||
bound_session = f"{VENDOR_DEVICE_ID}:GetRtkAdvanceRequest"
|
||||
bound = _envelope(
|
||||
"bootstrap:6:GetRtkAdvanceRequest",
|
||||
topic=GET_RTK_ADVANCE_REQUEST_TOPIC,
|
||||
session_id=bound_session,
|
||||
device_id=VENDOR_DEVICE_ID,
|
||||
)
|
||||
|
||||
responses = transport.exchange_batch_once(
|
||||
[unbound, bound],
|
||||
required_response_operation_keys={unbound.operation_key, bound.operation_key},
|
||||
)
|
||||
|
||||
assert responses[unbound.operation_key] == _application_message(
|
||||
":GetRtkAdvanceRequest"
|
||||
)
|
||||
assert responses[bound.operation_key] == _application_message(
|
||||
bound_session,
|
||||
device_id=VENDOR_DEVICE_ID,
|
||||
)
|
||||
snapshot = transport.snapshot()
|
||||
assert snapshot.publish_attempts == 2
|
||||
assert snapshot.qos2_completions == 2
|
||||
assert snapshot.correlated_responses == 2
|
||||
assert snapshot.late_known_responses == 0
|
||||
assert snapshot.automatic_retry is False
|
||||
assert len(fake.publish_calls) == 2
|
||||
|
||||
|
||||
def test_duplicate_start_response_blocks_stop_before_stop_is_published() -> None:
|
||||
class ModelingControlClient(FakeControlClient):
|
||||
def __init__(self) -> None:
|
||||
super().__init__(
|
||||
emit_exchange=False,
|
||||
response_topic=MODELING_RESPONSE_TOPIC,
|
||||
)
|
||||
|
||||
def publish(
|
||||
self,
|
||||
topic: str,
|
||||
payload: bytes,
|
||||
qos: int,
|
||||
retain: bool,
|
||||
) -> SimpleNamespace:
|
||||
info = super().publish(topic, payload, qos, retain)
|
||||
action = (
|
||||
ModelingAction.START
|
||||
if len(self.publish_calls) == 1
|
||||
else ModelingAction.STOP
|
||||
)
|
||||
self.events.append(("publish", info.mid))
|
||||
self.events.append(
|
||||
(
|
||||
"message",
|
||||
(MODELING_RESPONSE_TOPIC, _modeling_response(action)),
|
||||
)
|
||||
)
|
||||
return info
|
||||
|
||||
fake = ModelingControlClient()
|
||||
transport = ReviewedApplicationMqttTransport(
|
||||
"192.168.1.20",
|
||||
client_factory=lambda: cast(mqtt.Client, fake),
|
||||
)
|
||||
transport.open()
|
||||
|
||||
transport.exchange_batch_once(
|
||||
[_modeling_envelope(ModelingAction.START)],
|
||||
required_response_operation_keys={"modeling:start"},
|
||||
)
|
||||
fake.events.append(
|
||||
(
|
||||
"message",
|
||||
(
|
||||
MODELING_RESPONSE_TOPIC,
|
||||
_modeling_response(ModelingAction.START),
|
||||
),
|
||||
)
|
||||
)
|
||||
with pytest.raises(ApplicationCommandOutcomeUnknown, match="duplicate application response"):
|
||||
transport.exchange_batch_once(
|
||||
[_modeling_envelope(ModelingAction.STOP)],
|
||||
required_response_operation_keys={"modeling:stop"},
|
||||
)
|
||||
|
||||
snapshot = transport.snapshot()
|
||||
assert snapshot.publish_attempts == 1
|
||||
assert snapshot.correlated_responses == 1
|
||||
assert snapshot.late_known_responses == 0
|
||||
assert snapshot.state == "poisoned"
|
||||
assert len(fake.publish_calls) == 1
|
||||
|
||||
|
||||
def test_unanswered_optional_status_does_not_steal_same_identity_required_refresh() -> None:
|
||||
class ReusedStatusClient(FakeControlClient):
|
||||
def __init__(self) -> None:
|
||||
super().__init__(
|
||||
emit_exchange=False,
|
||||
response_topic=MODELING_STATUS_RESPONSE_TOPIC,
|
||||
)
|
||||
|
||||
def publish(
|
||||
self,
|
||||
topic: str,
|
||||
payload: bytes,
|
||||
qos: int,
|
||||
retain: bool,
|
||||
) -> SimpleNamespace:
|
||||
info = super().publish(topic, payload, qos, retain)
|
||||
self.events.append(("publish", info.mid))
|
||||
if len(self.publish_calls) == 2:
|
||||
# Ordinals 12 and 14 intentionally reuse the same protocol
|
||||
# identity. The capture has only one response after ordinal
|
||||
# 14; unanswered optional ordinal 12 must not swallow it.
|
||||
self.events.append(
|
||||
("message", (MODELING_STATUS_RESPONSE_TOPIC, payload))
|
||||
)
|
||||
return info
|
||||
|
||||
fake = ReusedStatusClient()
|
||||
transport = ReviewedApplicationMqttTransport(
|
||||
"192.168.1.20",
|
||||
client_factory=lambda: cast(mqtt.Client, fake),
|
||||
)
|
||||
transport.open()
|
||||
immediate = _modeling_status_envelope()
|
||||
refresh = _modeling_status_envelope("dialogue:14:ModelingStatusRequest")
|
||||
|
||||
transport.exchange_batch_once(
|
||||
[immediate],
|
||||
required_response_operation_keys=(),
|
||||
)
|
||||
responses = transport.exchange_batch_once(
|
||||
[refresh],
|
||||
required_response_operation_keys={refresh.operation_key},
|
||||
)
|
||||
|
||||
assert responses[refresh.operation_key] == refresh.payload
|
||||
snapshot = transport.snapshot()
|
||||
assert snapshot.publish_attempts == 2
|
||||
assert snapshot.correlated_responses == 1
|
||||
assert snapshot.ignored_known_responses == 0
|
||||
assert snapshot.late_known_responses == 0
|
||||
|
||||
|
||||
def test_already_arrived_optional_status_is_consumed_before_required_refresh() -> None:
|
||||
class ReusedStatusClient(FakeControlClient):
|
||||
def __init__(self) -> None:
|
||||
super().__init__(
|
||||
emit_exchange=False,
|
||||
response_topic=MODELING_STATUS_RESPONSE_TOPIC,
|
||||
)
|
||||
|
||||
def publish(
|
||||
self,
|
||||
topic: str,
|
||||
payload: bytes,
|
||||
qos: int,
|
||||
retain: bool,
|
||||
) -> SimpleNamespace:
|
||||
info = super().publish(topic, payload, qos, retain)
|
||||
self.events.append(("publish", info.mid))
|
||||
if len(self.publish_calls) == 2:
|
||||
self.events.append(
|
||||
("message", (MODELING_STATUS_RESPONSE_TOPIC, payload))
|
||||
)
|
||||
return info
|
||||
|
||||
fake = ReusedStatusClient()
|
||||
transport = ReviewedApplicationMqttTransport(
|
||||
"192.168.1.20",
|
||||
client_factory=lambda: cast(mqtt.Client, fake),
|
||||
)
|
||||
transport.open()
|
||||
immediate = _modeling_status_envelope()
|
||||
refresh = _modeling_status_envelope("dialogue:14:ModelingStatusRequest")
|
||||
|
||||
transport.exchange_batch_once(
|
||||
[immediate],
|
||||
required_response_operation_keys=(),
|
||||
)
|
||||
# This callback is already available before ordinal 14 is admitted. The
|
||||
# retained socket's zero-wait service turn must consume it as ordinal 12.
|
||||
fake.events.append(
|
||||
("message", (MODELING_STATUS_RESPONSE_TOPIC, immediate.payload))
|
||||
)
|
||||
responses = transport.exchange_batch_once(
|
||||
[refresh],
|
||||
required_response_operation_keys={refresh.operation_key},
|
||||
)
|
||||
|
||||
assert responses[refresh.operation_key] == refresh.payload
|
||||
snapshot = transport.snapshot()
|
||||
assert snapshot.publish_attempts == 2
|
||||
assert snapshot.correlated_responses == 1
|
||||
assert snapshot.ignored_known_responses == 1
|
||||
assert snapshot.late_known_responses == 1
|
||||
|
||||
|
||||
def test_unbound_optional_status_remains_distinct_from_bound_required_refresh() -> None:
|
||||
class DistinctStatusClient(FakeControlClient):
|
||||
def __init__(self) -> None:
|
||||
super().__init__(
|
||||
emit_exchange=False,
|
||||
response_topic=MODELING_STATUS_RESPONSE_TOPIC,
|
||||
)
|
||||
|
||||
def publish(
|
||||
self,
|
||||
topic: str,
|
||||
payload: bytes,
|
||||
qos: int,
|
||||
retain: bool,
|
||||
) -> SimpleNamespace:
|
||||
info = super().publish(topic, payload, qos, retain)
|
||||
self.events.append(("publish", info.mid))
|
||||
if len(self.publish_calls) == 2:
|
||||
self.events.append(
|
||||
(
|
||||
"message",
|
||||
(
|
||||
MODELING_STATUS_RESPONSE_TOPIC,
|
||||
_application_message(":ModelingStatusRequest"),
|
||||
),
|
||||
)
|
||||
)
|
||||
self.events.append(
|
||||
("message", (MODELING_STATUS_RESPONSE_TOPIC, payload))
|
||||
)
|
||||
return info
|
||||
|
||||
fake = DistinctStatusClient()
|
||||
transport = ReviewedApplicationMqttTransport(
|
||||
"192.168.1.20",
|
||||
client_factory=lambda: cast(mqtt.Client, fake),
|
||||
)
|
||||
transport.open()
|
||||
initial = _envelope(
|
||||
"bootstrap:2:ModelingStatusRequest",
|
||||
topic="lixel/application/request/modeling_status",
|
||||
session_id=":ModelingStatusRequest",
|
||||
)
|
||||
refresh = _modeling_status_envelope("dialogue:14:ModelingStatusRequest")
|
||||
|
||||
transport.exchange_batch_once(
|
||||
[initial],
|
||||
required_response_operation_keys=(),
|
||||
)
|
||||
responses = transport.exchange_batch_once(
|
||||
[refresh],
|
||||
required_response_operation_keys={refresh.operation_key},
|
||||
)
|
||||
|
||||
assert responses[refresh.operation_key] == refresh.payload
|
||||
snapshot = transport.snapshot()
|
||||
assert snapshot.publish_attempts == 2
|
||||
assert snapshot.correlated_responses == 1
|
||||
assert snapshot.ignored_known_responses == 1
|
||||
assert snapshot.late_known_responses == 1
|
||||
|
||||
|
||||
def test_duplicate_current_application_response_fails_closed() -> None:
|
||||
class DuplicateResponseClient(FakeControlClient):
|
||||
def publish(
|
||||
self,
|
||||
topic: str,
|
||||
payload: bytes,
|
||||
qos: int,
|
||||
retain: bool,
|
||||
) -> SimpleNamespace:
|
||||
info = super().publish(topic, payload, qos, retain)
|
||||
self.events.append(("message", (self.response_topic, payload)))
|
||||
return info
|
||||
|
||||
fake = DuplicateResponseClient()
|
||||
transport = ReviewedApplicationMqttTransport(
|
||||
"192.168.1.20",
|
||||
client_factory=lambda: cast(mqtt.Client, fake),
|
||||
)
|
||||
transport.open()
|
||||
|
||||
with pytest.raises(
|
||||
ApplicationCommandOutcomeUnknown,
|
||||
match="duplicate application response",
|
||||
) as raised:
|
||||
transport.exchange_batch_once(
|
||||
[_envelope()],
|
||||
required_response_operation_keys={"bootstrap:1:DeviceInfoRequest"},
|
||||
)
|
||||
|
||||
assert raised.value.reason_code == "duplicate_application_response"
|
||||
assert transport.snapshot().state == "poisoned"
|
||||
assert len(fake.publish_calls) == 1
|
||||
|
||||
|
||||
def test_post_start_status_can_be_response_free_and_socket_is_continuously_serviced() -> None:
|
||||
clock = FakeClock()
|
||||
fake = FakeControlClient(
|
||||
|
|
@ -669,7 +297,7 @@ def test_post_start_status_can_be_response_free_and_socket_is_continuously_servi
|
|||
|
||||
responses = transport.exchange_batch_once(
|
||||
[_modeling_status_envelope()],
|
||||
required_response_operation_keys=(),
|
||||
required_response_topics=(),
|
||||
)
|
||||
transport.maintain_open_for(
|
||||
2.0,
|
||||
|
|
@ -758,7 +386,7 @@ def test_post_publish_timeout_poisoned_transport_never_retries() -> None:
|
|||
with pytest.raises(ApplicationCommandOutcomeUnknown, match="automatic retry is forbidden"):
|
||||
transport.exchange_batch_once(
|
||||
[_envelope()],
|
||||
required_response_operation_keys={"bootstrap:1:DeviceInfoRequest"},
|
||||
required_response_topics={DEVICE_INFO_RESPONSE_TOPIC},
|
||||
)
|
||||
|
||||
assert transport.snapshot().state == "poisoned"
|
||||
|
|
@ -766,6 +394,6 @@ def test_post_publish_timeout_poisoned_transport_never_retries() -> None:
|
|||
with pytest.raises(ApplicationCommandOutcomeUnknown, match="already consumed"):
|
||||
transport.exchange_batch_once(
|
||||
[_envelope()],
|
||||
required_response_operation_keys={"bootstrap:1:DeviceInfoRequest"},
|
||||
required_response_topics={DEVICE_INFO_RESPONSE_TOPIC},
|
||||
)
|
||||
assert len(fake.publish_calls) == 1
|
||||
|
|
|
|||
|
|
@ -40,7 +40,7 @@ def _envelope() -> OneShotPublishEnvelope:
|
|||
software_version="V3.0.2-build.1",
|
||||
system_version="V3.0.2",
|
||||
device_model="LixelKity K1",
|
||||
device_type="A4",
|
||||
device_type="K1",
|
||||
is_activated=True,
|
||||
)
|
||||
request = build_shadow_bootstrap(
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
|
|
@ -38,11 +37,6 @@ class FakeTransportSnapshot:
|
|||
def as_dict(self) -> dict[str, object]:
|
||||
return {
|
||||
"state": self.state,
|
||||
"publish_attempts": 0,
|
||||
"qos2_completions": 0,
|
||||
"correlated_responses": 0,
|
||||
"ignored_known_responses": 0,
|
||||
"late_known_responses": 0,
|
||||
"latest_device_session_state": "ready" if self.ready else "scanning",
|
||||
"latest_device_project_bound": not self.ready,
|
||||
"automatic_retry": False,
|
||||
|
|
@ -77,8 +71,8 @@ class FakeExecutor:
|
|||
device_serial="serial-id",
|
||||
software_version="3.0.2",
|
||||
system_version="3.0.2",
|
||||
device_model="LixelKity K1",
|
||||
device_type="A4",
|
||||
device_model="K1",
|
||||
device_type="scanner",
|
||||
is_activated=True,
|
||||
)
|
||||
|
||||
|
|
@ -130,17 +124,13 @@ class FakeExecutor:
|
|||
self.transport.ready = True
|
||||
return object()
|
||||
|
||||
def maintain_post_stop_until_standby(self) -> None:
|
||||
self.records.append("wait:device-standby")
|
||||
def maintain_post_stop_until_standby_confirmed(self, observed: Any) -> None:
|
||||
self.records.append("wait:steady-green")
|
||||
while not observed():
|
||||
threading.Event().wait(0.005)
|
||||
|
||||
def snapshot(self) -> dict[str, object]:
|
||||
return {
|
||||
"records": list(self.records),
|
||||
"dialogue_stage": "test-stage",
|
||||
"start_attempted": False,
|
||||
"stop_attempted": False,
|
||||
"automatic_retry": False,
|
||||
}
|
||||
return {"records": tuple(self.records), "automatic_retry": False}
|
||||
|
||||
|
||||
def _confirmation() -> OperatorPresenceConfirmation:
|
||||
|
|
@ -166,7 +156,7 @@ def _wait_phase(
|
|||
raise AssertionError(f"session did not reach {expected}: {session.snapshot()}")
|
||||
|
||||
|
||||
def test_canonical_stages_require_operator_events_but_device_standby_does_not(
|
||||
def test_every_canonical_stage_requires_a_separate_operator_event(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
FakeExecutor.records = []
|
||||
|
|
@ -205,9 +195,12 @@ def test_canonical_stages_require_operator_events_but_device_standby_does_not(
|
|||
assert FakeExecutor.records[-1] == "wait:stop"
|
||||
|
||||
session.request_stop(confirmation=_confirmation())
|
||||
standby = _wait_phase(session, "awaiting-standby-confirmation")
|
||||
assert standby["can_confirm_standby"] is True
|
||||
assert FakeExecutor.records[-1] == "wait:steady-green"
|
||||
|
||||
session.confirm_standby()
|
||||
completed = _wait_phase(session, "completed")
|
||||
assert completed["can_confirm_standby"] is False
|
||||
assert completed["pending_operator_action"] is None
|
||||
assert loader.calls == 1
|
||||
assert completed["automatic_retry"] is False
|
||||
assert completed["scripted_transitions"] is False
|
||||
|
|
@ -221,508 +214,5 @@ def test_canonical_stages_require_operator_events_but_device_standby_does_not(
|
|||
"start:11-14",
|
||||
"wait:stop",
|
||||
"stop",
|
||||
"wait:device-standby",
|
||||
"wait:steady-green",
|
||||
]
|
||||
|
||||
|
||||
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,23 +64,6 @@ def _burst_ffmpeg(tmp_path: Path) -> Path:
|
|||
return executable
|
||||
|
||||
|
||||
def _clean_source_end_ffmpeg(tmp_path: Path) -> Path:
|
||||
executable = tmp_path / "clean-source-end-ffmpeg"
|
||||
executable.write_text(
|
||||
f"#!{sys.executable}\n"
|
||||
"import sys, time\n"
|
||||
"def box(kind, payload=b''):\n"
|
||||
" return (8 + len(payload)).to_bytes(4, 'big') + kind + payload\n"
|
||||
"sys.stdout.buffer.write(box(b'ftyp', b'isom') + box(b'moov'))\n"
|
||||
"sys.stdout.buffer.write(box(b'moof') + box(b'mdat', b'final-frame'))\n"
|
||||
"sys.stdout.buffer.flush()\n"
|
||||
"time.sleep(0.15)\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
executable.chmod(0o700)
|
||||
return executable
|
||||
|
||||
|
||||
def _buffered_tail_ffmpeg(tmp_path: Path, sentinel: Path, *, fragments: int) -> Path:
|
||||
executable = tmp_path / "buffered-tail-ffmpeg"
|
||||
executable.write_text(
|
||||
|
|
@ -306,76 +289,6 @@ def test_acquisition_records_without_browser_and_source_switch_seals_epochs(
|
|||
gateway.close()
|
||||
|
||||
|
||||
def test_expected_camera_source_end_during_device_stop_seals_complete_epoch(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setenv(
|
||||
"MISSIONCORE_FFMPEG_BINARY",
|
||||
str(_clean_source_end_ffmpeg(tmp_path)),
|
||||
)
|
||||
gateway = XgridsK1CameraGateway(tmp_path, XGRIDS_K1_PLUGIN_ID)
|
||||
session = tmp_path / "session"
|
||||
session.mkdir()
|
||||
try:
|
||||
selected = gateway.select("sensor.camera.left", "192.168.1.20")
|
||||
gateway.start_recording(session)
|
||||
gateway.expect_source_end_for_device_stop()
|
||||
|
||||
_wait_until(
|
||||
lambda: gateway.snapshot()["recording"]["completed_epochs"] == 1,
|
||||
)
|
||||
state = gateway.snapshot()
|
||||
assert state["phase"] == "idle"
|
||||
assert state["error"] is None
|
||||
assert state["delivery"] is None
|
||||
assert state["recording"]["active"] is True
|
||||
assert state["recording"]["source_end_expected"] is False
|
||||
|
||||
gateway.stop_recording(status="complete")
|
||||
epoch = (
|
||||
session
|
||||
/ "media"
|
||||
/ "sensor.camera.left"
|
||||
/ f"epoch-{selected['generation']}"
|
||||
)
|
||||
summary = json.loads((epoch / "summary.json").read_text(encoding="utf-8"))
|
||||
assert summary["status"] == "complete"
|
||||
assert summary["failure_code"] is None
|
||||
assert summary["media_segment_count"] == 1
|
||||
finally:
|
||||
gateway.close()
|
||||
|
||||
|
||||
def test_unexpected_camera_source_end_remains_a_recording_failure(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setenv(
|
||||
"MISSIONCORE_FFMPEG_BINARY",
|
||||
str(_clean_source_end_ffmpeg(tmp_path)),
|
||||
)
|
||||
gateway = XgridsK1CameraGateway(tmp_path, XGRIDS_K1_PLUGIN_ID)
|
||||
session = tmp_path / "session"
|
||||
session.mkdir()
|
||||
try:
|
||||
selected = gateway.select("sensor.camera.left", "192.168.1.20")
|
||||
gateway.start_recording(session)
|
||||
|
||||
_wait_until(lambda: gateway.snapshot()["phase"] == "error")
|
||||
epoch = (
|
||||
session
|
||||
/ "media"
|
||||
/ "sensor.camera.left"
|
||||
/ f"epoch-{selected['generation']}"
|
||||
)
|
||||
summary = json.loads((epoch / "summary.json").read_text(encoding="utf-8"))
|
||||
assert summary["status"] == "interrupted"
|
||||
assert summary["failure_code"] == "camera-source-ended"
|
||||
finally:
|
||||
gateway.close()
|
||||
|
||||
|
||||
def test_camera_storage_open_failure_is_loud_and_never_starts_ffmpeg(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
|
|
@ -588,8 +501,7 @@ def test_service_publishes_two_dynamic_camera_rows_and_stale_stop_is_safe(
|
|||
service._compatibility_attestation = {
|
||||
"firmware_version": "3.0.2",
|
||||
"topology": "direct-lan",
|
||||
"verification": "live-device-info",
|
||||
"basis": "selected-profile-live-device-info-required",
|
||||
"basis": "operator-attested",
|
||||
"observed_at": "2026-07-16T20:00:00Z",
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -48,7 +48,6 @@ def test_xgrids_compatibility_profile_loads_exact_firmware_and_sources() -> None
|
|||
assert profile["profile_id"] == "xgrids.lixelkity-k1.fw-3.0.2.direct-lan.v1"
|
||||
assert profile["scope"]["vendor"] == "XGRIDS"
|
||||
assert profile["scope"]["model"] == "LixelKity K1"
|
||||
assert profile["scope"]["platform_type"] == "A4"
|
||||
assert profile["scope"]["firmware"] == {"match": "exact", "version": "3.0.2"}
|
||||
assert profile["scope"]["topology"] == "direct-lan"
|
||||
assert LOADER.matches_target(profile, firmware="3.0.2", topology="direct-lan")
|
||||
|
|
@ -147,15 +146,6 @@ def test_xgrids_compatibility_profile_maps_actions_without_enabling_writes() ->
|
|||
assert profile["safety"]["default_mode"] == "read-only"
|
||||
assert profile["safety"]["vendor_writes_enabled"] is False
|
||||
assert control["mode"] == "operator-manual"
|
||||
assert control["software_acceptance_transport"] == {
|
||||
"status": "installed-operator-present",
|
||||
"default_authority": "disabled",
|
||||
"profile_gate": "live-device-info-exact-match",
|
||||
"dialogue": "single-socket-canonical-start-to-stop",
|
||||
"automatic_retry": False,
|
||||
"supported_mount_type": "handheld",
|
||||
"supported_gnss_mode": "none",
|
||||
}
|
||||
assert control["verified_device_control"]["gesture"] == "physical-double-click"
|
||||
|
||||
for action_id, action_code in (("acquisition.start", 1), ("acquisition.stop", 2)):
|
||||
|
|
|
|||
|
|
@ -75,7 +75,7 @@ def _binding(**changes: object) -> LiveDeviceControlBinding:
|
|||
"software_version": "V3.0.2-build.1",
|
||||
"system_version": "V3.0.2",
|
||||
"device_model": "LixelKity K1",
|
||||
"device_type": "A4",
|
||||
"device_type": "K1",
|
||||
"is_activated": True,
|
||||
}
|
||||
values.update(changes)
|
||||
|
|
|
|||
Loading…
Reference in New Issue