Snehal Patel

Snehal Patel

I love to build things ✨

A Self-Hosted Garden Sensor Agent with Ecowitt, Claude, and Telegram on GCP

My wife asked me a simple question: “When should I water the vegetable beds?” A normal person would have said “check if the soil feels dry about an inch down.” I am not a normal person. I know a thing or two about IoT sensors, LLMs, and agents. So naturally, I spent the next two weekends building a complete monitoring pipeline so that neither of us would ever have to go outside and touch grass again.

They say “touch grass” like it’s good advice. I say: why touch it when a $20 soil probe can touch it for you and text you the results?

The system: an Ecowitt GW1200 weather station pushes soil and air readings every 60 seconds to a FastAPI app on a free GCP VM, deterministic threshold rules catch anything that needs attention, and Claude writes a Telegram message telling us what to do about it. Every morning at 7am we get a brief: weather, bed status, watering plan, unprompted. My wife now gets better answers than “check if it feels dry,” and I get to feel justified about the whole thing.

The LLM is the smallest piece. It doesn’t decide whether to send an alert; a Python threshold check does that. Claude only writes the message. Keeping it in that box is the point.


What I Built

The code lives at spate141/garden-agent on GitHub. The live deployment is at garden.snehal.ai. Clone and start in three commands:

git clone https://github.com/spate141/garden-agent.git garden-agent
cd garden-agent && uv sync
cp secrets.env.example secrets.env  # fill in 5 values, then run deploy.sh

Three sensors and a gateway

Three devices handle distinct jobs.

Device Role
GW1200 The hub. Receives signals over 915 MHz RF from all sensors, connects to your WiFi, and pushes data to your server. Also has a built-in indoor temp, humidity, and barometric pressure sensor.
WH51 Soil moisture + battery voltage per probe. Stakes into each garden bed. Uses 2× AA batteries.
WN31 Multi-channel temp/humidity. Not rated for outdoor use; works in a greenhouse, shed, or near seedlings.

GW1200 gateway with WH51 soil probe and WN31 sensor

The three-piece kit: GW1200 gateway (center - small), WH51 soil probe (left/right), WN31 temp/humidity sensor (center - big).

WiFi gotcha: the GW1200 requires 2.4 GHz. It will not connect to a 5 GHz network even if your phone sees the SSID.

Pairing the WH51s: use the official WSView Plus app (not the older “WSView”). Tap your GW1200 → Sensor List → +, then hold the small button on the back of each WH51 until the LED flashes rapidly. The gateway assigns channels in order: ch1 maps to soilmoisture1/soilbatt1 in your DB, ch2 to soilmoisture2/soilbatt2. Do one at a time so you know which channel is which bed. Rename immediately in the app. Push the probe into soil at a ~45° angle, root-zone depth (~4–6 inches).

WH51 soil moisture probe staked into Bed 1
Bed 1: WH51 staked at ~45°, probe tip at root depth (~5 inches).
WH51 soil moisture probe staked into Bed 2
Bed 2: same setup. Antenna sits just above the soil surface.

WN31 dip switches: three switches on the back select the broadcast channel. All OFF = channel 1, which reports as temp1_f/humidity1. Set them before inserting batteries.

Pointing the gateway at your server: in WSView Plus, tap your GW1200 → gear icon → Customized Server. Fill in:

Field Value
Protocol Ecowitt (not Wunderground)
Server IP / Hostname your VM’s public IP
Path /api/ecowitt
Port 8080
Upload Interval 60 seconds

A note on the PASSKEY: the GW1200B doesn’t let you set a custom PASSKEY in the Customized Server UI — it generates one internally (derived from the device MAC) and sends it with every POST. Your server needs to match it, not the other way around. The discovery flow:

  1. Point the device at your server and save.
  2. Watch the service logs — the first POST will be rejected with a log line like:
    Rejected POST: bad PASSKEY (got 'ABC123...', want '...')
    
  3. Copy the got value into secrets.env as INGEST_PASSKEY, then sudo systemctl restart garden-agent.

After saving, wait 60–90 seconds and check the health endpoint on your own deployment:

curl https://your.domain.com/health
# {"status":"ok","sensors_seen":6,"last_reading_ts":"2026-06-28T14:03:11+00:00"}

If sensors_seen stays at 0, check the service logs on your VM:

sudo journalctl -u garden-agent -n 30
# "401 Invalid PASSKEY" → run the discovery flow above
# "Parsed snapshot ts=..., N metrics" → data is flowing

