import { fnv1a64 } from "./replay.mjs"; export const HAL_RUNTIME_SCHEMA = "sim_hal_runtime.v1"; const MAX_PIN_COUNT = 256; const MAX_PARAM_COUNT = 256; const MAX_COMPONENT_COUNT = 64; const MAX_THREAD_COUNT = 16; const MAX_PERIOD_TICKS = 1000000; const TYPE_NAMES = new Set(["bit", "s32", "u32", "float"]); const NAME_RE = /^[A-Za-z_][A-Za-z0-9_.-]{0,46}$/; const SPEC = Object.freeze({ corexy_by_hal: { pins: { "j0-motor-pos-cmd": "float", "j1-motor-pos-cmd": "float", "alpha-cmd": "float", "beta-cmd": "float", "alpha-fb": "float", "beta-fb": "float", "j0-motor-pos-fb": "float", "j1-motor-pos-fb": "float" }, params: {} }, constant: { pins: { out: "float" }, params: { value: ["float", 0] } }, and2: { pins: { in0: "bit", in1: "bit", out: "bit" }, params: {} }, or2: { pins: { in0: "bit", in1: "bit", out: "bit" }, params: {} }, xor2: { pins: { in0: "bit", in1: "bit", out: "bit" }, params: {} }, not: { pins: { in: "bit", out: "bit" }, params: {} }, abs: { pins: { in: "float", out: "float", sign: "bit", "is-positive": "bit", "is-negative": "bit" }, params: {} }, abs_s32: { pins: { in: "s32", out: "s32", sign: "bit", "is-positive": "bit", "is-negative": "bit" }, params: {} }, lowpass: { pins: { in: "float", out: "float", load: "bit" }, params: { gain: ["float", 0] } }, ddt: { pins: { in: "float", out: "float" }, params: {} }, integ: { pins: { in: "float", gain: "float", out: "float", reset: "bit", max: "float", min: "float" }, params: {} }, mux2: { pins: { sel: "bit", out: "float", in1: "float", in0: "float" }, params: {} }, sum2: { pins: { in0: "float", in1: "float", out: "float" }, params: { gain0: ["float", 1], gain1: ["float", 1], offset: ["float", 0] } }, mult2: { pins: { in0: "float", in1: "float", out: "float" }, params: {} }, div2: { pins: { in0: "float", in1: "float", out: "float" }, params: { deadband: ["float", 0] } }, hypot: { pins: { in0: "float", in1: "float", in2: "float", out: "float" }, params: {} }, near: { pins: { in1: "float", in2: "float", out: "bit" }, params: { scale: ["float", 1], difference: ["float", 0] } }, comp: { pins: { in0: "float", in1: "float", out: "bit", equal: "bit" }, params: { hyst: ["float", 0] } }, invert: { pins: { in: "float", out: "float" }, params: { deadband: ["float", 0] } }, edge: { pins: { in: "bit", out: "bit", "out-invert": "bit" }, params: { both: ["bit", 0], "in-edge": ["bit", 1], "out-width-ns": ["s32", 0], "time-left-ns": ["s32", 0], "last-in": ["bit", 0] } }, toggle: { pins: { in: "bit", out: "bit" }, params: { debounce: ["u32", 2] } }, estop_latch: { pins: { "ok-in": "bit", "fault-in": "bit", reset: "bit", "ok-out": "bit", "fault-out": "bit", watchdog: "bit" }, params: {} }, }); const clone = (value) => structuredClone(value); const validName = (value) => typeof value === "string" && NAME_RE.test(value); const finite = (value) => typeof value === "number" && Number.isFinite(value); const normalizeType = (value) => value === "signed" ? "s32" : value === "unsigned" ? "u32" : value; function valueFor(type, value = 0) { if (!TYPE_NAMES.has(type) || (type === "bit" && ![0, 1, false, true].includes(value)) || !finite(type === "bit" ? Number(value) : value)) throw new Error("hal_value_invalid"); if (type === "bit") return value ? 1 : 0; if (type === "s32") { if (!Number.isSafeInteger(value) || value < -2147483648 || value > 2147483647) throw new Error("hal_s32_range"); return value; } if (type === "u32") { if (!Number.isSafeInteger(value) || value < 0 || value > 4294967295) throw new Error("hal_u32_range"); return value; } return Number(value); } function qualified(instance, suffix) { if (!validName(instance) || !validName(suffix) || instance.length + suffix.length + 1 > 47) throw new Error("hal_name_invalid"); return `${instance}.${suffix}`; } export function parseCompSource(source) { if (typeof source !== "string" || source.length === 0 || source.length > 1024 * 1024) return { status: "blocked", code: "hal_comp_source_invalid" }; const lines = source.split(/\r?\n/); let component = null; const pins = []; const params = []; const functions = []; const options = []; const errors = []; for (let index = 0; index < lines.length; index += 1) { const line = lines[index].replace(/\/\/.*$/, "").trim(); let match = /^component\s+([A-Za-z_][A-Za-z0-9_]*)\b/.exec(line); if (match) { if (component && component !== match[1]) errors.push(`duplicate_component:${index + 1}`); component = match[1]; continue; } match = /^pin\s+(in|out|io)\s+([A-Za-z_][A-Za-z0-9_]*)\s+([A-Za-z_][A-Za-z0-9_.#-]*)/.exec(line); if (match) { const type = normalizeType(match[2]); if (!TYPE_NAMES.has(type)) errors.push(`hal_type_unsupported:${index + 1}`); pins.push({ direction: match[1], type, name: match[3], line: index + 1 }); continue; } match = /^param\s+(r|rw)\s+([A-Za-z_][A-Za-z0-9_]*)\s+([A-Za-z_][A-Za-z0-9_.-]*)(?:\s*=\s*([^\s]+))?/.exec(line); if (match) { const type = normalizeType(match[2]); if (!TYPE_NAMES.has(type)) errors.push(`hal_type_unsupported:${index + 1}`); params.push({ mode: match[1], type, name: match[3], default: match[4] ?? null, line: index + 1 }); continue; } match = /^function\s+([^;]+);/.exec(line); if (match) functions.push(...match[1].split(/\s+/).filter(Boolean).map((name) => ({ name, line: index + 1 }))); match = /^option\s+([A-Za-z_][A-Za-z0-9_]*)(?:\s*=\s*([^\s]+))?/.exec(line); if (match) options.push({ name: match[1], value: match[2] ?? null, line: index + 1 }); } if (!component) errors.push("hal_component_missing"); const names = [...pins, ...params].map((item) => item.name); if (new Set(names).size !== names.length) errors.push("hal_declaration_duplicate"); if (component && !SPEC[component]) errors.push("hal_component_unsupported"); return { schema: "hal-component-source.v1", status: errors.length ? "blocked" : "verified", ...(errors.length ? { code: errors[0].split(":", 1)[0] } : {}), component, pins, params, functions, options, errors }; } export class HalRuntime { constructor() { this.reset(); } reset() { this.state = { schema: HAL_RUNTIME_SCHEMA, tick: 0, threads: {}, pins: {}, params: {}, components: [], links: [], last_error: null }; return this.export(); } export() { const unsigned = clone(this.state); return { ...unsigned, state_hash: fnv1a64(unsigned) }; } hash() { return this.export().state_hash; } fail(code, detail = null) { this.state.last_error = code; return { status: "error", code, ...(detail ? { detail } : {}), state: this.export() }; } ok(command) { this.state.last_error = null; return { status: "verified", command, state: this.export() }; } registerThread(name, period_ticks) { if (!validName(name) || !Number.isSafeInteger(period_ticks) || period_ticks < 1 || period_ticks > MAX_PERIOD_TICKS) return this.fail("hal_thread_invalid"); if (this.state.threads[name]) return this.fail("hal_thread_duplicate"); if (Object.keys(this.state.threads).length >= MAX_THREAD_COUNT) return this.fail("hal_thread_capacity"); this.state.threads[name] = { name, period_ticks }; return this.ok("HAL_THREAD"); } registerComponent(instance, type, thread = "servo-thread") { if (!validName(instance) || !validName(type) || !validName(thread)) return this.fail("hal_component_name"); if (!SPEC[type]) return this.fail("hal_component_unsupported"); if (!this.state.threads[thread]) return this.fail("hal_thread_missing"); if (this.state.components.some((component) => component.instance === instance)) return this.fail("hal_component_duplicate"); if (this.state.components.length >= MAX_COMPONENT_COUNT) return this.fail("hal_component_capacity"); const spec = SPEC[type]; const before = clone(this.state); try { const component = { instance, type, thread, calls: 0, failed: false, memory: [0, 0, 0, 0], flags: [0, 0, 0, 0] }; for (const [suffix, pinType] of Object.entries(spec.pins)) { const name = qualified(instance, suffix); if (this.state.pins[name] || this.state.params[name]) throw new Error("hal_pin_duplicate"); if (Object.keys(this.state.pins).length >= MAX_PIN_COUNT) throw new Error("hal_pin_capacity"); this.state.pins[name] = { name, type: pinType, value: valueFor(pinType) }; } for (const [suffix, [paramType, initial]] of Object.entries(spec.params)) { const name = qualified(instance, suffix); if (this.state.pins[name] || this.state.params[name]) throw new Error("hal_param_duplicate"); if (Object.keys(this.state.params).length >= MAX_PARAM_COUNT) throw new Error("hal_param_capacity"); this.state.params[name] = { name, type: paramType, value: valueFor(paramType, initial) }; } if (type === "integ") { this.state.pins[qualified(instance, "gain")].value = 1; this.state.pins[qualified(instance, "max")].value = 1e20; this.state.pins[qualified(instance, "min")].value = -1e20; } if (type === "estop_latch") { this.state.pins[qualified(instance, "ok-in")].value = 1; this.state.pins[qualified(instance, "fault-out")].value = 1; } this.state.components.push(component); return this.ok("HAL_COMPONENT"); } catch (error) { this.state = before; return this.fail(error.message); } } configure({ threads = [], components = [], links = [] } = {}) { if (!Array.isArray(threads) || !Array.isArray(components) || !Array.isArray(links)) return this.fail("hal_config_invalid"); this.reset(); if (!threads.length) threads = [{ name: "servo-thread", period_ticks: 1 }]; for (const thread of threads) { const result = this.registerThread(thread?.name, thread?.period_ticks); if (result.status === "error") return result; } for (const component of components) { const instances = Array.isArray(component?.instances) && component.instances.length ? component.instances : [component?.instance ?? component?.name ?? component?.module]; for (const instance of instances) { const result = this.registerComponent(instance, component?.type ?? component?.module, component?.thread ?? "servo-thread"); if (result.status === "error") return result; } } for (const link of links) { const result = this.connect(link?.source, link?.target); if (result.status === "error") return result; } return this.ok("HAL_CONFIG"); } setValue(collection, name, value, type = undefined) { const entry = this.state[collection][name]; if (!entry) return this.fail("hal_value_missing"); const normalized = value && typeof value === "object" && !Array.isArray(value) ? value.value : value; const requestedType = value && typeof value === "object" && !Array.isArray(value) ? value.type : type; if (requestedType !== undefined && normalizeType(requestedType) !== entry.type) return this.fail("hal_value_type"); try { entry.value = valueFor(entry.type, normalized); } catch (error) { return this.fail(error.message); } return this.ok(collection === "pins" ? "HAL_SET_PIN" : "HAL_SET_PARAM"); } setPin(name, value, type = undefined) { return this.setValue("pins", name, value, type); } setParam(name, value, type = undefined) { return this.setValue("params", name, value, type); } getPin(name) { return this.state.pins[name] ? clone(this.state.pins[name]) : null; } getParam(name) { return this.state.params[name] ? clone(this.state.params[name]) : null; } connect(source, target) { if (!this.state.pins[source] || !this.state.pins[target]) return this.fail("hal_connection_missing"); if (this.state.pins[source].type !== this.state.pins[target].type) return this.fail("hal_connection_type"); if (this.state.links.some((link) => link.source === source && link.target === target)) return this.fail("hal_connection_duplicate"); this.state.links.push({ source, target }); this.state.pins[target].value = this.state.pins[source].value; return this.ok("HAL_CONNECT"); } setFailure(instance, failed = true) { const component = this.state.components.find((item) => item.instance === instance); if (!component) return this.fail("hal_component_missing"); component.failed = Boolean(failed); return this.ok("HAL_FAILURE"); } readPin(component, suffix) { return this.state.pins[qualified(component.instance, suffix)].value; } writePin(component, suffix, value) { const pin = this.state.pins[qualified(component.instance, suffix)]; pin.value = valueFor(pin.type, value); } readParam(component, suffix) { return this.state.params[qualified(component.instance, suffix)].value; } writeParam(component, suffix, value) { const param = this.state.params[qualified(component.instance, suffix)]; param.value = valueFor(param.type, value); } runComponent(component) { const p = (suffix) => this.readPin(component, suffix); const w = (suffix, value) => this.writePin(component, suffix, value); const q = (suffix) => this.readParam(component, suffix); switch (component.type) { case "corexy_by_hal": w("j0-motor-pos-fb", p("alpha-fb") + p("beta-fb")); w("j1-motor-pos-fb", p("alpha-fb") - p("beta-fb")); w("alpha-cmd", 0.5 * (p("j0-motor-pos-cmd") + p("j1-motor-pos-cmd"))); w("beta-cmd", 0.5 * (p("j0-motor-pos-cmd") - p("j1-motor-pos-cmd"))); break; case "constant": w("out", q("value")); break; case "and2": w("out", p("in0") && p("in1")); break; case "or2": w("out", p("in0") || p("in1")); break; case "xor2": w("out", Boolean(p("in0") !== p("in1"))); break; case "not": w("out", !p("in")); break; case "abs": { const value = p("in"); w("out", Math.abs(value)); w("sign", value < 0); w("is-positive", value > 0.000001); w("is-negative", value < -0.000001); break; } case "abs_s32": { const value = p("in"); if (value === -2147483648) throw new Error("hal_s32_range"); w("out", Math.abs(value)); w("sign", value < 0); w("is-positive", value > 0); w("is-negative", value < 0); break; } case "lowpass": { const input = p("in"); const output = p("out"); w("out", p("load") ? input : output + (input - output) * q("gain")); break; } case "ddt": { const input = p("in"); w("out", input - component.memory[0]); component.memory[0] = input; break; } case "integ": { const input = p("in"); component.memory[0] = p("reset") ? 0 : component.memory[0] + p("gain") * input; component.memory[0] = Math.min(p("max"), Math.max(p("min"), component.memory[0])); w("out", component.memory[0]); break; } case "mux2": w("out", p("sel") ? p("in1") : p("in0")); break; case "sum2": w("out", p("in0") * q("gain0") + p("in1") * q("gain1") + q("offset")); break; case "mult2": w("out", p("in0") * p("in1")); break; case "div2": { let divisor = p("in1"); const deadband = Math.max(1e-12, q("deadband")); if (Math.abs(divisor) < deadband) divisor = divisor < 0 ? -deadband : deadband; w("out", p("in0") / divisor); break; } case "hypot": w("out", Math.hypot(p("in0"), p("in1"), p("in2"))); break; case "near": { let left = p("in1"); let right = p("in2"); if (left < 0) { left = -left; right = -right; } w("out", (q("scale") > 1 && left / q("scale") <= right && right <= left * q("scale")) || Math.abs(left - right) <= q("difference")); break; } case "comp": { const difference = p("in1") - p("in0"); const half = 0.5 * q("hyst"); if (difference < -half) { w("out", 0); w("equal", 0); } else if (difference > half) { w("out", 1); w("equal", 0); } else w("equal", 1); break; } case "invert": { const deadband = Math.max(1e-12, q("deadband")); let input = p("in"); if (Math.abs(input) < deadband) input = input < 0 ? -deadband : deadband; w("out", 1 / input); break; } case "edge": { const input = p("in"); const previous = q("last-in"); if (!component.flags[0]) { component.flags[0] = 1; w("out", 0); w("out-invert", 1); } else { const rise = input && !previous; const fall = !input && previous; const hit = q("both") ? rise || fall : q("in-edge") ? fall : rise; w("out", hit); w("out-invert", !hit); } this.writeParam(component, "last-in", input); break; } case "toggle": { const input = p("in"); let output = p("out"); const debounce = q("debounce") < 1 || q("debounce") > 10000 ? 2 : q("debounce"); if (input) { component.flags[0] = Math.min(debounce, component.flags[0] + 1); if (component.flags[0] >= debounce && !component.flags[1]) { output = output ? 0 : 1; component.flags[1] = 1; } } else { component.flags[0] = 0; component.flags[1] = 0; } w("out", output); break; } case "estop_latch": { const ok = p("ok-in"); const fault = p("fault-in"); const reset = p("reset"); if (!ok || fault) component.flags[0] = 0; else if (reset && !component.flags[1]) component.flags[0] = 1; component.flags[1] = reset ? 1 : 0; w("ok-out", component.flags[0]); w("fault-out", !component.flags[0]); if (component.flags[0]) component.flags[2] = component.flags[2] ? 0 : 1; w("watchdog", component.flags[2]); break; } default: throw new Error("hal_component_unsupported"); } } step(tick) { if (!Number.isSafeInteger(tick) || tick < this.state.tick) return this.fail("hal_tick_regression"); const before = clone(this.state); try { for (let current = this.state.tick + 1; current <= tick; current += 1) { for (const link of this.state.links) this.state.pins[link.target].value = this.state.pins[link.source].value; for (const component of this.state.components) { const period = this.state.threads[component.thread].period_ticks; if (current % period !== 0) continue; if (component.failed) throw new Error("hal_function_failure"); this.runComponent(component); component.calls += 1; } } this.state.tick = tick; return this.ok("HAL_STEP"); } catch (error) { this.state = before; return this.fail(error.message); } } restore(value) { if (!value || value.schema !== HAL_RUNTIME_SCHEMA) throw new Error("hal_restore_schema"); const unsigned = clone(value); delete unsigned.state_hash; if (value.state_hash !== fnv1a64(unsigned)) throw new Error("hal_restore_hash"); this.state = unsigned; return this.export(); } } export { SPEC as STATIC_HAL_COMPONENTS }; export { HalRuntime as HalComponentRuntime, parseCompSource as parseHalComponent };