fix(sim): programmatic property changes reach the running simulation

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.
This commit is contained in:
David Montero 2026-08-15 08:29:39 +02:00
parent 44782a419a
commit d2648f5a66
1 changed files with 53 additions and 4 deletions

View File

@ -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<DynamicComponentProps> = ({
* (`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<Record<string, unknown>>({});
useEffect(() => {
if (!elementRef.current) return;
let changed = false;
const numericChanges: Record<string, number> = {};
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<DynamicComponentProps> = ({
}
}
(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<DynamicComponentProps> = ({
// bridge of the board this component is actually wired to: `gpio_in`
// for the guest, the canvas-fed `pin<N>` 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<string>();
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<DynamicComponentProps> = ({
} 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: () => {},