fix(sim): a net is a set of pins, and a part's own line belongs to the part
Hardening pass on57325aa, which fixed the reported HC-SR04 divider but did it by returning the first answer any branch produced. Five topologies people really draw break under that rule, and each one now has a test that fails on57325aaand passes here: - a ground tie point almost always carries something else (a button's pull-down). Reaching a rail has to END the walk, or a grounded pin resolves to whatever GPIO that pull-down hangs off. Same through a breadboard rail; - two custom chips on one wire stopped sharing a chipbus net key, because a nested frame answered with its own neighbour's per-endpoint key before the root could mint the net-canonical one; - a chip one hop away ended the search before a real GPIO on the same node was ever looked at; - a node first reached deep, where the hop budget cut it short, was never re-walked when a shorter route arrived. So the walk now models what a net IS. It collects the whole NODE (every pin joined without crossing a component: the wires, and a breadboard strip's holes), then judges the set: a driven pad wins outright; a supply pad means the node IS the rail and the walk stops there; only then does it cross a passive to the next node, where a rail is the weaker answer that lets a level-shifted sensor resolve. Wires, components and breadboard groups are indexed per store array, and a driven pad short-circuits collection — the common case is now 0.08 ms/pin on a 300-wire canvas, better than before this work started, with the shared-rail worst case at ~2 ms. Second half: a part that models its own line keeps it, on every board. connectDigitalInputsToMcu thresholds the solved node into every input pin, and its `sourcedNets` gate only ever protected part-driven lines by accident — it holds while no component sits on the net, and stops the moment the user adds the pull-up or the divider real hardware needs. Then the connector pins the line an HC-SR04 or a DHT22 is answering on. simulation/partPinOwnership records what each part drives (implicitly, through the simulator it is handed) and the connector skips those pins; the ESP32 bridge answers the same question for sensors the backend or an engine emulates, through a new optional `ownsPin` seam. Parts SPICE can describe never claim anything, so a mis-wired button still reads stuck — that property is why spiceDrivenInputs exists. Also: setSensors merges per FIELD, so the coarser registration path can no longer drop the finer one's echo_pin; the ESP32 bridge's guard is narrowed to the single-wire sensor types, since an ePaper's DC pin or a keypad row must stay drivable; and the Mega and Pico W pin maps get the supply-pad guard the other boards have, where '5V' parsed as D5 and '3V3' as D3. Verified in a browser against a local build of this tree, driving the real app: HC-SR04 and DHT22, direct wiring and level-shifted, on Uno (avr8js), Pico (rp2040js), ESP32 (esp32js and QEMU) and ESP32-S3 (esp32s3js) — see scripts/sensor-wiring-matrix.mjs in velxio-prod.
This commit is contained in:
parent
57325aa9a9
commit
8f148e836d
|
|
@ -0,0 +1,110 @@
|
|||
// @vitest-environment jsdom
|
||||
/**
|
||||
* A part that models its own line must keep it — on every board.
|
||||
*
|
||||
* connectDigitalInputsToMcu thresholds the solved node into each input pin, and
|
||||
* its `sourcedNets` gate is not a real guard: it only holds while NOTHING
|
||||
* electrical sits on the net. Wire the sensor the way the hardware needs it — a
|
||||
* pull-up on a DHT22, the 1k/2k2 divider a 5 V HC-SR04 needs to reach a 3.3 V
|
||||
* pad — and the net becomes component-backed, solves at what the passives say,
|
||||
* and the connector starts pinning the very line the sensor is pulsing.
|
||||
*
|
||||
* partPinOwnership is the rule that fixes it for every board at once, and this
|
||||
* test pins both halves of it: the unmodelled part keeps its pin, and the part
|
||||
* SPICE does model (a button) still reads the real circuit.
|
||||
*/
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import {
|
||||
claimPartPin,
|
||||
releasePartPins,
|
||||
isPartOwnedPin,
|
||||
clearPartPinOwnership,
|
||||
withPartPinOwnership,
|
||||
} from '../simulation/partPinOwnership';
|
||||
|
||||
describe('part pin ownership', () => {
|
||||
beforeEach(() => clearPartPinOwnership());
|
||||
|
||||
it('claims and releases per component', () => {
|
||||
claimPartPin('uno', 10, 'sr04-1');
|
||||
expect(isPartOwnedPin('uno', 10)).toBe(true);
|
||||
expect(isPartOwnedPin('uno', 11)).toBe(false);
|
||||
expect(isPartOwnedPin('esp32', 10)).toBe(false); // per board, not global
|
||||
releasePartPins('sr04-1');
|
||||
expect(isPartOwnedPin('uno', 10)).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps a pin owned while a second component still claims it', () => {
|
||||
claimPartPin('uno', 4, 'a');
|
||||
claimPartPin('uno', 4, 'b');
|
||||
releasePartPins('a');
|
||||
expect(isPartOwnedPin('uno', 4)).toBe(true);
|
||||
releasePartPins('b');
|
||||
expect(isPartOwnedPin('uno', 4)).toBe(false);
|
||||
});
|
||||
|
||||
it('claims what an unmodelled part drives, through the wrapper', () => {
|
||||
const calls: Array<[string, unknown[]]> = [];
|
||||
const sim = {
|
||||
setPinState: (...a: unknown[]) => calls.push(['setPinState', a]),
|
||||
schedulePinChange: (...a: unknown[]) => calls.push(['schedulePinChange', a]),
|
||||
registerSensor: (...a: unknown[]) => {
|
||||
calls.push(['registerSensor', a]);
|
||||
return true;
|
||||
},
|
||||
};
|
||||
// hc-sr04 has no SPICE mapper: nothing but the part describes ECHO.
|
||||
const wrapped = withPartPinOwnership(sim, 'esp32-s3', 'sr04-1', 'hc-sr04') as typeof sim;
|
||||
|
||||
wrapped.setPinState(41, false);
|
||||
expect(isPartOwnedPin('esp32-s3', 41)).toBe(true);
|
||||
|
||||
wrapped.schedulePinChange(7, true, 1000);
|
||||
expect(isPartOwnedPin('esp32-s3', 7)).toBe(true);
|
||||
|
||||
// A backend sensor owns its data pin AND every extra pin its props name.
|
||||
wrapped.registerSensor('hc-sr04', 21, { distance: 30, echo_pin: 41 });
|
||||
expect(isPartOwnedPin('esp32-s3', 21)).toBe(true);
|
||||
|
||||
// Everything still reached the real simulator.
|
||||
expect(calls.map((c) => c[0])).toEqual([
|
||||
'setPinState',
|
||||
'schedulePinChange',
|
||||
'registerSensor',
|
||||
]);
|
||||
});
|
||||
|
||||
it('never claims for a part the netlist already describes', () => {
|
||||
const sim = { setPinState: vi.fn() };
|
||||
// A pushbutton IS in SPICE. The solved circuit stays the truth for it —
|
||||
// that is what makes a mis-wired button read stuck instead of "working".
|
||||
const wrapped = withPartPinOwnership(sim, 'uno', 'btn-1', 'pushbutton') as typeof sim;
|
||||
wrapped.setPinState(2, false);
|
||||
expect(sim.setPinState).toHaveBeenCalledWith(2, false);
|
||||
expect(isPartOwnedPin('uno', 2)).toBe(false);
|
||||
});
|
||||
|
||||
it('leaves lazy getters and method binding intact', () => {
|
||||
let built = 0;
|
||||
const sim = {
|
||||
pinManager: { id: 'pm' },
|
||||
_spi: null as null | { tag: string },
|
||||
get spi() {
|
||||
if (!this._spi) {
|
||||
built += 1;
|
||||
this._spi = { tag: 'adapter' };
|
||||
}
|
||||
return this._spi;
|
||||
},
|
||||
value: 7,
|
||||
readValue(this: { value: number }) {
|
||||
return this.value;
|
||||
},
|
||||
};
|
||||
const wrapped = withPartPinOwnership(sim, 'uno', 'x-1', 'hc-sr04') as typeof sim;
|
||||
expect(wrapped.pinManager).toEqual({ id: 'pm' });
|
||||
expect(wrapped.spi).toBe(wrapped.spi); // memoised on the real object, once
|
||||
expect(built).toBe(1);
|
||||
expect(wrapped.readValue()).toBe(7); // `this` is the target, not the proxy
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,364 @@
|
|||
// @vitest-environment jsdom
|
||||
/**
|
||||
* Per-board regression net for the wire walk (simulation/PinTrace).
|
||||
*
|
||||
* The HC-SR04 divider bug (see hcsr04-divider-echo.test.ts) was reported on an
|
||||
* ESP32-S3, but nothing about it is ESP32-specific: the walk is shared by every
|
||||
* board family, and the two behaviours the fix introduced — exploring the whole
|
||||
* NODE instead of following a chain, and demoting a supply pad to the weakest
|
||||
* possible answer — can regress on one family while the reported board keeps
|
||||
* working. Each board here re-plays the same shapes through its own
|
||||
* boardPinToNumber naming ('7', 'GP15', 'PC13', bare GPIO numbers).
|
||||
*
|
||||
* The shapes, and why each one is here:
|
||||
*
|
||||
* - the level-shifting divider ECHO ─[1k]─┬─[2k2]─ GND
|
||||
* └─────── GPIO
|
||||
* the tap carries TWO wires; the walk must look at the other one instead of
|
||||
* answering "tied to a rail" with the first branch that reaches GND.
|
||||
*
|
||||
* - the pull-up drawn BEFORE the signal wire: same demotion, without a
|
||||
* divider, and with the rail sitting on the FIRST wire the walk sees.
|
||||
*
|
||||
* - a pad that genuinely reaches nothing but a rail must STILL answer -1
|
||||
* (an LED cathode returning a GPIO would be a worse bug than the one fixed),
|
||||
* and a diode must not be traced through the way a resistor is.
|
||||
*
|
||||
* - a breadboard strip: the pre-existing multi-hole hop, guarded here so the
|
||||
* node-walk rewrite cannot quietly cost us the ESP32 clock circuit.
|
||||
*/
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
|
||||
// ── Mocks (same shape as hcsr04-divider-echo.test.ts) ───────────────────────
|
||||
// addBoard builds a real simulator / bridge per family; none of them is under
|
||||
// test here, and the AVR and RP2040 ones pull in WASM.
|
||||
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?.();
|
||||
}
|
||||
}
|
||||
|
||||
vi.stubGlobal('WebSocket', MockWebSocket);
|
||||
vi.stubGlobal('requestAnimationFrame', (_cb: FrameRequestCallback) => 1);
|
||||
vi.stubGlobal('cancelAnimationFrame', vi.fn());
|
||||
|
||||
import { useSimulatorStore } from '../store/useSimulatorStore';
|
||||
import { traceDetailed, traceBoardGpio } from '../simulation/PinTrace';
|
||||
import { boardPinToNumber } from '../utils/boardPinMapping';
|
||||
import { isStm32BoardKind } from '../types/board';
|
||||
|
||||
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 },
|
||||
};
|
||||
}
|
||||
|
||||
/** Fresh canvas with exactly one board. The store is a module singleton, so a
|
||||
* leftover board from the previous describe would give the walk a second
|
||||
* place to land. */
|
||||
function freshCanvas(boardKind: string, boardId: string) {
|
||||
useSimulatorStore.setState({ boards: [], components: [], wires: [] } as never);
|
||||
useSimulatorStore.getState().addBoard(boardKind as never, 0, 0, boardId);
|
||||
}
|
||||
|
||||
function trace(componentId: string, pinName: string) {
|
||||
return traceDetailed(useSimulatorStore.getState() as never, componentId, pinName, 0);
|
||||
}
|
||||
|
||||
/** The reported divider, parameterised by board: the sensor's ECHO goes into
|
||||
* the top resistor, the bottom one goes to GND, and the GPIO hangs off the
|
||||
* tap between them. Returns the wires so a caller can append its own. */
|
||||
function dividerWires(
|
||||
boardId: string,
|
||||
gndPad: string,
|
||||
gpioPad: string,
|
||||
): Array<Record<string, unknown>> {
|
||||
return [
|
||||
wire('d1', ['sensor', 'ECHO'], ['r_top', '1']),
|
||||
wire('d2', ['r_top', '2'], ['r_bottom', '1']),
|
||||
wire('d3', [boardId, gndPad], ['r_bottom', '2']),
|
||||
wire('d4', [boardId, gpioPad], ['r_bottom', '1']),
|
||||
];
|
||||
}
|
||||
|
||||
const DIVIDER_PARTS = [
|
||||
{ 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' } },
|
||||
];
|
||||
|
||||
// ── Arduino Uno (AVR, bare digital pin names) ───────────────────────────────
|
||||
describe('arduino-uno', () => {
|
||||
const BOARD = 'arduino-uno';
|
||||
|
||||
beforeEach(() => {
|
||||
freshCanvas('arduino-uno', BOARD);
|
||||
const s = useSimulatorStore.getState();
|
||||
s.setComponents([
|
||||
...DIVIDER_PARTS,
|
||||
{ id: 'led1', metadataId: 'led', x: 0, y: 0, properties: {} },
|
||||
{ id: 'r_led', metadataId: 'resistor-220', x: 0, y: 0, properties: { value: '220' } },
|
||||
] as never);
|
||||
s.setWires([
|
||||
wire('u1', [BOARD, '5V'], ['sensor', 'VCC']),
|
||||
wire('u2', ['sensor', 'TRIG'], [BOARD, '9']),
|
||||
wire('u3', [BOARD, 'GND.1'], ['sensor', 'GND']),
|
||||
...dividerWires(BOARD, 'GND.1', '7'),
|
||||
// A perfectly ordinary LED: anode on D13, cathode to GND through 220R.
|
||||
wire('u4', [BOARD, '13'], ['led1', 'A']),
|
||||
wire('u5', ['led1', 'C'], ['r_led', '1']),
|
||||
wire('u6', ['r_led', '2'], [BOARD, 'GND.1']),
|
||||
] as never);
|
||||
});
|
||||
|
||||
it('traces ECHO across the 1k/2k2 divider to D7', () => {
|
||||
const echo = trace('sensor', 'ECHO');
|
||||
expect(echo.arduinoPin, 'ECHO must reach D7 through the tap').toBe(7);
|
||||
expect(echo.boardId).toBe(BOARD);
|
||||
});
|
||||
|
||||
it('traces the directly wired TRIG to D9', () => {
|
||||
expect(trace('sensor', 'TRIG').arduinoPin).toBe(9);
|
||||
});
|
||||
|
||||
it('still answers -1 for an LED cathode that only reaches GND', () => {
|
||||
// Two ways this could regress: the walk crossing the LED (a diode is not a
|
||||
// passive the digital layer may traverse, so D13 must NOT come back), or
|
||||
// railHit being dropped now that it no longer returns inline.
|
||||
const cathode = trace('led1', 'C');
|
||||
expect(cathode.arduinoPin, 'the cathode net is the GND rail, nothing else').toBe(-1);
|
||||
expect(traceBoardGpio(useSimulatorStore.getState() as never, 'led1', 'C')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Raspberry Pi Pico (RP2040, 'GPnn' pad names) ────────────────────────────
|
||||
describe('raspberry-pi-pico', () => {
|
||||
const BOARD = 'raspberry-pi-pico';
|
||||
|
||||
beforeEach(() => {
|
||||
freshCanvas('raspberry-pi-pico', BOARD);
|
||||
const s = useSimulatorStore.getState();
|
||||
s.setComponents(DIVIDER_PARTS as never);
|
||||
s.setWires([
|
||||
wire('p1', [BOARD, 'VSYS'], ['sensor', 'VCC']),
|
||||
wire('p2', ['sensor', 'TRIG'], [BOARD, 'GP14']),
|
||||
wire('p3', [BOARD, 'GND.3'], ['sensor', 'GND']),
|
||||
// The pad is named 'GP15' here; the walk must hand the NAME to
|
||||
// boardPinToNumber rather than assume a bare number like the ESP32 pads.
|
||||
...dividerWires(BOARD, 'GND.3', 'GP15'),
|
||||
] as never);
|
||||
});
|
||||
|
||||
it('traces ECHO across the divider to GP15', () => {
|
||||
const echo = trace('sensor', 'ECHO');
|
||||
expect(echo.arduinoPin).toBe(15);
|
||||
expect(echo.boardId).toBe(BOARD);
|
||||
});
|
||||
|
||||
it('traces the directly wired TRIG to GP14', () => {
|
||||
expect(trace('sensor', 'TRIG').arduinoPin).toBe(14);
|
||||
});
|
||||
});
|
||||
|
||||
// ── A pull-up drawn before the signal wire ──────────────────────────────────
|
||||
// Wire ORDER is the whole point: the first wire on the pin leads to a supply
|
||||
// pad through a resistor, so a walk that returns the first non-null answer
|
||||
// reports "rail" and the GPIO on the second wire is never seen.
|
||||
describe('a pull-up drawn before the signal wire', () => {
|
||||
const CASES: Array<{ kind: string; id: string; rail: string; gpio: string; expect: number }> = [
|
||||
{ kind: 'arduino-uno', id: 'arduino-uno', rail: '5V', gpio: '2', expect: 2 },
|
||||
{ kind: 'esp32', id: 'esp32', rail: '3V3', gpio: '4', expect: 4 },
|
||||
];
|
||||
|
||||
for (const c of CASES) {
|
||||
it(`resolves the GPIO, not the ${c.rail} pad behind the resistor (${c.kind})`, () => {
|
||||
freshCanvas(c.kind, c.id);
|
||||
const s = useSimulatorStore.getState();
|
||||
s.setComponents([
|
||||
{ id: 'dht', metadataId: 'dht22', x: 0, y: 0, properties: {} },
|
||||
{ id: 'r_pu', metadataId: 'resistor-10k', x: 0, y: 0, properties: { value: '10000' } },
|
||||
] as never);
|
||||
s.setWires([
|
||||
wire('a1', ['dht', 'SDA'], ['r_pu', '1']),
|
||||
wire('a2', ['r_pu', '2'], [c.id, c.rail]),
|
||||
wire('a3', ['dht', 'SDA'], [c.id, c.gpio]),
|
||||
] as never);
|
||||
const hit = trace('dht', 'SDA');
|
||||
expect(hit.arduinoPin).toBe(c.expect);
|
||||
expect(hit.boardId).toBe(c.id);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// ── STM32 ('PA0' / 'PC13' port-pin names) ───────────────────────────────────
|
||||
// The family only reaches the walk if boardPinToNumber knows its pad names;
|
||||
// check that first so a missing mapping reads as "skipped", not as a trace bug.
|
||||
const STM32_KIND = 'stm32-bluepill';
|
||||
const STM32_PA0 = boardPinToNumber(STM32_KIND, 'PA0');
|
||||
const STM32_PC13 = boardPinToNumber(STM32_KIND, 'PC13');
|
||||
const STM32_MAPPED =
|
||||
isStm32BoardKind(STM32_KIND) && STM32_PA0 !== null && STM32_PC13 !== null;
|
||||
|
||||
describe.skipIf(!STM32_MAPPED)('stm32-bluepill', () => {
|
||||
const BOARD = 'stm32-bluepill';
|
||||
|
||||
beforeEach(() => {
|
||||
freshCanvas(STM32_KIND, BOARD);
|
||||
const s = useSimulatorStore.getState();
|
||||
s.setComponents(DIVIDER_PARTS as never);
|
||||
s.setWires([
|
||||
wire('s1', [BOARD, '5V'], ['sensor', 'VCC']),
|
||||
wire('s2', ['sensor', 'TRIG'], [BOARD, 'PA0']),
|
||||
wire('s3', [BOARD, 'GND.1'], ['sensor', 'GND']),
|
||||
...dividerWires(BOARD, 'GND.1', 'PC13'),
|
||||
] as never);
|
||||
});
|
||||
|
||||
it('traces ECHO across the divider to PC13', () => {
|
||||
// Linear numbering, port*16+pin — the same number Stm32Bridge speaks.
|
||||
expect(trace('sensor', 'ECHO').arduinoPin).toBe(STM32_PC13);
|
||||
});
|
||||
|
||||
it('traces the directly wired TRIG to PA0', () => {
|
||||
// PA0 is linear 0: a legitimate driven pin, and the one value that a
|
||||
// truthiness check instead of `>= 0` would silently drop.
|
||||
expect(STM32_PA0).toBe(0);
|
||||
expect(trace('sensor', 'TRIG').arduinoPin).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ── A pin that only ever reaches a supply pad ───────────────────────────────
|
||||
describe('a pin that only reaches a supply pad', () => {
|
||||
const BOARD = 'esp32';
|
||||
|
||||
beforeEach(() => {
|
||||
freshCanvas('esp32', BOARD);
|
||||
const s = useSimulatorStore.getState();
|
||||
s.setComponents([
|
||||
{ id: 'sensor', metadataId: 'hc-sr04', x: 0, y: 0, properties: {} },
|
||||
{ id: 'r_pu', metadataId: 'resistor-10k', x: 0, y: 0, properties: { value: '10000' } },
|
||||
] as never);
|
||||
s.setWires([
|
||||
// Straight to GND.
|
||||
wire('g1', ['sensor', 'GND'], [BOARD, 'GND.4']),
|
||||
// And to 3V3 through a resistor, with no signal wire anywhere: the
|
||||
// demoted rail is the ONLY answer left, so it has to survive the walk.
|
||||
wire('g2', ['sensor', 'VCC'], ['r_pu', '1']),
|
||||
wire('g3', ['r_pu', '2'], [BOARD, '3V3']),
|
||||
] as never);
|
||||
});
|
||||
|
||||
it('answers -1 for a GND pad', () => {
|
||||
expect(trace('sensor', 'GND').arduinoPin).toBe(-1);
|
||||
});
|
||||
|
||||
it('answers -1 for a 3V3 pad behind a resistor', () => {
|
||||
expect(trace('sensor', 'VCC').arduinoPin).toBe(-1);
|
||||
});
|
||||
|
||||
it('reports both as "no GPIO" through traceBoardGpio', () => {
|
||||
const st = useSimulatorStore.getState() as never;
|
||||
expect(traceBoardGpio(st, 'sensor', 'GND')).toBeNull();
|
||||
expect(traceBoardGpio(st, 'sensor', 'VCC')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Through a breadboard strip ──────────────────────────────────────────────
|
||||
describe('through a breadboard strip', () => {
|
||||
const BOARD = 'arduino-uno';
|
||||
|
||||
beforeEach(() => {
|
||||
freshCanvas('arduino-uno', BOARD);
|
||||
const s = useSimulatorStore.getState();
|
||||
s.setComponents([
|
||||
{ id: 'sensor', metadataId: 'hc-sr04', x: 0, y: 0, properties: {} },
|
||||
{ id: 'bb', metadataId: 'breadboard', x: 0, y: 0, properties: {} },
|
||||
] as never);
|
||||
s.setWires([
|
||||
// Two different holes of the same 5-hole strip (column 18, top half):
|
||||
// internally shorted, with no component to trace "through".
|
||||
wire('b1', ['sensor', 'ECHO'], ['bb', '18t.a']),
|
||||
wire('b2', ['bb', '18t.e'], [BOARD, '6']),
|
||||
] as never);
|
||||
});
|
||||
|
||||
it('resolves a pin that reaches the board through a strip', () => {
|
||||
expect(trace('sensor', 'ECHO').arduinoPin).toBe(6);
|
||||
});
|
||||
|
||||
it('does not invent a connection to a different column', () => {
|
||||
// col17t is a separate net; nothing is wired there, so the answer is
|
||||
// "unreached" (null) rather than a neighbouring GPIO.
|
||||
useSimulatorStore.getState().setWires([
|
||||
wire('b1', ['sensor', 'ECHO'], ['bb', '17t.a']),
|
||||
wire('b2', ['bb', '18t.e'], [BOARD, '6']),
|
||||
] as never);
|
||||
expect(trace('sensor', 'ECHO').arduinoPin).toBeNull();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,234 @@
|
|||
// @vitest-environment jsdom
|
||||
/**
|
||||
* What the wire walk answers when a net offers more than one answer.
|
||||
*
|
||||
* These are the cases that decide whether walking the WHOLE node (rather than
|
||||
* a single chain) is safe. Each topology below is one a user really draws, and
|
||||
* each one broke a plausible implementation of the divider fix:
|
||||
*
|
||||
* - a ground tie point almost always has something else on it (a button's
|
||||
* pull-down, a decoupling cap). If reaching a rail did not END the walk,
|
||||
* a grounded pin would resolve to whatever GPIO the pull-down hangs off;
|
||||
* - a resistor lead used as a tie point can put a custom chip one hop away
|
||||
* from a pin that also reaches a real GPIO. The board pin has to win, even
|
||||
* when the chip is found first;
|
||||
* - the depth budget is spent by CROSSING parts, so a long chain explored
|
||||
* first must not make a shorter route to the same pin unreachable.
|
||||
*
|
||||
* Every case here fails on the first cut of the fix (57325aa), which returned
|
||||
* the first non-null answer any branch produced.
|
||||
*/
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { useSimulatorStore } from '../store/useSimulatorStore';
|
||||
import { traceDetailed } from '../simulation/PinTrace';
|
||||
|
||||
const BOARD = 'arduino-uno';
|
||||
|
||||
const wire = (id: string, a: [string, string], b: [string, string]) => ({
|
||||
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 },
|
||||
});
|
||||
|
||||
function load(components: unknown[], wires: unknown[]) {
|
||||
useSimulatorStore.setState({ boards: [], components: [], wires: [] } as never);
|
||||
useSimulatorStore.getState().addBoard('arduino-uno' as never, 0, 0, BOARD);
|
||||
const s = useSimulatorStore.getState();
|
||||
s.setComponents(components as never);
|
||||
s.setWires(wires as never);
|
||||
return useSimulatorStore.getState();
|
||||
}
|
||||
|
||||
const R = (id: string, metadataId = 'resistor-1k') => ({
|
||||
id,
|
||||
metadataId,
|
||||
x: 0,
|
||||
y: 0,
|
||||
properties: { value: '1000' },
|
||||
});
|
||||
|
||||
describe('what wins on a net', () => {
|
||||
beforeEach(() => useSimulatorStore.setState({ boards: [], components: [], wires: [] } as never));
|
||||
|
||||
it('a node that touches a rail IS the rail, whatever hangs off it', () => {
|
||||
// The LED's cathode is soldered to the same lead as a button's pull-down,
|
||||
// and that lead goes to GND. The cathode is grounded — it is not "wired to
|
||||
// D2" just because the pull-down's other leg is.
|
||||
const state = load(
|
||||
[{ id: 'led1', metadataId: 'led', x: 0, y: 0, properties: {} }, R('r-pulldown')],
|
||||
[
|
||||
wire('g1', ['led1', 'C'], ['r-pulldown', '2']),
|
||||
wire('g2', ['r-pulldown', '2'], [BOARD, 'GND']),
|
||||
wire('g3', ['r-pulldown', '1'], [BOARD, '2']),
|
||||
],
|
||||
);
|
||||
expect(traceDetailed(state as never, 'led1', 'C', 0).arduinoPin).toBe(-1);
|
||||
});
|
||||
|
||||
it('but a rail BEHIND a component never hides a GPIO on the near side', () => {
|
||||
// The divider shape: the walk crosses the top resistor, and the tap it
|
||||
// lands on carries both the lower resistor (to GND) and the wire to D7.
|
||||
const state = load(
|
||||
[R('r-top'), R('r-bot', 'resistor-2k2')],
|
||||
[
|
||||
wire('d1', ['sensor', 'ECHO'], ['r-top', '1']),
|
||||
wire('d2', ['r-top', '2'], ['r-bot', '1']),
|
||||
wire('d3', ['r-bot', '2'], [BOARD, 'GND']),
|
||||
wire('d4', ['r-bot', '1'], [BOARD, '7']),
|
||||
],
|
||||
);
|
||||
expect(traceDetailed(state as never, 'sensor', 'ECHO', 0).arduinoPin).toBe(7);
|
||||
});
|
||||
|
||||
it('a real board pin beats a custom chip found first', () => {
|
||||
// Wire order is the trap: the chip branch is drawn first, so a walk that
|
||||
// returns the first non-null answer hands back a synthetic chip pin and
|
||||
// never looks at the wire to D5 on the very same node.
|
||||
const state = load(
|
||||
[R('r-tie'), { id: 'chip1', metadataId: 'custom-chip', x: 0, y: 0, properties: {} }],
|
||||
[
|
||||
wire('c1', ['part1', 'OUT'], ['r-tie', '1']),
|
||||
wire('c2', ['r-tie', '1'], ['chip1', 'PIN1']),
|
||||
wire('c3', ['part1', 'OUT'], [BOARD, '5']),
|
||||
],
|
||||
);
|
||||
expect(traceDetailed(state as never, 'part1', 'OUT', 0).arduinoPin).toBe(5);
|
||||
});
|
||||
|
||||
it('still falls back to the chip pin when no board pin is reachable', () => {
|
||||
const state = load(
|
||||
[R('r-tie'), { id: 'chip1', metadataId: 'custom-chip', x: 0, y: 0, properties: {} }],
|
||||
[
|
||||
wire('c1', ['part1', 'OUT'], ['r-tie', '1']),
|
||||
wire('c2', ['r-tie', '1'], ['chip1', 'PIN1']),
|
||||
],
|
||||
);
|
||||
const hit = traceDetailed(state as never, 'part1', 'OUT', 0).arduinoPin;
|
||||
expect(hit).not.toBeNull();
|
||||
expect(hit).toBeGreaterThanOrEqual(100000); // synthetic chip-pin space
|
||||
});
|
||||
|
||||
it('a long dead end explored first does not hide a shorter route', () => {
|
||||
// Six resistors in series reach the tie point at the very edge of the
|
||||
// depth budget, so its own continuation is cut. The same tie point is one
|
||||
// resistor away from the start — that shorter route must still be walked,
|
||||
// and it reaches D4.
|
||||
const chain = ['r1', 'r2', 'r3', 'r4', 'r5', 'r6'];
|
||||
const comps = [...chain.map((id) => R(id)), R('r-short'), R('r-far')];
|
||||
const wires = [wire('s0', ['part1', 'OUT'], ['r1', '1'])];
|
||||
for (let i = 0; i < chain.length - 1; i++) {
|
||||
wires.push(wire(`s${i + 1}`, [chain[i], '2'], [chain[i + 1], '1']));
|
||||
}
|
||||
// The long chain lands on the tie point (r-far's near lead) at max depth.
|
||||
wires.push(wire('s-tie', ['r6', '2'], ['r-far', '1']));
|
||||
// The short route to the SAME tie point, drawn last on purpose.
|
||||
wires.push(wire('t1', ['part1', 'OUT'], ['r-short', '1']));
|
||||
wires.push(wire('t2', ['r-short', '2'], ['r-far', '1']));
|
||||
// And the tie point reaches D4 by crossing one more resistor.
|
||||
wires.push(wire('t3', ['r-far', '2'], [BOARD, '4']));
|
||||
const state = load(comps, wires);
|
||||
expect(traceDetailed(state as never, 'part1', 'OUT', 0).arduinoPin).toBe(4);
|
||||
});
|
||||
});
|
||||
|
||||
describe('nets whose answer is not a board pin', () => {
|
||||
beforeEach(() => useSimulatorStore.setState({ boards: [], components: [], wires: [] } as never));
|
||||
|
||||
it('two chips on one wire share ONE net key', async () => {
|
||||
// The chip bus exists so a write on one chip is visible to the other
|
||||
// through the same PinManager key. A walk that answers with the chip it
|
||||
// happened to reach first hands each endpoint its own key, and the two
|
||||
// chips stop hearing each other — with every symptom pointing at the
|
||||
// chips rather than at the walk.
|
||||
const { setChipBusEnabledForTest, resolveChipNetKey } = await import(
|
||||
'../simulation/customChips/chipNets'
|
||||
);
|
||||
setChipBusEnabledForTest(true);
|
||||
try {
|
||||
useSimulatorStore.setState({ boards: [], components: [], wires: [] } as never);
|
||||
const s = useSimulatorStore.getState();
|
||||
s.setComponents([
|
||||
{ id: 'driver', metadataId: 'custom-chip', x: 0, y: 0, properties: {} },
|
||||
{ id: 'reader', metadataId: 'custom-chip', x: 0, y: 0, properties: {} },
|
||||
] as never);
|
||||
s.setWires([wire('bus', ['driver', 'D0'], ['reader', 'D0'])] as never);
|
||||
const state = useSimulatorStore.getState();
|
||||
const a = traceDetailed(state as never, 'driver', 'D0', 0).arduinoPin;
|
||||
const b = traceDetailed(state as never, 'reader', 'D0', 0).arduinoPin;
|
||||
expect(a).toBe(b);
|
||||
expect(a).toBe(resolveChipNetKey(state as never, 'driver', 'D0'));
|
||||
} finally {
|
||||
setChipBusEnabledForTest(null);
|
||||
}
|
||||
});
|
||||
|
||||
it('a ground plane on a breadboard rail is still ground', () => {
|
||||
// Same rule as the tie point, through the shape people actually build: the
|
||||
// strip joins the cathode, the board's GND and a pull-down whose far leg
|
||||
// is a GPIO. Which hole the walk happens to reach first must not decide it.
|
||||
const state = load(
|
||||
[
|
||||
{ id: 'seg7', metadataId: '7segment', x: 0, y: 0, properties: {} },
|
||||
{ id: 'bb', metadataId: 'breadboard', x: 0, y: 0, properties: {} },
|
||||
R('r-pd', 'resistor-10k'),
|
||||
],
|
||||
[
|
||||
wire('b1', ['seg7', 'COM'], ['bb', 'bn.1']),
|
||||
wire('b2', ['r-pd', '2'], ['bb', 'bn.20']),
|
||||
wire('b3', ['r-pd', '1'], [BOARD, '2']),
|
||||
wire('b4', [BOARD, 'GND'], ['bb', 'bn.10']),
|
||||
],
|
||||
);
|
||||
expect(traceDetailed(state as never, 'seg7', 'COM', 0).arduinoPin).toBe(-1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('cost of walking the net', () => {
|
||||
// Walking the whole node is not free, and the shape that hurts is the one
|
||||
// every real canvas has: a ground rail with dozens of legs on it. This is a
|
||||
// guard rail, not a benchmark — it exists so a future change that turns the
|
||||
// walk quadratic again shows up here instead of as "the canvas feels slow".
|
||||
it('stays well under a millisecond per pin on a 300-wire canvas', () => {
|
||||
useSimulatorStore.setState({ boards: [], components: [], wires: [] } as never);
|
||||
useSimulatorStore.getState().addBoard('arduino-uno' as never, 0, 0, BOARD);
|
||||
const comps: unknown[] = [{ id: 'bb', metadataId: 'breadboard', x: 0, y: 0, properties: {} }];
|
||||
const wires: unknown[] = [wire('gnd', [BOARD, 'GND'], ['bb', 'bn.1'])];
|
||||
const N = 100;
|
||||
for (let i = 0; i < N; i++) {
|
||||
comps.push({ id: `led${i}`, metadataId: 'led', x: 0, y: 0, properties: {} });
|
||||
comps.push({ id: `r${i}`, metadataId: 'resistor-1k', x: 0, y: 0, properties: { value: '1000' } });
|
||||
wires.push(wire(`w${i}a`, [`led${i}`, 'C'], [`r${i}`, '1']));
|
||||
wires.push(wire(`w${i}b`, [`r${i}`, '2'], ['bb', `bn.${(i % 28) + 2}`]));
|
||||
wires.push(wire(`w${i}c`, [`led${i}`, 'A'], [BOARD, String((i % 12) + 2)]));
|
||||
}
|
||||
const s = useSimulatorStore.getState();
|
||||
s.setComponents(comps as never);
|
||||
s.setWires(wires as never);
|
||||
const state = useSimulatorStore.getState();
|
||||
|
||||
// The anodes go straight to a GPIO — the common case, and the one the
|
||||
// early exit is for.
|
||||
let t0 = performance.now();
|
||||
for (let i = 0; i < N; i++) {
|
||||
expect(traceDetailed(state as never, `led${i}`, 'A', 0).arduinoPin).toBe((i % 12) + 2);
|
||||
}
|
||||
const perDriven = (performance.now() - t0) / N;
|
||||
|
||||
// The cathodes all land on the same rail — the worst case, where the whole
|
||||
// node has to be collected before "ground" is certain.
|
||||
t0 = performance.now();
|
||||
for (let i = 0; i < N; i++) {
|
||||
expect(traceDetailed(state as never, `led${i}`, 'C', 0).arduinoPin).toBe(-1);
|
||||
}
|
||||
const perRail = (performance.now() - t0) / N;
|
||||
|
||||
console.log(
|
||||
`[pin-trace] 301 wires: ${perDriven.toFixed(3)} ms/pin driven, ` +
|
||||
`${perRail.toFixed(3)} ms/pin on the shared rail`,
|
||||
);
|
||||
expect(perDriven).toBeLessThan(3);
|
||||
expect(perRail).toBeLessThan(8);
|
||||
});
|
||||
});
|
||||
|
|
@ -34,6 +34,7 @@ import {
|
|||
} from '../simulation/PinResolver';
|
||||
import { BOARD_PIN_GROUPS } from '../simulation/spice/boardPinGroups';
|
||||
import { traceDetailed } from '../simulation/PinTrace';
|
||||
import { withPartPinOwnership, releasePartPins } from '../simulation/partPinOwnership';
|
||||
import { getMixedModeScheduler } from '../simulation/spice/MixedModeScheduler';
|
||||
import { getBoardLogicFamily } from '../simulation/LogicFamilies';
|
||||
|
||||
|
|
@ -638,9 +639,22 @@ export const DynamicComponent: React.FC<DynamicComponentProps> = ({
|
|||
}
|
||||
}
|
||||
|
||||
// Every pin this part drives itself becomes ITS pin for as long as it is
|
||||
// attached, so the SPICE-threshold input path leaves that line alone (see
|
||||
// simulation/partPinOwnership). Claims are implicit — a part that never
|
||||
// drives a pin never takes one — and released in the cleanup below, so a
|
||||
// rewire or an unmount hands the pin straight back to the circuit.
|
||||
releasePartPins(id);
|
||||
const ownedSimulator = withPartPinOwnership(
|
||||
stubSimulator,
|
||||
piBoardId ?? wiredBoardId ?? useSimulatorStore.getState().activeBoardId ?? null,
|
||||
id,
|
||||
metadata.id,
|
||||
);
|
||||
|
||||
cleanupSimulationEvents = logic.attachEvents(
|
||||
el,
|
||||
stubSimulator,
|
||||
ownedSimulator,
|
||||
getArduinoPin,
|
||||
id,
|
||||
getPinResolver,
|
||||
|
|
@ -649,6 +663,7 @@ export const DynamicComponent: React.FC<DynamicComponentProps> = ({
|
|||
|
||||
return () => {
|
||||
if (cleanupSimulationEvents) cleanupSimulationEvents();
|
||||
releasePartPins(id);
|
||||
|
||||
el.removeEventListener('button-press', onButtonPress);
|
||||
el.removeEventListener('button-release', onButtonRelease);
|
||||
|
|
|
|||
|
|
@ -63,6 +63,12 @@ export function toQemuBoardType(kind: BoardKind): 'esp32' | 'esp32-s3' | 'esp32-
|
|||
return 'esp32'; // esp32, esp32-devkit-c-v4, esp32-cam, wemos-lolin32-lite
|
||||
}
|
||||
|
||||
/** Sensor kinds whose model drives the pad itself for the whole exchange — a
|
||||
* DHT22's DATA line, an HC-SR04's TRIG/ECHO pair. Anything else registered
|
||||
* through the sensor channel (ePaper, keypad, I2C devices) leaves its GPIOs to
|
||||
* the host. */
|
||||
const SINGLE_WIRE_SENSOR_TYPES = new Set(['dht22', 'hc-sr04']);
|
||||
|
||||
const API_BASE = (): string => {
|
||||
// The desktop shell injects the sidecar URL at runtime (random port) via
|
||||
// window.__VELXIO_API_BASE__; honor it first so the QEMU-board WebSocket
|
||||
|
|
@ -620,7 +626,14 @@ export class Esp32Bridge {
|
|||
for (const s of sensors) {
|
||||
const pin = s['pin'];
|
||||
const idx = merged.findIndex((e) => e['pin'] === pin);
|
||||
if (idx >= 0) merged[idx] = s;
|
||||
// FIELD-level merge, not object replacement. The two registration paths
|
||||
// see different amounts: the part resolves the sensor's extra pins
|
||||
// through the full wire walk, while the store's pre-registration knows
|
||||
// the component's properties. Replacing the whole entry let the coarser
|
||||
// one silently drop `echo_pin` — the QEMU worker then fell back to
|
||||
// TRIG+1 and pulsed a pin nobody was reading (the 1k/2k2 divider
|
||||
// report). Whoever writes last still wins per FIELD.
|
||||
if (idx >= 0) merged[idx] = { ...merged[idx], ...s };
|
||||
else merged.push(s);
|
||||
}
|
||||
this._pendingSensors = merged;
|
||||
|
|
@ -667,8 +680,15 @@ export class Esp32Bridge {
|
|||
* 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 {
|
||||
ownsSensorPin(gpioPin: number): boolean {
|
||||
for (const s of this._pendingSensors) {
|
||||
// ONLY the single-wire sensors own a pad. The same channel registers
|
||||
// plenty of other things — an ePaper panel's DC/BUSY pins, a membrane
|
||||
// keypad's rows, every I2C device on a virtual 200+addr pin — and those
|
||||
// still need the host to drive their real GPIOs. Blocking those was the
|
||||
// difference between this guard and the in-browser engines' narrow
|
||||
// SingleWireSensorHub.ownsPin, which is the behaviour to match.
|
||||
if (!SINGLE_WIRE_SENSOR_TYPES.has(String(s['sensor_type']))) continue;
|
||||
if (s['pin'] === gpioPin) return true;
|
||||
if (s['echo_pin'] === gpioPin) return true;
|
||||
}
|
||||
|
|
@ -677,7 +697,7 @@ export class Esp32Bridge {
|
|||
|
||||
/** Drive a GPIO pin from an external source (e.g. connected Arduino) */
|
||||
sendPinEvent(gpioPin: number, state: boolean): void {
|
||||
if (this.sensorOwnsPin(gpioPin)) return;
|
||||
if (this.ownsSensorPin(gpioPin)) return;
|
||||
this._send({ type: 'esp32_gpio_in', data: { pin: gpioPin, state: state ? 1 : 0 } });
|
||||
}
|
||||
|
||||
|
|
|
|||
Binary file not shown.
|
|
@ -0,0 +1,135 @@
|
|||
/**
|
||||
* partPinOwnership — which board pins a PART drives itself.
|
||||
*
|
||||
* Some parts are not in the SPICE netlist at all: an HC-SR04 answers a trigger
|
||||
* with a timed ECHO pulse, a DHT22 bit-bangs a frame on its DATA line, a rotary
|
||||
* encoder and a keypad emit edges from their own event model. They drive those
|
||||
* pins by calling the simulator directly, and nothing electrical describes them.
|
||||
*
|
||||
* connectDigitalInputsToMcu thresholds the SOLVED node voltage into every input
|
||||
* pin, and its `sourcedNets` gate was the only thing keeping it off those pins:
|
||||
* a net with no real component on it is "unsourced", so a part-managed line was
|
||||
* left alone. That gate is wrong as soon as the user wires the part the way real
|
||||
* hardware needs it — a pull-up on a DHT22, or the 1k/2k2 divider a 5 V HC-SR04
|
||||
* needs to talk to a 3.3 V board. Then the net IS backed by components, solves
|
||||
* at whatever the passive network says (~0 V, since nothing models the sensor's
|
||||
* output), and the connector pins the line the part is trying to pulse. The
|
||||
* sensor answers and the MCU never sees it.
|
||||
*
|
||||
* So ownership is explicit and board-agnostic: while a part is attached and has
|
||||
* driven a pin, that pin is ITS pin, and the SPICE-threshold path leaves it
|
||||
* alone — on AVR, RP2040, STM32, ESP32 and every overlay board alike. The
|
||||
* in-browser ESP32 engines already had a private version of this rule
|
||||
* (SingleWireSensorHub.ownsPin); this is the same rule where every board can
|
||||
* reach it.
|
||||
*
|
||||
* Claims are made implicitly: DynamicComponent hands each part a simulator
|
||||
* whose setPinState / schedulePinChange / registerSensor claim the pin they
|
||||
* touch, keyed by the component id so a re-attach or an unmount releases
|
||||
* exactly what that component took. A part that never drives a pin (a button,
|
||||
* an LED — things SPICE models properly) never claims anything and keeps
|
||||
* reading the real circuit.
|
||||
*/
|
||||
|
||||
import { isSpiceMapped } from './spice/componentToSpice';
|
||||
|
||||
/** boardId -> pin -> set of component ids currently claiming it. */
|
||||
const claims = new Map<string, Map<number, Set<string>>>();
|
||||
|
||||
/** Reverse index so releasing a component is exact and cheap. */
|
||||
const byOwner = new Map<string, Array<{ boardId: string; pin: number }>>();
|
||||
|
||||
/** Record that `ownerId` (a component) drives `pin` on `boardId`. */
|
||||
export function claimPartPin(boardId: string, pin: number, ownerId: string): void {
|
||||
if (!boardId || !Number.isFinite(pin) || pin < 0) return;
|
||||
let board = claims.get(boardId);
|
||||
if (!board) claims.set(boardId, (board = new Map()));
|
||||
let owners = board.get(pin);
|
||||
if (!owners) board.set(pin, (owners = new Set()));
|
||||
if (owners.has(ownerId)) return;
|
||||
owners.add(ownerId);
|
||||
const list = byOwner.get(ownerId);
|
||||
if (list) list.push({ boardId, pin });
|
||||
else byOwner.set(ownerId, [{ boardId, pin }]);
|
||||
}
|
||||
|
||||
/** Drop every claim made by one component (unmount, re-attach, rewire). */
|
||||
export function releasePartPins(ownerId: string): void {
|
||||
const list = byOwner.get(ownerId);
|
||||
if (!list) return;
|
||||
byOwner.delete(ownerId);
|
||||
for (const { boardId, pin } of list) {
|
||||
const board = claims.get(boardId);
|
||||
const owners = board?.get(pin);
|
||||
if (!owners) continue;
|
||||
owners.delete(ownerId);
|
||||
if (owners.size === 0) board!.delete(pin);
|
||||
if (board!.size === 0) claims.delete(boardId);
|
||||
}
|
||||
}
|
||||
|
||||
/** True when a part drives this pin itself, so no other layer should. */
|
||||
export function isPartOwnedPin(boardId: string, pin: number): boolean {
|
||||
return !!claims.get(boardId)?.has(pin);
|
||||
}
|
||||
|
||||
/** Test/reset seam — also used when the whole workspace is replaced. */
|
||||
export function clearPartPinOwnership(): void {
|
||||
claims.clear();
|
||||
byOwner.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap the simulator handed to a part so every pin it drives is claimed for
|
||||
* `ownerId`. Kept as a Proxy rather than a copy because parts reach deep into
|
||||
* the object (`pinManager`, the lazy `spi` getter, `registerSensor`, board
|
||||
* specific extras) and those must keep their own `this`.
|
||||
*
|
||||
* `registerSensor(type, pin, props)` counts as driving: the sensor protocol
|
||||
* then runs in the backend QEMU worker (or an in-browser engine's sensor hub)
|
||||
* and owns both its data pin and any extra pin the props name — `echo_pin` for
|
||||
* an HC-SR04, which is exactly the line the divider report was about.
|
||||
*/
|
||||
export function withPartPinOwnership<T extends object | null>(
|
||||
sim: T,
|
||||
boardId: string | null,
|
||||
ownerId: string,
|
||||
metadataId: string,
|
||||
): T {
|
||||
// The rule lives HERE so no caller can forget half of it: a component the
|
||||
// netlist can describe does NOT get to own its pins. A button, a slide
|
||||
// switch, a pot are all in SPICE — the solved circuit is the truth for them,
|
||||
// which is the whole point of spiceDrivenInputs (a mis-wired button must
|
||||
// read stuck, not "work anyway"). Ownership is for the parts SPICE says
|
||||
// nothing about: the sensors, the encoders, the keypads, whose only model is
|
||||
// their own event code. The day one of them gets a mapper, it stops claiming
|
||||
// and the circuit takes over, with nothing to update here.
|
||||
if (!sim || !boardId || isSpiceMapped(metadataId)) return sim;
|
||||
const claim = (pin: unknown) => {
|
||||
if (typeof pin === 'number') claimPartPin(boardId, pin, ownerId);
|
||||
};
|
||||
return new Proxy(sim as object, {
|
||||
get(target, prop, _recv) {
|
||||
// The receiver is the TARGET on purpose: lazy getters (`spi`) memoise on
|
||||
// the real object, and methods must not see the proxy as `this`.
|
||||
const value = Reflect.get(target, prop, target);
|
||||
if (typeof value !== 'function') return value;
|
||||
if (prop === 'setPinState' || prop === 'schedulePinChange') {
|
||||
return (pin: number, ...rest: unknown[]) => {
|
||||
claim(pin);
|
||||
return (value as (...a: unknown[]) => unknown).call(target, pin, ...rest);
|
||||
};
|
||||
}
|
||||
if (prop === 'registerSensor') {
|
||||
return (type: string, pin: number, props?: Record<string, unknown>) => {
|
||||
claim(pin);
|
||||
for (const [k, v] of Object.entries(props ?? {})) {
|
||||
if (k.endsWith('_pin') || k.endsWith('Pin')) claim(v);
|
||||
}
|
||||
return (value as (...a: unknown[]) => unknown).call(target, type, pin, props);
|
||||
};
|
||||
}
|
||||
return (value as (...a: unknown[]) => unknown).bind(target);
|
||||
},
|
||||
}) as T;
|
||||
}
|
||||
|
|
@ -16,10 +16,26 @@
|
|||
* (custom-chip path): it knows ONLY the electrical store shape.
|
||||
*
|
||||
* Only pins the MCU is NOT actively driving as outputs are injected, so we
|
||||
* never fight a `digitalWrite`. Other boards (AVR / RP2040) keep the legacy
|
||||
* part-seed path; only the ESP32 QEMU bridge opts in (`spiceDrivenInputs`).
|
||||
* never fight a `digitalWrite`. Every simulator that opts in with
|
||||
* `spiceDrivenInputs` takes this path — AVR, RP2040, STM32 and the ESP32
|
||||
* bridges all do today.
|
||||
*
|
||||
* TWO kinds of pin are left alone, and the reason is the same for both: some
|
||||
* parts have no electrical model at all, so the solved node says nothing about
|
||||
* what they are doing.
|
||||
* - pins a PART drives itself (simulation/partPinOwnership): an HC-SR04's
|
||||
* ECHO pulse, a DHT22's frame, a rotary encoder's edges;
|
||||
* - pins the board's own bridge reports as sensor-owned (`ownsPin`), which is
|
||||
* what a backend- or engine-emulated sensor registers.
|
||||
* The older `sourcedNets` gate below tried to cover this by skipping nets no
|
||||
* component sits on, and that works right up until the user wires the sensor
|
||||
* the way real hardware needs it — a pull-up, or the level divider a 5 V
|
||||
* HC-SR04 needs to talk to a 3.3 V pad. Then the net IS component-backed, it
|
||||
* solves at whatever the passives say (~0 V, since nothing models the sensor's
|
||||
* output), and this connector pins the very line the sensor is answering on.
|
||||
*/
|
||||
import { useSimulatorStore, getBoardSimulator, getBoardPinManager } from '../../store/useSimulatorStore';
|
||||
import { isPartOwnedPin } from '../partPinOwnership';
|
||||
import { useElectricalStore } from '../../store/useElectricalStore';
|
||||
import { isStm32BoardKind } from '../../types/board';
|
||||
import type { BoardKind } from '../../types/board';
|
||||
|
|
@ -56,7 +72,12 @@ export function connectDigitalInputsToMcu(): () => void {
|
|||
const { boards } = useSimulatorStore.getState();
|
||||
for (const board of boards) {
|
||||
const sim = getBoardSimulator(board.id) as
|
||||
| { setPinState?: (pin: number, state: boolean) => void; spiceDrivenInputs?: boolean }
|
||||
| {
|
||||
setPinState?: (pin: number, state: boolean) => void;
|
||||
spiceDrivenInputs?: boolean;
|
||||
/** Optional seam: pins the simulator/bridge drives itself. */
|
||||
ownsPin?: (pin: number) => boolean;
|
||||
}
|
||||
| null;
|
||||
if (!sim?.spiceDrivenInputs || typeof sim.setPinState !== 'function') continue;
|
||||
const pm = getBoardPinManager(board.id);
|
||||
|
|
@ -71,6 +92,16 @@ export function connectDigitalInputsToMcu(): () => void {
|
|||
const gpio = isStm32 ? stm32PinNameToLinear(pinName) : gpioFromPinName(pinName, board.boardKind);
|
||||
if (gpio < 0) continue;
|
||||
if (driven.has(gpio)) continue; // the MCU drives this pin (digitalWrite)
|
||||
// A part that models this line ITSELF (an HC-SR04's ECHO pulse, a
|
||||
// DHT22's frame, an encoder's edges) owns it — see partPinOwnership.
|
||||
// The `sourcedNets` gate below cannot protect those: the moment the
|
||||
// user adds the pull-up or the level divider real hardware needs, the
|
||||
// net is component-backed and solves at whatever the passives say,
|
||||
// which is not what the part is driving.
|
||||
// Nothing is cached for a skipped pin on purpose: the connector must
|
||||
// stay free to emit the moment the part lets go of the line.
|
||||
if (isPartOwnedPin(board.id, gpio)) continue;
|
||||
if (sim.ownsPin?.(gpio)) continue; // a backend sensor drives this pad
|
||||
// Only drive pins whose net is backed by a real source/element (rail,
|
||||
// pull, button switch, divider, cross-board output, …). A net that is
|
||||
// only floating (an event-driven part like a rotary encoder / keypad
|
||||
|
|
|
|||
|
|
@ -41,7 +41,6 @@ import { useEditorStore } from './useEditorStore';
|
|||
import { fingerprintSources } from '../utils/sourceFingerprint';
|
||||
import { useVfsStore } from './useVfsStore';
|
||||
import { buildProjectSdImage, decodeSdFiles, bytesToB64 } from '../utils/sdCardFiles';
|
||||
import { boardPinToNumber, isBoardComponent } from '../utils/boardPinMapping';
|
||||
import {
|
||||
autoWireColor,
|
||||
DEFAULT_WIRE_COLOR,
|
||||
|
|
@ -346,6 +345,19 @@ class Esp32BridgeShim {
|
|||
return true; // backend handles the protocol
|
||||
}
|
||||
|
||||
/** Pins a backend-emulated single-wire sensor drives itself. The generic
|
||||
* seam connectDigitalInputsToMcu asks before thresholding a pin into the
|
||||
* guest — see simulation/partPinOwnership for the same rule on the part
|
||||
* side. Both exist because they know different halves: the part layer knows
|
||||
* what the canvas attached, the bridge knows what the worker was told. */
|
||||
ownsPin(pin: number): boolean {
|
||||
// Optional call on purpose: this shim wraps whichever bridge the build
|
||||
// installed (the OSS QEMU one, or an overlay's in-browser engine), and a
|
||||
// missing answer must degrade to "not owned", never to a TypeError thrown
|
||||
// inside the SPICE subscription.
|
||||
return this.bridge.ownsSensorPin?.(pin) ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Expose the underlying Esp32Bridge so simulation parts can subscribe to
|
||||
* board-specific WS events (e.g. `onEpaperUpdate` for the ePaper backend
|
||||
|
|
|
|||
|
|
@ -306,6 +306,10 @@ export function boardPinToNumber(boardId: string, pinName: string): number | nul
|
|||
}
|
||||
|
||||
if (boardId === 'arduino-mega') {
|
||||
// Supply pads BEFORE the numeric parse, or '5V' reads as D5 and '3.3V' as
|
||||
// D3 — a wire to the supply would drive a real pin, and a walk looking for
|
||||
// "what drives this net" would stop at a rail believing it found a GPIO.
|
||||
if (POWER_PAD_RE.test(pinName)) return -1;
|
||||
// Digital pins D0–D53 parsed numerically
|
||||
const num = parseInt(pinName, 10);
|
||||
if (!isNaN(num) && num >= 0 && num <= 53) return num;
|
||||
|
|
@ -362,6 +366,8 @@ export function boardPinToNumber(boardId: string, pinName: string): number | nul
|
|||
|
||||
// Pi Pico W — same GPIO mapping as Raspberry Pi Pico (GP0-GP28 → 0-28)
|
||||
if (boardId === 'pi-pico-w') {
|
||||
// Same trap as the Mega below/above: '3V3' would parse as 3 and '5V' as 5.
|
||||
if (POWER_PAD_RE.test(pinName)) return -1;
|
||||
if (pinName.startsWith('GP')) {
|
||||
const n = parseInt(pinName.substring(2), 10);
|
||||
if (!isNaN(n)) return n;
|
||||
|
|
|
|||
Loading…
Reference in New Issue