feat(pi-family): built-in peripheral plumbing for overlay QEMU-Linux boards

- profile key extra_drive: optional read-only second virtio-blk so an
  overlay can ship guest-side shim libraries (/dev/vdb)
- SENS <name> protocol op: canvas-fed named values (built-in sensors /
  buttons) served from PiInstance.sensor_state, pushed by the frontend
  via the new pi_sensor_state WS message
- DISP <b64> protocol op: guest display commands forwarded to the
  frontend as 'display' events (built-in screens)
- RaspberryPi3Bridge: onDisplay / onGpioPwm callbacks + setSensorState
- SimulatorCanvas hands piFamily boards their Pi bridge in
  attachBuiltins (was ESP32-only)
This commit is contained in:
David Montero Crespo 2026-07-28 15:06:25 +02:00
parent 55dd25eeba
commit 1cdcb5a967
4 changed files with 81 additions and 1 deletions

View File

@ -91,6 +91,13 @@ async def simulation_websocket(websocket: WebSocket, client_id: str):
state = msg_data.get('state', 0) state = msg_data.get('state', 0)
qemu_manager.set_pin_state(client_id, pin, state) qemu_manager.set_pin_state(client_id, pin, state)
elif msg_type == 'pi_sensor_state':
# Canvas-fed named values for overlay boards' built-in
# sensors/buttons; the guest polls them via SENS requests.
values = msg_data.get('values', {})
if isinstance(values, dict):
qemu_manager.set_sensor_state(client_id, values)
elif msg_type in ('pi_attach_slave', 'pi_detach_slave'): elif msg_type in ('pi_attach_slave', 'pi_detach_slave'):
# Pluggable hook — pro overlay registers the actual handler # Pluggable hook — pro overlay registers the actual handler
# via qemu_manager.set_pi_slave_handler(). In the OSS image # via qemu_manager.set_pi_slave_handler(). In the OSS image

View File

@ -254,6 +254,9 @@ class PiInstance:
self._proto_out_fd: int | None = None # we read here ← guest writes self._proto_out_fd: int | None = None # we read here ← guest writes
self._tasks: list[asyncio.Task] = [] self._tasks: list[asyncio.Task] = []
self.running = False self.running = False
# 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] = {}
async def emit(self, event_type: str, data: dict) -> None: async def emit(self, event_type: str, data: dict) -> None:
try: try:
@ -299,6 +302,22 @@ class QemuManager:
if inst and inst._gpio_writer: if inst and 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 set_sensor_state(self, client_id: str, values: dict) -> None:
"""Merge canvas-fed named values (served to the guest via SENS).
Used by overlay boards whose built-in sensors/buttons live on the
canvas element: the frontend pushes updates over the WebSocket and
the guest polls them with ``SENS <name>`` protocol requests.
"""
inst = self._instances.get(client_id)
if not inst:
return
for key, value in values.items():
try:
inst.sensor_state[str(key)] = float(value)
except (TypeError, ValueError):
continue
async def send_serial_bytes(self, client_id: str, data: bytes) -> None: async def send_serial_bytes(self, client_id: str, data: bytes) -> None:
inst = self._instances.get(client_id) inst = self._instances.get(client_id)
if not inst: if not inst:
@ -465,6 +484,17 @@ class QemuManager:
'-append', 'console=hvc0 root=/dev/vda rw quiet panic=10', '-append', 'console=hvc0 root=/dev/vda rw quiet panic=10',
] ]
# Optional read-only auxiliary disk (overlay-registered board
# profiles use it to ship guest-side shim libraries). Shows up as
# the second virtio-blk — /dev/vdb on the pci transport.
extra_drive = cfg.get('extra_drive')
if extra_drive and os.path.exists(extra_drive):
cmd += [
'-drive', f'if=none,file={extra_drive},format=raw,readonly=on,id=aux',
'-device', ('virtio-blk-pci,drive=aux' if cfg['bus'] == 'pci'
else 'virtio-blk-device,drive=aux'),
]
logger.info('Launching QEMU for %s: %s', logger.info('Launching QEMU for %s: %s',
inst.client_id, ' '.join(cmd)) inst.client_id, ' '.join(cmd))
@ -674,6 +704,21 @@ class QemuManager:
pass pass
return return
if op == 'SENS' and len(parts) == 2:
# Canvas-fed named value (overlay boards' built-in sensors /
# buttons). Unknown names read as 0 so guest shims degrade
# gracefully when nothing on the canvas feeds them.
value = inst.sensor_state.get(parts[1], 0.0)
await self._reply_gpio(inst, f'SENS {parts[1]} {value:g}')
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
# element (overlay boards with built-in screens).
await inst.emit('display', {'data': parts[1]})
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 known state of the pin. For Phase 2
# we just echo 0 — the canvas-side input wiring fans in # we just echo 0 — the canvas-side input wiring fans in

