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:
parent
13cbcf5ed3
commit
5606e7ebea
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in New Issue