fix(sim): a resistor divider on a sensor line made the board pin invisible

An HC-SR04 on an ESP32-S3 answered "No echo" forever the moment its ECHO
went through the 1k/2k2 divider a real 5 V sensor needs, and worked again
when the divider came out (reported on Discord, project esp32-s3-distance).
Nothing about the divider is wrong: three separate resolvers could not see
past it.

1. The wire walk treated a net as a chain. Arriving at the divider's tap it
   hopped straight across the lower resistor, hit GND, and answered "tied to
   a rail" (-1) — never looking at the OTHER wire on that same tap, where
   GPIO41 was. A rail was also allowed to end the search, so a plain pull-up
   drawn before the signal wire hid the GPIO behind it the same way. The walk
   now explores the whole node (same depth, one shared visited set) and
   treats a supply pad as the weakest answer: remembered, returned only when
   no driven pin was found anywhere.

2. The walk moved to simulation/PinTrace so the store can ask the same
   question. It pre-registered backend sensors with its own single-wire
   lookup — one wire, far end must be a board — so ECHO (landing on a
   resistor) resolved to nothing. That entry REPLACES the part's, keyed by
   pin, so the echo pin was silently dropped and the QEMU worker fell back to
   TRIG+1: it pulsed GPIO22, which nobody was reading. Both the data pin and
   the extra pins now resolve through the shared walk, so a sensor behind a
   breadboard strip or a divider registers like any other.

3. Esp32Bridge.sendPinEvent now refuses a pad a registered sensor owns, the
   guard the in-browser engines already have. connectDigitalInputsToMcu
   thresholds the solved node, and `sourcedNets` does not hold it back once
   real components sit on the net — the divider makes ECHO exactly that, and
   it solves at ~0 V because nothing models the sensor's output.

Test covers the reported circuit end to end: the walk, and the echo_pin that
reaches the start_esp32 payload.
This commit is contained in:
David Montero 2026-08-22 23:31:23 +02:00
parent d64b679604
commit 57325aa9a9
5 changed files with 262 additions and 303 deletions

View File