Once data is flowing, the WSView Plus app shows live readings from every paired sensor. This is also a useful sanity check before trusting the automated alerts.

WSView Plus app showing live soil moisture and battery readings for both garden beds

WSView Plus dashboard after pairing. Both WH51 probes reporting, channels renamed to Bed 1 and Bed 2.


What the build actually costs

Stack: GCP e2-micro (Always Free tier), Ubuntu 22.04, Python 3.12 + uv, FastAPI, SQLite, Claude Sonnet 4.6, Cloudflare Tunnel, Telegram bot.
Hardware: Ecowitt GW1200 gateway (~$60), WH51 soil probes (~$20 each), WN31 temp/humidity sensor (~$25).

The hardware is where the money goes. A GW1200 plus one WH51 soil probe gets you started; additional probes add ~$20 each. Everything else is free: the VM, the Cloudflare Tunnel, Telegram, and the Anthropic API spend (160 tokens per alert, 350 for the morning brief, a handful of calls on a busy day). You can run this for months for the price of the sensors.


How a soil reading becomes a text message

Two triggers feed one path to Telegram.

Every ~60 seconds the GW1200 POSTs a batch of sensor readings to the FastAPI endpoint. That POST is processed inline before the HTTP response returns: readings go to SQLite, threshold rules run, and if any rule fires, Claude writes the alert text and it goes straight to Telegram.

Every 15 minutes a systemd timer runs a cron tick. That tick checks watchdogs (is each sensor still reporting? has the gateway gone silent?), handles the morning brief schedule, and can catch conditions the live path missed.

SENSOR POST · ~60 s GW1200 POST /api/ecowitt Ecowitt protocol · PASSKEY auth parse · normalize store → SQLite readings threshold rule fires? NO → drop YES state: clear + past cooldown? NO → suppress YES Claude: write_alert() 2–4 sentences · prose only · ~160 tokens set alert_state = active Telegram: sendMessage alert title + Claude body TIMER TICK · every 15 min systemd timer fires run_cron_tick() watchdog: each sensor reported in last 30 min? 7am window + not sent today? NO → exit YES Open-Meteo forecast + latest reading per sensor Claude: write_daily_brief() 4 sections · Telegram HTML · ≤350 tokens write dedup → alert_state Telegram: sendMessage morning brief

The key constraint is visible in the diagram: Claude is only called after evaluate_instant() has already decided to fire. The rules run regardless of LLM availability; if the Anthropic API is down, the deterministic fallback text goes to Telegram instead.


Standing up the VM behind a Cloudflare Tunnel

An e2-micro in us-central1 fits inside GCP’s Always Free tier: one instance per month, no charge. 20GB of standard disk is plenty. Ubuntu 22.04. Install uv for dependency management:

curl -LsSf https://astral.sh/uv/install.sh | sh
source $HOME/.local/bin/env

Clone the repo, install deps, copy secrets:

mkdir -p ~/apps && cd ~/apps
git clone https://github.com/spate141/garden-agent.git garden-agent
cd garden-agent
uv sync
cp secrets.env.example secrets.env && chmod 600 secrets.env

secrets.env takes five values:

INGEST_PASSKEY=<discover from device logs — see gateway setup section above>
TELEGRAM_BOT_TOKEN=<from @BotFather>
TELEGRAM_CHAT_ID=<your chat id>
ANTHROPIC_API_KEY=<from console.anthropic.com>
DB_PATH=garden.sqlite3

Plus optional location fields (GARDEN_ZIPCODE, GARDEN_TIMEZONE) for weather and the morning brief.

The FastAPI app binds to 127.0.0.1:8001, never exposed directly. Cloudflare Tunnel serves the dashboard over HTTPS — no open ports needed for browser traffic.

One exception: the Ecowitt gateway. The GW1200B’s embedded HTTP client speaks plain HTTP for custom server uploads and can’t complete Cloudflare’s TLS handshake. The fix is a small socat bridge that listens on port 8080 and forwards to the app. This port is locked at the GCP firewall level to your home IP only:

# Install socat and drop the bridge service from the repo
sudo apt-get install -y socat
sudo cp systemd/ecowitt-bridge.service /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable --now ecowitt-bridge

