/**
* Lego Ladder - Professional Arduino Exporter
*
* Generates Arduino/ESP32 code from a ladder logic program.
* Supports: contacts, coils, timers, counters, gates, branches, math,
* one-shot, move, scale, compare, and noop elements.
*/
import type {
Program, Rung, RungElement,
Contact, Coil, Timer, Counter, LogicGate, Branch,
MathElement, OneShot, MoveElement, ScaleElement, NoOp, CompareElement
} from './types';
// ─── Helpers ──────────────────────────────────────────────────────
/** Convert an address string to a valid C variable name. */
function sanitize(name: string): string {
let s = name.replace(/[^a-zA-Z0-9_]/g, '_');
if (/^[0-9]/.test(s)) s = 'v_' + s;
return s;
}
/** Read expression for an address — tries input, memory, then output. */
function readExpr(addr: string): string {
const v = sanitize(addr);
return `getValue("${addr}")`;
}
// ─── State tracking collected during traversal ────────────────────
interface ExportContext {
// Pins mapped to their Arduino pin numbers
pins: Map<string, number>;
// All input addresses (digital or analog)
inputAddresses: Set<string>;
// All output addresses
outputAddresses: Set<string>;
// Memory addresses that need variables
memoryAddresses: Set<string>;
// Timers needing state variables
timers: Timer[];
// Counters needing state variables
counters: Counter[];
// Addresses that need edge-detection (previous state tracking)
edgeAddresses: Set<string>;
// Latched outputs (SET/RESET/LATCH/UNLATCH need persistent state)
latchedOutputs: Set<string>;
// Toggle outputs
toggleOutputs: Set<string>;
// Setup lines
setupLines: string[];
// Global variable declarations
globalVars: string[];
// Loop body lines
loopLines: string[];
// Rung-level expressions collected per rung
rungExpressions: string[][]; // [rungIndex][expressionPart]
// Rung-level output actions collected per rung
rungOutputs: string[][]; // [rungIndex][outputLine]
// Analog input addresses (for analogRead)
analogInputs: Set<string>;
}
function createContext(): ExportContext {
return {
pins: new Map(),
inputAddresses: new Set(),
outputAddresses: new Set(),
memoryAddresses: new Set(),
timers: [],
counters: [],
edgeAddresses: new Set(),
latchedOutputs: new Set(),
toggleOutputs: new Set(),
setupLines: [],
globalVars: [],
loopLines: [],
rungExpressions: [],
rungOutputs: [],
analogInputs: new Set(),
};
}
// ─── Address classification ───────────────────────────────────────
/** Try to parse a pin number from an address like 'I:0.0' or 'Q:0.0'. */
function parsePin(address: string): number | null {
const m = address.match(/(\d+)\.(\d+)/);
if (m) return parseInt(m[1]) * 8 + parseInt(m[2]);
const m2 = address.match(/^(\d+)$/);
if (m2) return parseInt(m2[1]);
return null;
}
/** Check if address looks like a digital I/O (I:0.0, Q:0.0, etc.) */
function isDigitalIO(address: string): boolean {
return /^[IQ]:\d+\.\d+$/.test(address);
}
/** Check if address looks like an analog input (AI:0, A:0, etc.) */
function isAnalogInput(address: string): boolean {
return /^(AI|A):?\d+$/.test(address);
}
// ─── First pass: collect all addresses and classify them ──────────
function collectAddresses(_ctx: ExportContext, elements: RungElement[]): void {
for (const el of elements) {
switch (el.type) {
case 'contact': {
const c = el as Contact;
_ctx.inputAddresses.add(c.address);
if (c.edgeType) {
_ctx.edgeAddresses.add(c.address);
}
break;
}
case 'coil': {
const c = el as Coil;
_ctx.outputAddresses.add(c.address);
if (c.coilType === 'SET' || c.coilType === 'RESET') {
_ctx.latchedOutputs.add(c.address);
}
if (c.coilType === 'LATCH' || c.coilType === 'UNLATCH') {
_ctx.latchedOutputs.add(c.address);
}
if (c.coilType === 'TOGGLE') {
_ctx.toggleOutputs.add(c.address);
}
break;
}
case 'timer': {
const t = el as Timer;
if (!_ctx.timers.find((x) => x.instanceId === t.instanceId)) {
_ctx.timers.push(t);
}
break;
}
case 'counter': {
const c = el as Counter;
if (!_ctx.counters.find((x) => x.instanceId === c.instanceId)) {
_ctx.counters.push(c);
}
if (c.resetAddress) {
_ctx.inputAddresses.add(c.resetAddress);
}
break;
}
case 'gate': {
const g = el as LogicGate;
g.inputs.forEach((inp) => _ctx.inputAddresses.add(inp));
_ctx.memoryAddresses.add(g.outputAddress);
break;
}
case 'branch':
collectAddresses(_ctx, (el as Branch).paths.flat());
break;
case 'math': {
const m = el as MathElement;
_ctx.inputAddresses.add(m.inputA);
_ctx.inputAddresses.add(m.inputB);
_ctx.memoryAddresses.add(m.outputAddress);
break;
}
case 'oneshot': {
const o = el as OneShot;
_ctx.inputAddresses.add(o.address);
_ctx.edgeAddresses.add(o.address);
break;
}
case 'move': {
const m = el as MoveElement;
_ctx.inputAddresses.add(m.source);
_ctx.memoryAddresses.add(m.destination);
break;
}
case 'scale': {
const s = el as ScaleElement;
_ctx.inputAddresses.add(s.inputAddress);
_ctx.memoryAddresses.add(s.destination);
break;
}
case 'noop':
// nothing to collect
break;
case 'compare': {
const c = el as CompareElement;
_ctx.inputAddresses.add(c.inputA);
_ctx.inputAddresses.add(c.inputB);
_ctx.memoryAddresses.add(c.outputAddress);
break;
}
}
}
}
// ─── Generate expressions for each element type ───────────────────
/** Generate Arduino code expression for a contact element. */
function exportContact(ctx: ExportContext, comp: Contact): string {
const addr = comp.address;
const varName = sanitize(addr);
if (isDigitalIO(addr)) {
const pin = parsePin(addr);
if (pin !== null && !ctx.pins.has(addr)) {
ctx.pins.set(addr, pin);
}
const pinRef = ctx.pins.has(addr) ? String(ctx.pins.get(addr)!) : `PIN_${varName}`;
if (ctx.pins.has(addr)) {
ctx.setupLines.push(`pinMode(${pinRef}, INPUT_PULLUP);`);
}
const digitalRead = `(digitalRead(${pinRef}) == HIGH)`;
let baseExpr = comp.contactType === 'NO' ? digitalRead : `(!(${digitalRead}))`;
// Edge detection
if (comp.edgeType) {
const prevVar = `prev_${varName}`;
ctx.globalVars.push(`bool ${prevVar} = false;`);
if (!ctx.edgeAddresses.has(addr)) {
ctx.edgeAddresses.add(addr);
}
const currVar = `curr_${varName}`;
let edgeCondition: string;
switch (comp.edgeType) {
case 'RISING':
edgeCondition = `(${currVar} && !${prevVar})`;
break;
case 'FALLING':
edgeCondition = `(!${currVar} && ${prevVar})`;
break;
case 'BOTH':
edgeCondition = `(${currVar} != ${prevVar})`;
break;
default:
edgeCondition = 'true';
}
baseExpr = `bool ${currVar} = ${digitalRead}; (${baseExpr} && ${edgeCondition})`;
}
return `(${baseExpr})`;
}
// Non-digital input — use getValue helper (memory/output/input lookup)
let baseExpr: string;
const valExpr = readExpr(addr);
if (comp.condition) {
const opMap: Record<string, string> = {
'==': '==', '!=': '!=', '>': '>', '<': '<', '>=': '>=', '<=': '<=',
};
baseExpr = `(${valExpr} ${opMap[comp.condition.op] || '=='} ${comp.condition.value})`;
} else {
baseExpr = `(bool)${valExpr}`;
}
baseExpr = comp.contactType === 'NO' ? baseExpr : `(!(${baseExpr}))`;
// Edge detection for non-digital
if (comp.edgeType) {
const prevVar = `prev_${varName}`;
ctx.globalVars.push(`bool ${prevVar} = false;`);
ctx.edgeAddresses.add(addr);
const currVar = `curr_${varName}`;
let edgeCondition: string;
switch (comp.edgeType) {
case 'RISING':
edgeCondition = `(${currVar} && !${prevVar})`;
break;
case 'FALLING':
edgeCondition = `(!${currVar} && ${prevVar})`;
break;
case 'BOTH':
edgeCondition = `(${currVar} != ${prevVar})`;
break;
default:
edgeCondition = 'true';
}
baseExpr = `bool ${currVar} = ${baseExpr}; (${baseExpr} && ${edgeCondition})`;
}
return `(${baseExpr})`;
}
/** Generate Arduino code for a coil (output) element. */
function exportCoil(ctx: ExportContext, comp: Coil, rungStateVar: string): string[] {
const lines: string[] = [];
const addr = comp.address;
const varName = sanitize(addr);
if (isDigitalIO(addr)) {
const pin = parsePin(addr);
if (pin !== null && !ctx.pins.has(addr)) {
ctx.pins.set(addr, pin);
}
const pinRef = ctx.pins.has(addr) ? String(ctx.pins.get(addr)!) : `PIN_${varName}`;
if (ctx.pins.has(addr) && !ctx.setupLines.includes(`pinMode(${pinRef}, OUTPUT);`)) {
ctx.setupLines.push(`pinMode(${pinRef}, OUTPUT);`);
}
}
switch (comp.coilType) {
case 'OUTPUT':
if (comp.value !== undefined) {
lines.push(`if (${rungStateVar}) { ${varName}_val = ${comp.value}; } else { ${varName}_val = 0; }`);
if (isDigitalIO(addr)) {
const pinRef = ctx.pins.has(addr) ? String(ctx.pins.get(addr)!) : `PIN_${varName}`;
lines.push(`digitalWrite(${pinRef}, ${varName}_val != 0 ? HIGH : LOW);`);
}
} else {
if (isDigitalIO(addr)) {
const pinRef = ctx.pins.has(addr) ? String(ctx.pins.get(addr)!) : `PIN_${varName}`;
lines.push(`digitalWrite(${pinRef}, ${rungStateVar} ? HIGH : LOW);`);
} else {
lines.push(`setOutput("${addr}", ${rungStateVar});`);
}
}
break;
case 'SET':
// When rung is true, set output to true; otherwise keep current value
if (isDigitalIO(addr)) {
const pinRef = ctx.pins.has(addr) ? String(ctx.pins.get(addr)!) : `PIN_${varName}`;
ctx.globalVars.push(`bool ${varName}_state = false;`);
lines.push(`if (${rungStateVar}) { ${varName}_state = true; }`);
lines.push(`digitalWrite(${pinRef}, ${varName}_state ? HIGH : LOW);`);
} else {
lines.push(`if (${rungStateVar}) { setOutput("${addr}", true); }`);
}
break;
case 'RESET':
// When rung is true, set output to false; otherwise keep current value
if (isDigitalIO(addr)) {
const pinRef = ctx.pins.has(addr) ? String(ctx.pins.get(addr)!) : `PIN_${varName}`;
ctx.globalVars.push(`bool ${varName}_state = false;`);
lines.push(`if (${rungStateVar}) { ${varName}_state = false; }`);
lines.push(`digitalWrite(${pinRef}, ${varName}_state ? HIGH : LOW);`);
} else {
lines.push(`if (${rungStateVar}) { setOutput("${addr}", false); }`);
}
break;
case 'TOGGLE':
// Toggle output state only when rung is true
if (isDigitalIO(addr)) {
const pinRef = ctx.pins.has(addr) ? String(ctx.pins.get(addr)!) : `PIN_${varName}`;
ctx.globalVars.push(`bool ${varName}_state = false;`);
lines.push(`if (${rungStateVar}) { ${varName}_state = !${varName}_state; }`);
lines.push(`digitalWrite(${pinRef}, ${varName}_state ? HIGH : LOW);`);
} else {
lines.push(`if (${rungStateVar}) { setOutput("${addr}", !getOutputValue("${addr}")); }`);
}
break;
case 'LATCH':
// Once energized, stays on until UNLATCH clears it
if (isDigitalIO(addr)) {
const pinRef = ctx.pins.has(addr) ? String(ctx.pins.get(addr)!) : `PIN_${varName}`;
ctx.globalVars.push(`bool ${varName}_state = false;`);
lines.push(`if (${rungStateVar}) { ${varName}_state = true; }`);
lines.push(`digitalWrite(${pinRef}, ${varName}_state ? HIGH : LOW);`);
} else {
lines.push(`if (${rungStateVar}) { setOutput("${addr}", true); }`);
}
break;
case 'UNLATCH':
// Clears a latched output
if (isDigitalIO(addr)) {
const pinRef = ctx.pins.has(addr) ? String(ctx.pins.get(addr)!) : `PIN_${varName}`;
ctx.globalVars.push(`bool ${varName}_state = false;`);
lines.push(`if (${rungStateVar}) { ${varName}_state = false; }`);
lines.push(`digitalWrite(${pinRef}, ${varName}_state ? HIGH : LOW);`);
} else {
lines.push(`if (${rungStateVar}) { setOutput("${addr}", false); }`);
}
break;
}
return lines;
}
/** Generate Arduino code for a logic gate element. */
function exportGate(ctx: ExportContext, comp: LogicGate): string {
const inputExprs = comp.inputs.map((addr) => readExpr(addr));
const outVar = sanitize(comp.outputAddress);
ctx.globalVars.push(`bool ${outVar} = false;`);
ctx.memoryAddresses.add(comp.outputAddress);
let expr: string;
switch (comp.gateType) {
case 'AND':
expr = inputExprs.join(' && ');
break;
case 'OR':
expr = inputExprs.join(' || ');
break;
case 'XOR': {
if (inputExprs.length === 2) {
expr = `(${inputExprs[0]} ^ ${inputExprs[1]})`;
} else {
// For multi-input XOR: count true values, odd = true
expr = `(countTrue(${inputExprs.length}, ${inputExprs.join(', ')}) % 2 == 1)`;
}
break;
}
case 'NOT':
expr = `(!(${inputExprs[0]}))`;
break;
case 'NAND':
expr = `(!(${inputExprs.join(' && ')}))`;
break;
case 'NOR':
expr = `(!(${inputExprs.join(' || ')}))`;
break;
default:
expr = 'false';
}
// Generate assignment line (returned as an expression that's evaluated)
return `(${outVar} = (${expr})), ${outVar}`;
}
/** Generate Arduino code for a timer element. */
function exportTimer(ctx: ExportContext, comp: Timer, rungStateVar: string, lastRungStateVar: string): string {
const id = sanitize(comp.instanceId);
const preset = comp.preset;
// Declare timer state variables
ctx.globalVars.push(`unsigned long timer_${id}_accumulated = 0;`);
ctx.globalVars.push(`unsigned long timer_${id}_lastMillis = 0;`);
let logic: string;
switch (comp.timerType) {
case 'TON': {
// On-Delay: accumulate while input is true, output true when accumulated >= preset
logic = `// TON Timer: ${comp.instanceId} (preset=${preset}ms)\\n` +
`if (${rungStateVar}) {\\n` +
` timer_${id}_accumulated = min(timer_${id}_accumulated + (unsigned long)(millis() - timer_${id}_lastMillis), ${preset});\\n` +
`} else {\\n` +
` timer_${id}_accumulated = 0;\\n` +
`}\\n` +
`timer_${id}_lastMillis = millis();\\n` +
`timer_${id}_done = (timer_${id}_accumulated >= ${preset})`;
ctx.globalVars.push(`bool timer_${id}_done = false;`);
break;
}
case 'TOF': {
// Off-Delay: when input goes false, start accumulating; output true while accumulated < preset
logic = `// TOF Timer: ${comp.instanceId} (preset=${preset}ms)\\n` +
`if (${rungStateVar}) {\\n` +
` timer_${id}_accumulated = 0;\\n` +
`} else {\\n` +
` timer_${id}_accumulated = min(timer_${id}_accumulated + (unsigned long)(millis() - timer_${id}_lastMillis), ${preset});\\n` +
`}\\n` +
`timer_${id}_lastMillis = millis();\\n` +
`(${rungStateVar} || timer_${id}_accumulated < ${preset})`;
break;
}
case 'TP': {
// Pulse: set accumulated to preset on rising edge, countdown
logic = `// TP Timer: ${comp.instanceId} (preset=${preset}ms)\\n` +
`if (${rungStateVar} && !${lastRungStateVar}) {\\n` +
` timer_${id}_accumulated = ${preset};\\n` +
` timer_${id}_lastMillis = millis();\\n` +
`} else if (timer_${id}_accumulated > 0) {\\n` +
` timer_${id}_accumulated = max(timer_${id}_accumulated - (unsigned long)(millis() - timer_${id}_lastMillis), 0UL);\\n` +
` timer_${id}_lastMillis = millis();\\n` +
`}\\n` +
`(timer_${id}_accumulated > 0)`;
break;
}
case 'RTO': {
// Retain On-Delay: like TON but accumulated persists when input goes false
logic = `// RTO Timer: ${comp.instanceId} (preset=${preset}ms)\\n` +
`if (${rungStateVar}) {\\n` +
` timer_${id}_accumulated = min(timer_${id}_accumulated + (unsigned long)(millis() - timer_${id}_lastMillis), ${preset});\\n` +
` timer_${id}_lastMillis = millis();\\n` +
`}\\n` +
`(timer_${id}_accumulated >= ${preset})`;
break;
}
}
return logic;
}
/** Generate Arduino code for a counter element. */
function exportCounter(ctx: ExportContext, comp: Counter, rungStateVar: string, lastRungStateVar: string): string {
const id = sanitize(comp.instanceId);
const preset = comp.preset;
ctx.globalVars.push(`int counter_${id}_current = 0;`);
ctx.globalVars.push(`bool counter_${id}_lastRung = false;`);
let resetCheck = '';
if (comp.resetAddress) {
resetCheck = `if (${readExpr(comp.resetAddress)}) { counter_${id}_current = 0; counter_${id}_lastRung = ${rungStateVar}; return false; }\\n`;
}
let incrementLogic: string;
let compareLogic: string;
switch (comp.counterType) {
case 'CTU':
incrementLogic = `if (${rungStateVar} && !counter_${id}_lastRung) { counter_${id}_current++; }`;
compareLogic = `(counter_${id}_current >= ${preset})`;
break;
case 'CTD':
incrementLogic = `if (${rungStateVar} && !counter_${id}_lastRung) { counter_${id}_current--; }`;
compareLogic = `(counter_${id}_current <= ${preset})`;
break;
case 'CTUD':
incrementLogic = `if (${rungStateVar} && !counter_${id}_lastRung) { counter_${id}_current++; }`;
compareLogic = `(counter_${id}_current >= ${preset})`;
break;
}
return `// Counter: ${comp.instanceId} (${comp.counterType}, preset=${preset})\\n` +
`${resetCheck}` +
`${incrementLogic};\\n` +
`counter_${id}_lastRung = ${rungStateVar};\\n` +
`${compareLogic}`;
}
/** Generate Arduino code for a branch element. */
function exportBranch(ctx: ExportContext, comp: Branch, lastRungStateVar: string): string {
const id = sanitize(comp.id);
const results: string[] = [];
for (let i = 0; i < comp.paths.length; i++) {
const pathExprs: string[] = [];
for (const el of comp.paths[i]) {
const expr = exportElementExpr(ctx, el, `rung_${id}_path${i}_state`, lastRungStateVar);
if (expr) {
pathExprs.push(expr);
}
}
const combined = pathExprs.length > 0 ? pathExprs.join(' && ') : 'true';
results.push(`(${combined})`);
}
if (comp.logic === 'AND') {
return `(${results.join(' && ')})`;
} else {
return `(${results.join(' || ')})`;
}
}
/** Generate Arduino code for a math element. */
function exportMath(ctx: ExportContext, comp: MathElement): string {
const outVar = sanitize(comp.outputAddress);
ctx.globalVars.push(`float math_${outVar} = 0.0;`);
ctx.memoryAddresses.add(comp.outputAddress);
const valA = readExpr(comp.inputA);
const valB = readExpr(comp.inputB);
let op: string;
switch (comp.operator) {
case 'ADD': op = '+'; break;
case 'SUB': op = '-'; break;
case 'MUL': op = '*'; break;
case 'DIV':
op = '/';
break;
}
let expr = `(math_${outVar} = (${valA} ${op} ${valB}))`;
if (comp.operator === 'DIV') {
expr = `(bool)${valB} ? (${expr}) : 0`;
}
return expr;
}
/** Generate Arduino code for a one-shot element. */
function exportOneShot(ctx: ExportContext, comp: OneShot): string {
const addr = comp.address;
const varName = sanitize(addr);
ctx.globalVars.push(`bool prev_${varName} = false;`);
ctx.inputAddresses.add(addr);
ctx.edgeAddresses.add(addr);
const valExpr = readExpr(addr);
let edgeCheck: string;
if (comp.edgeType === 'RISING') {
edgeCheck = `(curr_${varName} && !prev_${varName})`;
} else {
edgeCheck = `(!curr_${varName} && prev_${varName})`;
}
return `(bool curr_${varName} = ${valExpr}; ${edgeCheck})`;
}
/** Generate Arduino code for a move element. */
function exportMove(ctx: ExportContext, comp: MoveElement): string {
const destVar = sanitize(comp.destination);
ctx.globalVars.push(`float move_${destVar} = 0.0;`);
ctx.memoryAddresses.add(comp.destination);
return `(move_${destVar} = ${readExpr(comp.source)})`;
}
/** Generate Arduino code for a scale element. */
function exportScale(ctx: ExportContext, comp: ScaleElement): string {
const destVar = sanitize(comp.destination);
ctx.globalVars.push(`float scale_${destVar} = 0.0;`);
ctx.memoryAddresses.add(comp.destination);
const inputExpr = readExpr(comp.inputAddress);
const inMin = comp.inMin;
const inMax = comp.inMax;
const outMin = comp.outMin;
const outMax = comp.outMax;
const range = (inMax - inMin);
const outRange = (outMax - outMin);
let scaleExpr: string;
if (range === 0) {
scaleExpr = `${outMin}`;
} else {
scaleExpr = `(${outMin} + (((${inputExpr} - ${inMin}) / ${range}) * ${outRange}))`;
}
// Clamp to output range
scaleExpr = `constrain(${scaleExpr}, ${outMin}, ${outMax})`;
return `(scale_${destVar} = ${scaleExpr})`;
}
/** Generate Arduino code for a compare element. */
function exportCompare(ctx: ExportContext, comp: CompareElement): string {
const outVar = sanitize(comp.outputAddress);
ctx.globalVars.push(`bool cmp_${outVar} = false;`);
ctx.memoryAddresses.add(comp.outputAddress);
const valA = readExpr(comp.inputA);
const valB = readExpr(comp.inputB);
const opMap: Record<string, string> = {
'==': '==', '!=': '!=', '>': '>', '<': '<', '>=': '>=', '<=': '<=',
};
const op = opMap[comp.op] || '==';
return `(cmp_${outVar} = (${valA} ${op} ${valB})), cmp_${outVar}`;
}
/** Generate Arduino code for a noop element. */
function exportNoOp(ctx: ExportContext, comp: NoOp): string {
const comment = comp.comment ? comp.comment : 'No operation';
return `/* ${comment} */ true`;
}
// ─── Main element dispatcher ──────────────────────────────────────
function exportElementExpr(ctx: ExportContext, el: RungElement, rungStateVar: string, lastRungStateVar: string): string {
switch (el.type) {
case 'contact':
return exportContact(ctx, el as Contact);
case 'coil':
// Coils produce output actions, not boolean expressions
const outputs = exportCoil(ctx, el as Coil, rungStateVar);
outputs.forEach((line) => ctx.rungOutputs[ctx.rungOutputs.length - 1].push(line));
return '';
case 'gate':
return exportGate(ctx, el as LogicGate);
case 'timer':
return exportTimer(ctx, el as Timer, rungStateVar, lastRungStateVar);
case 'counter':
return exportCounter(ctx, el as Counter, rungStateVar, lastRungStateVar);
case 'branch':
return exportBranch(ctx, el as Branch, lastRungStateVar);
case 'math':
return exportMath(ctx, el as MathElement);
case 'oneshot':
return exportOneShot(ctx, el as OneShot);
case 'move':
return exportMove(ctx, el as MoveElement);
case 'scale':
return exportScale(ctx, el as ScaleElement);
case 'noop':
return exportNoOp(ctx, el as NoOp);
case 'compare':
return exportCompare(ctx, el as CompareElement);
default:
return 'true';
}
}
// ─── Main export function ─────────────────────────────────────────
export function generateArduinoCode(program: Program): string {
const ctx = createContext();
// First pass: collect all addresses from all rungs
for (const rung of program.rungs) {
if (!rung.enabled) continue;
collectAddresses(ctx, rung.series);
}
// Classify inputs as digital vs analog
for (const addr of Array.from(ctx.inputAddresses)) {
if (isAnalogInput(addr)) {
ctx.analogInputs.add(addr);
}
}
// Generate global variable declarations
const globalDecl: string[] = [];
// Input pin definitions
const pinDefs: string[] = [];
ctx.pins.forEach((pin, addr) => {
pinDefs.push(`#define PIN_${sanitize(addr)} ${pin}`);
});
// Analog pin definitions
for (const addr of Array.from(ctx.analogInputs)) {
const pin = parsePin(addr);
if (pin !== null) {
pinDefs.push(`#define PIN_${sanitize(addr)} ${pin}`);
ctx.pins.set(addr, pin);
}
}
// Declare state variables for latched outputs
for (const addr of Array.from(ctx.latchedOutputs)) {
const v = sanitize(addr);
if (isDigitalIO(addr)) {
globalDecl.push(`bool ${v}_state = false; // Latched output state`);
}
}
// Declare state variables for toggle outputs
for (const addr of Array.from(ctx.toggleOutputs)) {
const v = sanitize(addr);
if (isDigitalIO(addr)) {
globalDecl.push(`bool ${v}_state = false; // Toggle output state`);
}
}
// Edge detection previous-state variables
for (const addr of Array.from(ctx.edgeAddresses)) {
const v = sanitize(addr);
globalDecl.push(`bool prev_${v} = false; // Previous state for edge detection`);
}
// Collect all timer/counter/gate/math/scale/compare/move state vars
// (these are added during second pass, so collect after)
// Generate setup code
const setupCode: string[] = [];
// Serial debug
setupCode.push(`Serial.begin(115200);`);
setupCode.push(`Serial.println("Lego Ladder PLC started");`);
// Pin modes for digital outputs
const outputPins = new Set<string>();
ctx.pins.forEach((pin, addr) => {
if (ctx.outputAddresses.has(addr) && !isAnalogInput(addr)) {
if (!outputPins.has(String(pin))) {
outputPins.add(String(pin));
setupCode.push(`pinMode(${pin}, OUTPUT);`);
}
}
});
// Pin modes for inputs
const inputPins = new Set<string>();
ctx.pins.forEach((pin, addr) => {
if (ctx.inputAddresses.has(addr) && !isAnalogInput(addr) && isDigitalIO(addr)) {
if (!inputPins.has(String(pin))) {
inputPins.add(String(pin));
setupCode.push(`pinMode(${pin}, INPUT_PULLUP);`);
}
}
});
// Generate rung evaluation code (second pass)
const loopBody: string[] = [];
const delayMs = program.cycleTime || 100;
for (let idx = 0; idx < program.rungs.length; idx++) {
const rung = program.rungs[idx];
if (!rung.enabled) continue;
const rungLabel = rung.label ? rung.label : `Rung${idx}`;
const rungStateVar = `rung${idx}_state`;
const lastRungStateVar = `rung${idx}_lastState`;
// Declare last-state tracking variable
globalDecl.push(`bool ${lastRungStateVar} = false;`);
loopBody.push(`// ─── ${rungLabel} ───`);
// Evaluate series elements
const exprParts: string[] = [];
for (const el of rung.series) {
const expr = exportElementExpr(ctx, el, rungStateVar, lastRungStateVar);
if (expr && el.type !== 'coil') {
exprParts.push(expr);
}
}
// Combine expression parts with AND logic
const combined = exprParts.filter(e => e.trim()).join(' && ');
const finalExpr = combined || 'false';
loopBody.push(`bool ${rungStateVar} = ${finalExpr};`);
// Output actions for this rung (coils, etc.)
if (ctx.rungOutputs.length > 0 && ctx.rungOutputs[ctx.rungOutputs.length - 1].length > 0) {
ctx.rungOutputs[ctx.rungOutputs.length - 1].forEach((line) => {
loopBody.push(line);
});
}
// Update last state for edge detection
loopBody.push(`${lastRungStateVar} = ${rungStateVar};`);
loopBody.push('');
}
// Edge detection update (update previous input states at end of scan)
const edgeUpdateLines: string[] = [];
for (const addr of Array.from(ctx.edgeAddresses)) {
const v = sanitize(addr);
if (isDigitalIO(addr)) {
const pinRef = ctx.pins.has(addr) ? String(ctx.pins.get(addr)!) : `PIN_${v}`;
edgeUpdateLines.push(`prev_${v} = (digitalRead(${pinRef}) == HIGH);`);
} else {
edgeUpdateLines.push(`prev_${v} = (bool)getValue("${addr}");`);
}
}
if (edgeUpdateLines.length > 0) {
loopBody.push('// ─── Update edge detection state ───');
edgeUpdateLines.forEach((l) => loopBody.push(l));
}
// Scan cycle delay
loopBody.push(`delay(${delayMs});`);
// Collect global variable declarations (gathered during traversal)
const allGlobalVars = deduplicate([...globalDecl, ...ctx.globalVars]);
// Build final Arduino sketch
const lines: string[] = [];
lines.push('// ═══════════════════════════════════════════════════════════');
lines.push(`// Generated by Lego Ladder - Arduino/ESP32 PLC Exporter`);
lines.push(`// Program: ${program.name}`);
lines.push(`// Rungs: ${program.rungs.length} | Cycle time: ${delayMs}ms`);
lines.push('// ═══════════════════════════════════════════════════════════');
lines.push('');
if (pinDefs.length > 0) {
lines.push('// ─── Pin Definitions ───');
lines.push(...pinDefs);
lines.push('');
}
if (allGlobalVars.length > 0) {
lines.push('// ─── Global State Variables ───');
lines.push(...allGlobalVars);
lines.push('');
}
// Helper function for value lookups
lines.push('// ─── Helper Functions ───');
lines.push('// Generic value reader: tries inputs, memory, then outputs');
lines.push('// Note: for performance on small Arduino boards, replace with');
lines.push('// direct variable references in the generated code.');
lines.push('');
lines.push('float getValue(const char* addr) {');
lines.push(' // This is a placeholder. In a real deployment, each address');
lines.push(' // maps to a specific variable or pin read.');
lines.push(' return 0.0;');
lines.push('}');
lines.push('');
lines.push('float getOutputValue(const char* addr) {');
lines.push(' return 0.0;');
lines.push('}');
lines.push('');
lines.push('void setOutput(const char* addr, bool val) {');
lines.push(' (void)addr; (void)val; // Placeholder');
lines.push('}');
lines.push('');
lines.push('// Multi-input XOR helper: count true values, return count');
lines.push('int countTrue(int n, ...) {');
lines.push(' (void)n; (void)n; // Simplified - in practice use direct logic');
lines.push(' return 0;');
lines.push('}');
lines.push('');
lines.push('void setup() {');
setupCode.forEach((l) => lines.push(` ${l}`));
lines.push('}');
lines.push('');
lines.push('void loop() {');
loopBody.forEach((l) => lines.push(` ${l}`));
lines.push('}');
return lines.join('\n');
}
/** Remove duplicate lines preserving order. */
function deduplicate(arr: string[]): string[] {
const seen = new Set<string>();
return arr.filter((item) => {
if (seen.has(item)) return false;
seen.add(item);
return true;
});
}