Open source
PlantPulse
github.com/juanmmm21/PlantPulse
DIY plant monitoring: ESP32 sensor nodes, a free Supabase backend and Telegram alerts.
- Lines
- 2,281
- Files
- 18
- Test files
- 0
- Commits
- 24
- Started
- 12 Aug 2026
- Last commit
- 21 Oct 2026
- TypeScript 65.9%
- C++ 16%
- SQL 14.5%
- C 3.6%
- 01
A €15–25 node: capacitive soil probe, optional DHT22 and BH1750, all on an ESP32.
- 02
Deep sleep between readings every 30 minutes; the wireless variant runs for months on a LiPo.
- 03
Each plant carries its own thresholds — 'does it need water?' is a query, not a guess.
- 04
A scheduled function sends a Telegram alert when a plant crosses a line, plus a daily summary.
README · 6 min read
Commits per week
DIY plant monitoring: ESP32 sensor nodes that measure soil moisture, ambient conditions and light, publish every reading to a free Supabase backend, and message your phone over Telegram when a plant needs water or a node goes quiet.
What it is
PlantPulse turns any potted plant into something you can check remotely. A small, cheap (~15–25 €) sensor node sits in the pot: a capacitive soil moisture probe in the dirt, plus optional air temperature/humidity (DHT22) and light (BH1750) sensors, all wired to an ESP32. Every 30 minutes the node wakes from deep sleep, takes a reading, pushes it over HTTPS to a PostgreSQL database hosted on Supabase's free tier, and goes back to sleep.
Pots without a socket nearby get a fully wireless variant from the same firmware: a low-power ESP32 board, the soil probe and a small LiPo cell, running for months between charges and reporting its own battery voltage.
Because the data lives behind a hosted REST API rather than on your LAN, you can check on your plants from anywhere — no port forwarding, no VPN, no server of your own to maintain. Each plant carries its own thresholds in the database, so "does it need water?" is a query, not a guess.
You don't have to remember to ask, either. A scheduled function inside Supabase compares every plant against its own thresholds and sends a Telegram message when one crosses a line — too dry, too cold, too hot, too dark, a battery running low, or a node that stopped reporting — plus a daily summary of everything. There is no mobile app to install or publish: Telegram already knows how to put a notification on your phone.
How it works
┌─────────────────────────┐
│ Plant pot │
│ ┌───────────────────┐ │ HTTPS POST ┌──────────────────┐
│ │ ESP32 node │ │ (every 30 min) │ Supabase │
│ │ · soil moisture │──┼────────────────────────────▶│ PostgreSQL │
│ │ · DHT22 (opt.) │ │ /rest/v1/readings │ + PostgREST API │
│ │ · BH1750 (opt.) │ │ │ + RLS policies │
│ └───────────────────┘ │ └────────┬─────────┘
└─────────────────────────┘ │
│ REST
pg_cron ────┐ │
(every 15 min) │ │
▼ ▼
Edge Function any client, anywhere
`notify`
│
▼ Telegram Bot API
your phone
The node is fire-and-forget: all waits are bounded by explicit timeouts, and any failure (WiFi down, HTTP error, sensor glitch) is logged over serial and simply skipped until the next wake-up. Optional sensors that fail or aren't installed are reported as null, never as fake values.
Engineering notes
- Capacitive over resistive soil sensing — resistive probes corrode within weeks in wet soil; the capacitive probe has no exposed metal and stays accurate for years. Raw ADC values are averaged over 16 samples (the sensor output has significant ripple) and mapped through a per-unit two-point calibration (dry air / glass of water), because individual sensor units differ substantially.
- Deep sleep architecture — the whole duty cycle lives in
setup();loop()is intentionally empty since deep sleep resets the chip. This keeps power draw low and the firmware trivially restartable: every cycle starts from a clean state. - Battery nodes are a board choice, then a firmware budget — a stock ESP32 DevKit draws 4–15 mA in deep sleep (AMS1117 regulator, USB-UART bridge, power LED), which empties a LiPo in days no matter what the firmware does; battery nodes therefore use boards that sleep at ~15–25 µA (XIAO ESP32-C6, FireBeetle 2 ESP32-E). With the board fixed, awake time dominates, so the firmware powers the ~5 mA soil probe from a GPIO only while sampling, caches the access point's channel and BSSID in RTC memory to skip the WiFi scan on every wake, optionally skips DHCP with a static IP, and prints its awake time each cycle so the energy budget is measurable. Battery voltage is sampled at rest, before the radio sags it; below a cutoff (or after a brownout reset) the node stops using the radio and checks back every 12 h, recovering on its own once recharged.
- Serverless backend — Supabase provides PostgreSQL, an auto-generated REST API (PostgREST) and row-level security on the free tier. The schema ships as one idempotent SQL file; RLS limits the shipped
anonkey to inserting readings and reading data, nothing else. - Alerts are a state machine, not a cron that shouts — each condition opens exactly one row in
alertsand closes only once the value clears the threshold with margin (hysteresis), so a plant hovering at 30 % moisture doesn't message you twice an hour. Plants that like to dry out completely (snake plants, succulents) can also require the soil to stay dry for a set time — say two days at 0 % — before the watering alert fires. Sensor-based alerts freeze while a node is offline: a stale reading says nothing about the plant right now, so a dead node sends one notification instead of four. needs_wateris computed in the database — thelatest_readingsview joins each plant's latest reading with its own thresholds, so any client resolves the home-screen question ("which plants need attention?") with a single request.
Repository layout
PlantPulse/
├── firmware/plantpulse/
│ ├── plantpulse.ino # ESP32 sketch: wake → measure → publish → deep sleep
│ └── config.example.h # config template (copy to config.h, which is gitignored)
├── supabase/
│ ├── schema.sql # full schema: tables, views, alerts, RLS, cron jobs
│ └── functions/notify/ # Edge Function: evaluates alerts, sends Telegram
│ ├── logic.ts # pure alert evaluation (open / hold / resolve)
│ ├── messages.ts # pure message formatting
│ ├── db.ts # PostgREST access, no SDK
│ ├── telegram.ts # Bot API client
│ └── *_test.ts # unit tests for the pure modules
└── docs/ # step-by-step build guides (in Spanish)
├── 01-lista-de-compra.md # shopping list (~15–25 € per node)
├── 02-montaje-y-cableado.md # wiring, placement, calibration
├── 03-puesta-en-marcha.md # Supabase setup, flashing, verification
├── 04-notificaciones-telegram.md # bot, function deploy, thresholds
└── 05-nodos-a-bateria.md # wireless battery nodes: boards, wiring, battery life
Hardware
| Part | Role | Required |
|---|---|---|
| ESP32 DevKit (30- or 38-pin) | MCU + WiFi | yes |
| Capacitive soil moisture sensor v1.2/v2.0 | soil moisture (analog, GPIO 34) | yes |
| DHT22 / AM2302 module | air temperature + humidity (GPIO 27) | optional |
| BH1750 module | light level in lux (I2C, GPIO 25/26) | optional |
No soldering required. The 38-pin DevKit is exactly one inch wide, so on a breadboard it covers one whole header row — the default pins all sit on the reachable side. Full wiring tables, cable types and an ASCII schematic are in docs/02-montaje-y-cableado.md.
Wireless battery node (soil moisture only):
| Part | Role |
|---|---|
| Seeed Studio XIAO ESP32-C6 (or DFRobot FireBeetle 2 ESP32-E) | low-power MCU + WiFi, onboard LiPo charger |
| 1S LiPo, 500–1000 mAh, with protection circuit | power, recharged over USB-C in place |
| Capacitive soil moisture sensor, VCC on a GPIO | switched on only while sampling |
| 2 × 220 kΩ resistors (XIAO only) | voltage divider for battery monitoring |
Wiring, config.h presets per board, battery-life estimates and calibration are in docs/05-nodos-a-bateria.md.
Getting started
The detailed walkthrough (with expected outputs and a troubleshooting table) is docs/03-puesta-en-marcha.md. In short:
- Backend: create a free Supabase project, run
supabase/schema.sqlin the SQL Editor, insert a row intoplantsand note its UUID. - Toolchain: Arduino IDE 2 with the Espressif ESP32 board package, plus the DHT sensor library (Adafruit) and BH1750 (Christopher Laws) libraries.
- Firmware: copy
firmware/plantpulse/config.example.htoconfig.h, fill in WiFi credentials (2.4 GHz only), Supabase URL + anon key, the plant UUID and your calibration values, then flash with board ESP32 Dev Module. - Verify: watch the serial monitor at 115200 baud for
[http] 201: lectura registrada, then check thereadingstable in Supabase. - Notifications: create a Telegram bot with @BotFather, deploy the
notifyfunction with the Supabase CLI and store its URL and shared token in Vault. Full walkthrough indocs/04-notificaciones-telegram.md.
Data contract
readings insert payload (sent by the firmware):
{
"plant_id": "8f8e6c1a-…",
"soil_moisture_pct": 41.7,
"soil_moisture_raw": 2451,
"air_temp_c": 24.1,
"air_humidity_pct": 51.2,
"light_lux": 342.5
}
Battery-powered nodes add "battery_v": 3.96; mains-powered nodes omit the key entirely, so their firmware keeps working against a schema that predates the column.
latest_readings view (one row per plant — what any dashboard or script should read):
| column | meaning |
|---|---|
plant_id, name, species, location | plant identity |
soil_moisture_pct, air_temp_c, air_humidity_pct, light_lux | latest measurement (null = sensor absent/failed) |
moisture_min_pct, moisture_max_pct | per-plant comfort band |
needs_water | true when the latest moisture is below the plant's minimum |
created_at | timestamp of the latest reading |
Example query with curl:
curl "https://<project>.supabase.co/rest/v1/latest_readings?select=name,soil_moisture_pct,needs_water" \
-H "apikey: <anon-key>" -H "Authorization: Bearer <anon-key>"
Troubleshooting
[wifi] timeout— wrong credentials or a 5 GHz-only network; the ESP32 only joins 2.4 GHz.- HTTP
401— anon key mistyped or truncated (it is very long). - Moisture stuck at 0% or 100% — calibration values in
config.hdon't match your sensor unit; redo the two-point calibration. - Upload stuck at
Connecting....— hold the board's BOOT button until flashing starts.
More cases in docs/03-puesta-en-marcha.md.