From code to copper
This page is for the developer. It follows one sensor reading from a wire on a terminal, through the driver, into engineering units the control loop can use — and one command back out to the relay. It maps the boxes on the System blueprint to the code that runs them.
The abstraction: IODriver
Section titled “The abstraction: IODriver”All hardware sits behind one interface, IODriver (agent/src/riser_agent/io/base.py). The control
loop only ever talks to that interface — it never imports hardware libraries. That is what lets the
exact same agent code run against:
SimulatedDriver(io/simulated.py) — a thermal model with fault injection, used for tests andmake run-sim. No hardware, no board.ModBerryDriver(io/modberry.py) — the real ModBerry 500 CM4.RevPiDriver(io/stubs.py) — the Revolution Pi fallback (stub until hardware is in hand).
The factory picks one from config (driver = "simulated" | "modberry" | "revpi"). Swapping hardware
is a config change, not a code change.
The ModBerry driver: two transports
Section titled “The ModBerry driver: two transports”ModBerryDriver splits I/O across two buses, because that is how the board is wired:
| I/O | Transport | Library | What it does |
|---|---|---|---|
| Relay enable output, digital inputs | GPIO lines | libgpiod (gpiod v2) |
Drives DO1 to the interposing relay; reads DI pulses |
| Analog / RTD / thermocouple channels | Modbus RTU, internal RS-485 | pymodbus | Reads the onboard sensor module (AI*, TC1) |
| Boiler burner controller | Modbus RTU, external RS-485 | pymodbus | Reads status / writes setpoints on a modern boiler (B2) |
The module imports with no hardware libraries present.
gpiodis imported lazily inside__init__, soio/and the whole test suite run on a laptop. Construct the driver without the libraries or the board and it raises a clearDriverError— fail fast, never silently fake I/O.
Every line that touches real hardware is tagged # HARDWARE: in io/modberry.py with what to confirm
on the board (chip path, line offsets, unit IDs, registers, scaling, relay polarity, TC cold-junction).
On-device validation is the only thing left for that driver — the code is written.
The map: sensor → terminal → config → code
Section titled “The map: sensor → terminal → config → code”All board-specific routing lives in the [io.modberry] config block, so calibration is a config edit.
This is the chain for each channel:
| Sensor (tag) | Terminal | Physical ID | Config section | Register / line | Units |
|---|---|---|---|---|---|
Supply temp (S1) |
AI1 |
ai0 |
[io.modberry.analog.ai0] |
unit 1, reg 0, scale=0.1 |
°C |
Return temp (S2) |
AI2 |
ai1 |
[io.modberry.analog.ai1] |
unit 1, reg 1, scale=0.1 |
°C |
Outdoor temp (S4) |
AI4 |
ai2 |
[io.modberry.analog.ai2] |
unit 1, reg 2, scale=0.1 |
°C |
Pressure (S3) |
AI3 |
ai3 |
[io.modberry.analog.ai3] |
unit 1, reg 3, scale=0.01 |
psi |
Flue temp (S5) |
TC1 |
tc0 |
[io.modberry.analog.tc0] |
unit 1, reg 4, scale=0.1 |
°C |
Boiler enable (R1) |
DO1 |
do0 |
[io.modberry.gpio_lines] |
GPIO line 17 | on/off |
| Water-meter pulse | DI |
di0 |
[io.modberry.gpio_lines] |
GPIO line 27 | pulse |
Example config (from agent/config.example.toml):
[io.modberry]gpio_chip = "/dev/gpiochip0"burner_unit_id = 1
[io.modberry.gpio_lines] # physical digital id -> GPIO line offsetdo0 = 17 # boiler_enable -> interposing relaydi0 = 27 # water_meter pulse
[io.modberry.io_serial] # onboard analog/RTD/TC module, internal Modbus RTU busport = "/dev/ttyS0"baudrate = 9600
[io.modberry.analog.ai0] # supply_temp RTD, module reports deg C x10unit_id = 1register = 0scale = 0.1Reading one channel
Section titled “Reading one channel”A read is: pull the raw holding register over Modbus, then affine-scale it to engineering units.
def apply_scale(raw: int, scale: float, offset: float) -> float: """Affine-map a raw register value to engineering units (deg C, psi, ...).""" return raw * scale + offsetSo an onboard module reporting 712 (°C ×10) with scale=0.1 becomes 71.2 °C. A 4–20 mA channel
uses scale/offset to finish the span → psi. A read failure (wiring/Modbus fault) raises
IOReadError; an unmapped channel raises ChannelNotFound naming the config section to add. Neither
is faked — a bad read is a fault, and a fault fails safe.
Writing the relay
Section titled “Writing the relay”The control loop is the single writer of the output. It never gets bypassed:
def set_boiler_enable(self, enabled: bool) -> None: # HARDWARE: confirm polarity — ACTIVE must ENERGISE the interposing coil (enable firing). ...Remote commands (from an operator) do not write the relay directly. They mutate the control
config; the loop reads that config on its next tick and applies it — after checking the interlocks.
That is why the cloud can never force an unsafe output: the interlocks always get the last word. See
commands/processor.py and control/interlocks.py.
The control loop
Section titled “The control loop”control/loop.py is an explicit state machine. Two modes ship today; two are architected but fail-safe
until their control math lands:
| Mode | Status | Behavior |
|---|---|---|
monitor |
shipped | Agent observes; firing stays with the native aquastat. This is the safe state — heat available. |
manual |
shipped | Operator on/off, gated by interlocks. |
setpoint |
architected | Target a supply temperature. Control math is a marked TODO; fails safe until then. |
schedule |
architected | Weather-reset from outdoor temp. Same — TODO, fails safe. |
Each healthy tick the loop pets the watchdog (watchdog/). A missed pet — a hung or crashed loop —
forces the safe state and lets systemd restart the agent, which comes up safe. On the ModBerry the
software watchdog is backed by the hardware watchdog (/dev/watchdog) where configured.
Store-and-forward
Section titled “Store-and-forward”Telemetry is decoupled from the loop. The loop emits readings into a local SQLite outbox
(storage/buffer.py); a separate sender thread (telemetry/client.py) drains it to the backend on an
interval. If the post fails, the round stops and the rest stays buffered → ordered catch-up on
reconnect. The network can be down for hours; the loop neither knows nor cares.
Where to look in the tree
Section titled “Where to look in the tree”| You want | File |
|---|---|
| The hardware interface | agent/src/riser_agent/io/base.py |
| The ModBerry driver | agent/src/riser_agent/io/modberry.py |
| Config models + TOML loader | agent/src/riser_agent/config/models.py |
| The control loop | agent/src/riser_agent/control/loop.py |
| Interlocks + command validation | agent/src/riser_agent/control/interlocks.py |
| Watchdog | agent/src/riser_agent/watchdog/ |
| Store-and-forward buffer | agent/src/riser_agent/storage/buffer.py |
| Backend it talks to | backend/app/ — see Operator & admin panels |