From 5606e7ebea900fbe0f758c3707d229932564c433 Mon Sep 17 00:00:00 2001 From: David Montero Crespo Date: Wed, 19 Aug 2026 20:44:00 +0200 Subject: [PATCH] =?UTF-8?q?feat(esp32):=20server-side=20matrix=20keypad=20?= =?UTF-8?q?=E2=80=94=20columns=20driven=20synchronously=20in=20QEMU?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A 4x4 membrane keypad scan is us-timing-critical: firmware drives one row LOW and reads the columns back within the same loop pass, far faster than the browser<->QEMU websocket round trip, so the browser-side matrix simulation could never register a key on ESP32 boards. Worker: new 'matrix-keypad' sensor type (rows/cols GPIO lists, pressed key set). _on_dir_change/_on_pin_change recompute the column levels synchronously on the QEMU thread — the pinMode(OUTPUT) transition is the trigger on steady-state passes, since the row's output latch stays LOW and the digitalWrite fires no change callback. Async gpio_in writes to keypad-owned columns are ignored so the browser's generic matrix logic can't clobber them. Frontend: SimulatorCanvas attaches the keypad as a matrix-keypad sensor when every R/C pin resolves to an ESP32-family board GPIO, and streams the pressed-key set from the element's button-press/release events. --- backend/app/services/esp32_worker.py | 100 +++++++++++++++++- .../components/simulator/SimulatorCanvas.tsx | 62 +++++++++++ 2 files changed, 158 insertions(+), 4 deletions(-) diff --git a/backend/app/services/esp32_worker.py b/backend/app/services/esp32_worker.py index 67ede316..18412f46 100644 --- a/backend/app/services/esp32_worker.py +++ b/backend/app/services/esp32_worker.py @@ -634,6 +634,61 @@ def main() -> None: # noqa: C901 (complexity OK for inline worker) _sensors: dict[int, dict] = {} _sensors_lock = threading.Lock() + # ── Matrix keypad (server-side row/column emulation) ───────────────────── + # A 4×4 membrane keypad scan is µs-timing-critical: firmware drives one + # row LOW and reads the columns back within the same loop pass — far + # faster than the browser↔QEMU round trip, so the matrix must be + # emulated HERE. The frontend attaches a 'matrix-keypad' sensor with the + # row/col GPIO lists and streams the pressed-key set; _on_dir_change / + # _on_pin_change recompute the column levels synchronously (both run on + # the QEMU thread, so qemu_picsimlab_set_pin lands before the firmware's + # digitalRead() that follows its digitalWrite(row, LOW)). + _keypad_by_row: dict[int, dict] = {} # row gpio → keypad state dict + _keypad_cols_owned: set[int] = set() # col gpios driven by the worker + + def _keypad_install(gpio: int, sensor_data: dict) -> None: + rows = [int(r) for r in sensor_data.get('rows', [])] + cols = [int(cg) for cg in sensor_data.get('cols', [])] + kp = { + 'rows': rows, 'cols': cols, + 'pressed': set(), # {(row_idx, col_idx)} + 'row_out': [False] * len(rows), # row currently OUTPUT + 'col_level': [1] * len(cols), # last level applied per col + } + sensor_data['keypad'] = kp + for r in rows: + _keypad_by_row[r] = kp + for cg in cols: + _keypad_cols_owned.add(cg) + # Idle columns HIGH — firmware scans them as INPUT_PULLUP. + for cg in cols: + lib.qemu_picsimlab_set_pin(cg + 1, 1) + _log(f'matrix-keypad installed rows={rows} cols={cols} (anchor gpio={gpio})') + + def _keypad_uninstall(sensor_data: dict) -> None: + kp = sensor_data.get('keypad') + if not kp: + return + for r in kp['rows']: + _keypad_by_row.pop(r, None) + for cg in kp['cols']: + _keypad_cols_owned.discard(cg) + lib.qemu_picsimlab_set_pin(cg + 1, 1) + + def _keypad_recompute(kp: dict) -> None: + rows, cols, pressed = kp['rows'], kp['cols'], kp['pressed'] + for j, cg in enumerate(cols): + low = any( + kp['row_out'][i] + and _pin_state.get(rows[i], 1) == 0 + and (i, j) in pressed + for i in range(len(rows)) + ) + lvl = 0 if low else 1 + if kp['col_level'][j] != lvl: + kp['col_level'][j] = lvl + lib.qemu_picsimlab_set_pin(cg + 1, lvl) + # ── Generic sync-handler registry ──────────────────────────────────────── # Each entry implements step() -> bool. step() is called once per # GPIO_IN read sync (every digitalRead() / pulseIn() iteration in firmware). @@ -832,6 +887,12 @@ def main() -> None: # noqa: C901 (complexity OK for inline worker) return gpio = int(_PINMAP[slot]) if 1 <= slot <= _GPIO_COUNT else slot _pin_state[gpio] = value & 1 + # Matrix keypad: a row level changed — refresh column inputs NOW, + # synchronously on the QEMU thread, so the digitalRead()s that follow + # the firmware's digitalWrite(row, LOW) see the pressed key. + _kp = _keypad_by_row.get(gpio) + if _kp is not None: + _keypad_recompute(_kp) # Flush pending SPI bytes BEFORE announcing this pin change so the # frontend processes them under the pin state (e.g. the ILI9341 DC line) # that was in effect when they were sent. With CS events gated off for @@ -947,6 +1008,19 @@ def main() -> None: # noqa: C901 (complexity OK for inline worker) # ── DHT22: track direction changes + trigger sync response ─────── if slot >= 1: gpio = int(_PINMAP[slot]) if slot <= _GPIO_COUNT else slot + # Matrix keypad: rows toggle OUTPUT(scan)/INPUT(idle) every pass. + # Track the direction and refresh the columns synchronously — + # on later passes the row's output latch is already LOW, so the + # pinMode(OUTPUT) transition is the only signal that the row + # became active (the digitalWrite(LOW) that follows is a + # no-change write and fires no pin callback). + _kp = _keypad_by_row.get(gpio) + if _kp is not None: + try: + _kp['row_out'][_kp['rows'].index(gpio)] = direction == 1 + except ValueError: + pass + _keypad_recompute(_kp) with _sensors_lock: sensor = _sensors.get(gpio) if sensor is not None and sensor.get('type') == 'dht22': @@ -1414,9 +1488,11 @@ def main() -> None: # noqa: C901 (complexity OK for inline worker) 'saw_low': False, 'responding': False, } + if sensor_type == 'matrix-keypad': + _keypad_install(gpio, sensor_data) # For I2C sensors, also create the slave state machine immediately # so _on_i2c_event can find it when the firmware's Wire.begin() runs. - if sensor_type == 'mpu6050': + elif sensor_type == 'mpu6050': i2c_addr = int(s.get('addr', 0x68)) slave = _MPU6050Slave(i2c_addr) _i2c_slaves[i2c_addr] = slave @@ -1773,7 +1849,11 @@ def main() -> None: # noqa: C901 (complexity OK for inline worker) if c == 'set_pin': # Identity pinmap: slot = gpio_num + 1 - lib.qemu_picsimlab_set_pin(int(cmd['pin']) + 1, int(cmd['value'])) + # Keypad-owned columns are driven synchronously by + # _keypad_recompute; a stale async gpio_in from the browser's + # generic matrix simulation must not clobber them. + if int(cmd['pin']) not in _keypad_cols_owned: + lib.qemu_picsimlab_set_pin(int(cmd['pin']) + 1, int(cmd['value'])) elif c == 'set_adc': raw_v = int(int(cmd['millivolts']) * 4095 / 3300) @@ -1858,7 +1938,9 @@ def main() -> None: # noqa: C901 (complexity OK for inline worker) 'saw_low': False, 'responding': False, } - if sensor_type == 'mpu6050': + if sensor_type == 'matrix-keypad': + _keypad_install(gpio, sensor_data) + elif sensor_type == 'mpu6050': i2c_addr = int(cmd.get('addr', 0x68)) slave = _MPU6050Slave(i2c_addr) _i2c_slaves[i2c_addr] = slave @@ -1974,7 +2056,15 @@ def main() -> None: # noqa: C901 (complexity OK for inline worker) sensor[k] = v stype = sensor.get('type') slave = sensor.get('slave') - if stype == 'mpu6050' and slave is not None: + if stype == 'matrix-keypad': + kp = sensor.get('keypad') + if kp is not None and 'pressed' in cmd: + kp['pressed'] = { + (int(rc[0]), int(rc[1])) + for rc in (cmd.get('pressed') or []) + } + _keypad_recompute(kp) + elif stype == 'mpu6050' and slave is not None: slave.update( accel_x=float(sensor.get('accelX', 0)), accel_y=float(sensor.get('accelY', 0)), @@ -1996,6 +2086,8 @@ def main() -> None: # noqa: C901 (complexity OK for inline worker) gpio = int(cmd['pin']) with _sensors_lock: sensor = _sensors.pop(gpio, None) + if sensor and sensor.get('type') == 'matrix-keypad': + _keypad_uninstall(sensor) if sensor and 'i2c_addr' in sensor: _i2c_slaves.pop(sensor['i2c_addr'], None) if sensor and 'epaper_component_id' in sensor: diff --git a/frontend/src/components/simulator/SimulatorCanvas.tsx b/frontend/src/components/simulator/SimulatorCanvas.tsx index a9828b58..28622fde 100644 --- a/frontend/src/components/simulator/SimulatorCanvas.tsx +++ b/frontend/src/components/simulator/SimulatorCanvas.tsx @@ -1393,6 +1393,68 @@ export const SimulatorCanvas = ({ headerSlot }: SimulatorCanvasProps = {}) => { (w) => w.start.componentId === component.id || w.end.componentId === component.id, ); + // 4×4 membrane keypad on an ESP32-family board: the row/column matrix + // scan is µs-timing-critical — firmware drives one row LOW and reads + // the columns back within the same loop pass, far faster than the + // browser↔QEMU round trip — so the matrix is emulated server-side. + // Attach it as a 'matrix-keypad' sensor (rows/cols GPIO lists) and + // stream only the pressed-key set; the QEMU worker drives the column + // pins synchronously from its GPIO callbacks. + if (component.metadataId === 'membrane-keypad') { + const pinGpio: Record = {}; + let keypadBoardId: string | null = null; + connectedWires.forEach((wire) => { + const isStartSelf = wire.start.componentId === component.id; + const selfEndpoint = isStartSelf ? wire.start : wire.end; + const otherEndpoint = isStartSelf ? wire.end : wire.start; + if (!isBoardComponent(otherEndpoint.componentId)) return; + const boardInstance = boards.find((b) => b.id === otherEndpoint.componentId); + const lookupKey = boardInstance ? boardInstance.boardKind : otherEndpoint.componentId; + const gpio = boardPinToNumber(lookupKey, otherEndpoint.pinName); + if (gpio !== null && gpio >= 0) { + pinGpio[selfEndpoint.pinName] = gpio; + keypadBoardId = otherEndpoint.componentId; + } + }); + const kpRows = ['R1', 'R2', 'R3', 'R4'].map((p) => pinGpio[p] ?? -1); + const kpCols = ['C1', 'C2', 'C3', 'C4'].map((p) => pinGpio[p] ?? -1); + const kpBridge = keypadBoardId ? getEsp32Bridge(keypadBoardId) : null; + if (kpBridge && kpRows.every((g) => g >= 0) && kpCols.every((g) => g >= 0)) { + const anchorPin = kpRows[0]; + kpBridge.sendSensorAttach('matrix-keypad', anchorPin, { + rows: kpRows, + cols: kpCols, + }); + const pressed = new Set(); + const sendPressed = () => + kpBridge.sendSensorUpdate(anchorPin, { + pressed: [...pressed].map((s) => s.split(',').map(Number)), + }); + const timeout = setTimeout(() => { + const el = document.getElementById(component.id); + if (!el) return; + const onPress = (e: Event) => { + const { row, column } = (e as CustomEvent).detail; + pressed.add(`${row},${column}`); + sendPressed(); + }; + const onRelease = (e: Event) => { + const { row, column } = (e as CustomEvent).detail; + pressed.delete(`${row},${column}`); + sendPressed(); + }; + el.addEventListener('button-press', onPress); + el.addEventListener('button-release', onRelease); + cleanups.push(() => { + el.removeEventListener('button-press', onPress); + el.removeEventListener('button-release', onRelease); + }); + }, 300); + cleanups.push(() => clearTimeout(timeout)); + cleanups.push(() => kpBridge.sendSensorDetach(anchorPin)); + } + } + connectedWires.forEach((wire) => { const isStartSelf = wire.start.componentId === component.id; const selfEndpoint = isStartSelf ? wire.start : wire.end;