Compare commits

..

No commits in common. "fecb5885d0663a7d0d15239c6afa608693a49172" and "d7a2c22faf2c8d8b5348a1503efb4ff056f2fb98" have entirely different histories.

73 changed files with 830 additions and 4849 deletions

View File

@ -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 810, user-confirmed START 11,
immediate status read 12 and state-gated reads 1314 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.

View File

@ -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);
}}
>
Применить адрес

View File

@ -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);
}}
/>
</>;
);
}

View File

@ -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>

View File

@ -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,

View File

@ -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;

View File

@ -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,
{

View File

@ -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;

View File

@ -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,
};
}

View File

@ -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;

View File

@ -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

View File

@ -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 {

View File

@ -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", {

View File

@ -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,
})),

View File

@ -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());

View File

@ -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 15300 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 16 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 1314 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.

View File

@ -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 26 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 2425 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 16
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 15120 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 16. 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 15120-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

View File

@ -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

View File

@ -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

View File

@ -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 1314 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 26 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 15300 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 15120 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 16 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 16 to connection, request 7 to scan-workspace entry,
requests 810 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 1314 are gated by bound `SCANNING`, project presence and
`init_ready`, not by the observed 2425-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 (16), workspace entry (7),
project preparation (810) 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

View File

@ -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 46 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 26 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 1314 about 2425 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 16 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
114, 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.

View File

@ -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.

View File

@ -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 1314. 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

View File

@ -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}
/>

View File

@ -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();

View File

@ -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,
});

View File

@ -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"
? "Выполняются операции 16 записанного диалога; следующий этап ждёт подтверждённый ответ 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)}

View File

@ -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">ПОДКЛЮЧЕНИЕ · ШАГИ 0103</span><h2>Подключите устройство к сети</h2></div>
<StatusBadge tone={phaseTone}>{phaseLabel}</StatusBadge>
</header>
<div className="configuration-anchor">
<span className="nodedc-field__description">
Неподтверждённые сетевые топологии уже отражены в интерфейсе, но не могут быть выбраны до отдельной приёмки.
</span>
<Select
label="Способ подключения"
value={connectionMode}
options={connectionModeOptions}
onChange={(value) => {
setConnectionMode(value);
resetProvisioningIntent();
}}
disabled={isBusy}
variant="split"
/>
</div>
<div className="wizard-list">
<WizardStep number="01" title="Включите устройство" status={powerConfirmed ? "Подтверждено" : "Ожидает"} tone={powerConfirmed ? "success" : "warning"}>
<div className="nodedc-field">
@ -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="Передайте настройки WiFi" 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="Название сети WiFi" hint="SSID" value={ssid} onChange={(event) => { setSsid(event.target.value); provisioningIntentRef.current = null; }} autoComplete="off" spellCheck={false} placeholder="Сеть локального контура" />
<TextField label="Пароль WiFi" hint="Только в оперативной памяти" type="password" value={password} onChange={(event) => { setPassword(event.target.value); provisioningIntentRef.current = null; }} autoComplete="off" placeholder="Введите пароль" />
</div>
@ -218,7 +201,7 @@ export function K1ProvisioningPipeline({
<Button width="full" variant="primary" icon={<Icon name="network" />} disabled={!canConnect} onClick={() => void submitConnect()}>
{pendingAction === "connect" ? "Подключаем…" : "Подключить устройство к WiFi"}
</Button>
<p className="safety-note">Наличие адреса подтверждает результат предыдущей настройки, но не текущее соединение. Пароль передаётся только локальному сервису, не сохраняется в браузере и удаляется из формы после успеха.</p>
<p className="safety-note">Пароль передаётся только локальному сервису, не сохраняется в браузере и удаляется из формы после успеха.</p>
</WizardStep>
</div>
</GlassSurface>

View File

@ -72,7 +72,7 @@ function phasePresentation(
awaiting_external_stop: {
label: softwareCommanded ? "K1 завершает и сохраняет" : "Ожидание остановки на устройстве",
detail: softwareCommanded
? "STOP не повторяется. 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>
);
}

View File

@ -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,
},
];

View File

@ -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";

View File

@ -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, "неуказанная ошибка"],
[

View File

@ -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;
}

View File

@ -9,7 +9,7 @@ const phaseLabels: Record<string, string> = {
device_selected: "Устройство выбрано",
provisioning: "Передача настроек WiFi",
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";
}

View File

@ -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;
}

View File

@ -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,

View File

@ -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")

View File

@ -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"
],

View File

@ -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(

View File

@ -251,7 +251,7 @@ def serve_console(
] = 8000,
) -> None:
"""Serve the built Mission Core Control Station and loopback control API."""
frontend = Path(__file__).resolve().parents[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 "

View File

@ -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."""

View File

@ -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",
)

View File

@ -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()

View File

@ -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,

View File

@ -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",
)

View File

@ -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

View File

@ -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"

View File

@ -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:

View File

@ -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,

View File

@ -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:

View File

@ -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."""

View File

@ -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,

View File

@ -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:

View File

@ -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,

View File

@ -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."
}

View File

@ -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"])

View File

@ -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",

View File

@ -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:

View File

@ -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()

View File

@ -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:

View File

@ -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:

View File

@ -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"):

View File

@ -77,7 +77,7 @@ def test_validation_errors_do_not_echo_sensitive_request_values(
"compatibility_attestation": {
"firmware_version": "3.0.2",
"topology": "direct-lan",
"verification": "live-device-info",
"operator_confirmed": True,
},
}
payload = {"input": action_input} if wrap_input else action_input

View File

@ -31,7 +31,7 @@ from k1link.device_plugins.xgrids_k1.protocol.application_bootstrap import (
ATTESTATION = CompatibilityAttestationRequest(
firmware_version="3.0.2",
topology="direct-lan",
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,

View File

@ -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:

View File

@ -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")

View File

@ -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"),
)

View File

@ -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

View File

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

View File

@ -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

View File

@ -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",
}

View File

@ -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)):

View File

@ -75,7 +75,7 @@ def _binding(**changes: object) -> LiveDeviceControlBinding:
"software_version": "V3.0.2-build.1",
"system_version": "V3.0.2",
"device_model": "LixelKity K1",
"device_type": "A4",
"device_type": "K1",
"is_activated": True,
}
values.update(changes)