feat: Enhance DHT22 sync handling, improve ADC support for ESP32, and update component metadata

pull/47/head
David Montero Crespo 2026-03-23 10:37:06 -03:00
parent a473cbe74f
commit aa6522fd60
7 changed files with 300 additions and 68 deletions

View File

@ -276,6 +276,15 @@ def main() -> None: # noqa: C901 (complexity OK for inline worker)
Each entry means: after sync_count digitalRead() calls in this phase, Each entry means: after sync_count digitalRead() calls in this phase,
drive the pin to pin_value and advance to the next phase. drive the pin to pin_value and advance to the next phase.
The Adafruit DHT library decodes bits by comparing
highCycles > lowCycles only RATIOS matter, not absolute values.
We use the raw µs values as sync counts to preserve correct ratios.
After the last data bit (40th bit HIGHLOW), the firmware's
expectPulse() loop ends no more syncs will arrive. So we do
NOT add a trailing phase; cleanup happens immediately after the
last phase transition fires.
""" """
phases: list[tuple[int, int]] = [] phases: list[tuple[int, int]] = []
# Preamble: LOW 80 syncs → drive HIGH # Preamble: LOW 80 syncs → drive HIGH
@ -288,8 +297,6 @@ def main() -> None: # noqa: C901 (complexity OK for inline worker)
bit = (byte_val >> b) & 1 bit = (byte_val >> b) & 1
phases.append((50, 1)) # LOW phase → drive HIGH phases.append((50, 1)) # LOW phase → drive HIGH
phases.append((70 if bit else 26, 0)) # HIGH phase → drive LOW phases.append((70 if bit else 26, 0)) # HIGH phase → drive LOW
# Final: LOW 50 syncs → release HIGH
phases.append((50, 1))
return phases return phases
def _dht22_sync_step() -> None: def _dht22_sync_step() -> None:
@ -308,19 +315,8 @@ def main() -> None: # noqa: C901 (complexity OK for inline worker)
phases = state['phases'] phases = state['phases']
if phase_idx >= len(phases): if phase_idx >= len(phases):
# Response complete — clean up # All phases done — clean up immediately
gpio_pin = state['gpio'] _dht22_sync_cleanup(state)
total = state['total_syncs']
with _sensors_lock:
sensor = _sensors.get(gpio_pin)
if sensor:
sensor['responding'] = False
_dht22_sync[0] = None
_log(f'DHT22 sync respond done gpio={gpio_pin} '
f'total_syncs={total} phases={len(phases)}')
_emit({'type': 'system', 'event': 'dht22_diag',
'gpio': gpio_pin, 'status': 'ok',
'total_syncs': total})
return return
target, pin_value = phases[phase_idx] target, pin_value = phases[phase_idx]
@ -329,6 +325,26 @@ def main() -> None: # noqa: C901 (complexity OK for inline worker)
state['total_syncs'] += state['count'] state['total_syncs'] += state['count']
state['count'] = 0 state['count'] = 0
state['phase_idx'] += 1 state['phase_idx'] += 1
# If that was the last phase, clean up now — the firmware's
# expectPulse() loop ends after the last data bit, so no more
# syncs will arrive to trigger cleanup later.
if state['phase_idx'] >= len(phases):
_dht22_sync_cleanup(state)
def _dht22_sync_cleanup(state: dict) -> None:
"""Clean up after DHT22 sync response completes."""
gpio_pin = state['gpio']
total = state['total_syncs']
with _sensors_lock:
sensor = _sensors.get(gpio_pin)
if sensor:
sensor['responding'] = False
_dht22_sync[0] = None
_log(f'DHT22 sync respond done gpio={gpio_pin} '
f'total_syncs={total} phases={len(state["phases"])}')
_emit({'type': 'system', 'event': 'dht22_diag',
'gpio': gpio_pin, 'status': 'ok',
'total_syncs': total})
def _hcsr04_respond(trig_pin: int, echo_pin: int, distance_cm: float) -> None: def _hcsr04_respond(trig_pin: int, echo_pin: int, distance_cm: float) -> None:
"""Thread function: inject the HC-SR04 echo pulse via qemu_picsimlab_set_pin.""" """Thread function: inject the HC-SR04 echo pulse via qemu_picsimlab_set_pin."""
@ -399,10 +415,11 @@ def main() -> None: # noqa: C901 (complexity OK for inline worker)
# thread, perfectly synchronized with the firmware's expectPulse() # thread, perfectly synchronized with the firmware's expectPulse()
# loop iterations. # loop iterations.
if slot == -1: if slot == -1:
if direction == -1 and _dht22_sync[0] is not None: if direction == -1:
# GPIO_IN read — advance DHT22 sync response # GPIO_IN read sync — advance DHT22 response if active
_dht22_sync_step() if _dht22_sync[0] is not None:
return _dht22_sync_step()
return # always return for GPIO_IN syncs (fast path)
marker = direction & 0xF000 marker = direction & 0xF000
if marker == 0x2000: # GPIO_FUNCX_OUT_SEL_CFG change if marker == 0x2000: # GPIO_FUNCX_OUT_SEL_CFG change
gpio_pin = direction & 0xFF gpio_pin = direction & 0xFF
@ -411,6 +428,7 @@ def main() -> None: # noqa: C901 (complexity OK for inline worker)
if 72 <= signal <= 87: if 72 <= signal <= 87:
ledc_ch = signal - 72 # ch 0-15 ledc_ch = signal - 72 # ch 0-15
_ledc_gpio_map[ledc_ch] = gpio_pin _ledc_gpio_map[ledc_ch] = gpio_pin
_log(f'LEDC map: ch{ledc_ch} → GPIO{gpio_pin} (signal={signal})')
return return
# ── DHT22: track direction changes + trigger sync response ─────── # ── DHT22: track direction changes + trigger sync response ───────
@ -429,9 +447,9 @@ def main() -> None: # noqa: C901 (complexity OK for inline worker)
sensor['responding'] = True sensor['responding'] = True
# Build the response waveform phases # Build the response waveform phases
payload = _dht22_build_payload( temp = sensor.get('temperature', 25.0)
sensor.get('temperature', 25.0), hum = sensor.get('humidity', 50.0)
sensor.get('humidity', 50.0)) payload = _dht22_build_payload(temp, hum)
phases = _dht22_build_sync_phases(payload) phases = _dht22_build_sync_phases(payload)
# Drive pin LOW synchronously — firmware sees LOW # Drive pin LOW synchronously — firmware sees LOW
@ -448,6 +466,7 @@ def main() -> None: # noqa: C901 (complexity OK for inline worker)
'total_syncs': 0, 'total_syncs': 0,
} }
_log(f'DHT22 sync armed gpio={gpio} ' _log(f'DHT22 sync armed gpio={gpio} '
f'temp={temp} hum={hum} '
f'phases={len(phases)} payload={payload}') f'phases={len(phases)} payload={payload}')
gpio = int(_PINMAP[slot]) if 1 <= slot <= _GPIO_COUNT else slot gpio = int(_PINMAP[slot]) if 1 <= slot <= _GPIO_COUNT else slot
_emit({'type': 'gpio_dir', 'pin': gpio, 'dir': direction}) _emit({'type': 'gpio_dir', 'pin': gpio, 'dir': direction})
@ -566,22 +585,42 @@ def main() -> None: # noqa: C901 (complexity OK for inline worker)
def _ledc_poll_thread() -> None: def _ledc_poll_thread() -> None:
lib.qemu_picsimlab_get_internals.restype = ctypes.c_void_p lib.qemu_picsimlab_get_internals.restype = ctypes.c_void_p
# Track last-emitted duty to avoid flooding identical updates
_last_duty = [0.0] * 16
_diag_count = [0]
_log('LEDC poll thread started')
while not _stopped.wait(0.1): while not _stopped.wait(0.1):
try: try:
ptr = lib.qemu_picsimlab_get_internals(0) ptr = lib.qemu_picsimlab_get_internals(6) # LEDC_CHANNEL_DUTY
if ptr is None: _diag_count[0] += 1
# Log first 5 polls for diagnostics
if _diag_count[0] <= 5:
_log(f'LEDC poll #{_diag_count[0]}: ptr={ptr} '
f'(type={type(ptr).__name__}) gpio_map={dict(_ledc_gpio_map)}')
if ptr is None or ptr == 0:
if _diag_count[0] <= 5:
_log(f'LEDC poll: ptr is NULL/0, skipping')
continue continue
arr = (ctypes.c_uint32 * 16).from_address(ptr) # duty[] is float[16] in QEMU (percentage 0-100)
arr = (ctypes.c_float * 16).from_address(ptr)
if _diag_count[0] <= 5:
nonzero = {ch: round(float(arr[ch]), 2) for ch in range(16)
if float(arr[ch]) != 0.0}
_log(f'LEDC poll: nonzero duties={nonzero}')
for ch in range(16): for ch in range(16):
duty = int(arr[ch]) duty_pct = float(arr[ch])
if duty > 0: if abs(duty_pct - _last_duty[ch]) < 0.01:
continue
_last_duty[ch] = duty_pct
if duty_pct > 0:
gpio = _ledc_gpio_map.get(ch, -1) gpio = _ledc_gpio_map.get(ch, -1)
_emit({'type': 'ledc_update', 'channel': ch, _emit({'type': 'ledc_update', 'channel': ch,
'duty': duty, 'duty': round(duty_pct, 2),
'duty_pct': round(duty / 8192 * 100, 1), 'duty_pct': round(duty_pct, 2),
'gpio': gpio}) 'gpio': gpio})
except Exception: except Exception as e:
pass import traceback
_log(f'LEDC poll error: {e}\n{traceback.format_exc()}')
threading.Thread(target=_ledc_poll_thread, daemon=True, name='ledc-poll').start() threading.Thread(target=_ledc_poll_thread, daemon=True, name='ledc-poll').start()

