ADR 0025: Accessory sensor nodes
Draft. This page describes intended behaviour that has not been verified on a real Gardyn yet.
Status: Proposed Date: 2026-11-05
Context
Taking a Gardyn apart showed that its Pi Zero is soldered to the carrier board. ADR-0018 and the Adding a probe guide assume you can reach the Pi’s 3.3 V, GND, SDA and SCL. On this unit that means soldering onto a board that sits next to the water, and then routing wires out through the housing.
The probes don’t need to be on that Pi. Light, pump, water level, button and cameras are wired to the carrier and stay there, but an EZO pH circuit only needs a bus and a network. ADR-0004 already separates hardware from core, and ADR-0007 is network-transparent. What’s missing is a way for two daemons to appear as one unit, and four things stand in the way today:
haldcan’t run sensors-only.Config::actuators()falls back to light and pump when no[[actuator]]table is present, so a bare Pi would claim GPIO18 and GPIO24 and advertise a pump it doesn’t have.- Core binds to one unit.
createHaltakes oneHAL_UNIT_ID. hald reference says core discovers units over mDNS, but core has no_seedypod-hal._tcpbrowser. MqttHalClientkeeps the last value of every sensor when a daemon goes offline.sensor/<id>isn’t retained, so nothing replaces it. That’s tolerable when the daemon and core share a Pi. It isn’t for a box on Wi-Fi.- The broker listens on 127.0.0.1:1883 by default. Only
--mqtt-lanopens port 1884, and its single user ishomeassistant.
Decision
An accessory is a second hald that has only sensors and names its parent
unit. It speaks the existing contract to the parent’s broker. Core merges it
into the parent behind HalClient, so nothing above that interface changes.
First use: one Atlas EZO-pH circuit.
The node
- A new top-level
hal.tomlkey,role = "unit" | "accessory", defaulting tounit. A new key,parent, holds the parent’sunit-idand is required when the role isaccessory. role = "accessory"builds no stock devices: no stock sensors, actuators or cameras.hald --checkrejects[[actuator]],[[camera]]and[dosing] enabled = trueunder this role, and rejects an accessory with no[[sensor]].- Probes are declared in the accessory’s
hal.toml. They’re built-in parts of that box, which is what ADR-0018 keepshal.tomlfor.
role = "accessory"
parent = "gardyn-abc123"
mqtt_url = "mqtt://seedypod.local:1884"
mqtt_username = "accessory"
mqtt_password = "…"
[[sensor]]
kind = "ezo_ph"
addr = 0x63The contract
SystemInfo gains optional role and parent, the same additive way board
was added. The contract version stays 1: an older core ignores both fields and
an older hald omits them. No topics change. Fixtures come first, per
AGENTS.md.
Core
- Core subscribes to
seedypod/+/system. A retainedsystemwhoseparentequalsHAL_UNIT_IDcreates anMqttHalClientfor that unit on the existing connection. Accessories bind at runtime with no change to core’s config, so this doesn’t need the mDNS browser. Correct the line in the hald reference when this lands. CompositeHal implements HalClientwraps the parent and its accessories, andcreateHalreturns it.SimHalstays as it is.
HalClient surface | Behaviour |
|---|---|
snapshot().sensors, caps.sensors | Union of the parent and online accessories |
snapshot().devices | Concatenated; accessory probes are source: config, so the app offers no Remove |
snapshot().online, system, actuators, alerts | The parent only |
calibrate(id) | Routed to the node that owns id |
setActuator, acknowledgeAlert, captureStill, addSensor, removeSensor | The parent |
availability and system events from an accessory | Not forwarded; they update the merge |
SensorIdstays unique per unit. If two nodes report the same id, the parent wins and the accessory’s copy is dropped with a warning.addSensoron the parent refuses a quantity an accessory already provides, extending the ADR-0018 guard across nodes.- An offline accessory leaves the snapshot. When its
availabilitygoesoffline, through the LWT or otherwise, its sensors are removed from the merge and its inventory rows reportabsent. A stale pH must not keep showing as current or feed Autopilot. Readings return with the next publish.
The broker
The parent needs --mqtt-lan. Accessories get their own user in the password
file instead of reusing homeassistant, so one can be revoked alone. With
Home Assistant’s broker (--no-broker), both daemons point at it and nothing
else changes. Port 1884 is plain TCP, so readings and that password travel the
LAN in the clear, which is the ADR-0012 trust model and the same exposure HA
has today.
Safety
Accessories are sensors-only, enforced by hald --check rather than by
convention. No guard on the parent depends on one: PumpGuard and the dry-run
interlock use the parent’s own water ranger, and nothing on the pump path reads
an accessory. Losing the node costs pH readings and nothing else.
Dosing is out of scope. ADR-0014 stays proposed, and dosing from a node would need a new decision: its interlocks would have to cross the network and fail safe when the parent is unreachable.
Hardware
A Raspberry Pi Zero 2 W, with an EZO-pH on an Atlas electrically isolated
carrier, in a small enclosure outside the unit and above the water line, on its
own USB supply. Only the probe’s lead goes into the reservoir. The Pi runs the
same hald build, so the EZO driver, the calibration flow and HA discovery are
reused with no new driver work. The isolated carrier isn’t strictly needed for
pH alone, but EC later would need it (implementation plan 6.2).
Buses
An accessory isn’t limited to I²C. It’s a separate machine, so it can use any bus Linux gives it, including USB. That has two effects:
- The node doesn’t have to be a Pi. A USB probe needs no GPIO, so any always-on Linux machine within reach of the reservoir can host it, which saves the Pi, SD card, supply and enclosure.
- More parts qualify. Atlas’s Gen 2 electrically isolated USB carrier presents an EZO as an FTDI serial port and costs about the same as the I²C one. A microcontroller on USB is the same case: a serial device with its own driver.
The first build stays on I²C, because that driver exists. USB needs:
- A
devicekey on[[sensor]], preferring a stable/dev/serial/by-id/…path.hald --checkrequires exactly one ofaddranddevice. - A transport split in
hal_drivers::ezo.Ezois bound toI2cBusand to I²C’s status-byte framing, while EZO UART uses CR-terminated ASCII with*OK/*ERreplies and continuous readings to switch off.parse_valueandcalibration_argsare already transport-independent. - For a non-Pi host: an x86_64 release target (
release.ymlbuilds only ARM andinstall-halrefuses anything else) anddialoutin the service’sSupplementaryGroups.haldopens I²C only when an I²C kind is built, so a serial-only node never touchesrppal. - An explicit
unit_idoff a Pi. Without a Pi serial,identity::unit_idfalls back togardyn-unknown, which two such nodes would share (upstream #130).hald --checkrejects that fallback under this role.
The contract doesn’t change: bus in the kind descriptor is already a string
and inventory rows already allow a null addr. DeviceCommand.addr stays I²C,
which is fine because app add and remove are parent-only.
Alternatives considered
- Wire the probe to the unit’s Pi. Rejected for the reasons in Context.
- Have the box call core’s tRPC API. Rejected. tRPC is core’s surface for clients. Writing through it would skip the contract, leave Home Assistant without the probe when core is down, and give core a second hardware path with its own calibration and inventory handling.
- ESP32 firmware speaking the contract. Sturdier (no SD card, instant boot)
and cheaper, but it means rewriting the EZO driver, calibration and contract
publishing in firmware for one probe. The contract is the seam, so an ESP32
can replace the Pi later without changes to core, as long as it sets its own
unit-idand publishesroleandparent. - Arduino. No network without an add-on board. Over USB or serial it needs a cable back into the unit, which is the problem being avoided.
- ESPHome straight into Home Assistant. Quick, but the reading never reaches core, so there’s no history, calibration wizard or Autopilot signal.
- Pair accessories in core’s config (
HAL_ACCESSORY_UNITS). Simple, but it means editing a service file on the parent and restarting core. The accessory already has to be configured with the parent’s broker, soparentgoes in the same file.
Consequences
Positive
- Probes can be added to a unit without opening it, including units whose Pi can’t be reached.
- pH appears on the home screen, in history, in Autopilot and in calibration through the existing paths, with no UI changes.
- The node works with Home Assistant alone, like any
hald. It shows up as its own HA device; setvia_deviceto link it to the parent.
Negative
- A second Pi and SD card to install, update and power.
install-halcovers the install, but nothing updates accessories from the parent. - ADR-0021 backups run on the parent only. The EZO stores its own calibration,
so rebuilding the box means reflashing and restoring one
hal.toml. - pH history has gaps whenever Wi-Fi or the parent’s broker is down.
- App add and remove stay parent-only. Changing the accessory’s probes means
editing its
hal.toml.HalClient.addSensorwould need a target node to change that, which isn’t worth it for one fixed probe.
Not addressed
- Temperature compensation. The driver has
set_temperature_compensation, buthaldnever calls it, so the circuit stays at its default temperature wherever it’s mounted. It’s a separate fix, and on an accessory the water temperature would have to come from an RTD on the same node.
Unverified
Nothing here has run on real hardware:
- A route for the probe’s lead into the reservoir, with the lid closed and the bulb clear of the pump inlet.
- Whether the running pump puts noise on the pH reading.
- Wi-Fi reliability at the box’s location.
Implementation order
- Fixtures, then
roleandparentincontract.rsandcontract.ts. hald: the role, its--checkrules, and no stock devices.- Core:
CompositeHal, runtime binding, offline removal. pnpm test:sim-bringupwith a secondhald --simas the accessory: pH appears, calibrates, and leaves the snapshot when the process is killed.install-hal: an accessory mode and a broker user. Update the Adding a probe guide and the hald reference.- Build the box, then mark this ADR accepted.
- When a node needs it: the serial transport and non-Pi packaging from Buses.
Sources: ADR 0004: Two tiers — a hardware daemon and a core · ADR 0007: HAL contract v1 · ADR 0012: Deployment and trust model · ADR 0014: Auto-dosing safety · ADR 0018: Runtime sensor inventory · hald reference · Atlas Gen 2 Electrically Isolated USB EZO Carrier Board
Imported from docs/adr/0025-accessory-sensor-nodes.md in seedypod/core. Edit it there, not here.