import type { AxisStateView, Capabilities, MessageEnvelope, PanelSnapshotV1, SimSnapshotV1 } from "./protocol"; export type SessionStatus = "disconnected" | "handshaking" | "attaching" | "loading" | "ready" | "running" | "paused" | "recovering" | "blocked" | "error"; export type FrontendState = { status: SessionStatus; contextId: string; sessionId: string; tick: number; lastSeq: number; capabilities: Capabilities | null; snapshot: SimSnapshotV1 | null; pending: string[]; error: { code: string; message?: string } | null; alarms: Array>; task: Record | null; program: Record | null; devices: Record | null; deviceIo: Record | null; hal: Record | null; plc: Record | null; python: Record | null; ioAck: Record | null; axisState: AxisStateView | null; gates: Record; toolpath: Array<{ id: number; source_line?: number | null; start: Record; end: Record; active?: boolean }>; executionTrace: Array<{ line: number; text: string; program_status?: string | null }>; }; // Keep enough authoritative WASM execution lines for long files while bounding // browser memory when a session runs continuously. export const EXECUTION_TRACE_CACHE_LIMIT = 8192; export const INITIAL_STATE: FrontendState = { status: "disconnected", contextId: "linuxcnc-2.10.0-pre0-wasm-m1-py-c1", sessionId: "", tick: 0, lastSeq: 0, capabilities: null, snapshot: null, pending: [], error: null, alarms: [], task: null, program: null, devices: null, deviceIo: null, hal: null, plc: null, python: null, ioAck: null, axisState: null, gates: {}, toolpath: [], executionTrace: [], }; function payload(message: MessageEnvelope): T { return message.payload as T; } function projection(result: Record, message: MessageEnvelope, previous: FrontendState) { const panel = result.panel_snapshot as PanelSnapshotV1 | undefined; const trace = Array.isArray(result.execution_trace) ? (result.execution_trace as Array>) .map((entry) => ({ line: Number(entry.line), text: String(entry.text ?? ""), program_status: entry.program_status ? String(entry.program_status) : null })) .filter((entry) => Number.isSafeInteger(entry.line) && entry.line > 0) : []; const resetExecutionTrace = ["PROGRAM_LOAD", "PROGRAM_RESTART"].includes(String(result.command ?? "")); const executionTrace = resetExecutionTrace ? trace.slice(-EXECUTION_TRACE_CACHE_LIMIT) : trace.length ? [...previous.executionTrace, ...trace].slice(-EXECUTION_TRACE_CACHE_LIMIT) : previous.executionTrace; const delta = result.motion_sample as { tick?: number; pose?: Record; joints?: number[]; active_segment_id?: number; active_progress?: number } | undefined; if (!panel && delta && previous.snapshot) { const pose = delta.pose ?? {}; const joints = Array.isArray(delta.joints) ? [...delta.joints] : previous.snapshot.motion?.joints ?? []; const visual = previous.snapshot.visual ? { ...previous.snapshot.visual, pose: { ...previous.snapshot.visual.pose, ...pose }, joints, ...(delta.active_segment_id !== undefined ? { active_segment_id: delta.active_segment_id } : {}), ...(delta.active_progress !== undefined ? { active_progress: delta.active_progress } : {}), ...(Array.isArray(result.motion_trace) ? { motion_trace: result.motion_trace } : {}), } : previous.snapshot.visual; const nextSnapshot = { ...previous.snapshot, simulation_tick: message.simulation_tick ?? delta.tick ?? previous.snapshot.simulation_tick, state_hash: String(result.state_hash ?? previous.snapshot.state_hash), task: (result.task as SimSnapshotV1["task"]) ?? previous.snapshot.task, program: (result.program as Record) ?? previous.snapshot.program, overrides: (result.overrides as SimSnapshotV1["overrides"]) ?? previous.snapshot.overrides, motion: { ...previous.snapshot.motion, ...(result.motion as SimSnapshotV1["motion"] | undefined), // Keep the XYZAC axis vector intact on every motion delta. Older // projection code rebuilt only XYZ, which made A/C disappear from // consumers that read the canonical motion array. axes: [ Number(pose.x ?? previous.snapshot.motion?.axes?.[0] ?? 0), Number(pose.y ?? previous.snapshot.motion?.axes?.[1] ?? 0), Number(pose.z ?? previous.snapshot.motion?.axes?.[2] ?? 0), Number(pose.a ?? previous.snapshot.motion?.axes?.[3] ?? 0), Number(pose.c ?? previous.snapshot.motion?.axes?.[4] ?? 0), ], joints, }, wasm_motion: (result.wasm_motion as Record) ?? previous.snapshot.wasm_motion, ...(visual ? { visual } : {}), } satisfies SimSnapshotV1; return { snapshot: nextSnapshot, toolpath: previous.toolpath, task: (result.task as Record) ?? previous.task, program: (result.program as Record) ?? previous.program, axisState: (result.axis_state as AxisStateView) ?? previous.axisState, gates: (result.gates as FrontendState["gates"]) ?? previous.gates, executionTrace, }; } if (panel?.schema === "axis-panel-snapshot.v1") { const visualSegments = (Array.isArray(panel.visual.toolpath) ? panel.visual.toolpath : previous.toolpath) .filter((segment) => segment && typeof segment === "object" && Number.isSafeInteger((segment as { id?: unknown }).id ?? 0)) .map((segment, index) => { const value = segment as Record; return { id: Number(value.id ?? index + 1), source_line: typeof value.source_line === "number" ? value.source_line : null, start: value.start as Record, end: value.end as Record, ...(value.center ? { center: value.center as Record } : {}), ...(value.normal ? { normal: value.normal as Record } : {}), ...(Array.isArray(value.samples) ? { samples: value.samples as Array> } : {}), ...(typeof value.samples_source === "string" ? { samples_source: value.samples_source } : {}), ...(typeof value.kind === "string" ? { kind: value.kind } : {}), ...(typeof value.helix === "boolean" ? { helix: value.helix } : {}), ...(typeof value.radius === "number" ? { radius: value.radius } : {}), ...(typeof value.turns === "number" ? { turns: value.turns } : {}), ...(typeof value.axial_delta === "number" ? { axial_delta: value.axial_delta } : {}), ...(typeof value.canonical_motion_mode === "number" ? { canonical_motion_mode: value.canonical_motion_mode } : {}), ...(typeof value.kinematics_mode === "number" ? { kinematics_mode: value.kinematics_mode } : {}), ...(typeof value.motion_type === "number" ? { motion_type: value.motion_type } : {}), ...(typeof value.turn === "number" ? { turn: value.turn } : {}), active: value.id === panel.visual.active_segment_id, }; }); const pose = panel.visual.pose; const axes = [pose.x, pose.y, pose.z, pose.a, pose.c].map((value) => Number.isFinite(value) ? value : 0); const snapshot = { schema: panel.schema, simulation_tick: panel.simulation_tick, state_hash: panel.state_hash, task: panel.task as SimSnapshotV1["task"], motion: { ...(panel.motion as SimSnapshotV1["motion"]), axes, joints: panel.visual.joints }, coordinates: panel.coordinates, overrides: panel.overrides, wasm_motion: panel.wasm_motion, authoritative: panel.authoritative as SimSnapshotV1["authoritative"], configuration: (panel.configuration ?? previous.snapshot?.configuration ?? null) as SimSnapshotV1["configuration"], visual: { ...panel.visual, toolpath: visualSegments }, kinematics: panel.kinematics, xyzac: panel.xyzac, gates: panel.gates, motion_authority: panel.motion_authority, task_authority: panel.task_authority, } satisfies SimSnapshotV1; return { snapshot, toolpath: visualSegments, task: panel.task, program: panel.program, devices: panel.devices ?? previous.devices, deviceIo: panel.device_io ?? previous.deviceIo, hal: panel.hal ?? previous.hal, axisState: panel.axis_state, gates: { ...panel.gates, jog: panel.gates.jogStart, load: panel.gates.load, run: panel.gates.run, step: panel.gates.step, pause: panel.gates.pause, resume: panel.gates.resume, abort: panel.gates.abort, mdi: panel.gates.mdi, home: panel.gates.home }, executionTrace, }; } const wasmMotion = (result.wasm_motion as { state?: Record } | undefined)?.state; const rawMotion = (wasmMotion ?? result.motion ?? previous.snapshot?.motion ?? {}) as Record; const motionState = (rawMotion.state ?? rawMotion) as Record; const axesObject = (motionState.axes ?? motionState.position) as Record | undefined; const interpreterState = (result.state ?? result.interpreter_state) as Record | undefined; const axes = result.motion !== undefined ? Array.isArray(motionState.axes) ? motionState.axes as number[] : axesObject && [axesObject.x, axesObject.y, axesObject.z].some((value) => typeof value === "number") ? [axesObject.x, axesObject.y, axesObject.z].map((value) => typeof value === "number" ? value : 0) : [0, 0, 0] : interpreterState && [interpreterState.x, interpreterState.y, interpreterState.z].some((value) => typeof value === "number") ? [interpreterState.x, interpreterState.y, interpreterState.z].map((value, index) => typeof value === "number" ? value : Number(previous.snapshot?.motion?.axes?.[index] ?? 0)) : [Number(previous.snapshot?.motion?.axes?.[0] ?? 0), Number(previous.snapshot?.motion?.axes?.[1] ?? 0), Number(previous.snapshot?.motion?.axes?.[2] ?? 0)]; const joints = Array.isArray(motionState.joints) ? motionState.joints as number[] : []; const authoritative = result.authoritative as SimSnapshotV1["authoritative"]; const programResult = result.program ?? (typeof result.program_status === "string" ? result : previous.program); const planner = authoritative?.planner; const preview = result.preview as { status?: string; segments?: Array> } | undefined; const previewSegments = preview?.status === "verified" && Array.isArray(preview.segments) ? preview.segments : []; const plannerSegments = [ ...(planner?.queue ?? []), ...(planner?.active ? [planner.active] : []), ].filter((segment): segment is NonNullable & { id: number; start: Record; end: Record } => Number.isSafeInteger(segment.id) && Boolean(segment.start) && Boolean(segment.end)); const command = String(result.command ?? ""); const resetPath = command === "PROGRAM_LOAD" || command === "PROGRAM_RESTART"; const known = resetPath ? new Set() : new Set(previous.toolpath.map((segment) => segment.id)); const nextToolpath = resetPath ? previewSegments.map((segment, index) => ({ ...segment, id: index + 1, source_line: typeof segment.source_line === "number" ? segment.source_line : null, start: segment.start as Record, end: segment.end as Record, active: false })) : previous.toolpath.map((segment) => ({ ...segment, start: { ...segment.start }, end: { ...segment.end } })); for (const segment of plannerSegments) { if (!known.has(segment.id)) nextToolpath.push({ id: segment.id, source_line: segment.source_line, start: segment.start, end: segment.end, active: segment.id === planner?.active?.id }); } if (planner?.active?.id !== undefined) { for (const segment of nextToolpath) segment.active = segment.id === planner.active?.id; } else { for (const segment of nextToolpath) segment.active = false; } const visual = resetPath ? { ...(previous.snapshot?.visual ?? { schema: "axis-visual.v1", pose: {}, joints: [], source: "wasm_canonical_motion" as const }), toolpath: nextToolpath, active_segment_id: null, active_progress: 0, motion_trace: [], } : previous.snapshot?.visual; const snapshot = { simulation_tick: message.simulation_tick ?? previous.tick, state_hash: String(result.state_hash ?? previous.snapshot?.state_hash ?? ""), task: result.task as SimSnapshotV1["task"], motion: { axes, joints, velocity: Array.isArray(motionState.velocity) ? motionState.velocity as number[] : [], homed: typeof motionState.homed === "boolean" ? motionState.homed : undefined, homed_axes: Array.isArray(motionState.homed_axes) ? motionState.homed_axes as boolean[] : undefined }, overrides: (result.overrides as SimSnapshotV1["overrides"]) ?? previous.snapshot?.overrides, authoritative, ...(visual ? { visual } : {}) } satisfies SimSnapshotV1; return { snapshot, toolpath: nextToolpath, task: (result.task as Record) ?? previous.task, program: programResult as Record ?? previous.program, devices: (result.devices as Record) ?? previous.devices, deviceIo: (result.device_io as Record) ?? previous.deviceIo, hal: (result.hal_runtime as Record) ?? previous.hal, plc: (result.plc as Record) ?? previous.plc, python: (result.python as Record) ?? previous.python, ioAck: (result.io_ack as Record) ?? previous.ioAck, axisState: (result.axis_state as AxisStateView) ?? previous.axisState, gates: (result.gates as FrontendState["gates"]) ?? previous.gates, executionTrace }; } export function reduceFrontendState(state: FrontendState, message: MessageEnvelope): FrontendState { const tick = message.simulation_tick ?? state.tick; const base = { ...state, contextId: message.context_id, sessionId: message.session_id, lastSeq: Math.max(state.lastSeq, message.seq), tick, error: null }; switch (message.kind) { case "CAPABILITIES": return { ...base, status: "handshaking", capabilities: payload(message) }; case "SESSION_READY": { const readyPayload = payload>(message); return { ...base, status: "ready", task: (readyPayload.task as Record) ?? state.task, axisState: (readyPayload.axis_state as AxisStateView) ?? state.axisState }; } case "RECOVERY_STATUS": return { ...base, status: payload<{ status?: string }>(message).status === "verified" ? "ready" : "recovering" }; case "STATE_SNAPSHOT": { const value = payload>(message); const projected = projection(value, message, state); return { ...base, status: "ready", ...projected }; } case "COMMAND_RESULT": { const result = payload>(message); const task = result.task as { state?: string } | undefined; const status = result.status === "blocked" || ["ESTOP", "FAULT"].includes(String(task?.state)) ? "blocked" : task?.state === "RUNNING" ? "running" : task?.state === "PAUSED" ? "paused" : "ready"; return { ...base, status, ...projection(result, message, state) }; } case "ALARM": return { ...base, status: "blocked", alarms: [...state.alarms, payload>(message)] }; case "ERROR": { const value = payload>(message); const projected = projection(value, message, state); const taskState = String((value.task as Record | undefined)?.state ?? "").toUpperCase(); const status = ["ESTOP", "FAULT"].includes(taskState) ? "blocked" : "error"; const readable = value.reason ?? value.gate_reason ?? value.message_key ?? value.code ?? "worker_error"; return { ...base, status, error: { code: String(value.code ?? "worker_error"), message: String(readable) }, ...projected }; } default: return base; } }