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
parent
f5257009cd
commit
6a55f58e46
|
|
@ -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':
|
||||
|
|
|
|||
|
|
@ -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) ─────────────────────
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
|
@ -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<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.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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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); // 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 <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();
|
||||
|
|
|
|||
|
|
@ -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 {}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Reference in New Issue