Irrigation
A distributed, Rust-powered IoT irrigation system with soil moisture telemetry, safety-first valve control, and a local web dashboard
Irrigation
A low-cost, distributed irrigation system built in Rust. Soil moisture telemetry, gravity-fed watering, and state-driven control logic — all running locally on Raspberry Pi hardware with no cloud dependency.
Built to manage a lot of plants without turning watering into a daily chore, and without the reliability and cost problems that come with commercial smart irrigation.
The Problem
Commercial smart irrigation systems are built for outdoor sprinkler zones. They assume municipal water pressure, uniform lawn layouts, and a cloud connection. None of that maps to a mixed indoor/outdoor setup with dozens of potted plants, drip lines, and a gravity-fed reservoir.
The specific issues:
- Cloud dependency — most systems require a vendor account and internet connection. If the API goes down, your plants don’t get watered.
- Cost at scale — commercial moisture sensors are $30-50 each. Scaling to dozens of plants gets expensive fast.
- No extensibility — closed firmware means you can’t add custom logic, integrate with other systems, or adapt to unusual setups.
- Reliability — consumer devices tend to lose connectivity, fail silently, or require frequent manual intervention.
Architecture
The system uses a hub-and-node architecture. A Raspberry Pi 5 acts as the central hub — it runs the MQTT broker, executes all irrigation control logic, drives valve relays via GPIO, persists data to SQLite, and serves the web dashboard. Sensor nodes (Pi Zeros) are stateless: they read soil moisture and publish telemetry over MQTT. Nodes never make watering decisions.
┌──────────────────────┐
│ Raspberry Pi 5 │
│ HUB │
│ │
│ MQTT Broker │
│ Irrigation Control │
│ Valve GPIO Driver │
│ Web Dashboard │
└─────────┬────────────┘
MQTT
┌──────────────┴──────────────┐
│ │
┌───────────────┐ ┌───────────────┐
│ Pi Zero Node │ │ Pi Zero Node │
│ (Sensors) │ │ (Sensors) │
└───────────────┘ └───────────────┘
↓
Gravity-fed water drum
↓
Zone valves → Plants
This split is intentional. Keeping control logic centralized in the hub means there’s exactly one place that decides when valves open. Sensor nodes are cheap, replaceable, and can fail without causing a flood.
The web dashboard is a Preact SPA bundled into the Rust binary via include_str! — no separate frontend deployment. The hub serves everything from a single process on port 8080.
Safety
Irrigation systems can cause real damage. An accidental valve-open can flood a room in minutes. Safety is a first-class design constraint, not an afterthought.
- Normally-closed valves — on power loss, valves close. The system fails safe by default.
- Hub-only actuation — sensors publish data; only the hub can open valves. No distributed control means no ambiguous state.
- Daily watering limits — each zone has caps on total open-seconds and pulse count per day. Even if the control logic has a bug, physical damage is bounded.
- Sensor staleness detection — if a sensor stops reporting, the hub flags the zone as stale and refuses to water. No data is treated as a fault, not a green light.
- Time-bounded pulses — every valve activation has a maximum duration. The hub enforces this regardless of what triggered the watering.
- All valves OFF on startup — the hub initializes every valve to closed before doing anything else.
Pulse and Soak
The system doesn’t just open a valve until soil is wet. It uses pulse-and-soak irrigation: when moisture drops below a threshold, the valve opens briefly (a pulse), then closes for a soak period while water absorbs into the soil. After the soak, moisture is re-evaluated. This prevents runoff, avoids sensor lag issues, and stops the valve from oscillating on and off.
Zone configuration controls the behavior:
[[zones]]
zone_id = "back-garden"
name = "Back Garden"
min_moisture = 0.25
target_moisture = 0.45
pulse_sec = 45
soak_min = 15
max_open_sec_per_day = 240
max_pulses_per_day = 8
valve_gpio_pin = 27
Sensors are mapped to zones with calibration values for converting raw ADC readings to moisture percentages:
[[sensors]]
sensor_id = "node-b/s1"
node_id = "node-b"
zone_id = "back-garden"
raw_dry = 26000
raw_wet = 12000
Tech Stack
| Layer | Technology |
|---|---|
| Runtime | Rust (tokio, axum, sqlx, rumqttc) |
| GPIO | rppal (feature-gated — mock in dev, real on Pi) |
| Database | SQLite (WAL mode) |
| Messaging | MQTT via Eclipse Mosquitto |
| Dashboard | Preact, Tailwind CSS 4, shadcn/ui, Recharts |
| Build | Cargo workspace, Vite, cross (ARM cross-compilation) |
| CI | GitHub Actions (fmt, clippy, test) |
Current Status
| Feature | Status |
|---|---|
| Hub-and-node architecture | Complete |
| MQTT telemetry pipeline | Complete |
| SQLite persistence | Complete |
| Web dashboard | Complete |
| GPIO valve control | Complete |
| Safety constraints | Complete |
| Zone/sensor configuration | Complete |
| Docker dev environment | Complete |
| ADS1115 real sensor reads | Planned |
| Moisture calibration workflow | Planned |
| Automatic watering triggers | Planned |
| ESP32 battery-powered nodes | Future |
| Weather integration | Future |
Learnings
- Safety-first design changes everything — starting from “what happens if this fails?” rather than “how do I make this work?” led to a fundamentally different architecture. Normally-closed valves, daily limits, and centralized control aren’t features — they’re the foundation everything else builds on.
- Feature-gated GPIO is essential for dev ergonomics — the
gpioCargo feature lets the entire system run on a Mac with mock GPIO. Without this, every code change would require deploying to a Pi to test. Most of the development happens without touching hardware. - Embedding the SPA in the binary is underrated —
include_str!means the dashboard ships as part of the Rust binary. No nginx, no static file serving, no CORS. One process, one binary, onescpto deploy. The tradeoff is rebuild time when the UI changes, but for a system like this it’s worth it.
More projects
View all projectsCodable SwiftData
A proof-of-concept for using SwiftData and the Codable protocol for working with JSON in offline-first iOS apps
PocketBase Swift SDK
A Swift SDK for PocketBase backend-as-a-service with authentication, CRUD operations, and realtime subscriptions
Flow Forecast
Time-series forecasting API for river flow rates using Facebook Prophet, integrated with GaugeWatcher