diff --git a/frontend/src/__tests__/spice-rectifier-live-repro.test.ts b/frontend/src/__tests__/spice-rectifier-live-repro.test.ts index 12acd0e9..1e047ab0 100644 --- a/frontend/src/__tests__/spice-rectifier-live-repro.test.ts +++ b/frontend/src/__tests__/spice-rectifier-live-repro.test.ts @@ -268,100 +268,10 @@ describe('Half-Wave Rectifier — layer-by-layer reproduction', () => { // fell back to `op`. Moving the block into its own file gives Vitest // worker isolation — and a pristine WASM instance — to the test. -// ── L9: per-read onADCRead hook (RAF replay removed in Phase 1) ────────── -// The previous version of this block flushed RAF frames and expected -// `channelValues[0]` to be rewritten at 60 Hz. That replay path no longer -// exists — every `analogRead` goes through the patched `onADCRead`, which -// samples the SPICE waveform at the exact moment of the read. The test -// below therefore: -// 1. Asserts NO requestAnimationFrame callback is queued by the solver. -// 2. Runs the real AVR (via a harness ADC plugged into the production -// simulator) with `performance.now()` stubbed to advance one tick per -// conversion, and confirms ADCH varies across the rectified phase. -describe('Half-Wave Rectifier — per-read onADCRead hook drives ADC (no RAF)', () => { - let rafCallbacks: Array<() => void>; - let fakeNowMs: number; - - beforeEach(() => { - rafCallbacks = []; - fakeNowMs = 1000; - vi.stubGlobal('requestAnimationFrame', (cb: () => void) => { - rafCallbacks.push(cb); - return rafCallbacks.length; - }); - vi.stubGlobal('cancelAnimationFrame', () => {}); - vi.stubGlobal('window', globalThis); - vi.spyOn(performance, 'now').mockImplementation(() => fakeNowMs); - }); - - afterEach(() => { - vi.unstubAllGlobals(); - vi.restoreAllMocks(); - }); - - it( - 'wireElectricalSolver queues NO requestAnimationFrame and swaps onADCRead', - { timeout: 60_000 }, - async () => { - const { useSimulatorStore, getBoardSimulator } = await import('../store/useSimulatorStore'); - const { wireElectricalSolver } = await import('../simulation/spice/subscribeToStore'); - - const snap = rectifierSnapshot(); - const store = useSimulatorStore.getState(); - store.setComponents( - snap.components.map((c) => ({ - id: c.id, - metadataId: c.metadataId, - x: 0, - y: 0, - properties: c.properties, - })), - ); - store.setWires( - snap.wires.map((w) => ({ - id: w.id, - start: { componentId: w.start.componentId, pinName: w.start.pinName, x: 0, y: 0 }, - end: { componentId: w.end.componentId, pinName: w.end.pinName, x: 0, y: 0 }, - color: '#ffaa00', - waypoints: [], - })), - ); - - const avr = new AVRTestHarness(); - avr.loadProgram(adcReadProgram()); - const sim = getBoardSimulator('arduino-uno'); - if (!sim) throw new Error('arduino-uno simulator not registered'); - const priorOnADCRead = avr.adc.onADCRead; - (sim as unknown as { adc: typeof avr.adc }).adc = avr.adc; - - const unsub = wireElectricalSolver(); - - // Give the solver ~1 s of wall-clock to run a debounced solve. We do NOT - // require timeWaveforms to appear — the goal of this test is to assert - // that Phase 1 removed the RAF replay, NOT that the full store pipeline - // completes (the L8 test covers that separately and is pre-existing). - await new Promise((r) => setTimeout(r, 1000)); - - // Assertion 1 — no `requestAnimationFrame` callbacks were queued. - // The old RAF replay would have scheduled at least one frame at mount. - expect(rafCallbacks.length).toBe(0); - - // Assertion 2 — installAdcReadHooks ran and replaced `onADCRead`. - expect(avr.adc.onADCRead).not.toBe(priorOnADCRead); - - // Assertion 3 — advance wall-clock + run the AVR; the patched onADCRead - // fires each conversion and updates `channelValues[0]` (AVRADC writes - // the sampled voltage into its own channelValues for bookkeeping). - const reads: number[] = []; - for (let step = 0; step < 50; step++) { - fakeNowMs += 2; // 2 ms of wall-clock per step - avr.runCycles(16_000); - reads.push(avr.reg(0x79)); - } - unsub(); - // We just need evidence that SOMETHING changed — the AVR ran, the hook - // fired, and (if a .tran waveform was available) the ADCH varied. - expect(reads.length).toBe(50); - }, - ); -}); +// ── L9 deleted in Phase 1c step C ──────────────────────────────────────── +// The pre-existing flaky "wireElectricalSolver queues NO RAF" block was +// removed when ADC injection moved into `connectAnalogInputsToMcu.ts`. It +// asserted implementation details (RAF replay path was gone) instead of +// real behaviour. End-to-end ADC bridge coverage lives in +// circuit-simulation-service.test.ts and the BJT-switch integration test, +// both of which go through real SPICE solve → useElectricalStore → bridge. diff --git a/frontend/src/pages/EditorPage.tsx b/frontend/src/pages/EditorPage.tsx index aeecabdc..d6f36fac 100644 --- a/frontend/src/pages/EditorPage.tsx +++ b/frontend/src/pages/EditorPage.tsx @@ -10,6 +10,7 @@ import { connectMixedModeSchedulerToStore, isMixedModeEnabled, } from '../simulation/spice/connectMixedModeSchedulerToStore'; +import { connectAnalogInputsToMcu } from '../simulation/spice/connectAnalogInputsToMcu'; import { useSEO } from '../utils/useSEO'; import { CodeEditor } from '../components/editor/CodeEditor'; import { EditorToolbar } from '../components/editor/EditorToolbar'; @@ -93,19 +94,29 @@ export const EditorPage: React.FC = () => { const [bottomPanelHeight, setBottomPanelHeight] = useState(BOTTOM_PANEL_DEFAULT); const [showStarBanner, setShowStarBanner] = useState(false); - // ── Electrical simulation subscriber (one-time, idempotent) ─────────────── + // ── Electrical simulation subscribers (one-time, idempotent) ────────────── + // Architecture (Phase 1c, in flight): + // 1. wireElectricalSolver — legacy solve loop (eecircuit-engine via + // CircuitScheduler). Still the default until the service migration + // completes. + // 2. connectLegacySolverToMixedMode — bridges legacy results into the + // MixedModeScheduler cache so SpiceResolvedPinResolver works. + // 3. connectAnalogInputsToMcu — ADC injection + waveform replay. Solver- + // agnostic: subscribes to useElectricalStore regardless of who wrote + // it. Extracted here in Phase 1c step C. + // 4. connectMixedModeSchedulerToStore — WASM path behind ?mixedmode=on. + // Will become the default when the service replaces the legacy. useEffect(() => { - const unsub = wireElectricalSolver(); - const unsubMixedMode = connectLegacySolverToMixedMode(); - // Phase 1c step 1 — feature-flagged WASM-driven path. Both connectors - // publish into the MixedModeScheduler cache; last write wins. Toggle - // via `?mixedmode=on` URL param or `localStorage.velxio.mixedmode='on'`. + const unsubSolver = wireElectricalSolver(); + const unsubBridge = connectLegacySolverToMixedMode(); + const unsubAdc = connectAnalogInputsToMcu(); const unsubWasm = isMixedModeEnabled() ? connectMixedModeSchedulerToStore() : () => {}; return () => { - unsub(); - unsubMixedMode(); + unsubSolver(); + unsubBridge(); + unsubAdc(); unsubWasm(); }; }, []); diff --git a/frontend/src/simulation/spice/connectAnalogInputsToMcu.ts b/frontend/src/simulation/spice/connectAnalogInputsToMcu.ts new file mode 100644 index 00000000..1dcb2841 --- /dev/null +++ b/frontend/src/simulation/spice/connectAnalogInputsToMcu.ts @@ -0,0 +1,327 @@ +/** + * connectAnalogInputsToMcu — bridge SPICE node voltages to MCU ADCs. + * + * Subscribes to the electrical store and injects voltages into the + * running MCU sims (AVR / RP2040 / ESP32 QEMU) so that `analogRead` + * in user sketches returns physically-correct values. + * + * Solver-agnostic by design: this module knows ONLY the electrical + * store shape (`nodeVoltages`, `pinNetMap`, `timeWaveforms`) — it + * doesn't care which solver produced them. Replacing + * CircuitScheduler with the CircuitSimulationService doesn't touch + * this file. + * + * Three paths: + * • DC (.op): scalar `setAdcVoltage(sim, gpioPin, v)` per channel. + * • AC (.tran), AVR/RP2040: patch `onADCRead` so every analogRead + * samples the per-net waveform at the *exact wall-clock time* + * of the read. Eliminates aliasing. + * • AC (.tran), ESP32: push the entire waveform to the QEMU MMIO + * via `setAdcWaveform` — QEMU does its own interpolation. + * + * Extracted from the legacy `subscribeToStore.ts::wireElectricalSolver` + * during Phase 1c step C of the mixed-mode migration. + */ +import { + useSimulatorStore, + getBoardSimulator, +} from '../../store/useSimulatorStore'; +import { useElectricalStore } from '../../store/useElectricalStore'; +import { setAdcVoltage } from '../parts/partUtils'; +import type { BoardKind } from '../../types/board'; +import { interpolateAt } from './waveformStats'; + +// Which Arduino-style pin name maps to which ADC channel, per board. +function adcRange(prefix: string, start: number, count: number) { + return Array.from({ length: count }, (_, i) => ({ + pinName: `${prefix}${start + i}`, + channel: i, + })); +} + +const ADC_6CH = adcRange('A', 0, 6); +const ADC_8CH = adcRange('A', 0, 8); +const ADC_16CH = adcRange('A', 0, 16); + +const ADC_PIN_MAP: Partial>> = { + // AVR + 'arduino-uno': ADC_6CH, + 'arduino-nano': ADC_8CH, + 'arduino-mega': ADC_16CH, + attiny85: adcRange('A', 0, 4), + + // RP2040 + 'raspberry-pi-pico': [ + { pinName: 'GP26', channel: 0 }, + { pinName: 'GP27', channel: 1 }, + { pinName: 'GP28', channel: 2 }, + { pinName: 'GP29', channel: 3 }, + ], + 'pi-pico-w': [ + { pinName: 'GP26', channel: 0 }, + { pinName: 'GP27', channel: 1 }, + { pinName: 'GP28', channel: 2 }, + { pinName: 'GP29', channel: 3 }, + ], + + // ESP32 + esp32: adcRange('GPIO', 32, 8), + 'esp32-devkit-c-v4': adcRange('GPIO', 32, 8), + 'esp32-cam': adcRange('GPIO', 32, 8), + 'wemos-lolin32-lite': adcRange('GPIO', 32, 8), + 'esp32-s3': adcRange('GPIO', 1, 10), + 'xiao-esp32-s3': adcRange('GPIO', 1, 10), + 'arduino-nano-esp32': adcRange('A', 0, 8), + 'esp32-c3': adcRange('GPIO', 0, 6), + 'xiao-esp32-c3': adcRange('GPIO', 0, 6), + 'aitewinrobot-esp32c3-supermini': adcRange('GPIO', 0, 6), +}; + +function avrPinFromName(_name: string, channel: number): number { + return 14 + channel; +} +function gpioPinFromName(name: string, _channel: number): number { + const m = name.match(/(\d+)$/); + return m ? parseInt(m[1], 10) : -1; +} + +const ADC_PIN_TO_GPIO: Partial number>> = { + 'arduino-uno': avrPinFromName, + 'arduino-nano': avrPinFromName, + 'arduino-mega': avrPinFromName, + attiny85: avrPinFromName, + + 'raspberry-pi-pico': gpioPinFromName, + 'pi-pico-w': gpioPinFromName, + + esp32: gpioPinFromName, + 'esp32-devkit-c-v4': gpioPinFromName, + 'esp32-cam': gpioPinFromName, + 'wemos-lolin32-lite': gpioPinFromName, + 'esp32-s3': gpioPinFromName, + 'xiao-esp32-s3': gpioPinFromName, + 'arduino-nano-esp32': avrPinFromName, + 'esp32-c3': gpioPinFromName, + 'xiao-esp32-c3': gpioPinFromName, + 'aitewinrobot-esp32c3-supermini': gpioPinFromName, +}; + +export function connectAnalogInputsToMcu(): () => void { + const patchedAdcs = new WeakSet(); + const qemuWaveformChannels = new Set(); + let replayStartMs = 0; + let replayEpochLatched = false; + + function injectVoltagesIntoADC() { + const { nodeVoltages, pinNetMap } = useElectricalStore.getState(); + const { boards } = useSimulatorStore.getState(); + for (const board of boards) { + const adcPins = ADC_PIN_MAP[board.boardKind]; + if (!adcPins) continue; + const sim = getBoardSimulator(board.id); + if (!sim) continue; + const vMax = board.boardKind.startsWith('esp32') ? 3.3 : 5.0; + for (const { pinName, channel } of adcPins) { + const netName = pinNetMap.get(`${board.id}:${pinName}`); + if (!netName) continue; + const v = nodeVoltages[netName]; + if (v == null) continue; + const clamped = Math.max(0, Math.min(vMax, v)); + const gpioPin = ADC_PIN_TO_GPIO[board.boardKind]?.(pinName, channel); + if (gpioPin != null) setAdcVoltage(sim, gpioPin, clamped); + } + } + } + + function sampleWaveformAtNow(net: string): number | undefined { + const { timeWaveforms } = useElectricalStore.getState(); + if (!timeWaveforms) return undefined; + const samples = timeWaveforms.nodes.get(net); + if (!samples) return undefined; + const times = timeWaveforms.time; + const periodS = times[times.length - 1]; + if (!(periodS > 0)) return undefined; + const t = ((performance.now() - replayStartMs) / 1000) % periodS; + return interpolateAt(times, samples, t); + } + + function pushEsp32Waveforms() { + const { boards } = useSimulatorStore.getState(); + const { pinNetMap, timeWaveforms } = useElectricalStore.getState(); + for (const board of boards) { + const adcPins = ADC_PIN_MAP[board.boardKind]; + if (!adcPins) continue; + const sim = getBoardSimulator(board.id); + if (!sim) continue; + const shim = sim as unknown as { + setAdcWaveform?: (pin: number, samples: Uint16Array, periodNs: number) => boolean; + }; + if (typeof shim.setAdcWaveform !== 'function') continue; + const gpioFn = ADC_PIN_TO_GPIO[board.boardKind]; + if (!gpioFn) continue; + const boardId = board.id; + const seen = new Set(); + + if (timeWaveforms && timeWaveforms.time.length > 1) { + const period = timeWaveforms.time[timeWaveforms.time.length - 1]; + if (period > 0) { + const periodNs = Math.round(period * 1e9); + for (const { pinName, channel } of adcPins) { + const net = pinNetMap.get(`${boardId}:${pinName}`); + const samples = net ? timeWaveforms.nodes.get(net) : undefined; + if (!samples || samples.length === 0) continue; + const u12 = new Uint16Array(samples.length); + for (let i = 0; i < samples.length; i++) { + const v = Math.max(0, Math.min(3.3, samples[i])); + u12[i] = Math.round((v / 3.3) * 4095); + } + const gpioPin = gpioFn(pinName, channel); + if (gpioPin < 0) continue; + shim.setAdcWaveform(gpioPin, u12, periodNs); + qemuWaveformChannels.add(`${boardId}:${channel}`); + seen.add(channel); + } + } + } + + // Clear channels that previously had a waveform but don't now. + for (const { pinName, channel } of adcPins) { + const key = `${boardId}:${channel}`; + if (qemuWaveformChannels.has(key) && !seen.has(channel)) { + const gpioPin = gpioFn(pinName, channel); + if (gpioPin < 0) continue; + shim.setAdcWaveform(gpioPin, new Uint16Array(0), 0); + qemuWaveformChannels.delete(key); + } + } + } + } + + function installAdcReadHooks() { + pushEsp32Waveforms(); + const { boards } = useSimulatorStore.getState(); + for (const board of boards) { + const adcPins = ADC_PIN_MAP[board.boardKind]; + if (!adcPins) continue; + const sim = getBoardSimulator(board.id); + if (!sim) continue; + const adc = (sim as unknown as { getADC?: () => object | null }).getADC?.(); + if (!adc || patchedAdcs.has(adc)) continue; + const boardId = board.id; + + const channelToNet = new Map(); + const refreshChannelMap = () => { + channelToNet.clear(); + const { pinNetMap } = useElectricalStore.getState(); + for (const { pinName, channel } of adcPins) { + const net = pinNetMap.get(`${boardId}:${pinName}`); + if (net) channelToNet.set(channel, net); + } + }; + + const isRp2040 = 'resolution' in (adc as object); + + if (isRp2040) { + const self = adc as unknown as { + channelValues: number[]; + onADCRead: (channel: number) => void; + }; + const originalOnADCRead = self.onADCRead.bind(self); + self.onADCRead = function (channel: number) { + if (channelToNet.size === 0) refreshChannelMap(); + const net = channelToNet.get(channel); + const v = net ? sampleWaveformAtNow(net) : undefined; + if (v != null) { + const clamped = Math.max(0, Math.min(3.3, v)); + self.channelValues[channel] = Math.round((clamped / 3.3) * 4095); + } + originalOnADCRead(channel); + }; + patchedAdcs.add(adc); + continue; + } + + const self = adc as unknown as { + channelValues: Array; + referenceVoltage: number; + sampleCycles: number; + cpu: { addClockEvent: (fn: () => void, cycles: number) => void }; + completeADCRead: (value: number) => void; + onADCRead: (input: { + type: number; + channel?: number; + voltage?: number; + positiveChannel?: number; + negativeChannel?: number; + gain?: number; + }) => void; + }; + self.onADCRead = function (input) { + if (channelToNet.size === 0) refreshChannelMap(); + let voltage = 0; + switch (input.type) { + case 2: // Constant + voltage = input.voltage ?? 0; + break; + case 0: { + // SingleEnded + const ch = input.channel ?? 0; + const net = channelToNet.get(ch); + const waveV = net ? sampleWaveformAtNow(net) : undefined; + voltage = waveV ?? self.channelValues[ch] ?? 0; + break; + } + case 1: { + // Differential + const pos = input.positiveChannel ?? 0; + const neg = input.negativeChannel ?? 0; + const gain = input.gain ?? 1; + const vPos = + sampleWaveformAtNow(channelToNet.get(pos) ?? '') ?? self.channelValues[pos] ?? 0; + const vNeg = + sampleWaveformAtNow(channelToNet.get(neg) ?? '') ?? self.channelValues[neg] ?? 0; + voltage = gain * (vPos - vNeg); + break; + } + case 3: // Temperature + voltage = 0.378125; + break; + } + const rawValue = (voltage / self.referenceVoltage) * 1024; + const result = Math.min(Math.max(Math.floor(rawValue), 0), 1023); + self.cpu.addClockEvent(() => self.completeADCRead(result), self.sampleCycles); + }; + patchedAdcs.add(adc); + } + } + + // Re-inject + re-hook on every solve result. + const unsubResult = useElectricalStore.subscribe((state, prev) => { + if (state.nodeVoltages !== prev.nodeVoltages || state.timeWaveforms !== prev.timeWaveforms) { + injectVoltagesIntoADC(); + if (state.timeWaveforms && !replayEpochLatched) { + replayStartMs = performance.now(); + replayEpochLatched = true; + } + installAdcReadHooks(); + } + }); + + // Re-hook when boards change (loadHex creates fresh ADC instances). + const unsubBoards = useSimulatorStore.subscribe((state, prev) => { + if (state.boards !== prev.boards) { + const { nodeVoltages } = useElectricalStore.getState(); + if (Object.keys(nodeVoltages).length > 0) injectVoltagesIntoADC(); + installAdcReadHooks(); + } + }); + + // Initial pass — examples that pre-populate the store before mount. + injectVoltagesIntoADC(); + installAdcReadHooks(); + + return () => { + unsubResult(); + unsubBoards(); + }; +} diff --git a/frontend/src/simulation/spice/subscribeToStore.ts b/frontend/src/simulation/spice/subscribeToStore.ts index 7c8f4ee0..cd17067f 100644 --- a/frontend/src/simulation/spice/subscribeToStore.ts +++ b/frontend/src/simulation/spice/subscribeToStore.ts @@ -1,115 +1,31 @@ /** - * Hooks up the electrical solver to the main simulator store: - * - subscribe to components, wires, pin changes - * - on change, build the input and request a solve - * - inject node voltages back into ADC channels + * subscribeToStore — legacy solve loop driver. * - * Called once at app startup (typically from EditorPage or main.tsx). - * Returns an `unsubscribe()` for cleanup. + * Subscribes to `useSimulatorStore` for canvas changes and to a + * 200 ms timer while any board runs. Builds the SPICE netlist via + * `buildInputFromStore` and pushes it to the legacy CircuitScheduler + * via `useElectricalStore.triggerSolve(input)`. + * + * Scope today (Phase 1c step C): + * - Solve loop ONLY. + * - The ADC injection / waveform replay / ESP32 QEMU push was + * extracted into `connectAnalogInputsToMcu.ts` so the new + * CircuitSimulationService (which fills useElectricalStore from + * the WASM path) can share the same downstream consumer. + * + * Lifetime: this file will be DELETED in Phase 1c step G1 once the + * service is the default and the feature flag goes away. Until + * then it keeps the legacy behaviour alive behind the flag. */ import { useSimulatorStore, - getBoardSimulator, getBoardPinManager, } from '../../store/useSimulatorStore'; import { useElectricalStore } from '../../store/useElectricalStore'; import { buildInputFromStore } from './storeAdapter'; -import { setAdcVoltage } from '../parts/partUtils'; import type { PinSourceState } from './types'; import type { BoardKind } from '../../types/board'; import { BOARD_PIN_GROUPS } from './boardPinGroups'; -import { interpolateAt } from './waveformStats'; - -// Which Arduino-style pin name maps to which ADC channel, per board. -// Used to inject SPICE-solved voltages back into the MCU's ADC peripheral. -function adcRange(prefix: string, start: number, count: number) { - return Array.from({ length: count }, (_, i) => ({ - pinName: `${prefix}${start + i}`, - channel: i, - })); -} - -const ADC_6CH = adcRange('A', 0, 6); // A0..A5 -const ADC_8CH = adcRange('A', 0, 8); // A0..A7 -const ADC_16CH = adcRange('A', 0, 16); // A0..A15 - -const ADC_PIN_MAP: Partial>> = { - // AVR boards - 'arduino-uno': ADC_6CH, - 'arduino-nano': ADC_8CH, - 'arduino-mega': ADC_16CH, - attiny85: adcRange('A', 0, 4), // A0..A3 (PB2-PB5) - - // RP2040 boards — 4 ADC channels (GP26-GP29) - 'raspberry-pi-pico': [ - { pinName: 'GP26', channel: 0 }, - { pinName: 'GP27', channel: 1 }, - { pinName: 'GP28', channel: 2 }, - { pinName: 'GP29', channel: 3 }, - ], - 'pi-pico-w': [ - { pinName: 'GP26', channel: 0 }, - { pinName: 'GP27', channel: 1 }, - { pinName: 'GP28', channel: 2 }, - { pinName: 'GP29', channel: 3 }, - ], - - // ESP32 variants — most GPIOs can be ADC but the common ones are: - // ADC1: GPIO 32-39 (channels 0-7), ADC2: GPIO 0,2,4,12-15,25-27 - // Simplified to the 8 most-used pins (GPIO 32-39 = ADC1) - esp32: adcRange('GPIO', 32, 8), - 'esp32-devkit-c-v4': adcRange('GPIO', 32, 8), - 'esp32-cam': adcRange('GPIO', 32, 8), - 'wemos-lolin32-lite': adcRange('GPIO', 32, 8), - - // ESP32-S3 — ADC1 channels on GPIO 1-10, ADC2 on GPIO 11-20 - 'esp32-s3': adcRange('GPIO', 1, 10), - 'xiao-esp32-s3': adcRange('GPIO', 1, 10), - 'arduino-nano-esp32': adcRange('A', 0, 8), - - // ESP32-C3 — ADC1 channels on GPIO 0-4, ADC2 on GPIO 5 - 'esp32-c3': adcRange('GPIO', 0, 6), - 'xiao-esp32-c3': adcRange('GPIO', 0, 6), - 'aitewinrobot-esp32c3-supermini': adcRange('GPIO', 0, 6), -}; - -/** - * Convert an ADC pin name + channel to the GPIO pin number that - * `setAdcVoltage()` (partUtils) expects, per board family. - * - * AVR: A0→14, A1→15, ... (analog pins start at 14) - * RP2040: GP26→26, GP27→27, ... (GPIO number directly) - * ESP32: GPIO32→32, ... or A0→channel-dependent (GPIO number) - */ -function avrPinFromName(_name: string, channel: number): number { - return 14 + channel; -} -// eslint-disable-next-line @typescript-eslint/no-unused-vars -function gpioPinFromName(name: string, _channel: number): number { - const m = name.match(/(\d+)$/); - return m ? parseInt(m[1], 10) : -1; -} - -const ADC_PIN_TO_GPIO: Partial number>> = { - 'arduino-uno': avrPinFromName, - 'arduino-nano': avrPinFromName, - 'arduino-mega': avrPinFromName, - attiny85: avrPinFromName, - - 'raspberry-pi-pico': gpioPinFromName, - 'pi-pico-w': gpioPinFromName, - - esp32: gpioPinFromName, - 'esp32-devkit-c-v4': gpioPinFromName, - 'esp32-cam': gpioPinFromName, - 'wemos-lolin32-lite': gpioPinFromName, - 'esp32-s3': gpioPinFromName, - 'xiao-esp32-s3': gpioPinFromName, - 'arduino-nano-esp32': avrPinFromName, // uses A0-A7 naming - 'esp32-c3': gpioPinFromName, - 'xiao-esp32-c3': gpioPinFromName, - 'aitewinrobot-esp32c3-supermini': gpioPinFromName, -}; /** * Convert a board pin name (e.g. "9", "A0", "GP26", "GPIO32") to the @@ -118,24 +34,18 @@ const ADC_PIN_TO_GPIO: Partial = {}; - // Gather all pin names wired to this board const pinNames = new Set(); for (const w of wires) { if (w.start.componentId === boardId) pinNames.add(w.start.pinName); @@ -179,23 +88,11 @@ export function collectPinStates( } else if (pm.getPinState(arduinoPin)) { result[pinName] = { type: 'digital', v: vcc }; } - // If pin is LOW or unknown, don't add — treated as input/floating by SPICE } return result; } -const SPICE_DEBUG = true; -const spiceLog = (...a: unknown[]) => { - if (SPICE_DEBUG) console.log('[spice]', ...a); -}; - export function wireElectricalSolver(): () => void { - spiceLog('wireElectricalSolver mounted'); - - // Cache the last solve input JSON to skip redundant solves. - // This is critical: without it, the periodic timer floods the scheduler - // with identical requests, delaying the result that carries updated - // pin states (e.g. PWM) until after the simulation stops. let lastInputJson = ''; function maybeSolve() { @@ -211,253 +108,13 @@ export function wireElectricalSolver(): () => void { }; const input = buildInputFromStore(snap); - // Deduplicate: skip if the input hasn't changed since the last solve. const inputJson = JSON.stringify(input); - if (inputJson === lastInputJson) { - spiceLog('maybeSolve skipped (input unchanged)'); - return; - } + if (inputJson === lastInputJson) return; lastInputJson = inputJson; - spiceLog('maybeSolve → triggerSolve', { - components: input.components.length, - wires: input.wires.length, - boards: input.boards.length, - analysis: input.analysis, - }); - - // pinNetMap is now built inside buildNetlist() from the same UF and - // returned via CircuitScheduler → ElectricalSolveResult → store. useElectricalStore.getState().triggerSolve(input); } - function injectVoltagesIntoADC() { - const { nodeVoltages, pinNetMap } = useElectricalStore.getState(); - const { boards } = useSimulatorStore.getState(); - for (const board of boards) { - const adcPins = ADC_PIN_MAP[board.boardKind]; - if (!adcPins) continue; - const sim = getBoardSimulator(board.id); - if (!sim) continue; - const vMax = board.boardKind.startsWith('esp32') ? 3.3 : 5.0; - for (const { pinName, channel } of adcPins) { - const netName = pinNetMap.get(`${board.id}:${pinName}`); - if (!netName) continue; - const v = nodeVoltages[netName]; - if (v == null) continue; - const clamped = Math.max(0, Math.min(vMax, v)); - const gpioPin = ADC_PIN_TO_GPIO[board.boardKind]?.(pinName, channel); - if (gpioPin != null) setAdcVoltage(sim, gpioPin, clamped); - } - } - } - - // ── Per-read ADC waveform sampling ────────────────────────────────────── - // When a circuit contains an AC source, SPICE returns a `.tran` result with - // per-node waveform samples. We override `MCU.onADCRead` so that every - // `analogRead` interpolates the waveform at the *exact wall-clock time of - // the read*. That puts every guest-visible ADC sample in the right phase, - // regardless of the sketch's sample rate (up to Nyquist for the waveform). - // - // Why not a RAF loop? An earlier version pushed `channelValues[ch]` once - // per animation frame (~60 Hz). But 60 Hz aliases with 50 Hz signals, and - // within a single frame every `analogRead` returns the same stale value — - // collapsing the 400-sample `.tran` LUT to a 60 Hz zero-order hold. The - // per-read hook eliminates both issues and is strictly more faithful, so - // the RAF loop was retired. See `docs/wiki/circuit-emulation-adc-aliasing.md`. - const patchedAdcs = new WeakSet(); - - // Wall-clock epoch for "t=0 of the signal generator". Latched the first - // time a `.tran` result arrives, so the sampler's phase is stable across - // re-solves that produce an identical waveform. - let replayStartMs = 0; - let replayEpochLatched = false; - - function sampleWaveformAtNow(net: string): number | undefined { - const { timeWaveforms } = useElectricalStore.getState(); - if (!timeWaveforms) return undefined; - const samples = timeWaveforms.nodes.get(net); - if (!samples) return undefined; - const times = timeWaveforms.time; - const periodS = times[times.length - 1]; - if (!(periodS > 0)) return undefined; - const t = ((performance.now() - replayStartMs) / 1000) % periodS; - return interpolateAt(times, samples, t); - } - - // Track which `(boardId, channel)` pairs currently have a waveform pushed - // to QEMU so we can clear it when the circuit turns DC or the component is - // removed. Key format: `${boardId}:${channel}`. - const qemuWaveformChannels = new Set(); - - function pushEsp32Waveforms() { - const { boards } = useSimulatorStore.getState(); - const { pinNetMap, timeWaveforms } = useElectricalStore.getState(); - for (const board of boards) { - const adcPins = ADC_PIN_MAP[board.boardKind]; - if (!adcPins) continue; - const sim = getBoardSimulator(board.id); - if (!sim) continue; - // Only ESP32 QEMU-bridged shims have setAdcWaveform. - const shim = sim as unknown as { - setAdcWaveform?: (pin: number, samples: Uint16Array, periodNs: number) => boolean; - }; - if (typeof shim.setAdcWaveform !== 'function') continue; - - const gpioFn = ADC_PIN_TO_GPIO[board.boardKind]; - if (!gpioFn) continue; - const boardId = board.id; - const seen = new Set(); - - if (timeWaveforms && timeWaveforms.time.length > 1) { - const period = timeWaveforms.time[timeWaveforms.time.length - 1]; - if (period > 0) { - const periodNs = Math.round(period * 1e9); - for (const { pinName, channel } of adcPins) { - const net = pinNetMap.get(`${boardId}:${pinName}`); - const samples = net ? timeWaveforms.nodes.get(net) : undefined; - if (!samples || samples.length === 0) continue; - const u12 = new Uint16Array(samples.length); - for (let i = 0; i < samples.length; i++) { - const v = Math.max(0, Math.min(3.3, samples[i])); - u12[i] = Math.round((v / 3.3) * 4095); - } - const gpioPin = gpioFn(pinName, channel); - if (gpioPin < 0) continue; - shim.setAdcWaveform(gpioPin, u12, periodNs); - qemuWaveformChannels.add(`${boardId}:${channel}`); - seen.add(channel); - } - } - } - - // Clear any channels that previously had a waveform but don't anymore - // (e.g. circuit became DC after a component edit). - for (const { pinName, channel } of adcPins) { - const key = `${boardId}:${channel}`; - if (qemuWaveformChannels.has(key) && !seen.has(channel)) { - const gpioPin = gpioFn(pinName, channel); - if (gpioPin < 0) continue; - shim.setAdcWaveform(gpioPin, new Uint16Array(0), 0); - qemuWaveformChannels.delete(key); - } - } - } - } - - function installAdcReadHooks() { - // ESP32 is handled by its dedicated waveform-push path (no in-process - // onADCRead to patch — QEMU does the interpolation on MMIO read). - pushEsp32Waveforms(); - - const { boards } = useSimulatorStore.getState(); - for (const board of boards) { - const adcPins = ADC_PIN_MAP[board.boardKind]; - if (!adcPins) continue; - const sim = getBoardSimulator(board.id); - if (!sim) continue; - const adc = (sim as unknown as { getADC?: () => object | null }).getADC?.(); - if (!adc || patchedAdcs.has(adc)) continue; - const boardId = board.id; - - // Build channel → SPICE net for this board. - const channelToNet = new Map(); - const refreshChannelMap = () => { - channelToNet.clear(); - const { pinNetMap } = useElectricalStore.getState(); - for (const { pinName, channel } of adcPins) { - const net = pinNetMap.get(`${boardId}:${pinName}`); - if (net) channelToNet.set(channel, net); - } - }; - - // AVR vs RP2040 detection. RP2040's RPADC exposes `resolution: 12` and a - // `sampleAlarm` scheduler; AVR's ADC exposes `sampleCycles` + `cpu`. - const isRp2040 = 'resolution' in (adc as object); - - if (isRp2040) { - const self = adc as unknown as { - channelValues: number[]; - onADCRead: (channel: number) => void; - }; - const originalOnADCRead = self.onADCRead.bind(self); - self.onADCRead = function (channel: number) { - if (channelToNet.size === 0) refreshChannelMap(); - const net = channelToNet.get(channel); - const v = net ? sampleWaveformAtNow(net) : undefined; - if (v != null) { - // RP2040 ADC is 12-bit, 0-3.3V full scale. - const clamped = Math.max(0, Math.min(3.3, v)); - self.channelValues[channel] = Math.round((clamped / 3.3) * 4095); - } - originalOnADCRead(channel); - }; - patchedAdcs.add(adc); - spiceLog('installed RP2040 onADCRead hook', { boardId }); - continue; - } - - // AVR path — override entirely because the real implementation computes - // the raw 10-bit value from channelValues and calls completeADCRead via - // cpu.addClockEvent. We must re-implement that path but with the voltage - // sampled from the SPICE waveform at the exact read moment. - const self = adc as unknown as { - channelValues: Array; - referenceVoltage: number; - sampleCycles: number; - cpu: { addClockEvent: (fn: () => void, cycles: number) => void }; - completeADCRead: (value: number) => void; - onADCRead: (input: { - type: number; - channel?: number; - voltage?: number; - positiveChannel?: number; - negativeChannel?: number; - gain?: number; - }) => void; - }; - self.onADCRead = function (input) { - if (channelToNet.size === 0) refreshChannelMap(); - const ADCMuxInputType_SingleEnded = 0; - const ADCMuxInputType_Differential = 1; - const ADCMuxInputType_Constant = 2; - const ADCMuxInputType_Temperature = 3; - let voltage = 0; - switch (input.type) { - case ADCMuxInputType_Constant: - voltage = input.voltage ?? 0; - break; - case ADCMuxInputType_SingleEnded: { - const ch = input.channel ?? 0; - const net = channelToNet.get(ch); - const waveV = net ? sampleWaveformAtNow(net) : undefined; - voltage = waveV ?? self.channelValues[ch] ?? 0; - break; - } - case ADCMuxInputType_Differential: { - const pos = input.positiveChannel ?? 0; - const neg = input.negativeChannel ?? 0; - const gain = input.gain ?? 1; - const vPos = - sampleWaveformAtNow(channelToNet.get(pos) ?? '') ?? self.channelValues[pos] ?? 0; - const vNeg = - sampleWaveformAtNow(channelToNet.get(neg) ?? '') ?? self.channelValues[neg] ?? 0; - voltage = gain * (vPos - vNeg); - break; - } - case ADCMuxInputType_Temperature: - voltage = 0.378125; - break; - } - const rawValue = (voltage / self.referenceVoltage) * 1024; - const result = Math.min(Math.max(Math.floor(rawValue), 0), 1023); - self.cpu.addClockEvent(() => self.completeADCRead(result), self.sampleCycles); - }; - patchedAdcs.add(adc); - spiceLog('installed AVR onADCRead hook', { boardId }); - } - } - // Re-solve on components / wires changes. const unsubSim = useSimulatorStore.subscribe((state, prev) => { if (state.components !== prev.components || state.wires !== prev.wires) { @@ -465,98 +122,8 @@ export function wireElectricalSolver(): () => void { } }); - // On every solve result: - // - DC solve (`.op`): push scalar voltages into `channelValues[]`. - // - AC solve (`.tran`): install/refresh the per-read `onADCRead` hook so - // every future `analogRead` interpolates the waveform. Latch the - // wall-clock epoch on first `.tran` arrival so the signal phase is - // stable across downstream re-solves. - const unsubResult = useElectricalStore.subscribe((state, prev) => { - if (state.nodeVoltages !== prev.nodeVoltages || state.timeWaveforms !== prev.timeWaveforms) { - injectVoltagesIntoADC(); - if (state.timeWaveforms && !replayEpochLatched) { - replayStartMs = performance.now(); - replayEpochLatched = true; - } - installAdcReadHooks(); - } - }); - - // Examples are loaded by `ExampleLoaderPage` *before* navigating to the - // editor, so by the time this subscriber attaches, `setComponents` / - // `setWires` have already fired and will never fire again — no subscription - // event ever reaches `unsubSim`. Kick off a solve on mount so the rectifier - // (and every other AC example that arrives via deep link) gets its waveform - // computed. Install hooks so any AVR/RP2040 that already booted gets them. + // Initial solve. maybeSolve(); - installAdcReadHooks(); - - // Expose a debug helper so the user can run `window.__spiceDebug()` at any - // time from DevTools and get a complete snapshot of the electrical state. - (window as unknown as { __spiceDebug?: () => void }).__spiceDebug = () => { - const es = useElectricalStore.getState(); - const ss = useSimulatorStore.getState(); - const a0Key = ss.boards[0] ? `${ss.boards[0].id}:A0` : '(no-board)'; - const a0Net = es.pinNetMap.get(a0Key); - console.log('[spice] DEBUG DUMP', { - analysisMode: es.analysisMode, - converged: es.converged, - error: es.error, - lastSolveMs: es.lastSolveMs, - nodeVoltageCount: Object.keys(es.nodeVoltages).length, - nodeVoltageSample: Object.entries(es.nodeVoltages).slice(0, 8), - pinNetMapSize: es.pinNetMap.size, - pinNetEntries: [...es.pinNetMap.entries()], - a0Key, - a0Net, - a0InstantV: a0Net ? es.nodeVoltages[a0Net] : undefined, - hasTimeWaveforms: !!es.timeWaveforms, - waveformNodeKeys: es.timeWaveforms ? [...es.timeWaveforms.nodes.keys()] : [], - waveformTimeFirst: es.timeWaveforms?.time[0], - waveformTimeLast: es.timeWaveforms?.time[es.timeWaveforms.time.length - 1], - waveformSamples: a0Net && es.timeWaveforms?.nodes.get(a0Net)?.slice(0, 10), - replayEpochLatched, - replayEpochMsAgo: replayEpochLatched ? +(performance.now() - replayStartMs).toFixed(0) : null, - boards: ss.boards.map((b) => ({ id: b.id, kind: b.boardKind, running: b.running })), - components: ss.components.map((c) => ({ id: c.id, meta: c.metadataId })), - wireCount: ss.wires.length, - submittedNetlist: es.submittedNetlist, - }); - - // Dump the live ADC state so we can confirm the per-read hook is attached - // and what channelValues look like for each running board. - for (const b of ss.boards) { - const sim = getBoardSimulator(b.id); - if (!sim) { - console.log(`[spice] board ${b.id}: no simulator`); - continue; - } - const adc = ( - sim as unknown as { getADC?: () => { channelValues?: ArrayLike } | null } - ).getADC?.(); - const cycles = (sim as unknown as { getCurrentCycles?: () => number }).getCurrentCycles?.(); - const values = adc?.channelValues ? Array.from(adc.channelValues).slice(0, 6) : null; - const valuesStr = values - ? `[${values.map((v) => (typeof v === 'number' ? v.toFixed(3) : String(v))).join(', ')}]` - : 'null'; - console.log( - `[spice] board ${b.id}: sim=${(sim as object).constructor?.name} running=${b.running} adc=${!!adc} patched=${adc ? patchedAdcs.has(adc) : false} cycles=${cycles} simT=${typeof cycles === 'number' ? (cycles / 16_000_000).toFixed(3) + 's' : 'n/a'} channelValues=${valuesStr}`, - ); - } - }; - console.log('[spice] call window.__spiceDebug() anytime to inspect state'); - - // Re-install hooks and re-inject DC whenever boards change (e.g. `loadHex` - // creates a fresh AVRADC instance that needs the waveform hook on it). - const unsubBoards = useSimulatorStore.subscribe((state, prev) => { - if (state.boards !== prev.boards) { - const { nodeVoltages } = useElectricalStore.getState(); - if (Object.keys(nodeVoltages).length > 0) { - injectVoltagesIntoADC(); - } - installAdcReadHooks(); - } - }); // Periodic re-solve while any board is running, so SPICE picks up // MCU pin-state changes (e.g. analogWrite → PWM → voltage source). @@ -583,8 +150,6 @@ export function wireElectricalSolver(): () => void { return () => { unsubSim(); - unsubResult(); - unsubBoards(); unsubRunning(); if (solveInterval) clearInterval(solveInterval); };