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.
src/main.pts
Section titled “src/main.pts”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), exposesread(channel)that hides the config-register write/wait/read dance.signals.cooldown(fn, { key, coolingMs })— wraps the pump action so concurrent calls AND calls withincooldownMsof 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.
What’s reactive and what isn’t
Section titled “What’s reactive and what isn’t”signal tankRaw— periodic ADC read at 1 Hz. The only mutable cell in the tank chain.derived tankLevel+derived tankEmpty— read-only computations.tankEmptyrecomputes every timetankLevelchanges; nothing else has to track it.when tankEmpty start { ... }+ barewhen stop { ... }— the trailingstartmodifier on the first arm fires once at registration if the predicate is initially truthy AND on each false→true rise; the pairedwhen 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 startnotifies immediately; the bare-pairedwhen stop { }only fires on actual refills (no fake “back above empty” alert on a healthy boot).startandstopare symmetric — fire when the predicate starts being true / stops being true. The runtime handles the edge tracking — the alternative is hand-rollingwasEmptyplusif (empty && !wasEmpty).signals.cooldown(runPump, { key, coolingMs })— wraps the pump action so concurrent calls AND calls withincooldownMsof the previous run are silently dropped. ReturnsPromise<boolean>— true if it fired, false if it was suppressed. Thekeypartitions the cooldown per-plant;coolingMscan be a number or a function of the call arguments. Replaces the per-plantMap<name, { phase, lastWateredAt }>you’d otherwise hand-write.
Hardware
Section titled “Hardware”I²C bus 1 → ADS1115 @ 0x48 ch 0..2 → capacitive moisture sensors (one per plant) ch 3 → analog tank-level sensorGPIO chip pinctrl-rp1 (RP1, on Pi 5) BCM 17 / 27 / 22 → relay drivers for the three pumpsThe 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.
Run it
Section titled “Run it”On the Pi with hardware wired up:
parabun src/main.pjsThe 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 emptyWithout hardware
Section titled “Without hardware”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:
parabun demos/iot-waterer.pts --simulate --demo-empty --seconds 14Next steps
Section titled “Next steps”parabun:gpioandparabun:i2c— the hardware primitives@para/signals—signal/derived/when/effect- Edge example — the same
whenshape in a Cloudflare Durable Object