Capture the current unreleased K1 connection, recovery, lifecycle, viewer, and test work as a single known-bad baseline for subsequent fixes.
431 lines
16 KiB
TypeScript
431 lines
16 KiB
TypeScript
import type {
|
|
OperatorPresenceConfirmation,
|
|
XgridsAcquisition,
|
|
XgridsApplicationControlSession,
|
|
XgridsConnectionMode,
|
|
XgridsK1State,
|
|
} from "./api";
|
|
import { currentAppliedConnectionTopology, isSoftwareCommandedAcquisition } from "./lifecycle";
|
|
|
|
export interface PhysicalConfirmationChecks {
|
|
operatorPresent: boolean;
|
|
ownerControlledDevice: boolean;
|
|
lixelgoClosed: boolean;
|
|
batteryStorageConfirmed: boolean;
|
|
expectedPhysicalStateConfirmed: boolean;
|
|
}
|
|
|
|
type CompletedPhysicalConfirmationChecks = {
|
|
[Key in keyof PhysicalConfirmationChecks]: true;
|
|
};
|
|
|
|
export type K1PhysicalConfirmationKind = "prepare" | "start" | "stop";
|
|
|
|
/**
|
|
* Semantic state that authorises one physical command confirmation.
|
|
*
|
|
* Timestamps, the polling snapshot revision and ConnectionSupervisor.revision
|
|
* are deliberately absent: the latter is an observation counter and advances
|
|
* even when a probe confirms the same semantic route. A read-only refresh must
|
|
* not invalidate an operator confirmation. Every field below, however, changes
|
|
* the identity, route, CAS authority, acquisition or runtime state of the
|
|
* command and therefore closes an already-open modal.
|
|
*/
|
|
export interface K1PhysicalCommandFence {
|
|
kind: K1PhysicalConfirmationKind;
|
|
commandDeviceId: string;
|
|
commandProjectName: string;
|
|
acquisitionId: string;
|
|
runtimeId: string | null;
|
|
runtimePhase: string | null;
|
|
runtimeSourceMode: string | null;
|
|
selectedDeviceId: string | null;
|
|
deviceRefId: string | null;
|
|
deviceSessionId: string | null;
|
|
deviceSessionDeviceId: string | null;
|
|
deviceSessionConnectivity: string | null;
|
|
connectionIntentId: string | null;
|
|
requestedConnectionMode: XgridsConnectionMode | null;
|
|
expectedDeviceId: string | null;
|
|
deviceNetworkState: string | null;
|
|
deviceNetworkIntentId: string | null;
|
|
transportRef: string | null;
|
|
connectionMode: XgridsConnectionMode | null;
|
|
targetIpv4: string | null;
|
|
targetPort: number | null;
|
|
hostPathEpoch: number | null;
|
|
hostPathAvailable: boolean | null;
|
|
deviceIdentityState: string | null;
|
|
deviceIdentityId: string | null;
|
|
controlPlaneState: string | null;
|
|
controlPlaneSessionId: string | null;
|
|
dataPlaneState: string | null;
|
|
dataPlaneSessionId: string | null;
|
|
leaseState: string | null;
|
|
leaseGeneration: number | null;
|
|
controlAllowed: boolean | null;
|
|
acquisitionStartAllowed: boolean | null;
|
|
dataIngestAuthoritative: boolean | null;
|
|
controlSessionGeneration: number | null;
|
|
controlStateRevision: number | null;
|
|
controlState: string | null;
|
|
controlSocketOpen: boolean | null;
|
|
verifiedControlSessionId: string | null;
|
|
controlProofRevision: number | null;
|
|
controlProofFresh: boolean | null;
|
|
deviceReportedState: string | null;
|
|
deviceProjectBound: boolean | null;
|
|
deviceInitReady: boolean | null;
|
|
acquisitionState: string | null;
|
|
acquisitionStateRevision: number | null;
|
|
acquisitionDeviceId: string | null;
|
|
acquisitionDeviceSessionId: string | null;
|
|
acquisitionControlMode: string | null;
|
|
}
|
|
|
|
export interface K1PhysicalCommandTarget {
|
|
deviceId: string;
|
|
connection: string;
|
|
projectName: string;
|
|
acquisitionId: string;
|
|
deviceState: string;
|
|
fence: K1PhysicalCommandFence;
|
|
}
|
|
|
|
export interface K1PhysicalCommandCheckpoint {
|
|
readonly kind: K1PhysicalConfirmationKind;
|
|
readonly target: Readonly<Omit<K1PhysicalCommandTarget, "fence">>;
|
|
readonly fence: Readonly<K1PhysicalCommandFence>;
|
|
readonly fenceKey: string;
|
|
}
|
|
|
|
export interface K1PhysicalCommandConfirmationPayload {
|
|
readonly physicalAcceptance: Readonly<OperatorPresenceConfirmation>;
|
|
readonly checkpoint: K1PhysicalCommandCheckpoint;
|
|
}
|
|
|
|
export function emptyPhysicalConfirmationChecks(): PhysicalConfirmationChecks {
|
|
return {
|
|
operatorPresent: false,
|
|
ownerControlledDevice: false,
|
|
lixelgoClosed: false,
|
|
batteryStorageConfirmed: false,
|
|
expectedPhysicalStateConfirmed: false,
|
|
};
|
|
}
|
|
|
|
export function physicalConfirmationComplete(
|
|
checks: PhysicalConfirmationChecks,
|
|
): checks is CompletedPhysicalConfirmationChecks {
|
|
return (
|
|
checks.operatorPresent
|
|
&& checks.ownerControlledDevice
|
|
&& checks.lixelgoClosed
|
|
&& checks.batteryStorageConfirmed
|
|
&& checks.expectedPhysicalStateConfirmed
|
|
);
|
|
}
|
|
|
|
export function operatorPresenceConfirmation(
|
|
checks: PhysicalConfirmationChecks,
|
|
): OperatorPresenceConfirmation | null {
|
|
if (!physicalConfirmationComplete(checks)) return null;
|
|
return {
|
|
operator_present: checks.operatorPresent,
|
|
owner_controlled_device: checks.ownerControlledDevice,
|
|
lixelgo_closed: checks.lixelgoClosed,
|
|
battery_storage_confirmed: checks.batteryStorageConfirmed,
|
|
expected_physical_state_confirmed: checks.expectedPhysicalStateConfirmed,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* One deliberate click on the local K1 START/STOP action is the operator's
|
|
* physical acceptance. The backend still validates the exact control CAS,
|
|
* live DeviceInfo/status binding and command ledger before a vendor write;
|
|
* this helper only removes the redundant five-checkbox modal.
|
|
*/
|
|
export function operatorActionPhysicalAcceptance(): OperatorPresenceConfirmation {
|
|
return {
|
|
operator_present: true,
|
|
owner_controlled_device: true,
|
|
lixelgo_closed: true,
|
|
battery_storage_confirmed: true,
|
|
expected_physical_state_confirmed: true,
|
|
};
|
|
}
|
|
|
|
function recordValue(
|
|
record: Record<string, unknown> | null | undefined,
|
|
key: string,
|
|
): unknown {
|
|
return record?.[key];
|
|
}
|
|
|
|
function trimmed(value: unknown): string | null {
|
|
return typeof value === "string" && value.trim() ? value.trim() : null;
|
|
}
|
|
|
|
function integer(value: unknown): number | null {
|
|
return typeof value === "number" && Number.isSafeInteger(value) && value >= 0
|
|
? value
|
|
: null;
|
|
}
|
|
|
|
function boolean(value: unknown): boolean | null {
|
|
return typeof value === "boolean" ? value : null;
|
|
}
|
|
|
|
function commandFence(
|
|
kind: K1PhysicalConfirmationKind,
|
|
state: XgridsK1State | null | undefined,
|
|
target: Omit<K1PhysicalCommandTarget, "fence">,
|
|
): K1PhysicalCommandFence {
|
|
const supervisor = state?.connection_supervisor;
|
|
const deviceNetwork = supervisor?.observed.device_network;
|
|
const hostPath = supervisor?.observed.host_path;
|
|
const deviceIdentity = supervisor?.observed.device_identity;
|
|
const controlPlane = supervisor?.observed.control_plane;
|
|
const dataPlane = supervisor?.observed.data_plane;
|
|
const control = state?.application_control_session;
|
|
const verifiedControl = control?.verified_control;
|
|
const acquisition = state?.acquisition;
|
|
|
|
return {
|
|
kind,
|
|
commandDeviceId: target.deviceId,
|
|
commandProjectName: target.projectName,
|
|
acquisitionId: target.acquisitionId,
|
|
runtimeId: trimmed(state?.snapshot_runtime_id),
|
|
runtimePhase: trimmed(state?.phase),
|
|
runtimeSourceMode: trimmed(state?.source_mode),
|
|
selectedDeviceId: trimmed(state?.selected_device_id),
|
|
deviceRefId: trimmed(state?.device_ref?.device_id),
|
|
deviceSessionId: trimmed(state?.device_session?.device_session_id),
|
|
deviceSessionDeviceId: trimmed(state?.device_session?.device_id),
|
|
deviceSessionConnectivity: trimmed(state?.device_session?.connectivity),
|
|
connectionIntentId: trimmed(supervisor?.intent?.intent_id),
|
|
requestedConnectionMode: supervisor?.intent?.requested_mode ?? null,
|
|
expectedDeviceId: trimmed(supervisor?.intent?.expected_device_id),
|
|
deviceNetworkState: trimmed(deviceNetwork?.state),
|
|
deviceNetworkIntentId: trimmed(deviceNetwork?.intent_id),
|
|
transportRef: trimmed(deviceNetwork?.transport_ref),
|
|
connectionMode: deviceNetwork?.connection_mode ?? null,
|
|
targetIpv4: trimmed(deviceNetwork?.target?.ipv4),
|
|
targetPort: integer(deviceNetwork?.target?.port),
|
|
hostPathEpoch: integer(hostPath?.epoch),
|
|
hostPathAvailable: boolean(hostPath?.available),
|
|
deviceIdentityState: trimmed(deviceIdentity?.state),
|
|
deviceIdentityId: trimmed(deviceIdentity?.logical_device_id),
|
|
controlPlaneState: trimmed(controlPlane?.state),
|
|
controlPlaneSessionId: trimmed(controlPlane?.session_id),
|
|
dataPlaneState: trimmed(dataPlane?.state),
|
|
dataPlaneSessionId: trimmed(dataPlane?.session_id),
|
|
leaseState: trimmed(supervisor?.lease.state),
|
|
leaseGeneration: integer(supervisor?.lease.generation),
|
|
controlAllowed: boolean(supervisor?.authority.control_allowed),
|
|
acquisitionStartAllowed: boolean(supervisor?.authority.acquisition_start_allowed),
|
|
dataIngestAuthoritative: boolean(supervisor?.authority.data_ingest_authoritative),
|
|
controlSessionGeneration: integer(control?.session_generation),
|
|
controlStateRevision: integer(control?.state_revision),
|
|
controlState: trimmed(control?.state),
|
|
controlSocketOpen: boolean(control?.control_socket_open),
|
|
verifiedControlSessionId: trimmed(verifiedControl?.control_session_id),
|
|
controlProofRevision: integer(verifiedControl?.control_proof_revision),
|
|
controlProofFresh: boolean(verifiedControl?.control_proof_fresh),
|
|
deviceReportedState: trimmed(recordValue(control?.transport, "latest_device_session_state")),
|
|
deviceProjectBound: boolean(recordValue(control?.transport, "latest_device_project_bound")),
|
|
deviceInitReady: boolean(recordValue(control?.transport, "latest_device_init_ready")),
|
|
acquisitionState: trimmed(acquisition?.state),
|
|
acquisitionStateRevision: integer(acquisition?.state_revision),
|
|
acquisitionDeviceId: trimmed(acquisition?.device_id),
|
|
acquisitionDeviceSessionId: trimmed(acquisition?.device_session_id),
|
|
acquisitionControlMode: trimmed(acquisition?.control_mode),
|
|
};
|
|
}
|
|
|
|
export function physicalCommandFenceKey(
|
|
kind: K1PhysicalConfirmationKind,
|
|
target: K1PhysicalCommandTarget,
|
|
): string {
|
|
// Both values are included. This makes a mismatched component kind fail
|
|
// closed even if a caller accidentally supplies a target built for another
|
|
// physical command.
|
|
return JSON.stringify([
|
|
kind,
|
|
target.deviceId,
|
|
target.connection,
|
|
target.projectName,
|
|
target.acquisitionId,
|
|
target.deviceState,
|
|
target.fence,
|
|
]);
|
|
}
|
|
|
|
export function createPhysicalCommandCheckpoint(
|
|
kind: K1PhysicalConfirmationKind,
|
|
target: K1PhysicalCommandTarget,
|
|
): K1PhysicalCommandCheckpoint {
|
|
const fence = Object.freeze({ ...target.fence });
|
|
const targetSnapshot = Object.freeze({
|
|
deviceId: target.deviceId,
|
|
connection: target.connection,
|
|
projectName: target.projectName,
|
|
acquisitionId: target.acquisitionId,
|
|
deviceState: target.deviceState,
|
|
});
|
|
return Object.freeze({
|
|
kind,
|
|
target: targetSnapshot,
|
|
fence,
|
|
fenceKey: physicalCommandFenceKey(kind, target),
|
|
});
|
|
}
|
|
|
|
export function physicalCommandCheckpointMatches(
|
|
checkpoint: K1PhysicalCommandCheckpoint,
|
|
kind: K1PhysicalConfirmationKind,
|
|
target: K1PhysicalCommandTarget,
|
|
): boolean {
|
|
return checkpoint.kind === kind
|
|
&& checkpoint.fence.kind === kind
|
|
&& target.fence.kind === kind
|
|
&& checkpoint.fenceKey === physicalCommandFenceKey(kind, target);
|
|
}
|
|
|
|
function targetWithFence(
|
|
kind: K1PhysicalConfirmationKind,
|
|
state: XgridsK1State | null | undefined,
|
|
target: Omit<K1PhysicalCommandTarget, "fence">,
|
|
): K1PhysicalCommandTarget {
|
|
return {
|
|
...target,
|
|
fence: commandFence(kind, state, target),
|
|
};
|
|
}
|
|
|
|
function exactReadyState(
|
|
control: XgridsApplicationControlSession,
|
|
): string | null {
|
|
const deviceState = trimmed(recordValue(control.transport, "latest_device_session_state"));
|
|
const projectBound = recordValue(control.transport, "latest_device_project_bound");
|
|
const initReady = recordValue(control.transport, "latest_device_init_ready");
|
|
if (deviceState !== "ready" || projectBound !== true || initReady !== false) return null;
|
|
return "READY · проект привязан · инициализация не запущена";
|
|
}
|
|
|
|
function exactScanningState(
|
|
control: XgridsApplicationControlSession | null | undefined,
|
|
): string {
|
|
const deviceState = trimmed(recordValue(control?.transport, "latest_device_session_state"));
|
|
const projectBound = recordValue(control?.transport, "latest_device_project_bound");
|
|
const initReady = recordValue(control?.transport, "latest_device_init_ready");
|
|
if (deviceState === "scanning" && projectBound === true && initReady === true) {
|
|
return "SCANNING · проект привязан · инициализация завершена";
|
|
}
|
|
return deviceState
|
|
? `${deviceState.toUpperCase()} · последнее подтверждённое состояние K1`
|
|
: "Состояние K1 не подтверждено текущим управляющим каналом";
|
|
}
|
|
|
|
function exactConnection(
|
|
control: XgridsApplicationControlSession | null | undefined,
|
|
): string | null {
|
|
const verified = control?.verified_control;
|
|
if (!verified) return null;
|
|
return `${verified.connection_mode} · ${verified.target_ipv4}:${verified.target_port}`;
|
|
}
|
|
|
|
function acquisitionProject(acquisition: XgridsAcquisition): string {
|
|
return trimmed(acquisition.project_name) ?? "Проект без опубликованного имени";
|
|
}
|
|
|
|
export function preparedStartTarget(
|
|
state: XgridsK1State | null | undefined,
|
|
): K1PhysicalCommandTarget | null {
|
|
const acquisition = state?.acquisition;
|
|
const control = state?.application_control_session;
|
|
const verified = control?.verified_control;
|
|
const topology = currentAppliedConnectionTopology(state);
|
|
const supervisor = state?.connection_supervisor;
|
|
const deviceNetwork = supervisor?.observed.device_network;
|
|
const hostPath = supervisor?.observed.host_path;
|
|
const readyState = control ? exactReadyState(control) : null;
|
|
if (
|
|
!acquisition
|
|
|| acquisition.state !== "prepared"
|
|
|| acquisition.control_mode !== "plugin-commanded"
|
|
|| !control
|
|
|| control.state !== "project-ready"
|
|
|| control.can_start !== true
|
|
|| !verified
|
|
|| verified.control_proof_fresh !== true
|
|
|| verified.logical_device_id !== acquisition.device_id
|
|
|| verified.compatibility_profile_id !== acquisition.compatibility_profile_id
|
|
|| supervisor?.authority.acquisition_start_allowed !== true
|
|
|| verified.intent_id !== supervisor.intent?.intent_id
|
|
|| verified.host_path_epoch !== hostPath?.epoch
|
|
|| verified.transport_ref !== deviceNetwork?.transport_ref
|
|
|| verified.connection_mode !== deviceNetwork?.connection_mode
|
|
|| verified.target_ipv4 !== deviceNetwork?.target?.ipv4
|
|
|| verified.target_port !== deviceNetwork?.target?.port
|
|
|| topology?.status !== "active"
|
|
|| topology.connectionMode !== verified.connection_mode
|
|
|| topology.endpoint !== verified.target_ipv4
|
|
|| state?.connection_lifecycle?.ready_to_start !== true
|
|
|| !readyState
|
|
) {
|
|
return null;
|
|
}
|
|
return targetWithFence("start", state, {
|
|
deviceId: verified.logical_device_id,
|
|
connection: `${verified.connection_mode} · ${verified.target_ipv4}:${verified.target_port}`,
|
|
projectName: acquisitionProject(acquisition),
|
|
acquisitionId: acquisition.acquisition_id,
|
|
deviceState: readyState,
|
|
});
|
|
}
|
|
|
|
export function preparationTarget(
|
|
state: XgridsK1State | null | undefined,
|
|
projectName: string,
|
|
): K1PhysicalCommandTarget | null {
|
|
const topology = currentAppliedConnectionTopology(state);
|
|
const supervisor = state?.connection_supervisor;
|
|
const deviceNetwork = supervisor?.observed.device_network;
|
|
if (
|
|
!topology
|
|
|| !supervisor
|
|
|| topology.status === "configured-offline"
|
|
|| !deviceNetwork?.transport_ref
|
|
|| !deviceNetwork.target
|
|
) {
|
|
return null;
|
|
}
|
|
const logicalDeviceId = supervisor.observed.device_identity.logical_device_id
|
|
?? supervisor.intent?.expected_device_id
|
|
?? deviceNetwork.transport_ref;
|
|
return targetWithFence("prepare", state, {
|
|
deviceId: logicalDeviceId,
|
|
connection: `${topology.connectionMode} · ${deviceNetwork.target.ipv4}:${deviceNetwork.target.port}`,
|
|
projectName: projectName.trim(),
|
|
acquisitionId: "Будет создана подготовительным этапом; START пока недоступен",
|
|
deviceState: "Подготовка не начата · физический START не разрешён",
|
|
});
|
|
}
|
|
|
|
export function activeStopTarget(
|
|
state: XgridsK1State | null | undefined,
|
|
): K1PhysicalCommandTarget | null {
|
|
const acquisition = state?.acquisition;
|
|
if (!acquisition || !isSoftwareCommandedAcquisition(state)) return null;
|
|
const control = state?.application_control_session;
|
|
return targetWithFence("stop", state, {
|
|
deviceId: acquisition.device_id,
|
|
connection: exactConnection(control) ?? "Текущий управляющий канал не подтверждён",
|
|
projectName: acquisitionProject(acquisition),
|
|
acquisitionId: acquisition.acquisition_id,
|
|
deviceState: exactScanningState(control),
|
|
});
|
|
}
|