Skip to content

Virtual devices

A virtual device is a Python module that simulates a real hardware component (a sensor, an actuator, a memory) connected to the firmware over SPI or I2C. The bridge loads devices at boot and uses them to answer the firmware’s transactions automatically — no physical hardware required.

Create a file under dashboard/devices/, named after the device (lowercase, underscores):

dashboard/devices/my_sensor.py
from ._base import Device
class MySensor(Device):
bus = "spi" # "spi" | "i2c"
port = 0 # bus port (uint)
cs = 12 # SPI: CS pin (int) — required for SPI
# addr = 0x44 # I2C: 7-bit address (int) — required for I2C
def on_transfer(self, tx: bytes) -> bytes:
"""Called for every SPI_TX: tx = bytes received from the firmware."""
return bytes(len(tx)) # reply: len(tx) bytes

The bridge picks the first Device subclass in the module. The class name does not matter — only that it inherits from Device.

| Hook | Called when | Return value | |---|---|---| | on_transfer(tx) | an SPI_TX arrives | reply bytes (length = len(tx)) | | on_write(data) | an I2C_WRITE arrives | None (always ACKs) | | on_read(length) | an I2C_READ arrives | reply bytes (length = length) |

The base class default returns bytes(len(tx)) / bytes(length) — all zeros. Override only the hooks you need.

A device binds to a specific hardware instance through class attributes or CLI arguments. The values in code are defaults; the operator can override them:

Terminal window
python3 dashboard/run.py \
--firmware build/host/examples/spi_thermo/spi_thermo \
--device my_sensor bus=spi port=0 cs=5

--device NAME [key=value …] looks up dashboard/devices/NAME.py, instantiates the device, and applies each key=value as a setattr. Numeric values (0, 12, 0x44) are converted automatically.

dashboard/devices/max31855.py
from ._base import Device
class Max31855(Device):
bus = "spi"
port = 0
cs = 12
_temperature: float = 25.0
_fault: bool = False
def set_temperature(self, celsius: float) -> None:
self._temperature = celsius
def on_transfer(self, tx: bytes) -> bytes:
raw_tc = int(self._temperature / 0.25) & 0x3FFF
word = (raw_tc << 18)
if self._fault:
word |= (1 << 16)
return word.to_bytes(4, "big")
dashboard/devices/sht31.py
from ._base import Device
def _crc8(data: bytes) -> int:
crc = 0xFF
for byte in data:
crc ^= byte
for _ in range(8):
crc = (crc << 1) ^ 0x31 if crc & 0x80 else crc << 1
crc &= 0xFF
return crc
class Sht31(Device):
bus = "i2c"
addr = 0x44
_temperature: float = 25.0
_humidity: float = 50.0
def on_write(self, data: bytes) -> None:
if data == bytes([0x2C, 0x06]): # single-shot high-repeatability
self._pending_read = True
def on_read(self, length: int) -> bytes:
if length != 6:
return bytes(length)
t_raw = int((self._temperature + 45.0) / 175.0 * 65535.0)
rh_raw = int(self._humidity / 100.0 * 65535.0)
t_msb, t_lsb = (t_raw >> 8) & 0xFF, t_raw & 0xFF
rh_msb, rh_lsb = (rh_raw >> 8) & 0xFF, rh_raw & 0xFF
return bytes([t_msb, t_lsb, _crc8([t_msb, t_lsb]),
rh_msb, rh_lsb, _crc8([rh_msb, rh_lsb])])

Devices can expose set_* methods so test scripts can vary the simulated values — this is the pattern used by the unit tests in dashboard/tests/test_bridge.py.

If no device is registered for an SPI or I2C transaction, the bridge schedules an auto-reply after 40 ms:

  • SPI: SPI_RX 0xFF … (MISO idle)
  • I2C write: I2C_WRITE_ACK 1 (ACK)
  • I2C read: I2C_READ 00 … (all zeros)

The firmware has a 50 ms timeout, so the 40 ms auto-reply never trips it under normal conditions. A WebSocket client that injects a reply manually cancels the pending auto-reply.

  1. Create dashboard/devices/<name>.py with a class that inherits from Device.
  2. Set bus, port, and cs (SPI) or addr (I2C) as class attributes.
  3. Implement on_transfer (SPI) or on_write + on_read (I2C).
  4. Add a TestMyDevice class in dashboard/tests/test_bridge.py.
  5. Verify with python3 -m pytest from dashboard/.
  6. Document the run.py --device launch command.