Skip to content

Simulation bus

The simulation bus is the observation-and-injection channel that connects firmware running on the host to simulated components, test tools, and the dashboard. It is part of the POSIX port, not the HAL — it does not exist on a real MCU.

The firmware process acts as a server on a Unix domain socket. Each client (simulated component, visualizer, test script) connects and can receive events or send injections. Multiple clients can connect at once.

┌─────────────────────────────────────────┐
│ firmware process │
│ ┌──────────────────────────────────┐ │
│ │ hal/posix │ │
│ │ gpio_state[] uart pipes timer │ │
│ │ sim bus server │ │
│ └──────────┬───────────────────────┘ │
└─────────────┼────────────────────────────┘
│ Unix domain socket
┌─────────┼──────────────┐
┌───▼───┐ ┌───▼─────┐ ┌──────▼─────┐
│ LED │ │ button │ │ visualizer │
│ (py) │ │ (py) │ │ (browser) │
└───────┘ └─────────┘ └────────────┘

The socket path is configurable via the LPL_SIM_SOCKET environment variable (default /tmp/lpl_sim_<pid>.sock). If the socket cannot be created, the firmware runs normally without the bus — the bus is always optional.

The protocol is text, line-based: every message is a single ASCII line terminated by \n. There is no JSON parser in the C code — just write(fd, line, len). Any language can implement a client with readline().

DIRECTION TYPE ARGS...\n
  • DIRECTION> firmware → client (event), < client → firmware (injection)
  • TYPE — the event type identifier
  • ARGS — space-separated parameters
> GPIO_WRITE <pin> <0|1>
> GPIO_DIR <pin> <IN|OUT>
> GPIO_TOGGLE <pin>
> UART_TX <port> <hex_byte>
> TIMER_TICK <ms>
> ADC_READ <channel> <value>
> SPI_CS <port> <cs_pin> <ASSERT|DEASSERT>
> SPI_TX <port> <b0> <b1> … # requires < SPI_RX
> I2C_WRITE <port> <addr_hex> <b0> … # requires < I2C_WRITE_ACK
> I2C_READ <port> <addr_hex> <len> # requires < I2C_READ
< GPIO_SET <pin> <0|1> # forces the level read by gpio_read
< UART_RX <port> <hex_byte> # injects a byte into the UART buffer
< ADC_SET <channel> <value> # sets the ADC value (0–4095, 12-bit)
< SPI_RX <port> <b0> <b1> … # MISO data for the pending transfer
< I2C_WRITE_ACK <port> <addr_hex> <0|1># 1 = ACK, 0 = NACK
< I2C_READ <port> <addr_hex> <b0>…# data read from the device

When the firmware emits SPI_TX, I2C_WRITE, or I2C_READ, the HAL thread blocks on a pthread_cond_timedwait for up to 50 ms waiting for the reply. On timeout, SPI fills rx with 0xFF (MISO idle) and I2C returns LPL_ERR_TIMEOUT. The bridge replies within ≤ 40 ms — directly (a virtual device) or through a scheduled auto-reply — so the firmware timeout never fires under normal conditions.

An SPI session reading a MAX31855 thermocouple:

> SPI_CS 0 12 ASSERT
> SPI_TX 0 00 00 00 00
< SPI_RX 0 19 10 00 00
> SPI_CS 0 12 DEASSERT

An I2C session reading an SHT31:

> I2C_WRITE 0 44 2C 06
< I2C_WRITE_ACK 0 44 1
> I2C_READ 0 44 06
< I2C_READ 0 44 60 EB 1E 64 3A 99

You can inspect the protocol live with nc -U /tmp/lpl_sim_<pid>.sock or socat.

A simulated component is a separate process (typically Python, stdlib only) that connects to the socket, filters the events it cares about, keeps its own state, and can send injections in response. The core knows nothing about them — it only observes them through the protocol. Each component is an independent lapillo.

A virtual device is a Python module the bridge loads to answer SPI/I2C transactions automatically. See Virtual devices for how to write one.

The dashboard bridge (bridge.py, asyncio + the websockets library) is the firmware process manager: it spawns N firmware processes, discovers their sockets, reads every sim bus in parallel, and multiplexes all events onto a single WebSocket toward the browser. Each WS message includes a nodeId (source node) and a bridge-authoritative ts (milliseconds since the bridge started).

Browser → bridge messages carry injections and lifecycle control (GPIO_SET, UART_RX, ADC_SET, SPI_RX, I2C_WRITE_ACK, I2C_READ) plus CTRL_RESTART, CTRL_STOP, CTRL_PAUSE, and CTRL_RESUME. Pause/resume is implemented with SIGSTOP/SIGCONT on the firmware process.

This decoupling means the C project has no native graphics dependency, the visualizer can be replaced without recompiling firmware, and a single bridge drives N firmware processes in parallel on a headless server.