Skip to content

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.

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 and make 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.

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. gpiod is imported lazily inside __init__, so io/ and the whole test suite run on a laptop. Construct the driver without the libraries or the board and it raises a clear DriverError — 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 offset
do0 = 17 # boiler_enable -> interposing relay
di0 = 27 # water_meter pulse
[io.modberry.io_serial] # onboard analog/RTD/TC module, internal Modbus RTU bus
port = "/dev/ttyS0"
baudrate = 9600
[io.modberry.analog.ai0] # supply_temp RTD, module reports deg C x10
unit_id = 1
register = 0
scale = 0.1

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 + offset

So 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.

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.

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.

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.

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