12 Deploy and Monitor

12 Deploy and Monitor

πŸ“Έ Web UI screenshots coming once Phase 7 ships the Flask app. The ASCII mockups below show the layout; the cron + status sections are already live.

Calibration done; the script knows when to water. The last step is handing it off to cron so it runs on a schedule without you babysitting the terminal β€” and then optionally bringing it up under a small Flask web UI for at-a-glance monitoring from your phone. Cron is the source of truth for scheduling; the Flask UI is just a window onto the running system. Both are LAN-only; both are optional past the cron schedule. Most people stop after cron + SSH. That’s a complete, working build.

The cron schedule

Page 09’s setup-pi.sh already installed two cron entries β€” a 7am check and a 6pm check. Open the crontab with crontab -e to see them, or run crontab -l to print them without opening the editor. The two lines look like this:

0 7 * * * ~/irrigation/run.sh >> ~/irrigation/logs/cron.log 2>&1
0 18 * * * ~/irrigation/run.sh >> ~/irrigation/logs/cron.log 2>&1

Each scheduled run executes ~/irrigation/run.sh (the venv-activator wrapper from page 09 that calls python3 irrigate.py), redirecting both stdout and stderr to the cron log at ~/irrigation/logs/cron.log. The script’s per-zone JSON log lives separately at ~/irrigation/logs/irrigation.json β€” that’s the structured one status.py reads.

What does 0 7 * * * mean? Cron’s five fields are minute, hour, day-of-month, month, day-of-week. 0 7 * * * reads as β€œminute 0, hour 7, every day, every month, every weekday” β€” 7am sharp every day. 0 18 * * * is the same thing at 6pm. The * means β€œany value.” If you want the full reference, man 5 crontab on the Pi has it.

Optional: midday check during Seattle’s dry season

In July and August, Seattle gets enough sun + heat that twice-a-day watering can fall behind. Add a third cron line for a midday check:

# Uncomment in July/August for a midday check
# 0 12 * * * ~/irrigation/run.sh >> ~/irrigation/logs/cron.log 2>&1

To edit the crontab, run crontab -e from the Pi’s SSH session, paste the line in, save, and exit. Comment it back out in September when temperatures drop. The script is moisture-driven, not time-driven β€” adding a midday slot doesn’t water more often if soil is fine; it just gives the script another chance to notice if a zone went dry between 7am and 6pm.

The status script

status.py lives at ~/irrigation/status.py (page 09’s setup-pi.sh installed it alongside the irrigation script). Run it anytime you want a quick snapshot of what the system has been doing and what the sensors are reading right now:

cd ~/irrigation
source venv/bin/activate
python3 status.py

The output prints two blocks. The first is recent activity β€” the last 10 entries from irrigation.json, each line showing timestamp, zone, action (watered or skip), moisture reading, and how many cycles ran. The second block is current sensor readings β€” a fresh ADC read per zone, averaged over 5 samples, with a DRY / OK / WET label based on the same thresholds the irrigation script uses. If a zone shows ERROR, the sensor isn’t responding (loose wire most likely; the Stuck? sidebar below points at the diagnostic).

Remote check from your laptop

You don’t have to SSH into the Pi and re-activate the venv every time you want a status check. Run the whole thing as a single SSH command from your laptop:

ssh [email protected] 'cd ~/irrigation && source venv/bin/activate && python3 status.py'

That gives you the same two-block output, printed straight back to your laptop terminal. If irrigator.local doesn’t resolve (your phone or laptop doesn’t speak mDNS), fall back to the Pi’s IP address β€” page 09’s mDNS callout walks the lookup. macOS, modern Linux, and most home routers handle .local natively; older Windows machines sometimes don’t. If you’re on iOS/Android, an SSH client like Termux or Termius will resolve irrigator.local correctly when on the same WiFi.

Skip or stay? If β€œSSH from another device whenever I want a status check” is enough for you, you’re done with this page. The cron schedule keeps the irrigation running; status.py over SSH gives you visibility on demand. Novice readers: skip ahead to page 13 β†’. The Flask UI section below is for readers who want a phone-friendly status page on the LAN β€” it’s nicer, but not required for a complete build.

Optional: the Flask web UI

For at-a-glance status from any phone or tablet on your home WiFi, run a small Flask app on the Pi that exposes a status page at http://irrigator.local:8080/. It reads the moisture sensors + the log tail every page load and renders a single HTML page that meta-refreshes every 30 seconds. No JavaScript required. There’s a manual-water override behind a query-string key, but it’s LAN-only β€” never port-forward this to the public internet (the security caveat below covers why). The screenshots aren’t ready yet (Phase 7 ships the actual app); the ASCII mockups below show the layout the implementation is targeting.

The home page (GET /)

