#!/usr/bin/env python3 """ stm32_worker.py — Standalone STM32 QEMU subprocess worker. Loads libqemu-arm in its own process (same model as esp32_worker.py) and drives an STM32 machine from the lcgamboa fork through the PICSimLab bridge (hw/arm/stm32_picsimlab.c). Supports GPIO + UART TX + I2C + SPI. The I2C/SPI device models (sensors + displays) are reused verbatim from the ESP32 worker's slave modules (esp32_i2c_slaves.py, esp32_spi_slaves.py). Excluded (ESP32-specific): WiFi, RMT/WS2812, LEDC, GPIO matrix, camera, WASM custom chips, DHT22/HC-SR04 sync handlers. Pin numbering is LINEAR 0-based: global_pin = port_index*16 + pin. stdin line 1 : JSON config {"lib_path","firmware_b64","machine","sensors"} stdin line 2+: JSON commands (set_pin, uart_send, sensor_attach/update/detach, set_i2c_response, set_spi_response, stop) stdout : JSON event lines (system, gpio_change, gpio_dir, uart_tx, i2c_trace, spi_batch, spi_event, epaper_update, error) stderr : debug logs """ import base64 import ctypes import json import os import sys import tempfile import threading # ── I2C slaves (reused from the ESP32 worker; generic register machines) ────── try: from app.services.esp32_i2c_slaves import ( MPU6050Slave as _MPU6050Slave, BMP280Slave as _BMP280Slave, DS1307Slave as _DS1307Slave, DS3231Slave as _DS3231Slave, I2CWriteSink as _I2CWriteSink, ProxySlave as _ProxySlave, ) except ImportError: import importlib.util as _ilu, pathlib as _pl, sys as _sys _spec = _ilu.spec_from_file_location( 'esp32_i2c_slaves', _pl.Path(__file__).parent / 'esp32_i2c_slaves.py') _mod = _ilu.module_from_spec(_spec) # type: ignore[arg-type] _sys.modules['esp32_i2c_slaves'] = _mod _spec.loader.exec_module(_mod) # type: ignore[union-attr] _MPU6050Slave = _mod.MPU6050Slave; _BMP280Slave = _mod.BMP280Slave _DS1307Slave = _mod.DS1307Slave; _DS3231Slave = _mod.DS3231Slave _I2CWriteSink = _mod.I2CWriteSink; _ProxySlave = _mod.ProxySlave # ── SPI slaves (ePaper) ─────────────────────────────────────────────────────── try: from app.services.esp32_spi_slaves import ( Ssd168xEpaperSlave as _Ssd168xEpaperSlave, Uc8159cEpaperSlave as _Uc8159cEpaperSlave, ) except ImportError: import importlib.util as _ilu, pathlib as _pl, sys as _sys _spec = _ilu.spec_from_file_location( 'esp32_spi_slaves', _pl.Path(__file__).parent / 'esp32_spi_slaves.py') _mod = _ilu.module_from_spec(_spec) # type: ignore[arg-type] _sys.modules['esp32_spi_slaves'] = _mod _spec.loader.exec_module(_mod) # type: ignore[union-attr] _Ssd168xEpaperSlave = _mod.Ssd168xEpaperSlave _Uc8159cEpaperSlave = _mod.Uc8159cEpaperSlave _stdout_lock = threading.Lock() def _emit(obj: dict) -> None: with _stdout_lock: sys.stdout.write(json.dumps(obj) + '\n') sys.stdout.flush() def _log(msg: str) -> None: sys.stderr.write(f'[stm32_worker] {msg}\n') sys.stderr.flush() _WRITE_PIN = ctypes.CFUNCTYPE(None, ctypes.c_int, ctypes.c_int) _DIR_PIN = ctypes.CFUNCTYPE(None, ctypes.c_int, ctypes.c_int) _I2C_EVENT = ctypes.CFUNCTYPE(ctypes.c_int, ctypes.c_uint8, ctypes.c_uint8, ctypes.c_uint16) _SPI_EVENT = ctypes.CFUNCTYPE(ctypes.c_uint8, ctypes.c_uint8, ctypes.c_uint16) _UART_TX = ctypes.CFUNCTYPE(None, ctypes.c_uint8, ctypes.c_uint8) _RMT_EVENT = ctypes.CFUNCTYPE(None, ctypes.c_uint8, ctypes.c_uint32, ctypes.c_uint32) _GPIO_MATRIX_CB = ctypes.CFUNCTYPE(None, ctypes.c_int, ctypes.c_int) _PULL_PIN = ctypes.CFUNCTYPE(None, ctypes.c_int, ctypes.c_int) class _CallbacksT(ctypes.Structure): # MUST match, field-for-field, the callbacks_t struct in # hw/arm/stm32_picsimlab.c. picsimlab_pull_pin is appended last (matching # the C append) so the ESP32-compatible prefix stays byte-identical. _fields_ = [ ('picsimlab_write_pin', _WRITE_PIN), ('picsimlab_dir_pin', _DIR_PIN), ('picsimlab_i2c_event', _I2C_EVENT), ('picsimlab_spi_event', _SPI_EVENT), ('picsimlab_uart_tx_event', _UART_TX), ('pinmap', ctypes.c_void_p), ('picsimlab_rmt_event', _RMT_EVENT), ('picsimlab_gpio_matrix_cb', _GPIO_MATRIX_CB), ('picsimlab_pull_pin', _PULL_PIN), ] # I2C op decode (matches QEMU enum i2c_event + picsimlab_i2c.c encoding). _I2C_OP = {0x00: 'START_RECV', 0x01: 'START_SEND', 0x03: 'FINISH', 0x04: 'NACK', 0x05: 'WRITE', 0x06: 'READ'} def main() -> None: raw_cfg = sys.stdin.readline() if not raw_cfg.strip(): os._exit(1) cfg = json.loads(raw_cfg) lib_path = cfg['lib_path'] firmware_b64 = cfg['firmware_b64'] machine = cfg.get('machine', 'stm32vldiscovery') initial_sensors = cfg.get('sensors', []) _MINGW64_BIN = r'C:\msys64\mingw64\bin' if os.name == 'nt' and os.path.isdir(_MINGW64_BIN): os.add_dll_directory(_MINGW64_BIN) try: lib = ctypes.CDLL(lib_path) except Exception as exc: _emit({'type': 'error', 'message': f'Cannot load library: {exc}'}) os._exit(1) lib.qemu_picsimlab_set_pin.restype = None lib.qemu_picsimlab_set_pin.argtypes = [ctypes.c_int, ctypes.c_int] try: _shutdown_request = lib.qemu_system_shutdown_request _shutdown_request.restype = None _shutdown_request.argtypes = [ctypes.c_int] except AttributeError: _shutdown_request = None try: fw_bytes = base64.b64decode(firmware_b64) tmp = tempfile.NamedTemporaryFile(suffix='.elf', delete=False) tmp.write(fw_bytes) tmp.close() firmware_path = tmp.name except Exception as exc: _emit({'type': 'error', 'message': f'Firmware decode error: {exc}'}) os._exit(1) args_list = [b'qemu', b'-M', machine.encode(), b'-nographic', b'-kernel', firmware_path.encode()] argc = len(args_list) argv = (ctypes.c_char_p * argc)(*args_list) _stopped = threading.Event() _init_done = threading.Event() # ── Shared peripheral state ─────────────────────────────────────────────── _i2c_slaves: dict = {} # 7-bit addr → slave instance _i2c_responses: dict = {} # addr → response byte (no-slave fallback) _spi_response = [0xFF] _spi_byte_buf = bytearray() _spi_buf_lock = threading.Lock() _SPI_BATCH_FLUSH_AT = 4096 _SPI_BATCH_PERIOD_S = 0.05 _epaper_slaves: dict = {} _epaper_state: dict = {} _pin_state: dict = {} _sensors: dict = {} _sensors_lock = threading.Lock() def _register_sensor(s: dict) -> None: """Instantiate the right I2C/SPI slave for a sensor descriptor.""" stype = s.get('sensor_type', '') if stype == 'mpu6050': addr = int(s.get('addr', 0x68)); _i2c_slaves[addr] = _MPU6050Slave(addr) elif stype == 'bmp280': addr = int(s.get('addr', 0x76)); sl = _BMP280Slave(addr) if 'temperature' in s: sl.update(float(s['temperature']), sl._press_hpa) if 'pressure' in s: sl.update(sl._temp_c, float(s['pressure'])) _i2c_slaves[addr] = sl elif stype in ('ds1307', 'ds3231'): addr = int(s.get('addr', 0x68)) _i2c_slaves[addr] = _DS3231Slave() if stype == 'ds3231' else _DS1307Slave() elif stype in ('ssd1306', 'pcf8574'): addr = int(s.get('addr', 0x3C if stype == 'ssd1306' else 0x27)) _i2c_slaves[addr] = _I2CWriteSink(addr, _emit) elif stype == 'epaper-ssd168x': comp = str(s.get('component_id', 'epaper')) w = int(s.get('width', 200)); h = int(s.get('height', 200)) refresh = int(s.get('refresh_ms', 50)) fam = str(s.get('controller_family', 'ssd168x')) def _on_flush(frame, _c=comp, _w=w, _h=h, _r=refresh): try: b64 = base64.b64encode(frame.pixels).decode('ascii') except Exception: return _emit({'type': 'epaper_update', 'data': { 'component_id': _c, 'width': _w, 'height': _h, 'frame_b64': b64, 'refresh_ms': _r}}) slave = (_Uc8159cEpaperSlave if fam == 'uc8159c' else _Ssd168xEpaperSlave)( component_id=comp, width=w, height=h, on_flush=_on_flush) _epaper_slaves[comp] = slave _epaper_state[comp] = { 'slave': slave, 'dc_pin': int(s.get('dc_pin', -1)), 'cs_pin': int(s.get('cs_pin', -1)), 'rst_pin': int(s.get('rst_pin', -1)), 'cs_low': False, 'dc_high': False} # ── Callbacks (QEMU thread) ─────────────────────────────────────────────── def _on_pin_change(pin, value): if _stopped.is_set(): return pin = int(pin); value &= 1 _pin_state[pin] = value _emit({'type': 'gpio_change', 'pin': pin, 'state': value}) # ePaper DC/CS/RST tracking (CS active-low, RST clears on low). for st in _epaper_state.values(): if pin == st['dc_pin']: st['dc_high'] = bool(value) elif pin == st['cs_pin']: st['cs_low'] = (value == 0) elif pin == st['rst_pin'] and value == 0: st['slave'].reset() def _on_dir_change(pin, direction): if _stopped.is_set() or pin < 0: return _emit({'type': 'gpio_dir', 'pin': int(pin), 'dir': int(direction)}) def _on_pull_change(pin, pull): # Internal pull of an INPUT pin changed: 0=none, 1=up, 2=down. The # frontend stamps the matching weak resistor in its circuit netlist so # digitalRead() of a button-to-GND on an INPUT_PULLUP pin reads idle # HIGH / pressed LOW. QEMU already de-dupes (only fires on a real # change), so forward verbatim. `pin` is the linear port*16+pin index. if _stopped.is_set() or pin < 0: return _emit({'type': 'gpio_pull', 'pin': int(pin), 'pull': int(pull)}) def _on_uart_tx(uart_id, byte_val): if _stopped.is_set(): return _emit({'type': 'uart_tx', 'uart': int(uart_id), 'byte': int(byte_val)}) def _on_i2c_event(bus_id, addr, event): """Synchronous — dispatch to the slave at `addr`; return read byte.""" slave = _i2c_slaves.get(addr) op = event & 0xFF if slave is not None: result = slave.handle_event(event) if not _stopped.is_set() and type(slave).__name__ != 'I2CWriteSink': _emit({'type': 'i2c_trace', 'bus': int(bus_id), 'addr': int(addr), 'op': _I2C_OP.get(op, hex(op)), 'result': result}) return result resp = _i2c_responses.get(addr, 0) # NACK an unclaimed address on START so probes fail cleanly. if op in (0x00, 0x01) and resp == 0 and addr not in _i2c_responses: return 1 return resp def _flush_spi_batch_locked(): if _spi_byte_buf and not _stopped.is_set(): b64 = base64.b64encode(bytes(_spi_byte_buf)).decode('ascii') _emit({'type': 'spi_batch', 'b64': b64}) _spi_byte_buf.clear() def _on_spi_event(bus_id, event): op = event & 0xFF mosi = (event >> 8) & 0xFF # ePaper panels: feed every byte to the active (CS low) slave. if _epaper_state and op == 0x00: for st in _epaper_state.values(): if st['cs_low']: try: st['slave'].feed(mosi, st['dc_high']) except Exception as e: _log(f'[epaper] {e!r}') return 0xFF resp = _spi_response[0] if _stopped.is_set(): return resp if op == 0x00: with _spi_buf_lock: _spi_byte_buf.append(mosi) if len(_spi_byte_buf) >= _SPI_BATCH_FLUSH_AT: _flush_spi_batch_locked() else: with _spi_buf_lock: _flush_spi_batch_locked() _emit({'type': 'spi_event', 'bus': int(bus_id), 'event': int(event)}) return resp def _on_rmt_event(channel, config0, value): return def _on_gpio_matrix(gpio, signal_id): return _PINMAP = (ctypes.c_int16 * 1)(0) _cbs_ref = _CallbacksT( picsimlab_write_pin = _WRITE_PIN(_on_pin_change), picsimlab_dir_pin = _DIR_PIN(_on_dir_change), picsimlab_i2c_event = _I2C_EVENT(_on_i2c_event), picsimlab_spi_event = _SPI_EVENT(_on_spi_event), picsimlab_uart_tx_event = _UART_TX(_on_uart_tx), pinmap = ctypes.cast(_PINMAP, ctypes.c_void_p).value, picsimlab_rmt_event = _RMT_EVENT(_on_rmt_event), picsimlab_gpio_matrix_cb = _GPIO_MATRIX_CB(_on_gpio_matrix), picsimlab_pull_pin = _PULL_PIN(_on_pull_change), ) lib.qemu_picsimlab_register_callbacks(ctypes.byref(_cbs_ref)) # Pre-register sensors from the start config. Keep a record keyed by pin so # later sensor_update commands (from the live SensorControlPanel) can find # the device and merge new values in. for s in initial_sensors: try: _register_sensor(s) _sensors[int(s.get('pin', 0))] = dict(s) except Exception as e: _log(f'sensor init failed: {e!r}') _log(f'i2c slaves: {list(_i2c_slaves.keys())}') def _qemu_thread(): try: lib.qemu_init(argc, argv, None) except Exception as exc: _emit({'type': 'error', 'message': f'qemu_init failed: {exc}'}) finally: _init_done.set() lib.qemu_main_loop() import io as _io _orig_stdin_fd = os.dup(0) _nul = os.open(os.devnull, os.O_RDONLY); os.dup2(_nul, 0); os.close(_nul) _orig_stdout_fd = os.dup(1) _nul_w = os.open(os.devnull, os.O_WRONLY); os.dup2(_nul_w, 1); os.close(_nul_w) sys.stdout = _io.TextIOWrapper( _io.FileIO(_orig_stdout_fd, mode='w', closefd=True), line_buffering=True, write_through=True) qemu_t = threading.Thread(target=_qemu_thread, daemon=True, name=f'qemu-{machine}') qemu_t.start() if not _init_done.wait(timeout=30.0): _emit({'type': 'error', 'message': 'qemu_init timed out after 30 s'}) os._exit(1) # SPI batch flush thread. def _spi_flush_loop(): while not _stopped.is_set(): _stopped.wait(_SPI_BATCH_PERIOD_S) if _stopped.is_set(): break with _spi_buf_lock: _flush_spi_batch_locked() threading.Thread(target=_spi_flush_loop, daemon=True, name='stm32-spi-flush').start() _emit({'type': 'system', 'event': 'booted'}) _log(f'QEMU started: machine={machine}') for raw_line in os.fdopen(_orig_stdin_fd, 'r'): raw_line = raw_line.strip() if not raw_line: continue try: cmd = json.loads(raw_line) except Exception: continue c = cmd.get('cmd', '') if c == 'set_pin': lib.qemu_picsimlab_set_pin(int(cmd['pin']), int(cmd['value'])) elif c == 'uart_send': if hasattr(lib, 'qemu_picsimlab_uart_receive'): data = base64.b64decode(cmd.get('data', '')) if data: buf = (ctypes.c_uint8 * len(data))(*data) lib.qemu_picsimlab_uart_receive(int(cmd.get('uart', 0)), buf, len(data)) else: _log('uart_send ignored: qemu_picsimlab_uart_receive not in libqemu-arm') elif c == 'set_i2c_response': _i2c_responses[int(cmd['addr'])] = int(cmd['response']) & 0xFF elif c == 'set_spi_response': _spi_response[0] = int(cmd['response']) & 0xFF elif c == 'sensor_attach': with _sensors_lock: try: _register_sensor(cmd) _sensors[int(cmd.get('pin', 0))] = dict(cmd) except Exception as e: _log(f'sensor_attach failed: {e!r}') _log(f'sensor attached: {cmd.get("sensor_type")} i2c={list(_i2c_slaves.keys())}') elif c == 'sensor_update': # The SensorControlPanel sends {pin, }. # Look up the device by pin, merge the new values into its stored # record (panels change a single slider at a time), then push the # full state to the slave. Derive the I2C address from the record or # from the virtual-pin convention (pin = 200 + addr). with _sensors_lock: pin = int(cmd.get('pin', -1)) rec = _sensors.get(pin) if rec is None and pin >= 200: rec = {'addr': pin - 200} _sensors[pin] = rec if rec is not None: for k, v in cmd.items(): if k not in ('cmd', 'pin'): rec[k] = v addr = int(rec.get('addr', pin - 200 if pin >= 200 else -1)) stype = rec.get('sensor_type', '') slave = _i2c_slaves.get(addr) try: if stype == 'bmp280' and slave is not None: slave.update(float(rec.get('temperature', 25.0)), float(rec.get('pressure', 1013.25))) elif stype == 'mpu6050' and slave is not None and hasattr(slave, 'update'): slave.update(accel_x=float(rec.get('accelX', 0)), accel_y=float(rec.get('accelY', 0)), accel_z=float(rec.get('accelZ', 1)), gyro_x=float(rec.get('gyroX', 0)), gyro_y=float(rec.get('gyroY', 0)), gyro_z=float(rec.get('gyroZ', 0)), temp=float(rec.get('temp', 25.0))) elif stype in ('ds3231',) and slave is not None and hasattr(slave, 'update'): slave.update(float(rec.get('temperature', 25.0))) except Exception as e: _log(f'sensor_update failed: {e!r}') elif c == 'sensor_detach': with _sensors_lock: addr = int(cmd.get('addr', -1)) _i2c_slaves.pop(addr, None) elif c == 'stop': _stopped.set() if _shutdown_request: try: _shutdown_request(3) except Exception: pass qemu_t.join(timeout=5.0) if firmware_path: try: os.unlink(firmware_path) except OSError: pass os._exit(0) if __name__ == '__main__': main()