Optimize XYZAC motion updates and persist power-off recovery

This commit is contained in:
2026-08-29 04:52:44 +08:00
parent a97971ea19
commit f6e545018e
17 changed files with 479 additions and 74 deletions

View File

@@ -153,8 +153,8 @@ export function App() {
const trajectory = [...(planner?.queue ?? []), ...(planner?.active ? [planner.active] : [])];
const stopRunTimer = () => { if (runTimer.current) { clearInterval(runTimer.current); runTimer.current = null; } };
const stopJogTimer = () => { if (jogTimer.current) { clearInterval(jogTimer.current); jogTimer.current = null; } };
const startJogTimer = () => { if (jogTimer.current) return; jogTimer.current = setInterval(() => { if (jogStepPending.current) return; jogStepPending.current = true; operationController.execute("SIM_STEP", { cycles: 1 }, { state: axisState, gate: false }).catch(() => undefined).finally(() => { jogStepPending.current = false; }); }, 50); };
const startRunTimer = () => { if (runTimer.current) return; runTimer.current = setInterval(() => { if (runStepPending.current) return; runStepPending.current = true; operationController.execute("SIM_STEP", { cycles: 5 }, { state: axisState, gate: false }).catch(() => undefined).finally(() => { runStepPending.current = false; }); }, 50); };
const startJogTimer = () => { if (jogTimer.current) return; jogTimer.current = setInterval(() => { if (jogStepPending.current) return; jogStepPending.current = true; operationController.execute("SIM_STEP", { cycles: 1, response: "motion_delta" }, { state: axisState, gate: false }).catch(() => undefined).finally(() => { jogStepPending.current = false; }); }, 50); };
const startRunTimer = () => { if (runTimer.current) return; runTimer.current = setInterval(() => { if (runStepPending.current) return; runStepPending.current = true; operationController.execute("SIM_STEP", { cycles: 5, response: "motion_delta" }, { state: axisState, gate: false }).catch(() => undefined).finally(() => { runStepPending.current = false; }); }, 50); };
const dispatch = (kind: string, payload: Record<string, unknown> = {}, gate = true, action: string | null = null): Promise<void> => {
setCommandStatus(`${kind} pending`);
const pending = operationController.execute(kind, payload, { state: axisState, gate, ...(action ? { action } : {}) });

View File

@@ -0,0 +1,40 @@
import type { MessageEnvelope } from "./protocol";
const STORAGE_KEY = "linuxcnc-web-wasm:xyzac-power-off-checkpoint";
function payloadOf(message: MessageEnvelope): Record<string, unknown> {
return message.payload && typeof message.payload === "object" ? message.payload as Record<string, unknown> : {};
}
export function persistPowerOffCheckpoint(message: MessageEnvelope): { status: string; storage: string; reason?: string } | null {
const payload = payloadOf(message);
const checkpoint = payload.recovery_checkpoint;
if (message.kind !== "COMMAND_RESULT" || payload.status !== "verified" || payload.command !== "POWER" || payload.active !== false || !checkpoint || typeof checkpoint !== "object") return null;
try {
if (!globalThis.localStorage) return { status: "blocked", storage: "browser_local_storage", reason: "local_storage_unavailable" };
globalThis.localStorage.setItem(STORAGE_KEY, JSON.stringify(checkpoint));
return { status: "verified", storage: "browser_local_storage" };
} catch (error) {
return { status: "blocked", storage: "browser_local_storage", reason: error instanceof Error ? error.message : String(error) };
}
}
export function attachPersistedPowerOffCheckpoint(payload: unknown): unknown {
if (!payload || typeof payload !== "object" || Array.isArray(payload)) return payload;
try {
const raw = globalThis.localStorage?.getItem(STORAGE_KEY);
if (!raw) return payload;
const checkpoint = JSON.parse(raw);
return { ...(payload as Record<string, unknown>), recovery_checkpoint: checkpoint };
} catch {
return payload;
}
}
export function clearPersistedPowerOffCheckpoint(message: MessageEnvelope): void {
const payload = payloadOf(message);
if (message.kind !== "COMMAND_RESULT" || payload.status !== "verified" || payload.command !== "POWER" || payload.active !== true) return;
try { globalThis.localStorage?.removeItem(STORAGE_KEY); } catch { /* storage cleanup is best effort */ }
}
export { STORAGE_KEY };

View File

@@ -42,6 +42,7 @@ export type SimSnapshotV1 = {
simulation_tick: number;
state_hash: string;
task?: { state: string; mode: string; interp_state?: string; current_line?: number | null };
program?: Record<string, unknown> | null;
motion?: MotionSnapshot;
wasm_motion?: Record<string, unknown>;
authoritative?: { state_hash?: string; planner?: TrajectorySnapshot; kinematics?: Record<string, unknown> };
@@ -56,6 +57,8 @@ export type SimSnapshotV1 = {
panel_snapshot?: PanelSnapshotV1;
configuration?: Record<string, unknown> | null;
visual?: PanelSnapshotV1["visual"];
motion_sample?: { tick?: number; pose?: Record<string, number>; joints?: number[]; active_segment_id?: number; active_progress?: number };
snapshot_mode?: "full" | "motion_delta";
kinematics?: XyzacKinematicsSnapshot;
xyzac?: XyzacCapabilitySnapshot;
gates?: Record<string, { action: string; enabled: boolean; reason: string }>;

View File

@@ -56,6 +56,41 @@ function payload<T>(message: MessageEnvelope): T {
function projection(result: Record<string, unknown>, message: MessageEnvelope, previous: FrontendState) {
const panel = result.panel_snapshot as PanelSnapshotV1 | undefined;
const delta = result.motion_sample as { tick?: number; pose?: Record<string, number>; 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<string, unknown>) ?? previous.snapshot.program,
motion: {
...previous.snapshot.motion,
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)],
joints,
},
wasm_motion: (result.wasm_motion as Record<string, unknown>) ?? previous.snapshot.wasm_motion,
...(visual ? { visual } : {}),
} satisfies SimSnapshotV1;
return {
snapshot: nextSnapshot,
toolpath: previous.toolpath,
task: (result.task as Record<string, unknown>) ?? previous.task,
program: (result.program as Record<string, unknown>) ?? previous.program,
axisState: (result.axis_state as AxisStateView) ?? previous.axisState,
gates: (result.gates as FrontendState["gates"]) ?? previous.gates,
};
}
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))
@@ -178,7 +213,8 @@ export function reduceFrontendState(state: FrontendState, message: MessageEnvelo
const projected = projection(value, message, state);
const taskState = String((value.task as Record<string, unknown> | undefined)?.state ?? "").toUpperCase();
const status = ["ESTOP", "FAULT"].includes(taskState) ? "blocked" : "error";
return { ...base, status, error: { code: String(value.code ?? "worker_error"), message: JSON.stringify(value) }, ...projected };
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;
}

