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.
pull/47/head
David Montero Crespo 2026-03-22 18:03:17 -03:00
parent f5257009cd
commit 6a55f58e46
13 changed files with 1117 additions and 112 deletions

View File

@ -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':

View File

@ -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) ─────────────────────

View File

@ -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()

View File

@ -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

View File

@ -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<string, unknown> }> {
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<string, unknown> = {}): 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<string, unknown>): boolean {
bridge.sendSensorAttach(type, pin, properties);
return true;
},
updateSensor(pin: number, properties: Record<string, unknown>): 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<string, number>) =>
(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 },
},
]);
});
});

View File

@ -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.01.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<string, unknown> = {}): HTMLElement {
return {
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
angle: 0,
...props,
} as unknown as HTMLElement;
}
/** Simulator mock that mimics Esp32BridgeShim (no valid CPU cycles) */
function makeEsp32Shim() {
let pwmCallback: ((pin: number, duty: number) => void) | null = null;
const unsubPwm = vi.fn();
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<string, number>) =>
(name: string): number | null =>
name in map ? map[name] : null;
// ─────────────────────────────────────────────────────────────────────────────
// 1. Servo — ESP32 path: subscribes to onPwmChange
// ─────────────────────────────────────────────────────────────────────────────
describe('Servo — ESP32 PWM subscription', () => {
const logic = () => PartSimulationRegistry.get('servo')!;
it('subscribes to onPwmChange when simulator has no valid CPU cycles (ESP32 shim)', () => {
const shim = makeEsp32Shim();
const el = makeElement();
logic().attachEvents!(el, shim as any, pinMap({ PWM: 13 }), 'servo-esp32');
expect(shim.pinManager.onPwmChange).toHaveBeenCalledWith(13, expect.any(Function));
});
it('updates angle when PWM duty cycle changes', () => {
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.01.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);
});
});

View File

@ -3709,23 +3709,36 @@ void loop() {
// Wiring: DATA → GPIO4 | VCC → 3V3 | GND → GND
#include <DHT.h>
#include <esp_task_wdt.h>
#include <soc/timer_group_struct.h>
#include <soc/timer_group_reg.h>
#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 <ESP32Servo.h>
#include <esp_task_wdt.h>
#include <soc/timer_group_struct.h>
#include <soc/timer_group_reg.h>
#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); // 04095 (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 <DHT.h>
#include <esp_task_wdt.h>
#include <soc/timer_group_struct.h>
#include <soc/timer_group_reg.h>
#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();

View File

@ -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<string, unknown>): boolean { return false; }
updateSensor(_pin: number, _props: Record<string, unknown>): void {}
unregisterSensor(_pin: number): void {}
}

View File

@ -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<Record<string, unknown>> = [];
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<Record<string, unknown>>): 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<string, unknown>): 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<string, unknown>): 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 {

View File

@ -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<string, unknown>): boolean { return false; }
updateSensor(_pin: number, _props: Record<string, unknown>): void {}
unregisterSensor(_pin: number): void {}
}

View File

@ -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

View File

@ -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);
};
}

View File

@ -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<string, {
sensorType: string;
dataPinName: string;
propertyKeys: string[];
}> = {
'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<string, unknown>): boolean {
this.bridge.sendSensorAttach(type, pin, properties);
return true; // backend handles the protocol
}
updateSensor(pin: number, properties: Record<string, unknown>): 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<SimulatorState>((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<Record<string, unknown>> = [];
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<string, unknown> = {
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<SimulatorState>((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<SimulatorState>((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);