feat(uart): el puerto serie del header tambien en modo Linux

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 <b64>   el guest transmitio por el header -> al canvas
  UARTRX         el guest pregunta que le llego -> UART_RXQ <b64>

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.
This commit is contained in:
David Montero Crespo 2026-07-29 16:53:45 +02:00
parent 2e0be83f23
commit 508d2e141e
4 changed files with 93 additions and 2 deletions

View File

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

View File

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

View File

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

View File

@ -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<string, number>): void {