View File

@@ -1,7 +1,20 @@
import { CONTEXT_ID, PROTOCOL_SCHEMA, isEnvelope, type MessageEnvelope } from "./protocol";
import { attachPersistedPowerOffCheckpoint, clearPersistedPowerOffCheckpoint, persistPowerOffCheckpoint } from "./power-off-checkpoint-storage";
type Listener = (message: MessageEnvelope) => void;
function commandError(message: MessageEnvelope): Error {
const payload = message.payload && typeof message.payload === "object" ? message.payload as Record<string, unknown> : {};
const error = new Error(String(payload.reason ?? payload.gate_reason ?? payload.message_key ?? payload.code ?? "worker_error"));
Object.assign(error, {
code: String(payload.code ?? "worker_error"),
reason: payload.reason ?? payload.gate_reason ?? null,
message_key: payload.message_key ?? null,
response: message,
});
return error;
}
export class ReactWorkerClient {
private readonly worker: Worker;
private readonly contextId: string;
@@ -19,11 +32,14 @@ export class ReactWorkerClient {
this.sessionId = sessionId;
this.worker.onmessage = (event: MessageEvent<unknown>) => {
if (!isEnvelope(event.data)) return;
const persistence = persistPowerOffCheckpoint(event.data);
if (persistence && event.data.payload && typeof event.data.payload === "object") Object.assign(event.data.payload, { recovery_persistence: persistence });
clearPersistedPowerOffCheckpoint(event.data);
for (const listener of this.listeners) listener(event.data);
const pending = this.pending.get(event.data.seq);
if (pending) {
this.pending.delete(event.data.seq);
if (event.data.kind === "ERROR" || event.data.payload && typeof event.data.payload === "object" && (event.data.payload as { status?: string }).status === "blocked") pending.reject(new Error(String((event.data.payload as { code?: string }).code ?? "worker_error")));
if (event.data.kind === "ERROR" || event.data.payload && typeof event.data.payload === "object" && (event.data.payload as { status?: string }).status === "blocked") pending.reject(commandError(event.data));
else pending.resolve(event.data);
}
};
@@ -45,7 +61,8 @@ export class ReactWorkerClient {
subscribe(listener: Listener): () => void { this.listeners.add(listener); return () => this.listeners.delete(listener); }
send(kind: string, payload: unknown = {}, operationId?: string): Promise<MessageEnvelope> {
const message: MessageEnvelope = { schema_version: PROTOCOL_SCHEMA, kind, context_id: this.contextId, session_id: this.sessionId, seq: ++this.sequence, ...(operationId ? { operation_id: operationId } : {}), payload };
const nextPayload = kind === "LOAD_WORKSPACE" ? attachPersistedPowerOffCheckpoint(payload) : payload;
const message: MessageEnvelope = { schema_version: PROTOCOL_SCHEMA, kind, context_id: this.contextId, session_id: this.sessionId, seq: ++this.sequence, ...(operationId ? { operation_id: operationId } : {}), payload: nextPayload };
return new Promise((resolve, reject) => { this.pending.set(message.seq, { resolve, reject }); this.worker.postMessage(message); });
}