Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions deploy.sh
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,23 @@ if [ ! -f "$APP_DIR/secrets.env" ]; then
exit 1
fi

# Notify Telegram directly on completion/failure (curl, not the app): when this
# script is triggered by the /deploy bot command (garden/bot.py), it restarts
# the very process that queued it, so that process can't reliably report back.
set -a
# shellcheck source=/dev/null
source "$APP_DIR/secrets.env"
set +a

_notify() {
[ -n "${TELEGRAM_BOT_TOKEN:-}" ] && [ -n "${TELEGRAM_CHAT_ID:-}" ] || return 0
curl -sf -X POST "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendMessage" \
--data-urlencode "chat_id=${TELEGRAM_CHAT_ID}" \
--data-urlencode "text=$1" \
> /dev/null || true
}
trap '_notify "🌱 Garden: Deploy FAILED — check deploy.log / journalctl -u garden-agent on the VM."' ERR

# uv installs to ~/.local/bin; non-login shells may not have it on PATH
# shellcheck source=/dev/null
[ -f "$HOME/.local/bin/env" ] && source "$HOME/.local/bin/env"
Expand Down Expand Up @@ -94,3 +111,4 @@ sudo systemctl is-active --quiet ecowitt-bridge && echo " ecowitt-bridg

echo ""
echo "==> Deploy complete."
_notify "🌱 Garden: Deploy complete ✅ (garden-agent restarted)."
40 changes: 39 additions & 1 deletion docs/telegram.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ No further input needed — the Telegram code is already written.

---

## 5. Inbound bot commands (/bed1, /beds, /weather, /air, /brief)
## 5. Inbound bot commands (/bed1, /beds, /weather, /air, /brief, /deploy)

The bot can also answer commands on demand — tap `/bed4` in Telegram and get a
summary of Bed 4's moisture, battery, and crops back within a second. This needs
Expand Down Expand Up @@ -118,8 +118,40 @@ the message box) to see all commands, or type them directly:
| `/weather` | Today's forecast + current conditions |
| `/air` | VPD, dew point / frost risk, feels-like |
| `/brief` | Sends the morning brief immediately (bypasses the 7am schedule) |
| `/deploy` | Runs `deploy.sh` on the VM: git pull, restart services. See below. |
| `/help` | Lists all available commands |

### `/deploy`

Runs `deploy.sh` on the VM — the same script you'd otherwise SSH in and run by
hand after pushing to `main`. Because `deploy.sh` restarts `garden-agent`
itself partway through, `garden/bot.py` launches it as an independent
transient systemd unit (`systemd-run --unit=garden-deploy --collect`) instead
of a plain subprocess — a plain child process would share this service's
cgroup and get killed by that restart before finishing the remaining steps
(cron/backup timers, webhook re-registration, status summary). The bot
replies immediately ("Deploy started…"); `deploy.sh` itself posts a "Deploy
complete ✅" or "Deploy FAILED" message straight to Telegram (via `curl`,
using the same `TELEGRAM_BOT_TOKEN`/`TELEGRAM_CHAT_ID` from `secrets.env`)
once it finishes, since the process that queued it doesn't survive to report
back.