@ -0,0 +1,205 @@
// @vitest-environment jsdom
/**
* Reported bug: an HC-SR04 on an ESP32-S3 works when ECHO goes straight to the
* GPIO, and answers "No echo" forever the moment the user inserts the 1k/2k2
* divider that the real 5 V sensor needs — the wiring you are supposed to use.
*
* The circuit below is the reported project verbatim
* (velxio.dev/martijn-kuipers/esp32-s3-distance):
*
* ECHO ──[1k]──┬──[2k2]── GND
* └──────── GPIO41
*
* Two separate resolvers were blind to it:
*
* 1. the wire walk crossed the top resistor, crossed the bottom one, hit GND
* and reported "tied to a rail" — never looking at the OTHER wire on the
* tap, where the GPIO was;
* 2. the backend sensor pre-registration in startBoard read a single wire's
* far end, so ECHO (which lands on a resistor) resolved to nothing and the
* QEMU worker fell back to TRIG+1 — pulsing GPIO22, which nobody reads.
*/
import { describe, it, expect, beforeEach, vi } from 'vitest';
// ── Mocks (same shape as esp32-integration.test.ts) ─────────────────────────
vi.mock('../simulation/AVRSimulator', () => ({
AVRSimulator: vi.fn(function (this: any) {
this.onSerialData = null;
this.onBaudRateChange = null;
this.onPinChangeWithTime = null;
this.start = vi.fn();
this.stop = vi.fn();
this.reset = vi.fn();
this.loadHex = vi.fn();
this.addI2CDevice = vi.fn();
this.setPinState = vi.fn();
}),
}));
vi.mock('../simulation/RP2040Simulator', () => ({
RP2040Simulator: vi.fn(function (this: any) {
this.onSerialData = null;
this.onPinChangeWithTime = null;
this.start = vi.fn();
this.stop = vi.fn();
this.reset = vi.fn();
this.loadBinary = vi.fn();
this.addI2CDevice = vi.fn();
this.attachPioPeripheral = vi.fn();
this.spi = { onByte: null, completeTransfer: vi.fn() };
}),
}));
vi.mock('../simulation/PinManager', () => ({
PinManager: vi.fn(function (this: any) {
this.updatePort = vi.fn();
this.onPinChange = vi.fn().mockReturnValue(() => {});
this.getListenersCount = vi.fn().mockReturnValue(0);
this.hardResetPinStates = vi.fn();
this.resetPinStates = vi.fn();
this.getOutputPins = vi.fn().mockReturnValue(new Set<number>());
}),
}));
vi.mock('../store/useOscilloscopeStore', () => ({
useOscilloscopeStore: {
getState: vi.fn().mockReturnValue({ channels: [], pushSample: vi.fn() }),
},
}));
class MockWebSocket {
static OPEN = 1;
static CLOSING = 2;
static CLOSED = 3;
readyState = MockWebSocket.OPEN;
onopen: (() => void) | null = null;
onmessage: ((e: { data: string }) => void) | null = null;
onclose: (() => void) | null = null;
onerror: (() => void) | null = null;
sent: string[] = [];
send(data: string) {
this.sent.push(data);
}
close() {
this.readyState = MockWebSocket.CLOSED;
this.onclose?.();
}
open() {
this.readyState = MockWebSocket.OPEN;
this.onopen?.();
}
get messages(): Array<{ type: string; data: Record<string, unknown> }> {
return this.sent.map((s) => JSON.parse(s));
}
}
vi.stubGlobal('WebSocket', MockWebSocket);
vi.stubGlobal('requestAnimationFrame', (_cb: FrameRequestCallback) => 1);
vi.stubGlobal('cancelAnimationFrame', vi.fn());
import { useSimulatorStore, getEsp32Bridge } from '../store/useSimulatorStore';
import { traceDetailed, traceBoardGpio } from '../simulation/PinTrace';
const BOARD = 'esp32-s3';
const SENSOR = 'hc_sr04_1';
const R_TOP = 'resistor_1k_1'; // ECHO → tap
const R_BOTTOM = 'resistor_2k2_1'; // tap → GND
function wire(
id: string,
a: [string, string],
b: [string, string],
): Record<string, unknown> {
return {
id,
waypoints: [],
color: '#000',
start: { componentId: a[0], pinName: a[1], x: 0, y: 0 },
end: { componentId: b[0], pinName: b[1], x: 0, y: 0 },
};
}
/** The reported project: 5 V sensor, TRIG straight to GPIO21, ECHO through a
* 1k/2k2 divider whose tap carries the wire to GPIO41. */
function loadDividerCircuit() {
const s = useSimulatorStore.getState();
s.setComponents([
{ id: SENSOR, metadataId: 'hc-sr04', x: 0, y: 0, properties: {} },
{ id: R_TOP, metadataId: 'resistor-1k', x: 0, y: 0, properties: { value: '1000' } },
{ id: R_BOTTOM, metadataId: 'resistor-2k2', x: 0, y: 0, properties: { value: '2200' } },
] as never);
s.setWires([
wire('w1', [BOARD, '5V'], [SENSOR, 'VCC']),
wire('w2', [SENSOR, 'TRIG'], [BOARD, '21']),
wire('w3', [BOARD, 'GND.4'], [SENSOR, 'GND']),
wire('w4', [SENSOR, 'ECHO'], [R_TOP, '1']),
wire('w5', [R_TOP, '2'], [R_BOTTOM, '1']),
wire('w6', [BOARD, 'GND.4'], [R_BOTTOM, '2']),
wire('w7', [BOARD, '41'], [R_BOTTOM, '1']),
] as never);
}
describe('HC-SR04 behind a 1k/2k2 level-shifting divider', () => {
beforeEach(() => {
useSimulatorStore.setState({ boards: [], components: [], wires: [] } as never);
useSimulatorStore.getState().addBoard('esp32-s3' as never, 0, 0, BOARD);
loadDividerCircuit();
});
it('traces ECHO through both resistors to GPIO41, not to the GND leg', () => {
const state = useSimulatorStore.getState();
const echo = traceDetailed(state as never, SENSOR, 'ECHO', 0);
expect(echo.arduinoPin, 'ECHO reaches GPIO41 across the divider tap').toBe(41);
expect(echo.boardId).toBe(BOARD);
});
it('still traces the directly wired TRIG to GPIO21', () => {
const state = useSimulatorStore.getState();
expect(traceDetailed(state as never, SENSOR, 'TRIG', 0).arduinoPin).toBe(21);
});
it('reports a rail (-1) only for a pin that reaches nothing else', () => {
const state = useSimulatorStore.getState();
// The sensor's own GND pad: a rail is the honest answer there.
expect(traceDetailed(state as never, SENSOR, 'GND', 0).arduinoPin).toBe(-1);
// traceBoardGpio speaks only of real GPIOs, so a rail is "no GPIO".
expect(traceBoardGpio(state as never, SENSOR, 'GND')).toBeNull();
});
it('pre-registers the sensor with echo_pin 41 in the start_esp32 payload', () => {
const bridge = getEsp32Bridge(BOARD)!;
useSimulatorStore.getState().startBoard(BOARD);
const socket = (bridge as unknown as { socket: MockWebSocket }).socket;
socket.open();
const start = socket.messages.find((m) => m.type === 'start_esp32');
expect(start, 'start_esp32 was sent').toBeDefined();
const sensors = start!.data.sensors as Array<Record<string, unknown>>;
expect(sensors).toContainEqual(
expect.objectContaining({ sensor_type: 'hc-sr04', pin: 21, echo_pin: 41 }),
);
});
});
describe('a data pin whose first wire is a pull-up', () => {
beforeEach(() => {
useSimulatorStore.setState({ boards: [], components: [], wires: [] } as never);
useSimulatorStore.getState().addBoard('esp32-s3' as never, 0, 0, BOARD);
const s = useSimulatorStore.getState();
s.setComponents([
{ id: 'dht22_1', metadataId: 'dht22', x: 0, y: 0, properties: {} },
{ id: 'r_pullup', metadataId: 'resistor-10k', x: 0, y: 0, properties: { value: '10000' } },
] as never);
// The pull-up wire is drawn FIRST — the walk used to answer with the 3V3
// pad it reaches through the resistor and stop looking.
s.setWires([
wire('p1', ['dht22_1', 'SDA'], ['r_pullup', '1']),
wire('p2', ['r_pullup', '2'], [BOARD, '3V3']),
wire('p3', ['dht22_1', 'SDA'], [BOARD, '4']),
] as never);
});
it('resolves the GPIO, not the supply pad behind the resistor', () => {
const state = useSimulatorStore.getState();
expect(traceDetailed(state as never, 'dht22_1', 'SDA', 0).arduinoPin).toBe(4);
});
});

