fix(pi): perifericos completos en la familia Pi — entradas, buses y pines

Auditoria de "la Pi tiene todo lo de la placa real" con cuatro huecos
encontrados y cerrados:

1) GPIO de entrada en modo Linux: GPIO_IN respondia VAL 0 fijo (stub de
   la fase 2), asi que GPIO.input() leia 0 eternamente aunque el canvas
   empujara el nivel. El backend guarda ahora el ultimo nivel por pin
   (set_pin_state lo escribe) y GPIO_IN contesta de ahi. Los flancos
   (SET) siguen llegando al guest como antes.

2) UART del header hacia otra placa: el shim del rootfs ya hablaba
   `UART <port> TX <hex>` / RX_REQ, pero sin modelo de esclavo el
   backend tragaba los bytes. Ahora TX sin esclavo se emite al canvas
   (uart_tx) y RX_REQ sin esclavo drena la cola que llena pi_uart_rx —
   el mismo protocolo de siempre, sin ops nuevas.

3) El escaner de esclavos I2C/SPI/UART estaba doblemente muerto:
   clasificaba por numero fisico de pin ('3','5','19'...) cuando el
   elemento expone GPIOxx, y su unico llamador era RaspberryPiWorkspace,
   que el terminal unificado reemplazo. Acepta ambos nombres y corre en
   onBooted del store.

4) boardPinToNumber solo mapeaba los pines de la 3/4/5; la Zero, 1B+ y
   2B (mismo header de 40 pines, mismo elemento) se quedaban sin mapa.
This commit is contained in:
David Montero Crespo 2026-07-29 17:05:39 +02:00
parent 508d2e141e
commit 93388c675a
4 changed files with 69 additions and 39 deletions

View File

@ -296,6 +296,9 @@ class PiInstance:
# (TX->RX wire). The guest drains them with the UARTRX op; nothing # (TX->RX wire). The guest drains them with the UARTRX op; nothing
# here interprets them, they are a pipe between two boards. # here interprets them, they are a pipe between two boards.
self.uart_rx = bytearray() self.uart_rx = bytearray()
# Last externally-driven level per BCM pin (canvas buttons, PIR,
# a wired board's output). GPIO_IN answers from here.
self.pin_levels: dict[int, int] = {}
# Raw `start_pi` payload — carries whatever the client declared for # Raw `start_pi` payload — carries whatever the client declared for
# this session (e.g. the packages an overlay must materialise). # this session (e.g. the packages an overlay must materialise).
self.start_payload: dict = {} self.start_payload: dict = {}
@ -343,7 +346,13 @@ class QemuManager:
def set_pin_state(self, client_id: str, pin: str | int, state: int) -> None: def set_pin_state(self, client_id: str, pin: str | int, state: int) -> None:
"""Drive a GPIO pin from outside (e.g. connected Arduino).""" """Drive a GPIO pin from outside (e.g. connected Arduino)."""
inst = self._instances.get(client_id) inst = self._instances.get(client_id)
if inst and inst._gpio_writer: if not inst:
return
# Remember the level: GPIO_IN polls answer from this map. Without
# it a button on the canvas fired edge callbacks in the guest but
# GPIO.input() read an eternal 0 (the old stub).
inst.pin_levels[int(pin)] = 1 if state else 0
if inst._gpio_writer:
asyncio.create_task(self._send_gpio(inst, int(pin), bool(state))) asyncio.create_task(self._send_gpio(inst, int(pin), bool(state)))
def push_uart_rx(self, client_id: str, data: bytes) -> None: def push_uart_rx(self, client_id: str, data: bytes) -> None:
@ -806,24 +815,6 @@ class QemuManager:
await self._reply_gpio(inst, f'SENS {parts[1]} {value:g}') await self._reply_gpio(inst, f'SENS {parts[1]} {value:g}')
return return
if op == 'UARTTX' and len(parts) == 2:
# Guest wrote to its header UART: hand the bytes to the canvas,
# which routes them down the wire to whatever board is on the
# other end. Opaque base64, exactly like DISP.
await inst.emit('uart_tx', {'data': parts[1]})
return
if op == 'UARTRX':
# Guest polls for bytes received on its header UART.
pending = bytes(inst.uart_rx)
inst.uart_rx.clear()
await self._reply_gpio(
inst,
'UART_RXQ ' + (base64.b64encode(pending).decode('ascii')
if pending else ''),
)
return
if op == 'DISP' and len(parts) == 2: if op == 'DISP' and len(parts) == 2:
# Guest display command (opaque base64 payload). Forwarded # Guest display command (opaque base64 payload). Forwarded
# verbatim to the frontend, which renders it on the board # verbatim to the frontend, which renders it on the board
@ -832,14 +823,13 @@ class QemuManager:
return return
if op == 'GPIO_IN' and len(parts) == 2: if op == 'GPIO_IN' and len(parts) == 2:
# Reply with the last known state of the pin. For Phase 2 # Reply with the last externally-driven level of the pin
# we just echo 0 — the canvas-side input wiring fans in # (canvas buttons / PIR / a wired board's output, delivered
# through SET commands which the shim caches on the guest. # through set_pin_state). Unknown pins read 0.
# When canvas-driven inputs land in Phase 2.5 this will
# query the gpio event bus' last-state map.
try: try:
pin = int(parts[1]) pin = int(parts[1])
await self._reply_gpio(inst, f'VAL {pin} 0') await self._reply_gpio(
inst, f'VAL {pin} {inst.pin_levels.get(pin, 0)}')
except ValueError: except ValueError:
pass pass
return return
@ -897,8 +887,28 @@ class QemuManager:
await self._reply_gpio( await self._reply_gpio(
inst, f'SPI_DATA {parts[1]} {parts[2]} {"00" * length}') inst, f'SPI_DATA {parts[1]} {parts[2]} {"00" * length}')
return return
# No slave model on this UART: the port is wired to another
# BOARD on the canvas. TX goes out to it and RX comes back
# from the queue the frontend fills — the guest shim already
# speaks this, it just used to talk into the void.
if op == 'UART' and len(parts) >= 4 and parts[2] == 'TX':
try:
payload = bytes.fromhex(parts[3])
except ValueError:
return
await inst.emit('uart_tx', {
'port': parts[1],
'data': base64.b64encode(payload).decode('ascii'),
})
return
if op == 'UART' and len(parts) >= 3 and parts[2] == 'RX_REQ': if op == 'UART' and len(parts) >= 3 and parts[2] == 'RX_REQ':
await self._reply_gpio(inst, f'UART_RX {parts[1]}') pending = bytes(inst.uart_rx)
inst.uart_rx.clear()
await self._reply_gpio(
inst,
f'UART_RX {parts[1]} {pending.hex()}' if pending
else f'UART_RX {parts[1]}',
)
return return
# Unknown — log at debug level (not a hot path) # Unknown — log at debug level (not a hot path)

