From 508d2e141e2fe9040ac531f86aba6f5c1f7768b7 Mon Sep 17 00:00:00 2001 From: David Montero Crespo Date: Wed, 29 Jul 2026 16:53:45 +0200 Subject: [PATCH] feat(uart): el puerto serie del header tambien en modo Linux MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit La placa QEMU-Linux tiene DOS flujos serie y hasta ahora el cableado usaba el equivocado: el enrutado entregaba los bytes del vecino a la consola (el shell) y sacaba al cable la cháchara del arranque. El header, que es lo que el usuario cablea, no existia. Ahora el canal de protocolo lleva dos ops nuevas: UARTTX el guest transmitio por el header -> al canvas UARTRX el guest pregunta que le llego -> UART_RXQ El backend guarda una cola por instancia (acotada a 64 KB, que un script que no lee nunca no la haga crecer) y el websocket acepta `pi_uart_rx` con los bytes que el vecino manda. En el frontend el bridge gana onUartTx / sendUartBytes y el Interconnect engancha ESE flujo en vez de la consola para las placas Pi. Con esto el mismo script -- import serial, escribir, dormir, leer -- funciona en los dos motores. --- backend/app/api/routes/simulation.py | 7 ++++ backend/app/services/qemu_manager.py | 34 +++++++++++++++++++ frontend/src/simulation/Interconnect.ts | 29 ++++++++++++++-- frontend/src/simulation/RaspberryPi3Bridge.ts | 25 ++++++++++++++ 4 files changed, 93 insertions(+), 2 deletions(-) diff --git a/backend/app/api/routes/simulation.py b/backend/app/api/routes/simulation.py index ddf29abc..681991b3 100644 --- a/backend/app/api/routes/simulation.py +++ b/backend/app/api/routes/simulation.py @@ -100,6 +100,13 @@ async def simulation_websocket(websocket: WebSocket, client_id: str): if isinstance(values, dict): qemu_manager.set_sensor_state(client_id, values) + elif msg_type == 'pi_uart_rx': + # Bytes another board on the canvas sent down a TX->RX wire. + # Queued for the guest, which drains them with UARTRX. + rx: list[int] = msg_data.get('bytes', []) + if rx: + qemu_manager.push_uart_rx(client_id, bytes(rx)) + elif msg_type in ('pi_attach_slave', 'pi_detach_slave'): # Pluggable hook — pro overlay registers the actual handler # via qemu_manager.set_pi_slave_handler(). In the OSS image diff --git a/backend/app/services/qemu_manager.py b/backend/app/services/qemu_manager.py index a523c84c..99b06965 100644 --- a/backend/app/services/qemu_manager.py +++ b/backend/app/services/qemu_manager.py @@ -30,6 +30,7 @@ Protocol channel (chardev 1) — wired by Phase 2's pi_protocol_mux """ import asyncio +import base64 import logging import os import socket @@ -291,6 +292,10 @@ class PiInstance: # Canvas-fed named values served to the guest via the SENS # protocol op (overlay boards' built-in sensors/buttons). self.sensor_state: dict[str, float] = {} + # Bytes another board on the canvas sent to this one's header UART + # (TX->RX wire). The guest drains them with the UARTRX op; nothing + # here interprets them, they are a pipe between two boards. + self.uart_rx = bytearray() # Raw `start_pi` payload — carries whatever the client declared for # this session (e.g. the packages an overlay must materialise). self.start_payload: dict = {} @@ -341,6 +346,17 @@ class QemuManager: if inst and inst._gpio_writer: asyncio.create_task(self._send_gpio(inst, int(pin), bool(state))) + def push_uart_rx(self, client_id: str, data: bytes) -> None: + """Queue bytes for the guest's header UART (a wired board's TX).""" + inst = self._instances.get(client_id) + if not inst or not data: + return + # Bound the queue: a script that never reads must not grow it + # without limit (the peer keeps transmitting either way). + if len(inst.uart_rx) > 64 * 1024: + del inst.uart_rx[: len(inst.uart_rx) - 64 * 1024] + inst.uart_rx.extend(data) + def set_sensor_state(self, client_id: str, values: dict) -> None: """Merge canvas-fed named values (served to the guest via SENS). @@ -790,6 +806,24 @@ class QemuManager: await self._reply_gpio(inst, f'SENS {parts[1]} {value:g}') 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: # Guest display command (opaque base64 payload). Forwarded # verbatim to the frontend, which renders it on the board diff --git a/frontend/src/simulation/Interconnect.ts b/frontend/src/simulation/Interconnect.ts index dff5e313..28ba9a5a 100644 --- a/frontend/src/simulation/Interconnect.ts +++ b/frontend/src/simulation/Interconnect.ts @@ -236,8 +236,15 @@ function pushSerialByte(boardId: string, ch: string, uart: number): void { const bridge = runtime.getStm32Bridge(boardId); bridge?.sendSerialBytes?.([ch.charCodeAt(0)], uart); } else if (isPi3Bridge(entry.kind)) { - const bridge = runtime.getBoardBridge(boardId); - bridge?.sendSerialBytes?.([ch.charCodeAt(0)]); + const bridge = runtime.getBoardBridge(boardId) as + | { sendUartBytes?: (b: number[]) => void; sendSerialBytes?: (b: number[]) => void } + | undefined; + // The header UART is a different pipe from the console: typing a + // peer's bytes into the shell used to be the only option, and it + // meant the guest's own boot chatter went out on the wire while the + // data a script wrote never did. + if (bridge?.sendUartBytes) bridge.sendUartBytes([ch.charCodeAt(0)]); + else bridge?.sendSerialBytes?.([ch.charCodeAt(0)]); } } @@ -352,6 +359,24 @@ function ensureSerialHook(entry: BoardEntry): void { ? runtime.getStm32Bridge(entry.id) : runtime.getBoardBridge(entry.id); if (!bridge) return; + + // A QEMU-Linux board has TWO serial streams: the console (the shell) + // and the header UART. Only the second one is on the wire — hooking the + // console here would send the guest's boot chatter and shell prompt to + // the peer board, which is what used to happen for lack of anything + // better. + const piBridge = bridge as unknown as { onUartTx?: ((t: string) => void) | null }; + if (isPi3Bridge(entry.kind) && 'onUartTx' in piBridge) { + if ((bridge as unknown as { __icUartHook?: boolean }).__icUartHook) return; + (bridge as unknown as { __icUartHook?: boolean }).__icUartHook = true; + const prevUart = piBridge.onUartTx ?? null; + piBridge.onUartTx = (text: string) => { + prevUart?.(text); + const subs = boards.get(boardId)?.serialFanout.get(0); + if (subs) for (const ch of text) for (const cb of subs) cb(ch); + }; + return; + } if ((bridge as any).__icSerialHookInstalled) return; (bridge as any).__icSerialHookInstalled = true; entry.origSerialCallback = bridge.onSerialData ?? null; diff --git a/frontend/src/simulation/RaspberryPi3Bridge.ts b/frontend/src/simulation/RaspberryPi3Bridge.ts index 0e2c7822..dc1e9760 100644 --- a/frontend/src/simulation/RaspberryPi3Bridge.ts +++ b/frontend/src/simulation/RaspberryPi3Bridge.ts @@ -61,6 +61,9 @@ export class RaspberryPi3Bridge { /** Guest display command (opaque base64 payload from the DISP protocol * op). Overlay boards with built-in screens render it on their element. */ onDisplay: ((data: string) => void) | null = null; + /** Bytes the guest wrote to its HEADER UART (not the console): another + * board wired to those pads is the destination. Decoded text. */ + onUartTx: ((text: string) => void) | null = null; /** Guest PWM activity (PWM_START / PWM_CHANGE / PWM_STOP). Overlay boards * use it for built-in buzzers/speakers. */ onGpioPwm: @@ -164,6 +167,21 @@ export class RaspberryPi3Bridge { case 'display': this.onDisplay?.((msg.data.data as string) ?? ''); break; + case 'uart_tx': { + const b64 = (msg.data.data as string) ?? ''; + if (b64) { + try { + this.onUartTx?.( + new TextDecoder().decode( + Uint8Array.from(atob(b64), (ch) => ch.charCodeAt(0)), + ), + ); + } catch { + /* malformed payload — never kill the session over it */ + } + } + break; + } case 'gpio_pwm': this.onGpioPwm?.( (msg.data.pin as number) ?? 0, @@ -310,6 +328,13 @@ export class RaspberryPi3Bridge { this._send({ type: 'gpio_in', data: { pin: gpioPin, state: state ? 1 : 0 } }); } + /** Bytes for the guest's HEADER UART RX (a wired board's TX). Distinct + * from sendSerialBytes, which types into the console/shell. */ + sendUartBytes(bytes: number[]): void { + if (!bytes.length) return; + this._send({ type: 'pi_uart_rx', data: { bytes } }); + } + /** Push canvas-fed named values (built-in sensors/buttons of overlay * boards). The guest polls them via SENS protocol requests. */ setSensorState(values: Record): void {