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:
parent
d64b679604
commit
57325aa9a9
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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.
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Reference in New Issue