Writing a driver
A driver speaks one protocol and a device file describes one device and its limits, so most new devices need only a device file on top of an existing driver. Write a new driver only when no protocol driver can talk to the device.
This page is the contract every driver in the repository follows, and the tutorial at the end builds a working driver with openhal driver new.
The package
openhal/drivers/<name>/
__init__.py NAME and ENTRY only, no heavy imports
driver.py the Driver subclass, with a header docstring about the protocol and its quirks
simulator.py a mock or protocol simulator used by tests and the conformance case
conformance.py def case() -> ConformanceCase
catalog.json this driver's catalog entry (merged into drivers/catalog.json)
examples/ at least one complete example device file
tests/drivers/<name>/test_<name>.py
docs/drivers/<name>.md
docs/hardware-tests/<name>.md__init__.py holds exactly this, so the registry can find the driver without importing its dependencies.
NAME = "scpi"
ENTRY = "openhal.drivers.scpi.driver:ScpiDriver"The class
class ScpiDriver(Driver):
info = DriverInfo(name="scpi", protocols=("SCPI over VISA",), hazard_default="medium",
summary="Programmable test instruments over USB, LAN and serial.")
params_model = ScpiParams # a Pydantic model with extra="forbid"
@classmethod
async def discover(cls) -> list[Discovered]: ... # read-only, never changes hardware
async def identify(self) -> dict[str, str]: ... # read-only (*IDN?, M115, firmware describe)
async def connect(self) -> None: ...
async def describe(self) -> dict[str, Any] | None: ... # optional self-description
async def read(self, channel: str) -> Reading: ...
async def _write(self, channel: str, value: float | bool | str) -> Reading | None: ...
async def _action(self, name: str, args: dict[str, Any]) -> Any: ...
async def _safe_state(self) -> None: ... # optional protocol-native safe behavior
async def health(self) -> Health: ...
async def close(self) -> None: ...Every driver follows these rules.
- Never override
write,action,safe_state,attachor_consume. Because those methods consume the kernel's grant and then call_write,_actionand_safe_state, the base class refuses to load a class that overrides them. - Never call
_write,_actionor_safe_stateyourself, from anywhere. A test walks the whole package and fails if anything outsidedrivers/base.pydoes. - Values arrive already checked and converted to the channel's declared unit, so don't re-check limits or convert units again, but do convert from the channel unit to whatever the protocol needs (for example percent to a 0 to 255 PWM byte), and document the mapping.
- Discovery and identification send only documented read-only commands, with short timeouts, and never open a port another process holds.
readreturns values in the channel's declared unit, withReading.unitset for numeric channels.- Errors raise
DriverErrorwith a message that says what happened and what to try next. Timeouts may raiseTimeoutError, but never swallow an error and return a fake value. describe()maps whatever the protocol exposes (SCPI queries, Modbus registers, GATT characteristics, Home Assistant entities, ROS topics, OPC UA nodes, Moonraker objects) into device file sections.describe()returns a mapping with any ofdevice,match,read,write,actionsandnotes. Every limitdescribe()proposes is markedsuggested: true, because a driver never confirms limits.- Safe state. Set
DriverInfo.safe_state_supportto"native"if the protocol has its own emergency behavior (GRBL feed hold, outputs off), implement_safe_state, and describe in the docs what_safe_statedoes. If the protocol can't guarantee a safe state, state that limit in the docs page and in the example device file'ssafe_state_note. - Bridges (Home Assistant, ESPHome, ROS 2) expose only the entities the user selected in the device file, and the device file's limits apply even when the bridged system has its own.
- No secrets in device files. Parameters that need a secret take an environment variable name (
token_env: HA_TOKEN). - Optional dependencies go in a
pyproject.tomlextra named after the driver. Import the optional dependencies insidedriver.py, never in__init__.py.
Tests
A driver's tests cover these cases.
- Normal operation for every channel type the driver supports.
- Disconnects, timeouts, malformed responses and every error path.
- The conformance case passes (
tests/conformance/test_conformance.pypicks it up automatically). - No test touches real hardware.
Docs page
docs/drivers/<name>.md covers what the driver supports, setup steps, an example device file, known limitations, hazard defaults, and the protocol sources used to write the driver.
Hardware test plan
docs/hardware-tests/<name>.md is a step-by-step plan for a human with real hardware, ordered from read-only checks to the smallest safe writes, with exact commands, expected output, what to send back on failure and a report template that produces a verified-on-hardware report.
Catalog entry
The catalog entry is catalog.json in the driver package.
{
"name": "scpi",
"tier": 1,
"status": "implemented",
"protocols": ["SCPI over USB-TMC", "SCPI over LAN (VXI-11, raw socket)", "SCPI over serial"],
"families": ["bench power supplies", "multimeters", "oscilloscopes", "function generators", "electronic loads"],
"hazard_default": "medium",
"tests": "mock and pyvisa-sim",
"estimate": {"devices": "thousands of models", "reasoning": "...", "sources": ["https://..."]}
}Tutorial
The scaffold is a complete driver of about 80 lines that talks to an in-memory simulator, and the scaffold passes the conformance suite before you change anything. You then replace the simulator transport with the real protocol, one method at a time, and keep the suite passing.
-
Scaffold the driver from an OpenHAL checkout.
uv run openhal driver new acme-pump --title "Acme pump"The command creates
openhal/drivers/x_acme_pump/(driver, simulator, conformance case, catalog entry and an example device file),tests/drivers/x_acme_pump/,docs/drivers/x-acme-pump.mdanddocs/hardware-tests/x-acme-pump.md. New drivers are namedx-...and marked experimental. -
Run the conformance suite.
uv run openhal driver test x-acme-pumpEvery check passes against the simulator.
-
Write the protocol. In
driver.py, replaceconnect,readand_writewith the real transport, and makesimulator.pyspeak the same bytes the device does. Keepdiscover()andidentify()read-only. Because only the safety kernel's grant may reach_write, never call_writeyourself. -
Test every error path. Disconnects, timeouts, malformed replies and refusals each get a test, and each raises
DriverErrorwith a message that says what to do. -
Run the suite again, then fill in the docs page, the hardware test plan and the catalog entry's estimate with sources.
-
Promote the driver. When the driver passes conformance and a person has run the hardware test plan, a maintainer adds the plain name to the spec's known drivers and sets the driver's status to implemented.
openhal driver list shows every driver with its protocols, hazard default, conformance level and status.