A field-I/O gateway daemon. It owns the hardware buses on a Linux host, maps physical I/O points to Home Assistant entities over MQTT, and keeps a minimal set of control rules running locally so the installation still works when the network, the broker, or Home Assistant is unavailable.
Status: alpha, and it drives real hardware.
conduiot runcommissions raspihats I²C-HATs, publishes a device tree to a real broker, reports interrupt-driven inputs (~23 ms measured), runs local rules, and fails safe. Verified on a bench: with the daemon SIGKILLed and the bus silent, a commissioned board took its masked channel to the safe value on its own and held the channel configured to hold.Only the
raspihats/*andsimdrivers exist. The CANopen, GPIO and I²C-expander drivers are specified inSPEC.mdand not written. Missing CLI commands:status,monitor,set,canopen lss.Nothing is published yet — no PyPI release, no container image in a registry, no add-on repository. The packaging builds and has been installed, run, upgraded and purged on a Raspberry Pi, but the first tagged release is what puts it anywhere you can download it from.
Home Assistant no longer accepts integrations that talk to devices over GPIO, I²C or SPI (ADR-0019), and running a bus from inside a container was never comfortable anyway. MQTT is the interface that will not be deprecated out from under you: it works on every install type, and it works when Home Assistant is on a different machine from the I/O.
So ConduIOT is a network peer, not a co-process. Home Assistant on one host, one or more gateways on others, a broker between them.
Safe state is a first-class concept, and it is honest about who enforces
it. Every feature is declared as board, gateway or none:
fault_response |
link_loss |
|
|---|---|---|
| Enforced by | the device, autonomously | the gateway |
| Survives | daemon killed, host powered off, cable pulled | broker down, HA down, network partition |
| Available on | boards with a hardware watchdog | every driver |
Those are two differently-named config blocks, not one with a fallback. A guarantee that evaporates with the process that promised it should not share a name with one that does not.
Local rules keep running without a broker. A light switch keeps working
when the network is down. Five rule types, deliberately: direct, toggle,
pulse, interlock, inhibit. No scripting language — this is not a soft
PLC, and the vocabulary is fixed so it cannot quietly become one.
interlock and inhibit are constraints, not writers: they apply to every
write regardless of origin, so a command from Home Assistant that would
violate one is clamped and Home Assistant is told what the hardware actually
did. Asking for one direction of a gate while the other is energised releases
the first, waits out the dead time, then applies the second — a contactor that
has not finished opening is a short circuit waiting for the other to close.
Capability comes from the board, never from a version table. A driver asks the hardware what it can do and reports the answer; nothing above the driver layer knows which firmware release gained which register. A board that predates a feature says so by refusing the request, and that refusal is the answer.
A missing board costs only itself. One unplugged board does not stop the gateway: it is retried with backoff, its entities stay in Home Assistant greyed out rather than disappearing, and when it answers again its configuration is re-applied before it is trusted — because a board that dropped off may have power-cycled into factory defaults, with no watchdog and no safety mask.
Implemented:
| Driver | Hardware | Fault response |
|---|---|---|
raspihats/* |
raspihats I²C-HATs — digital in/out, relays | board — firmware watchdog, CiA 401 0x6206/0x6207 |
sim |
nothing; a board-shaped fake for trying the whole path without hardware | configurable, for testing both branches |
Verified against DI16ac, DQ5rly, DQ10rly and DI6acDQ6rly on one bus, spanning firmware v1.1.3 to v3.1.1 — every one identified from what it answered rather than from its version string.
Specified but not written — SPEC.md describes these and the
driver interface is designed for them, but no code exists yet:
| Driver | Hardware |
|---|---|
canopen401 |
any CiA-401 generic I/O device over CANopen |
gpio |
SoC GPIO via libgpiod v2 |
mcp23017, pcf8574 |
I²C expanders |
ads1115, pca9685 |
I²C ADC / PWM |
Nothing is published yet, so all four paths build from a checkout. Each has been installed and run on a Raspberry Pi.
From source
pip install -e '.[all]'
conduiot validate --config examples/panel-1.yamlDebian package — the reference install (SPEC.md §11.0). Carries its own
wheels and builds a virtualenv offline, so no network is needed at install time
and nothing is written to the system Python. One package covers Debian 12/13 and
Ubuntu 22.04/24.04.
packaging/build-deb.sh --arch arm64
sudo apt install ./dist/conduiot_*_arm64.debIt installs the service and does not start it. This daemon drives relays,
and which ones close is a decision for whoever is near the panel — postinst
prints the four steps instead.
Container — the substrate for compose and for the add-on.
packaging/build-image.sh --platform linux/arm64 --tag conduiot:localSee examples/docker-compose.yml. Use
docker stop -t 90 rather than --timeout, which only exists from Docker 28.
Home Assistant add-on — addon/ is an add-on repository wrapping
that image. Its options carry only broker credentials and log level; the
installation itself stays in the same config.yaml every other target uses.
The sim driver is a board with nothing behind it, so the whole path -
config, entities, discovery, commands - runs on a laptop with a broker:
buses:
- {id: sim0, type: sim}
boards:
- id: relays
driver: sim
bus: sim0
sim:
sections: {dq: 6, di: 6}
enforcement: {fault_response: board} # or gateway, or nothing at all
config:
fault_response: {timeout: 10s, default: false}
entities:
- {id: light, type: switch, point: relays.dq.0, name: Light}conduiot run --config sim.yaml
mosquitto_pub -t conduiot/<gateway>/light/set -m ONEntities appear in Home Assistant, and the switch works.
One YAML file per gateway, identical across every deployment target — only its
path differs. See examples/panel-1.yaml for a worked
installation and SPEC.md §3 for the whole schema.
gateway:
id: panel-1
mqtt:
host: 192.168.1.10
password: !env MQTT_PASSWORD
buses:
- {id: i2c1, type: i2c, device: /dev/i2c-1}
boards:
- id: relays
driver: raspihats/DQ10rly
bus: i2c1
address: 0x50
config:
fault_response:
timeout: 10s
default: false
overrides: {9: hold} # channel 9 holds its last state
entities:
- {id: garage_light, type: switch, point: relays.dq.0, name: Garage light}Validation is strict: unknown keys are errors, not warnings. A misspelled
fault_respose that validates silently is a safe state you believe you
configured and do not have.
conduiot doctor # diagnose the environment; changes nothing, needs no privilege
conduiot scan # find boards and write a starter config
conduiot validate # parse and check the configuration; opens no bus
conduiot apply # commission the boards; --check prints the diff and writes nothing
conduiot run # the gateway itselfdoctor is print-only by design: half of what it checks lives in
/boot/firmware/config.txt, and a diagnostic that half-applies a boot change
bricks a headless Pi one reboot later. It prints the command; you run it.
scan reports presence by default and transmits nothing. --identify sends
each driver family's request frame, and 0x50–0x57 is also where 24Cxx
EEPROMs live — so it is opt-in.
Output restore. gateway.restore: last puts outputs back after a restart,
from a state file that is never load-bearing: missing, corrupt or unwritable all
degrade to power-on values with a warning, never to a failure. Restored values
go through the ordinary write path, so a stale state file that would close both
contactors of a reversing starter meets the same interlock a live command does.
Gateway diagnostics. A retained JSON document carries uptime, MQTT reconnects, config hash, boards online and per-bus error counters, surfaced as Home Assistant diagnostic entities. Retries and errors are counted separately — a marginal I²C cable does not fail, it retries, and folding the two together destroys the only early warning there is.
python -m pytest -q # 496 testsRequires Python 3.11+. CI runs the suite on 3.11, 3.12 and 3.13 — the same range
the .deb carries wheels for, so the two cannot drift.
MIT.