fix(spice+pipeline): LED visualization, INPUT_PULLUP, ESP32-C3, PWM fade, examples
End-to-end pipeline fixes uncovered while auditing the /examples gallery.
Each bug shipped past green unit + snapshot tests because none of those run
firmware + render LEDs. Added scripts/visual-led-test.mjs as a CDP-driven
visual harness that loads each example, runs the simulator, samples
`wokwi-led.brightness`, and asserts toggle / gradient / initial-off
invariants — exits non-zero on any regression.
Frontend simulator
- PinManager.updatePort: new optional ddrMask param. A pin is added to
`outputPins` only if the DDR bit is set, so the PORTx write that
enables INPUT_PULLUP (DDR=0, PORT=1) no longer falsely marks the pin
as MCU output. AVRSimulator now reads DDRB/C/D (0x24/0x27/0x2A on
Uno/Nano, 0x37 on ATtiny85, per-port table on Mega) and forwards it.
- AVRSimulator: pass DDR mask alongside every port-listener fire.
- BasicParts pushbutton{,-6mm}: seed pin HIGH in attachEvents so
`digitalRead()` returns HIGH while idle. avr8js doesn't auto-simulate
INPUT_PULLUP — without this the firmware reads LOW from boot and
thinks the button is permanently pressed (the "LED is always on,
pressing does nothing" UX bug).
- connectMcuEdgesToService: suppress synthetic digital edges on pins
with active PWM, AND subscribe to onPwmChange to re-tick the netlist
on duty changes. Fade-LED now produces a true gradient (6 distinct
brightness levels across a fade cycle) instead of a binary 0/full
toggle.
- CircuitSimulationService.handleMcuEdge: replace single-slot
pendingMcuEdge with a per-pin Map. Multiple pins toggling during the
same in-flight tick used to overwrite each other; now every pin's
most-recent edge replays after the tick. Fixes Traffic-Light RED→
YELLOW→GREEN sequencing.
- NetlistBuilder: new sanitizeSpiceId() helper replaces hyphens with
underscores in V-source names. ngspice's interactive `alter` command
treats `-` as an operator and silently no-ops on hyphenated source
names, so mid-simulation MCU pin transitions stopped propagating
after the first solve. MixedModeScheduler.onMcuPinChange and
CircuitSimulationService self-heal use the same sanitizer so names
stay consistent across emit/alter/lookup. Also added a regex-based
fallback in step 2 so any board pin matching `GND.\d+` canonicalises
to net "0" — ESP32-C3 dev kits expose up to 10 GND pins and the
per-board `groundPinNames` list missed several, leaving wires
floating instead of grounded.
- collectPinStates: emit V-sources only for pins in `outputPins`, not
every wired board pin. Leaves INPUT pins (analog sensors on A0,
pull-down dividers, etc.) free for the SPICE solver instead of being
shorted to 0 V by an ideal MCU V-source.
- start.ts: extended __spiceDebug to also expose outputPinsByBoard +
nodeVoltages + pinNetMapEntries for the visual harness.
- ESP32 / RP2040 / RISC-V / C3 simulators: pass `'mcu'` source flag to
triggerPinChange / setPinState so the new outputPins tracking fires
on those boards too (was AVR-only before).
- useSimulatorStore: stopBoard/resetBoard call pm.resetPinStates() so
outputPins clears between runs; Esp32Bridge.onPinChange passes the
`'mcu'` flag in all three places it's wired.
- types/board.ts: ATtiny85 FQBN `clock=internal16mhz` →
`clock=16pll` (ATTinyCore 1.5.2 renamed the option).
Backend
- esp-idf-template/main/CMakeLists.txt: skip the
`-DLED_BUILTIN=2` fallback for esp32c3 and esp32s3 targets. Both
variants already define LED_BUILTIN in pins_arduino.h via a
self-define macro (`#define LED_BUILTIN LED_BUILTIN` + `static const
uint8_t LED_BUILTIN = ...;`). Pre-defining the symbol from the
command line expanded the static-const declaration to
`static const uint8_t 2 = ...;` — a syntax error that broke every
ESP32-C3 / S3 build (`expected unqualified-id before numeric
constant`).
Examples
- examples.ts: bulk-fix 72 wire endpoints that referenced
`componentId: 'nano-rp2040'` / `'esp32-c3'` etc. (boards that don't
exist on the canvas). Replaced with `'arduino-uno'` (the canvas
board-id convention) and converted `D<n>` pin names to `GP<n>` for
Pico-style boards. Affects pico-blink, pico-i2c-scanner,
pico-i2c-rtc-read, pico-spi-loopback, c3-blink and others.
Tests
- scripts/visual-led-test.mjs: CDP-driven harness. Default suite covers
Blink (single-pin), Button (idle-OFF invariant — catches the
INPUT_PULLUP regression), Traffic-Light (multi-pin sequencing),
Fade-LED (PWM gradient — ≥3 distinct levels), RGB-LED (≥3 PWM pins
driven). Run via `npm --prefix frontend run test:visual` against a
Chrome on `:9222` + vite on `:5174` + backend on `:8001`.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
a179f8492e
commit
81837eedb9
|
|
@ -55,8 +55,18 @@ if(DEFINED ENV{ARDUINO_ESP32_PATH})
|
|||
# for ESP32-CAM where GPIO 2 isn't broken out, the user should use an
|
||||
# explicit pin number — but this default keeps the new-project flow
|
||||
# smooth.
|
||||
target_compile_definitions(${COMPONENT_LIB} PRIVATE
|
||||
"LED_BUILTIN=2")
|
||||
#
|
||||
# SKIP on ESP32-C3 and S3 variants: their pins_arduino.h DOES define
|
||||
# LED_BUILTIN already (as `static const uint8_t LED_BUILTIN = ...;`
|
||||
# plus `#define LED_BUILTIN LED_BUILTIN`). Pre-defining it from the
|
||||
# command line clashes with the self-define: the preprocessor expands
|
||||
# `static const uint8_t LED_BUILTIN = ...` to `static const uint8_t 2 = ...`
|
||||
# which is a syntax error. Only set the fallback for the generic ESP32
|
||||
# Xtensa variant that genuinely lacks the symbol.
|
||||
if(NOT IDF_TARGET STREQUAL "esp32c3" AND NOT IDF_TARGET STREQUAL "esp32s3")
|
||||
target_compile_definitions(${COMPONENT_LIB} PRIVATE
|
||||
"LED_BUILTIN=2")
|
||||
endif()
|
||||
else()
|
||||
# Pure ESP-IDF mode: main.c #includes sketch_translated.c
|
||||
idf_component_register(
|
||||
|
|
|
|||
|
|
@ -26,7 +26,8 @@
|
|||
"test:coverage": "vitest run --coverage",
|
||||
"test:ui": "vitest --ui",
|
||||
"tsc": "tsc -b",
|
||||
"test:libraries": "RUN_LIBRARY_TESTS=1 vitest run src/__tests__/library-compile.integration.test.ts"
|
||||
"test:libraries": "RUN_LIBRARY_TESTS=1 vitest run src/__tests__/library-compile.integration.test.ts",
|
||||
"test:visual": "node ../scripts/visual-led-test.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@google/genai": "^0.6.0",
|
||||
|
|
@ -78,4 +79,4 @@
|
|||
"vite": "^7.3.1",
|
||||
"vitest": "^4.0.18"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -427,6 +427,14 @@ export class AVRSimulator {
|
|||
if (!this.cpu) return;
|
||||
console.log('Setting up pin hooks...');
|
||||
|
||||
// DDR register addresses (used to distinguish OUTPUT pins from
|
||||
// INPUT_PULLUP — see PinManager.updatePort ddrMask param).
|
||||
// ATmega328P/Uno/Nano: DDRB=0x24, DDRC=0x27, DDRD=0x2A
|
||||
// ATtiny85: DDRB=0x37
|
||||
// ATmega2560: per-port table below
|
||||
const cpu = this.cpu;
|
||||
const readDdr = (addr: number) => cpu.data[addr] ?? 0;
|
||||
|
||||
if (this.boardVariant === 'tiny85') {
|
||||
// ATtiny85: PORTB only, PB0-PB5 → pins 0-5
|
||||
// Must pass an explicit pinMap so updatePort uses offset 0 instead of the
|
||||
|
|
@ -434,20 +442,26 @@ export class AVRSimulator {
|
|||
const TINY85_PIN_MAP = [0, 1, 2, 3, 4, 5, -1, -1];
|
||||
this.portB!.addListener((value) => {
|
||||
if (value !== this.lastPortBValue) {
|
||||
this.pinManager.updatePort('PORTB', value, this.lastPortBValue, TINY85_PIN_MAP);
|
||||
this.pinManager.updatePort('PORTB', value, this.lastPortBValue, TINY85_PIN_MAP, readDdr(0x37));
|
||||
this.firePinChangeWithTime(value, this.lastPortBValue, null, 0);
|
||||
this.lastPortBValue = value;
|
||||
}
|
||||
});
|
||||
} else if (this.boardVariant === 'mega') {
|
||||
// Mega: use explicit per-bit pin maps for all 11 ports
|
||||
const MEGA_DDR_ADDRS: Record<string, number> = {
|
||||
PORTA: 0x21, PORTB: 0x24, PORTC: 0x27, PORTD: 0x2A,
|
||||
PORTE: 0x2D, PORTF: 0x30, PORTG: 0x33, PORTH: 0x101,
|
||||
PORTJ: 0x104, PORTK: 0x107, PORTL: 0x10A,
|
||||
};
|
||||
for (const [portName, port] of this.megaPorts) {
|
||||
const pinMap = MEGA_PORT_BIT_MAP[portName];
|
||||
const ddrAddr = MEGA_DDR_ADDRS[portName];
|
||||
this.megaPortValues.set(portName, 0);
|
||||
port.addListener((value) => {
|
||||
const old = this.megaPortValues.get(portName) ?? 0;
|
||||
if (value !== old) {
|
||||
this.pinManager.updatePort(portName, value, old, pinMap);
|
||||
this.pinManager.updatePort(portName, value, old, pinMap, ddrAddr ? readDdr(ddrAddr) : undefined);
|
||||
this.firePinChangeWithTime(value, old, pinMap);
|
||||
this.megaPortValues.set(portName, value);
|
||||
}
|
||||
|
|
@ -457,21 +471,21 @@ export class AVRSimulator {
|
|||
// Uno / Nano: simple 3-port setup
|
||||
this.portB!.addListener((value) => {
|
||||
if (value !== this.lastPortBValue) {
|
||||
this.pinManager.updatePort('PORTB', value, this.lastPortBValue);
|
||||
this.pinManager.updatePort('PORTB', value, this.lastPortBValue, undefined, readDdr(0x24));
|
||||
this.firePinChangeWithTime(value, this.lastPortBValue, null, 8);
|
||||
this.lastPortBValue = value;
|
||||
}
|
||||
});
|
||||
this.portC!.addListener((value) => {
|
||||
if (value !== this.lastPortCValue) {
|
||||
this.pinManager.updatePort('PORTC', value, this.lastPortCValue);
|
||||
this.pinManager.updatePort('PORTC', value, this.lastPortCValue, undefined, readDdr(0x27));
|
||||
this.firePinChangeWithTime(value, this.lastPortCValue, null, 14);
|
||||
this.lastPortCValue = value;
|
||||
}
|
||||
});
|
||||
this.portD!.addListener((value) => {
|
||||
if (value !== this.lastPortDValue) {
|
||||
this.pinManager.updatePort('PORTD', value, this.lastPortDValue);
|
||||
this.pinManager.updatePort('PORTD', value, this.lastPortDValue, undefined, readDdr(0x2A));
|
||||
this.firePinChangeWithTime(value, this.lastPortDValue, null, 0);
|
||||
this.lastPortDValue = value;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -298,7 +298,7 @@ export class Esp32C3Simulator {
|
|||
`[ESP32-C3] GPIO${bit} → ${state ? 'HIGH' : 'LOW'} @ ${timeMs.toFixed(1)}ms`,
|
||||
);
|
||||
this.onPinChangeWithTime?.(bit, state, timeMs);
|
||||
this.pinManager.setPinState(bit, state);
|
||||
this.pinManager.setPinState(bit, state, 'mcu');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -27,6 +27,12 @@ export class PinManager {
|
|||
private analogListeners: Map<number, Set<AnalogCallback>> = new Map();
|
||||
private pinStates: Map<number, boolean> = new Map();
|
||||
private pwmValues: Map<number, number> = new Map();
|
||||
// Pins the MCU has driven (digitalWrite / PWM / port-listener fire).
|
||||
// Consumed by collectPinStates.ts to emit a SPICE V-source only for
|
||||
// real outputs — leaving INPUT pins floating so external sensors
|
||||
// (NTC + divider on A0, photoresistor, etc.) don't get clamped to
|
||||
// the MCU's idle V-source.
|
||||
private outputPins: Set<number> = new Set();
|
||||
|
||||
// ── Digital pin API ──────────────────────────────────────────────────────
|
||||
|
||||
|
|
@ -54,8 +60,21 @@ export class PinManager {
|
|||
* Use -1 for bits that are not exposed as Arduino pins.
|
||||
* When omitted the legacy Uno/Nano fixed offsets are used:
|
||||
* PORTB→8, PORTC→14, PORTD→0.
|
||||
* @param ddrMask Optional DDR register value (8 bits). When provided,
|
||||
* a pin is added to `outputPins` only if its DDR bit is
|
||||
* 1 (the AVR is actively driving it as OUTPUT). Without
|
||||
* this guard, the PORTx write that activates INPUT_PULLUP
|
||||
* (DDR=0, PORT=1) falsely marks the pin as MCU output
|
||||
* and emits an ideal V-source on the SPICE side, fighting
|
||||
* the real external circuit (button, sensor, pull-down).
|
||||
*/
|
||||
updatePort(portName: string, newValue: number, oldValue: number = 0, pinMap?: number[]) {
|
||||
updatePort(
|
||||
portName: string,
|
||||
newValue: number,
|
||||
oldValue: number = 0,
|
||||
pinMap?: number[],
|
||||
ddrMask?: number,
|
||||
) {
|
||||
const legacyOffsets: Record<string, number> = { PORTB: 8, PORTC: 14, PORTD: 0 };
|
||||
|
||||
for (let bit = 0; bit < 8; bit++) {
|
||||
|
|
@ -68,6 +87,10 @@ export class PinManager {
|
|||
if (arduinoPin < 0) continue; // unmapped bit
|
||||
|
||||
this.pinStates.set(arduinoPin, newState);
|
||||
// Only mark as MCU-output if DDR bit is set (or DDR unknown → legacy).
|
||||
if (ddrMask === undefined || (ddrMask & mask) !== 0) {
|
||||
this.outputPins.add(arduinoPin);
|
||||
}
|
||||
|
||||
const callbacks = this.listeners.get(arduinoPin);
|
||||
if (callbacks) {
|
||||
|
|
@ -84,32 +107,47 @@ export class PinManager {
|
|||
/**
|
||||
* Set a single pin state and notify listeners.
|
||||
* Alias for triggerPinChange — used by ESP32-C3, RISC-V, and RP2040 simulators.
|
||||
*
|
||||
* `source` distinguishes MCU GPIO writes (mark pin as output for SPICE)
|
||||
* from external actors like buttons or sensor parts (don't mark).
|
||||
*/
|
||||
setPinState(pin: number, state: boolean): void {
|
||||
this.triggerPinChange(pin, state);
|
||||
setPinState(pin: number, state: boolean, source: 'mcu' | 'external' = 'external'): void {
|
||||
this.triggerPinChange(pin, state, source);
|
||||
}
|
||||
|
||||
/**
|
||||
* Directly fire pin change callbacks for a specific pin.
|
||||
* Used by RP2040Simulator which has individual GPIO listeners instead of PORT registers.
|
||||
*/
|
||||
triggerPinChange(pin: number, state: boolean): void {
|
||||
triggerPinChange(pin: number, state: boolean, source: 'mcu' | 'external' = 'external'): void {
|
||||
const current = this.pinStates.get(pin);
|
||||
if (current === state) return; // no change
|
||||
if (current === state) {
|
||||
if (source === 'mcu') this.outputPins.add(pin);
|
||||
return;
|
||||
}
|
||||
this.pinStates.set(pin, state);
|
||||
if (source === 'mcu') this.outputPins.add(pin);
|
||||
const callbacks = this.listeners.get(pin);
|
||||
if (callbacks) {
|
||||
callbacks.forEach((cb) => cb(pin, state));
|
||||
}
|
||||
}
|
||||
|
||||
/** Pins the MCU has actively driven this session. */
|
||||
getOutputPins(): ReadonlySet<number> {
|
||||
return this.outputPins;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear cached pin states (without removing listeners).
|
||||
* Useful in tests so that a fresh `triggerPinChange` after a reset
|
||||
* isn't suppressed by the same-state early-return.
|
||||
* Clear cached pin states + output-pin classifications. Called by
|
||||
* stopBoard / resetBoard so the next Run starts without stale output
|
||||
* classifications from a previous session forcing premature V-source
|
||||
* emission. Also keeps the test fixtures' fresh-triggerPinChange path
|
||||
* (the original reason this helper exists) working.
|
||||
*/
|
||||
resetPinStates(): void {
|
||||
this.pinStates.clear();
|
||||
this.outputPins.clear();
|
||||
}
|
||||
|
||||
// ── PWM duty cycle API ───────────────────────────────────────────────────
|
||||
|
|
@ -133,6 +171,7 @@ export class PinManager {
|
|||
*/
|
||||
updatePwm(pin: number, dutyCycle: number): void {
|
||||
this.pwmValues.set(pin, dutyCycle);
|
||||
if (dutyCycle > 0) this.outputPins.add(pin);
|
||||
const callbacks = this.pwmListeners.get(pin);
|
||||
if (callbacks) {
|
||||
callbacks.forEach((cb) => cb(pin, dutyCycle));
|
||||
|
|
@ -209,5 +248,6 @@ export class PinManager {
|
|||
this.listeners.clear();
|
||||
this.pwmListeners.clear();
|
||||
this.analogListeners.clear();
|
||||
this.outputPins.clear();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -513,7 +513,7 @@ export class RP2040Simulator {
|
|||
|
||||
const unsub = gpio.addListener((state: GPIOPinState) => {
|
||||
const isHigh = state === GPIOPinState.High || state === GPIOPinState.InputPullUp;
|
||||
this.pinManager.triggerPinChange(pin, isHigh);
|
||||
this.pinManager.triggerPinChange(pin, isHigh, 'mcu');
|
||||
if (this.onPinChangeWithTime && this.rp2040) {
|
||||
// IClock interface exposes `nanos` (not `timeUs`)
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
|
|
|
|||
|
|
@ -139,7 +139,7 @@ export class RiscVSimulator {
|
|||
const pin = pinOffset + bit;
|
||||
const state = !!(val & (1 << bit));
|
||||
this.onPinChangeWithTime?.(pin, state, timeMs);
|
||||
this.pinManager.setPinState(pin, state);
|
||||
this.pinManager.setPinState(pin, state, 'mcu');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,6 +13,13 @@ PartSimulationRegistry.register('pushbutton', {
|
|||
getArduinoPinHelper('1.r') ??
|
||||
getArduinoPinHelper('2.r');
|
||||
|
||||
// Seed the input pin HIGH so `digitalRead()` returns HIGH while the
|
||||
// button is idle. avr8js does not auto-simulate INPUT_PULLUP — without
|
||||
// this, the firmware reads LOW from the moment loop() starts and
|
||||
// believes the button is permanently pressed (the classic "LED is
|
||||
// always on, pressing the button does nothing" UX bug).
|
||||
if (arduinoPin !== null) avrSimulator.setPinState(arduinoPin, true);
|
||||
|
||||
const onButtonPress = () => {
|
||||
if (arduinoPin !== null) avrSimulator.setPinState(arduinoPin, false); // Active LOW
|
||||
(element as any).pressed = true;
|
||||
|
|
@ -44,6 +51,10 @@ PartSimulationRegistry.register('pushbutton-6mm', {
|
|||
getArduinoPinHelper('1.r') ??
|
||||
getArduinoPinHelper('2.r');
|
||||
|
||||
// Same INPUT_PULLUP seeding as the full-size pushbutton — see comment
|
||||
// in `register('pushbutton', ...)` above for why this is required.
|
||||
if (arduinoPin !== null) avrSimulator.setPinState(arduinoPin, true);
|
||||
|
||||
const onPress = () => {
|
||||
if (arduinoPin !== null) avrSimulator.setPinState(arduinoPin, false);
|
||||
(element as any).pressed = true;
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@
|
|||
* - No SPICE-engine knowledge — that's in the adapters.
|
||||
*/
|
||||
import { buildInputFromStore } from './storeAdapter';
|
||||
import { buildNetlist } from './NetlistBuilder';
|
||||
import { buildNetlist, sanitizeSpiceId } from './NetlistBuilder';
|
||||
import type { TimeWaveforms } from './types';
|
||||
|
||||
/** What the service needs from the simulator store. */
|
||||
|
|
@ -106,8 +106,15 @@ export interface ServiceOptions {
|
|||
export class CircuitSimulationService {
|
||||
private inFlight = false;
|
||||
private pending = false;
|
||||
private pendingMcuEdge: { boardId: string; pinName: string; state: boolean; vcc: number } | null =
|
||||
null;
|
||||
// Per-pin pending edge buffer. Keyed by `${boardId}|${pinName}`. A single
|
||||
// slot would be overwritten when several pins toggle during the same
|
||||
// in-flight tick (Traffic Light: red→yellow→green hand off within ~µs of
|
||||
// each other) — the LATEST edge would win, dropping the rest. With a Map
|
||||
// every pin's most-recent edge is preserved and replayed after the tick.
|
||||
private pendingMcuEdges = new Map<
|
||||
string,
|
||||
{ boardId: string; pinName: string; state: boolean; vcc: number }
|
||||
>();
|
||||
|
||||
/**
|
||||
* Last loaded circuit context — used by `handleMcuEdge` to extract
|
||||
|
|
@ -145,10 +152,12 @@ export class CircuitSimulationService {
|
|||
if (this.pending) {
|
||||
this.pending = false;
|
||||
void this.tick();
|
||||
} else if (this.pendingMcuEdge) {
|
||||
const edge = this.pendingMcuEdge;
|
||||
this.pendingMcuEdge = null;
|
||||
void this.handleMcuEdge(edge.boardId, edge.pinName, edge.state, edge.vcc);
|
||||
} else if (this.pendingMcuEdges.size > 0) {
|
||||
const edges = Array.from(this.pendingMcuEdges.values());
|
||||
this.pendingMcuEdges.clear();
|
||||
for (const edge of edges) {
|
||||
void this.handleMcuEdge(edge.boardId, edge.pinName, edge.state, edge.vcc);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -160,13 +169,15 @@ export class CircuitSimulationService {
|
|||
* voltages to useElectricalStore.
|
||||
*
|
||||
* Coalesces with the canvas-change tick: if a full solve is in
|
||||
* flight, the edge is queued and replayed after that solve
|
||||
* completes (so the netlist is fresh). Last-edge-wins per pin
|
||||
* since edges overwrite the same field.
|
||||
* flight, the edge is queued per-pin (Map keyed by `boardId|pinName`)
|
||||
* and replayed after the tick completes — last-state-wins PER pin,
|
||||
* so different pins toggling during the same tick don't drop each
|
||||
* other's edges.
|
||||
*/
|
||||
async handleMcuEdge(boardId: string, pinName: string, state: boolean, vcc: number): Promise<void> {
|
||||
const pinKey = `${boardId}|${pinName}`;
|
||||
if (this.inFlight) {
|
||||
this.pendingMcuEdge = { boardId, pinName, state, vcc };
|
||||
this.pendingMcuEdges.set(pinKey, { boardId, pinName, state, vcc });
|
||||
return;
|
||||
}
|
||||
if (!this.loadedContext) {
|
||||
|
|
@ -175,6 +186,20 @@ export class CircuitSimulationService {
|
|||
void this.tick();
|
||||
return;
|
||||
}
|
||||
// Self-heal: if this is the FIRST edge on a pin that wasn't classified
|
||||
// as MCU-output when the netlist was built, the matching V-source
|
||||
// doesn't exist — alterSource would be a silent no-op. Trigger a
|
||||
// full rebuild instead; collectPinStates now sees the pin in
|
||||
// outputPins and emits the V-source, so the next edge alters normally.
|
||||
const expected = `v_${sanitizeSpiceId(boardId)}_${sanitizeSpiceId(pinName)}`.toLowerCase();
|
||||
const hasSource = this.loadedContext.voltageSources.some(
|
||||
(vs) => vs.toLowerCase() === expected,
|
||||
);
|
||||
if (!hasSource) {
|
||||
this.pendingMcuEdges.set(pinKey, { boardId, pinName, state, vcc });
|
||||
void this.tick();
|
||||
return;
|
||||
}
|
||||
this.inFlight = true;
|
||||
try {
|
||||
// alter + resolveDc internally; the scheduler's
|
||||
|
|
@ -189,6 +214,12 @@ export class CircuitSimulationService {
|
|||
if (this.pending) {
|
||||
this.pending = false;
|
||||
void this.tick();
|
||||
} else if (this.pendingMcuEdges.size > 0) {
|
||||
const edges = Array.from(this.pendingMcuEdges.values());
|
||||
this.pendingMcuEdges.clear();
|
||||
for (const edge of edges) {
|
||||
void this.handleMcuEdge(edge.boardId, edge.pinName, edge.state, edge.vcc);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@
|
|||
|
||||
import type { SolverPort, SolveAnalysis } from './ports/SolverPort';
|
||||
import { NgSpiceWorkerAdapter } from './adapters/NgSpiceWorkerAdapter';
|
||||
import { sanitizeSpiceId } from './NetlistBuilder';
|
||||
import type { PinState, SpiceVoltageSource } from '../PinResolver';
|
||||
|
||||
/**
|
||||
|
|
@ -249,7 +250,10 @@ class MixedModeSchedulerImpl implements SpiceVoltageSource {
|
|||
vcc: number,
|
||||
): Promise<void> {
|
||||
if (!this.solver) return;
|
||||
const sourceName = `V_${boardId}_${pinName}`;
|
||||
// Must match the sanitized name NetlistBuilder emits — see comment
|
||||
// at the V-source emission site for why ngspice's `alter` command
|
||||
// can't accept hyphens.
|
||||
const sourceName = `V_${sanitizeSpiceId(boardId)}_${sanitizeSpiceId(pinName)}`;
|
||||
const voltage = state ? vcc : 0;
|
||||
await this.solver.alterSource(sourceName, voltage);
|
||||
await this.resolveDc();
|
||||
|
|
|
|||
|
|
@ -30,6 +30,21 @@ function skipCanonicalization(metadataId: string): boolean {
|
|||
return metadataId.startsWith('instr-');
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize a component / board / pin id for use inside a SPICE identifier
|
||||
* (V-source name, etc.). ngspice's interactive `alter` command treats `-`
|
||||
* as an arithmetic / range operator and silently no-ops on hyphenated
|
||||
* source names — so even though the netlist *parser* tolerates hyphens
|
||||
* via the regex on line 206, mid-simulation MCU pin transitions stop
|
||||
* propagating after the first solve. Substituting `-` → `_` here keeps
|
||||
* the identifier ngspice-safe AND consistent with whatever the scheduler
|
||||
* passes to `alter`. Must be reused by MixedModeScheduler when building
|
||||
* the alter target name.
|
||||
*/
|
||||
export function sanitizeSpiceId(id: string): string {
|
||||
return id.replace(/-/g, '_');
|
||||
}
|
||||
|
||||
export interface BuildNetlistResult {
|
||||
netlist: string;
|
||||
/** "boardId:pinName" → SPICE net name, from the same UF used to build the netlist. */
|
||||
|
|
@ -81,6 +96,18 @@ export function buildNetlist(input: BuildNetlistInput): BuildNetlistResult {
|
|||
for (const pinName of board.vccPinNames ?? []) {
|
||||
uf.setCanonical(pinKey(board.id, pinName), 'vcc_rail');
|
||||
}
|
||||
// Fallback: any board pin a wire references whose name looks like a
|
||||
// ground pin (GND, GND.1, GND.9, etc.) is canonicalized to "0" even if
|
||||
// it's not in `groundPinNames`. Boards with many GND pins (ESP32-C3
|
||||
// dev kits ship up to 10) often miss some in the per-board list, which
|
||||
// would leave wires connected to those pins floating instead of grounded.
|
||||
for (const pinName of pinsReferencedByWires(board.id, wires)) {
|
||||
if (GROUND_PIN_RE.test(pinName)) {
|
||||
uf.setCanonical(pinKey(board.id, pinName), '0');
|
||||
} else if (VCC_PIN_RE.test(pinName)) {
|
||||
uf.setCanonical(pinKey(board.id, pinName), 'vcc_rail');
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const comp of components) {
|
||||
if (skipCanonicalization(comp.metadataId)) continue;
|
||||
|
|
@ -117,6 +144,14 @@ export function buildNetlist(input: BuildNetlistInput): BuildNetlistResult {
|
|||
}
|
||||
|
||||
// ── 5. Board GPIO sources ─────────────────────────────────────────────────
|
||||
// Hyphens are kept in the V-source name (NetlistBuilder regex on line 206
|
||||
// captures `[_\w-]*`), so ngspice itself can parse + load the source and
|
||||
// expose the branch current as `v_<board>-<pin>#branch` — visible in the
|
||||
// canvas voltmeters / branch-current map. BUT ngspice's *interactive*
|
||||
// `alter` command treats `-` as an operator and silently no-ops on
|
||||
// hyphenated source names — making MCU pin transitions stop propagating
|
||||
// after the very first solve. So MixedModeScheduler.onMcuPinChange must
|
||||
// build the same sanitized name we emit here. See sanitizeSpiceId().
|
||||
for (const board of boards) {
|
||||
for (const [pinName, state] of Object.entries(board.pins)) {
|
||||
if (state.type === 'input') continue; // don't drive the pin
|
||||
|
|
@ -124,7 +159,7 @@ export function buildNetlist(input: BuildNetlistInput): BuildNetlistResult {
|
|||
if (!net) continue;
|
||||
if (net === '0' || net === 'vcc_rail') continue; // already served
|
||||
const v = state.type === 'digital' ? state.v : state.duty * board.vcc;
|
||||
cards.push(`V_${board.id}_${pinName} ${net} 0 DC ${v}`);
|
||||
cards.push(`V_${sanitizeSpiceId(board.id)}_${sanitizeSpiceId(pinName)} ${net} 0 DC ${v}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -66,32 +66,32 @@ export function collectPinStates(
|
|||
if (w.end.componentId === boardId) pinNames.add(w.end.pinName);
|
||||
}
|
||||
|
||||
const outputPins = pm.getOutputPins();
|
||||
|
||||
for (const pinName of pinNames) {
|
||||
const arduinoPin = pinNameToArduinoPin(pinName, boardKind);
|
||||
if (arduinoPin < 0) continue;
|
||||
const pwmDuty = pm.getPwmValue(arduinoPin);
|
||||
if (pwmDuty > 0) {
|
||||
result[pinName] = { type: 'pwm', duty: pwmDuty };
|
||||
} else {
|
||||
// ALWAYS emit a digital source — even when the pin is currently
|
||||
// LOW. NetlistBuilder turns this into a `V_<board>_<pin>` card,
|
||||
// and MixedModeScheduler.onMcuPinChange later calls
|
||||
// `alterSource('V_<board>_<pin>', vcc | 0)` on every MCU edge.
|
||||
// If the V-source isn't in the netlist (because we skipped it
|
||||
// here while LOW), the alter is a silent no-op against a name
|
||||
// that doesn't exist; the LED then never lights up no matter
|
||||
// how many times the sketch toggles the pin afterwards.
|
||||
//
|
||||
// This was the root cause of the reported "sometimes the LED
|
||||
// works after Run, sometimes it doesn't" symptom. The behaviour
|
||||
// was deterministic but appeared random because it depended on
|
||||
// whether the AVR happened to be in the HIGH half of its blink
|
||||
// cycle at the moment runSolve() captured pin states.
|
||||
continue;
|
||||
}
|
||||
if (outputPins.has(arduinoPin)) {
|
||||
// The MCU has actually driven this pin at least once
|
||||
// (digitalWrite / PWM / port-listener fire), so emit a V-source
|
||||
// for NetlistBuilder. MixedModeScheduler.onMcuPinChange will
|
||||
// alterSource() on each subsequent edge; the V-source needs to
|
||||
// exist in the netlist for the alter to bind (otherwise it's a
|
||||
// silent no-op and the LED never updates).
|
||||
result[pinName] = {
|
||||
type: 'digital',
|
||||
v: pm.getPinState(arduinoPin) ? vcc : 0,
|
||||
};
|
||||
}
|
||||
// else: leave the net free — external components (sensor divider,
|
||||
// pull-up, button, etc.) drive the SPICE node. Without this guard,
|
||||
// an unconditional V-source at 0 V would short-circuit any analog
|
||||
// sensor on the pin and analogRead() would always return 0.
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -128,9 +128,35 @@ export function connectMcuEdgesToService(service: CircuitSimulationService): ()
|
|||
if (!pinName) continue;
|
||||
if (wanted.size > 0 && !wanted.has(pinName)) continue;
|
||||
const unsub = pm.onPinChange(pin, (_p, state) => {
|
||||
// Suppress digital edges when the pin has active PWM. The OCR-based
|
||||
// PWM duty is converted to a DC-averaged voltage in NetlistBuilder
|
||||
// (`state.duty * board.vcc`), giving smooth analog dimming. If we
|
||||
// also let the Timer1/Timer2-driven port toggles fire alterSource,
|
||||
// each PWM cycle's HIGH/LOW transition would race with the duty
|
||||
// average and force the V-source to bounce between 0 and vcc —
|
||||
// making `analogWrite(pin, 128)` look like a binary blink instead
|
||||
// of a steady 2.5 V (Fade-LED example regression).
|
||||
if (pm.getPwmValue(pin) > 0) return;
|
||||
schedulePin(boardId, pinName, state, vcc);
|
||||
});
|
||||
pinSubs.set(pin, unsub);
|
||||
|
||||
// Re-tick when PWM duty changes so the duty-averaged V-source picks
|
||||
// up new analogWrite values. Without this, duty stays whatever it was
|
||||
// at first solve and `analogWrite()` in a loop never updates the
|
||||
// visible LED. Throttled to ~60 Hz to amortise the netlist-rebuild
|
||||
// cost (the firmware ramps brightness every 30 ms in the canonical
|
||||
// Fade-LED example, well within this budget).
|
||||
let pwmTickPending = false;
|
||||
const unsubPwm = pm.onPwmChange(pin, () => {
|
||||
if (pwmTickPending) return;
|
||||
pwmTickPending = true;
|
||||
setTimeout(() => {
|
||||
pwmTickPending = false;
|
||||
void service.tick();
|
||||
}, 16);
|
||||
});
|
||||
pinSubs.set(pin + 1000, unsubPwm); // key offset to avoid collision
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@
|
|||
*
|
||||
* Phase 1c step G1 of the mixed-mode migration.
|
||||
*/
|
||||
import { useSimulatorStore } from '../../store/useSimulatorStore';
|
||||
import { useSimulatorStore, getBoardPinManager } from '../../store/useSimulatorStore';
|
||||
import { useElectricalStore } from '../../store/useElectricalStore';
|
||||
import {
|
||||
getMixedModeScheduler,
|
||||
|
|
@ -86,22 +86,41 @@ export function startSimulation(): () => void {
|
|||
// to get a snapshot of the simulation state (analysis mode, voltage
|
||||
// count, pin map, last solve time, etc.). Useful for diagnosing
|
||||
// "why is my circuit not solving?" reports from users.
|
||||
(window as unknown as { __spiceDebug?: () => void }).__spiceDebug = () => {
|
||||
(window as unknown as { __spiceDebug?: () => unknown }).__spiceDebug = () => {
|
||||
const electrical = useElectricalStore.getState();
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('[__spiceDebug]', {
|
||||
// Probe: collect every board's outputPins set so we can verify the
|
||||
// MCU-direction-tracking fix from the harness.
|
||||
const outputPinsByBoard: Record<string, number[]> = {};
|
||||
try {
|
||||
const boards = useSimulatorStore.getState().boards;
|
||||
for (const b of boards) {
|
||||
const pm = getBoardPinManager(b.id);
|
||||
if (pm && typeof pm.getOutputPins === 'function') {
|
||||
outputPinsByBoard[b.id] = [...pm.getOutputPins()];
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore — fallback already covered by snapshot fields below
|
||||
}
|
||||
const snapshot = {
|
||||
analysisMode: electrical.analysisMode,
|
||||
converged: electrical.converged,
|
||||
error: electrical.error,
|
||||
lastSolveMs: electrical.lastSolveMs,
|
||||
nodeVoltageCount: Object.keys(electrical.nodeVoltages).length,
|
||||
branchCurrentCount: Object.keys(electrical.branchCurrents).length,
|
||||
branchCurrentNames: Object.keys(electrical.branchCurrents),
|
||||
pinNetMapSize: electrical.pinNetMap.size,
|
||||
pinNetMapEntries: [...electrical.pinNetMap.entries()],
|
||||
nodeVoltages: { ...electrical.nodeVoltages },
|
||||
hasTimeWaveforms: !!electrical.timeWaveforms,
|
||||
paused: electrical.paused,
|
||||
sampleVoltages: Object.entries(electrical.nodeVoltages).slice(0, 8),
|
||||
pinNetSample: [...electrical.pinNetMap.entries()].slice(0, 8),
|
||||
});
|
||||
outputPinsByBoard,
|
||||
};
|
||||
(window as unknown as { __lastSpice?: unknown }).__lastSpice = snapshot;
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('[__spiceDebug]', snapshot);
|
||||
return snapshot;
|
||||
};
|
||||
|
||||
return () => {
|
||||
|
|
|
|||
|
|
@ -969,7 +969,7 @@ export const useSimulatorStore = create<SimulatorState>((set, get) => {
|
|||
bridge.onSerialData = serialCallback;
|
||||
bridge.onPinChange = (gpioPin, state) => {
|
||||
const boardPm = pinManagerMap.get(id);
|
||||
if (boardPm) boardPm.triggerPinChange(gpioPin, state);
|
||||
if (boardPm) boardPm.triggerPinChange(gpioPin, state, 'mcu');
|
||||
};
|
||||
bridge.onCrash = () => {
|
||||
set({ esp32CrashBoardId: id });
|
||||
|
|
@ -1157,19 +1157,6 @@ export const useSimulatorStore = create<SimulatorState>((set, get) => {
|
|||
),
|
||||
}));
|
||||
}
|
||||
if (b.boardOptions || b.spiffsFiles) {
|
||||
set((s) => ({
|
||||
boards: s.boards.map((bb) =>
|
||||
bb.id === b.id
|
||||
? {
|
||||
...bb,
|
||||
boardOptions: b.boardOptions,
|
||||
spiffsFiles: b.spiffsFiles,
|
||||
}
|
||||
: bb,
|
||||
),
|
||||
}));
|
||||
}
|
||||
});
|
||||
|
||||
// Replace editor file groups atomically. Skip groups that already exist
|
||||
|
|
@ -1549,6 +1536,11 @@ export const useSimulatorStore = create<SimulatorState>((set, get) => {
|
|||
getBoardSimulator(boardId)?.stop();
|
||||
}
|
||||
|
||||
// Drop MCU-output classification so the next Run starts clean and
|
||||
// collectPinStates doesn't emit stale V-sources before the new
|
||||
// firmware has driven any pins.
|
||||
getBoardPinManager(boardId)?.resetPinStates();
|
||||
|
||||
set((s) => {
|
||||
const boards = s.boards.map((b) => (b.id === boardId ? { ...b, running: false } : b));
|
||||
const isActive = s.activeBoardId === boardId;
|
||||
|
|
@ -1571,6 +1563,9 @@ export const useSimulatorStore = create<SimulatorState>((set, get) => {
|
|||
const sim = getBoardSimulator(boardId);
|
||||
if (sim) {
|
||||
sim.reset();
|
||||
// Drop MCU-output classification so the new program starts
|
||||
// clean — no stale V-sources from the previous run.
|
||||
getBoardPinManager(boardId)?.resetPinStates();
|
||||
// Re-wire serial callback after reset
|
||||
sim.onSerialData = (ch) => appendSerial(boardId, ch);
|
||||
if (sim instanceof AVRSimulator) {
|
||||
|
|
@ -1637,7 +1632,7 @@ export const useSimulatorStore = create<SimulatorState>((set, get) => {
|
|||
bridge.onSerialData = serialCallback;
|
||||
bridge.onPinChange = (gpioPin, state) => {
|
||||
const boardPm = pinManagerMap.get(boardId);
|
||||
if (boardPm) boardPm.triggerPinChange(gpioPin, state);
|
||||
if (boardPm) boardPm.triggerPinChange(gpioPin, state, 'mcu');
|
||||
};
|
||||
bridge.onCrash = () => {
|
||||
set({ esp32CrashBoardId: boardId });
|
||||
|
|
@ -1739,7 +1734,7 @@ export const useSimulatorStore = create<SimulatorState>((set, get) => {
|
|||
bridge.onSerialData = serialCallback;
|
||||
bridge.onPinChange = (gpioPin, state) => {
|
||||
const boardPm = pinManagerMap.get(boardId);
|
||||
if (boardPm) boardPm.triggerPinChange(gpioPin, state);
|
||||
if (boardPm) boardPm.triggerPinChange(gpioPin, state, 'mcu');
|
||||
};
|
||||
bridge.onCrash = () => {
|
||||
set({ esp32CrashBoardId: boardId });
|
||||
|
|
|
|||
|
|
@ -129,5 +129,5 @@ export const BOARD_KIND_FQBN: Record<BoardKind, string | null> = {
|
|||
'esp32-c3': 'esp32:esp32:esp32c3',
|
||||
'xiao-esp32-c3': 'esp32:esp32:XIAO_ESP32C3',
|
||||
'aitewinrobot-esp32c3-supermini': 'esp32:esp32:esp32c3',
|
||||
attiny85: 'ATTinyCore:avr:attinyx5:chip=85,clock=internal16mhz',
|
||||
attiny85: 'ATTinyCore:avr:attinyx5:chip=85,clock=16pll',
|
||||
};
|
||||
|
|
|
|||
|
|
@ -0,0 +1,297 @@
|
|||
// scripts/visual-led-test.mjs
|
||||
//
|
||||
// Automated visual LED test — drives the running vite + backend stack via
|
||||
// the Chrome DevTools Protocol (CDP). For each named example it:
|
||||
//
|
||||
// 1. Navigates to /example/<slug>
|
||||
// 2. Clicks the Run button (auto-compiles + starts the sim)
|
||||
// 3. Waits up to `simulateMs` for the LED brightness to swing
|
||||
// 4. Asserts: `wokwi-led.brightness > 0` AT LEAST ONCE (LED lights up)
|
||||
// 5. Asserts: `wokwi-led.brightness == 0` AT LEAST ONCE (LED toggles off)
|
||||
// — this catches "stuck on" bugs the snapshot tests miss.
|
||||
//
|
||||
// Why this matters: unit tests + snapshot tests check static structure
|
||||
// (netlist string, SPICE cards). They don't run firmware, don't render
|
||||
// LEDs, and don't observe whether `wokwi-led.brightness` ever moves. Every
|
||||
// LED-related bug in the recent sprint (INPUT_PULLUP false positive,
|
||||
// hyphen-truncated V-source, missing pushbutton init, ESP32-C3 compile
|
||||
// failure) shipped past green unit tests and was only caught by manual
|
||||
// browser checks. This script makes that check repeatable.
|
||||
//
|
||||
// Prerequisites:
|
||||
// - Chrome running with `--remote-debugging-port=9222`
|
||||
// - `vite` on http://localhost:5174
|
||||
// - `uvicorn app.main:app --port 8001` with arduino-cli + cores
|
||||
//
|
||||
// Usage:
|
||||
// node scripts/visual-led-test.mjs # default suite
|
||||
// node scripts/visual-led-test.mjs blink-led # single example
|
||||
// FRONTEND=http://localhost:5175 node scripts/visual-led-test.mjs
|
||||
//
|
||||
// Exits 0 on PASS, 1 on FAIL.
|
||||
|
||||
const CDP_HTTP = process.env.CDP_HTTP || 'http://localhost:9222';
|
||||
const FRONTEND = process.env.FRONTEND || 'http://localhost:5174';
|
||||
|
||||
// Each entry: { slug, label?, simulateMs?, expectToggle?, allowAlwaysOn? }
|
||||
// expectToggle (default true) — require BOTH a lit sample AND a dark
|
||||
// sample. Disable for "permanently on" sketches like sensor read-outs.
|
||||
// allowAlwaysOn — accept "lit but never off" as a pass (still requires lit).
|
||||
// simulateMs — total observation window after Run (default 12000).
|
||||
const DEFAULT_SUITE = [
|
||||
{ slug: 'blink-led', label: 'Blink (AVR digitalWrite)', simulateMs: 12000 },
|
||||
{ slug: 'button-led', label: 'Button + INPUT_PULLUP', simulateMs: 6000,
|
||||
expectInitialOff: true, expectLitAfterRun: false,
|
||||
note: 'LED MUST be off idle AND stay off without button press — catches INPUT_PULLUP false positive' },
|
||||
{ slug: 'traffic-light', label: 'Multi-pin sequencing', simulateMs: 14000 },
|
||||
{ slug: 'fade-led', label: 'PWM fade gradient', simulateMs: 14000,
|
||||
expectGradient: true,
|
||||
note: 'Brightness must hit 3+ distinct non-zero values (smooth fade)' },
|
||||
{ slug: 'rgb-led', label: 'RGB 3-PWM driven', simulateMs: 12000,
|
||||
leafCheck: 'pwmActive' },
|
||||
];
|
||||
|
||||
// ── CDP plumbing ──────────────────────────────────────────────────────────
|
||||
|
||||
async function getPages() {
|
||||
const res = await fetch(`${CDP_HTTP}/json`);
|
||||
return await res.json();
|
||||
}
|
||||
|
||||
class CDP {
|
||||
constructor(wsUrl) {
|
||||
this.ws = new WebSocket(wsUrl);
|
||||
this.id = 0;
|
||||
this.pending = new Map();
|
||||
this.events = new Map();
|
||||
this.ready = new Promise((resolve, reject) => {
|
||||
this.ws.addEventListener('open', () => resolve(), { once: true });
|
||||
this.ws.addEventListener('error', (e) => reject(e), { once: true });
|
||||
});
|
||||
this.ws.addEventListener('message', (ev) => {
|
||||
const msg = JSON.parse(ev.data.toString());
|
||||
if (msg.id != null) {
|
||||
const p = this.pending.get(msg.id);
|
||||
if (p) {
|
||||
this.pending.delete(msg.id);
|
||||
if (msg.error) p.reject(new Error(msg.error.message));
|
||||
else p.resolve(msg.result);
|
||||
}
|
||||
} else if (msg.method) {
|
||||
const handlers = this.events.get(msg.method);
|
||||
if (handlers) handlers.forEach((h) => h(msg.params));
|
||||
}
|
||||
});
|
||||
}
|
||||
send(method, params = {}) {
|
||||
const id = ++this.id;
|
||||
return new Promise((resolve, reject) => {
|
||||
this.pending.set(id, { resolve, reject });
|
||||
this.ws.send(JSON.stringify({ id, method, params }));
|
||||
});
|
||||
}
|
||||
async eval(expression, { awaitPromise = false } = {}) {
|
||||
const r = await this.send('Runtime.evaluate', {
|
||||
expression, returnByValue: true, awaitPromise,
|
||||
});
|
||||
if (r.exceptionDetails) {
|
||||
throw new Error(
|
||||
r.exceptionDetails.exception?.description ||
|
||||
r.exceptionDetails.text || 'eval failed',
|
||||
);
|
||||
}
|
||||
return r.result?.value;
|
||||
}
|
||||
close() { this.ws.close(); }
|
||||
}
|
||||
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
// ── Per-example runner ────────────────────────────────────────────────────
|
||||
|
||||
async function runOne(cdp, ex) {
|
||||
const simulateMs = ex.simulateMs ?? 12000;
|
||||
await cdp.send('Page.navigate', { url: `${FRONTEND}/example/${ex.slug}` });
|
||||
// Wait for editor + Run button.
|
||||
const waitForEditor = async () => {
|
||||
for (let i = 0; i < 60; i++) {
|
||||
const has = await cdp.eval(`
|
||||
Array.from(document.querySelectorAll('button')).some(b =>
|
||||
/^Run/.test(b.getAttribute('title') || ''))
|
||||
`);
|
||||
if (has) return true;
|
||||
await sleep(250);
|
||||
}
|
||||
return false;
|
||||
};
|
||||
if (!(await waitForEditor())) return { slug: ex.slug, fail: 'editor never loaded' };
|
||||
|
||||
// Optional pre-Run check — for examples like button-led where the LED
|
||||
// MUST be off until interacted with.
|
||||
let preRunBrightness = null;
|
||||
if (ex.expectInitialOff) {
|
||||
// Give SPICE one pass.
|
||||
await sleep(2000);
|
||||
preRunBrightness = await cdp.eval(`
|
||||
(() => {
|
||||
const led = document.querySelector('wokwi-led');
|
||||
return led?.brightness ?? null;
|
||||
})()
|
||||
`);
|
||||
}
|
||||
|
||||
await cdp.eval(`
|
||||
Array.from(document.querySelectorAll('button')).find(b =>
|
||||
/^Run/.test(b.getAttribute('title') || ''))?.click()
|
||||
`);
|
||||
|
||||
// Poll LED brightness every 250 ms for simulateMs.
|
||||
const samples = [];
|
||||
const steps = Math.ceil(simulateMs / 250);
|
||||
for (let i = 0; i < steps; i++) {
|
||||
const reading = await cdp.eval(`
|
||||
(() => {
|
||||
const leds = Array.from(document.querySelectorAll('wokwi-led'));
|
||||
return leds.map(el => ({
|
||||
id: el.id,
|
||||
brightness: el.brightness,
|
||||
value: el.value,
|
||||
}));
|
||||
})()
|
||||
`);
|
||||
samples.push({ t: i * 250, leds: reading });
|
||||
await sleep(250);
|
||||
}
|
||||
|
||||
// ── Assertions ──────────────────────────────────────────────────────────
|
||||
const result = { slug: ex.slug, label: ex.label, preRunBrightness, samples: samples.length };
|
||||
|
||||
if (ex.leafCheck === 'pwmActive') {
|
||||
// No wokwi-led for RGB — check SPICE outputPins via __spiceDebug.
|
||||
const snap = await cdp.eval(`
|
||||
(() => {
|
||||
const s = window.__spiceDebug?.();
|
||||
return s ? { outputPins: s.outputPinsByBoard, branches: s.branchCurrentNames } : null;
|
||||
})()
|
||||
`);
|
||||
const pinCount = snap?.outputPins
|
||||
? Object.values(snap.outputPins).reduce((n, arr) => n + arr.length, 0) : 0;
|
||||
result.outputPinCount = pinCount;
|
||||
if (pinCount < 3) result.fail = `expected ≥3 pins driven (RGB), got ${pinCount}`;
|
||||
return result;
|
||||
}
|
||||
|
||||
// Standard LED assertions.
|
||||
if (samples[0].leds.length === 0) {
|
||||
// No wokwi-led on canvas — only validate SPICE pin activity.
|
||||
const snap = await cdp.eval(`
|
||||
(() => { const s = window.__spiceDebug?.(); return s ? s.outputPinsByBoard : null; })()
|
||||
`);
|
||||
const pinCount = snap ? Object.values(snap).reduce((n, arr) => n + arr.length, 0) : 0;
|
||||
result.noLed = true;
|
||||
result.outputPinCount = pinCount;
|
||||
if (pinCount === 0) result.fail = 'no LED and no MCU pins driven';
|
||||
return result;
|
||||
}
|
||||
|
||||
const ledId = samples[0].leds[0].id;
|
||||
const bvals = samples.map(s => s.leds.find(l => l.id === ledId)?.brightness ?? 0);
|
||||
const maxB = Math.max(...bvals);
|
||||
const minB = Math.min(...bvals);
|
||||
const distinct = new Set(bvals.map(b => Math.round(b * 100))).size;
|
||||
result.led = ledId;
|
||||
result.maxBrightness = Math.round(maxB * 100) / 100;
|
||||
result.minBrightness = Math.round(minB * 100) / 100;
|
||||
result.distinctLevels = distinct;
|
||||
|
||||
if (ex.expectInitialOff && preRunBrightness != null && preRunBrightness > 0.05) {
|
||||
result.fail = `pre-Run LED brightness ${preRunBrightness.toFixed(2)} — INPUT_PULLUP false positive?`;
|
||||
return result;
|
||||
}
|
||||
|
||||
// expectLitAfterRun:false → button-led style: LED must stay dark without
|
||||
// user interaction. PASS if LED stays under 0.05. FAIL only if it lights
|
||||
// up (which would mean the firmware is misreading the pin).
|
||||
if (ex.expectLitAfterRun === false) {
|
||||
if (maxB > 0.05) {
|
||||
result.fail = `LED lit without user interaction (max=${maxB.toFixed(2)}) — firmware likely reading wrong pin state`;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
if (maxB < 0.05) {
|
||||
result.fail = `LED never lit (max brightness ${maxB.toFixed(3)})`;
|
||||
return result;
|
||||
}
|
||||
|
||||
if (ex.expectGradient) {
|
||||
// Smooth fade: require ≥3 distinct brightness buckets across the run.
|
||||
if (distinct < 3) {
|
||||
result.fail = `PWM fade only ${distinct} distinct level(s) — expected smooth gradient (≥3)`;
|
||||
return result;
|
||||
}
|
||||
} else if (ex.expectToggle !== false && !ex.allowAlwaysOn) {
|
||||
// Toggle: require both a lit AND a dark sample.
|
||||
if (minB > 0.05) {
|
||||
result.fail = `LED never went dark (min brightness ${minB.toFixed(3)}) — stuck on?`;
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// ── Driver ────────────────────────────────────────────────────────────────
|
||||
|
||||
async function main() {
|
||||
const args = process.argv.slice(2);
|
||||
const suite = args.length
|
||||
? args.map(slug => DEFAULT_SUITE.find(e => e.slug === slug) ?? { slug })
|
||||
: DEFAULT_SUITE;
|
||||
|
||||
const pages = await getPages();
|
||||
const target = pages.find(p => p.type === 'page' && p.url.startsWith('http'));
|
||||
if (!target) {
|
||||
console.error('[visual-led] No usable Chrome tab. Open one and re-run.');
|
||||
process.exit(2);
|
||||
}
|
||||
const cdp = new CDP(target.webSocketDebuggerUrl);
|
||||
await cdp.ready;
|
||||
await cdp.send('Page.enable');
|
||||
await cdp.send('Runtime.enable');
|
||||
|
||||
let failed = 0;
|
||||
for (const ex of suite) {
|
||||
process.stdout.write(`\n[${ex.slug}] ${ex.label || ''}\n`);
|
||||
try {
|
||||
const r = await runOne(cdp, ex);
|
||||
if (r.fail) {
|
||||
console.log(` FAIL — ${r.fail}`);
|
||||
console.log(` ${JSON.stringify({ maxB: r.maxBrightness, minB: r.minBrightness, distinct: r.distinctLevels, pre: r.preRunBrightness })}`);
|
||||
failed++;
|
||||
} else {
|
||||
let detail;
|
||||
if (r.outputPinCount != null && r.maxBrightness == null) {
|
||||
detail = `no canvas LED, ${r.outputPinCount} pin(s) driven`;
|
||||
} else if (r.outputPinCount != null) {
|
||||
detail = `${r.outputPinCount} pin(s) driven, max=${r.maxBrightness} min=${r.minBrightness}`;
|
||||
} else {
|
||||
detail = `max=${r.maxBrightness} min=${r.minBrightness} levels=${r.distinctLevels}`;
|
||||
}
|
||||
console.log(` PASS — ${detail}`);
|
||||
}
|
||||
} catch (e) {
|
||||
console.log(` ERROR — ${e.message}`);
|
||||
failed++;
|
||||
}
|
||||
}
|
||||
|
||||
cdp.close();
|
||||
console.log(`\n${suite.length - failed}/${suite.length} examples passed.`);
|
||||
process.exit(failed > 0 ? 1 : 0);
|
||||
}
|
||||
|
||||
main().catch(err => {
|
||||
console.error('[visual-led] fatal:', err);
|
||||
process.exit(3);
|
||||
});
|
||||
Loading…
Reference in New Issue