diff --git a/backend/app/api/routes/simulation.py b/backend/app/api/routes/simulation.py index 26d3a9e0..2713e8de 100644 --- a/backend/app/api/routes/simulation.py +++ b/backend/app/api/routes/simulation.py @@ -91,6 +91,13 @@ async def simulation_websocket(websocket: WebSocket, client_id: str): state = msg_data.get('state', 0) 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'): # 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 771fe217..f5a2eab9 100644 --- a/backend/app/services/qemu_manager.py +++ b/backend/app/services/qemu_manager.py @@ -254,6 +254,9 @@ class PiInstance: self._proto_out_fd: int | None = None # we read here ← guest writes self._tasks: list[asyncio.Task] = [] 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: try: @@ -299,6 +302,22 @@ class QemuManager: if inst and inst._gpio_writer: 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 `` 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: inst = self._instances.get(client_id) if not inst: @@ -465,6 +484,17 @@ class QemuManager: '-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', inst.client_id, ' '.join(cmd)) @@ -674,6 +704,21 @@ class QemuManager: pass 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: # Reply with the last known state of the pin. For Phase 2 # we just echo 0 — the canvas-side input wiring fans in diff --git a/frontend/src/components/simulator/SimulatorCanvas.tsx b/frontend/src/components/simulator/SimulatorCanvas.tsx index a660f63b..4504e066 100644 --- a/frontend/src/components/simulator/SimulatorCanvas.tsx +++ b/frontend/src/components/simulator/SimulatorCanvas.tsx @@ -1,6 +1,7 @@ import { useSimulatorStore, getEsp32Bridge, + getBoardBridge, getBoardSimulator, } from '../../store/useSimulatorStore'; import { getProBoard } from '../../lib/proBoardRegistry'; @@ -250,7 +251,9 @@ export const SimulatorCanvas = ({ headerSlot }: SimulatorCanvasProps = {}) => { proDef.attachBuiltins!({ el, 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) { diff --git a/frontend/src/simulation/RaspberryPi3Bridge.ts b/frontend/src/simulation/RaspberryPi3Bridge.ts index 7b2acdd0..f04f8520 100644 --- a/frontend/src/simulation/RaspberryPi3Bridge.ts +++ b/frontend/src/simulation/RaspberryPi3Bridge.ts @@ -56,6 +56,14 @@ export class RaspberryPi3Bridge { onDisconnected: (() => void) | null = null; onError: ((msg: string) => void) | null = null; onSystemEvent: ((event: string, data: Record) => 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 * interactive shell prompt. `connected` only means the WebSocket is open * (~1s); the guest still takes 30-60s to boot. Drives the "booting" UI and @@ -123,6 +131,17 @@ export class RaspberryPi3Bridge { case 'system': this.onSystemEvent?.(msg.data.event as string, msg.data); 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': this.onError?.(msg.data.message as string); break; @@ -235,6 +254,12 @@ export class RaspberryPi3Bridge { 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): void { + this._send({ type: 'pi_sensor_state', data: { values } }); + } + /** * Attach an I2C/SPI/UART slave model to the running Pi. The backend * pro overlay turns this into a PiSlaveRegistry entry that the