Files
linuxcncWebWASM/web/worker/plc-runtime.mjs

124 lines
7.3 KiB
JavaScript

import { fnv1a64 } from "./replay.mjs";
export const PLC_SCHEMA = "sim_plc.v1";
const MAX_RUNG_COUNT = 256;
const MAX_REGISTER_COUNT = 256;
const MAX_SAMPLE_CAPACITY = 1024;
export function parseClassicLadder(source) {
if (typeof source !== "string" || source.length === 0) return { status: "error", code: "clp_source_invalid" };
const lines = source.split(/\r?\n/);
const rungs = [];
let active = null;
for (let index = 0; index < lines.length; index += 1) {
const line = lines[index].trim();
const start = /^_FILE-rung_(\d+)\.csv$/.exec(line);
if (start) { active = { number: Number(start[1]), cells: [] }; rungs.push(active); continue; }
if (line.startsWith("_/FILE-rung_")) { active = null; continue; }
if (!active || !line || line.startsWith("#")) continue;
const cells = line.split(",").map((cell) => cell.trim()).filter(Boolean);
const parsed = [];
for (const cell of cells) {
const match = /^(\d+)-(\d+)-(\d+)\/(\d+)$/.exec(cell);
if (!match) return { status: "error", code: "clp_cell_invalid", line: index + 1, text: line };
parsed.push({ type: Number(match[1]), negated: Number(match[2]), value: Number(match[3]), index: Number(match[4]) });
}
active.cells.push(parsed);
}
if (rungs.length === 0) return { status: "error", code: "clp_rung_missing" };
const supported = new Set([0, 1, 2, 9, 10, 20, 50, 51, 52, 53, 55, 60, 99, 103]);
for (const rung of rungs) for (const row of rung.cells) for (const cell of row) if (!supported.has(cell.type)) return { status: "error", code: "clp_cell_unsupported", type: cell.type, rung: rung.number };
return { status: "verified", schema: "classicladder-clp.v1", rungs: rungs.map((rung) => ({ number: rung.number, rows: rung.cells.length, cells: rung.cells.reduce((count, row) => count + row.length, 0) })), cell_count: rungs.reduce((count, rung) => count + rung.cells.reduce((n, row) => n + row.length, 0), 0) };
}
function clone(value) { return structuredClone(value); }
function finite(value) { return typeof value === "number" && Number.isFinite(value); }
function name(value) { return typeof value === "string" && /^[A-Za-z_][A-Za-z0-9_.-]{0,63}$/.test(value) ? value : null; }
export class PlcRuntime {
constructor() { this.reset(); }
reset() {
this.state = { schema: PLC_SCHEMA, tick: 0, scan_count: 0, registers: {}, timers: {}, counters: {}, rungs: [], sampler: { capacity: 0, policy: "drop_newest", samples: [], dropped: 0, triggered: 0 }, last_error: null };
return this.export();
}
export() { const value = clone(this.state); return { ...value, state_hash: fnv1a64(value) }; }
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() }; }
configure({ rungs = [], sample_capacity = 0, backpressure = "drop_newest" } = {}) {
if (!Array.isArray(rungs) || rungs.length > MAX_RUNG_COUNT) return this.fail("plc_rung_capacity");
if (!Number.isSafeInteger(sample_capacity) || sample_capacity < 0 || sample_capacity > MAX_SAMPLE_CAPACITY) return this.fail("plc_sampler_capacity");
if (!["drop_newest", "drop_oldest", "block"].includes(backpressure)) return this.fail("plc_backpressure_policy");
const normalized = [];
for (const rung of rungs) {
if (!rung || typeof rung !== "object" || !["SET", "ADD", "AND", "OR", "NOT", "TIMER", "COUNTER"].includes(rung.op)) return this.fail("plc_rung_unsupported");
if (!name(rung.target)) return this.fail("plc_register_name");
if (rung.source !== undefined && !name(rung.source)) return this.fail("plc_register_name");
normalized.push({ op: rung.op, target: rung.target, ...(rung.source ? { source: rung.source } : {}), ...(rung.value !== undefined ? { value: rung.value } : {}), ...(rung.preset !== undefined ? { preset: rung.preset } : {}) });
if (!(rung.target in this.state.registers)) this.state.registers[rung.target] = 0;
}
this.state.rungs = normalized;
this.state.sampler = { capacity: sample_capacity, policy: backpressure, samples: [], dropped: 0, triggered: 0 };
return this.ok("PLC_CONFIG");
}
setRegister(register, value) {
if (!name(register) || !finite(value)) return this.fail("plc_register_value");
if (!(register in this.state.registers) && Object.keys(this.state.registers).length >= MAX_REGISTER_COUNT) return this.fail("plc_register_capacity");
this.state.registers[register] = value;
return this.ok("PLC_SET");
}
scan(tick) {
if (!Number.isSafeInteger(tick) || tick < this.state.tick) return this.fail("plc_tick_regression");
this.state.tick = tick;
for (const rung of this.state.rungs) {
const source = rung.source ? (this.state.registers[rung.source] ?? 0) : (rung.value ?? 0);
const current = this.state.registers[rung.target] ?? 0;
if (rung.op === "SET") this.state.registers[rung.target] = source;
else if (rung.op === "ADD") this.state.registers[rung.target] = current + source;
else if (rung.op === "AND") this.state.registers[rung.target] = current !== 0 && source !== 0 ? 1 : 0;
else if (rung.op === "OR") this.state.registers[rung.target] = current !== 0 || source !== 0 ? 1 : 0;
else if (rung.op === "NOT") this.state.registers[rung.target] = current === 0 ? 1 : 0;
else if (rung.op === "TIMER") {
const timer = this.state.timers[rung.target] ?? { elapsed: 0, preset: Number.isSafeInteger(rung.preset) ? rung.preset : 1, done: false };
if (source !== 0) timer.elapsed += 1; else timer.elapsed = 0;
timer.done = timer.elapsed >= timer.preset; this.state.timers[rung.target] = timer; this.state.registers[rung.target] = timer.done ? 1 : 0;
} else if (rung.op === "COUNTER") {
const counter = this.state.counters[rung.target] ?? { count: 0, preset: Number.isSafeInteger(rung.preset) ? rung.preset : 1, done: false };
if (source !== 0) counter.count += 1;
counter.done = counter.count >= counter.preset; this.state.counters[rung.target] = counter; this.state.registers[rung.target] = counter.done ? 1 : 0;
}
if (!finite(this.state.registers[rung.target])) return this.fail("plc_nonfinite");
}
this.state.scan_count += 1;
return this.capture(false);
}
capture(triggered = true) {
const sampler = this.state.sampler;
if (sampler.capacity === 0) return this.ok(triggered ? "PLC_TRIGGER" : "PLC_SCAN");
const sample = { tick: this.state.tick, registers: clone(this.state.registers), scan_count: this.state.scan_count };
if (sampler.samples.length >= sampler.capacity) {
if (sampler.policy === "block") return this.fail("plc_backpressure");
sampler.dropped += 1;
if (sampler.policy === "drop_newest") return this.ok(triggered ? "PLC_TRIGGER" : "PLC_SCAN");
sampler.samples.shift();
}
sampler.samples.push(sample); if (triggered) sampler.triggered += 1;
return this.ok(triggered ? "PLC_TRIGGER" : "PLC_SCAN");
}
trigger() { return this.capture(true); }
restore(value) {
if (!value || value.schema !== PLC_SCHEMA) throw new Error("plc_restore_schema");
const unsigned = { ...value }; delete unsigned.state_hash;
if (value.state_hash !== fnv1a64(unsigned)) throw new Error("plc_restore_hash");
this.state = clone(unsigned); return this.export();
}
}