feat(esp32): server-side matrix keypad — columns driven synchronously in QEMU

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.
This commit is contained in:
David Montero Crespo 2026-08-19 20:44:00 +02:00
parent 13cbcf5ed3
commit 5606e7ebea
2 changed files with 158 additions and 4 deletions

View File

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

View File

@ -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<string, number> = {};
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<string>();
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;