Skip to content

Multi-plant auto waterer

A real-shaped IoT controller: three potted plants with capacitive moisture sensors on an ADS1115 ADC, three pumps on GPIO relays, and a fourth ADC channel for a reservoir-level sensor. Each plant has its own state machine — ok → watering → cooldown → ok — and the tank coordinates all of them through when blocks that fire once on each empty/refilled transition rather than every tick.

This is the example that earns Para’s reactive primitives — multiple sensors feeding multiple actuators with cross-cutting safety state (the tank). The same code as plain JS would be either a pile of prevX flag tracking or an over-eager event bus.

The full source. Three Parabun helpers do the heavy lifting:

  • gpio.openDefaultChip() — opens RP1 on Pi 5 / falls through to gpiochip0 elsewhere; one line, no chip-discovery boilerplate.
  • i2c.ads1115("/dev/i2c-1") — opens the bus, binds the address (0x48 default), exposes read(channel) that hides the config-register write/wait/read dance.
  • signals.cooldown(fn, { key, coolingMs }) — wraps the pump action so concurrent calls AND calls within cooldownMs of the previous run are silently dropped. Replaces the per-plant ok/watering/cooldown phase tracking entirely.
import gpio from "parabun:gpio";
import i2c from "parabun:i2c";
import signals from "@para/signals";
import lifecycle from "@para/lifecycle";
const PLANTS = [
{ name: "basil", ch: 0, pin: 17, dryAt: 0.35, pumpMs: 1500, cooldownMs: 60_000 },
{ name: "fern", ch: 1, pin: 27, dryAt: 0.40, pumpMs: 2000, cooldownMs: 90_000 },
{ name: "succulent", ch: 2, pin: 22, dryAt: 0.18, pumpMs: 800, cooldownMs: 120_000 },
];
const TANK_CH = 3;
const ads = i2c.ads1115("/dev/i2c-1");
const chip = gpio.openDefaultChip();
const pumps = new Map(PLANTS.map(p => [p.pin, chip.line(p.pin, { mode: "out", initial: 0 })]));
// Capacitive moisture sensor: raw 26000 ≈ dry, 10000 ≈ wet. Calibrate yours.
const moisture = (raw: number) => Math.max(0, Math.min(1, (26000 - raw) / 16000));
// Tank state — three derived predicates and three edge-triggered alerts.
const tankRaw = signals.fromInterval(() => ads.read(TANK_CH), 1000);
derived tankLevel = moisture(tankRaw.signal.get() ?? 26000);
derived tankEmpty = tankLevel < 0.05;
// Paired form: trailing `start` catches boot-already-empty + each new
// empty event; bare `when stop { }` is the inverse-edge arm (always
// strict-edge, so a healthy boot doesn't fake a recovery alert).
when tankEmpty start { console.error("⚠️ tank EMPTY — pausing watering"); }
when stop { console.error("✓ tank back above empty"); }
// Per-plant pump, rate-limited by signals.cooldown.
const water = signals.cooldown(
async (p: typeof PLANTS[number]) => {
pumps.get(p.pin)!.write(1);
await Bun.sleep(p.pumpMs);
pumps.get(p.pin)!.write(0);
},
{ key: .name, coolingMs: .cooldownMs }, // .name is shorthand for `p => p.name`
);
// Once a second: read every plant; water any dry ones if the tank has water.
signals.fromInterval(async () => {
if (tankEmpty) return;
for (const p of PLANTS) {
if (moisture(await ads.read(p.ch)) < p.dryAt) await water(p);
}
}, 1000);
// Block until SIGINT/SIGTERM, then run any cleanup before exit.
// (fromInterval's timer is .unref()'d so without an explicit hold
// the script would exit immediately.)
await lifecycle.keepAlive({
onShutdown: () => { chip.close(); ads.close(); },
});

That’s the whole thing — ~30 lines of actual logic. The shipped demo wraps it with a status-row formatter (so you can watch it work), a --simulate flag for dev-box testing, and a --demo-empty switch that drains the simulated tank fast so the alert cycle fires within seconds.

  • signal tankRaw — periodic ADC read at 1 Hz. The only mutable cell in the tank chain.
  • derived tankLevel + derived tankEmpty — read-only computations. tankEmpty recomputes every time tankLevel changes; nothing else has to track it.
  • when tankEmpty start { ... } + bare when stop { ... } — the trailing start modifier on the first arm fires once at registration if the predicate is initially truthy AND on each false→true rise; the paired when stop { } arm is always strict-edge falling (no initial-truthy fire). Picking the right one matters for ops alerts: if you boot with the tank already empty, when tankEmpty start notifies immediately; the bare-paired when stop { } only fires on actual refills (no fake “back above empty” alert on a healthy boot). start and stop are symmetric — fire when the predicate starts being true / stops being true. The runtime handles the edge tracking — the alternative is hand-rolling wasEmpty plus if (empty && !wasEmpty).
  • signals.cooldown(runPump, { key, coolingMs }) — wraps the pump action so concurrent calls AND calls within cooldownMs of the previous run are silently dropped. Returns Promise<boolean> — true if it fired, false if it was suppressed. The key partitions the cooldown per-plant; coolingMs can be a number or a function of the call arguments. Replaces the per-plant Map<name, { phase, lastWateredAt }> you’d otherwise hand-write.
I²C bus 1 → ADS1115 @ 0x48
ch 0..2 → capacitive moisture sensors (one per plant)
ch 3 → analog tank-level sensor
GPIO chip pinctrl-rp1 (RP1, on Pi 5)
BCM 17 / 27 / 22 → relay drivers for the three pumps

The default capacitive-moisture calibration assumes dry≈26000 / wet≈10000 raw; dunk + dry your sensors and adjust to taste. Use --i2c <bus> to override the default I²C bus number if your ADS1115 lives on something other than /dev/i2c-1.

On the Pi with hardware wired up:

Terminal window
parabun src/main.pjs

The script auto-discovers the RP1 GPIO chip and opens the ADS1115 on /dev/i2c-1. It runs silently until the tank crosses an alert edge:

⚠️ tank EMPTY — pausing watering
✓ tank back above empty

The shipped demo at demos/iot-waterer.pts wraps the same core with a status-row formatter and two flags for dev-box testing — --simulate swaps the I²C reads for a deterministic oscillator + slowly-draining tank, --demo-empty drains the tank fast (~12s to zero) so you can actually watch the when block edges fire:

Terminal window
parabun demos/iot-waterer.pts --simulate --demo-empty --seconds 14