View File

@ -1,6 +1,6 @@
{ {
"version": "1.0.0", "version": "1.0.0",
"generatedAt": "2026-03-22T15:21:16.907Z", "generatedAt": "2026-03-23T13:04:13.804Z",
"components": [ "components": [
{ {
"id": "arduino-mega", "id": "arduino-mega",

View File

@ -8,8 +8,10 @@
* 4. LEDC update routes to correct GPIO pin (not LEDC channel) * 4. LEDC update routes to correct GPIO pin (not LEDC channel)
* 5. LEDC duty_pct is normalized to 0.01.0 * 5. LEDC duty_pct is normalized to 0.01.0
* 6. LEDC fallback to channel when gpio=-1 * 6. LEDC fallback to channel when gpio=-1
* 7. Servo angle maps correctly from duty cycle * 7. Servo angle maps correctly from duty cycle (pulse-width based)
* 8. Potentiometer setAdcVoltage returns false for ESP32 (SimulatorCanvas handles it) * 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'; import { describe, it, expect, beforeEach, vi } from 'vitest';
@ -113,6 +115,7 @@ function makeElement(props: Record<string, unknown> = {}): HTMLElement {
function makeEsp32Shim() { function makeEsp32Shim() {
let pwmCallback: ((pin: number, duty: number) => void) | null = null; let pwmCallback: ((pin: number, duty: number) => void) | null = null;
const unsubPwm = vi.fn(); const unsubPwm = vi.fn();
const adcCalls: { channel: number; millivolts: number }[] = [];
return { return {
pinManager: { pinManager: {
@ -131,9 +134,19 @@ function makeEsp32Shim() {
registerSensor: vi.fn().mockReturnValue(true), registerSensor: vi.fn().mockReturnValue(true),
updateSensor: vi.fn(), updateSensor: vi.fn(),
unregisterSensor: 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 // Test helpers
_getPwmCallback: () => pwmCallback, _getPwmCallback: () => pwmCallback,
_unsubPwm: unsubPwm, _unsubPwm: unsubPwm,
_getAdcCalls: () => adcCalls,
}; };
} }
@ -183,7 +196,7 @@ describe('Servo — ESP32 PWM subscription', () => {
expect(shim.pinManager.onPwmChange).toHaveBeenCalledWith(13, expect.any(Function)); expect(shim.pinManager.onPwmChange).toHaveBeenCalledWith(13, expect.any(Function));
}); });
it('updates angle when PWM duty cycle changes', () => { it('updates angle when PWM duty cycle changes (pulse-width mapping)', () => {
const shim = makeEsp32Shim(); const shim = makeEsp32Shim();
const el = makeElement() as any; const el = makeElement() as any;
logic().attachEvents!(el, shim as any, pinMap({ PWM: 13 }), 'servo-esp32-angle'); logic().attachEvents!(el, shim as any, pinMap({ PWM: 13 }), 'servo-esp32-angle');
@ -191,17 +204,44 @@ describe('Servo — ESP32 PWM subscription', () => {
const cb = shim._getPwmCallback(); const cb = shim._getPwmCallback();
expect(cb).not.toBeNull(); expect(cb).not.toBeNull();
// duty 0.0 → 0° // ESP32 servo pulse-width mapping:
cb!(13, 0.0); // 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); expect(el.angle).toBe(0);
// duty 0.5 → 90° // At max duty → 180°
cb!(13, 0.5); cb!(13, MAX_DC);
expect(el.angle).toBe(90);
// duty 1.0 → 180°
cb!(13, 1.0);
expect(el.angle).toBe(180); 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', () => { it('clamps angle to 0-180 range', () => {
@ -211,12 +251,12 @@ describe('Servo — ESP32 PWM subscription', () => {
const cb = shim._getPwmCallback(); const cb = shim._getPwmCallback();
// Negative duty → // Slightly below MIN_DC (but above 1% filter) → clamps to
cb!(13, -0.1); cb!(13, 0.015);
expect(el.angle).toBe(0); expect(el.angle).toBe(0);
// Duty > 1 → 180° // Slightly above MAX_DC (but below 20% filter) → clamps to 180°
cb!(13, 1.5); cb!(13, 0.15);
expect(el.angle).toBe(180); expect(el.angle).toBe(180);
}); });
@ -285,17 +325,17 @@ describe('LEDC update routing', () => {
}); });
it('routes to GPIO pin when update.gpio >= 0', () => { it('routes to GPIO pin when update.gpio >= 0', () => {
const update = { channel: 0, duty: 4096, duty_pct: 50, gpio: 13 }; const update = { channel: 0, duty: 7.5, duty_pct: 7.5, gpio: 13 };
const targetPin = (update.gpio !== undefined && update.gpio >= 0) const targetPin = (update.gpio !== undefined && update.gpio >= 0)
? update.gpio ? update.gpio
: update.channel; : update.channel;
pm.updatePwm(targetPin, update.duty_pct / 100); pm.updatePwm(targetPin, update.duty_pct / 100);
expect(pm.updatePwm).toHaveBeenCalledWith(13, 0.5); expect(pm.updatePwm).toHaveBeenCalledWith(13, 0.075);
}); });
it('falls back to channel when gpio is -1', () => { it('falls back to channel when gpio is -1', () => {
const update = { channel: 2, duty: 4096, duty_pct: 50, gpio: -1 }; const update = { channel: 2, duty: 50, duty_pct: 50, gpio: -1 };
const targetPin = (update.gpio !== undefined && update.gpio >= 0) const targetPin = (update.gpio !== undefined && update.gpio >= 0)
? update.gpio ? update.gpio
: update.channel; : update.channel;
@ -305,7 +345,7 @@ describe('LEDC update routing', () => {
}); });
it('falls back to channel when gpio is undefined', () => { it('falls back to channel when gpio is undefined', () => {
const update = { channel: 3, duty: 8192, duty_pct: 100 } as any; const update = { channel: 3, duty: 100, duty_pct: 100 } as any;
const targetPin = (update.gpio !== undefined && update.gpio >= 0) const targetPin = (update.gpio !== undefined && update.gpio >= 0)
? update.gpio ? update.gpio
: update.channel; : update.channel;
@ -315,7 +355,7 @@ describe('LEDC update routing', () => {
}); });
it('normalizes duty_pct to 0.01.0 (divides by 100)', () => { it('normalizes duty_pct to 0.01.0 (divides by 100)', () => {
const update = { channel: 0, duty: 2048, duty_pct: 25, gpio: 5 }; const update = { channel: 0, duty: 25, duty_pct: 25, gpio: 5 };
const targetPin = (update.gpio !== undefined && update.gpio >= 0) const targetPin = (update.gpio !== undefined && update.gpio >= 0)
? update.gpio ? update.gpio
: update.channel; : update.channel;
@ -326,29 +366,34 @@ describe('LEDC update routing', () => {
}); });
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
// 7. Servo angle mapping from duty cycle // 7. Servo angle mapping — pulse-width based for ESP32
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
describe('Servo angle mapping', () => { describe('Servo angle mapping (pulse-width)', () => {
const logic = () => PartSimulationRegistry.get('servo')!; const logic = () => PartSimulationRegistry.get('servo')!;
it('maps duty 0.0 → angle 0, duty 0.5 → angle 90, duty 1.0 → angle 180', () => { it('maps real servo duty cycles to correct angles', () => {
const shim = makeEsp32Shim(); const shim = makeEsp32Shim();
const el = makeElement() as any; const el = makeElement() as any;
logic().attachEvents!(el, shim as any, pinMap({ PWM: 13 }), 'servo-map'); logic().attachEvents!(el, shim as any, pinMap({ PWM: 13 }), 'servo-map');
const cb = shim._getPwmCallback(); 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 = [ const testCases = [
{ duty: 0.0, expectedAngle: 0 }, { pulseUs: 544, expectedAngle: 0 },
{ duty: 0.25, expectedAngle: 45 }, { pulseUs: 1008, expectedAngle: 45 },
{ duty: 0.5, expectedAngle: 90 }, { pulseUs: 1472, expectedAngle: 90 },
{ duty: 0.75, expectedAngle: 135 }, { pulseUs: 1936, expectedAngle: 135 },
{ duty: 1.0, expectedAngle: 180 }, { pulseUs: 2400, expectedAngle: 180 },
]; ];
for (const { duty, expectedAngle } of testCases) { for (const { pulseUs, expectedAngle } of testCases) {
cb!(13, duty); const dutyCycle = pulseUs / 20000; // fraction of 20ms period
cb!(13, dutyCycle);
expect(el.angle).toBe(expectedAngle); expect(el.angle).toBe(expectedAngle);
} }
}); });
@ -359,10 +404,17 @@ describe('Servo angle mapping', () => {
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
describe('Potentiometer — ESP32 ADC path', () => { describe('Potentiometer — ESP32 ADC path', () => {
it('setAdcVoltage returns false for ESP32 shim (GPIO 34 is not AVR/RP2040 ADC range)', () => { it('setAdcVoltage delegates to Esp32BridgeShim.setAdcVoltage', () => {
const shim = makeEsp32Shim(); const shim = makeEsp32Shim();
// GPIO 34 on ESP32 — not in AVR range (14-19) nor RP2040 range (26-29)
const result = setAdcVoltage(shim as any, 34, 1.65); 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); expect(result).toBe(false);
}); });
@ -373,3 +425,113 @@ describe('Potentiometer — ESP32 ADC path', () => {
expect(result).toBe(true); 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 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 });
});
});

View File

@ -148,6 +148,7 @@ export class Esp32Bridge {
break; break;
} }
case 'ledc_update': { case 'ledc_update': {
console.log(`[Esp32Bridge:${this.boardId}] ledc_update ch=${msg.data.channel} duty=${msg.data.duty_pct}% gpio=${msg.data.gpio}`);
this.onLedcUpdate?.(msg.data as unknown as LedcUpdate); this.onLedcUpdate?.(msg.data as unknown as LedcUpdate);
break; break;
} }

View File

@ -68,7 +68,8 @@ PartSimulationRegistry.register('potentiometer', {
// Determine reference voltage based on board type // Determine reference voltage based on board type
const isRP2040 = simulator instanceof RP2040Simulator; const isRP2040 = simulator instanceof RP2040Simulator;
const refVoltage = isRP2040 ? 3.3 : 5.0; const isESP32 = typeof (simulator as any).setAdcVoltage === 'function';
const refVoltage = (isRP2040 || isESP32) ? 3.3 : 5.0;
const onInput = () => { const onInput = () => {
const raw = parseInt((element as any).value || '0', 10); const raw = parseInt((element as any).value || '0', 10);
@ -93,7 +94,8 @@ PartSimulationRegistry.register('slide-potentiometer', {
const el = element as any; const el = element as any;
const isRP2040 = avrSimulator instanceof RP2040Simulator; const isRP2040 = avrSimulator instanceof RP2040Simulator;
const refVoltage = isRP2040 ? 3.3 : 5.0; const isESP32 = typeof (avrSimulator as any).setAdcVoltage === 'function';
const refVoltage = (isRP2040 || isESP32) ? 3.3 : 5.0;
const onInput = () => { const onInput = () => {
const min = el.min ?? 0; const min = el.min ?? 0;
@ -328,8 +330,16 @@ PartSimulationRegistry.register('servo', {
&& (avrSimulator as any).getCurrentCycles() >= 0; && (avrSimulator as any).getCurrentCycles() >= 0;
if (pinManager && !hasCpuCycles) { if (pinManager && !hasCpuCycles) {
// ESP32 Servo.h uses 50Hz PWM with pulse 544-2400µs
// dutyCycle here is 0.0-1.0 (fraction of PWM period = 20ms)
// 544µs = 2.72%, 2400µs = 12.0%
const MIN_DC = MIN_PULSE_US / 20000; // 0.0272
const MAX_DC = MAX_PULSE_US / 20000; // 0.12
const unsubscribe = pinManager.onPwmChange(pinSIG, (_pin, dutyCycle) => { const unsubscribe = pinManager.onPwmChange(pinSIG, (_pin, dutyCycle) => {
const angle = Math.round(dutyCycle * 180); if (dutyCycle < 0.01 || dutyCycle > 0.20) return; // ignore out-of-range
const angle = Math.round(
((dutyCycle - MIN_DC) / (MAX_DC - MIN_DC)) * 180
);
el.angle = Math.max(0, Math.min(180, angle)); el.angle = Math.max(0, Math.min(180, angle));
}); });
return () => { unsubscribe(); }; return () => { unsubscribe(); };

View File

@ -14,14 +14,19 @@ export function getADC(avrSimulator: AnySimulator): any | null {
} }
/** /**
* Write an analog voltage to an ADC channel, supporting both AVR and RP2040. * Write an analog voltage to an ADC channel, supporting AVR, RP2040, and ESP32.
* *
* AVR: pins 14-19 ADC channels 0-5, voltage stored directly (0-5V) * AVR: pins 14-19 ADC channels 0-5, voltage stored directly (0-5V)
* RP2040: GPIO 26-29 ADC channels 0-3, converted to 12-bit value (0-4095) * RP2040: GPIO 26-29 ADC channels 0-3, converted to 12-bit value (0-4095)
* ESP32: GPIO 32-39 ADC1 channels 4-11, sent via WebSocket bridge
* *
* Returns true if the voltage was successfully injected. * Returns true if the voltage was successfully injected.
*/ */
export function setAdcVoltage(simulator: AnySimulator, pin: number, voltage: number): boolean { export function setAdcVoltage(simulator: AnySimulator, pin: number, voltage: number): boolean {
// ESP32 BridgeShim: delegate to bridge via WebSocket
if (typeof (simulator as any).setAdcVoltage === 'function') {
return (simulator as any).setAdcVoltage(pin, voltage);
}
// RP2040: GPIO26-29 → ADC channels 0-3 // RP2040: GPIO26-29 → ADC channels 0-3
if (simulator instanceof RP2040Simulator) { if (simulator instanceof RP2040Simulator) {
if (pin >= 26 && pin <= 29) { if (pin >= 26 && pin <= 29) {

View File

@ -73,6 +73,21 @@ class Esp32BridgeShim {
} }
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
getADC(): any { return null; } getADC(): any { return null; }
/**
* Set ADC value for an ESP32 GPIO pin.
* ESP32 ADC1: GPIO 36-39 CH0-3, GPIO 32-35 CH4-7
* Returns true if the pin is a valid ADC pin.
*/
setAdcVoltage(pin: number, voltage: number): boolean {
let channel = -1;
if (pin >= 36 && pin <= 39) channel = pin - 36; // GPIO 36→CH0, 37→CH1, 38→CH2, 39→CH3
else if (pin >= 32 && pin <= 35) channel = pin - 28; // GPIO 32→CH4, 33→CH5, 34→CH6, 35→CH7
if (channel < 0) return false;
const millivolts = Math.round(voltage * 1000);
this.bridge.setAdc(channel, millivolts);
return true;
}
getMCU(): null { return null; } getMCU(): null { return null; }
start(): void { /* managed by bridge */ } start(): void { /* managed by bridge */ }
stop(): void { /* managed by bridge */ } stop(): void { /* managed by bridge */ }