View File

@ -30,11 +30,16 @@ type CanvasComponent = {
// - SDA1/SCL1 → I2C bus 1 // - SDA1/SCL1 → I2C bus 1
// - MOSI/MISO/SCLK → SPI bus 0 (CE0/CE1 distinguish slaves) // - MOSI/MISO/SCLK → SPI bus 0 (CE0/CE1 distinguish slaves)
// - TXD/RXD → primary UART (port 0) // - TXD/RXD → primary UART (port 0)
const I2C_PINS = new Set(['3', '5']); // Both namings are accepted: the wire may carry the PHYSICAL pin number
const SPI_DATA_PINS = new Set(['19', '21', '23']); // ('3') or the BCM label the board art actually exposes ('GPIO2'). The
const SPI_CE0_PIN = '24'; // scanner shipped matching only physical numbers while every element
const SPI_CE1_PIN = '26'; // names its pads GPIOxx — so no wire ever classified and the whole
const UART_PINS = new Set(['8', '10']); // slave-attach path was dead.
const I2C_PINS = new Set(['3', '5', 'GPIO2', 'GPIO3', 'SDA', 'SCL', 'SDA1', 'SCL1']);
const SPI_DATA_PINS = new Set(['19', '21', '23', 'GPIO10', 'GPIO9', 'GPIO11', 'MOSI', 'MISO', 'SCLK', 'SCK']);
const SPI_CE0_PINS = new Set(['24', 'GPIO8', 'CE0']);
const SPI_CE1_PINS = new Set(['26', 'GPIO7', 'CE1']);
const UART_PINS = new Set(['8', '10', 'GPIO14', 'GPIO15', 'TXD', 'RXD', 'TX', 'RX']);
// Map wokwi component metadata IDs / element types → backend model_id. // Map wokwi component metadata IDs / element types → backend model_id.
// Lowercase. Components not in this table get skipped silently. // Lowercase. Components not in this table get skipped silently.
@ -83,7 +88,7 @@ function classifyPiPin(pinName: string): {
bus_num: number; bus_num: number;
} | null { } | null {
if (I2C_PINS.has(pinName)) return { bus_kind: 'i2c', bus_num: 1 }; if (I2C_PINS.has(pinName)) return { bus_kind: 'i2c', bus_num: 1 };
if (SPI_DATA_PINS.has(pinName) || pinName === SPI_CE0_PIN || pinName === SPI_CE1_PIN) if (SPI_DATA_PINS.has(pinName) || SPI_CE0_PINS.has(pinName) || SPI_CE1_PINS.has(pinName))
return { bus_kind: 'spi', bus_num: 0 }; return { bus_kind: 'spi', bus_num: 0 };
if (UART_PINS.has(pinName)) return { bus_kind: 'uart', bus_num: 0 }; if (UART_PINS.has(pinName)) return { bus_kind: 'uart', bus_num: 0 };
return null; return null;
@ -142,8 +147,8 @@ export function attachSlavesFromCanvas(
// wires as informational only — the CE wire is the one that // wires as informational only — the CE wire is the one that
// pins down which slave gets attached. // pins down which slave gets attached.
let cs: number; let cs: number;
if (piEndpoint.pinName === SPI_CE0_PIN) cs = 0; if (SPI_CE0_PINS.has(piEndpoint.pinName)) cs = 0;
else if (piEndpoint.pinName === SPI_CE1_PIN) cs = 1; else if (SPI_CE1_PINS.has(piEndpoint.pinName)) cs = 1;
else continue; else continue;
spec = { spec = {
bus_kind: 'spi', bus_kind: 'spi',

View File

@ -7,6 +7,7 @@ import {
type ProBoardSimulator, type ProBoardSimulator,
} from '../lib/proBoardRegistry'; } from '../lib/proBoardRegistry';
import { AVRSimulator } from '../simulation/AVRSimulator'; import { AVRSimulator } from '../simulation/AVRSimulator';
import { attachSlavesFromCanvas } from '../simulation/piSlaveScanner';
import { RP2040Simulator } from '../simulation/RP2040Simulator'; import { RP2040Simulator } from '../simulation/RP2040Simulator';
import { RiscVSimulator } from '../simulation/RiscVSimulator'; import { RiscVSimulator } from '../simulation/RiscVSimulator';
import { Esp32C3Simulator } from '../simulation/Esp32C3Simulator'; import { Esp32C3Simulator } from '../simulation/Esp32C3Simulator';
@ -1297,6 +1298,21 @@ export const useSimulatorStore = create<SimulatorState>((set, get) => {
// cannot interleave with it. // cannot interleave with it.
bridge.onBooted = () => { bridge.onBooted = () => {
const setup = getGuestSetup(boardKind); const setup = getGuestSetup(boardKind);
// Attach the slave models for I2C/SPI/UART components wired to
// this Pi. This used to live in RaspberryPiWorkspace, which the
// unified terminal replaced — leaving the scan with no caller,
// so a BMP280 on the Pi's I2C pins never got its backend model.
try {
const st = get();
attachSlavesFromCanvas(
id,
bridge,
st.components as never,
st.wires as never,
);
} catch (e) {
console.warn('[pi] slave scan failed:', e);
}
const flip = () => const flip = () =>
set((s) => ({ set((s) => ({
boards: s.boards.map((b) => (b.id === id ? { ...b, piBooted: true } : b)), boards: s.boards.map((b) => (b.id === id ? { ...b, piBooted: true } : b)),

View File

@ -324,11 +324,10 @@ export function boardPinToNumber(boardId: string, pinName: string): number | nul
// table works. `pinName` may be either the physical pin number // table works. `pinName` may be either the physical pin number
// ("1" … "40") OR a BCM-style name ("GPIO14") emitted by the Pi // ("1" … "40") OR a BCM-style name ("GPIO14") emitted by the Pi
// element's pinInfo — power / GND pins return -1. // element's pinInfo — power / GND pins return -1.
if ( // The whole QEMU-Linux Pi family shares the 40-pin header (the Zero,
boardId === 'raspberry-pi-3' || boardId.startsWith('raspberry-pi-3') || // 1B+ and 2B render the same element as the 3) — matching only 3/4/5
boardId === 'raspberry-pi-4' || boardId.startsWith('raspberry-pi-4') || // left the small boards without any pin mapping at all.
boardId === 'raspberry-pi-5' || boardId.startsWith('raspberry-pi-5') if (boardId.startsWith('raspberry-pi-') && boardId !== 'raspberry-pi-pico') {
) {
if (/^(GND|VCC|3V3|5V|ID_S[DC])/.test(pinName)) return -1; if (/^(GND|VCC|3V3|5V|ID_S[DC])/.test(pinName)) return -1;
if (pinName.startsWith('GPIO')) { if (pinName.startsWith('GPIO')) {
const n = parseInt(pinName.substring(4), 10); const n = parseInt(pinName.substring(4), 10);