feat: Enhance DHT22 sync handling, improve ADC support for ESP32, and update component metadata
parent
a473cbe74f
commit
aa6522fd60
|
|
@ -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 HIGH→LOW), 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()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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.0–1.0
|
* 5. LEDC duty_pct is normalized to 0.0–1.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 → 0°
|
// Slightly below MIN_DC (but above 1% filter) → clamps to 0°
|
||||||
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.0–1.0 (divides by 100)', () => {
|
it('normalizes duty_pct to 0.0–1.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 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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(); };
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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 */ }
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue