diff --git a/backend/app/services/esp32_worker.py b/backend/app/services/esp32_worker.py index f50a1723..48f86759 100644 --- a/backend/app/services/esp32_worker.py +++ b/backend/app/services/esp32_worker.py @@ -238,11 +238,285 @@ def main() -> None: # noqa: C901 (complexity OK for inline worker) argc = len(args_list) argv = (ctypes.c_char_p * argc)(*args_list) + # ── I2C slave state machine (MPU-6050 and future register-map devices) ────── + class _MPU6050Slave: + """Full MPU-6050 register-map I2C slave emulation. + + I2C event constants (picsimlab convention): + 0x0100 = START + address phase → return 1 (ACK = device present) + 0x0200 = WRITE byte (byte in bits 7:0) → return 1 (ACK) + 0x0300 = READ request → return register byte at current pointer + 0x0000 = STOP + """ + I2C_START = 0x0100 + I2C_WRITE = 0x0200 + I2C_READ = 0x0300 + + def __init__(self, addr: int = 0x68): + self.addr = addr + self.regs = bytearray(256) + self.reg_ptr = 0 + self.first_byte = True + + # WHO_AM_I + self.regs[0x75] = 0x68 + # PWR_MGMT_1 — awake (0 = no sleep) + self.regs[0x6B] = 0x00 + # ACCEL_CONFIG / GYRO_CONFIG (default ±2g / ±250°/s) + self.regs[0x1C] = 0x00 + self.regs[0x1B] = 0x00 + # ACCEL_Z = +1g = 16384 (0x4000) at ±2g full-scale + self.regs[0x3B] = 0x00; self.regs[0x3C] = 0x00 # X + self.regs[0x3D] = 0x00; self.regs[0x3E] = 0x00 # Y + self.regs[0x3F] = 0x40; self.regs[0x40] = 0x00 # Z = +1g + # TEMP: T(°C) = raw/340 + 36.53 → raw = (25 - 36.53) × 340 ≈ -3920 = 0xF190 + temp_raw = round((25.0 - 36.53) * 340) & 0xFFFF + self.regs[0x41] = (temp_raw >> 8) & 0xFF + self.regs[0x42] = temp_raw & 0xFF + # GYRO all zero (stationary) + + def handle_event(self, event: int) -> int: + phase = event & 0xFF00 + if phase == self.I2C_START: + self.first_byte = True + return 1 # ACK — device present + elif phase == self.I2C_WRITE: + data = event & 0xFF + if self.first_byte: + self.reg_ptr = data + self.first_byte = False + else: + self.regs[self.reg_ptr] = data + self.reg_ptr = (self.reg_ptr + 1) & 0xFF + return 1 # ACK + elif phase == self.I2C_READ: + val = self.regs[self.reg_ptr] + self.reg_ptr = (self.reg_ptr + 1) & 0xFF + return val + else: # STOP / unknown + self.first_byte = True + return 0 + + def _write_i16(self, reg_h: int, raw_float: float) -> None: + raw = max(-32768, min(32767, round(raw_float))) & 0xFFFF + self.regs[reg_h] = (raw >> 8) & 0xFF + self.regs[reg_h + 1] = raw & 0xFF + + def update(self, accel_x: float = 0, accel_y: float = 0, accel_z: float = 1, + gyro_x: float = 0, gyro_y: float = 0, gyro_z: float = 0, + temp: float = 25.0) -> None: + self._write_i16(0x3B, accel_x * 16384) + self._write_i16(0x3D, accel_y * 16384) + self._write_i16(0x3F, accel_z * 16384) + self._write_i16(0x43, gyro_x * 131) + self._write_i16(0x45, gyro_y * 131) + self._write_i16(0x47, gyro_z * 131) + self._write_i16(0x41, (temp - 36.53) * 340) + + # ── BMP280 Barometric Pressure + Temperature Sensor ────────────────────────── + class _BMP280Slave: + """Full BMP280 register-map I2C slave (address 0x76 or 0x77). + + Uses BMP280 datasheet Section 8.2 example calibration constants. + Implements Bosch compensation formulas with binary-search inversion + to find raw ADC values from the desired temperature / pressure. + """ + # Section 8.2 calibration constants + DIG_T1 = 27504; DIG_T2 = 26435; DIG_T3 = -1000 + DIG_P1 = 36477; DIG_P2 = -10685; DIG_P3 = 3024 + DIG_P4 = 2855; DIG_P5 = 140; DIG_P6 = -7 + DIG_P7 = 15500; DIG_P8 = -14600; DIG_P9 = 6000 + + def __init__(self, addr: int = 0x76): + self.addr = addr + self.regs = bytearray(256) + self.reg_ptr = 0 + self.first_byte = True + self._temp_c = 25.0 + self._press_hpa = 1013.25 + self._init_calibration() + self._update_measurements() + + # ── calibration register layout ────────────────────────────────────── + def _wu16(self, a: int, v: int) -> None: + self.regs[a] = v & 0xFF; self.regs[a + 1] = (v >> 8) & 0xFF + + def _ws16(self, a: int, v: int) -> None: + self._wu16(a, v & 0xFFFF) + + def _init_calibration(self) -> None: + self.regs[0xD0] = 0x60 # chip_id BMP280 + self.regs[0xF3] = 0x00 # status (done) + self._wu16(0x88, self.DIG_T1); self._ws16(0x8A, self.DIG_T2); self._ws16(0x8C, self.DIG_T3) + self._wu16(0x8E, self.DIG_P1); self._ws16(0x90, self.DIG_P2); self._ws16(0x92, self.DIG_P3) + self._ws16(0x94, self.DIG_P4); self._ws16(0x96, self.DIG_P5); self._ws16(0x98, self.DIG_P6) + self._ws16(0x9A, self.DIG_P7); self._ws16(0x9C, self.DIG_P8); self._ws16(0x9E, self.DIG_P9) + + # ── Bosch compensation formulas ─────────────────────────────────────── + def _t_fine(self, adc_t: int) -> int: + v1 = (((adc_t >> 3) - (self.DIG_T1 << 1)) * self.DIG_T2) >> 11 + s = (adc_t >> 4) - self.DIG_T1 + v2 = ((s * s >> 12) * self.DIG_T3) >> 14 + return v1 + v2 + + def _compensate_t(self, adc_t: int) -> int: + return (self._t_fine(adc_t) * 5 + 128) >> 8 + + def _compensate_p(self, adc_p: int, adc_t: int) -> float: + tf = self._t_fine(adc_t) + v1 = tf / 2.0 - 64000.0 + v2 = v1 * v1 * self.DIG_P6 / 32768.0 + v2 = v2 + v1 * self.DIG_P5 * 2.0 + v2 = v2 / 4.0 + self.DIG_P4 * 65536.0 + v1 = (self.DIG_P3 * v1 * v1 / 524288.0 + self.DIG_P2 * v1) / 524288.0 + v1 = (1.0 + v1 / 32768.0) * self.DIG_P1 + if v1 == 0: + return 0.0 + p = 1048576.0 - adc_p + p = (p - v2 / 4096.0) * 6250.0 / v1 + p = p + (self.DIG_P9 * p * p / 2147483648.0 + p * self.DIG_P8 / 32768.0 + self.DIG_P7) / 16.0 + return p + + def _find_adc_t(self, target_centideg: int) -> int: + lo, hi = 0, (1 << 20) - 1 + while lo < hi: + mid = (lo + hi) >> 1 + if self._compensate_t(mid) < target_centideg: + lo = mid + 1 + else: + hi = mid + return lo + + def _find_adc_p(self, target_pa: float, adc_t: int) -> int: + lo, hi = 0, (1 << 20) - 1 + while lo < hi: + mid = (lo + hi) >> 1 + if self._compensate_p(mid, adc_t) > target_pa: + lo = mid + 1 + else: + hi = mid + return lo + + def _encode20(self, v: int) -> tuple: + return (v >> 12) & 0xFF, (v >> 4) & 0xFF, (v & 0xF) << 4 + + def _update_measurements(self) -> None: + adc_t = self._find_adc_t(round(self._temp_c * 100)) + adc_p = self._find_adc_p(self._press_hpa * 100.0, adc_t) + pm, pl, px = self._encode20(adc_p) + tm, tl, tx = self._encode20(adc_t) + self.regs[0xF7] = pm; self.regs[0xF8] = pl; self.regs[0xF9] = px + self.regs[0xFA] = tm; self.regs[0xFB] = tl; self.regs[0xFC] = tx + + def update(self, temperature_c: float, pressure_hpa: float) -> None: + self._temp_c = temperature_c + self._press_hpa = pressure_hpa + self._update_measurements() + + def handle_event(self, event: int) -> int: + phase = event & 0xFF00 + if phase == 0x0100: + self.first_byte = True; return 1 + elif phase == 0x0200: + data = event & 0xFF + if self.first_byte: + self.reg_ptr = data; self.first_byte = False + else: + self.regs[self.reg_ptr] = data + self.reg_ptr = (self.reg_ptr + 1) & 0xFF + return 1 + elif phase == 0x0300: + val = self.regs[self.reg_ptr] + self.reg_ptr = (self.reg_ptr + 1) & 0xFF + return val + else: + self.first_byte = True; return 0 + + # ── DS1307 / DS3231 Real-Time Clock ─────────────────────────────────────── + import datetime as _datetime + + class _DS1307Slave: + """DS1307 I2C RTC — returns current system time in BCD (address 0x68).""" + def __init__(self) -> None: + self.reg_ptr = 0 + self.first_byte = True + + @staticmethod + def _bcd(n: int) -> int: + return ((n // 10) << 4) | (n % 10) + + def _read_reg(self, reg: int) -> int: + now = _datetime.datetime.now() + if reg == 0x00: return self._bcd(now.second) + elif reg == 0x01: return self._bcd(now.minute) + elif reg == 0x02: return self._bcd(now.hour) + elif reg == 0x03: return self._bcd(now.weekday() + 1) # Mon=1..Sun=7 + elif reg == 0x04: return self._bcd(now.day) + elif reg == 0x05: return self._bcd(now.month) + elif reg == 0x06: return self._bcd(now.year % 100) + return 0x00 + + def handle_event(self, event: int) -> int: + phase = event & 0xFF00 + if phase == 0x0100: + self.first_byte = True; return 1 + elif phase == 0x0200: + data = event & 0xFF + if self.first_byte: + self.reg_ptr = data; self.first_byte = False + return 1 + elif phase == 0x0300: + val = self._read_reg(self.reg_ptr) + self.reg_ptr = (self.reg_ptr + 1) & 0x3F + return val + else: + self.first_byte = True; return 0 + + class _DS3231Slave(_DS1307Slave): + """DS3231 I2C RTC with on-chip temperature (address 0x68).""" + def __init__(self) -> None: + super().__init__() + self.temperatureC = 25.0 + + def _read_reg(self, reg: int) -> int: + if reg == 0x0E: return 0x00 # Control + if reg == 0x0F: return 0x00 # Status (OSF cleared) + if reg == 0x11: # Temp MSB (signed integer °C) + return int(self.temperatureC) & 0xFF + if reg == 0x12: # Temp LSB (fractional bits 7:6) + frac = abs(self.temperatureC) - int(abs(self.temperatureC)) + return (round(frac / 0.25) & 0x03) << 6 + return super()._read_reg(reg) + + # ── I2C Write Sink (relay for write-only devices: SSD1306, PCF8574) ────── + class _I2CWriteSink: + """ACKs all I2C writes, emits complete transaction to frontend on STOP.""" + def __init__(self, addr: int, emit_fn) -> None: + self.addr = addr + self._emit = emit_fn + self._buf: list[int] = [] + + def handle_event(self, event: int) -> int: + phase = event & 0xFF00 + if phase == 0x0100: # START — reset buffer + self._buf = []; return 1 + elif phase == 0x0200: # WRITE — accumulate byte + self._buf.append(event & 0xFF); return 1 + elif phase == 0x0300: # READ — write-only device + return 0xFF + else: # STOP — emit transaction + if self._buf: + self._emit({'type': 'i2c_transaction', + 'addr': self.addr, 'data': list(self._buf)}) + self._buf = [] + return 0 + # ── 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 + _i2c_responses: dict[int, int] = {} # 7-bit addr → response byte (simple) + _i2c_slaves: dict = {} # 7-bit addr → I2C slave/sink instance _spi_response = [0xFF] # MISO byte for SPI transfers _rmt_decoders: dict[int, _RmtDecoder] = {} _uart0_buf = bytearray() # accumulate UART0 for crash detection @@ -560,6 +834,10 @@ def main() -> None: # noqa: C901 (complexity OK for inline worker) def _on_i2c_event(bus_id: int, addr: int, event: int) -> int: """Synchronous — must return immediately; called from QEMU thread.""" + # Register-map slaves (MPU-6050, etc.) take priority over static responses + slave = _i2c_slaves.get(addr) + if slave is not None: + return slave.handle_event(event) resp = _i2c_responses.get(addr, 0) if not _stopped.is_set(): _emit({'type': 'i2c_event', 'bus': bus_id, 'addr': addr, @@ -725,13 +1003,39 @@ def main() -> None: # noqa: C901 (complexity OK for inline worker) gpio = int(cmd['pin']) sensor_type = cmd.get('sensor_type', '') with _sensors_lock: - _sensors[gpio] = { + sensor_data: dict = { 'type': sensor_type, **{k: v for k, v in cmd.items() if k not in ('cmd', 'pin', 'sensor_type')}, 'saw_low': False, 'responding': False, } + if sensor_type == 'mpu6050': + i2c_addr = int(cmd.get('addr', 0x68)) + slave = _MPU6050Slave(i2c_addr) + _i2c_slaves[i2c_addr] = slave + sensor_data['i2c_addr'] = i2c_addr + sensor_data['slave'] = slave + elif sensor_type == 'bmp280': + i2c_addr = int(cmd.get('addr', 0x76)) + slave = _BMP280Slave(i2c_addr) + _i2c_slaves[i2c_addr] = slave + sensor_data['i2c_addr'] = i2c_addr + sensor_data['slave'] = slave + elif sensor_type in ('ds1307', 'ds3231'): + i2c_addr = 0x68 + slave = _DS3231Slave() if sensor_type == 'ds3231' else _DS1307Slave() + _i2c_slaves[i2c_addr] = slave + sensor_data['i2c_addr'] = i2c_addr + sensor_data['slave'] = slave + elif sensor_type in ('ssd1306', 'pcf8574'): + default_addr = 0x3C if sensor_type == 'ssd1306' else 0x27 + i2c_addr = int(cmd.get('addr', default_addr)) + sink = _I2CWriteSink(i2c_addr, _emit) + _i2c_slaves[i2c_addr] = sink + sensor_data['i2c_addr'] = i2c_addr + sensor_data['slave'] = sink + _sensors[gpio] = sensor_data _log(f'Sensor {sensor_type} attached on GPIO {gpio}') elif c == 'sensor_update': @@ -742,11 +1046,32 @@ def main() -> None: # noqa: C901 (complexity OK for inline worker) for k, v in cmd.items(): if k not in ('cmd', 'pin'): sensor[k] = v + stype = sensor.get('type') + slave = sensor.get('slave') + if stype == 'mpu6050' and slave is not None: + slave.update( + accel_x=float(sensor.get('accelX', 0)), + accel_y=float(sensor.get('accelY', 0)), + accel_z=float(sensor.get('accelZ', 1)), + gyro_x =float(sensor.get('gyroX', 0)), + gyro_y =float(sensor.get('gyroY', 0)), + gyro_z =float(sensor.get('gyroZ', 0)), + temp =float(sensor.get('temp', 25.0)), + ) + elif stype == 'bmp280' and slave is not None: + slave.update( + temperature_c =float(sensor.get('temperature', 25.0)), + pressure_hpa =float(sensor.get('pressure', 1013.25)), + ) + elif stype == 'ds3231' and slave is not None: + slave.temperatureC = float(sensor.get('temperature', 25.0)) elif c == 'sensor_detach': gpio = int(cmd['pin']) with _sensors_lock: - _sensors.pop(gpio, None) + sensor = _sensors.pop(gpio, None) + if sensor and 'i2c_addr' in sensor: + _i2c_slaves.pop(sensor['i2c_addr'], None) _log(f'Sensor detached from GPIO {gpio}') elif c == 'stop': diff --git a/frontend/src/data/examples.ts b/frontend/src/data/examples.ts index f109c49c..6fe85be6 100644 --- a/frontend/src/data/examples.ts +++ b/frontend/src/data/examples.ts @@ -4675,6 +4675,128 @@ void loop() { components: [], wires: [], }, + + // ── ESP32 BMP280 Weather Station ───────────────────────────────────────────── + { + id: 'esp32-bmp280', + title: 'ESP32: BMP280 Weather Station', + description: 'Read temperature and pressure from a BMP280 barometric sensor over I2C (SDA=D21, SCL=D22).', + libraries: ['Adafruit BMP280 Library', 'Adafruit Unified Sensor'], + category: 'sensors', + difficulty: 'intermediate', + boardType: 'esp32', + boardFilter: 'esp32', + code: `// ESP32 — BMP280 Barometric Pressure & Temperature (I2C) +// Requires: Adafruit BMP280 Library, Adafruit Unified Sensor +// Wiring: SDA → D21 | SCL → D22 | VCC → 3V3 | GND → GND + +#include +#include + +Adafruit_BMP280 bmp; + +void setup() { + Serial.begin(115200); + Wire.begin(21, 22); + if (!bmp.begin(0x76)) { + Serial.println("BMP280 not found! Check wiring."); + while (true) delay(10); + } + bmp.setSampling(Adafruit_BMP280::MODE_NORMAL, + Adafruit_BMP280::SAMPLING_X2, + Adafruit_BMP280::SAMPLING_X16, + Adafruit_BMP280::FILTER_X16, + Adafruit_BMP280::STANDBY_MS_500); + Serial.println("BMP280 ready!"); +} + +void loop() { + float tempC = bmp.readTemperature(); + float pressure = bmp.readPressure() / 100.0F; // hPa + float altitude = bmp.readAltitude(1013.25); // m + + Serial.printf("Temp: %.2f C Pressure: %.2f hPa Altitude: %.1f m\\n", + tempC, pressure, altitude); + delay(2000); +}`, + components: [ + { type: 'wokwi-bmp280', id: 'e32-bmp1', x: 420, y: 150, properties: { temperature: '25', pressure: '1013.25' } }, + ], + wires: [ + { id: 'e32b-vcc', start: { componentId: 'esp32', pinName: '3V3' }, end: { componentId: 'e32-bmp1', pinName: 'VCC' }, color: '#ff4444' }, + { id: 'e32b-gnd', start: { componentId: 'esp32', pinName: 'GND' }, end: { componentId: 'e32-bmp1', pinName: 'GND' }, color: '#000000' }, + { id: 'e32b-sda', start: { componentId: 'esp32', pinName: '21' }, end: { componentId: 'e32-bmp1', pinName: 'SDA' }, color: '#22aaff' }, + { id: 'e32b-scl', start: { componentId: 'esp32', pinName: '22' }, end: { componentId: 'e32-bmp1', pinName: 'SCL' }, color: '#ff8800' }, + ], + }, + + // ── ESP32 SSD1306 OLED Display ──────────────────────────────────────────────── + { + id: 'esp32-oled', + title: 'ESP32: SSD1306 OLED Display', + description: 'Display text and graphics on a 128×64 SSD1306 OLED over I2C (SDA=D21, SCL=D22).', + libraries: ['Adafruit SSD1306', 'Adafruit GFX Library'], + category: 'displays', + difficulty: 'intermediate', + boardType: 'esp32', + boardFilter: 'esp32', + code: `// ESP32 — SSD1306 OLED Display (I2C 128×64) +// Requires: Adafruit SSD1306, Adafruit GFX Library +// Wiring: SDA → D21 | SCL → D22 | VCC → 3V3 | GND → GND + +#include +#include +#include + +#define SCREEN_WIDTH 128 +#define SCREEN_HEIGHT 64 + +Adafruit_SSD1306 display(SCREEN_WIDTH, SCREEN_HEIGHT, &Wire, -1); + +int counter = 0; + +void setup() { + Serial.begin(115200); + Wire.begin(21, 22); + if (!display.begin(SSD1306_SWITCHCAPVCC, 0x3C)) { + Serial.println("SSD1306 not found!"); + while (true) delay(10); + } + display.clearDisplay(); + display.setTextSize(2); + display.setTextColor(SSD1306_WHITE); + display.setCursor(0, 0); + display.println("Hello"); + display.println("Velxio!"); + display.display(); + Serial.println("OLED ready!"); +} + +void loop() { + counter++; + display.clearDisplay(); + display.setTextSize(2); + display.setTextColor(SSD1306_WHITE); + display.setCursor(0, 0); + display.println("Hello"); + display.println("Velxio!"); + display.setTextSize(1); + display.setCursor(0, 48); + display.printf("Count: %d", counter); + display.display(); + Serial.printf("Frame: %d\\n", counter); + delay(1000); +}`, + components: [ + { type: 'wokwi-ssd1306', id: 'e32-oled1', x: 420, y: 130, properties: {} }, + ], + wires: [ + { id: 'e32o-vcc', start: { componentId: 'esp32', pinName: '3V3' }, end: { componentId: 'e32-oled1', pinName: 'VCC' }, color: '#ff4444' }, + { id: 'e32o-gnd', start: { componentId: 'esp32', pinName: 'GND' }, end: { componentId: 'e32-oled1', pinName: 'GND' }, color: '#000000' }, + { id: 'e32o-sda', start: { componentId: 'esp32', pinName: '21' }, end: { componentId: 'e32-oled1', pinName: 'SDA' }, color: '#22aaff' }, + { id: 'e32o-scl', start: { componentId: 'esp32', pinName: '22' }, end: { componentId: 'e32-oled1', pinName: 'SCL' }, color: '#ff8800' }, + ], + }, ]; // Get examples by category diff --git a/frontend/src/simulation/Esp32Bridge.ts b/frontend/src/simulation/Esp32Bridge.ts index 2c03b02a..717de8db 100644 --- a/frontend/src/simulation/Esp32Bridge.ts +++ b/frontend/src/simulation/Esp32Bridge.ts @@ -24,8 +24,9 @@ * { type: 'gpio_dir', data: { pin: number, dir: 0 | 1 } } * { type: 'ledc_update', data: { channel: number, duty: number, duty_pct: number } } * { type: 'ws2812_update', data: { channel: number, pixels: [number, number, number][] } } - * { type: 'i2c_event', data: { addr: number, data: number } } - * { type: 'spi_event', data: { data: number } } + * { type: 'i2c_event', data: { addr: number, data: number } } + * { type: 'i2c_transaction', data: { addr: number, data: number[] } } + * { type: 'spi_event', data: { data: number } } * { type: 'system', data: { event: string, ... } } * { type: 'error', data: { message: string } } */ @@ -76,8 +77,9 @@ export class Esp32Bridge { onPinDir: ((gpioPin: number, dir: 0 | 1) => void) | null = null; onLedcUpdate: ((update: LedcUpdate) => void) | null = null; onWs2812Update: ((channel: number, pixels: Ws2812Pixel[]) => void) | null = null; - onI2cEvent: ((addr: number, data: number) => void) | null = null; - onSpiEvent: ((data: number) => void) | null = null; + onI2cEvent: ((addr: number, data: number) => void) | null = null; + onI2cTransaction: ((addr: number, data: number[]) => void) | null = null; + onSpiEvent: ((data: number) => void) | null = null; onConnected: (() => void) | null = null; onDisconnected: (() => void) | null = null; onError: ((msg: string) => void) | null = null; @@ -179,6 +181,12 @@ export class Esp32Bridge { this.onI2cEvent?.(addr, data); break; } + case 'i2c_transaction': { + const addr = msg.data.addr as number; + const data = msg.data.data as number[]; + this.onI2cTransaction?.(addr, data); + break; + } case 'spi_event': { const data = msg.data.data as number; this.onSpiEvent?.(data); diff --git a/frontend/src/simulation/parts/ProtocolParts.ts b/frontend/src/simulation/parts/ProtocolParts.ts index aa1e1522..a67c372e 100644 --- a/frontend/src/simulation/parts/ProtocolParts.ts +++ b/frontend/src/simulation/parts/ProtocolParts.ts @@ -293,10 +293,30 @@ PartSimulationRegistry.register('ssd1306', { // I2C mode (default) const sim = simulator as any; - if (typeof sim.addI2CDevice !== 'function') return () => {}; - const device = new VirtualSSD1306(0x3C, element); - sim.addI2CDevice(device); - return () => removeI2CDevice(sim, device.address); + const i2cAddr = 0x3C; + + if (typeof sim.addI2CDevice === 'function') { + // ── AVR / RP2040 path ────────────────────────────────────────────────── + const device = new VirtualSSD1306(i2cAddr, element); + sim.addI2CDevice(device); + return () => removeI2CDevice(sim, device.address); + + } else if (typeof sim.registerSensor === 'function') { + // ── ESP32 path: relay I2C writes via backend ─────────────────────────── + const virtualPin = 200 + i2cAddr; + const device = new VirtualSSD1306(i2cAddr, element); + sim.registerSensor('ssd1306', virtualPin, { addr: i2cAddr }); + sim.addI2CTransactionListener(i2cAddr, (data: number[]) => { + data.forEach((b: number) => device.writeByte(b)); + device.stop(); + }); + return () => { + sim.unregisterSensor(virtualPin); + sim.removeI2CTransactionListener(i2cAddr); + }; + } + + return () => {}; }, }); @@ -309,10 +329,21 @@ PartSimulationRegistry.register('ssd1306', { PartSimulationRegistry.register('ds1307', { attachEvents: (_element, simulator, _getPin) => { const sim = simulator as any; - if (typeof sim.addI2CDevice !== 'function') return () => {}; - const rtc = new VirtualDS1307(); - sim.addI2CDevice(rtc); - return () => removeI2CDevice(sim, rtc.address); + + if (typeof sim.addI2CDevice === 'function') { + // ── AVR / RP2040 path ────────────────────────────────────────────────── + const rtc = new VirtualDS1307(); + sim.addI2CDevice(rtc); + return () => removeI2CDevice(sim, rtc.address); + + } else if (typeof sim.registerSensor === 'function') { + // ── ESP32 path: delegate to backend QEMU RTC slave ──────────────────── + const virtualPin = 200 + 0x68; + sim.registerSensor('ds1307', virtualPin, { addr: 0x68 }); + return () => sim.unregisterSensor(virtualPin); + } + + return () => {}; }, }); @@ -386,35 +417,55 @@ class VirtualMPU6050 implements I2CDevice { PartSimulationRegistry.register('mpu6050', { attachEvents: (element, simulator, _getPin, componentId) => { - const sim = simulator as any; - if (typeof sim.addI2CDevice !== 'function') return () => {}; - const el = element as any; - // Respect AD0 pin on element: `el.ad0 = true` → address 0x69 + const sim = simulator as any; + const el = element as any; + // Respect AD0 pin: `el.ad0 = true` → address 0x69, else 0x68 const addr = (el.ad0 === true || el.ad0 === 'true') ? 0x69 : 0x68; - const device = new VirtualMPU6050(addr); - sim.addI2CDevice(device); - // Helper: write a signed 16-bit value to two consecutive registers (H, L) - const writeI16 = (regH: number, raw: number) => { - const v = Math.max(-32768, Math.min(32767, Math.round(raw))) & 0xFFFF; - device.registers[regH] = (v >> 8) & 0xFF; - device.registers[regH + 1] = v & 0xFF; - }; + if (typeof sim.addI2CDevice === 'function') { + // ── AVR / RP2040 path: virtual I2C device in JavaScript ────────────── + const device = new VirtualMPU6050(addr); + sim.addI2CDevice(device); - registerSensorUpdate(componentId, (values) => { - if ('accelX' in values) writeI16(0x3B, (values.accelX as number) * 16384); - if ('accelY' in values) writeI16(0x3D, (values.accelY as number) * 16384); - if ('accelZ' in values) writeI16(0x3F, (values.accelZ as number) * 16384); - if ('gyroX' in values) writeI16(0x43, (values.gyroX as number) * 131); - if ('gyroY' in values) writeI16(0x45, (values.gyroY as number) * 131); - if ('gyroZ' in values) writeI16(0x47, (values.gyroZ as number) * 131); - if ('temp' in values) writeI16(0x41, ((values.temp as number) - 36.53) * 340); - }); + const writeI16 = (regH: number, raw: number) => { + const v = Math.max(-32768, Math.min(32767, Math.round(raw))) & 0xFFFF; + device.registers[regH] = (v >> 8) & 0xFF; + device.registers[regH + 1] = v & 0xFF; + }; - return () => { - removeI2CDevice(sim, device.address); - unregisterSensorUpdate(componentId); - }; + registerSensorUpdate(componentId, (values) => { + if ('accelX' in values) writeI16(0x3B, (values.accelX as number) * 16384); + if ('accelY' in values) writeI16(0x3D, (values.accelY as number) * 16384); + if ('accelZ' in values) writeI16(0x3F, (values.accelZ as number) * 16384); + if ('gyroX' in values) writeI16(0x43, (values.gyroX as number) * 131); + if ('gyroY' in values) writeI16(0x45, (values.gyroY as number) * 131); + if ('gyroZ' in values) writeI16(0x47, (values.gyroZ as number) * 131); + if ('temp' in values) writeI16(0x41, ((values.temp as number) - 36.53) * 340); + }); + + return () => { + removeI2CDevice(sim, device.address); + unregisterSensorUpdate(componentId); + }; + + } else if (typeof sim.registerSensor === 'function') { + // ── ESP32 path: delegate to backend QEMU I2C slave state machine ───── + // Use (200 + addr) as a virtual pin for I2C sensors — above valid GPIO + // range (0–48) so it won't collide with real GPIO sensors. + const virtualPin = 200 + addr; + sim.registerSensor('mpu6050', virtualPin, { addr }); + + registerSensorUpdate(componentId, (values) => { + sim.updateSensor(virtualPin, values); + }); + + return () => { + sim.unregisterSensor(virtualPin); + unregisterSensorUpdate(componentId); + }; + } + + return () => {}; }, }); @@ -972,28 +1023,47 @@ PartSimulationRegistry.register('microsd-card', { */ PartSimulationRegistry.register('bmp280', { attachEvents: (element, simulator, _getPin, componentId) => { - const sim = simulator as any; - if (typeof sim.addI2CDevice !== 'function') return () => {}; - + const sim = simulator as any; const el = element as any; const addr = (el.address === '0x77' || el.address === 0x77) ? 0x77 : 0x76; - const dev = new VirtualBMP280(addr); - if (el.temperature !== undefined) dev.temperatureC = parseFloat(el.temperature); - if (el.pressure !== undefined) dev.pressureHPa = parseFloat(el.pressure); + if (typeof sim.addI2CDevice === 'function') { + // ── AVR / RP2040 path ────────────────────────────────────────────────── + const dev = new VirtualBMP280(addr); - sim.addI2CDevice(dev); + if (el.temperature !== undefined) dev.temperatureC = parseFloat(el.temperature); + if (el.pressure !== undefined) dev.pressureHPa = parseFloat(el.pressure); - // SensorControlPanel: update temperature / pressure in real-time - registerSensorUpdate(componentId, (values) => { - if ('temperature' in values) dev.temperatureC = values.temperature as number; - if ('pressure' in values) dev.pressureHPa = values.pressure as number; - }); + sim.addI2CDevice(dev); - return () => { - removeI2CDevice(sim, dev.address); - unregisterSensorUpdate(componentId); - }; + registerSensorUpdate(componentId, (values) => { + if ('temperature' in values) dev.temperatureC = values.temperature as number; + if ('pressure' in values) dev.pressureHPa = values.pressure as number; + }); + + return () => { + removeI2CDevice(sim, dev.address); + unregisterSensorUpdate(componentId); + }; + + } else if (typeof sim.registerSensor === 'function') { + // ── ESP32 path: delegate to backend QEMU BMP280 slave ───────────────── + const virtualPin = 200 + addr; + const initTemp = el.temperature !== undefined ? parseFloat(el.temperature) : 25.0; + const initPressure = el.pressure !== undefined ? parseFloat(el.pressure) : 1013.25; + sim.registerSensor('bmp280', virtualPin, { addr, temperature: initTemp, pressure: initPressure }); + + registerSensorUpdate(componentId, (values) => { + sim.updateSensor(virtualPin, values); + }); + + return () => { + sim.unregisterSensor(virtualPin); + unregisterSensorUpdate(componentId); + }; + } + + return () => {}; }, }); @@ -1014,14 +1084,24 @@ PartSimulationRegistry.register('bmp280', { PartSimulationRegistry.register('ds3231', { attachEvents: (element, simulator, _getPin) => { const sim = simulator as any; - if (typeof sim.addI2CDevice !== 'function') return () => {}; - const el = element as any; - const dev = new VirtualDS3231(); - if (el.temperature !== undefined) dev.temperatureC = parseFloat(el.temperature); - sim.addI2CDevice(dev); - return () => removeI2CDevice(sim, dev.address); + if (typeof sim.addI2CDevice === 'function') { + // ── AVR / RP2040 path ────────────────────────────────────────────────── + const dev = new VirtualDS3231(); + if (el.temperature !== undefined) dev.temperatureC = parseFloat(el.temperature); + sim.addI2CDevice(dev); + return () => removeI2CDevice(sim, dev.address); + + } else if (typeof sim.registerSensor === 'function') { + // ── ESP32 path: delegate to backend QEMU DS3231 slave ───────────────── + const virtualPin = 200 + 0x68; + const initTemp = el.temperature !== undefined ? parseFloat(el.temperature) : 25.0; + sim.registerSensor('ds3231', virtualPin, { addr: 0x68, temperature: initTemp }); + return () => sim.unregisterSensor(virtualPin); + } + + return () => {}; }, }); @@ -1042,9 +1122,7 @@ PartSimulationRegistry.register('ds3231', { PartSimulationRegistry.register('pcf8574', { attachEvents: (element, simulator, _getPin) => { const sim = simulator as any; - if (typeof sim.addI2CDevice !== 'function') return () => {}; - - const el = element as any; + const el = element as any; // Parse address from element property (accepts '0x27', '39', or numeric) let addr = 0x27; @@ -1056,15 +1134,30 @@ PartSimulationRegistry.register('pcf8574', { if (!isNaN(parsed)) addr = parsed; } - const dev = new VirtualPCF8574(addr); + if (typeof sim.addI2CDevice === 'function') { + // ── AVR / RP2040 path ────────────────────────────────────────────────── + const dev = new VirtualPCF8574(addr); + if (el.portState !== undefined) dev.portState = Number(el.portState) & 0xFF; + dev.onWrite = (value: number) => { el.value = value; }; + sim.addI2CDevice(dev); + return () => removeI2CDevice(sim, dev.address); - // Seed port state from element if present - if (el.portState !== undefined) dev.portState = Number(el.portState) & 0xFF; + } else if (typeof sim.registerSensor === 'function') { + // ── ESP32 path: relay I2C writes via backend ─────────────────────────── + const virtualPin = 200 + addr; + const dev = new VirtualPCF8574(addr); + if (el.portState !== undefined) dev.portState = Number(el.portState) & 0xFF; + dev.onWrite = (value: number) => { el.value = value; }; + sim.registerSensor('pcf8574', virtualPin, { addr }); + sim.addI2CTransactionListener(addr, (data: number[]) => { + if (data.length > 0) dev.writeByte(data[0]); + }); + return () => { + sim.unregisterSensor(virtualPin); + sim.removeI2CTransactionListener(addr); + }; + } - // Feed writes back to the element so visual components can re-render - dev.onWrite = (value: number) => { el.value = value; }; - - sim.addI2CDevice(dev); - return () => removeI2CDevice(sim, dev.address); + return () => {}; }, }); diff --git a/frontend/src/store/useSimulatorStore.ts b/frontend/src/store/useSimulatorStore.ts index 06ceff36..bdbe8123 100644 --- a/frontend/src/store/useSimulatorStore.ts +++ b/frontend/src/store/useSimulatorStore.ts @@ -110,6 +110,23 @@ class Esp32BridgeShim { unregisterSensor(pin: number): void { this.bridge.sendSensorDetach(pin); } + + // ── I2C write-only device relay (SSD1306, PCF8574) ─────────────────────── + private _i2cTransactionListeners = new Map void>(); + + addI2CTransactionListener(addr: number, fn: (data: number[]) => void): void { + this._i2cTransactionListeners.set(addr, fn); + this.bridge.onI2cTransaction = (a: number, data: number[]) => { + this._i2cTransactionListeners.get(a)?.(data); + }; + } + + removeI2CTransactionListener(addr: number): void { + this._i2cTransactionListeners.delete(addr); + if (this._i2cTransactionListeners.size === 0) { + this.bridge.onI2cTransaction = null; + } + } } // ── Shared LEDC update handler (used by addBoard, setBoardType, initSimulator) ─