732 lines
30 KiB
TypeScript
732 lines
30 KiB
TypeScript
/**
|
||
* 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 = {
|
||
onPinChange: vi.fn().mockReturnValue(() => {}),
|
||
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.updatePwm = vi.fn();
|
||
this.triggerPinChange = vi.fn();
|
||
}),
|
||
}));
|
||
|
||
vi.mock('../simulation/I2CBusManager', () => ({
|
||
VirtualDS1307: vi.fn(function (this: any) {}),
|
||
VirtualTempSensor: vi.fn(function (this: any) {}),
|
||
I2CMemoryDevice: vi.fn(function (this: any) {}),
|
||
}));
|
||
|
||
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')!;
|
||
|
||
it('ledc_update with 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');
|
||
|
||
// Simulate what useSimulatorStore.onLedcUpdate does:
|
||
const update = { channel: 0, duty: 7.36, duty_pct: 7.36, gpio: 13 };
|
||
const targetPin = (update.gpio >= 0) ? update.gpio : update.channel;
|
||
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);
|
||
});
|
||
|
||
it('ledc_update with WRONG ch=80 and gpio=-1 would NOT reach servo on pin 13', () => {
|
||
// This demonstrates the bug that was fixed:
|
||
// ch=80 (from broken & 0xFF) with gpio=-1 → updatePwm(80, duty)
|
||
// But servo listens on pin 13 → callback never fires
|
||
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 });
|
||
});
|
||
});
|