From 6a55f58e46d0c3cf4d4d1b65add0a9ec4741509a Mon Sep 17 00:00:00 2001 From: David Montero Crespo Date: Sun, 22 Mar 2026 18:03:17 -0300 Subject: [PATCH] feat: Implement generic sensor registration for ESP32 and RP2040 - Added board-agnostic sensor registration methods in RP2040Simulator. - Enhanced ComplexParts to handle LEDC PWM duty updates for ESP32. - Updated ProtocolParts to check if the simulator handles sensor protocols natively, delegating to backend if applicable. - Introduced pre-registration of sensors in useSimulatorStore for ESP32 to prevent race conditions. - Added tests for ESP32 DHT22 sensor registration flow, ensuring proper delegation and fallback mechanisms. - Created tests for ESP32 Servo and Potentiometer interactions, verifying PWM subscriptions and ADC handling. --- backend/app/api/routes/simulation.py | 32 +- backend/app/services/esp32_lib_manager.py | 33 +- backend/app/services/esp32_worker.py | 82 ++-- backend/app/services/esp_qemu_manager.py | 48 +- .../src/__tests__/esp32-dht22-flow.test.ts | 430 ++++++++++++++++++ .../src/__tests__/esp32-servo-pot.test.ts | 375 +++++++++++++++ frontend/src/data/examples.ts | 53 ++- frontend/src/simulation/AVRSimulator.ts | 8 + frontend/src/simulation/Esp32Bridge.ts | 39 +- frontend/src/simulation/RP2040Simulator.ts | 8 + frontend/src/simulation/parts/ComplexParts.ts | 20 + .../src/simulation/parts/ProtocolParts.ts | 25 +- frontend/src/store/useSimulatorStore.ts | 76 +++- 13 files changed, 1117 insertions(+), 112 deletions(-) create mode 100644 frontend/src/__tests__/esp32-dht22-flow.test.ts create mode 100644 frontend/src/__tests__/esp32-servo-pot.test.ts diff --git a/backend/app/api/routes/simulation.py b/backend/app/api/routes/simulation.py index 9a113e4..6a4f277 100644 --- a/backend/app/api/routes/simulation.py +++ b/backend/app/api/routes/simulation.py @@ -81,12 +81,13 @@ async def simulation_websocket(websocket: WebSocket, client_id: str): elif msg_type == 'start_esp32': board = msg_data.get('board', 'esp32') firmware_b64 = msg_data.get('firmware_b64') + sensors = msg_data.get('sensors', []) fw_size_kb = round(len(firmware_b64) * 0.75 / 1024) if firmware_b64 else 0 lib_available = _use_lib() - logger.info('[%s] start_esp32 board=%s firmware=%dKB lib_available=%s', - client_id, board, fw_size_kb, lib_available) + logger.info('[%s] start_esp32 board=%s firmware=%dKB lib_available=%s sensors=%d', + client_id, board, fw_size_kb, lib_available, len(sensors)) if lib_available: - await esp_lib_manager.start_instance(client_id, board, qemu_callback, firmware_b64) + await esp_lib_manager.start_instance(client_id, board, qemu_callback, firmware_b64, sensors) else: logger.warning('[%s] libqemu-xtensa not available — using subprocess fallback', client_id) esp_qemu_manager.start_instance(client_id, board, qemu_callback, firmware_b64) @@ -173,31 +174,28 @@ async def simulation_websocket(websocket: WebSocket, client_id: str): client_id, bytes(raw_bytes), uart_id=2 ) - # ── ESP32 DHT22 sensor (backend-side protocol emulation) ───── - elif msg_type == 'esp32_dht22_attach': + # ── ESP32 sensor protocol offloading (generic) ──────────────── + elif msg_type == 'esp32_sensor_attach': + sensor_type = msg_data.get('sensor_type', '') pin = int(msg_data.get('pin', 0)) - temperature = float(msg_data.get('temperature', 25.0)) - humidity = float(msg_data.get('humidity', 50.0)) if _use_lib(): - esp_lib_manager.dht22_attach(client_id, pin, temperature, humidity) + esp_lib_manager.sensor_attach(client_id, sensor_type, pin, msg_data) else: - esp_qemu_manager.dht22_attach(client_id, pin, temperature, humidity) + esp_qemu_manager.sensor_attach(client_id, sensor_type, pin, msg_data) - elif msg_type == 'esp32_dht22_update': + elif msg_type == 'esp32_sensor_update': pin = int(msg_data.get('pin', 0)) - temperature = float(msg_data.get('temperature', 25.0)) - humidity = float(msg_data.get('humidity', 50.0)) if _use_lib(): - esp_lib_manager.dht22_update(client_id, pin, temperature, humidity) + esp_lib_manager.sensor_update(client_id, pin, msg_data) else: - esp_qemu_manager.dht22_update(client_id, pin, temperature, humidity) + esp_qemu_manager.sensor_update(client_id, pin, msg_data) - elif msg_type == 'esp32_dht22_detach': + elif msg_type == 'esp32_sensor_detach': pin = int(msg_data.get('pin', 0)) if _use_lib(): - esp_lib_manager.dht22_detach(client_id, pin) + esp_lib_manager.sensor_detach(client_id, pin) else: - esp_qemu_manager.dht22_detach(client_id, pin) + esp_qemu_manager.sensor_detach(client_id, pin) # ── ESP32 status query ──────────────────────────────────────── elif msg_type == 'esp32_status': diff --git a/backend/app/services/esp32_lib_manager.py b/backend/app/services/esp32_lib_manager.py index 181c384..593fba1 100644 --- a/backend/app/services/esp32_lib_manager.py +++ b/backend/app/services/esp32_lib_manager.py @@ -161,6 +161,7 @@ class EspLibManager: board_type: str, callback: EventCallback, firmware_b64: str | None = None, + sensors: list | None = None, ) -> None: # Stop any existing instance for this client_id first if client_id in self._instances: @@ -177,6 +178,7 @@ class EspLibManager: 'lib_path': lib_path, 'firmware_b64': firmware_b64, 'machine': machine, + 'sensors': sensors or [], }) logger.info('Launching esp32_worker for %s (machine=%s, script=%s, python=%s)', @@ -343,30 +345,37 @@ class EspLibManager: if inst and inst.running and inst.process.returncode is None: self._write_cmd(inst, {'cmd': 'set_spi_response', 'response': response_byte & 0xFF}) - # ── DHT22 sensor (backend-side protocol emulation) ───────────────────── + # ── Generic sensor protocol offloading ────────────────────────────────── - def dht22_attach(self, client_id: str, pin: int, temperature: float, humidity: float) -> None: - """Register a DHT22 sensor on a GPIO pin — the worker handles the protocol.""" + def sensor_attach(self, client_id: str, sensor_type: str, pin: int, + properties: dict) -> None: + """Register a sensor on a GPIO pin — the worker handles its protocol.""" with self._instances_lock: inst = self._instances.get(client_id) if inst and inst.running and inst.process.returncode is None: - self._write_cmd(inst, {'cmd': 'dht22_attach', 'pin': pin, - 'temperature': temperature, 'humidity': humidity}) + self._write_cmd(inst, { + 'cmd': 'sensor_attach', 'sensor_type': sensor_type, + 'pin': pin, **{k: v for k, v in properties.items() + if k not in ('sensor_type', 'pin')}, + }) - def dht22_update(self, client_id: str, pin: int, temperature: float, humidity: float) -> None: - """Update a DHT22 sensor's temperature and humidity values.""" + def sensor_update(self, client_id: str, pin: int, + properties: dict) -> None: + """Update a sensor's properties (temperature, humidity, distance…).""" with self._instances_lock: inst = self._instances.get(client_id) if inst and inst.running and inst.process.returncode is None: - self._write_cmd(inst, {'cmd': 'dht22_update', 'pin': pin, - 'temperature': temperature, 'humidity': humidity}) + self._write_cmd(inst, { + 'cmd': 'sensor_update', 'pin': pin, + **{k: v for k, v in properties.items() if k != 'pin'}, + }) - def dht22_detach(self, client_id: str, pin: int) -> None: - """Remove a DHT22 sensor from a GPIO pin.""" + def sensor_detach(self, client_id: str, pin: int) -> None: + """Remove a sensor from a GPIO pin.""" with self._instances_lock: inst = self._instances.get(client_id) if inst and inst.running and inst.process.returncode is None: - self._write_cmd(inst, {'cmd': 'dht22_detach', 'pin': pin}) + self._write_cmd(inst, {'cmd': 'sensor_detach', 'pin': pin}) # ── LEDC polling (no-op: worker polls automatically) ───────────────────── diff --git a/backend/app/services/esp32_worker.py b/backend/app/services/esp32_worker.py index dc4256c..707cb9f 100644 --- a/backend/app/services/esp32_worker.py +++ b/backend/app/services/esp32_worker.py @@ -174,9 +174,10 @@ def main() -> None: # noqa: C901 (complexity OK for inline worker) _log(f'Bad config JSON: {exc}') os._exit(1) - lib_path = cfg['lib_path'] - firmware_b64 = cfg['firmware_b64'] - machine = cfg.get('machine', 'esp32-picsimlab') + lib_path = cfg['lib_path'] + firmware_b64 = cfg['firmware_b64'] + machine = cfg.get('machine', 'esp32-picsimlab') + initial_sensors = cfg.get('sensors', []) # Adjust GPIO pinmap based on chip: ESP32-C3 has only 22 GPIOs if 'c3' in machine: @@ -217,6 +218,7 @@ def main() -> None: # noqa: C901 (complexity OK for inline worker) # ── 4. Shared mutable state ─────────────────────────────────────────────── _stopped = threading.Event() # set on "stop" command _init_done = threading.Event() # set when qemu_init() returns + _sensors_ready = threading.Event() # set after pre-registering initial sensors _i2c_responses: dict[int, int] = {} # 7-bit addr → response byte _spi_response = [0xFF] # MISO byte for SPI transfers _rmt_decoders: dict[int, _RmtDecoder] = {} @@ -229,9 +231,9 @@ def main() -> None: # noqa: C901 (complexity OK for inline worker) # ESP32 signal indices: 72-79 = LEDC HS ch 0-7, 80-87 = LEDC LS ch 0-7 _ledc_gpio_map: dict[int, int] = {} - # DHT22 sensor state: gpio_pin → {temperature, humidity, saw_low, responding} - _dht22_sensors: dict[int, dict] = {} - _dht22_lock = threading.Lock() + # Sensor state: gpio_pin → {type, properties..., saw_low, responding} + _sensors: dict[int, dict] = {} + _sensors_lock = threading.Lock() def _busy_wait_us(us: int) -> None: """Busy-wait for the given number of microseconds using perf_counter_ns.""" @@ -280,8 +282,8 @@ def main() -> None: # noqa: C901 (complexity OK for inline worker) except Exception as exc: _log(f'DHT22 respond error on GPIO {gpio_pin}: {exc}') finally: - with _dht22_lock: - sensor = _dht22_sensors.get(gpio_pin) + with _sensors_lock: + sensor = _sensors.get(gpio_pin) if sensor: sensor['responding'] = False @@ -293,10 +295,10 @@ def main() -> None: # noqa: C901 (complexity OK for inline worker) gpio = int(_PINMAP[slot]) if 1 <= slot <= _GPIO_COUNT else slot _emit({'type': 'gpio_change', 'pin': gpio, 'state': value}) - # DHT22: detect start signal (firmware drives pin LOW then HIGH) - with _dht22_lock: - sensor = _dht22_sensors.get(gpio) - if sensor is not None: + # Sensor protocol dispatch by type + with _sensors_lock: + sensor = _sensors.get(gpio) + if sensor is not None and sensor.get('type') == 'dht22': if value == 0 and not sensor.get('responding', False): sensor['saw_low'] = True elif value == 1 and sensor.get('saw_low', False): @@ -304,7 +306,8 @@ def main() -> None: # noqa: C901 (complexity OK for inline worker) sensor['responding'] = True threading.Thread( target=_dht22_respond, - args=(gpio, sensor['temperature'], sensor['humidity']), + args=(gpio, sensor.get('temperature', 25.0), + sensor.get('humidity', 50.0)), daemon=True, name=f'dht22-gpio{gpio}', ).start() @@ -395,6 +398,10 @@ def main() -> None: # noqa: C901 (complexity OK for inline worker) _emit({'type': 'error', 'message': f'qemu_init failed: {exc}'}) finally: _init_done.set() + # Wait for initial sensors to be pre-registered before executing firmware. + # This prevents race conditions where the firmware tries to read a sensor + # (e.g. DHT22 pulseIn) before the sensor handler is registered. + _sensors_ready.wait(timeout=5.0) lib.qemu_main_loop() # With -nographic, qemu_init registers the stdio mux chardev which reads @@ -415,6 +422,20 @@ def main() -> None: # noqa: C901 (complexity OK for inline worker) _emit({'type': 'error', 'message': 'qemu_init timed out after 30 s'}) os._exit(1) + # Pre-register initial sensors before letting QEMU execute firmware. + for s in initial_sensors: + gpio = int(s.get('pin', 0)) + sensor_type = s.get('sensor_type', '') + with _sensors_lock: + _sensors[gpio] = { + 'type': sensor_type, + **{k: v for k, v in s.items() if k not in ('sensor_type', 'pin')}, + 'saw_low': False, + 'responding': False, + } + _log(f'Pre-registered sensor {sensor_type} on GPIO {gpio}') + _sensors_ready.set() + _emit({'type': 'system', 'event': 'booted'}) _log(f'QEMU started: machine={machine} firmware={firmware_path}') @@ -477,32 +498,33 @@ def main() -> None: # noqa: C901 (complexity OK for inline worker) elif c == 'set_spi_response': _spi_response[0] = int(cmd['response']) & 0xFF - elif c == 'dht22_attach': + elif c == 'sensor_attach': gpio = int(cmd['pin']) - with _dht22_lock: - _dht22_sensors[gpio] = { - 'temperature': float(cmd.get('temperature', 25.0)), - 'humidity': float(cmd.get('humidity', 50.0)), + sensor_type = cmd.get('sensor_type', '') + with _sensors_lock: + _sensors[gpio] = { + 'type': sensor_type, + **{k: v for k, v in cmd.items() + if k not in ('cmd', 'pin', 'sensor_type')}, 'saw_low': False, 'responding': False, } - _log(f'DHT22 attached on GPIO {gpio}') + _log(f'Sensor {sensor_type} attached on GPIO {gpio}') - elif c == 'dht22_update': + elif c == 'sensor_update': gpio = int(cmd['pin']) - with _dht22_lock: - sensor = _dht22_sensors.get(gpio) + with _sensors_lock: + sensor = _sensors.get(gpio) if sensor: - if 'temperature' in cmd: - sensor['temperature'] = float(cmd['temperature']) - if 'humidity' in cmd: - sensor['humidity'] = float(cmd['humidity']) + for k, v in cmd.items(): + if k not in ('cmd', 'pin'): + sensor[k] = v - elif c == 'dht22_detach': + elif c == 'sensor_detach': gpio = int(cmd['pin']) - with _dht22_lock: - _dht22_sensors.pop(gpio, None) - _log(f'DHT22 detached from GPIO {gpio}') + with _sensors_lock: + _sensors.pop(gpio, None) + _log(f'Sensor detached from GPIO {gpio}') elif c == 'stop': _stopped.set() diff --git a/backend/app/services/esp_qemu_manager.py b/backend/app/services/esp_qemu_manager.py index 1396263..f65c5eb 100644 --- a/backend/app/services/esp_qemu_manager.py +++ b/backend/app/services/esp_qemu_manager.py @@ -70,8 +70,8 @@ class EspInstance: self._tasks: list[asyncio.Task] = [] self.running: bool = False - # DHT22 sensor state: gpio_pin → {temperature, humidity, saw_low, responding} - self.dht22_sensors: dict[int, dict] = {} + # Sensor state: gpio_pin → {type, properties..., saw_low, responding} + self.sensors: dict[int, dict] = {} async def emit(self, event_type: str, data: dict) -> None: try: @@ -125,32 +125,34 @@ class EspQemuManager: if inst and inst._gpio_writer: asyncio.create_task(self._send_gpio(inst, int(pin), bool(state))) - # ── DHT22 sensor API ────────────────────────────────────────────────────── + # ── Generic sensor API ────────────────────────────────────────────────── - def dht22_attach(self, client_id: str, pin: int, - temperature: float, humidity: float) -> None: + def sensor_attach(self, client_id: str, sensor_type: str, pin: int, + properties: dict) -> None: inst = self._instances.get(client_id) if inst: - inst.dht22_sensors[pin] = { - 'temperature': temperature, - 'humidity': humidity, + inst.sensors[pin] = { + 'type': sensor_type, + **{k: v for k, v in properties.items() + if k not in ('sensor_type', 'pin')}, 'saw_low': False, 'responding': False, } - logger.info('[%s] DHT22 attached on GPIO %d (T=%.1f H=%.1f)', - client_id, pin, temperature, humidity) + logger.info('[%s] Sensor %s attached on GPIO %d', + client_id, sensor_type, pin) - def dht22_update(self, client_id: str, pin: int, - temperature: float, humidity: float) -> None: + def sensor_update(self, client_id: str, pin: int, + properties: dict) -> None: inst = self._instances.get(client_id) - if inst and pin in inst.dht22_sensors: - inst.dht22_sensors[pin]['temperature'] = temperature - inst.dht22_sensors[pin]['humidity'] = humidity + if inst and pin in inst.sensors: + for k, v in properties.items(): + if k != 'pin': + inst.sensors[pin][k] = v - def dht22_detach(self, client_id: str, pin: int) -> None: + def sensor_detach(self, client_id: str, pin: int) -> None: inst = self._instances.get(client_id) if inst: - inst.dht22_sensors.pop(pin, None) + inst.sensors.pop(pin, None) async def send_serial_bytes(self, client_id: str, data: bytes) -> None: inst = self._instances.get(client_id) @@ -295,9 +297,9 @@ class EspQemuManager: state = int(parts[2]) await inst.emit('gpio_change', {'pin': pin, 'state': state}) - # DHT22: detect start signal (firmware drives pin LOW then HIGH) - sensor = inst.dht22_sensors.get(pin) - if sensor is not None: + # Sensor protocol: dispatch by sensor type + sensor = inst.sensors.get(pin) + if sensor is not None and sensor.get('type') == 'dht22': if state == 0 and not sensor.get('responding', False): sensor['saw_low'] = True elif state == 1 and sensor.get('saw_low', False): @@ -305,8 +307,8 @@ class EspQemuManager: sensor['responding'] = True asyncio.create_task( self._dht22_respond(inst, pin, - sensor['temperature'], - sensor['humidity']) + sensor.get('temperature', 25.0), + sensor.get('humidity', 50.0)) ) except ValueError: pass @@ -374,7 +376,7 @@ class EspQemuManager: logger.warning('[%s] DHT22 respond error on GPIO %d: %s', inst.client_id, gpio_pin, exc) finally: - sensor = inst.dht22_sensors.get(gpio_pin) + sensor = inst.sensors.get(gpio_pin) if sensor: sensor['responding'] = False diff --git a/frontend/src/__tests__/esp32-dht22-flow.test.ts b/frontend/src/__tests__/esp32-dht22-flow.test.ts new file mode 100644 index 0000000..44b1de4 --- /dev/null +++ b/frontend/src/__tests__/esp32-dht22-flow.test.ts @@ -0,0 +1,430 @@ +/** + * esp32-dht22-flow.test.ts + * + * Tests for the ESP32 sensor registration flow, focusing on the DHT22 example. + * Verifies: + * 1. Esp32BridgeShim delegates registerSensor → bridge.sendSensorAttach + * 2. DHT22 attachEvents detects ESP32 shim and calls registerSensor + * 3. DHT22 attachEvents falls back to local protocol on AVR (registerSensor → false) + * 4. Sensor update flow: updateSensor → bridge.sendSensorUpdate + * 5. Cleanup: unregisterSensor → bridge.sendSensorDetach + * 6. Esp32Bridge includes pre-registered sensors in start_esp32 payload + * 7. Race condition fix: sensors are pre-registered before firmware executes + */ + +import { describe, it, expect, beforeEach, afterEach, 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(false); + this.registerSensor = vi.fn().mockReturnValue(false); + this.updateSensor = vi.fn(); + this.unregisterSensor = vi.fn(); + }), +})); + +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(false); + this.registerSensor = vi.fn().mockReturnValue(false); + this.updateSensor = vi.fn(); + this.unregisterSensor = 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(); + }), +})); + +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() }), + }, +})); + +// WebSocket mock +class MockWebSocket { + static OPEN = 1; + static CLOSING = 2; + static CLOSED = 3; + + readyState = MockWebSocket.OPEN; + onopen: (() => void) | null = null; + onmessage: ((e: { data: string }) => void) | null = null; + onclose: ((e?: any) => void) | null = null; + onerror: ((e?: any) => void) | null = null; + sent: string[] = []; + + send(data: string) { this.sent.push(data); } + close() { this.readyState = MockWebSocket.CLOSED; this.onclose?.({ code: 1000 }); } + open() { this.readyState = MockWebSocket.OPEN; this.onopen?.(); } + receive(payload: object) { + this.onmessage?.({ data: JSON.stringify(payload) }); + } + + /** Parse all sent JSON messages */ + get messages(): Array<{ type: string; data: Record }> { + return this.sent.map((s) => JSON.parse(s)); + } +} + +vi.stubGlobal('WebSocket', MockWebSocket); +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 { Esp32Bridge } from '../simulation/Esp32Bridge'; +import { PartSimulationRegistry } from '../simulation/parts/PartSimulationRegistry'; +import '../simulation/parts/ProtocolParts'; + +// ─── Mock factories ────────────────────────────────────────────────────────── + +function makeElement(props: Record = {}): HTMLElement { + return { + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), + ...props, + } as unknown as HTMLElement; +} + +/** Simulator mock that mimics Esp32BridgeShim (registerSensor returns true) */ +function makeEsp32Shim() { + const bridge = { + sendSensorAttach: vi.fn(), + sendSensorUpdate: vi.fn(), + sendSensorDetach: vi.fn(), + }; + return { + bridge, + pinManager: { + onPinChange: vi.fn().mockReturnValue(() => {}), + onPwmChange: vi.fn().mockReturnValue(() => {}), + }, + setPinState: vi.fn(), + isRunning: vi.fn().mockReturnValue(true), + registerSensor(type: string, pin: number, properties: Record): boolean { + bridge.sendSensorAttach(type, pin, properties); + return true; + }, + updateSensor(pin: number, properties: Record): void { + bridge.sendSensorUpdate(pin, properties); + }, + unregisterSensor(pin: number): void { + bridge.sendSensorDetach(pin); + }, + }; +} + +/** Simulator mock that mimics AVR (registerSensor returns false) */ +function makeAVRSim() { + return { + pinManager: { + onPinChange: vi.fn().mockReturnValue(() => {}), + onPwmChange: vi.fn().mockReturnValue(() => {}), + }, + setPinState: vi.fn(), + isRunning: vi.fn().mockReturnValue(true), + registerSensor: vi.fn().mockReturnValue(false), + updateSensor: vi.fn(), + unregisterSensor: vi.fn(), + schedulePinChange: vi.fn(), + getCurrentCycles: vi.fn().mockReturnValue(1000), + cpu: { data: new Uint8Array(512).fill(0), cycles: 1000 }, + }; +} + +const pinMap = + (map: Record) => + (name: string): number | null => + name in map ? map[name] : null; + +// ───────────────────────────────────────────────────────────────────────────── +// 1. Esp32BridgeShim — registerSensor delegates to bridge +// ───────────────────────────────────────────────────────────────────────────── + +describe('Esp32BridgeShim — sensor registration', () => { + it('registerSensor calls bridge.sendSensorAttach and returns true', () => { + const shim = makeEsp32Shim(); + const result = shim.registerSensor('dht22', 4, { temperature: 28, humidity: 65 }); + expect(result).toBe(true); + expect(shim.bridge.sendSensorAttach).toHaveBeenCalledWith( + 'dht22', 4, { temperature: 28, humidity: 65 } + ); + }); + + it('updateSensor calls bridge.sendSensorUpdate', () => { + const shim = makeEsp32Shim(); + shim.updateSensor(4, { temperature: 30, humidity: 70 }); + expect(shim.bridge.sendSensorUpdate).toHaveBeenCalledWith( + 4, { temperature: 30, humidity: 70 } + ); + }); + + it('unregisterSensor calls bridge.sendSensorDetach', () => { + const shim = makeEsp32Shim(); + shim.unregisterSensor(4); + expect(shim.bridge.sendSensorDetach).toHaveBeenCalledWith(4); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// 2. DHT22 attachEvents — ESP32 shim detection +// ───────────────────────────────────────────────────────────────────────────── + +describe('dht22 — ESP32 shim detection', () => { + const logic = () => PartSimulationRegistry.get('dht22')!; + + it('detects ESP32 shim and delegates sensor to backend', () => { + const shim = makeEsp32Shim(); + const el = makeElement({ temperature: 28, humidity: 65 }); + logic().attachEvents!(el, shim as any, pinMap({ SDA: 4 }), 'dht22-1'); + + // Should have called registerSensor → sendSensorAttach + expect(shim.bridge.sendSensorAttach).toHaveBeenCalledWith( + 'dht22', 4, { temperature: 28, humidity: 65 } + ); + + // Should NOT register onPinChange (local protocol) — ESP32 backend handles it + expect(shim.pinManager.onPinChange).not.toHaveBeenCalled(); + // Should NOT call setPinState (no local pin driving) + expect(shim.setPinState).not.toHaveBeenCalled(); + }); + + it('cleanup calls unregisterSensor', () => { + const shim = makeEsp32Shim(); + const el = makeElement({ temperature: 28, humidity: 65 }); + const cleanup = logic().attachEvents!(el, shim as any, pinMap({ SDA: 4 }), 'dht22-1'); + + cleanup(); + expect(shim.bridge.sendSensorDetach).toHaveBeenCalledWith(4); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// 3. DHT22 attachEvents — AVR fallback (local protocol) +// ───────────────────────────────────────────────────────────────────────────── + +describe('dht22 — AVR local protocol fallback', () => { + const logic = () => PartSimulationRegistry.get('dht22')!; + + it('falls back to local protocol when registerSensor returns false', () => { + const sim = makeAVRSim(); + const el = makeElement({ temperature: 25, humidity: 50 }); + logic().attachEvents!(el, sim as any, pinMap({ SDA: 7 }), 'dht22-avr'); + + // AVR registerSensor returns false → local protocol path + expect(sim.registerSensor).toHaveBeenCalledWith('dht22', 7, { temperature: 25, humidity: 50 }); + + // Local protocol: onPinChange registered for start-signal detection + expect(sim.pinManager.onPinChange).toHaveBeenCalledWith(7, expect.any(Function)); + // Local protocol: DATA pin set HIGH (idle) + expect(sim.setPinState).toHaveBeenCalledWith(7, true); + }); + + it('local protocol does NOT call registerSensor on bridge methods', () => { + const sim = makeAVRSim(); + const el = makeElement({ temperature: 25, humidity: 50 }); + logic().attachEvents!(el, sim as any, pinMap({ SDA: 7 }), 'dht22-avr2'); + + // AVR path: uses onPinChange, not bridge sensor methods + expect(sim.pinManager.onPinChange).toHaveBeenCalledWith(7, expect.any(Function)); + // updateSensor/unregisterSensor should NOT have been called + expect(sim.updateSensor).not.toHaveBeenCalled(); + expect(sim.unregisterSensor).not.toHaveBeenCalled(); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// 4. Sensor update flow +// ───────────────────────────────────────────────────────────────────────────── + +describe('dht22 — sensor update via SensorUpdateRegistry', () => { + const logic = () => PartSimulationRegistry.get('dht22')!; + + it('registerSensorUpdate callback forwards to updateSensor', async () => { + const shim = makeEsp32Shim(); + const el = makeElement({ temperature: 28, humidity: 65 }) as any; + logic().attachEvents!(el, shim as any, pinMap({ SDA: 4 }), 'dht22-update'); + + // Import the registry and dispatch an update + const { dispatchSensorUpdate } = await import('../simulation/SensorUpdateRegistry'); + dispatchSensorUpdate('dht22-update', { temperature: 35 }); + + // Should have forwarded the update to the bridge + expect(shim.bridge.sendSensorUpdate).toHaveBeenCalledWith( + 4, + expect.objectContaining({ temperature: 35 }) + ); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// 5. Esp32Bridge — WebSocket sensor messages +// ───────────────────────────────────────────────────────────────────────────── + +describe('Esp32Bridge — sensor WebSocket protocol', () => { + let bridge: Esp32Bridge; + let ws: MockWebSocket; + + beforeEach(() => { + bridge = new Esp32Bridge('test-esp32', 'esp32'); + bridge.connect(); + // Get the WebSocket instance created in connect() + ws = (bridge as any).socket as MockWebSocket; + ws.open(); // Trigger onopen → sends start_esp32 + ws.sent = []; // Clear the start_esp32 message + }); + + afterEach(() => { + bridge.disconnect(); + }); + + it('sendSensorAttach sends esp32_sensor_attach message', () => { + bridge.sendSensorAttach('dht22', 4, { temperature: 28, humidity: 65 }); + expect(ws.messages).toEqual([ + { + type: 'esp32_sensor_attach', + data: { sensor_type: 'dht22', pin: 4, temperature: 28, humidity: 65 }, + }, + ]); + }); + + it('sendSensorUpdate sends esp32_sensor_update message', () => { + bridge.sendSensorUpdate(4, { temperature: 35, humidity: 70 }); + expect(ws.messages).toEqual([ + { + type: 'esp32_sensor_update', + data: { pin: 4, temperature: 35, humidity: 70 }, + }, + ]); + }); + + it('sendSensorDetach sends esp32_sensor_detach message', () => { + bridge.sendSensorDetach(4); + expect(ws.messages).toEqual([ + { + type: 'esp32_sensor_detach', + data: { pin: 4 }, + }, + ]); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// 6. Esp32Bridge — sensor pre-registration in start_esp32 payload +// ───────────────────────────────────────────────────────────────────────────── + +describe('Esp32Bridge — sensor pre-registration', () => { + it('includes sensors in start_esp32 payload when setSensors is called before connect', () => { + const bridge = new Esp32Bridge('test-esp32', 'esp32'); + bridge.setSensors([ + { sensor_type: 'dht22', pin: 4, temperature: 28, humidity: 65 }, + ]); + bridge.loadFirmware('AAAA'); // base64 firmware + + bridge.connect(); + const ws = (bridge as any).socket as MockWebSocket; + ws.open(); // Trigger onopen → sends start_esp32 + + const startMsg = ws.messages.find((m) => m.type === 'start_esp32'); + expect(startMsg).toBeDefined(); + expect(startMsg!.data.sensors).toEqual([ + { sensor_type: 'dht22', pin: 4, temperature: 28, humidity: 65 }, + ]); + }); + + it('start_esp32 payload has empty sensors array when none pre-registered', () => { + const bridge = new Esp32Bridge('test-esp32', 'esp32'); + bridge.loadFirmware('AAAA'); + bridge.connect(); + const ws = (bridge as any).socket as MockWebSocket; + ws.open(); + + const startMsg = ws.messages.find((m) => m.type === 'start_esp32'); + expect(startMsg).toBeDefined(); + expect(startMsg!.data.sensors).toEqual([]); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// 7. Race condition: sensors pre-registered before firmware starts +// ───────────────────────────────────────────────────────────────────────────── + +describe('Race condition fix — sensors arrive before firmware', () => { + it('sensor_attach message is part of start_esp32, not a separate later message', () => { + const bridge = new Esp32Bridge('test-esp32', 'esp32'); + bridge.setSensors([ + { sensor_type: 'dht22', pin: 4, temperature: 28, humidity: 65 }, + ]); + bridge.loadFirmware('AAAA'); + + bridge.connect(); + const ws = (bridge as any).socket as MockWebSocket; + ws.open(); + + // The very first message should be start_esp32 with sensors included + const firstMsg = ws.messages[0]; + expect(firstMsg.type).toBe('start_esp32'); + expect(firstMsg.data.sensors).toHaveLength(1); + expect(firstMsg.data.sensors[0].sensor_type).toBe('dht22'); + expect(firstMsg.data.sensors[0].pin).toBe(4); + + // No separate sensor_attach message should be needed at this point + const sensorAttachMsgs = ws.messages.filter((m) => m.type === 'esp32_sensor_attach'); + expect(sensorAttachMsgs).toHaveLength(0); + }); + + it('sensors can still be attached dynamically after start', () => { + const bridge = new Esp32Bridge('test-esp32', 'esp32'); + bridge.loadFirmware('AAAA'); + bridge.connect(); + const ws = (bridge as any).socket as MockWebSocket; + ws.open(); + ws.sent = []; // Clear start_esp32 + + // Dynamic attachment (e.g. user adds a sensor during simulation) + bridge.sendSensorAttach('hc-sr04', 12, { distance: 50 }); + expect(ws.messages).toEqual([ + { + type: 'esp32_sensor_attach', + data: { sensor_type: 'hc-sr04', pin: 12, distance: 50 }, + }, + ]); + }); +}); diff --git a/frontend/src/__tests__/esp32-servo-pot.test.ts b/frontend/src/__tests__/esp32-servo-pot.test.ts new file mode 100644 index 0000000..b9919af --- /dev/null +++ b/frontend/src/__tests__/esp32-servo-pot.test.ts @@ -0,0 +1,375 @@ +/** + * 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 + * 8. Potentiometer setAdcVoltage returns false for ESP32 (SimulatorCanvas handles it) + */ + +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 = {}): 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(); + + 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(), + // Test helpers + _getPwmCallback: () => pwmCallback, + _unsubPwm: unsubPwm, + }; +} + +/** 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) => + (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', () => { + 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(); + + // duty 0.0 → 0° + cb!(13, 0.0); + 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); + expect(el.angle).toBe(180); + }); + + 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(); + + // Negative duty → 0° + cb!(13, -0.1); + expect(el.angle).toBe(0); + + // Duty > 1 → 180° + cb!(13, 1.5); + 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: 4096, duty_pct: 50, 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); + }); + + it('falls back to channel when gpio is -1', () => { + const update = { channel: 2, duty: 4096, 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: 8192, 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: 2048, 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 from duty cycle +// ───────────────────────────────────────────────────────────────────────────── + +describe('Servo angle mapping', () => { + const logic = () => PartSimulationRegistry.get('servo')!; + + it('maps duty 0.0 → angle 0, duty 0.5 → angle 90, duty 1.0 → angle 180', () => { + const shim = makeEsp32Shim(); + const el = makeElement() as any; + logic().attachEvents!(el, shim as any, pinMap({ PWM: 13 }), 'servo-map'); + + const cb = shim._getPwmCallback(); + + 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 }, + ]; + + for (const { duty, expectedAngle } of testCases) { + cb!(13, duty); + expect(el.angle).toBe(expectedAngle); + } + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// 8. Potentiometer — setAdcVoltage on ESP32 +// ───────────────────────────────────────────────────────────────────────────── + +describe('Potentiometer — ESP32 ADC path', () => { + it('setAdcVoltage returns false for ESP32 shim (GPIO 34 is not AVR/RP2040 ADC range)', () => { + 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(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); + }); +}); diff --git a/frontend/src/data/examples.ts b/frontend/src/data/examples.ts index 64f2c2b..af9b645 100644 --- a/frontend/src/data/examples.ts +++ b/frontend/src/data/examples.ts @@ -3709,23 +3709,36 @@ void loop() { // Wiring: DATA → GPIO4 | VCC → 3V3 | GND → GND #include +#include +#include +#include #define DHT_PIN 4 // GPIO 4 #define DHT_TYPE DHT22 DHT dht(DHT_PIN, DHT_TYPE); +// Disable hardware Timer Group WDTs (QEMU emulation is slower than real time) +void disableAllWDT() { + esp_task_wdt_deinit(); + TIMERG0.wdt_wprotect = TIMG_WDT_WKEY_VALUE; + TIMERG0.wdt_config0.en = 0; + TIMERG0.wdt_wprotect = 0; + TIMERG1.wdt_wprotect = TIMG_WDT_WKEY_VALUE; + TIMERG1.wdt_config0.en = 0; + TIMERG1.wdt_wprotect = 0; +} + void setup() { + disableAllWDT(); Serial.begin(115200); - // Disable watchdog — DHT pulseIn() can block in emulation - disableCore0WDT(); - disableCore1WDT(); dht.begin(); delay(2000); Serial.println("ESP32 DHT22 ready!"); } void loop() { + disableAllWDT(); delay(2000); float h = dht.readHumidity(); @@ -3907,19 +3920,35 @@ void loop() { // Pot : SIG → D34 | VCC → 3V3 | GND → GND #include +#include +#include +#include #define SERVO_PIN 13 #define POT_PIN 34 // input-only GPIO (ADC) Servo myServo; +// Disable hardware Timer Group WDTs (QEMU emulation is slower than real time) +void disableAllWDT() { + esp_task_wdt_deinit(); + TIMERG0.wdt_wprotect = TIMG_WDT_WKEY_VALUE; + TIMERG0.wdt_config0.en = 0; + TIMERG0.wdt_wprotect = 0; + TIMERG1.wdt_wprotect = TIMG_WDT_WKEY_VALUE; + TIMERG1.wdt_config0.en = 0; + TIMERG1.wdt_wprotect = 0; +} + void setup() { + disableAllWDT(); Serial.begin(115200); myServo.attach(SERVO_PIN, 500, 2400); // standard servo pulse range Serial.println("ESP32 Servo + Pot control"); } void loop() { + disableAllWDT(); // keep WDTs disabled (FreeRTOS may re-enable) int raw = analogRead(POT_PIN); // 0–4095 (12-bit ADC) int angle = map(raw, 0, 4095, 0, 180); myServo.write(angle); @@ -3999,22 +4028,36 @@ void loop() { // Wiring: DATA → GPIO3 | VCC → 3V3 | GND → GND #include +#include +#include +#include #define DHT_PIN 3 // GPIO 3 #define DHT_TYPE DHT22 DHT dht(DHT_PIN, DHT_TYPE); +// Disable hardware Timer Group WDTs (QEMU emulation is slower than real time) +void disableAllWDT() { + esp_task_wdt_deinit(); + TIMERG0.wdt_wprotect = TIMG_WDT_WKEY_VALUE; + TIMERG0.wdt_config0.en = 0; + TIMERG0.wdt_wprotect = 0; + TIMERG1.wdt_wprotect = TIMG_WDT_WKEY_VALUE; + TIMERG1.wdt_config0.en = 0; + TIMERG1.wdt_wprotect = 0; +} + void setup() { + disableAllWDT(); Serial.begin(115200); - // Disable watchdog — DHT pulseIn() can block in emulation - disableCore0WDT(); dht.begin(); delay(2000); Serial.println("ESP32-C3 DHT22 ready!"); } void loop() { + disableAllWDT(); delay(2000); float h = dht.readHumidity(); diff --git a/frontend/src/simulation/AVRSimulator.ts b/frontend/src/simulation/AVRSimulator.ts index a09e806..e441e38 100644 --- a/frontend/src/simulation/AVRSimulator.ts +++ b/frontend/src/simulation/AVRSimulator.ts @@ -651,4 +651,12 @@ export class AVRSimulator { this.i2cBus.addDevice(device); } } + + // ── Generic sensor registration (board-agnostic API) ────────────────────── + // AVR handles all sensor protocols locally via schedulePinChange, + // so these return false / no-op — the sensor runs its own frontend logic. + + registerSensor(_type: string, _pin: number, _props: Record): boolean { return false; } + updateSensor(_pin: number, _props: Record): void {} + unregisterSensor(_pin: number): void {} } diff --git a/frontend/src/simulation/Esp32Bridge.ts b/frontend/src/simulation/Esp32Bridge.ts index a66821f..8089456 100644 --- a/frontend/src/simulation/Esp32Bridge.ts +++ b/frontend/src/simulation/Esp32Bridge.ts @@ -14,6 +14,9 @@ * { type: 'esp32_adc_set', data: { channel: number, millivolts: number } } * { type: 'esp32_i2c_response', data: { addr: number, response: number } } * { type: 'esp32_spi_response', data: { response: number } } + * { type: 'esp32_sensor_attach', data: { sensor_type: string, pin: number, ... } } + * { type: 'esp32_sensor_update', data: { pin: number, ... } } + * { type: 'esp32_sensor_detach', data: { pin: number } } * * Backend → Frontend * { type: 'serial_output', data: { data: string, uart?: number } } @@ -77,6 +80,7 @@ export class Esp32Bridge { private socket: WebSocket | null = null; private _connected = false; private _pendingFirmware: string | null = null; + private _pendingSensors: Array> = []; constructor(boardId: string, boardKind: BoardKind) { this.boardId = boardId; @@ -108,6 +112,7 @@ export class Esp32Bridge { data: { board: toQemuBoardType(this.boardKind), ...(this._pendingFirmware ? { firmware_b64: this._pendingFirmware } : {}), + sensors: this._pendingSensors, }, }); }; @@ -202,6 +207,16 @@ export class Esp32Bridge { this._connected = false; } + /** + * Pre-register sensors so they are included in the start_esp32 payload. + * This ensures sensors are ready in the QEMU worker BEFORE the firmware + * begins executing, preventing race conditions where pulseIn() times out + * because the sensor handler hasn't been registered yet. + */ + setSensors(sensors: Array>): void { + this._pendingSensors = sensors; + } + /** * Load a compiled firmware (base64-encoded .bin) into the running ESP32. * If not yet connected, the firmware will be sent on next connect(). @@ -244,19 +259,25 @@ export class Esp32Bridge { this._send({ type: 'esp32_spi_response', data: { response } }); } - /** Attach a DHT22 sensor to a GPIO pin — backend will handle the protocol */ - dht22Attach(pin: number, temperature: number, humidity: number): void { - this._send({ type: 'esp32_dht22_attach', data: { pin, temperature, humidity } }); + // ── Generic sensor protocol offloading ──────────────────────────────────── + // Sensors call these to delegate their protocol to the backend QEMU. + // The sensor type (e.g. 'dht22', 'hc-sr04') tells the backend which + // protocol handler to use. Sensor-specific properties (temperature, + // humidity, distance …) are passed as a generic Record. + + /** Register a sensor on a GPIO pin — backend handles its protocol */ + sendSensorAttach(sensorType: string, pin: number, properties: Record): void { + this._send({ type: 'esp32_sensor_attach', data: { sensor_type: sensorType, pin, ...properties } }); } - /** Update DHT22 sensor readings */ - dht22Update(pin: number, temperature: number, humidity: number): void { - this._send({ type: 'esp32_dht22_update', data: { pin, temperature, humidity } }); + /** Update sensor properties (temperature, humidity, distance, etc.) */ + sendSensorUpdate(pin: number, properties: Record): void { + this._send({ type: 'esp32_sensor_update', data: { pin, ...properties } }); } - /** Detach the DHT22 sensor from a GPIO pin */ - dht22Detach(pin: number): void { - this._send({ type: 'esp32_dht22_detach', data: { pin } }); + /** Detach a sensor from a GPIO pin */ + sendSensorDetach(pin: number): void { + this._send({ type: 'esp32_sensor_detach', data: { pin } }); } private _send(payload: unknown): void { diff --git a/frontend/src/simulation/RP2040Simulator.ts b/frontend/src/simulation/RP2040Simulator.ts index e533480..d073dea 100644 --- a/frontend/src/simulation/RP2040Simulator.ts +++ b/frontend/src/simulation/RP2040Simulator.ts @@ -490,4 +490,12 @@ export class RP2040Simulator { spi.completeTransmit(response); }; } + + // ── Generic sensor registration (board-agnostic API) ────────────────────── + // RP2040 handles all sensor protocols locally via schedulePinChange, + // so these return false / no-op — the sensor runs its own frontend logic. + + registerSensor(_type: string, _pin: number, _props: Record): boolean { return false; } + updateSensor(_pin: number, _props: Record): void {} + unregisterSensor(_pin: number): void {} } diff --git a/frontend/src/simulation/parts/ComplexParts.ts b/frontend/src/simulation/parts/ComplexParts.ts index 6ac9fa5..3a98f9b 100644 --- a/frontend/src/simulation/parts/ComplexParts.ts +++ b/frontend/src/simulation/parts/ComplexParts.ts @@ -316,6 +316,26 @@ PartSimulationRegistry.register('servo', { return () => { avrSimulator.onPinChangeWithTime = null; }; } + // ── ESP32 path: subscribe to LEDC PWM duty updates via PinManager ── + // Esp32BridgeShim has pinManager but getCurrentCycles() returns -1 + // (no local CPU cycle counter — QEMU runs on the backend). + if (pinSIG !== null && !(avrSimulator instanceof RP2040Simulator)) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const pinManager = (avrSimulator as any).pinManager as import('../PinManager').PinManager | undefined; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const hasCpuCycles = typeof (avrSimulator as any).getCurrentCycles === 'function' + // eslint-disable-next-line @typescript-eslint/no-explicit-any + && (avrSimulator as any).getCurrentCycles() >= 0; + + if (pinManager && !hasCpuCycles) { + const unsubscribe = pinManager.onPwmChange(pinSIG, (_pin, dutyCycle) => { + const angle = Math.round(dutyCycle * 180); + el.angle = Math.max(0, Math.min(180, angle)); + }); + return () => { unsubscribe(); }; + } + } + // ── AVR primary: cycle-accurate pulse width measurement ──────────── if (pinSIG !== null) { // eslint-disable-next-line @typescript-eslint/no-explicit-any diff --git a/frontend/src/simulation/parts/ProtocolParts.ts b/frontend/src/simulation/parts/ProtocolParts.ts index 6c3fefc..40851d9 100644 --- a/frontend/src/simulation/parts/ProtocolParts.ts +++ b/frontend/src/simulation/parts/ProtocolParts.ts @@ -458,24 +458,27 @@ PartSimulationRegistry.register('dht22', { const pin = getPin('SDA') ?? getPin('DATA'); if (pin === null) return () => {}; - // ESP32: delegate DHT22 protocol to the backend QEMU worker. - // The frontend can't provide µs-level GPIO timing over WebSocket. - const sim = simulator as any; - if (sim.isEsp32) { - const bridge = sim.getBridge(); - const el = element as any; - const temperature = el.temperature ?? 25.0; - const humidity = el.humidity ?? 50.0; - bridge.dht22Attach(pin, temperature, humidity); + // Ask the simulator if it handles sensor protocols natively (e.g. ESP32 + // delegates to backend QEMU). If so, we only forward property updates. + const el = element as any; + const temperature = el.temperature ?? 25.0; + const humidity = el.humidity ?? 50.0; + const handledNatively = typeof (simulator as any).registerSensor === 'function' + && (simulator as any).registerSensor('dht22', pin, { temperature, humidity }); + + if (handledNatively) { registerSensorUpdate(componentId, (values) => { if ('temperature' in values) el.temperature = values.temperature as number; if ('humidity' in values) el.humidity = values.humidity as number; - bridge.dht22Update(pin, el.temperature ?? 25.0, el.humidity ?? 50.0); + (simulator as any).updateSensor(pin, { + temperature: el.temperature ?? 25.0, + humidity: el.humidity ?? 50.0, + }); }); return () => { - bridge.dht22Detach(pin); + (simulator as any).unregisterSensor(pin); unregisterSensorUpdate(componentId); }; } diff --git a/frontend/src/store/useSimulatorStore.ts b/frontend/src/store/useSimulatorStore.ts index b65edbd..957d5bf 100644 --- a/frontend/src/store/useSimulatorStore.ts +++ b/frontend/src/store/useSimulatorStore.ts @@ -14,6 +14,19 @@ import { RaspberryPi3Bridge } from '../simulation/RaspberryPi3Bridge'; import { Esp32Bridge } from '../simulation/Esp32Bridge'; import { useEditorStore } from './useEditorStore'; import { useVfsStore } from './useVfsStore'; +import { boardPinToNumber, isBoardComponent } from '../utils/boardPinMapping'; + +// ── Sensor pre-registration ────────────────────────────────────────────────── +// Maps component metadataId → { sensorType, dataPinName, propertyKeys } +// Used to pre-register sensors in the start_esp32 payload so the QEMU worker +// has them ready before the firmware starts executing (prevents race conditions). +const SENSOR_COMPONENT_MAP: Record = { + 'dht22': { sensorType: 'dht22', dataPinName: 'SDA', propertyKeys: ['temperature', 'humidity'] }, +}; // ── Legacy type aliases (keep external consumers working) ────────────────── export type BoardType = 'arduino-uno' | 'arduino-nano' | 'arduino-mega' | 'raspberry-pi-pico'; @@ -50,8 +63,6 @@ class Esp32BridgeShim { } setPinState(pin: number, state: boolean): void { this.bridge.sendPinEvent(pin, state); } - getBridge(): Esp32Bridge { return this.bridge; } - get isEsp32(): boolean { return true; } getCurrentCycles(): number { return -1; } getClockHz(): number { return 240_000_000; } isRunning(): boolean { return this.bridge.connected; } @@ -68,6 +79,20 @@ class Esp32BridgeShim { getSpeed(): number { return 1; } loadHex(_hex: string): void { /* no-op */ } loadBinary(_b64: string): void { /* no-op */ } + + // ── Generic sensor registration (board-agnostic API) ────────────────────── + // ESP32 delegates sensor protocols to the backend QEMU. + + registerSensor(type: string, pin: number, properties: Record): boolean { + this.bridge.sendSensorAttach(type, pin, properties); + return true; // backend handles the protocol + } + updateSensor(pin: number, properties: Record): void { + this.bridge.sendSensorUpdate(pin, properties); + } + unregisterSensor(pin: number): void { + this.bridge.sendSensorDetach(pin); + } } // ── Runtime Maps (outside Zustand — not serialisable) ───────────────────── @@ -536,7 +561,42 @@ export const useSimulatorStore = create((set, get) => { if (board.boardKind === 'raspberry-pi-3') { getBoardBridge(boardId)?.connect(); } else if (isEsp32Kind(board.boardKind)) { - getEsp32Bridge(boardId)?.connect(); + // Pre-register sensors connected to this board so the QEMU worker + // has them ready before the firmware starts executing. + const esp32Bridge = getEsp32Bridge(boardId); + if (esp32Bridge) { + const { components, wires } = get(); + const sensors: Array> = []; + for (const comp of components) { + const sensorDef = SENSOR_COMPONENT_MAP[comp.metadataId]; + if (!sensorDef) continue; + // Find the wire connecting this component's data pin to the board + for (const w of wires) { + const compEndpoint = (w.start.componentId === comp.id && w.start.pinName === sensorDef.dataPinName) + ? w.start : (w.end.componentId === comp.id && w.end.pinName === sensorDef.dataPinName) + ? w.end : null; + if (!compEndpoint) continue; + const boardEndpoint = compEndpoint === w.start ? w.end : w.start; + if (!isBoardComponent(boardEndpoint.componentId)) continue; + // Resolve GPIO pin number + const gpioPin = boardPinToNumber(board.boardKind, boardEndpoint.pinName); + if (gpioPin === null || gpioPin < 0) continue; + // Collect sensor properties from the component + const props: Record = { + sensor_type: sensorDef.sensorType, + pin: gpioPin, + }; + for (const key of sensorDef.propertyKeys) { + const val = comp.properties[key]; + if (val !== undefined) props[key] = typeof val === 'string' ? parseFloat(val) : val; + } + sensors.push(props); + break; // only one data pin per sensor + } + } + esp32Bridge.setSensors(sensors); + esp32Bridge.connect(); + } } else { getBoardSimulator(boardId)?.start(); } @@ -675,7 +735,10 @@ export const useSimulatorStore = create((set, get) => { bridge.onLedcUpdate = (update) => { const boardPm = pinManagerMap.get(boardId); if (boardPm && typeof boardPm.updatePwm === 'function') { - boardPm.updatePwm(update.channel, update.duty_pct); + const targetPin = (update.gpio !== undefined && update.gpio >= 0) + ? update.gpio + : update.channel; + boardPm.updatePwm(targetPin, update.duty_pct / 100); } }; bridge.onWs2812Update = (channel, pixels) => { @@ -768,7 +831,10 @@ export const useSimulatorStore = create((set, get) => { bridge.onLedcUpdate = (update) => { const boardPm = pinManagerMap.get(boardId); if (boardPm && typeof boardPm.updatePwm === 'function') { - boardPm.updatePwm(update.channel, update.duty_pct); + const targetPin = (update.gpio !== undefined && update.gpio >= 0) + ? update.gpio + : update.channel; + boardPm.updatePwm(targetPin, update.duty_pct / 100); } }; esp32BridgeMap.set(boardId, bridge);