velxio/frontend/src/__tests__/esp32-servo-pot.test.ts

735 lines
30 KiB
TypeScript
Raw Normal View History

/**
* esp32-servo-pot.test.ts
*
* Tests for the ESP32 Servo + Potentiometer example, focusing on:
* 1. Servo subscribes to onPwmChange for ESP32 (not AVR cycle measurement)
* 2. Servo uses onPinChange for AVR (existing behavior)
* 3. Servo uses onPinChangeWithTime for RP2040
* 4. LEDC update routes to correct GPIO pin (not LEDC channel)
* 5. LEDC duty_pct is normalized to 0.0–1.0
* 6. LEDC fallback to channel when gpio=-1
* 7. Servo angle maps correctly from duty cycle (pulse-width based)
* 8. Potentiometer setAdcVoltage works for ESP32 via bridge shim
* 9. ESP32 ADC channel mapping (GPIO → ADC1 channel)
* 10. LEDC polling reads float[] duty (not uint32) from QEMU internals(6)
*/
import { describe, it, expect, beforeEach, vi } from 'vitest';
// ── Mocks ────────────────────────────────────────────────────────────────────
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();
this.isRunning = vi.fn().mockReturnValue(true);
this.registerSensor = vi.fn().mockReturnValue(false);
this.pinManager = {
onPinChange: vi.fn().mockReturnValue(() => {}),
onPwmChange: vi.fn().mockReturnValue(() => {}),
updatePwm: vi.fn(),
};
this.getCurrentCycles = vi.fn().mockReturnValue(1000);
this.getClockHz = vi.fn().mockReturnValue(16_000_000);
this.cpu = { data: new Uint8Array(512).fill(0), cycles: 1000 };
}),
}));
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.isRunning = vi.fn().mockReturnValue(true);
this.registerSensor = vi.fn().mockReturnValue(false);
this.pinManager = {
fix(tests): restore Frontend Tests CI — patch stale RP2040 mocks + install-libraries CI's Frontend Tests workflow had been failing on master for ~15 runs. Two pre-existing issues, neither related to the SPI refactor in 8b1433d or the ESP32-CAM work: 1. RP2040Simulator mock missing attachCyw43 method (23 test files) PR #126 (8e769f8 "feat(multi-board): add wire-aware cross-board interconnect router", merged 2026-04-25) added a Pico-W-specific `sim.attachCyw43(bridge)` call inside addBoard(). The 23 test files that mock RP2040Simulator with vi.fn weren't updated; whenever a test path created a Pico W board the mock threw "TypeError: sim.attachCyw43 is not a function" and aborted addBoard. Fix: add `this.attachCyw43 = vi.fn()` to every affected mock. Also pre-populate `this.spi = { onByte: null, completeTransfer: vi.fn() }` so any future SPI-part tests don't trip on the new generic .spi adapter from 8b1433d. 2. install-libraries.test.ts payload mismatch PR #135 (b1026ec7 "library-version-uninstall", merged 2026-04-29) extended `installLibrary(name)` to `installLibrary(name, version?)` and now sends `{name, version: version ?? null}` over the wire. The test still asserted `{name}` only and failed. Fix: assert `{name, version: null}` for the no-version call. Verified locally: 1161 passed | 1 skipped (was 1117 passed | 44 failed). Backend E2E "Run HC-SR04 e2e test" is a separate failure that needs its own investigation — it downloads QEMU binaries from a release and runs real firmware compilation, which I can't reproduce on Windows. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 08:46:32 +07:00
onPinChange: vi.fn().mockReturnValue(() => {
feat(opencore): extract Pico W WiFi to a pluggable PIO peripheral seam Move the CYW43439 (Pico W) WiFi emulation out of the open-source tree so it can ship as a paid feature in a private overlay. OSS keeps a plain Pico W (no WiFi); the overlay registers the cyw43 protocol + backend network stack at runtime via generic seams. Frontend: - Add simulation/PioPeripheral.ts: a generic "PIO bus peripheral" seam (feedWord / inDiscardableWriteData / resetFraming / hostWakeLevel / onHostWake / onSimulationStart). No factory is installed in OSS, so createPioPeripheral() returns null and a Pico W simulates as a plain Pico. - RP2040Simulator: keep the fragile PIO-FIFO plumbing (it must re-run after loadMicroPython swaps the chip) but drive it through PioPeripheral instead of an inlined cyw43 import (attachCyw43 -> attachPioPeripheral, etc.). - useSimulatorStore: generic attach/detach + setBoardWifiStatus; drop the cyw43 bridge map. - MicroPythonLoader: add registerFirmwareVariant() so an overlay can add the RPI_PICO_W build; remove the OSS pico-w config + bundled .uf2. - Delete simulation/cyw43/ (moved to the overlay). Backend: - core/hooks.py: add generic register_ws_sim_handler / dispatch_ws_sim_message and register_gateway_proxy / dispatch_gateway_proxy seams. - simulation.py: route start_picow / stop_picow / picow_packet_out through the ws_sim_handler hook (the overlay handles + gates them). - iot_gateway.py: resolve the Pico W gateway through the gateway_proxy hook. - Delete services/picow_net/ + picow_net_bridge.py (moved to the overlay). Tests: move the cyw43/picow suites to the overlay; update RP2040Simulator mock stubs to attachPioPeripheral.
2026-06-15 13:33:28 +07:00
this.attachPioPeripheral = vi.fn();
fix(tests): restore Frontend Tests CI — patch stale RP2040 mocks + install-libraries CI's Frontend Tests workflow had been failing on master for ~15 runs. Two pre-existing issues, neither related to the SPI refactor in 8b1433d or the ESP32-CAM work: 1. RP2040Simulator mock missing attachCyw43 method (23 test files) PR #126 (8e769f8 "feat(multi-board): add wire-aware cross-board interconnect router", merged 2026-04-25) added a Pico-W-specific `sim.attachCyw43(bridge)` call inside addBoard(). The 23 test files that mock RP2040Simulator with vi.fn weren't updated; whenever a test path created a Pico W board the mock threw "TypeError: sim.attachCyw43 is not a function" and aborted addBoard. Fix: add `this.attachCyw43 = vi.fn()` to every affected mock. Also pre-populate `this.spi = { onByte: null, completeTransfer: vi.fn() }` so any future SPI-part tests don't trip on the new generic .spi adapter from 8b1433d. 2. install-libraries.test.ts payload mismatch PR #135 (b1026ec7 "library-version-uninstall", merged 2026-04-29) extended `installLibrary(name)` to `installLibrary(name, version?)` and now sends `{name, version: version ?? null}` over the wire. The test still asserted `{name}` only and failed. Fix: assert `{name, version: null}` for the no-version call. Verified locally: 1161 passed | 1 skipped (was 1117 passed | 44 failed). Backend E2E "Run HC-SR04 e2e test" is a separate failure that needs its own investigation — it downloads QEMU binaries from a release and runs real firmware compilation, which I can't reproduce on Windows. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 08:46:32 +07:00
this.spi = { onByte: null, completeTransfer: vi.fn() };
}),
onPwmChange: vi.fn().mockReturnValue(() => {}),
updatePwm: vi.fn(),
};
}),
}));
vi.mock('../simulation/PinManager', () => ({
PinManager: vi.fn(function (this: any) {
this.updatePort = vi.fn();
this.onPinChange = vi.fn().mockReturnValue(() => {});
this.onPwmChange = vi.fn().mockReturnValue(() => {});
this.getListenersCount = vi.fn().mockReturnValue(0);
this.hardResetPinStates = vi.fn();
this.updatePwm = vi.fn();
this.triggerPinChange = vi.fn();
}),
}));
vi.mock('../simulation/I2CBusManager', async () => {
const actual = await vi.importActual<typeof import('../simulation/I2CBusManager')>(
'../simulation/I2CBusManager',
);
return actual;
});
vi.mock('../store/useOscilloscopeStore', () => ({
useOscilloscopeStore: {
getState: vi.fn().mockReturnValue({ channels: [], pushSample: vi.fn() }),
},
}));
vi.stubGlobal('requestAnimationFrame', (_cb: FrameRequestCallback) => 1);
vi.stubGlobal('cancelAnimationFrame', vi.fn());
vi.stubGlobal('sessionStorage', {
getItem: vi.fn().mockReturnValue('test-session-id'),
setItem: vi.fn(),
});
// ── Imports (after mocks) ────────────────────────────────────────────────────
import { PartSimulationRegistry } from '../simulation/parts/PartSimulationRegistry';
import '../simulation/parts/ComplexParts';
import { PinManager } from '../simulation/PinManager';
import { RP2040Simulator } from '../simulation/RP2040Simulator';
import { setAdcVoltage } from '../simulation/parts/partUtils';
// ─── Mock factories ──────────────────────────────────────────────────────────
function makeElement(props: Record<string, unknown> = {}): HTMLElement {
return {
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
angle: 0,
...props,
} as unknown as HTMLElement;
}
/** Simulator mock that mimics Esp32BridgeShim (no valid CPU cycles) */
function makeEsp32Shim() {
let pwmCallback: ((pin: number, duty: number) => void) | null = null;
const unsubPwm = vi.fn();
const adcCalls: { channel: number; millivolts: number }[] = [];
return {
pinManager: {
onPinChange: vi.fn().mockReturnValue(() => {}),
onPwmChange: vi
.fn()
.mockImplementation((_pin: number, cb: (pin: number, duty: number) => void) => {
pwmCallback = cb;
return unsubPwm;
}),
updatePwm: vi.fn(),
triggerPinChange: vi.fn(),
},
setPinState: vi.fn(),
isRunning: vi.fn().mockReturnValue(true),
getCurrentCycles: vi.fn().mockReturnValue(-1), // ESP32: no valid cycles
getClockHz: vi.fn().mockReturnValue(240_000_000),
registerSensor: vi.fn().mockReturnValue(true),
updateSensor: vi.fn(),
unregisterSensor: vi.fn(),
// Esp32BridgeShim.setAdcVoltage — mirrors the real implementation
setAdcVoltage: vi.fn().mockImplementation((pin: number, voltage: number) => {
let channel = -1;
if (pin >= 36 && pin <= 39) channel = pin - 36;
else if (pin >= 32 && pin <= 35) channel = pin - 28;
if (channel < 0) return false;
adcCalls.push({ channel, millivolts: Math.round(voltage * 1000) });
return true;
}),
// Test helpers
_getPwmCallback: () => pwmCallback,
_unsubPwm: unsubPwm,
_getAdcCalls: () => adcCalls,
};
}
/** Simulator mock that mimics AVR (has valid CPU cycles) */
function makeAVRSim() {
let pinCallback: ((pin: number, state: boolean) => void) | null = null;
const unsubPin = vi.fn();
return {
pinManager: {
onPinChange: vi
.fn()
.mockImplementation((_pin: number, cb: (pin: number, state: boolean) => void) => {
pinCallback = cb;
return unsubPin;
}),
onPwmChange: vi.fn().mockReturnValue(() => {}),
updatePwm: vi.fn(),
},
setPinState: vi.fn(),
isRunning: vi.fn().mockReturnValue(true),
getCurrentCycles: vi.fn().mockReturnValue(1000),
getClockHz: vi.fn().mockReturnValue(16_000_000),
cpu: { data: new Uint8Array(512).fill(0), cycles: 1000 },
registerSensor: vi.fn().mockReturnValue(false),
// Test helpers
_getPinCallback: () => pinCallback,
_unsubPin: unsubPin,
};
}
const pinMap =
(map: Record<string, number>) =>
(name: string): number | null =>
name in map ? map[name] : null;
// ─────────────────────────────────────────────────────────────────────────────
// 1. Servo — ESP32 path: subscribes to onPwmChange
// ─────────────────────────────────────────────────────────────────────────────
describe('Servo — ESP32 PWM subscription', () => {
const logic = () => PartSimulationRegistry.get('servo')!;
it('subscribes to onPwmChange when simulator has no valid CPU cycles (ESP32 shim)', () => {
const shim = makeEsp32Shim();
const el = makeElement();
logic().attachEvents!(el, shim as any, pinMap({ PWM: 13 }), 'servo-esp32');
expect(shim.pinManager.onPwmChange).toHaveBeenCalledWith(13, expect.any(Function));
});
it('updates angle when PWM duty cycle changes (pulse-width mapping)', () => {
const shim = makeEsp32Shim();
const el = makeElement() as any;
logic().attachEvents!(el, shim as any, pinMap({ PWM: 13 }), 'servo-esp32-angle');
const cb = shim._getPwmCallback();
expect(cb).not.toBeNull();
// ESP32 servo pulse-width mapping:
// MIN_DC = 544/20000 = 0.0272 → 0°
// MAX_DC = 2400/20000 = 0.12 → 180°
const MIN_DC = 544 / 20000;
const MAX_DC = 2400 / 20000;
// At min duty → 0°
cb!(13, MIN_DC);
expect(el.angle).toBe(0);
// At max duty → 180°
cb!(13, MAX_DC);
expect(el.angle).toBe(180);
// At mid duty → ~90°
const midDC = (MIN_DC + MAX_DC) / 2;
cb!(13, midDC);
expect(el.angle).toBe(90);
});
it('ignores out-of-range duty cycles (noise filtering)', () => {
const shim = makeEsp32Shim();
const el = makeElement() as any;
logic().attachEvents!(el, shim as any, pinMap({ PWM: 13 }), 'servo-noise');
const cb = shim._getPwmCallback();
// Set to a known angle first
cb!(13, 0.075); // mid-range
const knownAngle = el.angle;
// Very low duty (< 1%) is ignored
cb!(13, 0.005);
expect(el.angle).toBe(knownAngle); // unchanged
// Very high duty (> 20%) is ignored
cb!(13, 0.5);
expect(el.angle).toBe(knownAngle); // unchanged
});
it('clamps angle to 0-180 range', () => {
const shim = makeEsp32Shim();
const el = makeElement() as any;
logic().attachEvents!(el, shim as any, pinMap({ PWM: 13 }), 'servo-clamp');
const cb = shim._getPwmCallback();
// Slightly below MIN_DC (but above 1% filter) → clamps to 0°
cb!(13, 0.015);
expect(el.angle).toBe(0);
// Slightly above MAX_DC (but below 20% filter) → clamps to 180°
cb!(13, 0.15);
expect(el.angle).toBe(180);
});
it('cleanup unsubscribes from onPwmChange', () => {
const shim = makeEsp32Shim();
const el = makeElement();
const cleanup = logic().attachEvents!(el, shim as any, pinMap({ PWM: 13 }), 'servo-cleanup');
cleanup();
expect(shim._unsubPwm).toHaveBeenCalled();
});
it('does NOT subscribe to onPinChange (AVR cycle measurement)', () => {
const shim = makeEsp32Shim();
const el = makeElement();
logic().attachEvents!(el, shim as any, pinMap({ PWM: 13 }), 'servo-no-pin');
expect(shim.pinManager.onPinChange).not.toHaveBeenCalled();
});
});
// ─────────────────────────────────────────────────────────────────────────────
// 2. Servo — AVR path: uses onPinChange + cycle measurement
// ─────────────────────────────────────────────────────────────────────────────
describe('Servo — AVR cycle-based measurement', () => {
const logic = () => PartSimulationRegistry.get('servo')!;
it('subscribes to onPinChange (not onPwmChange) when simulator has valid CPU cycles', () => {
const avr = makeAVRSim();
const el = makeElement();
logic().attachEvents!(el, avr as any, pinMap({ PWM: 9 }), 'servo-avr');
expect(avr.pinManager.onPinChange).toHaveBeenCalledWith(9, expect.any(Function));
// Should NOT use onPwmChange for AVR
expect(avr.pinManager.onPwmChange).not.toHaveBeenCalled();
});
});
// ─────────────────────────────────────────────────────────────────────────────
// 3. Servo — RP2040 path: uses onPinChangeWithTime (instanceof check)
// ─────────────────────────────────────────────────────────────────────────────
describe('Servo — RP2040 timing-based measurement', () => {
const logic = () => PartSimulationRegistry.get('servo')!;
it('uses onPinChangeWithTime when simulator is RP2040Simulator instance', () => {
const rp = new RP2040Simulator() as any;
const el = makeElement();
logic().attachEvents!(el, rp as any, pinMap({ PWM: 15 }), 'servo-rp2040');
// RP2040 path sets onPinChangeWithTime
expect(rp.onPinChangeWithTime).toBeTypeOf('function');
});
});
// ─────────────────────────────────────────────────────────────────────────────
// 4-6. LEDC update routing — PinManager.updatePwm
// ─────────────────────────────────────────────────────────────────────────────
describe('LEDC update routing', () => {
let pm: any;
beforeEach(() => {
pm = new PinManager();
});
it('routes to GPIO pin when update.gpio >= 0', () => {
const update = { channel: 0, duty: 7.5, duty_pct: 7.5, gpio: 13 };
const targetPin = update.gpio !== undefined && update.gpio >= 0 ? update.gpio : update.channel;
pm.updatePwm(targetPin, update.duty_pct / 100);
expect(pm.updatePwm).toHaveBeenCalledWith(13, 0.075);
});
it('falls back to channel when gpio is -1', () => {
const update = { channel: 2, duty: 50, duty_pct: 50, gpio: -1 };
const targetPin = update.gpio !== undefined && update.gpio >= 0 ? update.gpio : update.channel;
pm.updatePwm(targetPin, update.duty_pct / 100);
expect(pm.updatePwm).toHaveBeenCalledWith(2, 0.5);
});
it('falls back to channel when gpio is undefined', () => {
const update = { channel: 3, duty: 100, duty_pct: 100 } as any;
const targetPin = update.gpio !== undefined && update.gpio >= 0 ? update.gpio : update.channel;
pm.updatePwm(targetPin, update.duty_pct / 100);
expect(pm.updatePwm).toHaveBeenCalledWith(3, 1.0);
});
it('normalizes duty_pct to 0.0–1.0 (divides by 100)', () => {
const update = { channel: 0, duty: 25, duty_pct: 25, gpio: 5 };
const targetPin = update.gpio !== undefined && update.gpio >= 0 ? update.gpio : update.channel;
pm.updatePwm(targetPin, update.duty_pct / 100);
expect(pm.updatePwm).toHaveBeenCalledWith(5, 0.25);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// 7. Servo angle mapping — pulse-width based for ESP32
// ─────────────────────────────────────────────────────────────────────────────
describe('Servo angle mapping (pulse-width)', () => {
const logic = () => PartSimulationRegistry.get('servo')!;
it('maps real servo duty cycles to correct angles', () => {
const shim = makeEsp32Shim();
const el = makeElement() as any;
logic().attachEvents!(el, shim as any, pinMap({ PWM: 13 }), 'servo-map');
const cb = shim._getPwmCallback();
// Servo pulse widths at 50Hz (20ms period):
// 544µs = 2.72% duty → 0°
// 1472µs = 7.36% duty → 90°
// 2400µs = 12.00% duty → 180°
const testCases = [
{ pulseUs: 544, expectedAngle: 0 },
{ pulseUs: 1008, expectedAngle: 45 },
{ pulseUs: 1472, expectedAngle: 90 },
{ pulseUs: 1936, expectedAngle: 135 },
{ pulseUs: 2400, expectedAngle: 180 },
];
for (const { pulseUs, expectedAngle } of testCases) {
const dutyCycle = pulseUs / 20000; // fraction of 20ms period
cb!(13, dutyCycle);
expect(el.angle).toBe(expectedAngle);
}
});
});
// ─────────────────────────────────────────────────────────────────────────────
// 8. Potentiometer — setAdcVoltage on ESP32
// ─────────────────────────────────────────────────────────────────────────────
describe('Potentiometer — ESP32 ADC path', () => {
it('setAdcVoltage delegates to Esp32BridgeShim.setAdcVoltage', () => {
const shim = makeEsp32Shim();
const result = setAdcVoltage(shim as any, 34, 1.65);
expect(result).toBe(true);
expect(shim.setAdcVoltage).toHaveBeenCalledWith(34, 1.65);
});
it('setAdcVoltage returns false for non-ADC ESP32 pins', () => {
const shim = makeEsp32Shim();
// GPIO 13 is not an ADC pin on ESP32
const result = setAdcVoltage(shim as any, 13, 1.65);
expect(result).toBe(false);
});
it('setAdcVoltage works for AVR (pin 14-19)', () => {
const avrSim = makeAVRSim() as any;
avrSim.getADC = () => ({ channelValues: new Array(6).fill(0) });
const result = setAdcVoltage(avrSim as any, 14, 2.5);
expect(result).toBe(true);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// 9. ESP32 ADC channel mapping (GPIO → ADC1 channel)
// ─────────────────────────────────────────────────────────────────────────────
describe('ESP32 ADC channel mapping', () => {
it('maps GPIO 36-39 → ADC1 CH0-3', () => {
const shim = makeEsp32Shim();
setAdcVoltage(shim as any, 36, 1.0);
setAdcVoltage(shim as any, 37, 1.0);
setAdcVoltage(shim as any, 38, 1.0);
setAdcVoltage(shim as any, 39, 1.0);
const calls = shim._getAdcCalls();
expect(calls.map((c) => c.channel)).toEqual([0, 1, 2, 3]);
});
it('maps GPIO 32-35 → ADC1 CH4-7', () => {
const shim = makeEsp32Shim();
setAdcVoltage(shim as any, 32, 1.0);
setAdcVoltage(shim as any, 33, 1.0);
setAdcVoltage(shim as any, 34, 1.0);
setAdcVoltage(shim as any, 35, 1.0);
const calls = shim._getAdcCalls();
expect(calls.map((c) => c.channel)).toEqual([4, 5, 6, 7]);
});
it('converts voltage to millivolts correctly', () => {
const shim = makeEsp32Shim();
setAdcVoltage(shim as any, 34, 1.65);
const calls = shim._getAdcCalls();
expect(calls[0].millivolts).toBe(1650);
});
it('rejects non-ADC GPIOs (0-31)', () => {
const shim = makeEsp32Shim();
const result = setAdcVoltage(shim as any, 13, 1.0);
expect(result).toBe(false);
expect(shim._getAdcCalls()).toHaveLength(0);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// 10. LEDC 0x5000 marker decoding — channel extraction fix
// ─────────────────────────────────────────────────────────────────────────────
describe('LEDC 0x5000 marker decoding', () => {
// QEMU fires: qemu_set_irq(ledc_sync, 0x5000 | (ledn << 8) | intensity)
// Worker must extract: ledc_ch = (direction >> 8) & 0x0F (NOT & 0xFF)
function decodeLedc(direction: number) {
const marker = direction & 0xf000;
if (marker !== 0x5000) return null;
const ledc_ch = (direction >> 8) & 0x0f; // correct: strips marker bits
const intensity = direction & 0xff;
return { ledc_ch, intensity };
}
function decodeLedcBroken(direction: number) {
const marker = direction & 0xf000;
if (marker !== 0x5000) return null;
const ledc_ch = (direction >> 8) & 0xff; // BUG: includes marker bits
const intensity = direction & 0xff;
return { ledc_ch, intensity };
}
it('HS channel 0 (ledn=0): direction=0x500B → ch=0, not ch=80', () => {
const direction = 0x5000 | (0 << 8) | 11; // 0x500B
const correct = decodeLedc(direction)!;
const broken = decodeLedcBroken(direction)!;
expect(correct.ledc_ch).toBe(0); // correct
expect(broken.ledc_ch).toBe(80); // BUG: 0x50 = 80
expect(correct.intensity).toBe(11);
});
it('LS channel 0 (ledn=8): direction=0x5811 → ch=8, not ch=88', () => {
const direction = 0x5000 | (8 << 8) | 17; // 0x5811
const correct = decodeLedc(direction)!;
const broken = decodeLedcBroken(direction)!;
expect(correct.ledc_ch).toBe(8); // correct
expect(broken.ledc_ch).toBe(88); // BUG: 0x58 = 88
expect(correct.intensity).toBe(17);
});
it('HS channel 7 (ledn=7): direction=0x5732 → ch=7', () => {
const direction = 0x5000 | (7 << 8) | 50; // 0x5732
expect(decodeLedc(direction)!.ledc_ch).toBe(7);
expect(decodeLedc(direction)!.intensity).toBe(50);
});
it('LS channel 7 (ledn=15): direction=0x5F64 → ch=15', () => {
const direction = 0x5000 | (15 << 8) | 100; // 0x5F64
expect(decodeLedc(direction)!.ledc_ch).toBe(15);
expect(decodeLedc(direction)!.intensity).toBe(100);
});
it('all 16 channels decode correctly', () => {
for (let ledn = 0; ledn < 16; ledn++) {
const direction = 0x5000 | (ledn << 8) | 42;
const result = decodeLedc(direction)!;
expect(result.ledc_ch).toBe(ledn);
expect(result.intensity).toBe(42);
}
});
});
// ─────────────────────────────────────────────────────────────────────────────
// 11. GPIO out_sel scanning — LEDC→GPIO mapping
// ─────────────────────────────────────────────────────────────────────────────
describe('GPIO out_sel scanning for LEDC mapping', () => {
// Simulates what the LEDC poll thread does: read gpio_out_sel[40] and
// scan for LEDC signal values (72-87) to build _ledc_gpio_map
function scanOutSel(outSel: number[]): Map<number, number> {
const ledcGpioMap = new Map<number, number>();
for (let gpioPin = 0; gpioPin < outSel.length; gpioPin++) {
const signal = outSel[gpioPin] & 0xff;
if (signal >= 72 && signal <= 87) {
const ledcCh = signal - 72;
ledcGpioMap.set(ledcCh, gpioPin);
}
}
return ledcGpioMap;
}
it('detects LEDC HS ch0 (signal=72) on GPIO 13', () => {
const outSel = new Array(40).fill(256); // 256 = no function
outSel[13] = 72; // LEDC HS ch0 → GPIO 13
const map = scanOutSel(outSel);
expect(map.get(0)).toBe(13);
expect(map.size).toBe(1);
});
it('detects LEDC LS ch0 (signal=80) on GPIO 2', () => {
const outSel = new Array(40).fill(256);
outSel[2] = 80; // LEDC LS ch0 → GPIO 2
const map = scanOutSel(outSel);
expect(map.get(8)).toBe(2); // ch8 = LS ch0
});
it('detects multiple LEDC channels', () => {
const outSel = new Array(40).fill(256);
outSel[13] = 72; // HS ch0 → GPIO 13
outSel[12] = 73; // HS ch1 → GPIO 12
outSel[14] = 80; // LS ch0 → GPIO 14
const map = scanOutSel(outSel);
expect(map.get(0)).toBe(13);
expect(map.get(1)).toBe(12);
expect(map.get(8)).toBe(14);
expect(map.size).toBe(3);
});
it('ignores non-LEDC signals (< 72 or > 87)', () => {
const outSel = new Array(40).fill(256);
outSel[5] = 71; // signal 71 = not LEDC
outSel[6] = 88; // signal 88 = not LEDC
outSel[7] = 0; // signal 0 = GPIO matrix simple
const map = scanOutSel(outSel);
expect(map.size).toBe(0);
});
it('explains why 0x2000 marker was broken for LEDC signals', () => {
// QEMU fires: 0x2000 | ((signal & 0xFF) << 8) | (gpio & 0xFF)
// For signal=72 (0x48), gpio=13: direction = 0x2000 | 0x4800 | 0x0D = 0x680D
// marker = direction & 0xF000 = 0x6000 ≠ 0x2000 → NEVER MATCHED!
const signal = 72;
const gpio = 13;
const direction = 0x2000 | ((signal & 0xff) << 8) | (gpio & 0xff);
expect(direction).toBe(0x680d);
expect(direction & 0xf000).toBe(0x6000); // NOT 0x2000!
expect(direction & 0xf000).not.toBe(0x2000); // confirms the bug
});
});
// ─────────────────────────────────────────────────────────────────────────────
// 12. End-to-end: LEDC update with correct GPIO routes to servo
// ─────────────────────────────────────────────────────────────────────────────
describe('End-to-end: LEDC → servo angle', () => {
const logic = () => PartSimulationRegistry.get('servo')!;
refactor(esp32): retire ledc_update + broadcastPwm + channelGpioMemo The SignalRouter path has been in prod through Phase 2.5 / Phase 3.3 deploys without regressions, so the temporary fallback shipped in commit 77bf897 can come out. Closes #101. Backend (esp32_worker.py + esp32_lib_manager.py): - Stop emitting `ledc_update` from the 0x5000 LEDC callback and from the polling thread. Only `ledc_duty` (channel + duty_pct) and the GPIO matrix routing events ship now. - Drop the channel→gpio reverse-lookup that fed the legacy event. Frontend: - Delete `PinManager.broadcastPwm` and `PinManager.pwmListenerPinCount`. - Delete `makeLedcUpdateHandler` + its `channelGpioMemo`. - Delete `Esp32Bridge.onLedcUpdate` field + the `case 'ledc_update':` message handler + the `LedcUpdate` type. - Strip `this.onLedcUpdate = null` from 14 test mocks. - Rewrite the `does not call broadcastPwm` guard in esp32-multi-servo-gpio-matrix.test.ts to assert the method itself no longer exists on PinManager (stronger regression guard than the spy version, and doesn't need vi). - Remove the `PinManager.broadcastPwm fallback` describe block from esp32-servo-pot.test.ts — every test in it exercised the deleted fallback path. Docs (ESP32_EMULATION.md): - Replace `ledc_update` rows in the events / implementation tables with the SignalRouter trio (`ledc_duty`, `gpio_routing`, `gpio_routing_clear`). - Update the visual flow diagram + the "why this matters" paragraph to past-tense the broadcastPwm bug. Tests: 1886 frontend tests pass (the previously-failing board-kinds-coverage test that needed the new Pi Zero/1/2 kinds is also green). Backend unit suite: 279 pass, the 11 espidf_real_paths prereq failures are environment-dependent (need arduino-cli libs in the local shell) and unrelated. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 09:05:34 +07:00
it('ledc_duty for ch=0 routed to gpio=13 → updatePwm(13, duty) → servo moves', () => {
const shim = makeEsp32Shim();
const el = makeElement() as any;
logic().attachEvents!(el, shim as any, pinMap({ PWM: 13 }), 'servo-e2e');
refactor(esp32): retire ledc_update + broadcastPwm + channelGpioMemo The SignalRouter path has been in prod through Phase 2.5 / Phase 3.3 deploys without regressions, so the temporary fallback shipped in commit 77bf897 can come out. Closes #101. Backend (esp32_worker.py + esp32_lib_manager.py): - Stop emitting `ledc_update` from the 0x5000 LEDC callback and from the polling thread. Only `ledc_duty` (channel + duty_pct) and the GPIO matrix routing events ship now. - Drop the channel→gpio reverse-lookup that fed the legacy event. Frontend: - Delete `PinManager.broadcastPwm` and `PinManager.pwmListenerPinCount`. - Delete `makeLedcUpdateHandler` + its `channelGpioMemo`. - Delete `Esp32Bridge.onLedcUpdate` field + the `case 'ledc_update':` message handler + the `LedcUpdate` type. - Strip `this.onLedcUpdate = null` from 14 test mocks. - Rewrite the `does not call broadcastPwm` guard in esp32-multi-servo-gpio-matrix.test.ts to assert the method itself no longer exists on PinManager (stronger regression guard than the spy version, and doesn't need vi). - Remove the `PinManager.broadcastPwm fallback` describe block from esp32-servo-pot.test.ts — every test in it exercised the deleted fallback path. Docs (ESP32_EMULATION.md): - Replace `ledc_update` rows in the events / implementation tables with the SignalRouter trio (`ledc_duty`, `gpio_routing`, `gpio_routing_clear`). - Update the visual flow diagram + the "why this matters" paragraph to past-tense the broadcastPwm bug. Tests: 1886 frontend tests pass (the previously-failing board-kinds-coverage test that needed the new Pi Zero/1/2 kinds is also green). Backend unit suite: 279 pass, the 11 espidf_real_paths prereq failures are environment-dependent (need arduino-cli libs in the local shell) and unrelated. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 09:05:34 +07:00
// Simulate what makeLedcDutyHandler does after the SignalRouter
// resolves ch=0 → SIG_LEDC_HS_CH0 → pin 13:
const update = { channel: 0, duty_pct: 7.36 };
const targetPin = 13; // from router.pinsForSignal(SIG_LEDC_HS_CH0_OUT_IDX + 0)
const dutyCycleFraction = update.duty_pct / 100;
// This is what the store calls:
shim.pinManager.updatePwm(targetPin, dutyCycleFraction);
// The servo's onPwmChange callback should have been triggered
const cb = shim._getPwmCallback();
expect(cb).not.toBeNull();
// Manually invoke the callback (simulating PinManager dispatching)
cb!(13, dutyCycleFraction);
// 7.36% duty = 1472µs pulse → ~90°
expect(el.angle).toBeGreaterThanOrEqual(88);
expect(el.angle).toBeLessThanOrEqual(92);
});
refactor(esp32): retire ledc_update + broadcastPwm + channelGpioMemo The SignalRouter path has been in prod through Phase 2.5 / Phase 3.3 deploys without regressions, so the temporary fallback shipped in commit 77bf897 can come out. Closes #101. Backend (esp32_worker.py + esp32_lib_manager.py): - Stop emitting `ledc_update` from the 0x5000 LEDC callback and from the polling thread. Only `ledc_duty` (channel + duty_pct) and the GPIO matrix routing events ship now. - Drop the channel→gpio reverse-lookup that fed the legacy event. Frontend: - Delete `PinManager.broadcastPwm` and `PinManager.pwmListenerPinCount`. - Delete `makeLedcUpdateHandler` + its `channelGpioMemo`. - Delete `Esp32Bridge.onLedcUpdate` field + the `case 'ledc_update':` message handler + the `LedcUpdate` type. - Strip `this.onLedcUpdate = null` from 14 test mocks. - Rewrite the `does not call broadcastPwm` guard in esp32-multi-servo-gpio-matrix.test.ts to assert the method itself no longer exists on PinManager (stronger regression guard than the spy version, and doesn't need vi). - Remove the `PinManager.broadcastPwm fallback` describe block from esp32-servo-pot.test.ts — every test in it exercised the deleted fallback path. Docs (ESP32_EMULATION.md): - Replace `ledc_update` rows in the events / implementation tables with the SignalRouter trio (`ledc_duty`, `gpio_routing`, `gpio_routing_clear`). - Update the visual flow diagram + the "why this matters" paragraph to past-tense the broadcastPwm bug. Tests: 1886 frontend tests pass (the previously-failing board-kinds-coverage test that needed the new Pi Zero/1/2 kinds is also green). Backend unit suite: 279 pass, the 11 espidf_real_paths prereq failures are environment-dependent (need arduino-cli libs in the local shell) and unrelated. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 09:05:34 +07:00
it('updatePwm targeted at the wrong pin number does NOT reach a servo on pin 13', () => {
// Regression guard for the pre-SignalRouter bug where the worker
// would emit duty with gpio=-1 and the store fell back to using the
// channel number as the pin (ch=80 from broken & 0xFF). With the
// SignalRouter path the channel→pin lookup is authoritative; this
// test now just keeps the dispatch-by-pin invariant honest.
const shim = makeEsp32Shim();
const el = makeElement() as any;
logic().attachEvents!(el, shim as any, pinMap({ PWM: 13 }), 'servo-bug-demo');
const cb = shim._getPwmCallback();
// With the bug: updatePwm would be called with pin=80 (wrong)
// The servo registered on pin 13, so this would NOT trigger it
// (PinManager only dispatches to callbacks registered for that pin)
expect(cb).not.toBeNull();
// Calling with wrong pin does nothing (servo registered on 13, not 80)
cb!(80, 0.075); // wrong pin
// angle still 0 since the real PinManager wouldn't route pin 80 to pin 13's callback
// (In our mock, the callback is directly invoked, but in production it wouldn't fire)
});
});
// ─────────────────────────────────────────────────────────────────────────────
// 13. LEDC polling — data type and internal config
// ─────────────────────────────────────────────────────────────────────────────
describe('LEDC polling — data format', () => {
it('duty values from QEMU are floats representing percentages (0-100)', () => {
// Simulates what the LEDC polling thread reads from QEMU
// QEMU stores: duty[ch] = 100.0 * raw_duty / (16 * (2^duty_res - 1))
// For a servo at 50Hz, 13-bit resolution, 1500µs pulse:
// raw_duty = 1500/20000 * 8192 = 614.4
// duty_pct = 100 * 614.4 / (16 * 8191) ≈ 0.469... but QEMU formula differs
// What matters: duty is a float percentage
const dutyPct = 7.5; // 7.5% = 1500µs at 50Hz = ~90°
// Frontend receives duty_pct, divides by 100
const dutyCycleFraction = dutyPct / 100; // 0.075
// Servo maps pulse width:
const MIN_DC = 544 / 20000; // 0.0272
const MAX_DC = 2400 / 20000; // 0.12
const angle = Math.round(((dutyCycleFraction - MIN_DC) / (MAX_DC - MIN_DC)) * 180);
// 7.5% duty ≈ 93° (close to 90°)
expect(angle).toBeGreaterThanOrEqual(88);
expect(angle).toBeLessThanOrEqual(95);
});
it('LEDC internal config ID is 6 (QEMU_INTERNAL_LEDC_CHANNEL_DUTY)', () => {
// Verifies the constant matches QEMU's definition
// #define QEMU_INTERNAL_LEDC_CHANNEL_DUTY 6
const QEMU_INTERNAL_LEDC_CHANNEL_DUTY = 6;
expect(QEMU_INTERNAL_LEDC_CHANNEL_DUTY).toBe(6);
});
it('deduplication: identical duty values are not re-emitted', () => {
// Simulates the _last_duty tracking in _ledc_poll_thread
const lastDuty = [0.0, 0.0, 0.0];
const emitted: { ch: number; duty: number }[] = [];
function pollOnce(duties: number[]) {
for (let ch = 0; ch < duties.length; ch++) {
const duty = duties[ch];
if (Math.abs(duty - lastDuty[ch]) < 0.01) continue;
lastDuty[ch] = duty;
if (duty > 0) emitted.push({ ch, duty });
}
}
// First poll: duty = 7.5 → emitted
pollOnce([7.5, 0, 0]);
expect(emitted).toHaveLength(1);
expect(emitted[0]).toEqual({ ch: 0, duty: 7.5 });
// Second poll: same duty → NOT emitted (deduplication)
pollOnce([7.5, 0, 0]);
expect(emitted).toHaveLength(1); // still 1
// Third poll: duty changed → emitted
pollOnce([12.0, 0, 0]);
expect(emitted).toHaveLength(2);
expect(emitted[1]).toEqual({ ch: 0, duty: 12.0 });
});
});