# Open port 8080 only to your home IP
gcloud compute firewall-rules create ecowitt \
  --project=YOUR_GCP_PROJECT \
  --direction=INGRESS --action=ALLOW \
  --rules=tcp:8080 \
  --source-ranges=$(curl -s https://api.ipify.org)/32

If your ISP rotates your home IP and uploads stop, update the rule: gcloud compute firewall-rules update ecowitt --source-ranges=NEW_IP/32. The PASSKEY provides a second auth layer at the app level, so a rotating IP is inconvenient but not a security hole.

Install and configure cloudflared:

# Install
curl -L https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-amd64 \
  -o /tmp/cloudflared && sudo install /tmp/cloudflared /usr/local/bin/cloudflared

# Authenticate + create tunnel
cloudflared tunnel login
cloudflared tunnel create garden          # note the TUNNEL_ID it prints
cloudflared tunnel route dns garden your.domain.com

# Write ~/.cloudflared/config.yml
tunnel: <TUNNEL_ID>
credentials-file: /home/<user>/.cloudflared/<TUNNEL_ID>.json
ingress:
  - hostname: your.domain.com
    service: http://localhost:8001
  - service: http_status:404

# Install as systemd service
sudo cloudflared service install
sudo systemctl enable --now cloudflared

The two systemd services that run the app are in the systemd/ directory. A deploy.sh in the repo root handles everything on subsequent deploys: git pull, uv sync, daemon-reload when unit files change, service restarts, and a health check.


Rules decide, the LLM only writes

Every threshold check follows the same pattern. Here is the soil moisture rule:

@dataclass
class RuleResult:
    rule_id: str      # e.g. "soil_moisture_low:soilmoisture1"
    sensor_key: str
    fired: bool
    title: str        # short Telegram title
    body: str         # plain-English detail (used as fallback if LLM unavailable)

def check_soil_moisture_low() -> list[RuleResult]:
    # reads config: below=30%, consecutive=3
    for key in cfg.thresholds["soil_moisture_low"]["sensor_keys"]:
        recent = storage.recent_values(key, consecutive)
        fired = len(recent) == consecutive and all(v < threshold for v in recent)
        ...

Rules are pure read functions: they query storage, produce a RuleResult, and return without side effects. The runner applies cooldowns and dedup on top; if a rule passes all those gates, only then is Claude called.

The LLM call in llm.py is a single stateless request-response. For a soil moisture alert, the system prompt is:

You are a concise garden monitoring assistant. A soil-moisture sensor is below
the watering threshold. Write a short, plain-English Telegram message (2-4
sentences) that:
1. States which bed needs water and the current moisture reading.
2. Estimates how many minutes to run a standard garden hose (~12 L/min flow)
   to recover the bed.
3. Adjusts advice based on weather: if meaningful rain is expected in the next
   few hours, suggest waiting; in a heatwave (>95°F), advise watering
   deeper/longer.
Do not use markdown, bullet points, or headers. Be specific and practical.

The user prompt gives it the last 6 readings for that sensor (3 hours of history), current outdoor temperature, today’s weather from Open-Meteo, and the deterministic fallback body as a fact reference. That is all the LLM sees. No lookups, no cross-sensor access, no ability to suppress the alert. Claude writes a message body; the runner sends it.

The threshold numbers live in config.yaml and are read by both the deterministic rules and the LLM context:

thresholds:
  soil_moisture_low:
    sensor_keys: [soilmoisture1, soilmoisture2]
    below: 30           # alert when moisture drops below 30%
    consecutive: 3      # must see 3 consecutive readings below threshold

  soil_moisture_rapid_drop:
    drop_pct: 15        # alert if moisture drops 15+ points in 60 minutes

  battery_low:
    below: 1.1          # WH51 nominal ~1.5V; warn below 1.1V

  temp_frost:
    below: 35.6         # frost warning below 35.6°F (2°C)

  temp_heat:
    above: 100.4        # heat stress above 100.4°F (38°C)

Killing the notification spam

My first version fired a Telegram notification on every single sensor POST that tripped a threshold. The GW1200 pushes every 60 seconds. A bed sitting at 22% moisture would generate 60 notifications per hour until I went outside and watered it. That’s spam.

You might reach for a rate-limit instruction in the system prompt. Don’t. A natural-language rate-limit is probabilistic; it’ll work most of the time, which makes the occasional miss more dangerous because you’ve stopped watching.

Two fields of deterministic state in an alert_state SQLite table fix it:

rule_id (PK) | sensor_key | active | last_fired_ts

Alert-once-until-cleared: when a rule fires and the condition is still active, the alert fires exactly once. It won’t fire again until the condition clears (bed gets watered, moisture rises above 30%), and then re-trips. Each new alert is a new event, not a repeat of the same one.

Cooldown window: even after a clear-and-re-trip, there’s a minimum gap before the next alert. Configured per rule type in config.yaml:

cooldowns:
  soil_moisture_low_minutes: 120    # 2 hours between soil alerts
  battery_low_minutes: 1440         # once per day for battery
  temp_frost_minutes: 60
  watchdog_minutes: 360

State persists in SQLite across restarts. If the service crashes and restarts mid-condition, it reads the existing active flag and doesn’t re-fire.

In any system with real consequences, guarantees belong in the deterministic code around the agent, not in its instructions.


The 7am brief

Every morning at 7am local time (checked against GARDEN_TIMEZONE in secrets.env), the 15-minute cron tick detects that it’s brief time and hasn’t sent one today, fetches a weather forecast from Open-Meteo (no API key required), pulls one current reading per sensor, and calls Claude to write a structured Telegram message:

☀️ Weather
High 89°F / low 64°F, partly cloudy. Rain 10%. Wind 8 mph.

🌱 Beds
Bed 1: 34% - Good
Bed 2: 27% - Thirsty

💧 Watering plan
Bed 2: run hose ~18 min (~12 L/min). Water before 9am to limit evaporation.

⚠️ Watch
WH51 ch2 battery at 1.08V - replace soon.

The system prompt instructs Claude to use Telegram HTML (<b>, <i>, <code> tags only, no markdown), produce exactly those four sections, and omit the Watch section entirely if there’s nothing to flag. max_tokens is set to 350 (the original 512 limit was leaving ~45% unused; 350 gives safe headroom without paying for tokens that never fire).

To force-send a brief immediately for testing:

uv run python -m garden.agent.runner --brief

If the Anthropic API is unavailable, it falls back to a plain-text version of the same data assembled deterministically. If Open-Meteo is unreachable, the brief still sends with “Weather: unavailable.” The dedup record in alert_state is written after a successful send, so subsequent cron ticks that same morning skip it.


What breaks on a free-tier box

The nightly backup job (02:00 UTC) copies the SQLite file locally and, if you’ve configured Cloudflare R2 credentials, pushes it offsite. Worst-case data loss on VM failure: one day of readings. R2’s free tier covers 10GB; garden data is a few MB per year.

# Restore onto a fresh VM after cloning + filling secrets.env:
bash scripts/restore_db.sh              # pulls garden-latest.sqlite3
bash scripts/restore_db.sh 20260625    # or a specific date

sudo systemctl restart garden-agent
curl -s localhost:8001/health           # sensors_seen should be > 0

What actually breaks on a free-tier e2-micro:

  • OOM: 1GB RAM is tight for Python + uv + FastAPI. A 2GB swap file helps: sudo fallocate -l 2G /swapfile && sudo chmod 600 /swapfile && sudo mkswap /swapfile && sudo swapon /swapfile. If you see systemctl status showing OOM kills, bump to e2-small (~$15/month).
  • Watchdog gaps: if the service crashes and misses sensor POSTs, the 15-minute cron tick catches it via the watchdog check and fires a Telegram alert.
  • Lapsed Anthropic key: the fallback text still goes out, but there’s no Telegram notice that the API is failing. Add a Telegram-based heartbeat for this if it matters to you.
  • Sensor range: the WH51 transmits at 915 MHz. Thick concrete walls or distance can kill the signal. The watchdog will fire a Telegram alert if any sensor stops reporting for more than 30 minutes.

The morning brief is itself a daily liveness check. If it doesn’t arrive, the VM is down, the service crashed, or the Telegram bot token lapsed. I’ve found this more useful than explicit heartbeat pings.


What I’d build next

The garden-agent code is on GitHub at spate141/garden-agent. The reusable part is the pattern: a deterministic rules engine with thresholds in config.yaml, a thin LLM prose layer that is only called after a rule fires, SQLite for persistence, and Telegram for delivery. The hardware is Ecowitt-specific, but the Ecowitt HTTP push protocol is documented and a handful of other gateway models use the same format. New sensor channels appear automatically as new sensor_key values in the readings table; no schema migration needed.

What I’d add next: trend analysis in the brief (is moisture trending down faster than last week? is this the third consecutive day above 95°F?). Right now the brief only sees the current snapshot per sensor, not the trajectory. Richer context in the LLM prompt would let it flag early instead of reacting.

The threshold that triggers a watering alert is 30% for three consecutive readings. That number came from a few weeks of watching the sensors and seeing what 30% looks like in the soil, not from anything principled. Calibrate yours against your beds and your soil type before trusting the alerts.

If you dial in better numbers for your soil type or find sensor ranges that work well in clay or raised beds, I’d like to know.