Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
154 changes: 121 additions & 33 deletions client/src/components/features/parser-output.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@
defaultTab,
);
const [showAllPins, setShowAllPins] = useState(false);
/** detailView: false = compact (✓/—), true = extended (line numbers). Eye-button toggle per SSOT. */
const [detailView, setDetailView] = useState(false);

Check failure on line 43 in client/src/components/features/parser-output.tsx

View workflow job for this annotation

GitHub Actions / Lint, TypeCheck & Unit Tests

'setDetailView' is declared but its value is never read.

Check failure on line 43 in client/src/components/features/parser-output.tsx

View workflow job for this annotation

GitHub Actions / Lint, TypeCheck & Unit Tests

'setDetailView' is declared but its value is never read.
// PWM-capable pins on Arduino UNO
const PWM_PINS = [3, 5, 6, 9, 10, 11];

Expand Down Expand Up @@ -70,31 +72,30 @@
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 = `
Expand Down Expand Up @@ -360,19 +361,102 @@
</thead>
<tbody>
{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)];

Check failure on line 384 in client/src/components/features/parser-output.tsx

View workflow job for this annotation

GitHub Actions / Lint, TypeCheck & Unit Tests

Cannot redeclare block-scoped variable 'uniqueModes'.

Check failure on line 384 in client/src/components/features/parser-output.tsx

View workflow job for this annotation

GitHub Actions / Lint, TypeCheck & Unit Tests

Cannot redeclare block-scoped variable 'uniqueModes'.

// Conflict: TC9 (write on input) or TC11 (multi-mode)
const hasConflict =

Check failure on line 387 in client/src/components/features/parser-output.tsx

View workflow job for this annotation

GitHub Actions / Lint, TypeCheck & Unit Tests

'hasConflict' is declared but its value is never read.

Check failure on line 387 in client/src/components/features/parser-output.tsx

View workflow job for this annotation

GitHub Actions / Lint, TypeCheck & Unit Tests

'hasConflict' is declared but its value is never read.
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<number | "runtime"> | undefined,
legacyOps: typeof ops,
) => {
const hasNew = (newLines?.length ?? 0) > 0;
const hasLegacy = legacyOps.length > 0;
const isUsed = hasNew || hasLegacy;

if (!isUsed)
return (
<span className="text-gray-400">—</span>
);

// Compact mode: just a checkmark
if (!detailView)
return (
<span className="text-green-500 font-bold">
</span>
);

// Extended mode: line numbers
const lines: Array<number | "runtime"> = hasNew
? newLines!
: legacyOps.map((u) =>
u.line > 0
? u.line
: ("runtime" as const),
);
return (
<div className="space-y-0.5 text-center">
{lines.map((line, i) => (
<div key={i} className="text-ui-xs">
{line === "runtime" ? (
<span className="text-yellow-400 italic">
runtime
</span>
) : (
<span className="text-blue-400">
L{line}
</span>
)}
</div>
))}
</div>
);
};

const drCell = renderOpCell(

Check failure on line 441 in client/src/components/features/parser-output.tsx

View workflow job for this annotation

GitHub Actions / Lint, TypeCheck & Unit Tests

'drCell' is declared but its value is never read.

Check failure on line 441 in client/src/components/features/parser-output.tsx

View workflow job for this annotation

GitHub Actions / Lint, TypeCheck & Unit Tests

'drCell' is declared but its value is never read.
record.digitalReadLines,
ops.filter((u) => u.operation.includes("digitalRead")),
);
const digitalWrites = ops.filter((u) =>
u.operation.includes("digitalWrite"),
const dwCell = renderOpCell(

Check failure on line 445 in client/src/components/features/parser-output.tsx

View workflow job for this annotation

GitHub Actions / Lint, TypeCheck & Unit Tests

'dwCell' is declared but its value is never read.

Check failure on line 445 in client/src/components/features/parser-output.tsx

View workflow job for this annotation

GitHub Actions / Lint, TypeCheck & Unit Tests

'dwCell' is declared but its value is never read.
record.digitalWriteLines,
ops.filter((u) =>
u.operation.includes("digitalWrite"),
),
);
const analogReads = ops.filter((u) =>
u.operation.includes("analogRead"),
const arCell = renderOpCell(

Check failure on line 451 in client/src/components/features/parser-output.tsx

View workflow job for this annotation

GitHub Actions / Lint, TypeCheck & Unit Tests

'arCell' is declared but its value is never read.

Check failure on line 451 in client/src/components/features/parser-output.tsx

View workflow job for this annotation

GitHub Actions / Lint, TypeCheck & Unit Tests

'arCell' is declared but its value is never read.
record.analogReadLines,
ops.filter((u) => u.operation.includes("analogRead")),
);
const analogWrites = ops.filter((u) =>
u.operation.includes("analogWrite"),
const awCell = renderOpCell(

Check failure on line 455 in client/src/components/features/parser-output.tsx

View workflow job for this annotation

GitHub Actions / Lint, TypeCheck & Unit Tests

'awCell' is declared but its value is never read.

Check failure on line 455 in client/src/components/features/parser-output.tsx

View workflow job for this annotation

GitHub Actions / Lint, TypeCheck & Unit Tests

'awCell' is declared but its value is never read.
record.analogWriteLines,
ops.filter((u) =>
u.operation.includes("analogWrite"),
),
);
const pinModes = ops
.filter((u) => u.operation.includes("pinMode"))
Expand All @@ -387,7 +471,7 @@
? "INPUT_PULLUP"
: "UNKNOWN";
});
const uniqueModes = [...new Set(pinModes)];

Check failure on line 474 in client/src/components/features/parser-output.tsx

View workflow job for this annotation

GitHub Actions / Lint, TypeCheck & Unit Tests

Cannot redeclare block-scoped variable 'uniqueModes'.

Check failure on line 474 in client/src/components/features/parser-output.tsx

View workflow job for this annotation

GitHub Actions / Lint, TypeCheck & Unit Tests

Cannot redeclare block-scoped variable 'uniqueModes'.
const hasMultipleModes = uniqueModes.length > 1;
// Runtime conflict flag from RegistryManager
const isConflict = hasMultipleModes || record.hasConflict === true;
Expand Down Expand Up @@ -429,7 +513,7 @@
</div>
</td>

{/* pinMode Column */}
{/* pinMode Column – always shows mode name; conflict indicator if needed */}
<td
className={clsx(
"px-2 py-1 text-center",
Expand Down Expand Up @@ -526,8 +610,12 @@
<span className="text-red-400" title="Conflicting pin usage detected at runtime">?</span>
)}
</div>
) : 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"),
) ? (
<div
className="flex items-center justify-center"
title="pinMode() missing"
Expand All @@ -541,10 +629,10 @@

{/* digitalRead Column */}
<td className="px-2 py-1 text-center">
{digitalReads.length > 0 ? (

Check failure on line 632 in client/src/components/features/parser-output.tsx

View workflow job for this annotation

GitHub Actions / Lint, TypeCheck & Unit Tests

Cannot find name 'digitalReads'.

Check failure on line 632 in client/src/components/features/parser-output.tsx

View workflow job for this annotation

GitHub Actions / Lint, TypeCheck & Unit Tests

Cannot find name 'digitalReads'.
showAllPins ? (
<div className="space-y-0.5 text-center">
{digitalReads.map((usage, i) => (

Check failure on line 635 in client/src/components/features/parser-output.tsx

View workflow job for this annotation

GitHub Actions / Lint, TypeCheck & Unit Tests

Cannot find name 'digitalReads'.

Check failure on line 635 in client/src/components/features/parser-output.tsx

View workflow job for this annotation

GitHub Actions / Lint, TypeCheck & Unit Tests

Cannot find name 'digitalReads'.
<div key={i} className="text-ui-xs">
{usage.line > 0 ? (
<span className="text-blue-400">
Expand Down
12 changes: 12 additions & 0 deletions client/src/pages/arduino-simulator.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -753,6 +754,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);
Expand Down
30 changes: 30 additions & 0 deletions scripts/wait-for-backend.mjs
Original file line number Diff line number Diff line change
@@ -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);
}
46 changes: 43 additions & 3 deletions server/services/registry-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, (typeof merged)[number]>();
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
Expand All @@ -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 "<pinId>:<mode>" (e.g. "13:1").
* Reset on reset() / next program start.
*/
private runtimeSentFingerprints = new Set<string>();
private readonly logger = new Logger("RegistryManager");
private readonly onUpdateCallback?: RegistryUpdateCallback;
private readonly onTelemetryCallback?: TelemetryUpdateCallback;
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -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");
}
}
}

Expand All @@ -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);
Expand All @@ -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();

Expand Down
Loading