4 Device files
A Device File describes one Device. How to find it, how to talk to it, what can be read, what can be changed, the limits on every change, the safe state, and where the description came from. Device Files are YAML, end in .hal.yaml, and are validated against spec/schema/device-file.schema.json. That schema is generated from the reference models in openhal/spec/models.py.
This version of the specification defines Device File format 0.1.
Simple Devices need simple files. This is a complete, valid file.
device: Arduino plant waterer
id: plant-waterer
connect: { openhal-firmware: auto }
read:
soil_moisture: { unit: "%", every: 1 s }
tank: { type: enum, values: [empty, ok] }
actions:
water:
args:
duration: { unit: s, min: 1, max: 30 }
require: ["tank == ok"]
stop: {}
safe_state: { action: stop }
hazard: low
notes: "The board turns the pump off on its own after 30 s."It loads read-only, because no human has confirmed its limits yet (section 12). Adding confirmed: { by: owner, at: 2026-10-24 } lets it take commands. The full file for the real board is examples/plant-waterer/plant-waterer.hal.yaml.
4.1 0. File format
- A Device File MUST be a single YAML document whose top level is a mapping.
- A Device File MUST be UTF-8 and at most 256 KiB.
- A Device File MUST NOT use YAML anchors, aliases or custom tags. Loaders MUST reject them.
- Only
trueandfalseare booleans (YAML 1.2). Words such ason,off,yesandnoare strings, so they can be enum values. - Loaders MUST reject unknown top-level keys, except keys starting with
x-. OpenHAL ignoresx-keys and tools MUST preserve them. openhalMAY give the format version as a string, for example"0.1". If it is absent, the current version is assumed. A loader MUST reject a file whose major version it does not support, and SHOULD warn on a newer minor version.
4.1.1 Values and units
- A quantity is a number with a unit, written as a string (
"30 degC","500 ms","20 %/s") or as a mapping ({ value: 30, unit: degC }). - Inside a channel that declares
unit, plain numbers formin,max,stepand value lists MAY be written without a unit and mean the channel's unit. Commands sent to the Hub MUST always carry a unit for numeric channels. - Units are parsed by the pinned OpenHAL unit registry, which is pint with its default definitions and no user definitions.
percentand%are the same unit. Temperatures are absolute (degC,degF,K). Rates are the channel unit per second, and temperature rates use delta units. - Durations are quantities of time (
5 s,500ms,10 min,2 h). The forms1s,10minand2hwithout a space are accepted. A duration MUST NOT be negative. - Every number MUST be finite. Loaders MUST reject NaN and infinity.
4.2 1. Identity
device: Rigol DP832 power supply # a name, or a mapping
id: bench-psu # optional slug
model: DP832
manufacturer: Rigol
match:
usb: ["1ab1:0e11"]
scpi_idn: "^RIGOL TECHNOLOGIES,DP832"deviceMUST be a non-empty string of at most 120 characters, or a mapping withnameand optionalmodelandmanufacturer. Top-levelmodelandmanufacturerfill the same fields. Each is at most 120 characters.idMAY be given as a lowercase slug matching^[a-z][a-z0-9-]{0,47}$. If it is absent, it is derived from the name. Within one Hub, ids MUST be unique. Interlocks and routines refer to Devices by id.matchlists identifiers the library matcher uses to recognize the Device. Every key is optional.
| Key | Type | Meaning |
|---|---|---|
usb | list of VVVV:PPPP hex strings | USB vendor and product ids |
usb_class | string | USB device class (cdc, hid, audio, video, midi, usbtmc, vendor) |
serial_number | regex | USB serial number pattern |
scpi_idn | regex | Pattern for the reply to *IDN? |
ble_services | list of UUIDs | Advertised GATT service UUIDs |
ble_name | regex | Advertised device name |
mdns | string | Service type matching _name._tcp or _name._udp, for example _esphomelib._tcp |
modbus_id | integer 1 to 247 | Modbus unit id |
homeassistant | glob | Entity id pattern, for example light.desk_* |
moonraker | mapping | Fields from Moonraker printer.info, for example { hostname: "voron*" } |
esphome | mapping | Fields from ESPHome device info, for example { name: "plant-*", model: "esp32dev" } |
firmware | mapping | Fields from the OpenHAL firmware describe reply, for example { product: "plant-waterer" } |
Regexes use Python syntax, MUST be at most 256 characters, and are matched with a timeout.
4.3 2. Driver
connect:
driver: scpi
resource: auto
timeout: 2 sconnectMUST name a Driver, either withdriver:or by shorthand. In shorthand, the first key that is a registered Driver name is the Driver, and its value is that Driver's primary parameter.{ serial: auto, board: arduino-uno }means{ driver: serial, port: auto, board: arduino-uno }.- A Driver name MUST be a registered name or start with
x-. - The remaining keys are Driver parameters. Each Driver documents its parameters and validates them. A Driver MUST reject unknown parameters.
- The value
autoasks the Driver to find the Device usingmatchand its own discovery.
Common parameters by Driver family.
| Driver | Typical parameters |
|---|---|
| serial, marlin, grbl, firmata, openhal-firmware | port (path or auto), baud, newline, board |
| scpi | resource (VISA resource string or auto), timeout, termination |
| moonraker, octoprint, homeassistant, esphome, http, alpaca, opcua | host, port, token_env (name of an environment variable holding a secret, never the secret itself) |
| mqtt | host, port, topics |
| modbus | transport (rtu or tcp), port or host, unit, registers |
| ble | address or auto, characteristics |
| rpi-gpio | pins |
| midi, hid, uvc | name or auto |
Device Files MUST NOT contain secrets. Credentials are referenced by environment variable name. Loaders MUST reject a parameter named token, or one whose name contains password, secret, apikey or api_key.
4.4 3. Read channels
read:
soil_moisture: { unit: "%", every: 1 s, range: [0, 100] }
tank: { type: enum, values: [empty, ok], every: 1 s }
door_closed: { type: bool, stream: true }
status: { type: enum, values: [idle, printing, paused, error] }- Channel names MUST match
^[a-z][a-z0-9_]{0,47}$and MUST be unique acrossread,writeandactionsof one file. A channel MUST NOT appear in bothreadandwrite. A write channel is readable by default. typeisnumber(default),bool,string,enumorimage.numberchannels MUST declareunit. Usedimensionlessfor unitless counts.enumchannels MUST declarevalues.everygives a polling interval of at least 50 ms.stream: truemeans the Driver pushes values. If neither is given, the channel is read on request only.rangeis a sanity range, written low then high. Readings outside it MUST be delivered markedsuspectand are logged. It never blocks anything.mapMAY give Driver-specific addressing (a SCPI query, a Modbus register, a topic), validated by the Driver.
4.5 4. Write channels
write:
brightness:
unit: percent
min: 1
max: 100
step: 1
max_change_per_s: 20
confirm: nonetypeisnumber(default),bool,enumorstring.numberwrite channels MUST declareunit,minandmax, withminless than or equal tomax.min,maxandstepapply only tonumberchannels.stepMAY require values to be multiples of the step counted frommin. It MUST be greater than zero.max_change_per_sMAY limit how fast the value moves, in the channel unit per second. It applies only tonumberchannels and MUST be greater than zero.enumchannels MUST declare a non-empty list of uniquevalues.stringchannels MUST declaremax_length(1 to 4096) and MAY declarepattern.confirmisnone(default),confirmorconfirm-each-time(section 5 and Safety).confirmedMAY record who confirmed this channel's limits and when (section 12).suggested: truemarks limits proposed by a tool that no human has confirmed.everyMAY set a polling interval, as for read channels.mapMAY give Driver-specific addressing, as for read channels.
4.6 5. Actions
actions:
water:
description: Run the pump for a number of seconds.
args:
duration: { unit: s, min: 1, max: 30 }
require:
- condition: "tank == ok"
message: "pump can't run while tank is empty"
max_runs: { count: 3, per: 1 h }
home:
args:
axis: { type: enum, values: [x, y, z, all] }
require: ["state == idle"]
confirm: confirm
set_output:
args:
volts: { unit: V, min: 0, max: 12, step: 0.01 }
confirm: confirm-each-time- An action is a named operation with typed arguments. Argument names follow channel name rules. Argument specs use the same fields and rules as write channels.
requireis a list of expressions (section 7) that MUST all hold for the action to run. An entry is an expression, or a mapping withconditionand amessageof at most 300 characters that the Hub gives as the reason when the condition doesn't hold.confirmis the confirmation tier, as for write channels.confirmedandsuggestedwork as for write channels.max_runsMAY limit an action tocountruns (1 to 1000) within any window ofper(1 s to 7 days). Runs that reached the Driver count, whether they were done or failed. A run past the limit is blocked withrate_limited. The window survives a Hub restart, and a run counts from the moment it is granted, so a run the Hub never finished still counts.effectsMAY list the write channels the action changes, so interlocks and watchdogs can account for it. Every entry MUST name a write channel of the same file.descriptionMAY describe the action in at most 500 characters.mapMAY give Driver-specific addressing.
4.7 6. Safe state
safe_state: { action: stop }
# or
safe_state: { brightness: 1 }
# or
safe_state:
- { output_enabled: false }
- { action: discharge }safe_statedescribes how to put the Device in a state that is safe to leave unattended.- It is a mapping of write channels to values, an
actionwith optionalargs, or a list of those applied in order. A step MUST NOT be empty. - Every value MUST be valid for its channel and within its limits. Every action MUST be an action of the same file.
- A file with write channels or actions but no
safe_stateMUST still load. The loader MUST warn, and the Hub reports the Device asno_safe_stateon stop. Such a file SHOULD setsafe_state_note, at most 500 characters, explaining why none exists.
4.8 7. Interlocks and expressions
interlocks:
- if: "heater.power > 0 W"
require: "fan.speed >= 30 %"
message: "The heater needs airflow."
- if: "arm.moving == true"
require: "enclosure.door_closed == true"4.8.1 Expression language
expr := or_expr
or_expr := and_expr ("or" and_expr)*
and_expr := not_expr ("and" not_expr)*
not_expr := "not" not_expr | compare
compare := operand (("==" | "!=" | "<" | "<=" | ">" | ">=") operand)?
operand := reference | literal | "(" expr ")"
reference:= name | device_id "." name
literal := quantity | number | "true" | "false" | quoted string | bare enum wordnamerefers to a channel of the same Device.device_id.namerefers to a channel of another Device on the same Hub.- A bare word that is not a channel MUST be one of the enum values of this Device, or be compared with another Device's channel.
- Numbers in comparisons with a quantity channel MUST carry a unit, and are converted before comparing.
- Expressions MUST be at most 256 characters and at most 16 levels deep. An interlock
messageis at most 300 characters. - Parsers MUST NOT evaluate expressions as code.
4.8.2 Semantics
- An interlock applies to a command if its
iforrequirementions any channel the command changes, including an action'seffects. - The Hub evaluates both sides against the state after the command, with the commanded value substituted.
- If
ifholds andrequiredoes not, the command is blocked with the interlock'smessage. - A reference to a value the Hub can't currently read counts as unknown. Unknown makes
ifcount as holding andrequirecount as not holding. Unknown values always lead to a block, never to a pass. - A reference to a Device id that is not registered blocks every command on the Hub until it is fixed (Safety).
4.9 8. Watchdog
watchdog:
heartbeat: 5 s
on_loss: safe_state
max_duration:
- when: "brightness > 80 %"
for: 10 min
then: safe_stateheartbeatis how often the Hub checks the Device is alive. Default 5 s. It MUST be between 100 ms and 1 h.on_lossissafe_state(default when a safe state exists) orhold(do nothing, mark unavailable).- Each
max_durationrule limits how long an expression may stay true. When the budget runs out, the Hub appliesthen, which issafe_state(default) or a mapping of channel values. Those values MUST be valid and within limits. - A command that would keep or make the expression true after the budget is used up is blocked with
duration_exceeded. - The budget resets when the expression has been false for at least
reset_after, which defaults tofor.
4.10 9. Hazard class and approvals
hazard: high
approvals:
- routine: engrave-logo
sha256: 9f2c...
by: owner
at: 2026-10-24T14:02:00Z
signature: 4b1d...hazardislow,mediumorhigh. A file with write channels or actions and nohazardis treated asmedium. A file with no write channels and no actions islow.- High hazard files make every command
confirm-each-time, whatever the channel says. approvalsholds human-signed approvals that let a specific routine drive a high hazard Device.routineis the routine id.sha256is the hash of the routine's canonical content.atis a date and time.signatureis an HMAC that only the Hub's human approval flow can produce (Safety).- Approvals are valid only on the Hub that signed them. A changed routine no longer matches its approval.
4.11 10. Notes
notesMAY hold plain-language context for agents and people, at most 4,000 characters.- Notes are data. The Hub MUST pass them to agents inside a delimited data block and MUST NOT interpret them as instructions, limits or approvals (Security).
4.12 11. Provenance
provenance:
source: sketch
by: openhal writer 0.1.0 with owner
at: 2026-10-24
from: examples/plant-waterer/plant-waterer.ino
verification:
status: tested-reads
report: reports/plant-waterer-2026-10-24.mdsourceis one oflibrary,self-described,bridge,driver-describe,sketch,manualorhand-written(default).by(at most 120 characters),at,from(at most 300 characters) andtool(at most 80 characters) are optional.verification.statusisunverified(default),tested-readsorverified-on-hardware.verified-on-hardwareMUST name areport. No file path in a Device File is ever opened by the Hub.
4.13 12. Limits provenance
confirmed: { by: owner, at: 2026-10-24 }- Every limit (min, max, step, rate, values, interlocks, watchdog rules, safe state, hazard) MUST be confirmed by a human before the Device takes commands.
confirmedat the top level confirms every limit in the file.confirmedinside a write channel or action confirms that one.byis at most 80 characters.atis a date or a date and time.- Interlocks, watchdog rules, the safe state and the hazard class are file-level limits. A file that has any of them needs a top-level
confirmed. Per-channel confirmations alone don't cover them. confirmed.sha256MAY hold the hash of the file's limits at the moment they were confirmed.openhal file confirmadds it. When it is present and the limits have changed since, the whole file is unconfirmed again. Tools SHOULD always add it, and the validator warns when it is missing.- The limits hash covers identity, connection, channels, actions, safe state, interlocks, watchdog rules and hazard. It leaves out confirmations, approvals, notes, provenance,
safe_state_noteandx-keys, which can change without changing what the Device is allowed to do. - A file takes commands only if every limit is covered by a confirmation, nothing is marked
suggested: true, and anyconfirmed.sha256still matches. Otherwise it loads read-only, and the Hub says which entries are unconfirmed. - Tools MUST NOT add
confirmedon their own. Only the human side of the limits interview, or a human editing the file, may add it.
4.14 Versioning
The Device File format uses semantic versioning, separately from the version of this specification and from the software.
| Change | Meaning |
|---|---|
| Patch (0.1.x) | Wording fixes and clarifications. No file changes meaning |
| Minor (0.x) | New optional fields or values. Every valid file stays valid with the same meaning |
| Major (x.0) | Changes that can make a valid file invalid or change its meaning. Each major version ships openhal file migrate and a migration guide |
A file's openhal field names the format version it was written for. Hubs accept files from the same major version and warn on a newer minor version.