**One-time VM setup required**: `deploy.sh`'s `sudo` calls (`systemctl
restart`/`enable`/`reset-failed`, `tee` into `/etc/systemd/system/`) prompt
for a password when run interactively — fine over SSH, but there's no
terminal to answer that prompt when Telegram triggers it. Add passwordless
sudo for your deploy user (`sudo visudo -f /etc/sudoers.d/garden-deploy`):

```
your_vm_user ALL=(root) NOPASSWD: /usr/bin/systemctl, /usr/bin/tee, /usr/bin/systemd-run
```

**Security note**: this is a materially bigger capability than the other
commands — anyone who can message the authorized `TELEGRAM_CHAT_ID` can
trigger a `git pull` of whatever is on `main` plus a root-level systemd
restart. The existing owner-chat-id check in `garden/bot.py` is the only
gate, same as every other command; make sure that chat is one you trust.

### How it works

Telegram POSTs each command to `/api/telegram` on the running app (`garden/main.py`).
Expand All @@ -141,3 +173,9 @@ no separate data path to keep in sync.
```bash
curl -s "https://api.telegram.org/bot<TOKEN>/getWebhookInfo" | python3 -m json.tool
```
- `/deploy` replied but nothing happened / no completion message: check
`sudo journalctl -u garden-deploy -n 100` for the transient unit's own
output, and `cat deploy.log` in the project directory (deploy.sh's stdout/
stderr are appended there when launched this way). A "Failed to start the
deploy" reply means `sudo systemd-run` itself couldn't launch — usually the
passwordless-sudo entry above is missing.
19 changes: 14 additions & 5 deletions garden/agent/rules.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,14 +35,21 @@ def _iso_now() -> str:

def _bed_dry_threshold(bed: dict, fallback: float) -> float:
"""
The moisture % below which `bed` is considered dry: its self-learned band
minimum when there's enough history, else its crop-derived band minimum,
else `fallback` (the flat config.yaml threshold).
The moisture % below which `bed` is actually considered dry: its band
minimum (self-learned when there's enough history, else crop-derived)
minus derived.near_dry_margin(), else `fallback` (the flat config.yaml
threshold) when there's no band at all.

Mirrors garden.main._effective_bed_band so the "time to water" alert
agrees with the dashboard's Dry/OK/Wet chip for the same bed — without
this, a bed with unusually loose or compacted soil could show "Dry" on
the dashboard while never tripping (or always tripping) this alert.
the dashboard while never tripping (or always tripping) this alert. The
margin subtraction matters too: the dashboard's "Soil moisture vs. band"
chart only labels a bed "Dry" once it's past that margin below band
min — anything closer is "Drying" (still effectively OK). Without the
same margin here, this alert fired on every dip into "Drying" territory,
re-arming every cooldown window even for a bed sitting 1-2 points under
its threshold.
"""
moist_key = bed.get("sensors", {}).get("soil_moisture")
plants = bed.get("plants", [])
Expand All @@ -66,7 +73,9 @@ def _bed_dry_threshold(bed: dict, fallback: float) -> float:
else:
band = derived.bed_moisture_band(plants, cfg.crops)

return band[0] if band else fallback
if band is None:
return fallback
return band[0] - derived.near_dry_margin(band)


def check_soil_moisture_low() -> list[RuleResult]:
Expand Down
53 changes: 52 additions & 1 deletion garden/bot.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
"""
bot.py — Inbound Telegram bot commands (/bed1, /beds, /weather, /air, /brief, /help).
bot.py — Inbound Telegram bot commands (/bed1, /beds, /weather, /air, /brief, /deploy, /help).

Counterpart to telegram.py (outbound-only). Telegram delivers each command as a
webhook POST to /api/telegram (see garden/main.py), which calls handle_update()
Expand All @@ -21,7 +21,9 @@
from __future__ import annotations

import logging
import subprocess
from datetime import datetime, timezone
from pathlib import Path
from typing import Any

import httpx
Expand All @@ -33,6 +35,9 @@

log = logging.getLogger("garden.bot")

_APP_DIR = Path(__file__).resolve().parent.parent # garden-agent/ (holds deploy.sh)
_DEPLOY_LOG = _APP_DIR / "deploy.log"

_STATUS_WORD = {
"ok": "Good", "dry": "Dry", "wet": "Wet",
"cold": "Cold", "heat": "Heat stress", "unknown": "Unknown",
Expand All @@ -43,6 +48,7 @@
("weather", "Current conditions + today's forecast"),
("air", "VPD, dew point, feels-like"),
("brief", "Send the morning brief now"),
("deploy", "Pull latest code and restart services"),
("help", "List all commands"),
]

Expand Down Expand Up @@ -185,6 +191,49 @@ def _air() -> str:
return "\n".join(lines)


def _deploy() -> str:
"""
Kick off deploy.sh (git pull, dep sync, service restarts) and reply
immediately — the run itself takes tens of seconds and restarts this very
process, so it can't run inline and report back the normal way.

Launched via `systemd-run` as its own transient unit rather than a plain
subprocess: deploy.sh's job includes `sudo systemctl restart garden-agent`,
and a plain child process would share this service's cgroup and get
killed by that restart before finishing the remaining steps (cron/backup
timers, webhook re-registration, status summary). `--collect` drops the
transient unit once it exits instead of leaving it around as "failed".
deploy.sh sends its own completion/failure message to Telegram directly
(see deploy.sh) since this process may not survive to do it.

Requires passwordless sudo for `systemd-run` (and, as before, for the
systemctl/tee calls inside deploy.sh) — see docs/telegram.md.
"""
try:
subprocess.Popen(
[
"sudo", "systemd-run",
"--unit=garden-deploy",
"--collect",
"--description=garden-agent deploy (via /deploy)",
"--working-directory", str(_APP_DIR),
"bash", "-c", f"bash deploy.sh >> {_DEPLOY_LOG} 2>&1",
],
cwd=_APP_DIR,
stdin=subprocess.DEVNULL,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
except Exception:
log.exception("Failed to launch deploy.sh")
return "Failed to start the deploy — check garden-agent logs on the VM."
return (
"\U0001f680 Deploy started (git pull, restart services). "
"This restarts garden-agent, so the bot will be briefly unreachable. "
"You'll get a message here when it's done."
)


def _help() -> str:
lines = ["<b>Available commands</b>"]
for cmd, desc in _bed_commands() + _STATIC_COMMANDS:
Expand All @@ -209,6 +258,8 @@ def dispatch(command: str) -> str:
from garden.agent.runner import send_daily_brief
send_daily_brief(force=True)
return "Morning brief sent."
if command == "deploy":
return _deploy()
return _help()


Expand Down
5 changes: 4 additions & 1 deletion garden/dashboard/static/js/garden.js
Original file line number Diff line number Diff line change
Expand Up @@ -2044,7 +2044,10 @@ function renderBedChips(insightBeds) {
════════════════════════════════════════════════════════════════════════════ */

/** How far below band.min still counts as "drying" rather than a hard "dry".
* Fraction of band width, floored so tight bands still get a usable margin. */
* Fraction of band width, floored so tight bands still get a usable margin.
* Mirrored in Python as derived.near_dry_margin() (garden/derived.py), used
* by the "Low soil moisture" Telegram alert so it agrees with this chart on
* what "Dry" means. Keep these two formulas in sync. */
function _nearDryMargin(band) {
const width = Math.max(0, band.max - band.min);
return Math.max(2, width * 0.25);
Expand Down
11 changes: 11 additions & 0 deletions garden/derived.py
Original file line number Diff line number Diff line change
Expand Up @@ -340,6 +340,17 @@ def _percentile(values: list[float], pct: float) -> float:
return ordered[lo] + (ordered[hi] - ordered[lo]) * frac


def near_dry_margin(band: tuple[float, float]) -> float:
"""
How far below band[0] (min) still counts as merely "drying" rather than
truly dry — mirrors _nearDryMargin in garden/dashboard/static/js/garden.js
so the Telegram alert and the "Soil moisture vs. band" chart agree on what
"Dry" means for a given bed. Keep these two formulas in sync.
"""
width = max(0.0, band[1] - band[0])
return max(2.0, width * 0.25)


def effective_moisture_band(
values: list[float],
plants: list[str],
Expand Down
31 changes: 29 additions & 2 deletions tests/test_bot.py
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,33 @@ def test_dispatch_air_with_readings(monkeypatch):
assert "2.38" in reply


# ── dispatch: /deploy ──────────────────────────────────────────────────────────

def test_dispatch_deploy_launches_systemd_run(monkeypatch):
calls = []
monkeypatch.setattr(bot.subprocess, "Popen", lambda args, **kw: calls.append((args, kw)))

reply = bot.dispatch("deploy")

assert len(calls) == 1
args, kwargs = calls[0]
assert args[:3] == ["sudo", "systemd-run", "--unit=garden-deploy"]
assert "--collect" in args
assert any("deploy.sh" in a for a in args)
assert kwargs["stdin"] is bot.subprocess.DEVNULL
assert "Deploy started" in reply


def test_dispatch_deploy_failure_to_launch_is_reported(monkeypatch):
def _boom(args, **kw):
raise OSError("sudo not found")

monkeypatch.setattr(bot.subprocess, "Popen", _boom)

reply = bot.dispatch("deploy")
assert "Failed to start" in reply


# ── dispatch: /help fallback ───────────────────────────────────────────────────

def test_dispatch_help_lists_bed_and_static_commands():
Expand All @@ -105,8 +132,8 @@ def test_command_menu_has_one_entry_per_bed_plus_static():
commands = {m["command"] for m in menu}
n_beds = len(cfg.dashboard.get("beds", []))
assert {"bed1", "bed2", "bed3", "bed4"} <= commands
assert {"beds", "weather", "air", "brief", "help"} <= commands
assert len(menu) == n_beds + 5
assert {"beds", "weather", "air", "brief", "deploy", "help"} <= commands
assert len(menu) == n_beds + 6


# ── handle_update: owner-only guard + parsing ─────────────────────────────────
Expand Down
9 changes: 6 additions & 3 deletions tests/test_rules.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,11 @@ def test_falls_back_to_flat_threshold_without_history(self):
threshold = _bed_dry_threshold(BED1_LOOSE, fallback=30.0)
# No history and no recognised-crop band mismatch here: falls back to
# the crop-derived band (tomato min 50), not the flat fallback,
# because bed_moisture_band still resolves without any samples.
assert threshold == 50.0
# because bed_moisture_band still resolves without any samples. The
# returned threshold is the band min minus its near-dry margin
# (band (50, 80) -> width 30 -> margin max(2, 30*0.25)=7.5 -> 42.5),
# matching the dashboard's "Dry" cutoff rather than the raw band edge.
assert threshold == 42.5

def test_learns_a_lower_threshold_for_loose_soil(self):
series_rows = [{"value": v} for v in _sawtooth(35, 58)]
Expand All @@ -60,7 +63,7 @@ def test_disabled_learning_uses_crop_band(self):
threshold = _bed_dry_threshold(BED1_LOOSE, fallback=30.0)
finally:
cfg.thresholds["moisture_learning"] = original
assert threshold == 50.0 # crop band min, learning bypassed
assert threshold == 42.5 # crop band min (50) minus near-dry margin (7.5), learning bypassed


class TestCheckSoilMoistureLow:
Expand Down
Loading