velxio/frontend/src/__tests__/hcsr04-divider-echo.test.ts

206 lines
7.5 KiB
TypeScript

// @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);
});
});