From de6aa34f95d3c9538c009640322a5618be21127b Mon Sep 17 00:00:00 2001 From: ttbombadil Date: Sat, 7 Mar 2026 12:11:45 +0100 Subject: [PATCH 1/2] feat: implement hybrid IO-Registry & anti-spam telemetry - Add static analysis for IO-Registry with constant/loop expansion. - Integrate real-time registry updates into simulator lifecycle. - Optimize RegistryManager with granular change-detection to prevent telemetry spam. - Update IOPinRecord schema for enhanced state tracking (lines/modes). --- .../src/components/features/parser-output.tsx | 340 +++++++------- client/src/pages/arduino-simulator.tsx | 12 + server/services/registry-manager.ts | 19 + shared/io-registry-parser.ts | 417 ++++++++++++++++++ shared/schema.ts | 13 + ssot_io-registry.md | 99 +++++ tests/client/parser-output-pinmode.test.tsx | 9 +- tests/shared/io-registry-parser.test.ts | 363 +++++++++++++++ 8 files changed, 1112 insertions(+), 160 deletions(-) create mode 100644 shared/io-registry-parser.ts create mode 100644 ssot_io-registry.md create mode 100644 tests/shared/io-registry-parser.test.ts diff --git a/client/src/components/features/parser-output.tsx b/client/src/components/features/parser-output.tsx index ee3eb21d..03003d90 100644 --- a/client/src/components/features/parser-output.tsx +++ b/client/src/components/features/parser-output.tsx @@ -39,6 +39,8 @@ export function ParserOutput({ defaultTab, ); const [showAllPins, setShowAllPins] = useState(false); + /** detailView: false = compact (✓/—), true = extended (line numbers). Eye-button toggle per SSOT. */ + const [detailView, setDetailView] = useState(false); // PWM-capable pins on Arduino UNO const PWM_PINS = [3, 5, 6, 9, 10, 11]; @@ -70,31 +72,30 @@ export function ParserOutput({ return labels[category] || category; }; + // A pin is "programmed" if it appears in the static or runtime registry + const isPinProgrammed = React.useCallback( + (record: IOPinRecord): boolean => + record.defined || + (record.pinModeLines?.length ?? 0) > 0 || + (record.digitalReadLines?.length ?? 0) > 0 || + (record.digitalWriteLines?.length ?? 0) > 0 || + (record.analogReadLines?.length ?? 0) > 0 || + (record.analogWriteLines?.length ?? 0) > 0 || + (record.usedAt?.length ?? 0) > 0, + [], + ); + // Filter pins: show only programmed pins by default, all pins if showAllPins is true const filteredRegistry = React.useMemo(() => { - if (showAllPins) { - return ioRegistry; - } - // Only show pins that have operations or are defined - return ioRegistry.filter((record) => { - const hasOperations = record.usedAt && record.usedAt.length > 0; - const hasPinMode = - record.defined || - (record.usedAt?.some((u) => u.operation.includes("pinMode")) ?? false); - return hasOperations || hasPinMode; - }); - }, [ioRegistry, showAllPins]); + if (showAllPins) return ioRegistry; + return ioRegistry.filter(isPinProgrammed); + }, [ioRegistry, showAllPins, isPinProgrammed]); // Count of programmed pins (pins with any operation) - const totalProgrammedPins = React.useMemo(() => { - return ioRegistry.filter((record) => { - const hasOperations = record.usedAt && record.usedAt.length > 0; - const hasPinMode = - record.defined || - (record.usedAt?.some((u) => u.operation.includes("pinMode")) ?? false); - return hasOperations || hasPinMode; - }).length; - }, [ioRegistry]); + const totalProgrammedPins = React.useMemo( + () => ioRegistry.filter(isPinProgrammed).length, + [ioRegistry, isPinProgrammed], + ); // Inline CSS to hide scrollbars while keeping scrolling functional const hideScrollbarStyle = ` @@ -298,19 +299,33 @@ export function ParserOutput({ ? `All pins (${ioRegistry.length})` : `Programmed pins (${totalProgrammedPins})`} - +
+ {/* Show-all toggle (text button) */} + + {/* Eye button: compact (✓/—) vs extended (line numbers) – SSOT eye-mode */} + +
@@ -360,35 +375,103 @@ export function ParserOutput({ {filteredRegistry.map((record, idx) => { - // Extract operations by type + // ── Derive modes ───────────────────────────────────── + // Prefer new static-parse fields (pinModeModes/Lines); + // fall back to legacy usedAt for runtime-only pins. const ops = record.usedAt || []; - const digitalReads = ops.filter((u) => - u.operation.includes("digitalRead"), + + const pmModes: string[] = + record.pinModeModes ?? + ops + .filter((u) => u.operation.includes("pinMode")) + .map((u) => { + const m = u.operation.match(/pinMode:(\d+)/); + const n = m ? parseInt(m[1]) : -1; + return n === 0 + ? "INPUT" + : n === 1 + ? "OUTPUT" + : n === 2 + ? "INPUT_PULLUP" + : "UNKNOWN"; + }); + const uniqueModes = [...new Set(pmModes)]; + + // Conflict: TC9 (write on input) or TC11 (multi-mode) + const hasConflict = + record.conflict ?? uniqueModes.length > 1; + + // ── Helper: render an op cell ──────────────────────── + // newLines = from static parse (has line numbers) + // legacyOps = from runtime usedAt (line may be 0) + const renderOpCell = ( + newLines: Array | undefined, + legacyOps: typeof ops, + ) => { + const hasNew = (newLines?.length ?? 0) > 0; + const hasLegacy = legacyOps.length > 0; + const isUsed = hasNew || hasLegacy; + + if (!isUsed) + return ( + + ); + + // Compact mode: just a checkmark + if (!detailView) + return ( + + ✓ + + ); + + // Extended mode: line numbers + const lines: Array = 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")) - .map((u) => { - const match = u.operation.match(/pinMode:(\d+)/); - const mode = match ? parseInt(match[1]) : -1; - return mode === 0 - ? "INPUT" - : mode === 1 - ? "OUTPUT" - : mode === 2 - ? "INPUT_PULLUP" - : "UNKNOWN"; - }); - const uniqueModes = [...new Set(pinModes)]; - const hasMultipleModes = uniqueModes.length > 1; return ( - {/* pinMode Column */} + {/* pinMode Column – always shows mode name; conflict indicator if needed */} - {pinModes.length > 0 ? ( + {pmModes.length > 0 ? (
{uniqueModes.map((mode, i) => { - const count = pinModes.filter( - (m) => m === mode, - ).length; const modeColor = mode === "INPUT" ? "text-blue-400" : mode === "OUTPUT" ? "text-orange-400" : "text-green-400"; + // In extended mode, also show line numbers per mode + const modeLines = detailView + ? record.pinModeLines?.filter( + (_, li) => + record.pinModeModes?.[li] === mode, + ) + : undefined; return (
- {mode} - {hasMultipleModes && ( - ? - )} - {count > 1 && ( - - x{count} +
+ + {mode} + {hasConflict && ( + + ! + + )} +
+ {modeLines && modeLines.length > 0 && ( +
+ {modeLines.map((l) => + l === "runtime" + ? "runtime" + : `L${l}`, + ).join(", ")} +
)}
); @@ -483,8 +583,12 @@ export function ParserOutput({ : "INPUT_PULLUP"}
- ) : 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"), + ) ? (
{/* digitalRead Column */} - - {digitalReads.length > 0 ? ( -
- {digitalReads.map((usage, i) => ( -
- {usage.line > 0 ? ( - - L{usage.line} - - ) : ( - - ✓ - - )} -
- ))} -
- ) : ( - - )} - + {drCell} {/* digitalWrite Column */} - - {digitalWrites.length > 0 ? ( -
- {digitalWrites.map((usage, i) => ( -
- {usage.line > 0 ? ( - - L{usage.line} - - ) : ( - - ✓ - - )} -
- ))} -
- ) : ( - - )} - + {dwCell} {/* analogRead Column */} - - {analogReads.length > 0 ? ( -
- {analogReads.map((usage, i) => ( -
- {usage.line > 0 ? ( - - L{usage.line} - - ) : ( - - ✓ - - )} -
- ))} -
- ) : ( - - )} - + {arCell} {/* analogWrite Column */} - - {analogWrites.length > 0 ? ( -
- {analogWrites.map((usage, i) => ( -
- {usage.line > 0 ? ( - - L{usage.line} - - ) : ( - - ✓ - - )} -
- ))} -
- ) : ( - - )} - + {awCell} ); })} diff --git a/client/src/pages/arduino-simulator.tsx b/client/src/pages/arduino-simulator.tsx index 68dcc7ab..701c454c 100644 --- a/client/src/pages/arduino-simulator.tsx +++ b/client/src/pages/arduino-simulator.tsx @@ -69,6 +69,7 @@ import type { ParserMessage, IOPinRecord, } from "@shared/schema"; +import { parseStaticIORegistry } from "@shared/io-registry-parser"; import type { DebugMessageParams } from "@/hooks/use-compile-and-run"; import { isMac } from "@/lib/platform"; @@ -709,6 +710,17 @@ export default function ArduinoSimulator() { } }, [simulationStatus]); + // ── Static IO-Registry: update from code whenever simulation is not running ─ + // Runs 300 ms after the user stops typing to avoid parsing every keystroke. + // When the simulation starts, the WS `io_registry` messages take over. + useEffect(() => { + 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/server/services/registry-manager.ts b/server/services/registry-manager.ts index d57d1cff..42f72ca6 100644 --- a/server/services/registry-manager.ts +++ b/server/services/registry-manager.ts @@ -76,6 +76,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; @@ -428,6 +435,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); @@ -461,8 +474,12 @@ export class RegistryManager { this.logger.info( `Registry send trigger: first-time pin use ${pinStr} (pinMode:${mode})`, ); + this.runtimeSentFingerprints.add(fingerprint); const nextHash = this.computeRegistryHash(); this.sendNow(nextHash, "pin-defined-changed"); + } else { + // Already defined but new mode (mode change) – mark as sent + this.runtimeSentFingerprints.add(fingerprint); } } else { // Create new pin record if not yet in registry @@ -477,6 +494,7 @@ export class RegistryManager { this.logger.debug( `New pin record created: ${pinStr} with mode=${mode}, sending immediately`, ); + this.runtimeSentFingerprints.add(fingerprint); const nextHash = this.computeRegistryHash(); this.sendNow(nextHash, "pin-new-record"); } @@ -526,6 +544,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 7679f401..fcb228ac 100644 --- a/shared/schema.ts +++ b/shared/schema.ts @@ -175,6 +175,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 definedAt?: { line: number; diff --git a/ssot_io-registry.md b/ssot_io-registry.md new file mode 100644 index 00000000..5ab880b6 --- /dev/null +++ b/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 134deba7..573c3d55 100644 --- a/tests/client/parser-output-pinmode.test.tsx +++ b/tests/client/parser-output-pinmode.test.tsx @@ -519,10 +519,11 @@ describe("ParserOutput Component", () => { expect(pinModeCell).not.toBeNull(); }); - it("displays operations with line numbers", () => { + it("displays operations with line numbers", async () => { + const user = userEvent.setup(); const ioRegistry: IOPinRecord[] = [ { - pin: 13, + pin: "13", defined: true, pinMode: 1, usedAt: [ @@ -541,6 +542,10 @@ describe("ParserOutput Component", () => { />, ); + // Enable extended view (eye-button) to show line numbers (SSOT: detail-toggle) + const eyeButton = screen.getByTestId("io-registry-detail-toggle"); + await user.click(eyeButton); + expect(screen.getByText("L5")).not.toBeNull(); expect(screen.getByText("L7")).not.toBeNull(); }); 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); + }); +}); From 4449ad2013f901a9572a24b33fdf0af136903866 Mon Sep 17 00:00:00 2001 From: ttbombadil Date: Sat, 7 Mar 2026 12:28:45 +0100 Subject: [PATCH 2/2] feat: enhance telemetry and registry management with deduplication and wait mode handling --- .vscode/settings.json | 6 +- package.json | 2 +- scripts/wait-for-backend.mjs | 30 ++++++ server/services/registry-manager.ts | 51 ++++++++-- server/services/sandbox-runner.ts | 2 +- .../ssot_io-registry.md | 0 .../server/services/registry-manager.test.ts | 92 +++++++++++++++++++ 7 files changed, 174 insertions(+), 9 deletions(-) create mode 100644 scripts/wait-for-backend.mjs rename ssot_io-registry.md => ssot/ssot_io-registry.md (100%) diff --git a/.vscode/settings.json b/.vscode/settings.json index f34db3a7..daeeade2 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -135,6 +135,10 @@ }, "/bin/ls": true, "/usr/bin/git": true, - "test": true + "test": true, + "/^node scripts/wait-for-backend\\.mjs$/": { + "approve": true, + "matchCommandLine": true + } }, } diff --git a/package.json b/package.json index 72cc75b0..7c571891 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "license": "MIT", "scripts": { "dev": "DISABLE_RATE_LIMIT=true tsx server/index.ts --host", - "dev:full": "concurrently -n \"BACKEND,CLIENT\" -c \"bgBlue,bgMagenta\" \"npm run dev\" \"npm run dev:client\"", + "dev:full": "concurrently -n \"BACKEND,CLIENT\" -c \"bgBlue,bgMagenta\" \"npm run dev\" \"node scripts/wait-for-backend.mjs && npm run dev:client\"", "dev:client": "vite", "preview": "vite preview", "build": "npm run build:client && npm run build:server && npm run build:copy-public", 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 42f72ca6..7f46e994 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 @@ -324,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( @@ -362,7 +379,18 @@ export class RegistryManager { return; } // Individual pin additions are not logged (20 per start is too noisy). - this.registry.push(pinRecord); + const existingIndex = this.registry.findIndex((p) => p.pin === pinRecord.pin); + if (existingIndex >= 0) { + const existing = this.registry[existingIndex]; + this.registry[existingIndex] = { + ...existing, + ...pinRecord, + defined: existing.defined || pinRecord.defined, + usedAt: mergeUsedAtEntries(existing.usedAt, pinRecord.usedAt), + }; + } else { + this.registry.push(pinRecord); + } this.isDirty = true; this.telemetry.incomingEvents++; } @@ -475,8 +503,11 @@ export class RegistryManager { `Registry send trigger: first-time pin use ${pinStr} (pinMode:${mode})`, ); this.runtimeSentFingerprints.add(fingerprint); - const nextHash = this.computeRegistryHash(); - this.sendNow(nextHash, "pin-defined-changed"); + this.isDirty = true; + if (!this.isCollecting && !this.waitingForRegistry) { + const nextHash = this.computeRegistryHash(); + this.sendNow(nextHash, "pin-defined-changed"); + } } else { // Already defined but new mode (mode change) – mark as sent this.runtimeSentFingerprints.add(fingerprint); @@ -495,8 +526,10 @@ export class RegistryManager { `New pin record created: ${pinStr} with mode=${mode}, sending immediately`, ); this.runtimeSentFingerprints.add(fingerprint); - const nextHash = this.computeRegistryHash(); - this.sendNow(nextHash, "pin-new-record"); + if (!this.isCollecting && !this.waitingForRegistry) { + const nextHash = this.computeRegistryHash(); + this.sendNow(nextHash, "pin-new-record"); + } } } @@ -523,6 +556,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); diff --git a/server/services/sandbox-runner.ts b/server/services/sandbox-runner.ts index 4689871f..03bafa77 100644 --- a/server/services/sandbox-runner.ts +++ b/server/services/sandbox-runner.ts @@ -620,7 +620,7 @@ export class SandboxRunner { this.totalPausedTime = 0; this.registryManager.reset(); this.registryManager.setBaudrate(this.baudrate); - this.registryManager.enableWaitMode(300); // Reduced from 1500ms to 300ms - faster serial output + this.registryManager.enableWaitMode(5000); // 5s timeout: wait for IO_REGISTRY_START; if never comes, flush once this.messageQueue = []; this.outputBuffer = ""; this.outputBufferIndex = 0; diff --git a/ssot_io-registry.md b/ssot/ssot_io-registry.md similarity index 100% rename from ssot_io-registry.md rename to ssot/ssot_io-registry.md 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", () => {