Implement AXIS parity UI and execution baseline
This commit is contained in:
173
web/worker/device-io.mjs
Normal file
173
web/worker/device-io.mjs
Normal file
@@ -0,0 +1,173 @@
|
||||
import { fnv1a64 } from "./replay.mjs";
|
||||
|
||||
export const DEVICE_IO_SCHEMA = "sim_device_io.v1";
|
||||
const MAX_ENCODERS = 8;
|
||||
const MAX_ACKS = 64;
|
||||
const clone = (value) => structuredClone(value);
|
||||
const finite = (value) => typeof value === "number" && Number.isFinite(value);
|
||||
|
||||
function encoder() {
|
||||
return { registered: true, speed_rps: 0, ppr: 1000, scale: 1, raw_count: 0, count: 0, position: 0, velocity: 0, phase_a: 1, phase_b: 0, phase_z: 1, index_enable: false, index_pulse: false, tick: 0, phase: 0 };
|
||||
}
|
||||
|
||||
export class DeviceIoRuntime {
|
||||
constructor(options = {}) { this.reset(options); }
|
||||
|
||||
reset({ encoder_count = 1, cycle_time = 0.1 } = {}) {
|
||||
if (!Number.isSafeInteger(encoder_count) || encoder_count < 1 || encoder_count > MAX_ENCODERS || !finite(cycle_time) || cycle_time <= 0 || cycle_time > 10) throw new Error("device_io_config");
|
||||
this.state = {
|
||||
schema: DEVICE_IO_SCHEMA,
|
||||
tick: 0,
|
||||
cycle_time,
|
||||
encoders: Array.from({ length: encoder_count }, encoder),
|
||||
coolant: { mist_requested: false, flood_requested: false, lube_requested: false, mist: false, flood: false, lube: false, fault: false },
|
||||
safety: { door_open: false, input_ok: true, estop: false, fault: false, interlock_ok: true, latched_fault: false, recovery_epoch: 0 },
|
||||
acks: [],
|
||||
last_error: "device_io_ok",
|
||||
};
|
||||
return this.export();
|
||||
}
|
||||
|
||||
export() { const unsigned = clone(this.state); delete unsigned.state_hash; return { ...unsigned, state_hash: fnv1a64(unsigned) }; }
|
||||
hash() { return this.export().state_hash; }
|
||||
ok(command = "DEVICE_IO") { this.state.last_error = "device_io_ok"; return { status: "verified", command, state: this.export() }; }
|
||||
fail(code, detail = null) { this.state.last_error = code; return { status: "blocked", code, ...(detail ? { detail } : {}), state: this.export() }; }
|
||||
|
||||
updateSafety() {
|
||||
const safety = this.state.safety;
|
||||
safety.fault = Boolean(safety.door_open || !safety.input_ok);
|
||||
if (safety.fault || safety.estop) safety.latched_fault = true;
|
||||
safety.interlock_ok = !safety.fault && !safety.estop && !safety.latched_fault;
|
||||
}
|
||||
|
||||
updateCoolant() {
|
||||
const coolant = this.state.coolant;
|
||||
const allowed = this.state.safety.interlock_ok && !coolant.fault;
|
||||
coolant.mist = allowed && coolant.mist_requested;
|
||||
coolant.flood = allowed && coolant.flood_requested;
|
||||
coolant.lube = allowed && coolant.lube_requested;
|
||||
}
|
||||
|
||||
configure(options = {}) {
|
||||
try { this.reset(options); return this.ok("DEVICE_CONFIG"); } catch (error) { return this.fail(error.message); }
|
||||
}
|
||||
|
||||
encoderSet(channel, { speed_rps, ppr, scale, index_enable, reset = false } = {}) {
|
||||
const item = this.state.encoders[channel];
|
||||
if (!item || !finite(speed_rps ?? item.speed_rps) || Math.abs(speed_rps ?? item.speed_rps) > 1000000) return this.fail("device_encoder_speed");
|
||||
if (ppr !== undefined && (!Number.isSafeInteger(ppr) || ppr < 1 || ppr > 100000000)) return this.fail("device_encoder_ppr");
|
||||
if (scale !== undefined && (!finite(scale) || scale <= 0)) return this.fail("device_encoder_scale");
|
||||
if (speed_rps !== undefined) item.speed_rps = speed_rps;
|
||||
if (ppr !== undefined) item.ppr = ppr;
|
||||
if (scale !== undefined) item.scale = scale;
|
||||
if (index_enable !== undefined) { if (typeof index_enable !== "boolean") return this.fail("device_encoder_index"); item.index_enable = index_enable; }
|
||||
if (reset === true) { item.raw_count = 0; item.count = 0; item.position = 0; item.phase = 0; item.index_pulse = false; }
|
||||
return this.ok("ENCODER_SET");
|
||||
}
|
||||
|
||||
coolantCommand({ mist = false, flood = false, lube = false } = {}) {
|
||||
if (![mist, flood, lube].every((value) => typeof value === "boolean")) return this.fail("device_coolant_argument");
|
||||
if ((mist || flood || lube) && !this.state.safety.interlock_ok) return this.fail("device_interlock");
|
||||
Object.assign(this.state.coolant, { mist_requested: mist, flood_requested: flood, lube_requested: lube });
|
||||
this.updateCoolant();
|
||||
return this.ok("COOLANT_COMMAND");
|
||||
}
|
||||
|
||||
coolantFault(fault) {
|
||||
if (typeof fault !== "boolean") return this.fail("device_coolant_fault");
|
||||
this.state.coolant.fault = fault; this.updateCoolant();
|
||||
return fault ? this.fail("device_coolant_fault") : this.ok("COOLANT_FAULT_CLEAR");
|
||||
}
|
||||
|
||||
doorSet(open) {
|
||||
if (typeof open !== "boolean") return this.fail("device_door_argument");
|
||||
this.state.safety.door_open = open; this.updateSafety(); this.updateCoolant();
|
||||
return open ? this.fail("device_interlock") : this.ok("DOOR_SET");
|
||||
}
|
||||
|
||||
interlockSet(inputOk) {
|
||||
if (typeof inputOk !== "boolean") return this.fail("device_interlock_argument");
|
||||
this.state.safety.input_ok = inputOk; this.updateSafety(); this.updateCoolant();
|
||||
return inputOk ? this.ok("INTERLOCK_SET") : this.fail("device_interlock");
|
||||
}
|
||||
|
||||
interlockReset() {
|
||||
this.updateSafety();
|
||||
if (this.state.safety.door_open || !this.state.safety.input_ok || this.state.safety.estop) return this.fail("device_interlock");
|
||||
this.state.safety.latched_fault = false; this.state.safety.recovery_epoch += 1;
|
||||
this.updateSafety(); this.updateCoolant();
|
||||
return this.ok("INTERLOCK_RESET");
|
||||
}
|
||||
|
||||
estop(active) {
|
||||
if (typeof active !== "boolean") return this.fail("device_estop_argument");
|
||||
this.state.safety.estop = active; this.updateSafety(); this.updateCoolant();
|
||||
return active ? this.fail("device_interlock") : this.ok("DEVICE_ESTOP");
|
||||
}
|
||||
|
||||
issueAck(operation_id, timeout_ticks = 10) {
|
||||
if (typeof operation_id !== "string" || operation_id.length === 0 || !Number.isSafeInteger(timeout_ticks) || timeout_ticks < 1 || timeout_ticks > 1000000) return this.fail("device_ack_argument");
|
||||
const existing = this.state.acks.find((entry) => entry.operation_id === operation_id);
|
||||
if (existing) return this.fail("device_ack_duplicate", { command: clone(existing) });
|
||||
if (this.state.acks.length >= MAX_ACKS) return this.fail("device_ack_capacity");
|
||||
const command = { operation_id, issued_tick: this.state.tick, deadline_tick: this.state.tick + timeout_ticks, status: "pending" };
|
||||
this.state.acks.push(command);
|
||||
return this.ok("DEVICE_ACK_ISSUE");
|
||||
}
|
||||
|
||||
ack(operation_id) {
|
||||
const command = this.state.acks.find((entry) => entry.operation_id === operation_id);
|
||||
if (!command) return this.fail("device_ack_missing");
|
||||
if (command.status !== "pending") return this.fail(command.status === "timed_out" ? "device_ack_late" : "device_ack_state", { command: clone(command) });
|
||||
command.status = "acked";
|
||||
return this.ok("DEVICE_ACK");
|
||||
}
|
||||
|
||||
abortAck(operation_id) {
|
||||
const command = this.state.acks.find((entry) => entry.operation_id === operation_id);
|
||||
if (!command) return this.fail("device_ack_missing");
|
||||
if (command.status === "acked") return this.fail("device_ack_too_late", { command: clone(command) });
|
||||
command.status = "aborted";
|
||||
return this.ok("DEVICE_ACK_ABORT");
|
||||
}
|
||||
|
||||
advanceAck(tick) {
|
||||
for (const command of this.state.acks) if (command.status === "pending" && tick >= command.deadline_tick) command.status = "timed_out";
|
||||
}
|
||||
|
||||
ackStep(tick) {
|
||||
if (!Number.isSafeInteger(tick) || tick < this.state.tick) return this.fail("device_tick_regression");
|
||||
this.state.tick = tick; this.advanceAck(tick); return this.ok("DEVICE_ACK_TICK");
|
||||
}
|
||||
|
||||
step(tick) {
|
||||
if (!Number.isSafeInteger(tick) || tick < this.state.tick) return this.fail("device_tick_regression");
|
||||
const before = clone(this.state);
|
||||
try {
|
||||
for (let current = this.state.tick + 1; current <= tick; current += 1) {
|
||||
this.state.tick = current; this.updateSafety(); this.updateCoolant();
|
||||
for (const item of this.state.encoders) {
|
||||
const delta = item.speed_rps * item.ppr * 4 * this.state.cycle_time;
|
||||
item.phase += delta;
|
||||
if (!finite(item.phase) || Math.abs(item.phase) > 2147483647) throw new Error("device_encoder_range");
|
||||
item.raw_count = item.phase < 0 ? Math.ceil(item.phase) : Math.floor(item.phase);
|
||||
item.count = item.raw_count; item.position = item.raw_count / item.scale; item.velocity = item.speed_rps * item.ppr * 4 / item.scale;
|
||||
const phase = ((item.raw_count % 4) + 4) % 4;
|
||||
item.phase_a = phase === 0 || phase === 1; item.phase_b = phase === 1 || phase === 2; item.index_pulse = false;
|
||||
if (item.index_enable && item.raw_count % (item.ppr * 4) === 0) { item.phase_z = 1; item.index_pulse = true; item.index_enable = false; } else item.phase_z = 0;
|
||||
item.tick = current;
|
||||
}
|
||||
this.advanceAck(current);
|
||||
}
|
||||
return this.ok("DEVICE_STEP");
|
||||
} catch (error) { this.state = before; return this.fail(error.message); }
|
||||
}
|
||||
|
||||
restore(value) {
|
||||
if (!value || value.schema !== DEVICE_IO_SCHEMA) throw new Error("device_io_restore_schema");
|
||||
const unsigned = clone(value); delete unsigned.state_hash;
|
||||
if (value.state_hash !== fnv1a64(unsigned)) throw new Error("device_io_restore_hash");
|
||||
if (!Array.isArray(unsigned.encoders) || unsigned.encoders.length > MAX_ENCODERS || !Array.isArray(unsigned.acks) || unsigned.acks.length > MAX_ACKS) throw new Error("device_io_restore_capacity");
|
||||
this.state = unsigned; return this.export();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user