View File

@ -1,6 +1,7 @@
import { import {
useSimulatorStore, useSimulatorStore,
getEsp32Bridge, getEsp32Bridge,
getBoardBridge,
getBoardSimulator, getBoardSimulator,
} from '../../store/useSimulatorStore'; } from '../../store/useSimulatorStore';
import { getProBoard } from '../../lib/proBoardRegistry'; import { getProBoard } from '../../lib/proBoardRegistry';
@ -250,7 +251,9 @@ export const SimulatorCanvas = ({ headerSlot }: SimulatorCanvasProps = {}) => {
proDef.attachBuiltins!({ proDef.attachBuiltins!({
el, el,
sim: getBoardSimulator(board.id), sim: getBoardSimulator(board.id),
bridge: getEsp32Bridge(board.id), // ESP32-family boards get their QEMU/JS bridge; QEMU-Linux
// (piFamily) boards get the Raspberry Pi bridge instead.
bridge: getEsp32Bridge(board.id) ?? getBoardBridge(board.id),
}), }),
); );
} catch (e) { } catch (e) {

View File

@ -56,6 +56,14 @@ export class RaspberryPi3Bridge {
onDisconnected: (() => void) | null = null; onDisconnected: (() => void) | null = null;
onError: ((msg: string) => void) | null = null; onError: ((msg: string) => void) | null = null;
onSystemEvent: ((event: string, data: Record<string, unknown>) => void) | null = null; onSystemEvent: ((event: string, data: Record<string, unknown>) => void) | null = null;
/** 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;
/** Guest PWM activity (PWM_START / PWM_CHANGE / PWM_STOP). Overlay boards
* use it for built-in buzzers/speakers. */
onGpioPwm:
| ((pin: number, frequency: number, dutyCycle: number, event: string) => void)
| null = null;
/** Fires once when the guest Linux has finished booting and reached an /** Fires once when the guest Linux has finished booting and reached an
* interactive shell prompt. `connected` only means the WebSocket is open * interactive shell prompt. `connected` only means the WebSocket is open
* (~1s); the guest still takes 30-60s to boot. Drives the "booting" UI and * (~1s); the guest still takes 30-60s to boot. Drives the "booting" UI and
@ -123,6 +131,17 @@ export class RaspberryPi3Bridge {
case 'system': case 'system':
this.onSystemEvent?.(msg.data.event as string, msg.data); this.onSystemEvent?.(msg.data.event as string, msg.data);
break; break;
case 'display':
this.onDisplay?.((msg.data.data as string) ?? '');
break;
case 'gpio_pwm':
this.onGpioPwm?.(
(msg.data.pin as number) ?? 0,
(msg.data.frequency as number) ?? 0,
(msg.data.duty_cycle as number) ?? 0,
(msg.data.event as string) ?? 'change',
);
break;
case 'error': case 'error':
this.onError?.(msg.data.message as string); this.onError?.(msg.data.message as string);
break; break;
@ -235,6 +254,12 @@ export class RaspberryPi3Bridge {
this._send({ type: 'gpio_in', data: { pin: gpioPin, state: state ? 1 : 0 } }); this._send({ type: 'gpio_in', data: { pin: gpioPin, state: state ? 1 : 0 } });
} }
/** 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 {
this._send({ type: 'pi_sensor_state', data: { values } });
}
/** /**
* Attach an I2C/SPI/UART slave model to the running Pi. The backend * Attach an I2C/SPI/UART slave model to the running Pi. The backend
* pro overlay turns this into a PiSlaveRegistry entry that the * pro overlay turns this into a PiSlaveRegistry entry that the