Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
456141a3f3 | ||
|
|
7d911b4b1f | ||
|
|
71c8b043c9 |
@@ -96,6 +96,23 @@ export function livePresentationCloseFence(
|
|||||||
return acquisitionId ? JSON.stringify([source.id, acquisitionId]) : null;
|
return acquisitionId ? JSON.stringify([source.id, acquisitionId]) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function acquisitionIdFromLivePresentationCloseFence(fence: string): string | null {
|
||||||
|
try {
|
||||||
|
const decoded: unknown = JSON.parse(fence);
|
||||||
|
if (
|
||||||
|
!Array.isArray(decoded)
|
||||||
|
|| decoded.length !== 2
|
||||||
|
|| typeof decoded[0] !== "string"
|
||||||
|
|| typeof decoded[1] !== "string"
|
||||||
|
|| !decoded[0].trim()
|
||||||
|
|| !decoded[1].trim()
|
||||||
|
) return null;
|
||||||
|
return decoded[1].trim();
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export interface LiveDefaultPresentationAdmission {
|
export interface LiveDefaultPresentationAdmission {
|
||||||
visibleIds: string[];
|
visibleIds: string[];
|
||||||
removedIds: string[];
|
removedIds: string[];
|
||||||
@@ -132,9 +149,11 @@ export function admitLiveDefaultPresentations(
|
|||||||
/**
|
/**
|
||||||
* A restored workspace may hide cameras that the device plugin has not selected.
|
* A restored workspace may hide cameras that the device plugin has not selected.
|
||||||
* It must still admit the exact selected delivery in a fresh browser document,
|
* It must still admit the exact selected delivery in a fresh browser document,
|
||||||
* or a replacement delivery for a camera already presented in this acquisition.
|
* a replacement delivery for a camera already presented in this acquisition,
|
||||||
|
* or the first delivery of a later acquisition in the same browser document.
|
||||||
* An explicit close remains a separate acquisition-scoped fence in
|
* An explicit close remains a separate acquisition-scoped fence in
|
||||||
* `admitLiveDefaultPresentations` and de-selects the source in the plugin first.
|
* `admitLiveDefaultPresentations`; whether it may de-select the source remains
|
||||||
|
* the plugin's current activation-authority decision.
|
||||||
*/
|
*/
|
||||||
export function restoredLayoutMayAdmitLiveDefault(
|
export function restoredLayoutMayAdmitLiveDefault(
|
||||||
sources: readonly ObservationSourceDescriptor[],
|
sources: readonly ObservationSourceDescriptor[],
|
||||||
@@ -145,7 +164,13 @@ export function restoredLayoutMayAdmitLiveDefault(
|
|||||||
if (presentedAcquisitionSources.size === 0) return true;
|
if (presentedAcquisitionSources.size === 0) return true;
|
||||||
return selectedSources.some((source) => {
|
return selectedSources.some((source) => {
|
||||||
const lineage = livePresentationCloseFence(source);
|
const lineage = livePresentationCloseFence(source);
|
||||||
return Boolean(lineage && presentedAcquisitionSources.has(lineage));
|
if (!lineage) return false;
|
||||||
|
if (presentedAcquisitionSources.has(lineage)) return true;
|
||||||
|
const acquisitionId = source.binding.acquisitionId?.trim();
|
||||||
|
if (!acquisitionId) return false;
|
||||||
|
return ![...presentedAcquisitionSources].some(
|
||||||
|
(presented) => acquisitionIdFromLivePresentationCloseFence(presented) === acquisitionId,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -67,6 +67,7 @@ let reconfigurationAllowsFreshDevice;
|
|||||||
let readOnlyObservationShowsNetworkUnavailable;
|
let readOnlyObservationShowsNetworkUnavailable;
|
||||||
let readOnlyFailureShowsNetworkUnavailable;
|
let readOnlyFailureShowsNetworkUnavailable;
|
||||||
let savedBridgeRequiresNetworkSetup;
|
let savedBridgeRequiresNetworkSetup;
|
||||||
|
let isConnectionControlBootstrapSettling;
|
||||||
let provisioningFailureRequiresFreshCandidate;
|
let provisioningFailureRequiresFreshCandidate;
|
||||||
let trustedConnectionBinding;
|
let trustedConnectionBinding;
|
||||||
let transportRefEquivalenceKey;
|
let transportRefEquivalenceKey;
|
||||||
@@ -189,6 +190,7 @@ before(async () => {
|
|||||||
readOnlyObservationShowsNetworkUnavailable,
|
readOnlyObservationShowsNetworkUnavailable,
|
||||||
readOnlyFailureShowsNetworkUnavailable,
|
readOnlyFailureShowsNetworkUnavailable,
|
||||||
savedBridgeRequiresNetworkSetup,
|
savedBridgeRequiresNetworkSetup,
|
||||||
|
isConnectionControlBootstrapSettling,
|
||||||
provisioningFailureRequiresFreshCandidate,
|
provisioningFailureRequiresFreshCandidate,
|
||||||
trustedConnectionBinding,
|
trustedConnectionBinding,
|
||||||
transportRefEquivalenceKey,
|
transportRefEquivalenceKey,
|
||||||
@@ -5355,6 +5357,55 @@ test("an unresolved physical command has one explicit server-bound read-only rec
|
|||||||
/Результаты последнего Bluetooth-поиска|nearby-unrelated-device|>Выбрать<|>Применить</,
|
/Результаты последнего Bluetooth-поиска|nearby-unrelated-device|>Выбрать<|>Применить</,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const quickRecoveryInsideBridgeState = structuredClone(state);
|
||||||
|
quickRecoveryInsideBridgeState.physical_command.record.connection.connection_mode =
|
||||||
|
"quick-connect";
|
||||||
|
quickRecoveryInsideBridgeState.physical_command.record.connection.target_ipv4 =
|
||||||
|
"192.168.56.1";
|
||||||
|
quickRecoveryInsideBridgeState.semantic_topology_store.record.connection_mode =
|
||||||
|
"quick-connect";
|
||||||
|
quickRecoveryInsideBridgeState.semantic_topology_store.record.ipv4 = "192.168.56.1";
|
||||||
|
quickRecoveryInsideBridgeState.connection_policy.actions[
|
||||||
|
"observe-configured-device-network"
|
||||||
|
].required_connection_mode = "quick-connect";
|
||||||
|
const quickRecoveryInsideBridgeMarkup = renderToStaticMarkup(createElement(
|
||||||
|
K1ProvisioningPipeline,
|
||||||
|
{
|
||||||
|
controller: provisioningController(quickRecoveryInsideBridgeState),
|
||||||
|
desiredMode: "bridge",
|
||||||
|
},
|
||||||
|
));
|
||||||
|
assert.equal(
|
||||||
|
buttonMarkupWithText(
|
||||||
|
quickRecoveryInsideBridgeMarkup,
|
||||||
|
"Переподключиться",
|
||||||
|
).length,
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
buttonMarkupWithText(
|
||||||
|
quickRecoveryInsideBridgeMarkup,
|
||||||
|
"Подключить новый K1",
|
||||||
|
).length,
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
|
||||||
|
const selectedQuickRecoveryState = structuredClone(
|
||||||
|
quickRecoveryInsideBridgeState,
|
||||||
|
);
|
||||||
|
selectedQuickRecoveryState.desired_connection_mode = "quick-connect";
|
||||||
|
const selectedQuickRecoveryMarkup = renderToStaticMarkup(createElement(
|
||||||
|
K1ProvisioningPipeline,
|
||||||
|
{
|
||||||
|
controller: provisioningController(selectedQuickRecoveryState),
|
||||||
|
desiredMode: "quick-connect",
|
||||||
|
},
|
||||||
|
));
|
||||||
|
assert.equal(
|
||||||
|
buttonMarkupWithText(selectedQuickRecoveryMarkup, "Переподключиться").length,
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
|
||||||
const networkSetupState = structuredClone(state);
|
const networkSetupState = structuredClone(state);
|
||||||
networkSetupState.connection_verification = {
|
networkSetupState.connection_verification = {
|
||||||
status: "unreachable",
|
status: "unreachable",
|
||||||
@@ -5454,6 +5505,14 @@ test("an unresolved physical command has one explicit server-bound read-only rec
|
|||||||
);
|
);
|
||||||
assert.match(physicalRecovery, /physicalRecoveryTarget\?\.serverBound/);
|
assert.match(physicalRecovery, /physicalRecoveryTarget\?\.serverBound/);
|
||||||
assert.match(physicalRecovery, /surfaceErrors: false/);
|
assert.match(physicalRecovery, /surfaceErrors: false/);
|
||||||
|
assert.match(
|
||||||
|
physicalRecovery,
|
||||||
|
/physicalRecoveryTarget\.connectionMode !== connectionMode/,
|
||||||
|
);
|
||||||
|
assert.doesNotMatch(
|
||||||
|
physicalRecovery,
|
||||||
|
/commitDesiredModeForExplicitAction|selectConnectionMode\(/,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("supervisor DeviceInfo Verify outranks a stale exact fresh row for physical recovery", () => {
|
test("supervisor DeviceInfo Verify outranks a stale exact fresh row for physical recovery", () => {
|
||||||
@@ -6128,6 +6187,20 @@ test("Apply presentation stays correlated through ACK, bootstrap, terminal and c
|
|||||||
"settling",
|
"settling",
|
||||||
"ready child success waits for the exact reachable lease projection",
|
"ready child success waits for the exact reachable lease projection",
|
||||||
);
|
);
|
||||||
|
assert.equal(
|
||||||
|
provisioningAttemptViewState(correlated, {
|
||||||
|
...initialState,
|
||||||
|
operations: [operation],
|
||||||
|
connection_attempt: {
|
||||||
|
...attempt,
|
||||||
|
status: "succeeded",
|
||||||
|
control_state: "unknown",
|
||||||
|
safe_next_action: "verify-control-read-only",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
"settling",
|
||||||
|
"the network-success/control-bootstrap handoff keeps the existing loader",
|
||||||
|
);
|
||||||
assert.equal(
|
assert.equal(
|
||||||
provisioningAttemptViewState(correlated, {
|
provisioningAttemptViewState(correlated, {
|
||||||
...initialState,
|
...initialState,
|
||||||
@@ -6157,6 +6230,92 @@ test("Apply presentation stays correlated through ACK, bootstrap, terminal and c
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("current Bridge bootstrap stays visible over an older unresolved STOP checkpoint", () => {
|
||||||
|
const presentation = provisioningAttemptPresentation({
|
||||||
|
localPhase: "settling",
|
||||||
|
attemptId: "network-operation-current",
|
||||||
|
});
|
||||||
|
const state = durableTopologyState();
|
||||||
|
state.snapshot_runtime_id = presentation.snapshotRuntimeId;
|
||||||
|
state.connection_attempt = {
|
||||||
|
schema_version: "missioncore.xgrids-k1-connection-attempt/v1",
|
||||||
|
attempt_id: presentation.attemptId,
|
||||||
|
connection_mode: "bridge",
|
||||||
|
status: "running",
|
||||||
|
phase: "network_applied",
|
||||||
|
control_state: "unknown",
|
||||||
|
stage: "mqtt-device-info",
|
||||||
|
public_error_code: null,
|
||||||
|
side_effect_status: "applied",
|
||||||
|
safe_next_action: "wait-for-current-attempt",
|
||||||
|
automatic_retry: false,
|
||||||
|
accepted_at: OBSERVED_AT,
|
||||||
|
completed_at: null,
|
||||||
|
timeline: [],
|
||||||
|
};
|
||||||
|
state.operations = [{
|
||||||
|
operation_id: presentation.attemptId,
|
||||||
|
action: "network.provision",
|
||||||
|
status: "succeeded",
|
||||||
|
idempotency_key: presentation.idempotencyKey,
|
||||||
|
}];
|
||||||
|
state.physical_command = {
|
||||||
|
status: "unresolved",
|
||||||
|
reason_code: "physical-command-reconciliation-required",
|
||||||
|
requires_reconciliation: true,
|
||||||
|
resolved_active_recovery_required: false,
|
||||||
|
automatic_replay_allowed: false,
|
||||||
|
runtime_bound: true,
|
||||||
|
reconciliation_ready: true,
|
||||||
|
observed_session_state: "scan_stopping",
|
||||||
|
active_operation_id: "older-stop-operation",
|
||||||
|
record: {
|
||||||
|
revision: 306,
|
||||||
|
operation_id: "older-stop-operation",
|
||||||
|
action: "stop",
|
||||||
|
stage: "observing",
|
||||||
|
resolution: null,
|
||||||
|
connection: {
|
||||||
|
transport_ref: presentation.deviceId,
|
||||||
|
connection_mode: "quick-connect",
|
||||||
|
target_ipv4: "192.168.56.1",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
assert.equal(isConnectionControlBootstrapSettling(state), true);
|
||||||
|
const markup = renderProvisioningWithAttempt({
|
||||||
|
controller: provisioningController(state),
|
||||||
|
desiredMode: "bridge",
|
||||||
|
}, presentation);
|
||||||
|
|
||||||
|
assert.match(markup, /Подтверждение подключения…/);
|
||||||
|
assert.match(markup, /Настройки переданы один раз/);
|
||||||
|
assert.match(markup, /<span>02<\/span>/);
|
||||||
|
assert.match(markup, /nodedc-activity-indicator/);
|
||||||
|
assert.doesNotMatch(markup, /Прежнее подключение не подтверждено/);
|
||||||
|
assert.doesNotMatch(markup, />Переподключиться<\/button>/);
|
||||||
|
assert.doesNotMatch(markup, />Подключить новый K1<\/button>/);
|
||||||
|
|
||||||
|
assert.equal(isConnectionControlBootstrapSettling({
|
||||||
|
...state,
|
||||||
|
connection_attempt: {
|
||||||
|
...state.connection_attempt,
|
||||||
|
status: "succeeded",
|
||||||
|
safe_next_action: "verify-control-read-only",
|
||||||
|
},
|
||||||
|
}), true);
|
||||||
|
assert.equal(isConnectionControlBootstrapSettling({
|
||||||
|
...state,
|
||||||
|
connection_attempt: {
|
||||||
|
...state.connection_attempt,
|
||||||
|
status: "failed",
|
||||||
|
control_state: "control_not_ready",
|
||||||
|
safe_next_action: "manual-recovery-required",
|
||||||
|
},
|
||||||
|
}), false);
|
||||||
|
});
|
||||||
|
|
||||||
test("Apply spends secret state before I/O and never stores a password in its presentation latch", () => {
|
test("Apply spends secret state before I/O and never stores a password in its presentation latch", () => {
|
||||||
const source = readFileSync(provisioningSourceUrl, "utf8");
|
const source = readFileSync(provisioningSourceUrl, "utf8");
|
||||||
const presentationType = sourceSlice(
|
const presentationType = sourceSlice(
|
||||||
|
|||||||
@@ -310,7 +310,7 @@ test("a sequential live acquisition re-arms the same camera without reopening a
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("a restored layout admits a selected delivery after reload and only same-acquisition successors", () => {
|
test("a restored layout admits same-lineage replacements and the next acquisition", () => {
|
||||||
const camera = (acquisitionId, deliveryId) => ({
|
const camera = (acquisitionId, deliveryId) => ({
|
||||||
id: "k1:sensor.camera.right",
|
id: "k1:sensor.camera.right",
|
||||||
sourceId: "sensor.camera.right",
|
sourceId: "sensor.camera.right",
|
||||||
@@ -344,7 +344,11 @@ test("a restored layout admits a selected delivery after reload and only same-ac
|
|||||||
|
|
||||||
assert.equal(restoredLayoutMayAdmitLiveDefault([original], new Set()), true);
|
assert.equal(restoredLayoutMayAdmitLiveDefault([original], new Set()), true);
|
||||||
assert.equal(restoredLayoutMayAdmitLiveDefault([successor], presented), true);
|
assert.equal(restoredLayoutMayAdmitLiveDefault([successor], presented), true);
|
||||||
assert.equal(restoredLayoutMayAdmitLiveDefault([unrelated], presented), false);
|
assert.equal(
|
||||||
|
restoredLayoutMayAdmitLiveDefault([unrelated], presented),
|
||||||
|
true,
|
||||||
|
"Bridge acquisition A must not suppress the first Quick Connect camera in acquisition B",
|
||||||
|
);
|
||||||
assert.equal(restoredLayoutMayAdmitLiveDefault([{
|
assert.equal(restoredLayoutMayAdmitLiveDefault([{
|
||||||
...original,
|
...original,
|
||||||
activation: { ...original.activation, selected: false },
|
activation: { ...original.activation, selected: false },
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
backendConnectionTopology,
|
backendConnectionTopology,
|
||||||
connectionAttemptForRuntimeError,
|
connectionAttemptForRuntimeError,
|
||||||
hasControlAuthority,
|
hasControlAuthority,
|
||||||
|
isConnectionControlBootstrapSettling,
|
||||||
isConfirmedLiveState,
|
isConfirmedLiveState,
|
||||||
isPhysicalStopRecoverySettling,
|
isPhysicalStopRecoverySettling,
|
||||||
isRecoveredPhysicalScanning,
|
isRecoveredPhysicalScanning,
|
||||||
@@ -274,12 +275,16 @@ export function XgridsK1Connection({ model, host }: DevicePluginConnectionProps)
|
|||||||
: sourceRuntimeBusy || preparedAcquisition
|
: sourceRuntimeBusy || preparedAcquisition
|
||||||
? "warning"
|
? "warning"
|
||||||
: "neutral";
|
: "neutral";
|
||||||
|
const connectionControlBootstrapSettling =
|
||||||
|
isConnectionControlBootstrapSettling(state);
|
||||||
const connectionPhaseLabel = livePreparationPending
|
const connectionPhaseLabel = livePreparationPending
|
||||||
? "Подготовка приёма"
|
? "Подготовка приёма"
|
||||||
: sourceRuntimeBusy || preparedAcquisition
|
: sourceRuntimeBusy || preparedAcquisition
|
||||||
? sourceLabel
|
? sourceLabel
|
||||||
: activeRecoveryPresentation
|
: activeRecoveryPresentation
|
||||||
? activeRecoveryPresentation.title
|
? activeRecoveryPresentation.title
|
||||||
|
: connectionControlBootstrapSettling
|
||||||
|
? "Подтверждение подключения"
|
||||||
: physicalStopRecoverySettling
|
: physicalStopRecoverySettling
|
||||||
? "Завершение остановки"
|
? "Завершение остановки"
|
||||||
: recoveredPhysicalScanning
|
: recoveredPhysicalScanning
|
||||||
@@ -307,6 +312,8 @@ export function XgridsK1Connection({ model, host }: DevicePluginConnectionProps)
|
|||||||
? sourceTone
|
? sourceTone
|
||||||
: activeRecoveryPresentation
|
: activeRecoveryPresentation
|
||||||
? activeRecoveryPresentation.tone
|
? activeRecoveryPresentation.tone
|
||||||
|
: connectionControlBootstrapSettling
|
||||||
|
? "accent"
|
||||||
: physicalRecoveryRequired
|
: physicalRecoveryRequired
|
||||||
? "warning"
|
? "warning"
|
||||||
: projectedPhase === "error"
|
: projectedPhase === "error"
|
||||||
@@ -322,6 +329,8 @@ export function XgridsK1Connection({ model, host }: DevicePluginConnectionProps)
|
|||||||
? "Команда остановки уже принята. Завершение выполняется без повторной команды."
|
? "Команда остановки уже принята. Завершение выполняется без повторной команды."
|
||||||
: activeRecoveryPresentation
|
: activeRecoveryPresentation
|
||||||
? activeRecoveryPresentation.detail
|
? activeRecoveryPresentation.detail
|
||||||
|
: connectionControlBootstrapSettling
|
||||||
|
? "Сеть применена. Сервис проверяет управляющее подключение без повторения BLE-команды."
|
||||||
: recoveredPhysicalScanning
|
: recoveredPhysicalScanning
|
||||||
? "Локальная запись остановлена, но сканирование ещё продолжается."
|
? "Локальная запись остановлена, но сканирование ещё продолжается."
|
||||||
: physicalRecoveryRequired
|
: physicalRecoveryRequired
|
||||||
|
|||||||
@@ -1139,6 +1139,7 @@ interface ConnectionVerifyRequestBase {
|
|||||||
device_id: string;
|
device_id: string;
|
||||||
compatibility_attestation: CompatibilityAttestation;
|
compatibility_attestation: CompatibilityAttestation;
|
||||||
operation_id?: string;
|
operation_id?: string;
|
||||||
|
expected_mode_revision: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ConnectionVerifyRequest =
|
export type ConnectionVerifyRequest =
|
||||||
|
|||||||
@@ -351,6 +351,18 @@ export function provisioningAttemptViewState(
|
|||||||
if (attempt.status === "succeeded" && attempt.control_state === "ready") {
|
if (attempt.status === "succeeded" && attempt.control_state === "ready") {
|
||||||
return "settling";
|
return "settling";
|
||||||
}
|
}
|
||||||
|
// The network operation can be projected as succeeded immediately before
|
||||||
|
// its service-owned control bootstrap appears in the next snapshot. Keep
|
||||||
|
// the click-owned inline progress surface during that exact handoff; a
|
||||||
|
// terminal bootstrap failure still arrives as failed/control_not_ready.
|
||||||
|
if (
|
||||||
|
attempt.status === "succeeded"
|
||||||
|
&& attempt.phase === "network_applied"
|
||||||
|
&& attempt.control_state === "unknown"
|
||||||
|
&& attempt.safe_next_action === "verify-control-read-only"
|
||||||
|
) {
|
||||||
|
return "settling";
|
||||||
|
}
|
||||||
if (TERMINAL_PROVISIONING_STATUSES.has(attempt.status)) return "failed";
|
if (TERMINAL_PROVISIONING_STATUSES.has(attempt.status)) return "failed";
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
@@ -819,11 +831,16 @@ export function localProvisioningDraftFenceKey({
|
|||||||
|
|
||||||
function observationRequest(
|
function observationRequest(
|
||||||
target: ReadOnlyConnectionObservationTarget,
|
target: ReadOnlyConnectionObservationTarget,
|
||||||
|
desiredModeRevision: number | null | undefined,
|
||||||
reconfigurationRevision: number,
|
reconfigurationRevision: number,
|
||||||
reconfigurationIntentId: string | null,
|
reconfigurationIntentId: string | null,
|
||||||
): ConnectionVerifyRequest | null {
|
): ConnectionVerifyRequest | null {
|
||||||
|
if (!Number.isInteger(desiredModeRevision) || (desiredModeRevision ?? -1) < 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
const commonRequest = {
|
const commonRequest = {
|
||||||
device_id: target.deviceId,
|
device_id: target.deviceId,
|
||||||
|
expected_mode_revision: desiredModeRevision as number,
|
||||||
compatibility_attestation: profileSelectionForConnectionMode(
|
compatibility_attestation: profileSelectionForConnectionMode(
|
||||||
target.connectionMode,
|
target.connectionMode,
|
||||||
),
|
),
|
||||||
@@ -1688,7 +1705,8 @@ export function K1ProvisioningPipeline({
|
|||||||
currentReadOnlyReconnectPresentation?.kind === "connection"
|
currentReadOnlyReconnectPresentation?.kind === "connection"
|
||||||
&& currentReadOnlyReconnectPresentation.connectionMode === connectionMode;
|
&& currentReadOnlyReconnectPresentation.connectionMode === connectionMode;
|
||||||
const physicalRecoveryVerificationPending =
|
const physicalRecoveryVerificationPending =
|
||||||
currentReadOnlyReconnectPresentation?.kind === "physical";
|
currentReadOnlyReconnectPresentation?.kind === "physical"
|
||||||
|
&& currentReadOnlyReconnectPresentation.connectionMode === connectionMode;
|
||||||
const provisioningMutationBusy = Boolean(
|
const provisioningMutationBusy = Boolean(
|
||||||
livePreparationPending
|
livePreparationPending
|
||||||
|| presentedPendingAction
|
|| presentedPendingAction
|
||||||
@@ -1886,8 +1904,15 @@ export function K1ProvisioningPipeline({
|
|||||||
const physicalRecoveryAuthorityKey = physicalRecoveryPresentationAuthorityKey(
|
const physicalRecoveryAuthorityKey = physicalRecoveryPresentationAuthorityKey(
|
||||||
state,
|
state,
|
||||||
);
|
);
|
||||||
|
const physicalRecoveryMatchesSelectedMode = Boolean(
|
||||||
|
physicalRecoveryTarget?.connectionMode === connectionMode
|
||||||
|
&& state?.desired_connection_mode === connectionMode,
|
||||||
|
);
|
||||||
const physicalReadOnlyVerificationAvailable = Boolean(
|
const physicalReadOnlyVerificationAvailable = Boolean(
|
||||||
physicalRecoveryTarget?.serverBound || physicalRecoveryVerificationPending,
|
(
|
||||||
|
physicalRecoveryMatchesSelectedMode
|
||||||
|
&& physicalRecoveryTarget?.serverBound
|
||||||
|
) || physicalRecoveryVerificationPending,
|
||||||
);
|
);
|
||||||
const presentedPhysicalRecoveryMode = physicalRecoveryBinding?.connectionMode
|
const presentedPhysicalRecoveryMode = physicalRecoveryBinding?.connectionMode
|
||||||
?? (physicalRecoveryVerificationPending
|
?? (physicalRecoveryVerificationPending
|
||||||
@@ -1996,16 +2021,18 @@ export function K1ProvisioningPipeline({
|
|||||||
const changeNetworkActionApplicable = connectionMode === "bridge"
|
const changeNetworkActionApplicable = connectionMode === "bridge"
|
||||||
&& connectedReconfigurationActionApplicable(state, "prepare-change-network");
|
&& connectedReconfigurationActionApplicable(state, "prepare-change-network");
|
||||||
const showNetworkStep = Boolean(
|
const showNetworkStep = Boolean(
|
||||||
!presentedPhysicalRecoveryRequired
|
connectionAttemptSettling
|
||||||
&& !presentedConnectionRecoveryRequired
|
|| (
|
||||||
&& (
|
!presentedPhysicalRecoveryRequired
|
||||||
connectionPresentationEstablished
|
&& !presentedConnectionRecoveryRequired
|
||||||
|| explicitProvisioningDraftRetained
|
&& (
|
||||||
|| explicitProvisioningDraftContextRetained
|
connectionPresentationEstablished
|
||||||
|| unresolvedAppliedAttempt
|
|| explicitProvisioningDraftRetained
|
||||||
|| connectionAttemptSettling
|
|| explicitProvisioningDraftContextRetained
|
||||||
|| connectionAttemptFailed
|
|| unresolvedAppliedAttempt
|
||||||
|| (changeNetworkDialogue && Boolean(changeNetworkRequiredDeviceId))
|
|| connectionAttemptFailed
|
||||||
|
|| (changeNetworkDialogue && Boolean(changeNetworkRequiredDeviceId))
|
||||||
|
)
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
const requestExplicitProvisioning = useCallback((
|
const requestExplicitProvisioning = useCallback((
|
||||||
@@ -2588,6 +2615,7 @@ export function K1ProvisioningPipeline({
|
|||||||
const request = appliedNetworkRecoveryTarget
|
const request = appliedNetworkRecoveryTarget
|
||||||
? observationRequest(
|
? observationRequest(
|
||||||
appliedNetworkRecoveryTarget,
|
appliedNetworkRecoveryTarget,
|
||||||
|
desiredModeRevision,
|
||||||
reconfigurationRevision,
|
reconfigurationRevision,
|
||||||
reconfigurationIntentId,
|
reconfigurationIntentId,
|
||||||
)
|
)
|
||||||
@@ -2644,6 +2672,7 @@ export function K1ProvisioningPipeline({
|
|||||||
async (target) => {
|
async (target) => {
|
||||||
const request = observationRequest(
|
const request = observationRequest(
|
||||||
target,
|
target,
|
||||||
|
desiredModeRevision,
|
||||||
reconfigurationRevision,
|
reconfigurationRevision,
|
||||||
reconfigurationIntentId,
|
reconfigurationIntentId,
|
||||||
);
|
);
|
||||||
@@ -2709,12 +2738,23 @@ export function K1ProvisioningPipeline({
|
|||||||
if (
|
if (
|
||||||
!physicalRecoveryRequired
|
!physicalRecoveryRequired
|
||||||
|| !physicalRecoveryTarget?.serverBound
|
|| !physicalRecoveryTarget?.serverBound
|
||||||
|
|| physicalRecoveryTarget.connectionMode !== connectionMode
|
||||||
|
|| state?.desired_connection_mode !== connectionMode
|
||||||
|| !snapshotRuntimeId
|
|| !snapshotRuntimeId
|
||||||
|| !verificationKey
|
|| !verificationKey
|
||||||
|| isBusy
|
|| isBusy
|
||||||
|
|| typeof getConnectionActionAuthority !== "function"
|
||||||
) return;
|
) return;
|
||||||
|
const modeAuthority = getConnectionActionAuthority(connectionMode);
|
||||||
|
if (!modeAuthority) {
|
||||||
|
setCandidateUnavailableMessage(
|
||||||
|
"Не удалось переподключиться: состояние K1 изменилось. Повторите проверку или подключите новый K1 вручную.",
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const request = observationRequest(
|
const request = observationRequest(
|
||||||
physicalRecoveryTarget,
|
physicalRecoveryTarget,
|
||||||
|
modeAuthority.desiredModeRevision,
|
||||||
reconfigurationRevision,
|
reconfigurationRevision,
|
||||||
reconfigurationIntentId,
|
reconfigurationIntentId,
|
||||||
);
|
);
|
||||||
@@ -2734,19 +2774,10 @@ export function K1ProvisioningPipeline({
|
|||||||
});
|
});
|
||||||
setCandidateUnavailableMessage(null);
|
setCandidateUnavailableMessage(null);
|
||||||
try {
|
try {
|
||||||
const modeAuthority = await commitDesiredModeForExplicitAction(
|
|
||||||
physicalRecoveryTarget.connectionMode,
|
|
||||||
);
|
|
||||||
if (!localScenarioActionEpochIsCurrent(
|
if (!localScenarioActionEpochIsCurrent(
|
||||||
actionEpoch,
|
actionEpoch,
|
||||||
localScenarioActionEpoch.current,
|
localScenarioActionEpoch.current,
|
||||||
)) return;
|
)) return;
|
||||||
if (!modeAuthority) {
|
|
||||||
setCandidateUnavailableMessage(
|
|
||||||
"Не удалось переподключиться: состояние K1 изменилось. Повторите проверку или подключите новый K1 вручную.",
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const actionFence = activateRuntimeActionFence(modeAuthority);
|
const actionFence = activateRuntimeActionFence(modeAuthority);
|
||||||
if (!actionFence || !runtimeActionIsCurrent(actionFence)) return;
|
if (!actionFence || !runtimeActionIsCurrent(actionFence)) return;
|
||||||
try {
|
try {
|
||||||
@@ -3267,6 +3298,8 @@ export function K1ProvisioningPipeline({
|
|||||||
? "accent"
|
? "accent"
|
||||||
: connectionEstablished
|
: connectionEstablished
|
||||||
? "success"
|
? "success"
|
||||||
|
: connectionAttemptSettling
|
||||||
|
? "accent"
|
||||||
: networkRecoveryRequired
|
: networkRecoveryRequired
|
||||||
|| presentedPhysicalRecoveryRequired
|
|| presentedPhysicalRecoveryRequired
|
||||||
|| explicitProvisioningDraftStale
|
|| explicitProvisioningDraftStale
|
||||||
@@ -3301,7 +3334,7 @@ export function K1ProvisioningPipeline({
|
|||||||
?? "Сервис не определил точный прежний K1 для безопасной проверки"}
|
?? "Сервис не определил точный прежний K1 для безопасной проверки"}
|
||||||
</small>
|
</small>
|
||||||
</div>
|
</div>
|
||||||
) : presentedPhysicalRecoveryRequired ? (
|
) : presentedPhysicalRecoveryRequired && !connectionAttemptSettling ? (
|
||||||
<div
|
<div
|
||||||
className="field-stack"
|
className="field-stack"
|
||||||
aria-busy={physicalReconnectPending || undefined}
|
aria-busy={physicalReconnectPending || undefined}
|
||||||
@@ -3359,7 +3392,7 @@ export function K1ProvisioningPipeline({
|
|||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
) : presentedConnectionRecoveryRequired ? (
|
) : presentedConnectionRecoveryRequired && !connectionAttemptSettling ? (
|
||||||
<div
|
<div
|
||||||
className="field-stack"
|
className="field-stack"
|
||||||
aria-busy={connectionReconnectPending || undefined}
|
aria-busy={connectionReconnectPending || undefined}
|
||||||
|
|||||||
@@ -466,6 +466,26 @@ export function sourceStatusLabel(state: XgridsK1State | null | undefined): stri
|
|||||||
return "Ожидание";
|
return "Ожидание";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The network write has already crossed its single mutation boundary and the
|
||||||
|
* service is now proving control authority without another BLE command. This
|
||||||
|
* current connection intent must remain visibly pending even when an older
|
||||||
|
* physical-command checkpoint is being reconciled by the same bootstrap.
|
||||||
|
*/
|
||||||
|
export function isConnectionControlBootstrapSettling(
|
||||||
|
state: XgridsK1State | null | undefined,
|
||||||
|
): boolean {
|
||||||
|
const attempt = state?.connection_attempt;
|
||||||
|
if (
|
||||||
|
!attempt
|
||||||
|
|| attempt.phase !== "network_applied"
|
||||||
|
|| attempt.control_state !== "unknown"
|
||||||
|
) return false;
|
||||||
|
if (["accepted", "running"].includes(attempt.status)) return true;
|
||||||
|
return attempt.status === "succeeded"
|
||||||
|
&& attempt.safe_next_action === "verify-control-read-only";
|
||||||
|
}
|
||||||
|
|
||||||
export function operationByIdempotencyKey(
|
export function operationByIdempotencyKey(
|
||||||
state: XgridsK1State | null | undefined,
|
state: XgridsK1State | null | undefined,
|
||||||
action: string,
|
action: string,
|
||||||
|
|||||||
@@ -120,6 +120,18 @@ PYTEST_TARGETS = (
|
|||||||
"tests/test_xgrids_acquisition_lifecycle.py::"
|
"tests/test_xgrids_acquisition_lifecycle.py::"
|
||||||
"test_quick_to_bridge_does_not_retrieve_old_handle_without_new_scan"
|
"test_quick_to_bridge_does_not_retrieve_old_handle_without_new_scan"
|
||||||
),
|
),
|
||||||
|
(
|
||||||
|
"tests/test_xgrids_acquisition_lifecycle.py::"
|
||||||
|
"test_quick_verify_is_rejected_while_bridge_is_selected_before_any_io"
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"tests/test_xgrids_acquisition_lifecycle.py::"
|
||||||
|
"test_physical_recovery_cannot_silently_change_bridge_draft_to_quick"
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"tests/test_xgrids_acquisition_lifecycle.py::"
|
||||||
|
"test_public_quick_physical_recovery_rejoins_saved_wifi_after_live_ble_proof"
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1076,6 +1076,10 @@ class ConnectionVerifyRequest(StrictRequest):
|
|||||||
)
|
)
|
||||||
compatibility_attestation: CompatibilityAttestationRequest | None = None
|
compatibility_attestation: CompatibilityAttestationRequest | None = None
|
||||||
operation_id: str | None = Field(default=None, min_length=1, max_length=128)
|
operation_id: str | None = Field(default=None, min_length=1, max_length=128)
|
||||||
|
# New clients bind Verify to the exact mode draft rendered at the click.
|
||||||
|
# Optionality keeps the legacy server-resolved `{}` request and older
|
||||||
|
# local clients readable; mode equality remains mandatory either way.
|
||||||
|
expected_mode_revision: int | None = Field(default=None, ge=0)
|
||||||
expected_discovery_generation: int | None = Field(default=None, ge=0)
|
expected_discovery_generation: int | None = Field(default=None, ge=0)
|
||||||
expected_reconfiguration_revision: int | None = Field(default=None, ge=0)
|
expected_reconfiguration_revision: int | None = Field(default=None, ge=0)
|
||||||
expected_reconfiguration_intent_id: str | None = Field(
|
expected_reconfiguration_intent_id: str | None = Field(
|
||||||
@@ -8236,7 +8240,10 @@ class XgridsK1CompatibilityService:
|
|||||||
active_profile_id,
|
active_profile_id,
|
||||||
device_session_id,
|
device_session_id,
|
||||||
camera_preview,
|
camera_preview,
|
||||||
activation_admitted=camera_activation_admitted,
|
# Automatic acquisition camera ownership is deliberately not
|
||||||
|
# manual preview authority. The selected delivery remains
|
||||||
|
# visible, but source controls cannot detach evidence.
|
||||||
|
activation_admitted=(camera_activation_admitted and not acquisition_active),
|
||||||
),
|
),
|
||||||
"device_calibration": device_calibration,
|
"device_calibration": device_calibration,
|
||||||
"camera_preview": camera_preview,
|
"camera_preview": camera_preview,
|
||||||
@@ -9400,6 +9407,22 @@ class XgridsK1CompatibilityService:
|
|||||||
runtime = self.runtime.snapshot()
|
runtime = self.runtime.snapshot()
|
||||||
control = self._application_control_session.snapshot()
|
control = self._application_control_session.snapshot()
|
||||||
physical = self._physical_command_coordinator.snapshot()
|
physical = self._physical_command_coordinator.snapshot()
|
||||||
|
physical_recovery_requires_explicit_reset = bool(
|
||||||
|
physical.get("resolved_unclassified_stop_recovery_required")
|
||||||
|
is True
|
||||||
|
)
|
||||||
|
if physical_recovery_requires_explicit_reset:
|
||||||
|
# A recovery card is bound to the mode recorded by the
|
||||||
|
# physical ledger. An old browser must never turn its
|
||||||
|
# action into a hidden mode change; only the visible
|
||||||
|
# scenario-reset control may retire that ownership.
|
||||||
|
raise NetworkProvisioningConflict(
|
||||||
|
"незавершённое физическое состояние K1 требует явного "
|
||||||
|
"сброса сценария перед сменой способа подключения",
|
||||||
|
reason_code=(
|
||||||
|
"connection-mode-selection-physical-recovery-reset-required"
|
||||||
|
),
|
||||||
|
)
|
||||||
acquisition_state = acquisition.state if acquisition is not None else None
|
acquisition_state = acquisition.state if acquisition is not None else None
|
||||||
selection_reasons = _connection_mode_selection_reason_codes(
|
selection_reasons = _connection_mode_selection_reason_codes(
|
||||||
acquisition_state=acquisition_state,
|
acquisition_state=acquisition_state,
|
||||||
@@ -9694,6 +9717,39 @@ class XgridsK1CompatibilityService:
|
|||||||
reason_code="network-provision-discovery-generation-conflict",
|
reason_code="network-provision-discovery-generation-conflict",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def _require_connection_mode_draft_for_verify(
|
||||||
|
self,
|
||||||
|
requested_mode: ConnectionMode | None,
|
||||||
|
*,
|
||||||
|
expected_mode_revision: int | None,
|
||||||
|
) -> None:
|
||||||
|
"""Fence Verify before BLE, host Wi-Fi, MQTT or topology mutation."""
|
||||||
|
|
||||||
|
with self._lock:
|
||||||
|
desired = self._desired_connection_mode
|
||||||
|
revision = self._desired_connection_mode_revision
|
||||||
|
scenario_reset_pending = self._connection_scenario_reset_pending is not None
|
||||||
|
if scenario_reset_pending:
|
||||||
|
raise ConnectionVerificationError(
|
||||||
|
"сначала завершается явная смена сценария подключения",
|
||||||
|
reason_code="connection-mode-switch-pending",
|
||||||
|
)
|
||||||
|
if requested_mode is None:
|
||||||
|
raise ConnectionVerificationError(
|
||||||
|
"Read-only проверка K1 не получила точный способ подключения",
|
||||||
|
reason_code="connection-verify-connection-missing",
|
||||||
|
)
|
||||||
|
if expected_mode_revision is not None and expected_mode_revision != revision:
|
||||||
|
raise ConnectionVerificationError(
|
||||||
|
"способ подключения изменился до начала проверки",
|
||||||
|
reason_code="connection-mode-draft-revision-conflict",
|
||||||
|
)
|
||||||
|
if requested_mode != desired:
|
||||||
|
raise ConnectionVerificationError(
|
||||||
|
"проверяемое подключение не совпадает с выбранным способом",
|
||||||
|
reason_code="connection-mode-draft-mismatch",
|
||||||
|
)
|
||||||
|
|
||||||
def _require_current_connection_reconfiguration_target(
|
def _require_current_connection_reconfiguration_target(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
@@ -12829,6 +12885,8 @@ class XgridsK1CompatibilityService:
|
|||||||
|
|
||||||
def _validate_active_acquisition_checkpoint_lineage(
|
def _validate_active_acquisition_checkpoint_lineage(
|
||||||
self,
|
self,
|
||||||
|
*,
|
||||||
|
allow_untrusted_terminal_settlement: bool = False,
|
||||||
) -> _ActiveAcquisitionCheckpointTrustToken | None:
|
) -> _ActiveAcquisitionCheckpointTrustToken | None:
|
||||||
"""Recompute the current unceased checkpoint/ledger lineage locally.
|
"""Recompute the current unceased checkpoint/ledger lineage locally.
|
||||||
|
|
||||||
@@ -12836,13 +12894,23 @@ class XgridsK1CompatibilityService:
|
|||||||
ledger and immutable archive retain its proof material. This method is
|
ledger and immutable archive retain its proof material. This method is
|
||||||
deliberately read-only: a mismatch revokes local START/resume admission
|
deliberately read-only: a mismatch revokes local START/resume admission
|
||||||
for the process lifetime and never repairs either durable document.
|
for the process lifetime and never repairs either durable document.
|
||||||
|
|
||||||
|
``allow_untrusted_terminal_settlement`` is reserved for one narrower
|
||||||
|
caller which has already durably committed an exact, non-retained
|
||||||
|
READY/SCAN_OVER reconciliation. It may recompute a token only to cease
|
||||||
|
the old checkpoint; process-local START/resume trust stays revoked until
|
||||||
|
that terminal CAS has succeeded.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
if getattr(
|
current_trust = getattr(
|
||||||
self,
|
self,
|
||||||
"_active_acquisition_checkpoint_trust",
|
"_active_acquisition_checkpoint_trust",
|
||||||
"trusted",
|
"trusted",
|
||||||
) != "trusted":
|
)
|
||||||
|
if current_trust != "trusted" and not (
|
||||||
|
allow_untrusted_terminal_settlement
|
||||||
|
and current_trust == "unavailable"
|
||||||
|
):
|
||||||
return None
|
return None
|
||||||
store = self._active_acquisition_checkpoint
|
store = self._active_acquisition_checkpoint
|
||||||
if store is None:
|
if store is None:
|
||||||
@@ -12967,6 +13035,110 @@ class XgridsK1CompatibilityService:
|
|||||||
)
|
)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
def _settle_untrusted_checkpoint_after_verified_standby(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
reconciliation_id: str,
|
||||||
|
reconciled_record: Mapping[str, Any],
|
||||||
|
) -> bool:
|
||||||
|
"""Consume a new durable standby proof without reviving START authority.
|
||||||
|
|
||||||
|
A process can reject an ACTIVE checkpoint while an older STOP is still
|
||||||
|
unresolved. If the same process later appends an exact read-only
|
||||||
|
READY/SCAN_OVER reconciliation, that durable change is new evidence.
|
||||||
|
Recompute the lineage only for terminal checkpoint cessation; no K1
|
||||||
|
command, network mutation, runtime rehydration, or acquisition authority
|
||||||
|
is permitted by this path.
|
||||||
|
"""
|
||||||
|
|
||||||
|
if getattr(
|
||||||
|
self,
|
||||||
|
"_active_acquisition_checkpoint_trust",
|
||||||
|
"trusted",
|
||||||
|
) != "unavailable":
|
||||||
|
return False
|
||||||
|
store = self._active_acquisition_checkpoint
|
||||||
|
if store is None:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
checkpoint_snapshot = store.snapshot()
|
||||||
|
ledger_snapshot = self._physical_command_ledger.snapshot()
|
||||||
|
record = ledger_snapshot.record
|
||||||
|
reconciliation = (
|
||||||
|
record.reconciliations[-1]
|
||||||
|
if record is not None and record.reconciliations
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
if not (
|
||||||
|
checkpoint_snapshot.status in {"prepared", "active"}
|
||||||
|
and checkpoint_snapshot.checkpoint is not None
|
||||||
|
and ledger_snapshot.status == "resolved"
|
||||||
|
and record is not None
|
||||||
|
and record.as_dict() == dict(reconciled_record)
|
||||||
|
and reconciliation is not None
|
||||||
|
and reconciliation.reconciliation_id == reconciliation_id
|
||||||
|
and reconciliation.resolution == "physical-standby-observed"
|
||||||
|
and reconciliation.kind
|
||||||
|
in {
|
||||||
|
"ambiguous-outcome",
|
||||||
|
"prepared-stop-classification",
|
||||||
|
"resolved-active-cessation",
|
||||||
|
}
|
||||||
|
and reconciliation.observation.source
|
||||||
|
== "explicit-read-only-reconciliation"
|
||||||
|
and reconciliation.observation.session_state
|
||||||
|
in {"ready", "scan_over"}
|
||||||
|
and not reconciliation.observation.project_bound
|
||||||
|
and not reconciliation.observation.init_ready
|
||||||
|
and not reconciliation.observation.mqtt_retained
|
||||||
|
):
|
||||||
|
return False
|
||||||
|
token = self._validate_active_acquisition_checkpoint_lineage(
|
||||||
|
allow_untrusted_terminal_settlement=True,
|
||||||
|
)
|
||||||
|
if token is None:
|
||||||
|
return False
|
||||||
|
settled = self._settle_restart_checkpoint_after_verified_standby(
|
||||||
|
token=token,
|
||||||
|
reconciliation_id=reconciliation_id,
|
||||||
|
reconciled_record=reconciled_record,
|
||||||
|
)
|
||||||
|
if not settled:
|
||||||
|
return False
|
||||||
|
committed = store.snapshot().checkpoint
|
||||||
|
if not (
|
||||||
|
committed is not None
|
||||||
|
and committed.state == "ceased"
|
||||||
|
and committed.acquisition_id == token.acquisition_id
|
||||||
|
and committed.original_start_operation_id
|
||||||
|
== token.root_start_operation_id
|
||||||
|
):
|
||||||
|
return False
|
||||||
|
with self._lock:
|
||||||
|
if self._active_acquisition_checkpoint_trust == "unavailable":
|
||||||
|
self._active_acquisition_checkpoint_trust = "trusted"
|
||||||
|
self._active_acquisition_checkpoint_reason = None
|
||||||
|
logger.info(
|
||||||
|
"untrusted K1 checkpoint closed from new durable standby proof",
|
||||||
|
extra={
|
||||||
|
"event_code": (
|
||||||
|
"active_acquisition_checkpoint_untrusted_standby_settled"
|
||||||
|
),
|
||||||
|
"acquisition_id": token.acquisition_id,
|
||||||
|
"reconciliation_id": reconciliation_id,
|
||||||
|
"device_write_performed": False,
|
||||||
|
"automatic_retry": False,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
except (
|
||||||
|
ActiveAcquisitionRecoveryCheckpointError,
|
||||||
|
PhysicalCommandLedgerError,
|
||||||
|
OSError,
|
||||||
|
ValueError,
|
||||||
|
):
|
||||||
|
return False
|
||||||
|
|
||||||
def _require_active_acquisition_checkpoint_store(
|
def _require_active_acquisition_checkpoint_store(
|
||||||
self,
|
self,
|
||||||
) -> ActiveAcquisitionRecoveryCheckpointStore:
|
) -> ActiveAcquisitionRecoveryCheckpointStore:
|
||||||
@@ -15700,6 +15872,10 @@ class XgridsK1CompatibilityService:
|
|||||||
else None
|
else None
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
|
self._require_connection_mode_draft_for_verify(
|
||||||
|
cast(ConnectionMode | None, requested_mode),
|
||||||
|
expected_mode_revision=request.expected_mode_revision,
|
||||||
|
)
|
||||||
self._require_current_connection_reconfiguration_target(
|
self._require_current_connection_reconfiguration_target(
|
||||||
expected_revision=request.expected_reconfiguration_revision,
|
expected_revision=request.expected_reconfiguration_revision,
|
||||||
expected_intent_id=request.expected_reconfiguration_intent_id,
|
expected_intent_id=request.expected_reconfiguration_intent_id,
|
||||||
@@ -15746,6 +15922,10 @@ class XgridsK1CompatibilityService:
|
|||||||
"фоновая проверка подключения не завершилась вовремя",
|
"фоновая проверка подключения не завершилась вовремя",
|
||||||
reason_code="connection-verify-lifecycle-busy",
|
reason_code="connection-verify-lifecycle-busy",
|
||||||
)
|
)
|
||||||
|
self._require_connection_mode_draft_for_verify(
|
||||||
|
cast(ConnectionMode | None, requested_mode),
|
||||||
|
expected_mode_revision=request.expected_mode_revision,
|
||||||
|
)
|
||||||
self._require_current_connection_reconfiguration_target(
|
self._require_current_connection_reconfiguration_target(
|
||||||
expected_revision=request.expected_reconfiguration_revision,
|
expected_revision=request.expected_reconfiguration_revision,
|
||||||
expected_intent_id=request.expected_reconfiguration_intent_id,
|
expected_intent_id=request.expected_reconfiguration_intent_id,
|
||||||
@@ -16041,8 +16221,7 @@ class XgridsK1CompatibilityService:
|
|||||||
else None
|
else None
|
||||||
)
|
)
|
||||||
checkpoint_settlement_required = bool(
|
checkpoint_settlement_required = bool(
|
||||||
checkpoint_trust_token is not None
|
ledger_snapshot.status == "resolved"
|
||||||
and ledger_snapshot.status == "resolved"
|
|
||||||
and record is not None
|
and record is not None
|
||||||
and latest_reconciliation is not None
|
and latest_reconciliation is not None
|
||||||
and latest_reconciliation.resolution
|
and latest_reconciliation.resolution
|
||||||
@@ -16064,14 +16243,24 @@ class XgridsK1CompatibilityService:
|
|||||||
if checkpoint_settlement_required:
|
if checkpoint_settlement_required:
|
||||||
assert record is not None
|
assert record is not None
|
||||||
assert latest_reconciliation is not None
|
assert latest_reconciliation is not None
|
||||||
settled = self._settle_restart_checkpoint_after_verified_standby(
|
if checkpoint_trust_token is not None:
|
||||||
token=checkpoint_trust_token,
|
settled = self._settle_restart_checkpoint_after_verified_standby(
|
||||||
reconciliation_id=(
|
token=checkpoint_trust_token,
|
||||||
latest_reconciliation.reconciliation_id
|
reconciliation_id=(
|
||||||
),
|
latest_reconciliation.reconciliation_id
|
||||||
reconciled_record=record.as_dict(),
|
),
|
||||||
)
|
reconciled_record=record.as_dict(),
|
||||||
if not settled:
|
)
|
||||||
|
else:
|
||||||
|
settled = (
|
||||||
|
self._settle_untrusted_checkpoint_after_verified_standby(
|
||||||
|
reconciliation_id=(
|
||||||
|
latest_reconciliation.reconciliation_id
|
||||||
|
),
|
||||||
|
reconciled_record=record.as_dict(),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if checkpoint_trust_token is not None and not settled:
|
||||||
raise ConnectionVerificationError(
|
raise ConnectionVerificationError(
|
||||||
"Подтверждённый READY не закрыл старую локальную START-сессию",
|
"Подтверждённый READY не закрыл старую локальную START-сессию",
|
||||||
reason_code=(
|
reason_code=(
|
||||||
@@ -16305,11 +16494,19 @@ class XgridsK1CompatibilityService:
|
|||||||
checkpoint_lineage=checkpoint_lineage,
|
checkpoint_lineage=checkpoint_lineage,
|
||||||
)
|
)
|
||||||
elif reconciled_record is not None:
|
elif reconciled_record is not None:
|
||||||
settled = self._settle_restart_checkpoint_after_verified_standby(
|
if checkpoint_trust_token is not None:
|
||||||
token=checkpoint_trust_token,
|
settled = self._settle_restart_checkpoint_after_verified_standby(
|
||||||
reconciliation_id=reconciliation_id,
|
token=checkpoint_trust_token,
|
||||||
reconciled_record=reconciled_record,
|
reconciliation_id=reconciliation_id,
|
||||||
)
|
reconciled_record=reconciled_record,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
settled = (
|
||||||
|
self._settle_untrusted_checkpoint_after_verified_standby(
|
||||||
|
reconciliation_id=reconciliation_id,
|
||||||
|
reconciled_record=reconciled_record,
|
||||||
|
)
|
||||||
|
)
|
||||||
if checkpoint_trust_token is not None and not settled:
|
if checkpoint_trust_token is not None and not settled:
|
||||||
raise ActiveAcquisitionRecoveryCheckpointError(
|
raise ActiveAcquisitionRecoveryCheckpointError(
|
||||||
"restart standby checkpoint settlement failed closed"
|
"restart standby checkpoint settlement failed closed"
|
||||||
@@ -17944,6 +18141,7 @@ class XgridsK1CompatibilityService:
|
|||||||
if isinstance(item.get("device_id"), str) and str(item.get("device_id")).strip()
|
if isinstance(item.get("device_id"), str) and str(item.get("device_id")).strip()
|
||||||
}
|
}
|
||||||
discovery_generation = self._ble_discovery_generation
|
discovery_generation = self._ble_discovery_generation
|
||||||
|
desired_mode_revision = self._desired_connection_mode_revision
|
||||||
selected_device_id = self._selected_device_id
|
selected_device_id = self._selected_device_id
|
||||||
selected_connection_mode = self._connection_mode
|
selected_connection_mode = self._connection_mode
|
||||||
selected_device_session_id = self._device_session_id
|
selected_device_session_id = self._device_session_id
|
||||||
@@ -18026,6 +18224,11 @@ class XgridsK1CompatibilityService:
|
|||||||
verification="live-device-info",
|
verification="live-device-info",
|
||||||
),
|
),
|
||||||
operation_id=request.operation_id,
|
operation_id=request.operation_id,
|
||||||
|
expected_mode_revision=(
|
||||||
|
request.expected_mode_revision
|
||||||
|
if request.expected_mode_revision is not None
|
||||||
|
else desired_mode_revision
|
||||||
|
),
|
||||||
expected_discovery_generation=(
|
expected_discovery_generation=(
|
||||||
discovery_generation if source == "fresh-scan" else None
|
discovery_generation if source == "fresh-scan" else None
|
||||||
),
|
),
|
||||||
@@ -18054,7 +18257,11 @@ class XgridsK1CompatibilityService:
|
|||||||
"direct-lan": "bridge",
|
"direct-lan": "bridge",
|
||||||
"device-ap": "quick-connect",
|
"device-ap": "quick-connect",
|
||||||
"controller-hotspot": "direct-connect",
|
"controller-hotspot": "direct-connect",
|
||||||
}[request.compatibility_attestation.topology],
|
}[request.compatibility_attestation.topology],
|
||||||
|
)
|
||||||
|
self._require_connection_mode_draft_for_verify(
|
||||||
|
requested_mode,
|
||||||
|
expected_mode_revision=request.expected_mode_revision,
|
||||||
)
|
)
|
||||||
self._require_current_connection_reconfiguration_target(
|
self._require_current_connection_reconfiguration_target(
|
||||||
expected_revision=request.expected_reconfiguration_revision,
|
expected_revision=request.expected_reconfiguration_revision,
|
||||||
@@ -18085,6 +18292,7 @@ class XgridsK1CompatibilityService:
|
|||||||
{
|
{
|
||||||
"device_id": request.device_id,
|
"device_id": request.device_id,
|
||||||
"source": request.source,
|
"source": request.source,
|
||||||
|
"expected_mode_revision": request.expected_mode_revision,
|
||||||
"expected_discovery_generation": (request.expected_discovery_generation),
|
"expected_discovery_generation": (request.expected_discovery_generation),
|
||||||
"expected_reconfiguration_revision": (request.expected_reconfiguration_revision),
|
"expected_reconfiguration_revision": (request.expected_reconfiguration_revision),
|
||||||
"expected_reconfiguration_intent_id": (request.expected_reconfiguration_intent_id),
|
"expected_reconfiguration_intent_id": (request.expected_reconfiguration_intent_id),
|
||||||
@@ -18222,6 +18430,7 @@ class XgridsK1CompatibilityService:
|
|||||||
request.compatibility_attestation,
|
request.compatibility_attestation,
|
||||||
verification_source=request.source,
|
verification_source=request.source,
|
||||||
expected_discovery_generation=(request.expected_discovery_generation),
|
expected_discovery_generation=(request.expected_discovery_generation),
|
||||||
|
expected_mode_revision=request.expected_mode_revision,
|
||||||
require_live_gatt_validation=(physical_recovery_target is not None),
|
require_live_gatt_validation=(physical_recovery_target is not None),
|
||||||
)
|
)
|
||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
@@ -18339,9 +18548,14 @@ class XgridsK1CompatibilityService:
|
|||||||
*,
|
*,
|
||||||
transport_ref: str,
|
transport_ref: str,
|
||||||
target_ipv4: str,
|
target_ipv4: str,
|
||||||
|
expected_mode_revision: int | None,
|
||||||
) -> _SavedQuickConnectHostAssociation:
|
) -> _SavedQuickConnectHostAssociation:
|
||||||
"""Restore the controller-side AP route without touching K1 over BLE."""
|
"""Restore the controller-side AP route without touching K1 over BLE."""
|
||||||
|
|
||||||
|
self._require_connection_mode_draft_for_verify(
|
||||||
|
"quick-connect",
|
||||||
|
expected_mode_revision=expected_mode_revision,
|
||||||
|
)
|
||||||
binding = _recover_durable_quick_connect_host_binding(
|
binding = _recover_durable_quick_connect_host_binding(
|
||||||
self.evidence_root,
|
self.evidence_root,
|
||||||
transport_ref=transport_ref,
|
transport_ref=transport_ref,
|
||||||
@@ -18401,6 +18615,13 @@ class XgridsK1CompatibilityService:
|
|||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
if association_performed:
|
if association_performed:
|
||||||
|
# Re-sample immediately before the only host-network mutation.
|
||||||
|
# A queued scenario reset or stale UI revision wins without
|
||||||
|
# invoking CoreWLAN.
|
||||||
|
self._require_connection_mode_draft_for_verify(
|
||||||
|
"quick-connect",
|
||||||
|
expected_mode_revision=expected_mode_revision,
|
||||||
|
)
|
||||||
association = await _run_blocking_operation_without_abandonment(
|
association = await _run_blocking_operation_without_abandonment(
|
||||||
associate_with_wifi_profile_once,
|
associate_with_wifi_profile_once,
|
||||||
self.repository_root
|
self.repository_root
|
||||||
@@ -18504,6 +18725,109 @@ class XgridsK1CompatibilityService:
|
|||||||
evidence_session_dir=session_dir,
|
evidence_session_dir=session_dir,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
async def _restore_saved_quick_connect_host_path(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
transport_ref: str,
|
||||||
|
target_ipv4: str,
|
||||||
|
ble_operation_performed: bool,
|
||||||
|
expected_mode_revision: int | None,
|
||||||
|
) -> tuple[
|
||||||
|
_SavedQuickConnectHostAssociation,
|
||||||
|
_ConfiguredEndpointHostObservation,
|
||||||
|
int,
|
||||||
|
]:
|
||||||
|
"""Restore one exact saved AP route and prove its sole MQTT endpoint."""
|
||||||
|
|
||||||
|
association = await self._associate_saved_quick_connect_host(
|
||||||
|
transport_ref=transport_ref,
|
||||||
|
target_ipv4=target_ipv4,
|
||||||
|
expected_mode_revision=expected_mode_revision,
|
||||||
|
)
|
||||||
|
supervisor_epoch_before = self._connection_supervisor.snapshot().host_path.epoch
|
||||||
|
try:
|
||||||
|
observation = await _run_blocking_operation_without_abandonment(
|
||||||
|
_probe_quick_connect_endpoint_after_route_settle,
|
||||||
|
target_ipv4,
|
||||||
|
path_probe=lambda target: self._sample_host_path(
|
||||||
|
target,
|
||||||
|
association_timeout_seconds=(
|
||||||
|
CONNECTION_MONITOR_ASSOCIATION_TIMEOUT_SECONDS
|
||||||
|
),
|
||||||
|
),
|
||||||
|
settle_timeout_seconds=(
|
||||||
|
DURABLE_QUICK_CONNECT_ROUTE_SETTLE_TIMEOUT_SECONDS
|
||||||
|
),
|
||||||
|
settle_interval_seconds=(
|
||||||
|
DURABLE_QUICK_CONNECT_ROUTE_SETTLE_INTERVAL_SECONDS
|
||||||
|
),
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
_write_quick_connect_host_network_proof(
|
||||||
|
association,
|
||||||
|
target_ipv4=target_ipv4,
|
||||||
|
observation=None,
|
||||||
|
outcome="probe-failed",
|
||||||
|
reason_code=(
|
||||||
|
getattr(exc, "reason_code", None) or "host-network-proof-failed"
|
||||||
|
),
|
||||||
|
supervisor_host_path_epoch_before_admission=supervisor_epoch_before,
|
||||||
|
ble_operation_performed=ble_operation_performed,
|
||||||
|
)
|
||||||
|
raise
|
||||||
|
if observation.reason_code == "host-route-changed-during-tcp-probe":
|
||||||
|
_write_quick_connect_host_network_proof(
|
||||||
|
association,
|
||||||
|
target_ipv4=target_ipv4,
|
||||||
|
observation=observation,
|
||||||
|
outcome="route-changed",
|
||||||
|
reason_code="connection-verify-lease-changed",
|
||||||
|
supervisor_host_path_epoch_before_admission=supervisor_epoch_before,
|
||||||
|
ble_operation_performed=ble_operation_performed,
|
||||||
|
)
|
||||||
|
raise ConnectionVerificationError(
|
||||||
|
"Маршрут к K1 изменился во время Quick Connect проверки",
|
||||||
|
reason_code="connection-verify-lease-changed",
|
||||||
|
)
|
||||||
|
if not observation.path.available or observation.path.route_class != "direct":
|
||||||
|
_write_quick_connect_host_network_proof(
|
||||||
|
association,
|
||||||
|
target_ipv4=target_ipv4,
|
||||||
|
observation=observation,
|
||||||
|
outcome="route-rejected",
|
||||||
|
reason_code="connection-verify-route-mismatch",
|
||||||
|
supervisor_host_path_epoch_before_admission=supervisor_epoch_before,
|
||||||
|
ble_operation_performed=ble_operation_performed,
|
||||||
|
)
|
||||||
|
raise ConnectionVerificationError(
|
||||||
|
"После подключения Quick Connect прямой маршрут к K1 не подтверждён",
|
||||||
|
reason_code="connection-verify-route-mismatch",
|
||||||
|
)
|
||||||
|
if not observation.reachable:
|
||||||
|
_write_quick_connect_host_network_proof(
|
||||||
|
association,
|
||||||
|
target_ipv4=target_ipv4,
|
||||||
|
observation=observation,
|
||||||
|
outcome="endpoint-unreachable",
|
||||||
|
reason_code="connection-verify-mqtt-unreachable",
|
||||||
|
supervisor_host_path_epoch_before_admission=supervisor_epoch_before,
|
||||||
|
ble_operation_performed=ble_operation_performed,
|
||||||
|
)
|
||||||
|
raise ConnectionVerificationError(
|
||||||
|
"Прямой маршрут Quick Connect подтверждён, но MQTT endpoint K1 недоступен",
|
||||||
|
reason_code="connection-verify-mqtt-unreachable",
|
||||||
|
)
|
||||||
|
_write_quick_connect_host_network_proof(
|
||||||
|
association,
|
||||||
|
target_ipv4=target_ipv4,
|
||||||
|
observation=observation,
|
||||||
|
outcome="direct-endpoint-reachable",
|
||||||
|
reason_code=None,
|
||||||
|
supervisor_host_path_epoch_before_admission=supervisor_epoch_before,
|
||||||
|
ble_operation_performed=ble_operation_performed,
|
||||||
|
)
|
||||||
|
return association, observation, supervisor_epoch_before
|
||||||
|
|
||||||
async def _adopt_existing_lan_connection(
|
async def _adopt_existing_lan_connection(
|
||||||
self,
|
self,
|
||||||
device_id: str,
|
device_id: str,
|
||||||
@@ -18513,6 +18837,7 @@ class XgridsK1CompatibilityService:
|
|||||||
"fresh-scan", "retained-current-process", "durable-configured-state"
|
"fresh-scan", "retained-current-process", "durable-configured-state"
|
||||||
] = "fresh-scan",
|
] = "fresh-scan",
|
||||||
expected_discovery_generation: int | None = None,
|
expected_discovery_generation: int | None = None,
|
||||||
|
expected_mode_revision: int | None = None,
|
||||||
require_live_gatt_validation: bool = False,
|
require_live_gatt_validation: bool = False,
|
||||||
) -> tuple[str, _ProvisionalFreshBridgeTopology | None]:
|
) -> tuple[str, _ProvisionalFreshBridgeTopology | None]:
|
||||||
"""Create a process lease from live or persisted device topology evidence."""
|
"""Create a process lease from live or persisted device topology evidence."""
|
||||||
@@ -18526,6 +18851,10 @@ class XgridsK1CompatibilityService:
|
|||||||
requested_mode = "direct-connect"
|
requested_mode = "direct-connect"
|
||||||
else:
|
else:
|
||||||
raise ValueError("read-only K1 adoption received an unsupported topology")
|
raise ValueError("read-only K1 adoption received an unsupported topology")
|
||||||
|
self._require_connection_mode_draft_for_verify(
|
||||||
|
requested_mode,
|
||||||
|
expected_mode_revision=expected_mode_revision,
|
||||||
|
)
|
||||||
with self._lock:
|
with self._lock:
|
||||||
scanned_device = next(
|
scanned_device = next(
|
||||||
(
|
(
|
||||||
@@ -18877,6 +19206,10 @@ class XgridsK1CompatibilityService:
|
|||||||
# Publish the BLE verification fence before awaiting native I/O.
|
# Publish the BLE verification fence before awaiting native I/O.
|
||||||
# A scan cannot invalidate a capture or its validation handoff in
|
# A scan cannot invalidate a capture or its validation handoff in
|
||||||
# the post-GATT/pre-pin window.
|
# the post-GATT/pre-pin window.
|
||||||
|
self._require_connection_mode_draft_for_verify(
|
||||||
|
requested_mode,
|
||||||
|
expected_mode_revision=expected_mode_revision,
|
||||||
|
)
|
||||||
with self._lock:
|
with self._lock:
|
||||||
if self._provisioning_active:
|
if self._provisioning_active:
|
||||||
raise RuntimeError("настройка Wi-Fi началась во время проверки сети")
|
raise RuntimeError("настройка Wi-Fi началась во время проверки сети")
|
||||||
@@ -18897,9 +19230,14 @@ class XgridsK1CompatibilityService:
|
|||||||
quick_host_association: _SavedQuickConnectHostAssociation | None = None
|
quick_host_association: _SavedQuickConnectHostAssociation | None = None
|
||||||
quick_supervisor_epoch_before: int | None = None
|
quick_supervisor_epoch_before: int | None = None
|
||||||
if requested_mode == "quick-connect":
|
if requested_mode == "quick-connect":
|
||||||
|
self._require_connection_mode_draft_for_verify(
|
||||||
|
requested_mode,
|
||||||
|
expected_mode_revision=expected_mode_revision,
|
||||||
|
)
|
||||||
quick_host_association = await self._associate_saved_quick_connect_host(
|
quick_host_association = await self._associate_saved_quick_connect_host(
|
||||||
transport_ref=actual_transport_ref,
|
transport_ref=actual_transport_ref,
|
||||||
target_ipv4=semantic_record.ipv4,
|
target_ipv4=semantic_record.ipv4,
|
||||||
|
expected_mode_revision=expected_mode_revision,
|
||||||
)
|
)
|
||||||
quick_supervisor_epoch_before = (
|
quick_supervisor_epoch_before = (
|
||||||
self._connection_supervisor.snapshot().host_path.epoch
|
self._connection_supervisor.snapshot().host_path.epoch
|
||||||
@@ -19132,6 +19470,10 @@ class XgridsK1CompatibilityService:
|
|||||||
transport_source == "durable-state"
|
transport_source == "durable-state"
|
||||||
and require_live_gatt_validation
|
and require_live_gatt_validation
|
||||||
)
|
)
|
||||||
|
self._require_connection_mode_draft_for_verify(
|
||||||
|
requested_mode,
|
||||||
|
expected_mode_revision=expected_mode_revision,
|
||||||
|
)
|
||||||
status_read = await read_wifi_status_once(
|
status_read = await read_wifi_status_once(
|
||||||
actual_transport_ref,
|
actual_transport_ref,
|
||||||
timeout_seconds=20.0,
|
timeout_seconds=20.0,
|
||||||
@@ -19372,6 +19714,38 @@ class XgridsK1CompatibilityService:
|
|||||||
)
|
)
|
||||||
if retained_after_read["status"] != "retained":
|
if retained_after_read["status"] != "retained":
|
||||||
raise RuntimeError("process recovery token изменился после GATT validation")
|
raise RuntimeError("process recovery token изменился после GATT validation")
|
||||||
|
live_quick_host_association: _SavedQuickConnectHostAssociation | None = None
|
||||||
|
live_quick_endpoint_observation: _ConfiguredEndpointHostObservation | None = None
|
||||||
|
live_quick_supervisor_epoch_before: int | None = None
|
||||||
|
if requested_mode == "quick-connect" and require_live_gatt_validation:
|
||||||
|
exact_saved_quick_topology = bool(
|
||||||
|
expected_reconciliation is None
|
||||||
|
and semantic_record is not None
|
||||||
|
and physical_transport_ref_comparison_key(semantic_record.transport_ref)
|
||||||
|
== physical_transport_ref_comparison_key(actual_transport_ref)
|
||||||
|
and semantic_record.connection_mode == "quick-connect"
|
||||||
|
and semantic_record.ipv4 == target
|
||||||
|
and semantic_record.compatibility_profile_id
|
||||||
|
== XGRIDS_K1_COMPATIBILITY_PROFILE_ID
|
||||||
|
and semantic_record.firmware_version
|
||||||
|
== compatibility_attestation.firmware_version
|
||||||
|
)
|
||||||
|
if not exact_saved_quick_topology:
|
||||||
|
raise ConnectionVerificationError(
|
||||||
|
"Live Quick Connect recovery не имеет точной сохранённой topology "
|
||||||
|
"этого K1; подключение Wi-Fi Mac не выполнялось",
|
||||||
|
reason_code="connection-verify-quick-host-binding-unavailable",
|
||||||
|
)
|
||||||
|
(
|
||||||
|
live_quick_host_association,
|
||||||
|
live_quick_endpoint_observation,
|
||||||
|
live_quick_supervisor_epoch_before,
|
||||||
|
) = await self._restore_saved_quick_connect_host_path(
|
||||||
|
transport_ref=actual_transport_ref,
|
||||||
|
target_ipv4=target,
|
||||||
|
ble_operation_performed=True,
|
||||||
|
expected_mode_revision=expected_mode_revision,
|
||||||
|
)
|
||||||
defer_fresh_bridge_semantic_commit = bool(
|
defer_fresh_bridge_semantic_commit = bool(
|
||||||
verification_source == "fresh-scan"
|
verification_source == "fresh-scan"
|
||||||
and requested_mode == "bridge"
|
and requested_mode == "bridge"
|
||||||
@@ -19538,11 +19912,17 @@ class XgridsK1CompatibilityService:
|
|||||||
raise RuntimeError(
|
raise RuntimeError(
|
||||||
"K1 сообщил адрес другой сети; прямой локальный маршрут отсутствует"
|
"K1 сообщил адрес другой сети; прямой локальный маршрут отсутствует"
|
||||||
)
|
)
|
||||||
control_endpoint_observation = await _run_blocking_operation_without_abandonment(
|
control_endpoint_observation = live_quick_endpoint_observation
|
||||||
self._probe_control_endpoint,
|
if control_endpoint_observation is None:
|
||||||
target,
|
control_endpoint_observation = (
|
||||||
association_timeout_seconds=COMMAND_BOUND_ASSOCIATION_TIMEOUT_SECONDS,
|
await _run_blocking_operation_without_abandonment(
|
||||||
)
|
self._probe_control_endpoint,
|
||||||
|
target,
|
||||||
|
association_timeout_seconds=(
|
||||||
|
COMMAND_BOUND_ASSOCIATION_TIMEOUT_SECONDS
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
if not control_endpoint_observation.reachable:
|
if not control_endpoint_observation.reachable:
|
||||||
self._update_applied_topology_reachability(
|
self._update_applied_topology_reachability(
|
||||||
connection_mode=requested_mode,
|
connection_mode=requested_mode,
|
||||||
@@ -19634,6 +20014,21 @@ class XgridsK1CompatibilityService:
|
|||||||
path=control_endpoint_observation.path,
|
path=control_endpoint_observation.path,
|
||||||
reachable=True,
|
reachable=True,
|
||||||
)
|
)
|
||||||
|
if live_quick_host_association is not None:
|
||||||
|
_write_quick_connect_host_network_proof(
|
||||||
|
live_quick_host_association,
|
||||||
|
target_ipv4=target,
|
||||||
|
observation=control_endpoint_observation,
|
||||||
|
outcome="direct-endpoint-reachable",
|
||||||
|
reason_code=None,
|
||||||
|
supervisor_host_path_epoch_before_admission=(
|
||||||
|
live_quick_supervisor_epoch_before
|
||||||
|
),
|
||||||
|
supervisor_host_path_epoch_after_admission=(
|
||||||
|
self._connection_supervisor.snapshot().host_path.epoch
|
||||||
|
),
|
||||||
|
ble_operation_performed=True,
|
||||||
|
)
|
||||||
logger.info(
|
logger.info(
|
||||||
"K1 existing direct-LAN connection adopted without provisioning",
|
"K1 existing direct-LAN connection adopted without provisioning",
|
||||||
extra={
|
extra={
|
||||||
@@ -22906,6 +23301,7 @@ class XgridsK1CompatibilityService:
|
|||||||
def select_camera_preview(self, request: CameraPreviewSelectRequest) -> dict[str, Any]:
|
def select_camera_preview(self, request: CameraPreviewSelectRequest) -> dict[str, Any]:
|
||||||
camera_holder_acquired = False
|
camera_holder_acquired = False
|
||||||
try:
|
try:
|
||||||
|
self._require_camera_device_session(request.device_session_id)
|
||||||
with self._lock:
|
with self._lock:
|
||||||
current_acquisition = self._acquisition
|
current_acquisition = self._acquisition
|
||||||
current_out_dir = self._acquisition_out_dir
|
current_out_dir = self._acquisition_out_dir
|
||||||
@@ -22941,6 +23337,17 @@ class XgridsK1CompatibilityService:
|
|||||||
raise RuntimeError(
|
raise RuntimeError(
|
||||||
"camera preview доступен после автоматического запуска правой камеры"
|
"camera preview доступен после автоматического запуска правой камеры"
|
||||||
)
|
)
|
||||||
|
if acquisition_active_before_select:
|
||||||
|
if request.source_id != DEFAULT_ACQUISITION_CAMERA_SOURCE:
|
||||||
|
raise LocalAcquisitionLifecycleError(
|
||||||
|
"Во время активного приёма правая камера принадлежит evidence-сессии; "
|
||||||
|
"переключение видеоканала недоступно до завершения приёма.",
|
||||||
|
reason_code="acquisition-camera-evidence-owned",
|
||||||
|
)
|
||||||
|
# The mandatory right camera is already selected and bound to
|
||||||
|
# this acquisition. Re-selecting it is a read-only no-op: a UI
|
||||||
|
# retry must never detach or replace the evidence producer.
|
||||||
|
return self.state()
|
||||||
camera_holder_acquired = self._ensure_camera_preview_process_lease()
|
camera_holder_acquired = self._ensure_camera_preview_process_lease()
|
||||||
target = self._camera_target_for_session(request.device_session_id)
|
target = self._camera_target_for_session(request.device_session_id)
|
||||||
with self._lock:
|
with self._lock:
|
||||||
@@ -22975,6 +23382,18 @@ class XgridsK1CompatibilityService:
|
|||||||
@_serialized_k1_transition_access
|
@_serialized_k1_transition_access
|
||||||
def stop_camera_preview(self, request: CameraPreviewStopRequest) -> dict[str, Any]:
|
def stop_camera_preview(self, request: CameraPreviewStopRequest) -> dict[str, Any]:
|
||||||
self._require_camera_device_session(request.device_session_id)
|
self._require_camera_device_session(request.device_session_id)
|
||||||
|
with self._lock:
|
||||||
|
acquisition = self._acquisition
|
||||||
|
acquisition_active = bool(
|
||||||
|
acquisition is not None
|
||||||
|
and acquisition.state not in TERMINAL_ACQUISITION_STATES
|
||||||
|
)
|
||||||
|
if acquisition_active:
|
||||||
|
raise LocalAcquisitionLifecycleError(
|
||||||
|
"Во время активного приёма правая камера принадлежит evidence-сессии; "
|
||||||
|
"остановите весь приём штатной командой STOP.",
|
||||||
|
reason_code="acquisition-camera-evidence-owned",
|
||||||
|
)
|
||||||
self.camera_preview.stop(request.generation)
|
self.camera_preview.stop(request.generation)
|
||||||
self._release_camera_preview_process_lease()
|
self._release_camera_preview_process_lease()
|
||||||
return self.state()
|
return self.state()
|
||||||
@@ -31644,6 +32063,7 @@ def _plugin_execution_http_status(reason_code: object) -> int:
|
|||||||
"connection-mode-switch-lifecycle-busy",
|
"connection-mode-switch-lifecycle-busy",
|
||||||
"connection-mode-switch-acquisition-changed",
|
"connection-mode-switch-acquisition-changed",
|
||||||
"connection-mode-selection-lifecycle-busy",
|
"connection-mode-selection-lifecycle-busy",
|
||||||
|
"connection-mode-selection-physical-recovery-reset-required",
|
||||||
"connection-mode-selection-physical-state-unsafe",
|
"connection-mode-selection-physical-state-unsafe",
|
||||||
"connection-mode-selection-control-state-unsafe",
|
"connection-mode-selection-control-state-unsafe",
|
||||||
"acquisition-start-lifecycle-busy",
|
"acquisition-start-lifecycle-busy",
|
||||||
@@ -35098,6 +35518,7 @@ def _write_quick_connect_host_network_proof(
|
|||||||
reason_code: str | None,
|
reason_code: str | None,
|
||||||
supervisor_host_path_epoch_before_admission: int | None,
|
supervisor_host_path_epoch_before_admission: int | None,
|
||||||
supervisor_host_path_epoch_after_admission: int | None = None,
|
supervisor_host_path_epoch_after_admission: int | None = None,
|
||||||
|
ble_operation_performed: bool = False,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Persist one redacted CoreWLAN -> route -> TCP proof without K1 mutation."""
|
"""Persist one redacted CoreWLAN -> route -> TCP proof without K1 mutation."""
|
||||||
|
|
||||||
@@ -35161,7 +35582,7 @@ def _write_quick_connect_host_network_proof(
|
|||||||
"supervisor_host_path_epoch_after_admission": (
|
"supervisor_host_path_epoch_after_admission": (
|
||||||
supervisor_host_path_epoch_after_admission
|
supervisor_host_path_epoch_after_admission
|
||||||
),
|
),
|
||||||
"ble_operation_performed": False,
|
"ble_operation_performed": ble_operation_performed,
|
||||||
"device_write_performed": False,
|
"device_write_performed": False,
|
||||||
"automatic_retry": False,
|
"automatic_retry": False,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -535,6 +535,7 @@ _CLOSED_WEBSOCKET_SEND_ERRORS = frozenset(
|
|||||||
"Unexpected ASGI message 'websocket.send', after sending "
|
"Unexpected ASGI message 'websocket.send', after sending "
|
||||||
"'websocket.close' or response already completed."
|
"'websocket.close' or response already completed."
|
||||||
),
|
),
|
||||||
|
"Unexpected ASGI message 'websocket.send', after sending 'websocket.close'.",
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -202,6 +202,10 @@ def test_plugin_expected_state_preserves_its_non_gateway_http_status(
|
|||||||
"unable to perform operation on <TCPTransport closed=True>; "
|
"unable to perform operation on <TCPTransport closed=True>; "
|
||||||
"the handler is closed"
|
"the handler is closed"
|
||||||
),
|
),
|
||||||
|
RuntimeError(
|
||||||
|
"Unexpected ASGI message 'websocket.send', after sending "
|
||||||
|
"'websocket.close'."
|
||||||
|
),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
def test_device_plugin_events_treats_proven_transport_disconnect_as_completion(
|
def test_device_plugin_events_treats_proven_transport_disconnect_as_completion(
|
||||||
|
|||||||
@@ -14120,6 +14120,7 @@ def test_confirmed_scanning_waits_for_first_authoritative_pcl_before_camera(
|
|||||||
assert pre_prepare_fence(lambda: True) is True
|
assert pre_prepare_fence(lambda: True) is True
|
||||||
events.append(("select", (source_id, target)))
|
events.append(("select", (source_id, target)))
|
||||||
camera_state["active_source_id"] = source_id
|
camera_state["active_source_id"] = source_id
|
||||||
|
camera_state["generation"] = 1
|
||||||
events.append(("record", session_dir))
|
events.append(("record", session_dir))
|
||||||
camera_state["recording"] = {
|
camera_state["recording"] = {
|
||||||
"active": True,
|
"active": True,
|
||||||
@@ -14313,9 +14314,23 @@ def test_confirmed_scanning_waits_for_first_authoritative_pcl_before_camera(
|
|||||||
assert any(
|
assert any(
|
||||||
stream["source_id"] == "sensor.camera.right"
|
stream["source_id"] == "sensor.camera.right"
|
||||||
and stream["activation"]["selected"] is True
|
and stream["activation"]["selected"] is True
|
||||||
and stream["activation"]["controllable"] is True
|
and stream["activation"]["controllable"] is False
|
||||||
for stream in admitted_camera_streams
|
for stream in admitted_camera_streams
|
||||||
)
|
)
|
||||||
|
admitted_generation = admitted_state["camera_preview"]["generation"]
|
||||||
|
assert isinstance(admitted_generation, int)
|
||||||
|
with pytest.raises(facade_module.LocalAcquisitionLifecycleError) as stop_error:
|
||||||
|
service.stop_camera_preview(
|
||||||
|
facade_module.CameraPreviewStopRequest(
|
||||||
|
device_session_id=service._device_session_id, # type: ignore[arg-type] # noqa: SLF001
|
||||||
|
generation=admitted_generation,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
assert stop_error.value.reason_code == "acquisition-camera-evidence-owned"
|
||||||
|
after_rejected_stop = service.camera_preview.snapshot()
|
||||||
|
assert after_rejected_stop["active_source_id"] == "sensor.camera.right"
|
||||||
|
assert after_rejected_stop["recording"]["active"] is True
|
||||||
|
assert after_rejected_stop["generation"] == admitted_generation
|
||||||
|
|
||||||
# Every later authoritative PCL for the same lineage is idempotent.
|
# Every later authoritative PCL for the same lineage is idempotent.
|
||||||
physical_snapshot_calls_before = physical_snapshot_calls
|
physical_snapshot_calls_before = physical_snapshot_calls
|
||||||
@@ -17570,7 +17585,7 @@ def test_abort_waits_for_start_handoff_then_stops_owned_producers(
|
|||||||
assert service._acquisition_session_lease is None # noqa: SLF001
|
assert service._acquisition_session_lease is None # noqa: SLF001
|
||||||
|
|
||||||
|
|
||||||
def test_camera_selection_finishes_before_serialized_acquisition_stop(
|
def test_active_acquisition_camera_selection_is_rejected_before_serialized_stop(
|
||||||
tmp_path: Path,
|
tmp_path: Path,
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
) -> None:
|
) -> None:
|
||||||
@@ -17591,43 +17606,8 @@ def test_camera_selection_finishes_before_serialized_acquisition_stop(
|
|||||||
service._device_session_id = device_session_id # noqa: SLF001
|
service._device_session_id = device_session_id # noqa: SLF001
|
||||||
out_dir = service._acquisition_out_dir # noqa: SLF001
|
out_dir = service._acquisition_out_dir # noqa: SLF001
|
||||||
assert out_dir is not None
|
assert out_dir is not None
|
||||||
entered_camera_arm = threading.Event()
|
|
||||||
release_camera_arm = threading.Event()
|
|
||||||
stop_finished = threading.Event()
|
|
||||||
worker_errors: list[BaseException] = []
|
|
||||||
events: list[str] = []
|
events: list[str] = []
|
||||||
|
|
||||||
def blocked_camera_arm(_out_dir: Path, *, require_session: bool = False) -> None:
|
|
||||||
assert require_session is True
|
|
||||||
events.append("arm")
|
|
||||||
entered_camera_arm.set()
|
|
||||||
if not release_camera_arm.wait(timeout=2):
|
|
||||||
raise TimeoutError("test did not release camera arm")
|
|
||||||
|
|
||||||
def select_worker() -> None:
|
|
||||||
try:
|
|
||||||
service.select_camera_preview(
|
|
||||||
CameraPreviewSelectRequest(
|
|
||||||
source_id="sensor.camera.left",
|
|
||||||
device_session_id=device_session_id,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
except BaseException as exc: # pragma: no cover - asserted below
|
|
||||||
worker_errors.append(exc)
|
|
||||||
|
|
||||||
def stop_worker() -> None:
|
|
||||||
try:
|
|
||||||
service.stop_acquisition(
|
|
||||||
_stop_request(
|
|
||||||
acquisition_id=acquisition_id,
|
|
||||||
mode="capture-only",
|
|
||||||
)
|
|
||||||
)
|
|
||||||
except BaseException as exc: # pragma: no cover - asserted below
|
|
||||||
worker_errors.append(exc)
|
|
||||||
finally:
|
|
||||||
stop_finished.set()
|
|
||||||
|
|
||||||
camera_snapshot = service.camera_preview.snapshot()
|
camera_snapshot = service.camera_preview.snapshot()
|
||||||
camera_snapshot.update(
|
camera_snapshot.update(
|
||||||
{
|
{
|
||||||
@@ -17647,7 +17627,11 @@ def test_camera_selection_finishes_before_serialized_acquisition_stop(
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
monkeypatch.setattr(service, "_arm_camera_recording", blocked_camera_arm)
|
monkeypatch.setattr(
|
||||||
|
service,
|
||||||
|
"_arm_camera_recording",
|
||||||
|
lambda *_args, **_kwargs: events.append("unexpected-arm"),
|
||||||
|
)
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
service.camera_preview,
|
service.camera_preview,
|
||||||
"snapshot",
|
"snapshot",
|
||||||
@@ -17656,13 +17640,7 @@ def test_camera_selection_finishes_before_serialized_acquisition_stop(
|
|||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
service.camera_preview,
|
service.camera_preview,
|
||||||
"select",
|
"select",
|
||||||
lambda source_id, _target: (
|
lambda *_args, **_kwargs: events.append("unexpected-select"),
|
||||||
events.append("select")
|
|
||||||
or {
|
|
||||||
"active_source_id": source_id,
|
|
||||||
"recording": {"active": False},
|
|
||||||
}
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
service.camera_preview,
|
service.camera_preview,
|
||||||
@@ -17670,18 +17648,24 @@ def test_camera_selection_finishes_before_serialized_acquisition_stop(
|
|||||||
lambda **_kwargs: events.append("camera-stop"),
|
lambda **_kwargs: events.append("camera-stop"),
|
||||||
)
|
)
|
||||||
monkeypatch.setattr(runtime, "stop", lambda: events.append("runtime-stop"))
|
monkeypatch.setattr(runtime, "stop", lambda: events.append("runtime-stop"))
|
||||||
select_thread = threading.Thread(target=select_worker)
|
|
||||||
stop_thread = threading.Thread(target=stop_worker)
|
|
||||||
select_thread.start()
|
|
||||||
assert entered_camera_arm.wait(timeout=2)
|
|
||||||
stop_thread.start()
|
|
||||||
assert not stop_finished.wait(timeout=0.05)
|
|
||||||
release_camera_arm.set()
|
|
||||||
select_thread.join(timeout=2)
|
|
||||||
stop_thread.join(timeout=2)
|
|
||||||
|
|
||||||
assert worker_errors == []
|
with pytest.raises(facade_module.LocalAcquisitionLifecycleError) as selection_error:
|
||||||
assert events == ["arm", "select", "camera-stop", "runtime-stop"]
|
service.select_camera_preview(
|
||||||
|
CameraPreviewSelectRequest(
|
||||||
|
source_id="sensor.camera.left",
|
||||||
|
device_session_id=device_session_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
assert selection_error.value.reason_code == "acquisition-camera-evidence-owned"
|
||||||
|
assert events == []
|
||||||
|
|
||||||
|
service.stop_acquisition(
|
||||||
|
_stop_request(
|
||||||
|
acquisition_id=acquisition_id,
|
||||||
|
mode="capture-only",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
assert events == ["camera-stop", "runtime-stop"]
|
||||||
assert service.state()["acquisition"]["state"] == "completed"
|
assert service.state()["acquisition"]["state"] == "completed"
|
||||||
assert service._acquisition_session_lease is None # noqa: SLF001
|
assert service._acquisition_session_lease is None # noqa: SLF001
|
||||||
|
|
||||||
@@ -21646,6 +21630,8 @@ def test_read_only_reconciliation_requires_exact_requested_mode_despite_shared_s
|
|||||||
service,
|
service,
|
||||||
intended_mode=intended_mode,
|
intended_mode=intended_mode,
|
||||||
)
|
)
|
||||||
|
if requested_mode != "bridge":
|
||||||
|
_select_connection_mode(service, requested_mode)
|
||||||
|
|
||||||
async def read_current_status(*_: object, **__: object) -> dict[str, Any]:
|
async def read_current_status(*_: object, **__: object) -> dict[str, Any]:
|
||||||
raise AssertionError("mode mismatch must fail before a GATT read")
|
raise AssertionError("mode mismatch must fail before a GATT read")
|
||||||
@@ -21820,6 +21806,13 @@ def test_durable_restart_rejects_request_target_mismatch_before_gatt(
|
|||||||
transport_ref=DURABLE_K1_UUID,
|
transport_ref=DURABLE_K1_UUID,
|
||||||
)
|
)
|
||||||
restarted, _ = service_with_fake_runtime(tmp_path)
|
restarted, _ = service_with_fake_runtime(tmp_path)
|
||||||
|
requested_mode = {
|
||||||
|
"direct-lan": "bridge",
|
||||||
|
"device-ap": "quick-connect",
|
||||||
|
"controller-hotspot": "direct-connect",
|
||||||
|
}[attestation.topology]
|
||||||
|
if requested_mode != "bridge":
|
||||||
|
_select_connection_mode(restarted, requested_mode)
|
||||||
reads: list[str] = []
|
reads: list[str] = []
|
||||||
|
|
||||||
async def forbidden_read(*_: object, **__: object) -> dict[str, Any]:
|
async def forbidden_read(*_: object, **__: object) -> dict[str, Any]:
|
||||||
@@ -21989,6 +21982,8 @@ def test_durable_restart_previous_topology_cannot_resolve_ambiguous_write(
|
|||||||
previous_connection=previous,
|
previous_connection=previous,
|
||||||
)
|
)
|
||||||
restarted, _ = service_with_fake_runtime(tmp_path)
|
restarted, _ = service_with_fake_runtime(tmp_path)
|
||||||
|
if intended_mode != "bridge":
|
||||||
|
_select_connection_mode(restarted, intended_mode)
|
||||||
captured = _durable_status_capture()
|
captured = _durable_status_capture()
|
||||||
reads = 0
|
reads = 0
|
||||||
pin_calls: list[tuple[object, str]] = []
|
pin_calls: list[tuple[object, str]] = []
|
||||||
@@ -22123,13 +22118,15 @@ def test_durable_restart_failed_status_keeps_ambiguity_and_does_not_pin(
|
|||||||
|
|
||||||
def _seed_durable_quick_reconnect_evidence(
|
def _seed_durable_quick_reconnect_evidence(
|
||||||
service: XgridsK1CompatibilityService,
|
service: XgridsK1CompatibilityService,
|
||||||
|
*,
|
||||||
|
transport_ref: str = DURABLE_K1_UUID,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Persist the exact secret-free Quick topology and AP activation proof."""
|
"""Persist the exact secret-free Quick topology and AP activation proof."""
|
||||||
|
|
||||||
topology_store = service._semantic_topology_store # noqa: SLF001
|
topology_store = service._semantic_topology_store # noqa: SLF001
|
||||||
assert topology_store is not None
|
assert topology_store is not None
|
||||||
topology_store.commit(
|
topology_store.commit(
|
||||||
transport_ref=DURABLE_K1_UUID,
|
transport_ref=transport_ref,
|
||||||
connection_mode="quick-connect",
|
connection_mode="quick-connect",
|
||||||
ipv4="192.168.56.1",
|
ipv4="192.168.56.1",
|
||||||
compatibility_profile_id=XGRIDS_K1_COMPATIBILITY_PROFILE_ID,
|
compatibility_profile_id=XGRIDS_K1_COMPATIBILITY_PROFILE_ID,
|
||||||
@@ -22145,7 +22142,7 @@ def _seed_durable_quick_reconnect_evidence(
|
|||||||
json.dumps(
|
json.dumps(
|
||||||
{
|
{
|
||||||
"operation": "single_reviewed_quick_connect_ap_activation",
|
"operation": "single_reviewed_quick_connect_ap_activation",
|
||||||
"device_macos_uuid": DURABLE_K1_UUID,
|
"device_macos_uuid": transport_ref,
|
||||||
"device_name": "XGR-A46BE7",
|
"device_name": "XGR-A46BE7",
|
||||||
"outcome": "ap_ready_observed",
|
"outcome": "ap_ready_observed",
|
||||||
"ready_observed": True,
|
"ready_observed": True,
|
||||||
@@ -22220,6 +22217,7 @@ def test_semantic_quick_restart_restores_saved_host_profile_before_endpoint_prob
|
|||||||
)
|
)
|
||||||
|
|
||||||
restarted, _ = service_with_fake_runtime(tmp_path)
|
restarted, _ = service_with_fake_runtime(tmp_path)
|
||||||
|
_select_connection_mode(restarted, "quick-connect")
|
||||||
calls: list[str] = []
|
calls: list[str] = []
|
||||||
association_calls: list[tuple[Path, str, str, float, float]] = []
|
association_calls: list[tuple[Path, str, str, float, float]] = []
|
||||||
route_samples: list[HostPathProbeResult] = []
|
route_samples: list[HostPathProbeResult] = []
|
||||||
@@ -22367,6 +22365,7 @@ def test_semantic_quick_restart_skips_wifi_mutation_when_direct_route_already_ex
|
|||||||
first, _ = service_with_fake_runtime(tmp_path)
|
first, _ = service_with_fake_runtime(tmp_path)
|
||||||
_seed_durable_quick_reconnect_evidence(first)
|
_seed_durable_quick_reconnect_evidence(first)
|
||||||
restarted, _ = service_with_fake_runtime(tmp_path)
|
restarted, _ = service_with_fake_runtime(tmp_path)
|
||||||
|
_select_connection_mode(restarted, "quick-connect")
|
||||||
wifi_mutations: list[str] = []
|
wifi_mutations: list[str] = []
|
||||||
tcp_calls: list[str] = []
|
tcp_calls: list[str] = []
|
||||||
|
|
||||||
@@ -22444,6 +22443,7 @@ def test_semantic_quick_restart_rejects_non_direct_route_without_tcp_or_ble(
|
|||||||
first, _ = service_with_fake_runtime(tmp_path)
|
first, _ = service_with_fake_runtime(tmp_path)
|
||||||
_seed_durable_quick_reconnect_evidence(first)
|
_seed_durable_quick_reconnect_evidence(first)
|
||||||
restarted, _ = service_with_fake_runtime(tmp_path)
|
restarted, _ = service_with_fake_runtime(tmp_path)
|
||||||
|
_select_connection_mode(restarted, "quick-connect")
|
||||||
device_edges: list[str] = []
|
device_edges: list[str] = []
|
||||||
tcp_calls: list[str] = []
|
tcp_calls: list[str] = []
|
||||||
|
|
||||||
@@ -22528,6 +22528,7 @@ def test_semantic_quick_restart_tcp_timeout_fails_without_ble_fallback(
|
|||||||
first, _ = service_with_fake_runtime(tmp_path)
|
first, _ = service_with_fake_runtime(tmp_path)
|
||||||
_seed_durable_quick_reconnect_evidence(first)
|
_seed_durable_quick_reconnect_evidence(first)
|
||||||
restarted, _ = service_with_fake_runtime(tmp_path)
|
restarted, _ = service_with_fake_runtime(tmp_path)
|
||||||
|
_select_connection_mode(restarted, "quick-connect")
|
||||||
device_edges: list[str] = []
|
device_edges: list[str] = []
|
||||||
tcp_calls: list[str] = []
|
tcp_calls: list[str] = []
|
||||||
|
|
||||||
@@ -22606,6 +22607,7 @@ def test_semantic_quick_restart_rejects_route_change_during_single_tcp_probe(
|
|||||||
first, _ = service_with_fake_runtime(tmp_path)
|
first, _ = service_with_fake_runtime(tmp_path)
|
||||||
_seed_durable_quick_reconnect_evidence(first)
|
_seed_durable_quick_reconnect_evidence(first)
|
||||||
restarted, _ = service_with_fake_runtime(tmp_path)
|
restarted, _ = service_with_fake_runtime(tmp_path)
|
||||||
|
_select_connection_mode(restarted, "quick-connect")
|
||||||
device_edges: list[str] = []
|
device_edges: list[str] = []
|
||||||
tcp_calls: list[str] = []
|
tcp_calls: list[str] = []
|
||||||
initial_path = _direct_host_path("192.168.56.1")
|
initial_path = _direct_host_path("192.168.56.1")
|
||||||
@@ -23014,6 +23016,240 @@ def test_semantic_durable_verify_rejects_association_change_during_tcp_probe(
|
|||||||
assert state["semantic_topology_store"]["record"]["revision"] == 1
|
assert state["semantic_topology_store"]["record"]["revision"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_quick_verify_is_rejected_while_bridge_is_selected_before_any_io(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
first, _ = service_with_fake_runtime(tmp_path)
|
||||||
|
_seed_durable_quick_reconnect_evidence(first)
|
||||||
|
restarted, _ = service_with_fake_runtime(tmp_path)
|
||||||
|
edges: list[str] = []
|
||||||
|
|
||||||
|
def forbidden_edge(*_: object, **__: object) -> object:
|
||||||
|
edges.append("io")
|
||||||
|
raise AssertionError("mode mismatch must fail before BLE, Wi-Fi or endpoint I/O")
|
||||||
|
|
||||||
|
async def forbidden_async_edge(*_: object, **__: object) -> dict[str, Any]:
|
||||||
|
forbidden_edge()
|
||||||
|
return {}
|
||||||
|
|
||||||
|
monkeypatch.setattr(facade_module, "read_wifi_status_once", forbidden_async_edge)
|
||||||
|
monkeypatch.setattr(facade_module, "provision_wifi_once", forbidden_async_edge)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
facade_module,
|
||||||
|
"associate_with_wifi_profile_once",
|
||||||
|
forbidden_edge,
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(facade_module, "_inspect_host_path", forbidden_edge)
|
||||||
|
before = restarted.state()
|
||||||
|
assert before["desired_connection_mode"] == "bridge"
|
||||||
|
|
||||||
|
with pytest.raises(facade_module.ConnectionVerificationError) as raised:
|
||||||
|
asyncio.run(
|
||||||
|
restarted.verify_connection(
|
||||||
|
ConnectionVerifyRequest(
|
||||||
|
device_id=DURABLE_K1_UUID,
|
||||||
|
source="durable-configured-state",
|
||||||
|
compatibility_attestation=QUICK_CONNECT_ATTESTATION,
|
||||||
|
expected_mode_revision=before[
|
||||||
|
"desired_connection_mode_revision"
|
||||||
|
],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
assert raised.value.reason_code == "connection-mode-draft-mismatch"
|
||||||
|
assert edges == []
|
||||||
|
after = restarted.state()
|
||||||
|
assert after["desired_connection_mode"] == "bridge"
|
||||||
|
assert after["operations"] == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_physical_recovery_cannot_silently_change_bridge_draft_to_quick(
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
original, _ = service_with_fake_runtime(tmp_path)
|
||||||
|
_persist_resolved_unclassified_stop_for_restart(
|
||||||
|
original,
|
||||||
|
connection_mode="quick-connect",
|
||||||
|
target_ipv4=facade_module.AP_FALLBACK_IPV4,
|
||||||
|
)
|
||||||
|
_seed_durable_quick_reconnect_evidence(
|
||||||
|
original,
|
||||||
|
transport_ref="test-ble-transport",
|
||||||
|
)
|
||||||
|
restarted, _ = service_with_fake_runtime(tmp_path)
|
||||||
|
before = restarted.state()
|
||||||
|
assert before["desired_connection_mode"] == "bridge"
|
||||||
|
assert before["connection_policy"]["actions"][
|
||||||
|
"observe-configured-device-network"
|
||||||
|
]["required_connection_mode"] == "quick-connect"
|
||||||
|
|
||||||
|
with pytest.raises(facade_module.NetworkProvisioningConflict) as raised:
|
||||||
|
restarted.select_connection_mode(
|
||||||
|
DesiredConnectionModeRequest(
|
||||||
|
connection_mode="quick-connect",
|
||||||
|
expected_revision=before["desired_connection_mode_revision"],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
assert raised.value.reason_code == (
|
||||||
|
"connection-mode-selection-physical-recovery-reset-required"
|
||||||
|
)
|
||||||
|
after = restarted.state()
|
||||||
|
assert after["desired_connection_mode"] == "bridge"
|
||||||
|
assert after["desired_connection_mode_revision"] == before[
|
||||||
|
"desired_connection_mode_revision"
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_public_quick_physical_recovery_rejoins_saved_wifi_after_live_ble_proof(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
original, _ = service_with_fake_runtime(tmp_path)
|
||||||
|
_persist_resolved_unclassified_stop_for_restart(
|
||||||
|
original,
|
||||||
|
connection_mode="quick-connect",
|
||||||
|
target_ipv4=facade_module.AP_FALLBACK_IPV4,
|
||||||
|
)
|
||||||
|
_seed_durable_quick_reconnect_evidence(
|
||||||
|
original,
|
||||||
|
transport_ref="test-ble-transport",
|
||||||
|
)
|
||||||
|
|
||||||
|
restarted, runtime = service_with_fake_runtime(tmp_path)
|
||||||
|
# Model a process whose explicit Quick draft was already authoritative
|
||||||
|
# before the unresolved physical record became visible. The public UI may
|
||||||
|
# never create this authority as a side effect of the recovery button.
|
||||||
|
with restarted._lock: # noqa: SLF001
|
||||||
|
restarted._desired_connection_mode = "quick-connect" # noqa: SLF001
|
||||||
|
restarted._desired_connection_mode_revision = 1 # noqa: SLF001
|
||||||
|
restarted._host_wifi_association_probe = FakeHostWifiAssociationProbe( # type: ignore[assignment] # noqa: SLF001
|
||||||
|
"a" * 64
|
||||||
|
)
|
||||||
|
capture = _durable_status_capture(device_id="test-ble-transport")
|
||||||
|
ordered_edges: list[str] = []
|
||||||
|
status_reads: list[tuple[str, bool, bool, float | None]] = []
|
||||||
|
route_samples: list[HostPathProbeResult] = []
|
||||||
|
network_writes: list[str] = []
|
||||||
|
|
||||||
|
async def read_current_quick_status(
|
||||||
|
device_id: str,
|
||||||
|
*,
|
||||||
|
allow_known_device_retrieval: bool = False,
|
||||||
|
rediscover: bool = False,
|
||||||
|
exact_scan_timeout_seconds: float | None = None,
|
||||||
|
on_gatt_validated: Callable[[object], None] | None = None,
|
||||||
|
**_: object,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
ordered_edges.append("ble-status-read")
|
||||||
|
status_reads.append(
|
||||||
|
(
|
||||||
|
device_id,
|
||||||
|
allow_known_device_retrieval,
|
||||||
|
rediscover,
|
||||||
|
exact_scan_timeout_seconds,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
assert on_gatt_validated is not None
|
||||||
|
on_gatt_validated(capture)
|
||||||
|
result = _wifi_status_read(None, device_id=device_id)
|
||||||
|
result["status"] = _ap_ready_wifi_status()
|
||||||
|
return result
|
||||||
|
|
||||||
|
def associate_saved_profile(*_: object, **__: object) -> dict[str, Any]:
|
||||||
|
ordered_edges.append("host-wifi-association")
|
||||||
|
return _successful_saved_quick_profile_association()
|
||||||
|
|
||||||
|
def settling_quick_route(target: str) -> HostPathProbeResult:
|
||||||
|
path = (
|
||||||
|
_tunnel_host_path(target)
|
||||||
|
if len(route_samples) < 2
|
||||||
|
else _direct_host_path(target)
|
||||||
|
)
|
||||||
|
route_samples.append(path)
|
||||||
|
return path
|
||||||
|
|
||||||
|
def reachable_tcp(target: str) -> facade_module.TcpReachabilityProbeResult:
|
||||||
|
assert target == facade_module.AP_FALLBACK_IPV4
|
||||||
|
ordered_edges.append("mqtt-endpoint-probe")
|
||||||
|
return facade_module.TcpReachabilityProbeResult(reachable=True)
|
||||||
|
|
||||||
|
async def forbidden_network_write(*_: object, **__: object) -> dict[str, Any]:
|
||||||
|
network_writes.append("network-write")
|
||||||
|
raise AssertionError("saved Quick physical recovery must not write K1 network state")
|
||||||
|
|
||||||
|
monkeypatch.setattr(facade_module, "read_wifi_status_once", read_current_quick_status)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
facade_module,
|
||||||
|
"associate_with_wifi_profile_once",
|
||||||
|
associate_saved_profile,
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(facade_module, "provision_wifi_once", forbidden_network_write)
|
||||||
|
monkeypatch.setattr(facade_module, "pin_connected_device_handle", lambda *_a, **_k: None)
|
||||||
|
monkeypatch.setattr(facade_module, "_target_is_local_ipv4", lambda _target: False)
|
||||||
|
monkeypatch.setattr(facade_module, "_inspect_host_path", settling_quick_route)
|
||||||
|
monkeypatch.setattr(facade_module, "_probe_control_endpoint_socket", reachable_tcp)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
facade_module,
|
||||||
|
"DURABLE_QUICK_CONNECT_ROUTE_SETTLE_INTERVAL_SECONDS",
|
||||||
|
0.001,
|
||||||
|
)
|
||||||
|
_install_real_coordinator_bootstrap(
|
||||||
|
restarted,
|
||||||
|
observed_session_state="ready",
|
||||||
|
)
|
||||||
|
|
||||||
|
before = restarted.state()
|
||||||
|
recovery = before["connection_policy"]["actions"][
|
||||||
|
"observe-configured-device-network"
|
||||||
|
]
|
||||||
|
assert recovery["allowed"] is True
|
||||||
|
assert recovery["required_connection_mode"] == "quick-connect"
|
||||||
|
assert recovery["requires_live_gatt_validation"] is True
|
||||||
|
|
||||||
|
verified = asyncio.run(restarted.verify_connection(ConnectionVerifyRequest()))
|
||||||
|
|
||||||
|
assert ordered_edges == [
|
||||||
|
"ble-status-read",
|
||||||
|
"host-wifi-association",
|
||||||
|
"mqtt-endpoint-probe",
|
||||||
|
]
|
||||||
|
assert status_reads == [
|
||||||
|
(
|
||||||
|
"test-ble-transport",
|
||||||
|
True,
|
||||||
|
True,
|
||||||
|
facade_module.CONNECTION_VERIFY_EXACT_UUID_SCAN_TIMEOUT_SECONDS,
|
||||||
|
)
|
||||||
|
]
|
||||||
|
assert network_writes == []
|
||||||
|
assert runtime.start_calls == []
|
||||||
|
assert runtime.stop_calls == 0
|
||||||
|
assert verified["active_connection_mode"] == "quick-connect"
|
||||||
|
assert verified["k1_ip"] == facade_module.AP_FALLBACK_IPV4
|
||||||
|
assert verified["last_operation"]["status"] == "succeeded"
|
||||||
|
assert verified["last_operation"]["result"]["physical_reconciliation"][
|
||||||
|
"performed"
|
||||||
|
] is True
|
||||||
|
reassociation_sessions = sorted(
|
||||||
|
restarted.evidence_root.glob("*viewer_k1_quick_connect_host_reassociation*")
|
||||||
|
)
|
||||||
|
assert len(reassociation_sessions) == 1
|
||||||
|
proof = json.loads(
|
||||||
|
(reassociation_sessions[0] / "host-network-proof.redacted.json").read_text(
|
||||||
|
encoding="utf-8"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
assert proof["outcome"] == "direct-endpoint-reachable"
|
||||||
|
assert proof["route_after"]["route_class"] == "direct"
|
||||||
|
assert proof["tcp_probe_performed"] is True
|
||||||
|
assert proof["ble_operation_performed"] is True
|
||||||
|
assert proof["device_write_performed"] is False
|
||||||
|
assert proof["automatic_retry"] is False
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("observed_session_state", ["ready", "scanning"])
|
@pytest.mark.parametrize("observed_session_state", ["ready", "scanning"])
|
||||||
def test_public_physical_recovery_refreshes_stale_dhcp_then_classifies_without_writes(
|
def test_public_physical_recovery_refreshes_stale_dhcp_then_classifies_without_writes(
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
@@ -27491,9 +27727,9 @@ def test_select_device_handoff_is_local_cancel_invalidates_candidates_and_stale_
|
|||||||
expected_reconfiguration_revision=fresh["connection_reconfiguration"]["revision"],
|
expected_reconfiguration_revision=fresh["connection_reconfiguration"]["revision"],
|
||||||
expected_reconfiguration_intent_id=fresh["connection_reconfiguration"]["intent_id"],
|
expected_reconfiguration_intent_id=fresh["connection_reconfiguration"]["intent_id"],
|
||||||
)
|
)
|
||||||
with pytest.raises(facade_module.NetworkProvisioningConflict) as quick_isolated:
|
with pytest.raises(facade_module.ConnectionVerificationError) as quick_isolated:
|
||||||
asyncio.run(service.verify_connection(quick_verify_request))
|
asyncio.run(service.verify_connection(quick_verify_request))
|
||||||
assert quick_isolated.value.reason_code == "connection-reconfiguration-target-mismatch"
|
assert quick_isolated.value.reason_code == "connection-mode-draft-mismatch"
|
||||||
cancel_request = _reconfiguration_request(fresh, "cancel")
|
cancel_request = _reconfiguration_request(fresh, "cancel")
|
||||||
|
|
||||||
cancelled = asyncio.run(service.prepare_connection_reconfiguration(cancel_request))
|
cancelled = asyncio.run(service.prepare_connection_reconfiguration(cancel_request))
|
||||||
@@ -30869,6 +31105,9 @@ def _persist_ambiguous_start_with_prepared_checkpoint(
|
|||||||
|
|
||||||
def _persist_resolved_active_start_for_restart(
|
def _persist_resolved_active_start_for_restart(
|
||||||
service: XgridsK1CompatibilityService,
|
service: XgridsK1CompatibilityService,
|
||||||
|
*,
|
||||||
|
connection_mode: facade_module.ConnectionMode = "bridge",
|
||||||
|
target_ipv4: str = "192.168.68.52",
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Persist one successful START without retaining process-local acquisition."""
|
"""Persist one successful START without retaining process-local acquisition."""
|
||||||
|
|
||||||
@@ -30879,8 +31118,8 @@ def _persist_resolved_active_start_for_restart(
|
|||||||
connection = PhysicalCommandConnectionBinding(
|
connection = PhysicalCommandConnectionBinding(
|
||||||
intent_id="old-start-intent",
|
intent_id="old-start-intent",
|
||||||
transport_ref="test-ble-transport",
|
transport_ref="test-ble-transport",
|
||||||
connection_mode="bridge",
|
connection_mode=connection_mode,
|
||||||
target_ipv4="192.168.68.52",
|
target_ipv4=target_ipv4,
|
||||||
target_port=facade_module.CONTROL_MQTT_PORT,
|
target_port=facade_module.CONTROL_MQTT_PORT,
|
||||||
host_path_epoch=1,
|
host_path_epoch=1,
|
||||||
control_session_id="old-start-control",
|
control_session_id="old-start-control",
|
||||||
@@ -31017,12 +31256,100 @@ def _persist_same_runtime_prepared_checkpoint_for_resolved_start(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _persist_active_checkpoint_for_resolved_start(
|
||||||
|
service: XgridsK1CompatibilityService,
|
||||||
|
*,
|
||||||
|
connection_mode: facade_module.ConnectionMode,
|
||||||
|
target_ipv4: str,
|
||||||
|
) -> None:
|
||||||
|
"""Persist one proven active acquisition without process-local ownership."""
|
||||||
|
|
||||||
|
_persist_resolved_active_start_for_restart(
|
||||||
|
service,
|
||||||
|
connection_mode=connection_mode,
|
||||||
|
target_ipv4=target_ipv4,
|
||||||
|
)
|
||||||
|
record = service._physical_command_ledger.snapshot().record # noqa: SLF001
|
||||||
|
store = service._active_acquisition_checkpoint # noqa: SLF001
|
||||||
|
assert record is not None and record.last_status is not None
|
||||||
|
assert store is not None
|
||||||
|
connection = record.connection
|
||||||
|
binding = ActiveAcquisitionRecoveryTransportBinding(
|
||||||
|
runtime_instance_id=service._snapshot_runtime_id, # noqa: SLF001
|
||||||
|
intent_id=connection.intent_id,
|
||||||
|
transport_ref=connection.transport_ref,
|
||||||
|
connection_mode=connection.connection_mode,
|
||||||
|
target_ipv4=connection.target_ipv4,
|
||||||
|
target_port=connection.target_port,
|
||||||
|
host_path_epoch=connection.host_path_epoch,
|
||||||
|
control_session_id=connection.control_session_id,
|
||||||
|
producer_generation=connection.producer_generation,
|
||||||
|
logical_device_id="known-k1",
|
||||||
|
compatibility_profile_id=XGRIDS_K1_COMPATIBILITY_PROFILE_ID,
|
||||||
|
vendor_device_id_sha256=_RECOVERY_VENDOR_HASH,
|
||||||
|
device_serial_sha256=_RECOVERY_SERIAL_HASH,
|
||||||
|
)
|
||||||
|
prepared = store.prepare(
|
||||||
|
transition_id="prepare-active-recovery-start",
|
||||||
|
predecessor_revision=0,
|
||||||
|
acquisition_id=record.acquisition_id,
|
||||||
|
original_start_operation_id=record.operation_id,
|
||||||
|
start_payload_sha256=record.payload_sha256,
|
||||||
|
identity=ActiveAcquisitionRecoveryIdentity(
|
||||||
|
logical_device_id="known-k1",
|
||||||
|
vendor_device_id_sha256=_RECOVERY_VENDOR_HASH,
|
||||||
|
device_serial_sha256=_RECOVERY_SERIAL_HASH,
|
||||||
|
),
|
||||||
|
connection=ActiveAcquisitionRecoveryConnection(
|
||||||
|
transport_ref=connection.transport_ref,
|
||||||
|
connection_mode=connection.connection_mode,
|
||||||
|
target_ipv4=connection.target_ipv4,
|
||||||
|
target_port=connection.target_port,
|
||||||
|
),
|
||||||
|
compatibility_profile_id=XGRIDS_K1_COMPATIBILITY_PROFILE_ID,
|
||||||
|
project_name="ACTIVE_RECOVERY",
|
||||||
|
project_name_wire_sha256=active_acquisition_project_name_sha256(
|
||||||
|
"ACTIVE_RECOVERY"
|
||||||
|
),
|
||||||
|
original_evidence_session_id="evidence-active-recovery",
|
||||||
|
duration_seconds=None,
|
||||||
|
requested_streams=("spatial.point-cloud.live", "camera.rgb.live"),
|
||||||
|
evidence_policy="required",
|
||||||
|
mount_type="handheld",
|
||||||
|
gnss_mode="none",
|
||||||
|
prepared_binding=binding,
|
||||||
|
)
|
||||||
|
store.activate(
|
||||||
|
transition_id="activate-active-recovery-start",
|
||||||
|
expected_revision=prepared.revision,
|
||||||
|
expected_acquisition_id=prepared.acquisition_id,
|
||||||
|
expected_start_operation_id=prepared.original_start_operation_id,
|
||||||
|
status_proof=service._checkpoint_status_proof( # noqa: SLF001
|
||||||
|
status=record.last_status,
|
||||||
|
binding=binding,
|
||||||
|
evidence_session_id="evidence-active-recovery",
|
||||||
|
),
|
||||||
|
physical_proof=service._checkpoint_physical_proof( # noqa: SLF001
|
||||||
|
record=record,
|
||||||
|
binding=binding,
|
||||||
|
checkpoint=prepared,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _persist_resolved_unclassified_stop_for_restart(
|
def _persist_resolved_unclassified_stop_for_restart(
|
||||||
service: XgridsK1CompatibilityService,
|
service: XgridsK1CompatibilityService,
|
||||||
|
*,
|
||||||
|
connection_mode: facade_module.ConnectionMode = "bridge",
|
||||||
|
target_ipv4: str = "192.168.68.52",
|
||||||
) -> tuple[str, int]:
|
) -> tuple[str, int]:
|
||||||
"""Persist the exact legacy startup shape without process-local owners."""
|
"""Persist the exact legacy startup shape without process-local owners."""
|
||||||
|
|
||||||
_persist_resolved_active_start_for_restart(service)
|
_persist_resolved_active_start_for_restart(
|
||||||
|
service,
|
||||||
|
connection_mode=connection_mode,
|
||||||
|
target_ipv4=target_ipv4,
|
||||||
|
)
|
||||||
ledger = service._physical_command_ledger # noqa: SLF001
|
ledger = service._physical_command_ledger # noqa: SLF001
|
||||||
start = ledger.snapshot().record
|
start = ledger.snapshot().record
|
||||||
assert start is not None
|
assert start is not None
|
||||||
@@ -32684,6 +33011,125 @@ def test_restart_auto_settles_durable_prepared_resolved_start_standby(
|
|||||||
assert restarted_runtime.stop_calls == 0
|
assert restarted_runtime.stop_calls == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_new_bridge_ready_settles_untrusted_quick_checkpoint_without_command(
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
"""A new exact READY closes stale Quick state locally in the same process."""
|
||||||
|
|
||||||
|
service, runtime = service_with_fake_runtime(tmp_path)
|
||||||
|
_persist_active_checkpoint_for_resolved_start(
|
||||||
|
service,
|
||||||
|
connection_mode="quick-connect",
|
||||||
|
target_ipv4="192.168.56.1",
|
||||||
|
)
|
||||||
|
ledger = service._physical_command_ledger # noqa: SLF001
|
||||||
|
start = ledger.snapshot().record
|
||||||
|
assert start is not None and start.last_status is not None
|
||||||
|
stop_operation_id = "physical-stop-untrusted-quick-to-bridge"
|
||||||
|
ledger.prepare(
|
||||||
|
operation_id=stop_operation_id,
|
||||||
|
parent_operation_id=start.operation_id,
|
||||||
|
acquisition_id=start.acquisition_id,
|
||||||
|
action="stop",
|
||||||
|
identity=start.identity,
|
||||||
|
connection=start.connection,
|
||||||
|
compatibility_profile_id=start.compatibility_profile_id,
|
||||||
|
payload_sha256="6" * 64,
|
||||||
|
baseline_status=start.last_status,
|
||||||
|
)
|
||||||
|
ledger.mark_dispatching(stop_operation_id)
|
||||||
|
ledger.mark_observing(
|
||||||
|
stop_operation_id,
|
||||||
|
publish_call_returned=True,
|
||||||
|
packet_id=42,
|
||||||
|
)
|
||||||
|
ledger.mark_qos2_completed(stop_operation_id, packet_id=42)
|
||||||
|
ledger.record_application_response(
|
||||||
|
stop_operation_id,
|
||||||
|
PhysicalCommandApplicationResponse(
|
||||||
|
operation_id=stop_operation_id,
|
||||||
|
action="stop",
|
||||||
|
control_session_id=start.connection.control_session_id,
|
||||||
|
host_path_epoch=start.connection.host_path_epoch,
|
||||||
|
producer_generation=start.connection.producer_generation,
|
||||||
|
result_code=PHYSICAL_COMMAND_APPLICATION_SUCCESS_CODE,
|
||||||
|
success=True,
|
||||||
|
payload_sha256="7" * 64,
|
||||||
|
observed_at_utc="2026-08-09T12:00:03.000Z",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
service._mark_active_acquisition_checkpoint_untrusted( # noqa: SLF001
|
||||||
|
trust="unavailable",
|
||||||
|
reason_code="active-acquisition-recovery-checkpoint-error",
|
||||||
|
)
|
||||||
|
assert service._validate_active_acquisition_checkpoint_lineage() is None # noqa: SLF001
|
||||||
|
|
||||||
|
coordinator = service._physical_command_coordinator # noqa: SLF001
|
||||||
|
coordinator.application_response(
|
||||||
|
ApplicationMqttResponseEvidence(
|
||||||
|
operation_key="bootstrap:bridge-ready:DeviceInfoRequest",
|
||||||
|
response_topic="lixel/application/response/device_info",
|
||||||
|
payload_sha256="8" * 64,
|
||||||
|
modeling_action=None,
|
||||||
|
result_code=None,
|
||||||
|
success=None,
|
||||||
|
observed_at_utc="2026-08-09T12:01:00.000Z",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
coordinator.bind_control_session(
|
||||||
|
PhysicalCommandRuntimeBinding(
|
||||||
|
vendor_device_id_sha256=_RECOVERY_VENDOR_HASH,
|
||||||
|
device_serial_sha256=_RECOVERY_SERIAL_HASH,
|
||||||
|
compatibility_profile_id=XGRIDS_K1_COMPATIBILITY_PROFILE_ID,
|
||||||
|
intent_id="fresh-bridge-intent",
|
||||||
|
transport_ref="test-ble-transport",
|
||||||
|
connection_mode="bridge",
|
||||||
|
target_ipv4="192.168.68.52",
|
||||||
|
target_port=facade_module.CONTROL_MQTT_PORT,
|
||||||
|
host_path_epoch=2,
|
||||||
|
control_session_id="fresh-bridge-control",
|
||||||
|
producer_generation=2,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
coordinator.device_status(
|
||||||
|
ApplicationMqttDeviceStatusEvidence(
|
||||||
|
vendor_device_id_sha256=_RECOVERY_VENDOR_HASH,
|
||||||
|
device_serial_sha256=_RECOVERY_SERIAL_HASH,
|
||||||
|
session_state="ready",
|
||||||
|
session_state_code=MODELING_STATE_BASE + 300,
|
||||||
|
project_bound=False,
|
||||||
|
project_id_sha256=None,
|
||||||
|
init_ready=False,
|
||||||
|
status_message_sha256="9" * 64,
|
||||||
|
mqtt_retained=False,
|
||||||
|
observed_at_utc="2026-08-09T12:01:01.000Z",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
reconciliation_id = "reconcile-untrusted-quick-to-bridge-ready"
|
||||||
|
standby_record = coordinator.reconcile_unresolved(
|
||||||
|
reconciliation_id=reconciliation_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
settled = service._settle_untrusted_checkpoint_after_verified_standby( # noqa: SLF001
|
||||||
|
reconciliation_id=reconciliation_id,
|
||||||
|
reconciled_record=standby_record,
|
||||||
|
)
|
||||||
|
|
||||||
|
checkpoint_store = service._active_acquisition_checkpoint # noqa: SLF001
|
||||||
|
assert checkpoint_store is not None
|
||||||
|
checkpoint = checkpoint_store.snapshot().checkpoint
|
||||||
|
assert settled is True
|
||||||
|
assert checkpoint is not None and checkpoint.state == "ceased"
|
||||||
|
assert service._active_acquisition_checkpoint_trust == "trusted" # noqa: SLF001
|
||||||
|
assert service._active_acquisition_checkpoint_reason is None # noqa: SLF001
|
||||||
|
assert standby_record["connection"]["connection_mode"] == "quick-connect"
|
||||||
|
assert standby_record["reconciliations"][-1]["verified_binding"]["connection"][
|
||||||
|
"connection_mode"
|
||||||
|
] == "bridge"
|
||||||
|
assert runtime.start_calls == []
|
||||||
|
assert runtime.stop_calls == 0
|
||||||
|
|
||||||
|
|
||||||
def test_transition_invalid_fences_repeated_verify_until_service_restart(
|
def test_transition_invalid_fences_repeated_verify_until_service_restart(
|
||||||
tmp_path: Path,
|
tmp_path: Path,
|
||||||
) -> None:
|
) -> None:
|
||||||
|
|||||||
Reference in New Issue
Block a user