Skip to Content
DevelopersHAL contract v1

HAL contract v1

The HAL contract is the only interface between hald and core, and the interface Home Assistant and your own scripts can use directly. It is small on purpose, versioned, and discoverable. ADR 0007 is the decision; the hald reference has the full operational detail.

Conventions

  • MQTT topic prefix seedypod/<unit-id>/. The unit id comes from the Pi’s serial number (gardyn-<serial>), never a shared default.
  • Payloads are JSON with SI units and RFC 3339 timestamps. QoS 1 throughout.
  • The contract version is the contract field of system. It is currently 1.

Topics

TopicDirectionRetainedPayload
availabilityfrom haldyesonline, or offline (also the last will)
systemfrom haldyesSystemInfo
sensor/<id>from haldnoReading
actuator/<id>/statefrom haldyesActuatorState
actuator/<id>/setto hald—ActuatorCommand; hald clamps or refuses
event/buttonfrom haldno{"press": "single" | "double" | "long"}
alert/<kind>from haldyesAlert
alert/<kind>/ackto hald—any payload; clears a latched fault

HTTP on the daemon (port 3001 by default): GET /health, GET /system, GET /state, and GET /camera/<name>.jpg for a still from the upper or lower camera.

Identifiers

KindValues
Sensorsair_temp, humidity, pcb_temp, water_distance, water_level_pct, pump_v, pump_a, pump_w, ph, ec, water_temp
Actuatorslight, pump, dose_1, dose_2, dose_3
Alertswater_low, pump_fault, over_temp, camera_missing (core adds hal_offline)
Qualitygood, stale, suspect

Payloads

These are the golden fixtures in hal/contract/fixtures/. Both the Rust and TypeScript test suites must parse them identically.

SystemInfo, published on system:

{ "contract": 1, "unitId": "gardyn-1a2b3c4d", "model": "gardyn 3.0", "halVersion": "0.1.0", "caps": { "sensors": [ "air_temp", "humidity", "pcb_temp", "water_distance", "water_level_pct", "pump_v", "pump_a", "pump_w" ], "actuators": ["light", "pump"], "cameras": ["upper", "lower"] }, "limits": { "pumpMaxRunS": 900, "pumpMaxDuty": 30, "waterLowCm": 16 } }

Reading, published on sensor/<id> on change and at least every telemetry interval:

{ "ts": "2026-10-31T03:57:23Z", "value": 7.95, "unit": "cm", "quality": "good" }

ActuatorCommand, sent to actuator/<id>/set. Every field is optional; durationS makes the run bounded, and hald clamps it to its own maximum:

{ "on": true, "level": 100, "durationS": 300 }

ActuatorState, published on actuator/<id>/state. fault is a latched fault that only an acknowledge clears; remainingS counts down a bounded run. A refused command republishes the state with its fault:

{ "on": true, "level": 30, "since": "2026-10-31T03:57:23Z", "fault": null, "remainingS": 19 }

Note the command above asked for level 100 and got 30: the pump’s duty cap.

Alert, published on alert/<kind>:

{ "kind": "pump_fault", "active": true, "ts": "2026-10-31T03:57:23Z", "detail": "pump stopped: tank ran dry" }

Try it

mosquitto_sub -v -t 'seedypod/#' mosquitto_pub -t 'seedypod/gardyn-1a2b3c4d/actuator/light/set' -m '{"on":true,"level":60}'

A pump command sent this way still passes PumpGuard. There is no way around the guard through the contract, by design.

Changing the contract

The contract lives twice: hal/crates/hal-core/src/contract.rs and src/hardware/contract.ts. Change it by adding a fixture to hal/contract/fixtures/ first, then make both sides accept it. Adding a sensor (pH, EC, water temperature) is a new sensor id, a fixture and a driver, with no topic changes.


Source: hal/contract/ · src/hardware/contract.ts

Last updated on