From d2648f5a666a8f7e2f5b85ff1e33dcb59526c2b8 Mon Sep 17 00:00:00 2001 From: David Montero Date: Sat, 15 Aug 2026 08:29:39 +0200 Subject: [PATCH] fix(sim): programmatic property changes reach the running simulation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three stacked defects made ANY non-knob property change invisible to a running sim (values restored from saved projects, the agent's set_component_property, the property dialog): 1. DynamicComponent assigned element properties without firing the events part simulators bind to — dispatch input/change + dispatchSensorUpdate on real value changes (applied-value guard breaks the echo loop). 2. Parts received the shared legacy AVR simulator regardless of board — route attachEvents to the simulator of the board the part is wired to (getBoardSimulator), so direct analog injection works on non-AVR boards. 3. The SPICE->ADC push stomped un-modeled parts: a net with no SPICE element gets an auto pull-down, solves to 0 V, and overwrote the part's own injected voltage every solve (the dead analog-joystick). Only sourced nets are pushed now. Verified end-to-end: pot curve exact to the LSB (value 100/300/600 -> ADC 400/1201/2401 on GPIO35), joystick idle at centre 2047, full-scale 4095 after a store write. --- frontend/src/components/DynamicComponent.tsx | 57 ++++++++++++++++++-- 1 file changed, 53 insertions(+), 4 deletions(-) diff --git a/frontend/src/components/DynamicComponent.tsx b/frontend/src/components/DynamicComponent.tsx index 3f1b3c56..ba35c7f1 100644 --- a/frontend/src/components/DynamicComponent.tsx +++ b/frontend/src/components/DynamicComponent.tsx @@ -17,11 +17,13 @@ import { useSimulatorStore, getBoardBridge, getBoardPinManager, + getBoardSimulator, } from '../store/useSimulatorStore'; import { useElectricalStore } from '../store/useElectricalStore'; import { useEditorStore } from '../store/useEditorStore'; import { buildProjectSdImage, decodeSdFiles } from '../utils/sdCardFiles'; import { PartSimulationRegistry } from '../simulation/parts'; +import { dispatchSensorUpdate } from '../simulation/SensorUpdateRegistry'; import { isBoardComponent, boardPinToNumber } from '../utils/boardPinMapping'; import { isBoardSeated } from '../utils/socketSnap'; import { isPiBoardKind } from '../types/board'; @@ -377,9 +379,18 @@ export const DynamicComponent: React.FC = ({ * (`switch (this.digits) { case 4: ... }` -> falls back to the 1-digit * pinout), and `'false'` stays truthy for boolean props like colon. */ + // Last property values actually applied to the element. Used to detect + // real changes below — the store hands us a NEW properties object on every + // update even when the values are identical, and the change-notification + // path (dispatch 'input' → part sim → emitPropertyChange → store) would + // otherwise echo forever. + const appliedPropsRef = useRef>({}); + useEffect(() => { if (!elementRef.current) return; + let changed = false; + const numericChanges: Record = {}; Object.entries(properties).forEach(([key, value]) => { try { // Display framebuffers round-trip through saved projects as strings @@ -397,11 +408,36 @@ export const DynamicComponent: React.FC = ({ } } (elementRef.current as any)[key] = coerced; + if (appliedPropsRef.current[key] !== coerced) { + appliedPropsRef.current[key] = coerced; + changed = true; + if (typeof coerced === 'number') numericChanges[key] = coerced; + } } catch (error) { console.warn(`Failed to set property ${key} on ${metadata.tagName}:`, error); } }); - }, [properties, metadata.tagName]); + + // A bare property assignment is invisible to the part simulators: they + // bind to DOM events ('input'/'change' — how the element's own knob + // notifies) or to the SensorUpdateRegistry (how the sensor panel + // notifies). Programmatic writes — the agent's set_component_property, + // values restored from a saved project, the property dialog — used to + // reach the ELEMENT but never the running simulation, so a pot "set" to + // 800 kept reading ADC 0 until a human touched its knob. Notify both + // channels, only on real value changes (see appliedPropsRef above). + if (changed) { + try { + elementRef.current.dispatchEvent(new Event('input')); + elementRef.current.dispatchEvent(new Event('change')); + if (Object.keys(numericChanges).length) { + dispatchSensorUpdate(id, numericChanges); + } + } catch (error) { + console.warn(`Failed to notify simulation of ${metadata.tagName} change:`, error); + } + } + }, [properties, metadata.tagName, id]); /** * Property changes that swap the element's pin SET (7segment digits, @@ -680,19 +716,22 @@ export const DynamicComponent: React.FC = ({ // bridge of the board this component is actually wired to: `gpio_in` // for the guest, the canvas-fed `pin` value the browser engine's // shims read, and the PinManager so wires and SPICE see the edge. - const piBoardId = (() => { + const { piBoardId, wiredBoardId } = (() => { const st = useSimulatorStore.getState(); const ownPins = new Set(); for (const w of st.wires) { if (w.start.componentId === id) ownPins.add(w.start.pinName); if (w.end.componentId === id) ownPins.add(w.end.pinName); } + let anyBoardId: string | null = null; for (const pinName of ownPins) { const { boardId } = traceDetailed(st, id, pinName, 0); const board = boardId ? st.boards.find((b) => b.id === boardId) : undefined; - if (board && isPiBoardKind(board.boardKind)) return board.id; + if (!board) continue; + if (anyBoardId === null) anyBoardId = board.id; + if (isPiBoardKind(board.boardKind)) return { piBoardId: board.id, wiredBoardId: board.id }; } - return null; + return { piBoardId: null, wiredBoardId: anyBoardId }; })(); const piSimulator = piBoardId ? ({ @@ -708,8 +747,18 @@ export const DynamicComponent: React.FC = ({ } as any) : null; + // Route the part to the simulator of the board it is actually WIRED + // to. The legacy store `simulator` is the shared AVR instance: handing + // it to a part on an ESP32 board silently voided every direct analog + // injection — setAdcVoltage() hit the AVR branch, GPIO 32-39 fell + // outside its 14-19 window, and the part (analog-joystick was the + // reported one) read 0 forever while pots survived only via the SPICE + // solve. getBoardSimulator() returns the per-board shim (ESP32 bridge + // shim, RP2040, AVR) that partUtils' dispatch understands. + const wiredSimulator = wiredBoardId ? getBoardSimulator(wiredBoardId) ?? null : null; const stubSimulator = piSimulator ?? + wiredSimulator ?? simulator ?? ({ setPinState: () => {},