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(); } }