Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
1da1ef6
Irrigation controller: Irrigation button, daily rain delay and batter…
claude Oct 8, 2026
271031b
Typed irrigation works like its button; fix the empty axis on rolling…
claude Oct 8, 2026
ac4de88
Irrigation battery button reads 'Battery alerts on/off'
claude Oct 8, 2026
55c3625
Irrigation: On and Off move into the Water menu; the Switch menu is gone
claude Oct 8, 2026
28fccfa
Irrigation: Pause schedule menu with bare periods, 'state' in the sta…
claude Oct 8, 2026
da70b68
Irrigation menu: no On button, Pause timer / unpause, Enable / Disabl…
claude Oct 8, 2026
33ea31b
Irrigation status: state, mode, battery with alerts in brackets; wait…
claude Oct 8, 2026
060746e
Irrigation: one more second before redrawing after a Water button
claude Oct 8, 2026
cc66607
Irrigation status: time until state off, pause line, new order; every…
claude Oct 8, 2026
4467f93
Irrigation: tell the subscribed chats when the daily check pauses the…
claude Oct 8, 2026
f48e82e
Irrigation: a Refresh button re-reads the controller
claude Oct 8, 2026
5e99f3c
Irrigation: every user may press the buttons, in groups too
claude Oct 8, 2026
d60356e
Irrigation: an Alerts menu with battery and pause alert toggles; the …
claude Oct 8, 2026
88b7eba
Irrigation status: always every line, in the order mode, state, pause…
claude Oct 8, 2026
8e40516
Irrigation: refuse a Water press while the timer is paused, with a to…
claude Oct 8, 2026
23363a3
Irrigation: the Pause button becomes a one-press Unpause while paused…
claude Oct 8, 2026
654949c
Irrigation status: 'pause: <duration>' or 'pause: not paused', each w…
claude Oct 8, 2026
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
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ Under systemd see `ecobot.service` (it loads the environment file; the clone's l
| `ALERT_COOLDOWN_MINUTES` | optional: the rain and temperature-crossing alerts: how long it must stay dry before "the rain has stopped" is sent, and the least time between alerts of the same kind (default 30, from 5 to 150). A change that comes inside the cooldown is held and announced when it ends, if the state still differs from the last one alerted; if it is back where it was, nothing is sent |
| `RAIN_QUIET_HOURS` | optional: local hours with no rain alerts (started, stopped, predicted), as `0-6` (the default; `22-6` wraps midnight; `off` for none). Rain that fell in them is summed up in one message once they end |
| `CHART_ALL_FEELS_LIKE=on` | optional: "weather all week" (every reading, a panel each, sent with just its title; UV is left out as it has the same shape as solar) also draws the feels-like panel; off by default, as it nearly repeats temperature. Naming either ("plot feels like", "plot solar and uv") always works |
| `TUYA_DEVICE_ID`, `TUYA_CLIENT_ID`, `TUYA_CLIENT_SECRET` | enables the irrigation controller (a Tuya smart irrigation timer, through the Tuya cloud API): an Irrigation button in private chats (it replaces Wind) with its status and buttons to water for 5 to 30 minutes or switch the valve off, pause for 24 to 72 hours (the Pause button becomes Unpause while paused), refresh, and an Alerts menu to turn the chat's irrigation battery and irrigation pause alerts on or off (or type irrigation, water, tap or sprinkler); a daily check at 18:00 that delays it 24 hours when more than 1 mm fell in the last 24 hours or Open-Meteo gives today or tomorrow a 50% or better chance of 1 mm (needs Ecowitt and `FORECAST=on` for each half); an "irrigation pause" alert when it does, and an "irrigation battery" alert below 5%. `TUYA_BASE_URL` (default `https://openapi.tuyaeu.com`, the EU data centre) and `IRRIGATION_CHECK_HOUR` (default `18`) are optional |
| `CHART_ALL_VPD=on` | optional: "weather all week" also draws the vapour pressure deficit panel; off by default, as it is temperature and humidity combined (the station reports it for the outdoor sensor only). Naming it ("plot vpd") always works |

## Layout
Expand All @@ -54,7 +55,8 @@ lib/captions.py when a chart is drawn, and the hint that makes the model
lib/ecowitt/ api, store (SQLite + memory), fetch, extremes, query (one history question), link (weather_link),
days (rank/count days), outlook (raining / likely soon), station, nightly archive
lib/airgradient/ metrics, store (SQLite), source
lib/alerts/ notify (chats, silent send), weather, air, pollen
lib/alerts/ notify (chats, silent send), weather, air, pollen, irrigation (the daily check)
lib/irrigation/ the optional Tuya irrigation controller: tuya (signed API client), menu (status text and buttons), watchdog (closes the valve after a run)
lib/pollen/, forecast/ the optional website sources: pollen + thunderstorm asthma, Open-Meteo forecast (fetched only 6 am-6 pm, see warm.py SYNC_HOURS)
tests/ pytest, against fake Ecowitt/AirGradient/Telegram; tests/evals holds the routing cases, tests/charts the chart reference images
scripts/ ecowitt_metrics.py (which metrics the station reports), cache_status.py (is everything cached?), eval_prompts.py, show_request.py (exactly what is
Expand All @@ -71,7 +73,7 @@ Both are off by default. They are websites, not APIs (the pollen page is parsed)

## How it stays fast and cheap

- Private chats also get a persistent button keyboard (`lib/templates.py`): tapping one sends its label as a message (a 3x3 grid: Help & Alerts = what the bot can do, then the alert settings, Rain, Status; Weather = every reading, a panel each, Temperature, Humidity; Air Quality = every air metric, Particulates = PM1, PM2.5 and PM10 on one chart, Wind; every chart button is the last day). `/keyboard` shows it, `/keyboard off` hides it. When the buttons change, the bot says "Buttons updated." with the new keyboard to each private chat at its next start, and any chat missed gets it with its next reply (the bot remembers which version each chat has; a chat that hid it keeps it hidden). Typed "weather now" shows every reading the station has. Weather lines of 31 days or fewer are drawn with no shaded min–max range (except wind's gusts; AirGradient charts keep theirs); longer periods are daily with the range. Pressure, CO₂, VOC, NOx, solar, wind and VPD also show their latest value beside the line (not when it is zero). A single-panel chart's caption is one line ("Temperature, since Tue 7 Jul 2026" when the period runs up to today, else "Temperature, Wed 24 – Tue 30 Sep 2026"); a chart of several panels has its title and then its time range on a second line; any answer text is a message of its own, sent just before the chart. A single chart carries period buttons under it (1D, 7D, 1M, 3M and 1Y, the chart's own marked with ●; pressing it does nothing): pressing one redraws that chart in place for the new period (`lib/periods.py`, `Bot.on_period_button`). The bot keeps the question behind its last 300 charts in its state file, so the buttons keep working across restarts; an older chart answers "out of date, please ask again". A reply with period buttons doesn't carry the persistent keyboard; that follows on the next reply. A group of several charts has no buttons. Charts drawn in the last 10 minutes are kept (the newest 60), so toggling between periods brings a chart back instantly without a new question. Any reading or "weather" with a period of two days or more ("humidity 7d", "weather 30d", "particulates 7d") is a chart, sent with no text.
- Private chats also get a persistent button keyboard (`lib/templates.py`): tapping one sends its label as a message (a 3x3 grid: Help & Alerts = what the bot can do, then the alert settings, Status, Rain; Weather = every reading, a panel each, Temperature, Humidity; Air Quality = every air metric, Particulates = PM1, PM2.5 and PM10 on one chart, Wind, which the Irrigation button replaces when the controller is configured; every chart button is the last day). `/keyboard` shows it, `/keyboard off` hides it. When the buttons change, the bot says "Buttons updated." with the new keyboard to each private chat at its next start, and any chat missed gets it with its next reply (the bot remembers which version each chat has; a chat that hid it keeps it hidden). Typed "weather now" shows every reading the station has. Weather lines of 31 days or fewer are drawn with no shaded min–max range (except wind's gusts; AirGradient charts keep theirs); longer periods are daily with the range. Pressure, CO₂, VOC, NOx, solar, wind and VPD also show their latest value beside the line (not when it is zero). A single-panel chart's caption is one line ("Temperature, since Tue 7 Jul 2026" when the period runs up to today, else "Temperature, Wed 24 – Tue 30 Sep 2026"); a chart of several panels has its title and then its time range on a second line; any answer text is a message of its own, sent just before the chart. A single chart carries period buttons under it (1D, 7D, 1M, 3M and 1Y, the chart's own marked with ●; pressing it does nothing): pressing one redraws that chart in place for the new period (`lib/periods.py`, `Bot.on_period_button`). The bot keeps the question behind its last 300 charts in its state file, so the buttons keep working across restarts; an older chart answers "out of date, please ask again". A reply with period buttons doesn't carry the persistent keyboard; that follows on the next reply. A group of several charts has no buttons. Charts drawn in the last 10 minutes are kept (the newest 60), so toggling between periods brings a chart back instantly without a new question. Any reading or "weather" with a period of two days or more ("humidity 7d", "weather 30d", "particulates 7d") is a chart, sent with no text.
- In private chats the bot shows Telegram's "Thinking..." draft (sendMessageDraft, re-sent every 20 s, as drafts expire after 30) and streams the answer into it before sending the real message; groups show "typing...". A "message generation stopped" update is only logged.
- The model only reasons for predictions ("will it rain?") and "describe it" questions, or when asked to think, try, reason, predict, estimate, grind or whirl.
- Simple highs/lows, chart and "air quality now" questions are fetched by the bot first, so the
Expand Down
16 changes: 13 additions & 3 deletions ecobot.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,14 @@

from lib import intent
from lib.airgradient import AirGradient
from lib.alerts import AIR_CHECK_SECONDS, AirMonitor, AlertState, ForecastMonitor, Notifier, PollenMonitor, WeatherMonitor
from lib.alerts import AIR_CHECK_SECONDS, AirMonitor, AlertState, ForecastMonitor, IrrigationMonitor, Notifier, PollenMonitor, WeatherMonitor
from lib.alerts.menu import available_kinds
from lib.bot import Bot, polling_error
from lib.config import ROOT, Config
from lib.ecowitt import Archive, Ecowitt
from lib.forecast import Forecast
from lib.irrigation import Tuya
from lib.irrigation.watchdog import CHECK_SECONDS as WATCHDOG_SECONDS
from lib.llm import Agent
from lib.pollen import Pollen
from lib.compose import Composer
Expand Down Expand Up @@ -82,7 +84,8 @@ async def main():
tools = Tools([t for s in sources for t in s.tools] + (composer.tools if composer else []))
agent = Agent(AsyncOpenAI(api_key=cfg.xai_api_key, base_url=cfg.xai_base_url), cfg.xai_model, tools)
state = AlertState(cfg.state_path)
bot = Bot(cfg, agent, sources, state)
irrigation = Tuya(cfg) if cfg.irrigation else None
bot = Bot(cfg, agent, sources, state, irrigation)

app = (Application.builder().token(cfg.telegram_token).concurrent_updates(True)
.defaults(Defaults(disable_notification=True)) # silent messages
Expand All @@ -105,7 +108,7 @@ async def main():
try: # any startup failure below still runs the shutdown steps (and shows the real error)
log.info("@%s + %s (sources: %s)", app.bot.username, cfg.xai_model,
", ".join(s.name for s in sources))
notify = Notifier(app.bot, state, available_kinds({s.name for s in sources}))
notify = Notifier(app.bot, state, available_kinds({s.name for s in sources} | ({"Irrigation"} if irrigation else set())))
await bot.refresh_keyboards(app.bot) # chats whose buttons are out of date are told, with the new ones

# Alerts
Expand All @@ -123,6 +126,11 @@ async def main():
if pollen:
pollen.warmer.after.append(PollenMonitor(pollen, state, notify).check) # after each refresh (in the day)
kinds += ["pollen", "asthma"]
if irrigation: # the daily check (rain delay, battery) and the watchdog that closes the valve after a run
tasks.append(asyncio.create_task(IrrigationMonitor(irrigation, state, notify, cfg.tz, cfg.irrigation_check_hour,
eco, forecast).run()))
tasks.append(asyncio.create_task(every(WATCHDOG_SECONDS, bot.watchdog.check)))
kinds.append("irrigation battery")
log.info("Alerts: %s: %d chats", ", ".join(kinds) or "none", len(state.alert_chats()))

# Keeping warm: everything questions need, refreshed before they arrive
Expand Down Expand Up @@ -177,6 +185,8 @@ async def air_backfill():
finally:
for source in sources:
await source.close()
if irrigation:
await irrigation.close()


if __name__ == "__main__":
Expand Down
2 changes: 1 addition & 1 deletion ecobot.service
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
# WorkingDirectory line: %h is the home directory of the User below, so a clone at ~/ecobot needs no editing, and any other
# place needs only that line. The bot's code and data files (caches, state) are found relative to its own location.
# The .venv is built with Python 3.13.5 (.python-version): python3.13 -m venv .venv && .venv/bin/pip install -r requirements.txt
# The bot never opens the environment file itself: systemd loads it (TZ, XAI_*, ECOWITT_*, AIRGRADIENT_*,
# The bot never opens the environment file itself: systemd loads it (TZ, XAI_*, ECOWITT_*, AIRGRADIENT_*, TUYA_*,
# TELEGRAM_BOT_TOKEN, ...).
[Unit]
Description=ecobot (Telegram weather and air-quality bot)
Expand Down
3 changes: 2 additions & 1 deletion lib/alerts/__init__.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
from .air import CHECK_SECONDS as AIR_CHECK_SECONDS, AirMonitor
from .forecast import ForecastMonitor
from .irrigation import IrrigationMonitor
from .notify import AlertState, Notifier, with_footer
from .pollen import PollenMonitor
from .weather import WeatherMonitor

__all__ = ["AIR_CHECK_SECONDS", "AirMonitor", "AlertState", "ForecastMonitor", "Notifier", "PollenMonitor", "WeatherMonitor", "with_footer"]
__all__ = ["AIR_CHECK_SECONDS", "AirMonitor", "AlertState", "ForecastMonitor", "IrrigationMonitor", "Notifier", "PollenMonitor", "WeatherMonitor", "with_footer"]
106 changes: 106 additions & 0 deletions lib/alerts/irrigation.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
"""The irrigation controller, checked once a day (18:00 local by default).

- a battery below 5% is told to the chats subscribed to "irrigation battery" (at most once a day, until it is charged);
- if more than 1 mm of rain fell in the last 24 hours, or Open-Meteo gives today or tomorrow a 50% or better chance of at least
1 mm of rain, the controller is put on a 24 hour weather delay (it skips its own schedule) and the chats subscribed to "irrigation
pause" are told why. A longer delay already set is left alone (nothing changes, so nothing is sent).

The bot has no scheduler, so `run` sleeps until the next check time. If the controller can't be reached it tries again every 30 minutes
until it works or the day ends. The date of the last completed check is saved, so a restart neither repeats a day nor skips one.
"""

import asyncio
import logging
from datetime import datetime, timedelta

from ..ecowitt.outlook import rain_amount
from ..timeutil import now_local
from .forecast import RAIN_FROM_PCT

log = logging.getLogger(__name__)

BATTERY_LOW_PCT = 5
MEASURED_MM = 1.0 # more than this in the last 24 hours counts as rain
RETRY_SECONDS = 30 * 60
MEASURED_HOURS = 24


class IrrigationMonitor:
def __init__(self, device, state, notify, tz, hour: int = 18, station=None, forecast=None):
"""station: the Ecowitt source (its recent() gives the measured rain); forecast: the Forecast source. Either may be None."""
self.device, self.state, self.notify, self.tz, self.hour = device, state, notify, tz, hour
self.station, self.forecast = station, forecast

def _memory(self) -> dict:
return self.state.monitor.setdefault("irrigation", {})

def due(self, now: datetime) -> bool:
"""Is today's check still to be done (it is past the check time and none is recorded for today)?"""
return now.hour >= self.hour and self._memory().get("checked") != now.date().isoformat()

def seconds_until_next(self, now: datetime) -> float:
target = now.replace(hour=self.hour, minute=0, second=0, microsecond=0)
if target <= now:
target += timedelta(days=1)
return max(1.0, (target - now).total_seconds())

async def run(self):
"""Forever: check when due, else sleep until the next check time."""
while True:
now = now_local(self.tz)
if self.due(now):
try:
await self.check(now)
except asyncio.CancelledError:
raise
except Exception as e:
log.warning("Irrigation check failed (%s: %s); trying again in %d minutes", type(e).__name__, e, RETRY_SECONDS // 60)
await asyncio.sleep(min(RETRY_SECONDS, self.seconds_until_next(now)))
continue
now = now_local(self.tz)
await asyncio.sleep(self.seconds_until_next(now))

async def check(self, now: datetime | None = None):
"""One check: the battery, then the rain. Raises when the controller can't be reached (nothing is recorded then)."""
now = now or now_local(self.tz)
today = now.date().isoformat()
status = await self.device.status()
memory = self._memory()
battery = status.get("battery_percentage")
if battery is not None and battery < BATTERY_LOW_PCT and memory.get("battery_warned") != today:
memory["battery_warned"] = today
self.state.save()
await self.notify(f"🪫 The irrigation battery is at {battery}%. Replace or recharge it soon, or watering will stop.",
kind="irrigation")
reason = await self.rain_reason()
if reason:
current = status.get("weather_delay")
if current in (None, "cancel"):
await self.device.delay("24h")
log.info("Irrigation: delayed 24 hours (%s)", reason)
await self.notify(f"☔ Irrigation paused for 24 hours: {reason}.", kind="irrigation_pause")
else:
log.info("Irrigation: %s, but the delay is already %s", reason, current)
else:
log.info("Irrigation: no rain measured or forecast; the schedule stands")
memory["checked"] = today
self.state.save()

async def rain_reason(self) -> str | None:
"""Why the irrigation should be delayed (what rained or will), or None."""
if self.station is not None:
try:
rows = await self.station.recent(MEASURED_HOURS)
fell = rain_amount(rows, rows[0][0], rows[-1][0]) if rows else None
except Exception as e: # the forecast can still decide
log.warning("Irrigation: the measured rain is unavailable (%s: %s)", type(e).__name__, e)
fell = None
if fell is not None and fell > MEASURED_MM:
return f"{fell:g} mm of rain in the last 24 hours"
if self.forecast is not None:
today = self.forecast.now().date()
for day in self.forecast.upcoming(2):
chance = day.get("rain_1mm_pct")
if chance is not None and chance >= RAIN_FROM_PCT:
return f"{chance}% chance of at least 1 mm {'today' if day['date'] == today else 'tomorrow'}"
return None
5 changes: 3 additions & 2 deletions lib/alerts/menu.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@

# kind -> the name on the button, in the order listed
LABELS = {"rain": "rain", "rain_likely": "rain predicted", "gusts": "gusts", "uv": "UV", "temps": "temperature crossing",
"air": "air quality", "pollen": "pollen & asthma", "forecast": "forecast changes"}
"air": "air quality", "pollen": "pollen & asthma", "forecast": "forecast changes",
"irrigation": "irrigation battery", "irrigation_pause": "irrigation pause"}
ALL = "all"
TITLE = "🔔 Alerts in this chat"
PER_ROW = 2
Expand All @@ -25,7 +26,7 @@ def available_kinds(sources: set[str]) -> list[str]:
"""The alert types the bot can send, from the names of its sources."""
wanted = (["rain", "rain_likely", "gusts", "uv", "temps"] if "Ecowitt" in sources else []) + (
["air"] if "AirGradient" in sources else []) + (["pollen"] if "Pollen" in sources else []) + (
["forecast"] if "Forecast" in sources else [])
["forecast"] if "Forecast" in sources else []) + (["irrigation", "irrigation_pause"] if "Irrigation" in sources else [])
return wanted


Expand Down
Loading
Loading