View File

@ -24,23 +24,18 @@ 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';
import { isKeyBindable, formatKeyLabel } from '../utils/keyButtonBindings';
import {
createDefaultPinResolver,
createSpiceResolvedPinResolver,
configFromLogicFamily,
isActiveDevice,
type PinResolver,
} from '../simulation/PinResolver';
import { BOARD_PIN_GROUPS } from '../simulation/spice/boardPinGroups';
import { syntheticChipPin } from '../simulation/customChips/syntheticPins';
import { resolveChipNetKey } from '../simulation/customChips/chipNets';
import { traceDetailed } from '../simulation/PinTrace';
import { getMixedModeScheduler } from '../simulation/spice/MixedModeScheduler';
import { getBoardLogicFamily } from '../simulation/LogicFamilies';
import { breadboardGroupKey } from '../utils/breadboardNets';
// Side-effect imports: register every web component we'll create at runtime.
// `@wokwi/elements` covers the upstream catalog; `../velxio-elements` adds
@ -50,256 +45,10 @@ import '@wokwi/elements';
import '../velxio-elements';
import './velxio-components/Ssd1306I2cElement'; // registers velxio-ssd1306-i2c-4pin (4-pin I2C OLED)
// Map metadataId → [pinA, pinB] for 2-terminal passives.
// "Tracing through" means: if the caller arrived on pinA, continue from pinB
// (and vice-versa).
//
// NOTE: diodes / transistors / op-amps are NOT traced through as passives —
// they have polarity / Vf / non-linear behaviour that the digital layer
// cannot interpret as "same pin". BJTs are an explicit shortcut for the
// canonical "Arduino digital pin controls a load via transistor" pattern so
// 7-segment multiplex circuits with BJT digit drivers still resolve.
const PASSIVE_PIN_PAIRS_BASE: Record<string, [string, string]> = {
resistor: ['1', '2'],
'resistor-us': ['1', '2'],
capacitor: ['1', '2'],
'capacitor-electrolytic': ['+', '−'],
inductor: ['1', '2'],
'analog-resistor': ['A', 'B'],
'analog-capacitor': ['A', 'B'],
'analog-inductor': ['A', 'B'],
'bjt-2n2222': ['C', 'B'],
'bjt-bc547': ['C', 'B'],
'bjt-2n3055': ['C', 'B'],
'bjt-2n3906': ['C', 'B'],
'bjt-bc557': ['C', 'B'],
};
// Preset variants of the generic passives share their parent's tag and pin
// layout. Mirrors the PASSIVE_PRESETS map in spice/componentToSpice.ts.
const PRESET_TO_BASE: Record<string, string> = {
'resistor-220': 'resistor',
'resistor-330': 'resistor',
'resistor-470': 'resistor',
'resistor-1k': 'resistor',
'resistor-2k2': 'resistor',
'resistor-4k7': 'resistor',
'resistor-10k': 'resistor',
'resistor-22k': 'resistor',
'resistor-47k': 'resistor',
'resistor-100k': 'resistor',
'resistor-1m': 'resistor',
'cap-10p': 'capacitor',
'cap-22p': 'capacitor',
'cap-100p': 'capacitor',
'cap-1n': 'capacitor',
'cap-10n': 'capacitor',
'cap-100n': 'capacitor',
'cap-1u': 'capacitor',
'cap-elec-1u': 'capacitor-electrolytic',
'cap-elec-10u': 'capacitor-electrolytic',
'cap-elec-47u': 'capacitor-electrolytic',
'cap-elec-100u': 'capacitor-electrolytic',
'cap-elec-470u': 'capacitor-electrolytic',
'cap-elec-1000u': 'capacitor-electrolytic',
'ind-100u': 'inductor',
'ind-1m': 'inductor',
'ind-10m': 'inductor',
};
const PASSIVE_PIN_PAIRS: Record<string, [string, string]> = {
...PASSIVE_PIN_PAIRS_BASE,
};
for (const [preset, base] of Object.entries(PRESET_TO_BASE)) {
PASSIVE_PIN_PAIRS[preset] = PASSIVE_PIN_PAIRS_BASE[base];
}
type TraceState = ReturnType<typeof useSimulatorStore.getState>;
// Custom-chip output pins get stable synthetic pin numbers from
// simulation/customChips/syntheticPins so the chip is a first-class pin source.
// Depth-limited BFS: trace from (fromId, fromPin) through wires, traversing
// through passive components to reach a board pin. Returns the arduino pin
// plus a `crossedActiveDevice` flag so the resolver factory can decide
// between digital fast-path and SPICE-resolved per-pin.
//
// A real board pin always wins (digital GPIO semantics are unchanged). Only
// when NO board pin is reachable do we fall back to a custom-chip pin on the
// net — either a neighbour chip pin, or (when the trace itself started at a
// chip pin) the starting chip pin — resolving it to its synthetic number.
//
// Lifted to module scope (was inside getArduinoPin) so that getPinResolver
// can call it too — the previous nested-scope version caused a runtime
// ReferenceError "traceDetailed is not defined" on the simulator page.
/**
* Resolve a component pad through a SEATED board rather than a wire.
*
* Sockets are a real electrical connection with nothing to draw: the board's
* pads sit on the component's pads. `boardSocket` (read off the element, the
* rule-6a way) says the component is a socket; isBoardSeated says a board is
* actually in it; and the shared pad NAME is the contract that makes the two
* grids one net — which is exactly why a socket's pinInfo uses the board's own
* names. Returns null for anything that is not a seated socket pad.
*/
function traceThroughSocket(
state: TraceState,
componentId: string,
pinName: string,
): { pin: number; boardId: string } | null {
const el = document.getElementById(componentId) as
| (HTMLElement & { boardSocket?: { anchorPin: string; accepts: string[] } })
| null;
const sock = el?.boardSocket;
if (!sock || !Array.isArray(sock.accepts)) return null;
for (const b of state.boards) {
if (!sock.accepts.some((prefix) => b.boardKind.startsWith(prefix))) continue;
if (!isBoardSeated(b.id, b.boardKind, b.x, b.y, state.components)) continue;
const pin = boardPinToNumber(b.boardKind, pinName);
if (pin !== null) return { pin, boardId: b.id };
}
return null;
}
export function traceDetailed(
state: TraceState,
fromId: string,
fromPin: string,
depth: number,
activeSeen = false,
): { arduinoPin: number | null; crossedActiveDevice: boolean; boardId?: string } {
if (depth > 6) return { arduinoPin: null, crossedActiveDevice: activeSeen };
const wires = state.wires.filter(
(w) =>
(w.start.componentId === fromId && w.start.pinName === fromPin) ||
(w.end.componentId === fromId && w.end.pinName === fromPin),
);
// A board SEATED on a socket component is connected without any wire — that
// is what seating means, and it is how the hardware ships: a XIAO pushed
// into a shield's header, a Pi HAT dropped onto the 40-pin. So when this
// component declares a socket (boardSocket, the same contract the magnet
// reads) and a board is seated on it, a pad resolves to the SAME-NAMED pin
// of that board. Without this hop a seated shield's buttons and LEDs were
// dead until the user drew wires that the real stack does not have.
const socketPin = traceThroughSocket(state, fromId, fromPin);
if (socketPin !== null) {
return { arduinoPin: socketPin.pin, crossedActiveDevice: activeSeen, boardId: socketPin.boardId };
}
// Remember a custom-chip neighbour on this net (if any) as a fallback —
// a real board pin found in any branch still takes priority over it.
let chipNeighbour: { id: string; pin: string } | null = null;
for (const w of wires) {
const selfEp =
w.start.componentId === fromId && w.start.pinName === fromPin ? w.start : w.end;
const otherEp = selfEp === w.start ? w.end : w.start;
// A board endpoint is recognised by the LIVE boards list first.
// `isBoardComponent` matches static id prefixes ('arduino-uno', …), which
// only covers the default board — every board added at runtime (the agent
// mints UUID ids) failed the check, so tracing treated it as an unknown
// component and returned null. Symptom: an ESP32 clock whose QEMU was
// emitting hundreds of GPIO edges/second at a display that stayed dark,
// because no resolver ever attached.
const boardEp = state.boards.find((b) => b.id === otherEp.componentId);
if (boardEp || isBoardComponent(otherEp.componentId)) {
const boardKind = boardEp?.boardKind ?? otherEp.componentId;
const pin = boardPinToNumber(boardKind, otherEp.pinName);
// The board id travels with the pin: a QEMU-Linux board has no MCU
// simulator, so an input part needs to know WHICH board's bridge to
// push the level into (see the pi-aware simulator below).
if (pin !== null)
return {
arduinoPin: pin,
crossedActiveDevice: activeSeen,
boardId: boardEp?.id ?? otherEp.componentId,
};
} else {
const comp = state.components.find((c) => c.id === otherEp.componentId);
if (!chipNeighbour && comp?.metadataId === 'custom-chip') {
chipNeighbour = { id: otherEp.componentId, pin: otherEp.pinName };
}
const pair = comp && PASSIVE_PIN_PAIRS[comp.metadataId];
if (pair) {
const [p1, p2] = pair;
const otherPin = otherEp.pinName === p1 ? p2 : p1;
const nowActive =
activeSeen || (comp ? isActiveDevice(comp.metadataId) : false);
const result = traceDetailed(
state,
otherEp.componentId,
otherPin,
depth + 1,
nowActive,
);
if (result.arduinoPin !== null) return result;
}
// Breadboards join N holes per internal group (5-hole strip / power
// rail), which the 2-terminal PASSIVE_PIN_PAIRS map can't express.
// Continue the trace from every OTHER wired hole in the same group.
//
// Exclusion is by INCOMING WIRE, not by hole name: two wires may
// legitimately share one hole (a seated pin plus a jumper landing in
// that same hole — the agent bridges strips straight into the seat
// hole). Excluding the arrival hole made those stacked connections
// invisible: an ESP32 clock with QEMU firing hundreds of GPIO edges
// per second sat dark because every segment's bridge landed on its
// resistor's own seat hole and the trace dead-ended there.
const bbGroup = comp && breadboardGroupKey(comp.metadataId, otherEp.pinName);
if (bbGroup && comp) {
const groupPins = new Set<string>();
for (const gw of state.wires) {
if (gw.id === w.id) continue; // never bounce back on the same wire
for (const ep of [gw.start, gw.end]) {
if (
ep.componentId === comp.id &&
breadboardGroupKey(comp.metadataId, ep.pinName) === bbGroup
) {
groupPins.add(ep.pinName);
}
}
}
for (const groupPin of groupPins) {
const result = traceDetailed(state, comp.id, groupPin, depth + 1, activeSeen);
if (result.arduinoPin !== null) return result;
}
}
}
}
// No board pin reachable. Multi-chip digital bus (chipbus flag, Phase 0 of
// project/multichip-bus/): when this net has two or more chip endpoints and
// no board pin, collapse every endpoint onto ONE net-canonical synthetic key
// so a write on one chip is visible to another through the synchronous
// PinManager fan-out (fixes root cause A: per-endpoint keys never matching).
// resolveChipNetKey returns null when the flag is off, when a board owns the
// net, or when there is a single chip endpoint — so the chip-to-component
// rules below (2 and 3) are left exactly as-is. Scoped to depth 0 (the
// starting chip pin); the key is net-bound, so a pin flipping INPUT<->OUTPUT
// keeps the same key with no re-trace.
if (depth === 0) {
const netKey = resolveChipNetKey(state, fromId, fromPin);
if (netKey !== null) {
return { arduinoPin: netKey, crossedActiveDevice: activeSeen };
}
}
// No board pin reachable. Fall back to a custom-chip pin on this net so the
// chip can still drive / read it through the synthetic-pin PinManager key.
if (chipNeighbour) {
return {
arduinoPin: syntheticChipPin(chipNeighbour.id, chipNeighbour.pin),
crossedActiveDevice: activeSeen,
};
}
if (depth === 0 && state.components.find((c) => c.id === fromId)?.metadataId === 'custom-chip') {
return { arduinoPin: syntheticChipPin(fromId, fromPin), crossedActiveDevice: activeSeen };
}
return { arduinoPin: null, crossedActiveDevice: activeSeen };
}
// The wire-graph walk that answers "which board pin owns this component pin"
// lives in simulation/PinTrace so the store can ask the SAME question when it
// pre-registers backend sensors. Re-exported here: it was this module's API.
export { traceDetailed };
interface DynamicComponentProps {
id: string;

View File

@ -653,8 +653,31 @@ export class Esp32Bridge {
this._send({ type: 'esp32_serial_input', data: { bytes, uart } });
}
/**
* True when a backend-emulated single-wire sensor OWNS this pad — its data
* line, or an HC-SR04's ECHO. The QEMU worker drives those itself, and a
* host-injected level outranks it, so whoever pushes one silences the sensor.
*
* The caller that makes this matter is connectDigitalInputsToMcu,
* thresholding the solved SPICE node. Its `sourcedNets` gate is meant to
* leave part-managed pins alone, but a net stops being "unsourced" the
* moment a real component sits on it: an HC-SR04 whose ECHO reaches the pad
* through the 1k/2k2 divider a real 5 V sensor needs solves at ~0 V (nothing
* models the sensor's output) and pinned the echo pad LOW between trigger
* and reply — pulseIn() then timed out forever while the worker was pulsing
* the pin correctly. Same shape as the in-browser engines' ownsPin guard.
*/
private sensorOwnsPin(gpioPin: number): boolean {
for (const s of this._pendingSensors) {
if (s['pin'] === gpioPin) return true;
if (s['echo_pin'] === gpioPin) return true;
}
return false;
}
/** Drive a GPIO pin from an external source (e.g. connected Arduino) */
sendPinEvent(gpioPin: number, state: boolean): void {
if (this.sensorOwnsPin(gpioPin)) return;
this._send({ type: 'esp32_gpio_in', data: { pin: gpioPin, state: state ? 1 : 0 } });
}

Binary file not shown.

View File

@ -66,6 +66,7 @@ import {
setInterconnectRuntime,
} from '../simulation/Interconnect';
import { SENSOR_CONTROLS, getSensorControl } from '../simulation/sensorControlConfig';
import { traceBoardGpio } from '../simulation/PinTrace';
import { dispatchSensorUpdate } from '../simulation/SensorUpdateRegistry';
// ── Sensor pre-registration ──────────────────────────────────────────────────
@ -2384,58 +2385,39 @@ export const useSimulatorStore = create<SimulatorState>((set, get) => {
// has them ready before the firmware starts executing.
const esp32Bridge = getEsp32Bridge(boardId);
if (esp32Bridge) {
const { components, wires } = get();
const traceState = get();
const { components } = traceState;
const sensors: Array<Record<string, unknown>> = [];
for (const comp of components) {
const sensorDef = SENSOR_COMPONENT_MAP[comp.metadataId];
if (!sensorDef) continue;
// Find the wire connecting this component's data pin to the board
for (const w of wires) {
const compEndpoint =
w.start.componentId === comp.id && w.start.pinName === sensorDef.dataPinName
? w.start
: w.end.componentId === comp.id && w.end.pinName === sensorDef.dataPinName
? w.end
: null;
if (!compEndpoint) continue;
const boardEndpoint = compEndpoint === w.start ? w.end : w.start;
if (!isBoardComponent(boardEndpoint.componentId)) continue;
// Resolve GPIO pin number
const gpioPin = boardPinToNumber(board.boardKind, boardEndpoint.pinName);
if (gpioPin === null || gpioPin < 0) continue;
// Collect sensor properties from the component
const props: Record<string, unknown> = {
sensor_type: sensorDef.sensorType,
pin: gpioPin,
};
for (const key of sensorDef.propertyKeys) {
const val = comp.properties[key];
if (val !== undefined) props[key] = typeof val === 'string' ? parseFloat(val) : val;
}
// Resolve extra pins (e.g. echo_pin for HC-SR04) from wires
if (sensorDef.extraPins) {
for (const [propName, compPinName] of Object.entries(sensorDef.extraPins)) {
for (const ew of wires) {
const epComp =
ew.start.componentId === comp.id && ew.start.pinName === compPinName
? ew.start
: ew.end.componentId === comp.id && ew.end.pinName === compPinName
? ew.end
: null;
if (!epComp) continue;
const epBoard = epComp === ew.start ? ew.end : ew.start;
if (!isBoardComponent(epBoard.componentId)) continue;
const extraGpio = boardPinToNumber(board.boardKind, epBoard.pinName);
if (extraGpio !== null && extraGpio >= 0) {
props[propName] = extraGpio;
}
break;
}
}
}
sensors.push(props);
break; // only one data pin per sensor
// Resolved with the SAME wire walk the parts use, not by reading
// one wire's far end. A sensor pin reaches the board through a
// breadboard strip or a level-shifting divider just as truly as
// through a jumper landing on the pad, and this entry REPLACES the
// one the part registered (setSensors is keyed by pin) — so every
// pin this fails to resolve is silently lost. That is what left an
// HC-SR04 behind a 1k/2k2 divider with no echo pin at all: the
// backend fell back to TRIG+1 and pulsed a GPIO nobody was reading.
const gpioPin = traceBoardGpio(traceState, comp.id, sensorDef.dataPinName, boardId);
if (gpioPin === null) continue;
// Collect sensor properties from the component
const props: Record<string, unknown> = {
sensor_type: sensorDef.sensorType,
pin: gpioPin,
};
for (const key of sensorDef.propertyKeys) {
const val = comp.properties[key];
if (val !== undefined) props[key] = typeof val === 'string' ? parseFloat(val) : val;
}
// Extra pins (e.g. echo_pin for HC-SR04) resolve the same way
if (sensorDef.extraPins) {
for (const [propName, compPinName] of Object.entries(sensorDef.extraPins)) {
const extraGpio = traceBoardGpio(traceState, comp.id, compPinName, boardId);
if (extraGpio !== null) props[propName] = extraGpio;
}
}
sensors.push(props);
}
// Pre-register I2C sensors (virtual pin = 200 + i2c_addr, no wire resolution needed)