diff --git a/backend/app/services/esp32_worker.py b/backend/app/services/esp32_worker.py index 2361180..c844d6d 100644 --- a/backend/app/services/esp32_worker.py +++ b/backend/app/services/esp32_worker.py @@ -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, 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]] = [] # 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 phases.append((50, 1)) # LOW phase → drive HIGH phases.append((70 if bit else 26, 0)) # HIGH phase → drive LOW - # Final: LOW 50 syncs → release HIGH - phases.append((50, 1)) return phases def _dht22_sync_step() -> None: @@ -308,19 +315,8 @@ def main() -> None: # noqa: C901 (complexity OK for inline worker) phases = state['phases'] if phase_idx >= len(phases): - # Response complete — clean up - 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(phases)}') - _emit({'type': 'system', 'event': 'dht22_diag', - 'gpio': gpio_pin, 'status': 'ok', - 'total_syncs': total}) + # All phases done — clean up immediately + _dht22_sync_cleanup(state) return 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['count'] = 0 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: """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() # loop iterations. if slot == -1: - if direction == -1 and _dht22_sync[0] is not None: - # GPIO_IN read — advance DHT22 sync response - _dht22_sync_step() - return + if direction == -1: + # GPIO_IN read sync — advance DHT22 response if active + if _dht22_sync[0] is not None: + _dht22_sync_step() + return # always return for GPIO_IN syncs (fast path) marker = direction & 0xF000 if marker == 0x2000: # GPIO_FUNCX_OUT_SEL_CFG change gpio_pin = direction & 0xFF @@ -411,6 +428,7 @@ def main() -> None: # noqa: C901 (complexity OK for inline worker) if 72 <= signal <= 87: ledc_ch = signal - 72 # ch 0-15 _ledc_gpio_map[ledc_ch] = gpio_pin + _log(f'LEDC map: ch{ledc_ch} → GPIO{gpio_pin} (signal={signal})') return # ── DHT22: track direction changes + trigger sync response ─────── @@ -429,9 +447,9 @@ def main() -> None: # noqa: C901 (complexity OK for inline worker) sensor['responding'] = True # Build the response waveform phases - payload = _dht22_build_payload( - sensor.get('temperature', 25.0), - sensor.get('humidity', 50.0)) + temp = sensor.get('temperature', 25.0) + hum = sensor.get('humidity', 50.0) + payload = _dht22_build_payload(temp, hum) phases = _dht22_build_sync_phases(payload) # Drive pin LOW synchronously — firmware sees LOW @@ -448,6 +466,7 @@ def main() -> None: # noqa: C901 (complexity OK for inline worker) 'total_syncs': 0, } _log(f'DHT22 sync armed gpio={gpio} ' + f'temp={temp} hum={hum} ' f'phases={len(phases)} payload={payload}') gpio = int(_PINMAP[slot]) if 1 <= slot <= _GPIO_COUNT else slot _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: 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): try: - ptr = lib.qemu_picsimlab_get_internals(0) - if ptr is None: + ptr = lib.qemu_picsimlab_get_internals(6) # LEDC_CHANNEL_DUTY + _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 - 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): - duty = int(arr[ch]) - if duty > 0: + duty_pct = float(arr[ch]) + 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) _emit({'type': 'ledc_update', 'channel': ch, - 'duty': duty, - 'duty_pct': round(duty / 8192 * 100, 1), + 'duty': round(duty_pct, 2), + 'duty_pct': round(duty_pct, 2), 'gpio': gpio}) - except Exception: - pass + except Exception as e: + import traceback + _log(f'LEDC poll error: {e}\n{traceback.format_exc()}') threading.Thread(target=_ledc_poll_thread, daemon=True, name='ledc-poll').start() diff --git a/frontend/public/components-metadata.json b/frontend/public/components-metadata.json index 7eed683..ed817b2 100644 --- a/frontend/public/components-metadata.json +++ b/frontend/public/components-metadata.json @@ -1,6 +1,6 @@ { "version": "1.0.0", - "generatedAt": "2026-03-22T15:21:16.907Z", + "generatedAt": "2026-03-23T13:04:13.804Z", "components": [ { "id": "arduino-mega", diff --git a/frontend/src/__tests__/esp32-servo-pot.test.ts b/frontend/src/__tests__/esp32-servo-pot.test.ts index b9919af..a022d97 100644 --- a/frontend/src/__tests__/esp32-servo-pot.test.ts +++ b/frontend/src/__tests__/esp32-servo-pot.test.ts @@ -8,8 +8,10 @@ * 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 - * 8. Potentiometer setAdcVoltage returns false for ESP32 (SimulatorCanvas handles it) + * 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'; @@ -113,6 +115,7 @@ function makeElement(props: Record = {}): HTMLElement { function makeEsp32Shim() { let pwmCallback: ((pin: number, duty: number) => void) | null = null; const unsubPwm = vi.fn(); + const adcCalls: { channel: number; millivolts: number }[] = []; return { pinManager: { @@ -131,9 +134,19 @@ function makeEsp32Shim() { 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, }; } @@ -183,7 +196,7 @@ describe('Servo — ESP32 PWM subscription', () => { 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 el = makeElement() as any; 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(); expect(cb).not.toBeNull(); - // duty 0.0 → 0° - cb!(13, 0.0); + // 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); - // duty 0.5 → 90° - cb!(13, 0.5); - expect(el.angle).toBe(90); - - // duty 1.0 → 180° - cb!(13, 1.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', () => { @@ -211,12 +251,12 @@ describe('Servo — ESP32 PWM subscription', () => { const cb = shim._getPwmCallback(); - // Negative duty → 0° - cb!(13, -0.1); + // Slightly below MIN_DC (but above 1% filter) → clamps to 0° + cb!(13, 0.015); expect(el.angle).toBe(0); - // Duty > 1 → 180° - cb!(13, 1.5); + // Slightly above MAX_DC (but below 20% filter) → clamps to 180° + cb!(13, 0.15); expect(el.angle).toBe(180); }); @@ -285,17 +325,17 @@ describe('LEDC update routing', () => { }); 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) ? update.gpio : update.channel; 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', () => { - 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) ? update.gpio : update.channel; @@ -305,7 +345,7 @@ describe('LEDC update routing', () => { }); 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) ? update.gpio : update.channel; @@ -315,7 +355,7 @@ describe('LEDC update routing', () => { }); 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) ? update.gpio : 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')!; - 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 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 = [ - { duty: 0.0, expectedAngle: 0 }, - { duty: 0.25, expectedAngle: 45 }, - { duty: 0.5, expectedAngle: 90 }, - { duty: 0.75, expectedAngle: 135 }, - { duty: 1.0, expectedAngle: 180 }, + { pulseUs: 544, expectedAngle: 0 }, + { pulseUs: 1008, expectedAngle: 45 }, + { pulseUs: 1472, expectedAngle: 90 }, + { pulseUs: 1936, expectedAngle: 135 }, + { pulseUs: 2400, expectedAngle: 180 }, ]; - for (const { duty, expectedAngle } of testCases) { - cb!(13, duty); + for (const { pulseUs, expectedAngle } of testCases) { + const dutyCycle = pulseUs / 20000; // fraction of 20ms period + cb!(13, dutyCycle); expect(el.angle).toBe(expectedAngle); } }); @@ -359,10 +404,17 @@ describe('Servo angle mapping', () => { // ───────────────────────────────────────────────────────────────────────────── 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(); - // GPIO 34 on ESP32 — not in AVR range (14-19) nor RP2040 range (26-29) 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); }); @@ -373,3 +425,113 @@ describe('Potentiometer — ESP32 ADC path', () => { 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 }); + }); +}); diff --git a/frontend/src/simulation/Esp32Bridge.ts b/frontend/src/simulation/Esp32Bridge.ts index 8089456..6f250b4 100644 --- a/frontend/src/simulation/Esp32Bridge.ts +++ b/frontend/src/simulation/Esp32Bridge.ts @@ -148,6 +148,7 @@ export class Esp32Bridge { break; } 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); break; } diff --git a/frontend/src/simulation/parts/ComplexParts.ts b/frontend/src/simulation/parts/ComplexParts.ts index 3a98f9b..414fccd 100644 --- a/frontend/src/simulation/parts/ComplexParts.ts +++ b/frontend/src/simulation/parts/ComplexParts.ts @@ -68,7 +68,8 @@ PartSimulationRegistry.register('potentiometer', { // Determine reference voltage based on board type 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 raw = parseInt((element as any).value || '0', 10); @@ -93,7 +94,8 @@ PartSimulationRegistry.register('slide-potentiometer', { const el = element as any; 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 min = el.min ?? 0; @@ -328,8 +330,16 @@ PartSimulationRegistry.register('servo', { && (avrSimulator as any).getCurrentCycles() >= 0; 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 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)); }); return () => { unsubscribe(); }; diff --git a/frontend/src/simulation/parts/partUtils.ts b/frontend/src/simulation/parts/partUtils.ts index 1e0295b..9342af7 100644 --- a/frontend/src/simulation/parts/partUtils.ts +++ b/frontend/src/simulation/parts/partUtils.ts @@ -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) * 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. */ 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 if (simulator instanceof RP2040Simulator) { if (pin >= 26 && pin <= 29) { diff --git a/frontend/src/store/useSimulatorStore.ts b/frontend/src/store/useSimulatorStore.ts index 4b99cd9..f9cfa51 100644 --- a/frontend/src/store/useSimulatorStore.ts +++ b/frontend/src/store/useSimulatorStore.ts @@ -73,6 +73,21 @@ class Esp32BridgeShim { } // eslint-disable-next-line @typescript-eslint/no-explicit-any 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; } start(): void { /* managed by bridge */ } stop(): void { /* managed by bridge */ }