When you visit http://irrigator.local:8080/ from your phone, the home page shows current moisture per zone with a DRY / OK / WET label, the last few log entries, and the next scheduled cron run. In the shipped UI each zone is a coloured panel β€” DRY is pink, OK is green, WET is blue β€” so the status is legible at arm’s length without reading the label. The ASCII bars in the mockup below are an aspirational visual only; the actual rendering uses the coloured-panel treatment.

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ Irrigator                           [auto-refresh]  β”‚
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
β”‚                                                     β”‚
β”‚  Zones                                              β”‚
β”‚  ─────                                              β”‚
β”‚   lettuce      14820   β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–‘β–‘β–‘β–‘  OK             β”‚
β”‚   tomatoes     21340   β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆ DRY            β”‚
β”‚   raised_bed   11200   β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–‘β–‘β–‘β–‘β–‘β–‘β–‘  WET            β”‚
β”‚                                                     β”‚
β”‚  Recent activity                                    β”‚
β”‚  ───────────────                                    β”‚
β”‚   2026-04-26 18:00   tomatoes    watered            β”‚
β”‚   2026-04-26 18:00   lettuce     skip               β”‚
β”‚   2026-04-26 07:00   raised_bed  watered            β”‚
β”‚   2026-04-26 07:00   tomatoes    watered            β”‚
β”‚                                                     β”‚
β”‚  Next run: 2026-04-27 07:00                         β”‚
β”‚                                                     β”‚
β”‚  [Manual water form below β€” when ?key= is valid]    β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

The page meta-refreshes every 30s β€” leave it open on a phone propped on the kitchen counter and you’ve got a live moisture display. No JS, no app to install; just a bookmark.

The manual-water override (POST /water)

When you arrive with a valid ?key= in the URL, the home page renders an inline form below the recent-activity log that lets you trigger a single zone for N seconds without waiting for the next cron run. Useful for β€œI just transplanted the lettuce and want to give it a drink right now” or β€œI’m bench-testing a new sensor and want to confirm the valve still fires.”

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ Manual water                                        β”‚
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
β”‚                                                     β”‚
β”‚  Zone:    [ tomatoes  β–Ύ ]                           β”‚
β”‚  Seconds: [ 30 ]  (1–120)                           β”‚
β”‚                                                     β”‚
β”‚  [Run zone]                                         β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

The form’s own action URL embeds the ?key= you arrived with β€” there’s no typed Key field on the form itself; the key travels as a query-string parameter on every submit. The form posts zone + seconds to /water?key=$IRRIGATOR_KEY. The Flask app validates the key, caps seconds at 120 (both client- and server-side β€” the form’s max attribute and a server-side check), and shells out to the existing manual_water.py to open the valve and run the pump for the requested duration. Without a valid key, the request is rejected with a 403; with one, the relevant zone fires immediately. Without ?key= in the URL the form simply doesn’t render β€” the home page shows a one-liner reminder to append the key to the URL. The 120s cap is the same safety limit manual_water.py already enforces from the command line β€” if you want longer, you’d have to edit the script and accept the risk of leaving a pump running.

Code listing + service unit

Phase 7 ships irrigator-web.py (the Flask app) and irrigator-web.service (a systemd unit that binds it to 0.0.0.0:8080 and restarts on failure). The service sources IRRIGATOR_KEY from /etc/default/irrigator-web so each install can have a unique key without baking it into source. The four existing scripts (irrigate.py, calibrate.py, status.py, manual_water.py) plus irrigator-web.py will all live at static/projects/balcony-irrigation/scripts/ for direct download β€” single-page checkout, no git clone needed.

Security caveat: LAN only. Do NOT port-forward port 8080 to the public internet. The shared ?key= is enough deterrent for casual nosy neighbors on your home WiFi; it is NOT enough to expose this anywhere a port-scanner can find it. The Flask app runs as a regular user with GPIO access, which means an attacker with the key can fire your pump on demand β€” annoying at worst on a balcony, catastrophic if the same pattern shows up on a system controlling a sprinkler valve indoors. If you want off-LAN access, see Tailscale below β€” never punch a hole in your router for this app.

Off-LAN access (advanced)

Tailscale is the right answer for off-LAN access without exposing port 8080 to the internet. Install Tailscale on the Pi (curl -fsSL https://tailscale.com/install.sh | sh, then sudo tailscale up), install Tailscale on your laptop or phone, and the Pi’s MagicDNS hostname (e.g., irrigator-pi.your-tailnet.ts.net) becomes reachable from anywhere you’ve authed Tailscale. http://irrigator-pi.your-tailnet.ts.net:8080/ works from any of those devices. WireGuard mesh-VPN under the hood; no port-forward, no public exposure, no NAT punch-through to debug.

Home Assistant integration via MQTT publishing is a v2 idea (REQUIREMENTS.md WEB-UI-V2-01); not in this build. The hook would be a small MQTT publish call from the irrigation loop that emits per-zone moisture + last-water-time so HA can plot it alongside the rest of your home sensors.

Build complete. Page 13’s diagnostic flowchart is your reference for when something stops working β€” bookmark it.