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 { buildProjectSdImage, decodeSdFiles } from '../utils/sdCardFiles';
|
||||||
import { PartSimulationRegistry } from '../simulation/parts';
|
import { PartSimulationRegistry } from '../simulation/parts';
|
||||||
import { dispatchSensorUpdate } from '../simulation/SensorUpdateRegistry';
|
import { dispatchSensorUpdate } from '../simulation/SensorUpdateRegistry';
|
||||||
import { isBoardComponent, boardPinToNumber } from '../utils/boardPinMapping';
|
|
||||||
import { isBoardSeated } from '../utils/socketSnap';
|
|
||||||
import { isPiBoardKind } from '../types/board';
|
import { isPiBoardKind } from '../types/board';
|
||||||
import { isKeyBindable, formatKeyLabel } from '../utils/keyButtonBindings';
|
import { isKeyBindable, formatKeyLabel } from '../utils/keyButtonBindings';
|
||||||
import {
|
import {
|
||||||
createDefaultPinResolver,
|
createDefaultPinResolver,
|
||||||
createSpiceResolvedPinResolver,
|
createSpiceResolvedPinResolver,
|
||||||
configFromLogicFamily,
|
configFromLogicFamily,
|
||||||
isActiveDevice,
|
|
||||||
type PinResolver,
|
type PinResolver,
|
||||||
} from '../simulation/PinResolver';
|
} from '../simulation/PinResolver';
|
||||||
import { BOARD_PIN_GROUPS } from '../simulation/spice/boardPinGroups';
|
import { BOARD_PIN_GROUPS } from '../simulation/spice/boardPinGroups';
|
||||||
import { syntheticChipPin } from '../simulation/customChips/syntheticPins';
|
import { traceDetailed } from '../simulation/PinTrace';
|
||||||
import { resolveChipNetKey } from '../simulation/customChips/chipNets';
|
|
||||||
import { getMixedModeScheduler } from '../simulation/spice/MixedModeScheduler';
|
import { getMixedModeScheduler } from '../simulation/spice/MixedModeScheduler';
|
||||||
import { getBoardLogicFamily } from '../simulation/LogicFamilies';
|
import { getBoardLogicFamily } from '../simulation/LogicFamilies';
|
||||||
import { breadboardGroupKey } from '../utils/breadboardNets';
|
|
||||||
|
|
||||||
// Side-effect imports: register every web component we'll create at runtime.
|
// Side-effect imports: register every web component we'll create at runtime.
|
||||||
// `@wokwi/elements` covers the upstream catalog; `../velxio-elements` adds
|
// `@wokwi/elements` covers the upstream catalog; `../velxio-elements` adds
|
||||||
|
|
@ -50,256 +45,10 @@ import '@wokwi/elements';
|
||||||
import '../velxio-elements';
|
import '../velxio-elements';
|
||||||
import './velxio-components/Ssd1306I2cElement'; // registers velxio-ssd1306-i2c-4pin (4-pin I2C OLED)
|
import './velxio-components/Ssd1306I2cElement'; // registers velxio-ssd1306-i2c-4pin (4-pin I2C OLED)
|
||||||
|
|
||||||
// Map metadataId → [pinA, pinB] for 2-terminal passives.
|
// The wire-graph walk that answers "which board pin owns this component pin"
|
||||||
// "Tracing through" means: if the caller arrived on pinA, continue from pinB
|
// lives in simulation/PinTrace so the store can ask the SAME question when it
|
||||||
// (and vice-versa).
|
// pre-registers backend sensors. Re-exported here: it was this module's API.
|
||||||
//
|
export { traceDetailed };
|
||||||
// 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 };
|
|
||||||
}
|
|
||||||
|
|
||||||
interface DynamicComponentProps {
|
interface DynamicComponentProps {
|
||||||
id: string;
|
id: string;
|
||||||
|
|
|
||||||
|
|
@ -653,8 +653,31 @@ export class Esp32Bridge {
|
||||||
this._send({ type: 'esp32_serial_input', data: { bytes, uart } });
|
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) */
|
/** Drive a GPIO pin from an external source (e.g. connected Arduino) */
|
||||||
sendPinEvent(gpioPin: number, state: boolean): void {
|
sendPinEvent(gpioPin: number, state: boolean): void {
|
||||||
|
if (this.sensorOwnsPin(gpioPin)) return;
|
||||||
this._send({ type: 'esp32_gpio_in', data: { pin: gpioPin, state: state ? 1 : 0 } });
|
this._send({ type: 'esp32_gpio_in', data: { pin: gpioPin, state: state ? 1 : 0 } });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Binary file not shown.
|
|
@ -66,6 +66,7 @@ import {
|
||||||
setInterconnectRuntime,
|
setInterconnectRuntime,
|
||||||
} from '../simulation/Interconnect';
|
} from '../simulation/Interconnect';
|
||||||
import { SENSOR_CONTROLS, getSensorControl } from '../simulation/sensorControlConfig';
|
import { SENSOR_CONTROLS, getSensorControl } from '../simulation/sensorControlConfig';
|
||||||
|
import { traceBoardGpio } from '../simulation/PinTrace';
|
||||||
import { dispatchSensorUpdate } from '../simulation/SensorUpdateRegistry';
|
import { dispatchSensorUpdate } from '../simulation/SensorUpdateRegistry';
|
||||||
|
|
||||||
// ── Sensor pre-registration ──────────────────────────────────────────────────
|
// ── Sensor pre-registration ──────────────────────────────────────────────────
|
||||||
|
|
@ -2384,58 +2385,39 @@ export const useSimulatorStore = create<SimulatorState>((set, get) => {
|
||||||
// has them ready before the firmware starts executing.
|
// has them ready before the firmware starts executing.
|
||||||
const esp32Bridge = getEsp32Bridge(boardId);
|
const esp32Bridge = getEsp32Bridge(boardId);
|
||||||
if (esp32Bridge) {
|
if (esp32Bridge) {
|
||||||
const { components, wires } = get();
|
const traceState = get();
|
||||||
|
const { components } = traceState;
|
||||||
const sensors: Array<Record<string, unknown>> = [];
|
const sensors: Array<Record<string, unknown>> = [];
|
||||||
for (const comp of components) {
|
for (const comp of components) {
|
||||||
const sensorDef = SENSOR_COMPONENT_MAP[comp.metadataId];
|
const sensorDef = SENSOR_COMPONENT_MAP[comp.metadataId];
|
||||||
if (!sensorDef) continue;
|
if (!sensorDef) continue;
|
||||||
// Find the wire connecting this component's data pin to the board
|
// Resolved with the SAME wire walk the parts use, not by reading
|
||||||
for (const w of wires) {
|
// one wire's far end. A sensor pin reaches the board through a
|
||||||
const compEndpoint =
|
// breadboard strip or a level-shifting divider just as truly as
|
||||||
w.start.componentId === comp.id && w.start.pinName === sensorDef.dataPinName
|
// through a jumper landing on the pad, and this entry REPLACES the
|
||||||
? w.start
|
// one the part registered (setSensors is keyed by pin) — so every
|
||||||
: w.end.componentId === comp.id && w.end.pinName === sensorDef.dataPinName
|
// pin this fails to resolve is silently lost. That is what left an
|
||||||
? w.end
|
// HC-SR04 behind a 1k/2k2 divider with no echo pin at all: the
|
||||||
: null;
|
// backend fell back to TRIG+1 and pulsed a GPIO nobody was reading.
|
||||||
if (!compEndpoint) continue;
|
const gpioPin = traceBoardGpio(traceState, comp.id, sensorDef.dataPinName, boardId);
|
||||||
const boardEndpoint = compEndpoint === w.start ? w.end : w.start;
|
if (gpioPin === null) continue;
|
||||||
if (!isBoardComponent(boardEndpoint.componentId)) continue;
|
// Collect sensor properties from the component
|
||||||
// Resolve GPIO pin number
|
const props: Record<string, unknown> = {
|
||||||
const gpioPin = boardPinToNumber(board.boardKind, boardEndpoint.pinName);
|
sensor_type: sensorDef.sensorType,
|
||||||
if (gpioPin === null || gpioPin < 0) continue;
|
pin: gpioPin,
|
||||||
// Collect sensor properties from the component
|
};
|
||||||
const props: Record<string, unknown> = {
|
for (const key of sensorDef.propertyKeys) {
|
||||||
sensor_type: sensorDef.sensorType,
|
const val = comp.properties[key];
|
||||||
pin: gpioPin,
|
if (val !== undefined) props[key] = typeof val === 'string' ? parseFloat(val) : val;
|
||||||
};
|
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
// 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)
|
// Pre-register I2C sensors (virtual pin = 200 + i2c_addr, no wire resolution needed)
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue