Optimize XYZAC motion updates and persist power-off recovery
This commit is contained in:
@@ -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 } : {}) });
|
||||
|
||||
40
web/react/src/power-off-checkpoint-storage.ts
Normal file
40
web/react/src/power-off-checkpoint-storage.ts
Normal 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 };
|
||||
@@ -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 }>;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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); });
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user