= hasNew
+ ? newLines!
+ : legacyOps.map((u) =>
+ u.line > 0
+ ? u.line
+ : ("runtime" as const),
+ );
+ return (
+
+ {lines.map((line, i) => (
+
+ {line === "runtime" ? (
+
+ runtime
+
+ ) : (
+
+ L{line}
+
+ )}
+
+ ))}
+
+ );
+ };
+
+ const drCell = renderOpCell(
+ record.digitalReadLines,
+ ops.filter((u) => u.operation.includes("digitalRead")),
);
- const digitalWrites = ops.filter((u) =>
- u.operation.includes("digitalWrite"),
+ const dwCell = renderOpCell(
+ record.digitalWriteLines,
+ ops.filter((u) =>
+ u.operation.includes("digitalWrite"),
+ ),
);
- const analogReads = ops.filter((u) =>
- u.operation.includes("analogRead"),
+ const arCell = renderOpCell(
+ record.analogReadLines,
+ ops.filter((u) => u.operation.includes("analogRead")),
);
- const analogWrites = ops.filter((u) =>
- u.operation.includes("analogWrite"),
+ const awCell = renderOpCell(
+ record.analogWriteLines,
+ ops.filter((u) =>
+ u.operation.includes("analogWrite"),
+ ),
);
const pinModes = ops
.filter((u) => u.operation.includes("pinMode"))
@@ -429,7 +513,7 @@ export function ParserOutput({
- {/* pinMode Column */}
+ {/* pinMode Column – always shows mode name; conflict indicator if needed */}
?
)}
- ) : digitalReads.length > 0 ||
- digitalWrites.length > 0 ? (
+ ) : (record.digitalReadLines?.length ?? 0) > 0 ||
+ (record.digitalWriteLines?.length ?? 0) > 0 ||
+ ops.some((u) =>
+ u.operation.includes("digitalRead") ||
+ u.operation.includes("digitalWrite"),
+ ) ? (
{
+ if (simulationStatus !== "stopped") return;
+ const timer = setTimeout(() => {
+ setIoRegistry(parseStaticIORegistry(code));
+ }, 300);
+ return () => clearTimeout(timer);
+ }, [code, simulationStatus]);
+
// Tab management handlers
const handleTabClick = (tabId: string) => {
const tab = tabs.find((t) => t.id === tabId);
diff --git a/scripts/wait-for-backend.mjs b/scripts/wait-for-backend.mjs
new file mode 100644
index 00000000..82c10c6b
--- /dev/null
+++ b/scripts/wait-for-backend.mjs
@@ -0,0 +1,30 @@
+const target = process.env.BACKEND_HEALTH_URL || "http://127.0.0.1:3000/api/health";
+const timeoutMs = Number(process.env.BACKEND_WAIT_TIMEOUT_MS || 30000);
+const intervalMs = Number(process.env.BACKEND_WAIT_INTERVAL_MS || 200);
+
+const start = Date.now();
+
+const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
+
+async function isReady() {
+ try {
+ const res = await fetch(target, { method: "GET", cache: "no-store" });
+ return res.ok;
+ } catch {
+ return false;
+ }
+}
+
+while (true) {
+ if (await isReady()) {
+ console.log(`[wait-for-backend] Backend ready at ${target}`);
+ process.exit(0);
+ }
+
+ if (Date.now() - start >= timeoutMs) {
+ console.error(`[wait-for-backend] Timeout after ${timeoutMs}ms waiting for ${target}`);
+ process.exit(1);
+ }
+
+ await sleep(intervalMs);
+}
diff --git a/server/services/registry-manager.ts b/server/services/registry-manager.ts
index e28df472..3f862165 100644
--- a/server/services/registry-manager.ts
+++ b/server/services/registry-manager.ts
@@ -59,6 +59,23 @@ function cleanupPinRecord(pin: IOPinRecord): IOPinRecord {
return cleaned;
}
+function mergeUsedAtEntries(
+ existing: IOPinRecord["usedAt"],
+ incoming: IOPinRecord["usedAt"],
+): IOPinRecord["usedAt"] {
+ const merged = [...(existing ?? []), ...(incoming ?? [])];
+ if (merged.length === 0) return undefined;
+
+ const unique = new Map ();
+ for (const entry of merged) {
+ const key = `${entry.operation}@${entry.line}`;
+ if (!unique.has(key)) {
+ unique.set(key, entry);
+ }
+ }
+ return Array.from(unique.values());
+}
+
/**
* RegistryManager handles the collection and management of Arduino pin states.
* It provides debouncing to minimize WebSocket traffic and change detection
@@ -76,6 +93,13 @@ export class RegistryManager {
private baudrate: number | undefined = undefined; // undefined = Serial.begin() not found in code
private destroyed = false; // Prevent logging after destruction
private debugStream: WriteStream | null = null; // Non-blocking telemetry stream
+ /**
+ * Anti-spam: tracks (pin, mode) pairs already sent via updatePinMode so that
+ * repeated calls (e.g. from loop()) never trigger a redundant WS message.
+ * Keyed as ":" (e.g. "13:1").
+ * Reset on reset() / next program start.
+ */
+ private runtimeSentFingerprints = new Set();
private readonly logger = new Logger("RegistryManager");
private readonly onUpdateCallback?: RegistryUpdateCallback;
private readonly onTelemetryCallback?: TelemetryUpdateCallback;
@@ -317,7 +341,7 @@ export class RegistryManager {
// ROBUSTNESS: Flush current registry state before clearing
// This ensures any pins added via updatePinMode before IO_REGISTRY_START marker are sent
- if (this.registry.length > 0 && this.isDirty) {
+ if (!this.waitingForRegistry && this.registry.length > 0 && this.isDirty) {
const hasDefinedPins = this.registry.some((p) => p.defined);
if (hasDefinedPins) {
this.logger.info(
@@ -463,6 +487,12 @@ export class RegistryManager {
*/
updatePinMode(pin: number, mode: number): void {
if (this.destroyed) return;
+ // ── Anti-spam: skip if this (pin, mode) was already sent ─────────────────
+ const fingerprint = `${pin}:${mode}`;
+ if (this.runtimeSentFingerprints.has(fingerprint)) {
+ this.telemetry.incomingEvents++;
+ return; // No new information – don't update registry or trigger WS send
+ }
const pinStr = pin >= 14 && pin <= 19 ? `A${pin - 14}` : String(pin);
const existing = this.registry.find((p) => p.pin === pinStr);
@@ -524,8 +554,11 @@ export class RegistryManager {
this.logger.debug(
`New pin record created: ${pinStr} with mode=${mode}, sending immediately`,
);
- const nextHash = this.computeRegistryHash();
- this.sendNow(nextHash, "pin-new-record");
+ this.runtimeSentFingerprints.add(fingerprint);
+ if (!this.isCollecting && !this.waitingForRegistry) {
+ const nextHash = this.computeRegistryHash();
+ this.sendNow(nextHash, "pin-new-record");
+ }
}
}
@@ -552,6 +585,12 @@ export class RegistryManager {
this.waitTimer = setTimeout(() => {
if (this.waitingForRegistry) {
this.logger.warn("Registry wait timeout - releasing queue");
+ if (this.isDirty) {
+ const nextHash = this.computeRegistryHash();
+ if (nextHash !== this.registryHash) {
+ this.sendNow(nextHash, "wait-timeout-flush");
+ }
+ }
this.waitingForRegistry = false;
}
}, timeoutMs);
@@ -573,6 +612,7 @@ export class RegistryManager {
this.registryHash = "";
this.waitingForRegistry = false;
this.isDirty = false;
+ this.runtimeSentFingerprints.clear(); // reset anti-spam state for new sketch run
this.stopTelemetry();
diff --git a/shared/io-registry-parser.ts b/shared/io-registry-parser.ts
new file mode 100644
index 00000000..b33e160a
--- /dev/null
+++ b/shared/io-registry-parser.ts
@@ -0,0 +1,417 @@
+/**
+ * io-registry-parser.ts
+ *
+ * Pure static parser for the Hybrid IO-Registry.
+ * Analyses Arduino/C++ source code and returns IOPinRecord[] for the
+ * 20 known hardware pins (0-13 digital, 14-19 = A0-A5 analog).
+ *
+ * Covers all 11 SSOT test cases:
+ * TC1 – literal pin + literal mode
+ * TC2 – A0-A5 alias resolution
+ * TC3 – for-loop expansion (variable range)
+ * TC4 – const int / variable resolution
+ * TC5 – #define resolution
+ * TC6 – static entry is created once (no per-call duplication)
+ * TC7 – same pin used in read AND write → both columns filled
+ * TC8 – dynamic pin (runtime() etc.) → NOT included (runtime only)
+ * TC9 – conflict: INPUT/INPUT_PULLUP mode + digitalWrite → warning
+ * TC10 – array index resolution (pins[1])
+ * TC11 – multiple different pinMode modes → warning + both lines
+ */
+
+import type { IOPinRecord } from "./schema";
+
+// ─────────────────────────────────────────────────────────────────────────────
+// Constants
+// ─────────────────────────────────────────────────────────────────────────────
+
+/** Built-in Arduino pin-name constants mapped to numeric IDs (0-19). */
+const BUILTIN_CONSTANTS: Record = {
+ LED_BUILTIN: 13,
+ A0: 14, A1: 15, A2: 16, A3: 17, A4: 18, A5: 19,
+};
+
+/** Canonical mode name table. */
+const MODE_MAP: Record = {
+ INPUT: "INPUT", "0": "INPUT",
+ OUTPUT: "OUTPUT", "1": "OUTPUT",
+ INPUT_PULLUP: "INPUT_PULLUP", "2": "INPUT_PULLUP",
+};
+
+type OpName =
+ | "pinMode"
+ | "digitalRead"
+ | "digitalWrite"
+ | "analogRead"
+ | "analogWrite";
+
+interface CallEntry {
+ op: OpName;
+ pinId: number;
+ line: number;
+ mode?: "INPUT" | "OUTPUT" | "INPUT_PULLUP";
+}
+
+// ─────────────────────────────────────────────────────────────────────────────
+// Comment stripping (position-preserving)
+// ─────────────────────────────────────────────────────────────────────────────
+
+/**
+ * Strip comments while preserving character positions (newlines kept intact).
+ * This allows line numbers in the stripped code to match the original source.
+ */
+function stripComments(code: string): string {
+ // Multi-line comments → spaces (preserve newlines for correct line counting)
+ let result = code.replace(/\/\*[\s\S]*?\*\//g, (m) =>
+ m.replace(/[^\n]/g, " "),
+ );
+ // Single-line comments → spaces (preserve line length)
+ result = result.replace(/\/\/[^\n]*/g, (m) => " ".repeat(m.length));
+ return result;
+}
+
+/** 1-based line number for a character position in a string. */
+function lineAt(code: string, pos: number): number {
+ return code.slice(0, pos).split("\n").length;
+}
+
+// ─────────────────────────────────────────────────────────────────────────────
+// Symbol resolution
+// ─────────────────────────────────────────────────────────────────────────────
+
+/**
+ * Build symbol table: name → pin ID (0-19).
+ * Handles: built-in constants, #define, const int/byte, plain int/byte.
+ */
+function buildSymbols(clean: string): Map {
+ const syms = new Map(Object.entries(BUILTIN_CONSTANTS));
+
+ // #define NAME VALUE
+ const defineRe = /^#define\s+([A-Za-z_]\w*)\s+([A-Za-z0-9_]+)/gm;
+ let m: RegExpExecArray | null;
+ while ((m = defineRe.exec(clean)) !== null) {
+ const v = resolveToken(m[2], syms);
+ if (v !== undefined) syms.set(m[1], v);
+ }
+
+ // const int/byte NAME = VALUE;
+ const constRe =
+ /\bconst\s+(?:int|byte|uint8_t|uint16_t|short|long)\s+([A-Za-z_]\w*)\s*=\s*([A-Za-z0-9_]+)\s*;/g;
+ while ((m = constRe.exec(clean)) !== null) {
+ const v = resolveToken(m[2], syms);
+ if (v !== undefined) syms.set(m[1], v);
+ }
+
+ // plain int/byte NAME = VALUE; (common in Arduino, e.g. int led = 12;)
+ const varRe =
+ /\b(?:int|byte|uint8_t)\s+([A-Za-z_]\w*)\s*=\s*([A-Za-z0-9_]+)\s*;/g;
+ while ((m = varRe.exec(clean)) !== null) {
+ if (syms.has(m[1])) continue; // already set by const variant
+ const v = resolveToken(m[2], syms);
+ if (v !== undefined) syms.set(m[1], v);
+ }
+
+ return syms;
+}
+
+/** Resolve a single token (numeric literal, A0-A5, or symbol) to a pin ID. */
+function resolveToken(
+ token: string,
+ syms: Map,
+): number | undefined {
+ if (/^\d+$/.test(token)) return parseInt(token, 10);
+ const analogMatch = /^A(\d+)$/.exec(token);
+ if (analogMatch) {
+ const n = parseInt(analogMatch[1], 10);
+ return n >= 0 && n <= 5 ? 14 + n : undefined;
+ }
+ return syms.get(token);
+}
+
+/**
+ * Build array table: `int arr[] = {a, b, c}` → arr → [pinId, pinId, …].
+ * Used to resolve array-index pin expressions like `pins[1]`.
+ */
+function buildArrays(
+ clean: string,
+ syms: Map,
+): Map {
+ const arrays = new Map();
+ const re =
+ /\b(?:int|byte|uint8_t)\s+([A-Za-z_]\w*)\s*\[\s*\d*\s*\]\s*=\s*\{([^}]+)\}/g;
+ let m: RegExpExecArray | null;
+ while ((m = re.exec(clean)) !== null) {
+ const vals = m[2]
+ .split(",")
+ .map((v) => resolveToken(v.trim(), syms));
+ if (vals.every((v) => v !== undefined)) {
+ arrays.set(m[1], vals as number[]);
+ }
+ }
+ return arrays;
+}
+
+/**
+ * Resolve a pin expression (literal, symbol, or array-index) to a pin ID 0-19.
+ * Returns undefined if the expression is not statically resolvable (→ TC 8).
+ */
+function resolvePin(
+ expr: string,
+ syms: Map,
+ arrays: Map,
+): number | undefined {
+ const t = expr.trim();
+ // Array access: name[index]
+ const arrM = /^([A-Za-z_]\w*)\s*\[\s*(\d+)\s*\]$/.exec(t);
+ if (arrM) {
+ const arr = arrays.get(arrM[1]);
+ const idx = parseInt(arrM[2], 10);
+ if (arr && idx < arr.length) {
+ const id = arr[idx];
+ return id >= 0 && id <= 19 ? id : undefined;
+ }
+ return undefined;
+ }
+ const n = resolveToken(t, syms);
+ return n !== undefined && n >= 0 && n <= 19 ? n : undefined;
+}
+
+// ─────────────────────────────────────────────────────────────────────────────
+// For-loop range detection
+// ─────────────────────────────────────────────────────────────────────────────
+
+interface LoopRange {
+ startPos: number;
+ endPos: number;
+ startLine: number;
+ variable: string;
+ values: number[];
+}
+
+/**
+ * Find all for-loops with a numeric iteration variable over a statically
+ * determinable range, e.g. `for (int i = 2; i < 4; i++)`.
+ */
+function findLoopRanges(
+ clean: string,
+ syms: Map,
+): LoopRange[] {
+ const ranges: LoopRange[] = [];
+ const re =
+ /\bfor\s*\(\s*(?:(?:byte|int|uint8_t|short)\s+)?([A-Za-z_]\w*)\s*=\s*(\d+)\s*;\s*\1\s*([<>]=?)\s*([A-Za-z0-9_]+)\s*;[^)]*\)\s*\{/g;
+ let m: RegExpExecArray | null;
+
+ while ((m = re.exec(clean)) !== null) {
+ const variable = m[1];
+ const start = parseInt(m[2], 10);
+ const op = m[3];
+ const limitVal = resolveToken(m[4], syms) ?? parseInt(m[4], 10);
+ if (isNaN(limitVal)) continue;
+
+ const values: number[] = [];
+ if (op === "<")
+ for (let i = start; i < limitVal && values.length <= 20; i++)
+ values.push(i);
+ if (op === "<=")
+ for (let i = start; i <= limitVal && values.length <= 20; i++)
+ values.push(i);
+ if (op === ">")
+ for (let i = start; i > limitVal && values.length <= 20; i--)
+ values.push(i);
+ if (op === ">=")
+ for (let i = start; i >= limitVal && values.length <= 20; i--)
+ values.push(i);
+
+ if (values.length === 0 || values.length > 20) continue;
+
+ // Find the matching closing brace of the loop body
+ let openBrace = m.index + m[0].length - 1;
+ while (openBrace < clean.length && clean[openBrace] !== "{") openBrace++;
+ let depth = 0;
+ let endPos = openBrace;
+ for (let i = openBrace; i < clean.length; i++) {
+ if (clean[i] === "{") depth++;
+ else if (clean[i] === "}") {
+ depth--;
+ if (depth === 0) {
+ endPos = i;
+ break;
+ }
+ }
+ }
+
+ ranges.push({
+ startPos: m.index,
+ endPos,
+ startLine: lineAt(clean, m.index),
+ variable,
+ values,
+ });
+ }
+
+ return ranges;
+}
+
+// ─────────────────────────────────────────────────────────────────────────────
+// Main exported function
+// ─────────────────────────────────────────────────────────────────────────────
+
+/**
+ * Statically parse an Arduino sketch and return an IOPinRecord[] for every pin
+ * usage found in the source code.
+ *
+ * – Populates `pinModeLines`, `digitalReadLines`, `digitalWriteLines`,
+ * `analogReadLines`, `analogWriteLines` for the extended (eye-on) view.
+ * – Sets `conflict = true` for TC 9 (write on input-mode pin) and
+ * TC 11 (same pin configured with multiple different modes).
+ * – Dynamically-resolved pins (TC 8) are silently skipped; they will be
+ * filled in by the runtime path.
+ * – Populates legacy `pinMode`, `definedAt`, `usedAt` fields for backward
+ * compatibility with the existing UI and runtime registry manager.
+ */
+export function parseStaticIORegistry(code: string): IOPinRecord[] {
+ const clean = stripComments(code);
+ const syms = buildSymbols(clean);
+ const arrays = buildArrays(clean, syms);
+ const loops = findLoopRanges(clean, syms);
+
+ const entries: CallEntry[] = [];
+
+ /**
+ * Regex captures:
+ * [1] function name
+ * [2] pin expression: array-index form OR simple token/number
+ * [3] optional second argument (mode for pinMode, ignored otherwise)
+ */
+ const callRe =
+ /\b(pinMode|digitalRead|digitalWrite|analogRead|analogWrite)\s*\(\s*((?:[A-Za-z_]\w*\s*\[\s*\d+\s*\])|(?:[A-Za-z_]\w*|\d+))(?:\s*,\s*([A-Za-z_]\w*|\d+))?/g;
+
+ let m: RegExpExecArray | null;
+ while ((m = callRe.exec(clean)) !== null) {
+ const op = m[1] as OpName;
+ const pinExpr = m[2].trim();
+ const secondArg = (m[3] ?? "").trim();
+ const callPos = m.index;
+ const callLine = lineAt(clean, callPos);
+
+ // ── Check for-loop variable expansion (TC 3) ──────────────────────────
+ const loop = loops.find(
+ (l) =>
+ l.startPos <= callPos &&
+ callPos <= l.endPos &&
+ l.variable === pinExpr,
+ );
+
+ if (loop) {
+ for (const pinId of loop.values) {
+ if (pinId < 0 || pinId > 19) continue;
+ if (op === "pinMode") {
+ const mode = MODE_MAP[secondArg];
+ if (!mode) continue;
+ entries.push({ op, pinId, line: loop.startLine, mode });
+ } else {
+ entries.push({ op, pinId, line: loop.startLine });
+ }
+ }
+ continue;
+ }
+
+ // ── Statically resolve pin expression ────────────────────────────────
+ const pinId = resolvePin(pinExpr, syms, arrays);
+ if (pinId === undefined) continue; // TC 8: dynamic → skip (runtime only)
+
+ if (op === "pinMode") {
+ const mode = MODE_MAP[secondArg];
+ if (!mode) continue; // mode not statically resolvable
+ entries.push({ op, pinId, line: callLine, mode });
+ } else {
+ entries.push({ op, pinId, line: callLine });
+ }
+ }
+
+ // ── Aggregate entries by pinId ────────────────────────────────────────────
+ const pinMap = new Map();
+ for (const entry of entries) {
+ if (!pinMap.has(entry.pinId)) pinMap.set(entry.pinId, []);
+ pinMap.get(entry.pinId)!.push(entry);
+ }
+
+ const records: IOPinRecord[] = [];
+
+ for (const [pinId, calls] of pinMap) {
+ const label = pinId >= 14 ? `A${pinId - 14}` : String(pinId);
+
+ const pmCalls = calls.filter((c) => c.op === "pinMode");
+ const drCalls = calls.filter((c) => c.op === "digitalRead");
+ const dwCalls = calls.filter((c) => c.op === "digitalWrite");
+ const arCalls = calls.filter((c) => c.op === "analogRead");
+ const awCalls = calls.filter((c) => c.op === "analogWrite");
+
+ const allModes = pmCalls.map((c) => c.mode!);
+ const uniqueModes = [...new Set(allModes)] as Array<
+ "INPUT" | "OUTPUT" | "INPUT_PULLUP"
+ >;
+
+ // TC 11: same pin configured with multiple DIFFERENT modes → conflict
+ const pinModeConflict = uniqueModes.length > 1;
+
+ // TC 9: pin set to INPUT/INPUT_PULLUP AND written via digital/analogWrite
+ const hasInputMode =
+ pmCalls.length > 0 &&
+ uniqueModes.some((mm) => mm === "INPUT" || mm === "INPUT_PULLUP");
+ const hasWrite = dwCalls.length > 0 || awCalls.length > 0;
+ const operationConflict = hasInputMode && hasWrite;
+
+ const conflict = pinModeConflict || operationConflict;
+
+ const record: IOPinRecord = {
+ pin: label,
+ pinId,
+ defined: calls.length > 0,
+ };
+
+ if (conflict) {
+ record.conflict = true;
+ record.conflictMessage = pinModeConflict
+ ? `Multiple modes: ${uniqueModes.join(", ")}`
+ : `Write on ${uniqueModes
+ .filter((mm) => mm !== "OUTPUT")
+ .join("/")} pin`;
+ }
+
+ // ── New extended-view line arrays ────────────────────────────────────
+ if (pmCalls.length > 0) {
+ record.pinModeLines = pmCalls.map((c) => c.line);
+ record.pinModeModes = pmCalls.map((c) => c.mode!);
+ }
+ if (drCalls.length > 0)
+ record.digitalReadLines = drCalls.map((c) => c.line);
+ if (dwCalls.length > 0)
+ record.digitalWriteLines = dwCalls.map((c) => c.line);
+ if (arCalls.length > 0)
+ record.analogReadLines = arCalls.map((c) => c.line);
+ if (awCalls.length > 0)
+ record.analogWriteLines = awCalls.map((c) => c.line);
+
+ // ── Legacy fields (backward compat with runtime registry manager) ────
+ if (pmCalls.length > 0) {
+ const lastMode = allModes[allModes.length - 1];
+ record.pinMode =
+ lastMode === "INPUT" ? 0 : lastMode === "OUTPUT" ? 1 : 2;
+ record.definedAt = { line: pmCalls[pmCalls.length - 1].line };
+ }
+
+ const nonPmCalls = [...drCalls, ...dwCalls, ...arCalls, ...awCalls];
+ if (nonPmCalls.length > 0) {
+ record.usedAt = nonPmCalls.map((c) => ({
+ line: c.line,
+ operation: c.op,
+ }));
+ }
+
+ records.push(record);
+ }
+
+ // Sort by pinId (0 → 19)
+ return records.sort((a, b) => (a.pinId ?? 0) - (b.pinId ?? 0));
+}
diff --git a/shared/schema.ts b/shared/schema.ts
index 2e5f2f31..996ce847 100644
--- a/shared/schema.ts
+++ b/shared/schema.ts
@@ -176,6 +176,19 @@ export type ParserMessage = {
export interface IOPinRecord {
pin: string;
defined: boolean;
+ // ── Numeric pin id (0-13 digital, 14-19 = A0-A5). Optional for compat. ──
+ pinId?: number;
+ // ── Per-operation line arrays (for extended / eye-on view) ───────────────
+ pinModeLines?: Array;
+ pinModeModes?: Array<"INPUT" | "OUTPUT" | "INPUT_PULLUP">;
+ digitalReadLines?: Array;
+ digitalWriteLines?: Array;
+ analogReadLines?: Array;
+ analogWriteLines?: Array;
+ // ── Conflict / warning flags (TC 9: write-on-input, TC 11: multi-mode) ───
+ conflict?: boolean;
+ conflictMessage?: string;
+ // ── Legacy fields (kept for runtime path + backward compat) ─────────────
pinMode?: number; // 0=INPUT, 1=OUTPUT, 2=INPUT_PULLUP
/** Set to true when conflicting modes or a write-to-INPUT is detected at runtime */
hasConflict?: boolean;
diff --git a/ssot/ssot_io-registry.md b/ssot/ssot_io-registry.md
new file mode 100644
index 00000000..5ab880b6
--- /dev/null
+++ b/ssot/ssot_io-registry.md
@@ -0,0 +1,99 @@
+# Textbeschreibung:
+ Wir wollen zunächst nur über die IO-Registry reden. Die Storyline ist so:
+
+Im ino-File werden Pins verwendet. Die Verwendung der Pins soll in einer Tabelle dargestellt werden. Es geht um die Verwendung der Pins 0-13 und 14-19 (alias A0-A5) mit:
+* pinMode (INPUT, OUTPUT, INPUT_PULLUP)
+* digitalRead
+* digitalWrite
+
+
+Die IO-Registry ist eine Tabelle aller verwendeter Pins. Als Spalten existieren dann die Funktionen (siehe Bild).
+
+
+# Aktualisierung der Tabelle:
+Die Verwendung der Pins wird durch ein Parsing während der Eingabe ermittelt und dargestellt. Es kann aber auch sein, dass die Verwendung erst im laufenden Programm deutlich wird. Es soll soweit es geht das statische Parsing eingesetzt werden. Das Run-Time-Parsing wird zur Ergänzung der TAbelle verwendet.
+
+
+# Übermittlung via Telemetry-Messages.
+Die Übermittlung der Daten für die Tabelle (Backend->Frontend) soll soweit wie möglich gebündelt werden. Der Nutzer möchte in Echtzeit Einträge aus seiner programmierung sehen. Aber wenn ein Programm geladen wird, dann sollen die Telegramme mehrere Pinverwendungen gleichzeitig übermitteln.
+
+
+# Der Auge-Button
+Der Button toggelt eine kompakte und erweiterte Ansicht. Kompakt: grüne Haken bei Verwendung/ graue "-" bei Nicht-Verwendung. Erweitert: Bei Verwendung werden die Zeilennummern in der Tabelle eingetragen (für alle pinMode, digitalRead, digitalWrite).
+
+
+# Spezifikation: Hybrid IO-Registry
+1. Kernkonzept & Mapping
+
+Die IO-Registry erfasst die Verwendung der Hardware-Ressourcen eines Arduino Uno.
+
+ Pin-Range: Digital 0–13 und Analog A0–A5 (intern als 14–19 gemappt).
+
+ Identifikation: Pins werden im Frontend mit ihren Alias-Namen (RX, TX, ~, A0 etc.) dargestellt, im Backend jedoch über ihre numerische ID (0–19) eindeutig identifiziert.
+
+2. Spaltenstruktur der Tabelle
+
+Die Tabelle bildet die Interaktion zwischen Code und Hardware ab. Gemäß Screenshot existieren folgende Spalten:
+
+ Pin: Anzeige des Namens/Labels (z.B. "A0").
+
+ pinMode: Aktueller Modus (INPUT, OUTPUT, INPUT_PULLUP).
+
+ digitalRead: Erfasst Lesezugriffe.
+
+ digitalWrite: Erfasst Schreibzugriffe.
+
+ analogRead: Erfasst analoge Eingänge (primär A0–A5).
+
+ analogWrite: Erfasst PWM-Ausgaben (Pins mit ~-Markierung).
+
+3. Hybrid-Parsing (Statisch & Dynamisch)
+
+Das System nutzt zwei Quellen, um die Tabelle zu befüllen:
+
+ Statisches Parsing (CodeParser): Scannt den Quellcode sofort bei Eingabe. Es erkennt explizite Aufrufe (z.B. pinMode(13, OUTPUT) oder digitalWrite(LED_BUILTIN, HIGH)) und löst Konstanten/Variablen auf.
+
+ Run-Time Ergänzung: Während der Simulation werden Aufrufe erfasst, die statisch nicht eindeutig waren (z.B. dynamische Pin-Zuweisungen in Schleifen oder über berechnete Variablen).
+
+ Konflikt-Management: Wenn statisches und dynamisches Parsing unterschiedliche Informationen liefern (siehe Screenshot bei A0: INPUT vs INPUT_PULLUP), muss die Registry dies als Konflikt markieren (rotes Fragezeichen/Warnung).
+
+4. Telemetrie-Strategie (Effizienz)
+
+Die Kommunikation zwischen Backend (Simulator) und Frontend (Tabelle) folgt dem Prinzip: "So viel wie nötig, so wenig wie möglich."
+
+ Initialer Batch: Beim Laden eines Programms werden alle durch das statische Parsing ermittelten Daten in einer einzigen Nachricht gebündelt übertragen.
+
+ Echtzeit-Updates: Während der Simulation sendet das Backend nur dann ein Telegramm, wenn ein Pin zum ersten Mal in einer neuen Funktion (z.B. das erste Mal digitalWrite) verwendet wird oder sich der pinMode ändert.
+
+ Kein Spam: Wiederholte Aufrufe (z.B. in der loop()) triggern keine neuen Nachrichten, wenn sich die Tabelleneinträge dadurch nicht ändern.
+
+5. UI-Interaktion: Der "Auge-Button"
+
+Ein Toggle-Button in der UI (oben rechts im Screenshot) steuert die Detailtiefe:
+
+ Kompakt-Ansicht (Default):
+
+ Zustand: Binäre Anzeige (Aktiv/Inaktiv).
+
+ Visualisierung: Grüne Haken für Nutzung, graue Striche (—) für Nicht-Nutzung.
+
+ Erweiterte Ansicht:
+
+ Zustand: Debug-Informationen.
+
+ Visualisierung: Anstelle der Haken werden die Zeilennummern aus dem Code angezeigt, an denen die jeweilige Funktion aufgerufen wurde (z.B. "L12, L45").
+
+# Testfälle
+
+Nr,Code-Szenario,3. Ergebnis: Kompakt-Modus (Auge aus),4. Ergebnis: Erweitert-Modus (Auge an)
+1,"pinMode(13, OUTPUT);","Pin 13, Spalte pinMode: Grüner Haken [✔]","Pin 13, Spalte pinMode: Zeigt L5"
+2,digitalRead(A0);,"Pin A0 (14), Spalte digitalRead: Grüner Haken [✔]","Pin A0, Spalte digitalRead: Zeigt L10"
+3,"for(int i=2; i<4; i++) {digitalWrite(i, HIGH); }","Pin 2 & 3, Spalte digitalWrite: Grüner Haken [✔]","Pin 2 & 3, Spalte digitalWrite: Zeigt L2"
+4,"const int led = 12;digitalWrite(led, HIGH);","Pin 12, Spalte digitalWrite: Grüner Haken [✔]","Pin 12, Spalte digitalWrite: Zeigt L12"
+5,"#define BTN A3pinMode(BTN, INPUT);","Pin A3 (17), Spalte pinMode: Grüner Haken [✔]","Pin A3, Spalte pinMode: Zeigt L2"
+6,"void loop() {digitalWrite(9, HIGH); }",Pin 9: Einmaliger Haken [✔] (Kein Telemetrie-Spam/Flackern),Pin 9: Zeigt dauerhaft L2
+7,"digitalRead(5); (L10)digitalWrite(5, LOW); (L20)",Pin 5: Haken [✔] in Spalte Read UND Write,Spalte Read: L10Spalte Write: L20
+8,"Runtime-Erkennung:int p = random(0,5);digitalRead(p);","Pin erscheint live mit Haken [✔], sobald die Zeile ausgeführt wird.",Zeigt den Text Runtime oder Live (da keine statische Zeile existiert).
+9,"Widerspruch (Read/Write):pinMode(A0, INPUT);digitalWrite(A0, HIGH);",Pin A0: Warnung durch Icon [!] (Modus passt nicht zur Aktion),"Warnung: Zeigt beide Zeilen L1, L2 zur Fehleranalyse."
+10,"int pins[] = {7, 8};digitalRead(pins[1]);","Pin 8, Spalte digitalRead: Grüner Haken [✔]","Pin 8, Spalte digitalRead: Zeigt L2"
+11,"Wechsel (pinMode):pinMode(13, OUTPUT); (L5)pinMode(13, INPUT); (L25)",Pin 13: Warnung durch Icon [!] (Mehrfache Definition),"Warnung: Zeigt beide Zeilen L5, L25 in der Spalte pinMode."
\ No newline at end of file
diff --git a/tests/client/parser-output-pinmode.test.tsx b/tests/client/parser-output-pinmode.test.tsx
index 7bb327da..4f6174e8 100644
--- a/tests/client/parser-output-pinmode.test.tsx
+++ b/tests/client/parser-output-pinmode.test.tsx
@@ -523,7 +523,7 @@ describe("ParserOutput Component", () => {
const user = userEvent.setup();
const ioRegistry: IOPinRecord[] = [
{
- pin: 13,
+ pin: "13",
defined: true,
pinMode: 1,
usedAt: [
diff --git a/tests/server/services/registry-manager.test.ts b/tests/server/services/registry-manager.test.ts
index c1fa367b..431b0a85 100644
--- a/tests/server/services/registry-manager.test.ts
+++ b/tests/server/services/registry-manager.test.ts
@@ -46,6 +46,20 @@ describe("RegistryManager", () => {
expect(manager.getRegistry()).toHaveLength(1);
expect(manager.getRegistry()[0].pin).toBe("12");
});
+
+ it("should deduplicate repeated IO_PIN records by pin", () => {
+ manager.startCollection();
+ manager.addPin({ pin: "13", defined: true, pinMode: 1, usedAt: [] });
+ manager.addPin({ pin: "13", defined: true, pinMode: 1, usedAt: [] });
+ manager.addPin({ pin: "A0", defined: true, pinMode: 0, usedAt: [] });
+ manager.addPin({ pin: "A0", defined: true, pinMode: 0, usedAt: [] });
+ manager.finishCollection();
+
+ const registry = manager.getRegistry();
+ expect(registry).toHaveLength(2);
+ expect(registry.find((p) => p.pin === "13")).toBeDefined();
+ expect(registry.find((p) => p.pin === "A0")).toBeDefined();
+ });
});
describe("updatePinMode", () => {
@@ -173,6 +187,22 @@ describe("RegistryManager", () => {
expect(updateCallback).toHaveBeenCalledTimes(1);
});
+ it("should defer pin-new-record sends while collection is active", () => {
+ manager.startCollection();
+ manager.updatePinMode(12, 1);
+ manager.updatePinMode(11, 0);
+
+ // No immediate sends during active collection
+ expect(updateCallback).not.toHaveBeenCalled();
+
+ manager.finishCollection();
+
+ // Single batched send at end of collection
+ expect(updateCallback).toHaveBeenCalledTimes(1);
+ expect(updateCallback.mock.calls[0][2]).toBe("collection-complete");
+ expect(updateCallback.mock.calls[0][0]).toHaveLength(2);
+ });
+
it("should not send if registry hash unchanged", () => {
manager.startCollection();
manager.addPin({ pin: "13", defined: true, pinMode: 1, usedAt: [] });
@@ -219,6 +249,68 @@ describe("RegistryManager", () => {
expect(manager.isWaiting()).toBe(false);
});
+
+ it("should suppress pin-new-record while waiting and send once on collection-complete", () => {
+ manager.enableWaitMode(1000);
+
+ manager.updatePinMode(14, 0); // A0 before IO_REGISTRY_START
+ manager.updatePinMode(0, 0);
+ manager.updatePinMode(1, 0);
+
+ // No immediate spam while waiting for registry sync
+ expect(updateCallback).not.toHaveBeenCalled();
+
+ manager.startCollection();
+ manager.addPin({ pin: "A0", defined: true, pinMode: 2, usedAt: [] });
+ manager.addPin({ pin: "0", defined: true, pinMode: 0, usedAt: [] });
+ manager.addPin({ pin: "1", defined: true, pinMode: 0, usedAt: [] });
+ manager.finishCollection();
+
+ expect(updateCallback).toHaveBeenCalledTimes(1);
+ expect(updateCallback.mock.calls[0][2]).toBe("collection-complete");
+ });
+
+ it("should flush dirty registry once when wait mode times out", () => {
+ manager.enableWaitMode(500);
+ manager.updatePinMode(13, 1);
+ manager.updatePinMode(12, 0);
+
+ expect(updateCallback).not.toHaveBeenCalled();
+
+ vi.advanceTimersByTime(500);
+
+ expect(updateCallback).toHaveBeenCalledTimes(1);
+ expect(updateCallback.mock.calls[0][2]).toBe("wait-timeout-flush");
+ });
+
+ it("should suppress pins discovered during wait-mode even with long compilation delay", () => {
+ // Scenario: enableWaitMode(5000) called, but compilation takes ~1s
+ // PIN_MODE events may arrive before wait-timeout, then collection starts
+ manager.enableWaitMode(5000);
+
+ // Fast PIN_MODE events arrive (wait still active)
+ manager.updatePinMode(14, 0); // A0
+ manager.updatePinMode(0, 0);
+ expect(updateCallback).not.toHaveBeenCalled();
+
+ // Then collection starts
+ manager.startCollection();
+ manager.updatePinMode(1, 0);
+
+ // Still nothing sent
+ expect(updateCallback).not.toHaveBeenCalled();
+
+ // Collection completes with batched pins
+ manager.addPin({ pin: "A0", defined: true, pinMode: 0, usedAt: [] });
+ manager.addPin({ pin: "0", defined: true, pinMode: 0, usedAt: [] });
+ manager.addPin({ pin: "1", defined: true, pinMode: 0, usedAt: [] });
+ manager.finishCollection();
+
+ // Exactly one send
+ expect(updateCallback).toHaveBeenCalledTimes(1);
+ expect(updateCallback.mock.calls[0][2]).toBe("collection-complete");
+ expect(updateCallback.mock.calls[0][0]).toHaveLength(3);
+ });
});
describe("reset", () => {
diff --git a/tests/shared/io-registry-parser.test.ts b/tests/shared/io-registry-parser.test.ts
new file mode 100644
index 00000000..04f05ae6
--- /dev/null
+++ b/tests/shared/io-registry-parser.test.ts
@@ -0,0 +1,363 @@
+/**
+ * io-registry-parser.test.ts
+ *
+ * Unit tests for parseStaticIORegistry() covering all 11 test cases defined
+ * in ssot_io-registry.md.
+ *
+ * TC 1 – literal pin + literal mode (pinMode)
+ * TC 2 – A0 alias → pinId 14, digitalRead column
+ * TC 3 – for-loop expansion → multiple pins in digitalWrite column
+ * TC 4 – const int variable resolution → correct pin
+ * TC 5 – #define BTN A3 resolution → pin A3 (id 17)
+ * TC 6 – loop() call → single static entry, no per-iteration duplication
+ * TC 7 – same pin in both read AND write columns
+ * TC 8 – dynamic pin (runtime()) → NOT in static result
+ * TC 9 – pinMode INPUT + digitalWrite → conflict flag
+ * TC 10 – array index resolution (pins[1] → pin 8)
+ * TC 11 – multiple different modes on same pin → conflict + both line numbers
+ */
+
+import { describe, it, expect } from "vitest";
+import { parseStaticIORegistry } from "../../shared/io-registry-parser";
+
+// ─────────────────────────────────────────────────────────────────────────────
+// Helpers
+// ─────────────────────────────────────────────────────────────────────────────
+
+/** Build a sketch string with optional extra lines for correct line counting. */
+function sketch(lines: string[]): string {
+ return lines.join("\n");
+}
+
+// ─────────────────────────────────────────────────────────────────────────────
+// Tests
+// ─────────────────────────────────────────────────────────────────────────────
+
+describe("parseStaticIORegistry – SSOT test cases", () => {
+ // ── TC 1 ──────────────────────────────────────────────────────────────────
+ it("TC1: pinMode(13, OUTPUT) → pin 13, OUTPUT in pinModeLines", () => {
+ const code = sketch([
+ "void setup() {",
+ " pinMode(13, OUTPUT);",
+ "}",
+ "void loop() {}",
+ ]);
+ const registry = parseStaticIORegistry(code);
+
+ const pin13 = registry.find((p) => p.pin === "13");
+ expect(pin13, "pin 13 must be in registry").toBeDefined();
+ expect(pin13!.pinModeModes).toContain("OUTPUT");
+ expect(pin13!.pinModeLines).toHaveLength(1);
+ // Compact mode: defined means checkmark
+ expect(pin13!.defined).toBe(true);
+ // Line number must be > 0 (not 0)
+ expect(pin13!.pinModeLines![0]).toBeGreaterThan(0);
+ });
+
+ // ── TC 2 ──────────────────────────────────────────────────────────────────
+ it("TC2: digitalRead(A0) → pin A0 (pinId 14) in digitalRead column", () => {
+ const code = sketch([
+ "void setup() {}",
+ "void loop() {",
+ " digitalRead(A0);",
+ "}",
+ ]);
+ const registry = parseStaticIORegistry(code);
+
+ const pinA0 = registry.find((p) => p.pin === "A0");
+ expect(pinA0, "A0 must be in registry").toBeDefined();
+ expect(pinA0!.pinId).toBe(14);
+ expect(pinA0!.digitalReadLines).toBeDefined();
+ expect(pinA0!.digitalReadLines!.length).toBeGreaterThan(0);
+ // No pinMode was set, so the flag should not be set
+ expect(pinA0!.pinModeModes).toBeUndefined();
+ });
+
+ // ── TC 3 ──────────────────────────────────────────────────────────────────
+ it("TC3: for-loop digitalWrite(i) expands to pins 2 and 3", () => {
+ const code = sketch([
+ "void loop() {",
+ " for (int i = 2; i < 4; i++) {",
+ " digitalWrite(i, HIGH);",
+ " }",
+ "}",
+ ]);
+ const registry = parseStaticIORegistry(code);
+
+ const pin2 = registry.find((p) => p.pin === "2");
+ const pin3 = registry.find((p) => p.pin === "3");
+
+ expect(pin2, "pin 2 must be in registry").toBeDefined();
+ expect(pin3, "pin 3 must be in registry").toBeDefined();
+ expect(pin2!.digitalWriteLines!.length).toBeGreaterThan(0);
+ expect(pin3!.digitalWriteLines!.length).toBeGreaterThan(0);
+
+ // Pins 0, 1, 4+ must NOT be added by this code
+ expect(registry.find((p) => p.pin === "4")).toBeUndefined();
+ });
+
+ // ── TC 4 ──────────────────────────────────────────────────────────────────
+ it("TC4: const int led = 12; digitalWrite(led) → pin 12", () => {
+ const code = sketch([
+ "const int led = 12;",
+ "void setup() {}",
+ "void loop() {",
+ " digitalWrite(led, HIGH);",
+ "}",
+ ]);
+ const registry = parseStaticIORegistry(code);
+
+ const pin12 = registry.find((p) => p.pin === "12");
+ expect(pin12, "pin 12 must be in registry").toBeDefined();
+ expect(pin12!.digitalWriteLines!.length).toBeGreaterThan(0);
+ });
+
+ // ── TC 5 ──────────────────────────────────────────────────────────────────
+ it("TC5: #define BTN A3 + pinMode(BTN, INPUT) → pin A3 (id 17)", () => {
+ const code = sketch([
+ "#define BTN A3",
+ "void setup() {",
+ " pinMode(BTN, INPUT);",
+ "}",
+ "void loop() {}",
+ ]);
+ const registry = parseStaticIORegistry(code);
+
+ const pinA3 = registry.find((p) => p.pin === "A3");
+ expect(pinA3, "A3 must be in registry").toBeDefined();
+ expect(pinA3!.pinId).toBe(17);
+ expect(pinA3!.pinModeModes).toContain("INPUT");
+ });
+
+ // ── TC 6 ──────────────────────────────────────────────────────────────────
+ it("TC6: digitalWrite(9) in loop() → exactly one static entry (no duplicates)", () => {
+ const code = sketch([
+ "void setup() {}",
+ "void loop() {",
+ " digitalWrite(9, HIGH);",
+ "}",
+ ]);
+ const registry = parseStaticIORegistry(code);
+
+ const pin9 = registry.find((p) => p.pin === "9");
+ expect(pin9, "pin 9 must be in registry").toBeDefined();
+ // Static parser must produce exactly ONE entry even though loop() runs many times
+ expect(pin9!.digitalWriteLines).toHaveLength(1);
+ });
+
+ // ── TC 7 ──────────────────────────────────────────────────────────────────
+ it("TC7: digitalRead(5) + digitalWrite(5) → both columns filled with different lines", () => {
+ // Build code so that digitalRead is around line 10 and digitalWrite around line 20
+ const lines = [
+ "void setup() {}",
+ "void loop() {",
+ " int x = 0;",
+ " int y = 0;",
+ " int z = 0;",
+ " int a = 0;",
+ " int b = 0;",
+ " int c = 0;",
+ " digitalRead(5);", // ~line 9
+ " int d = 0;",
+ " int e = 0;",
+ " int f = 0;",
+ " int g = 0;",
+ " int h = 0;",
+ " int i = 0;",
+ " int j = 0;",
+ " int k = 0;",
+ " int l = 0;",
+ " int m = 0;",
+ " digitalWrite(5, LOW);", // ~line 20
+ "}",
+ ];
+ const code = sketch(lines);
+ const registry = parseStaticIORegistry(code);
+
+ const pin5 = registry.find((p) => p.pin === "5");
+ expect(pin5, "pin 5 must be in registry").toBeDefined();
+ expect(pin5!.digitalReadLines!.length, "digitalRead column").toBeGreaterThan(0);
+ expect(pin5!.digitalWriteLines!.length, "digitalWrite column").toBeGreaterThan(0);
+ // The two lines must be different
+ expect(pin5!.digitalReadLines![0]).not.toBe(pin5!.digitalWriteLines![0]);
+ });
+
+ // ── TC 8 ──────────────────────────────────────────────────────────────────
+ it("TC8: dynamic pin (int p = random(0,5); digitalRead(p)) → NOT in static registry", () => {
+ const code = sketch([
+ "void setup() {}",
+ "void loop() {",
+ " int p = random(0, 5);",
+ " digitalRead(p);",
+ "}",
+ ]);
+ const registry = parseStaticIORegistry(code);
+
+ // `p` is assigned from random() which is not a compile-time constant.
+ // The static parser must not add any pin for this.
+ expect(registry).toHaveLength(0);
+ });
+
+ // ── TC 9 ──────────────────────────────────────────────────────────────────
+ it("TC9: pinMode(A0, INPUT) + digitalWrite(A0) → conflict = true", () => {
+ const code = sketch([
+ "void setup() {",
+ " pinMode(A0, INPUT);",
+ " digitalWrite(A0, HIGH);",
+ "}",
+ "void loop() {}",
+ ]);
+ const registry = parseStaticIORegistry(code);
+
+ const pinA0 = registry.find((p) => p.pin === "A0");
+ expect(pinA0, "A0 must be in registry").toBeDefined();
+ expect(pinA0!.conflict).toBe(true);
+ expect(pinA0!.conflictMessage).toBeTruthy();
+ // Both columns must be populated
+ expect(pinA0!.pinModeModes).toContain("INPUT");
+ expect(pinA0!.digitalWriteLines!.length).toBeGreaterThan(0);
+ });
+
+ // ── TC 10 ─────────────────────────────────────────────────────────────────
+ it("TC10: int pins[] = {7, 8}; digitalRead(pins[1]) → pin 8", () => {
+ const code = sketch([
+ "int pins[] = {7, 8};",
+ "void setup() {}",
+ "void loop() {",
+ " digitalRead(pins[1]);",
+ "}",
+ ]);
+ const registry = parseStaticIORegistry(code);
+
+ const pin8 = registry.find((p) => p.pin === "8");
+ expect(pin8, "pin 8 must be in registry").toBeDefined();
+ expect(pin8!.digitalReadLines!.length).toBeGreaterThan(0);
+ // pin 7 must NOT be in registry (only pins[1] = 8 is read)
+ expect(registry.find((p) => p.pin === "7")).toBeUndefined();
+ });
+
+ // ── TC 11 ─────────────────────────────────────────────────────────────────
+ it("TC11: pinMode(13, OUTPUT) + pinMode(13, INPUT) → conflict, both lines recorded", () => {
+ // Build code with the two pinMode calls far apart (lines ~5 and ~25)
+ const lines = [
+ "void setup() {",
+ " // ----",
+ " // ----",
+ " // ----",
+ " pinMode(13, OUTPUT);", // line 5
+ " // ....",
+ " // ....",
+ " // ....",
+ " // ....",
+ " // ....",
+ " // ....",
+ " // ....",
+ " // ....",
+ " // ....",
+ " // ....",
+ " // ....",
+ " // ....",
+ " // ....",
+ " // ....",
+ " // ....",
+ " // ....",
+ " // ....",
+ " // ....",
+ " // ....",
+ " pinMode(13, INPUT);", // line 25
+ "}",
+ "void loop() {}",
+ ];
+ const code = sketch(lines);
+ const registry = parseStaticIORegistry(code);
+
+ const pin13 = registry.find((p) => p.pin === "13");
+ expect(pin13, "pin 13 must be in registry").toBeDefined();
+ expect(pin13!.conflict).toBe(true);
+ expect(pin13!.pinModeLines, "both pinMode lines must be recorded").toHaveLength(2);
+ expect(pin13!.pinModeModes).toContain("OUTPUT");
+ expect(pin13!.pinModeModes).toContain("INPUT");
+ // The two line numbers must differ
+ expect(pin13!.pinModeLines![0]).not.toBe(pin13!.pinModeLines![1]);
+ });
+});
+
+// ─────────────────────────────────────────────────────────────────────────────
+// Additional edge-case tests
+// ─────────────────────────────────────────────────────────────────────────────
+
+describe("parseStaticIORegistry – edge cases", () => {
+ it("sorts output by pinId (0 → 19)", () => {
+ const code = sketch([
+ "void setup() {",
+ " pinMode(13, OUTPUT);",
+ " pinMode(0, INPUT);",
+ " pinMode(A5, INPUT_PULLUP);",
+ "}",
+ "void loop() {}",
+ ]);
+ const registry = parseStaticIORegistry(code);
+ const ids = registry.map((r) => r.pinId);
+ expect(ids).toEqual([...ids].sort((a, b) => (a ?? 0) - (b ?? 0)));
+ });
+
+ it("A0-A5 labels are correct (A0 = id 14, A5 = id 19)", () => {
+ const code = sketch([
+ "void setup() {",
+ " analogRead(A0);",
+ " analogRead(A5);",
+ "}",
+ "void loop() {}",
+ ]);
+ const registry = parseStaticIORegistry(code);
+ expect(registry.find((p) => p.pin === "A0")?.pinId).toBe(14);
+ expect(registry.find((p) => p.pin === "A5")?.pinId).toBe(19);
+ });
+
+ it("LED_BUILTIN resolves to pin 13", () => {
+ const code = sketch([
+ "void setup() {",
+ " pinMode(LED_BUILTIN, OUTPUT);",
+ " digitalWrite(LED_BUILTIN, HIGH);",
+ "}",
+ "void loop() {}",
+ ]);
+ const registry = parseStaticIORegistry(code);
+ const pin13 = registry.find((p) => p.pin === "13");
+ expect(pin13).toBeDefined();
+ expect(pin13!.pinModeModes).toContain("OUTPUT");
+ expect(pin13!.digitalWriteLines!.length).toBeGreaterThan(0);
+ });
+
+ it("code without any IO calls returns empty array", () => {
+ const code = sketch([
+ "void setup() { Serial.begin(115200); }",
+ "void loop() { Serial.println(42); }",
+ ]);
+ expect(parseStaticIORegistry(code)).toHaveLength(0);
+ });
+
+ it("pins in comments are ignored", () => {
+ const code = sketch([
+ "void setup() {",
+ " // pinMode(13, OUTPUT); // this is a comment",
+ " /* digitalWrite(7, HIGH); */",
+ "}",
+ "void loop() {}",
+ ]);
+ expect(parseStaticIORegistry(code)).toHaveLength(0);
+ });
+
+ it("INPUT_PULLUP mode is preserved", () => {
+ const code = sketch([
+ "void setup() {",
+ " pinMode(2, INPUT_PULLUP);",
+ "}",
+ "void loop() {}",
+ ]);
+ const registry = parseStaticIORegistry(code);
+ const pin2 = registry.find((p) => p.pin === "2");
+ expect(pin2!.pinModeModes).toContain("INPUT_PULLUP");
+ expect(pin2!.pinMode).toBe(2);
+ });
+});
|