diff --git a/README.md b/README.md index b57f3e2..59ee76e 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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 @@ -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 diff --git a/ecobot.py b/ecobot.py index a6f99bb..0704a88 100644 --- a/ecobot.py +++ b/ecobot.py @@ -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 @@ -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 @@ -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 @@ -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 @@ -177,6 +185,8 @@ async def air_backfill(): finally: for source in sources: await source.close() + if irrigation: + await irrigation.close() if __name__ == "__main__": diff --git a/ecobot.service b/ecobot.service index faf9570..62cc7fc 100644 --- a/ecobot.service +++ b/ecobot.service @@ -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) diff --git a/lib/alerts/__init__.py b/lib/alerts/__init__.py index c91a5d9..1f19430 100644 --- a/lib/alerts/__init__.py +++ b/lib/alerts/__init__.py @@ -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"] diff --git a/lib/alerts/irrigation.py b/lib/alerts/irrigation.py new file mode 100644 index 0000000..bcddb6c --- /dev/null +++ b/lib/alerts/irrigation.py @@ -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 diff --git a/lib/alerts/menu.py b/lib/alerts/menu.py index a2a7941..13fde7d 100644 --- a/lib/alerts/menu.py +++ b/lib/alerts/menu.py @@ -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 @@ -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 diff --git a/lib/bot.py b/lib/bot.py index e13ff4e..25ad903 100644 --- a/lib/bot.py +++ b/lib/bot.py @@ -23,6 +23,8 @@ from . import intent, periods, prompt, report, templates from .alerts import menu from .alerts import AlertState, with_footer +from .irrigation import IrrigationError, Watchdog +from .irrigation import menu as irrigation_menu from .charts import render as render_chart from .tools import Turn from .config import Config @@ -38,6 +40,7 @@ TURN_SECONDS = 90 # a question that takes longer is given up on, so the ones queued behind it in the chat are not stuck DRAFT_REFRESH_SECONDS = 20 # Telegram drops a draft 30 s after its last update, so it is re-sent before that DRAFT_MIN_GAP = 1.0 # at most one draft update a second while the answer streams in +IRRIGATION_SETTLE_SECONDS = 3 # after any irrigation action, the controller is given this long to report its new state before it is read and the message redrawn WATCHDOG_SECONDS = 45 # a question still running after this many seconds logs where everything is waiting HELP = ("Hi! Message me directly, or in groups @mention me or reply to me.\n" @@ -211,8 +214,11 @@ async def deliver(msg: Message, text: str, photos: list[bytes], link: tuple[str, class Bot: - def __init__(self, cfg: Config, agent: Agent, sources: list, state: AlertState | None): - self.cfg, self.agent, self.sources, self.state = cfg, agent, sources, state + def __init__(self, cfg: Config, agent: Agent, sources: list, state: AlertState | None, irrigation=None): + """irrigation: the Tuya client of the irrigation controller, when one is configured (its button replaces Wind).""" + self.cfg, self.agent, self.sources, self.state, self.irrigation = cfg, agent, sources, state, irrigation + self.watchdog = Watchdog(irrigation, state) if irrigation and state else None # closes the valve after a run, however it ends + self.keyboard_version = templates.version(irrigation is not None) self.chats: dict[tuple, ChatState] = defaultdict(ChatState) self.by_name = {s.name: s for s in sources} self.images = periods.ImageCache() # charts drawn lately, so toggling the period buttons is instant @@ -224,6 +230,8 @@ def register(self, app): app.add_handler(CommandHandler("keyboard", self.on_keyboard)) app.add_handler(CommandHandler("alerts", self.on_alerts)) app.add_handler(CallbackQueryHandler(self.on_alert_button, pattern=r"^al:")) + if self.irrigation: + app.add_handler(CallbackQueryHandler(self.on_irrigation_button, pattern=r"^ir:")) app.add_handler(CallbackQueryHandler(self.on_period_button, pattern=rf"^{periods.PREFIX}")) app.add_handler(MessageHandler(filters.ALL & ~filters.TEXT & ~filters.COMMAND, self.on_other), group=1) app.add_handler(ChatMemberHandler(self.on_membership, ChatMemberHandler.MY_CHAT_MEMBER)) @@ -253,7 +261,7 @@ async def on_membership(self, update: Update, context: ContextTypes.DEFAULT_TYPE self.state.remove_chat(change.chat.id, f"bot {status}") def _alert_kinds(self) -> list[str]: - return menu.available_kinds(set(self.by_name)) + return menu.available_kinds(set(self.by_name) | ({"Irrigation"} if self.irrigation else set())) async def on_alerts(self, update: Update, context: ContextTypes.DEFAULT_TYPE): """/alerts: the alert settings, as buttons (Subscribe | Unsubscribe). /alerts on and /alerts off still subscribe or @@ -316,6 +324,117 @@ async def on_alert_button(self, update: Update, context: ContextTypes.DEFAULT_TY if "not modified" not in str(e).lower(): raise + # ---------- irrigation ---------- + def _irrigation_alerts(self, chat_id: int, kind: str = "irrigation") -> bool: + """Does this chat get the irrigation battery alerts (kind "irrigation") or the pause alerts ("irrigation_pause")?""" + return bool(self.state and self.state.subscribed(chat_id, kind)) + + def _irrigation_keyboard(self, chat_id: int, opened: str | None = None, paused: bool = False): + return irrigation_menu.keyboard(self._irrigation_alerts(chat_id), self._irrigation_alerts(chat_id, "irrigation_pause"), opened, paused) + + def _irrigation_text(self, status: dict, chat_id: int) -> str: + return irrigation_menu.status_text(status, self._irrigation_alerts(chat_id), self._irrigation_alerts(chat_id, "irrigation_pause")) + + async def send_irrigation(self, msg: Message): + """The Irrigation button: the controller's status, with its buttons.""" + try: + status = await self.irrigation.status() + except IrrigationError as e: + log.warning("Irrigation status failed: %s", e) + await msg.reply_text("Sorry, I couldn't reach the irrigation controller.") + return + await msg.reply_text(self._irrigation_text(status, msg.chat_id), reply_markup=self._irrigation_keyboard(msg.chat_id, None, irrigation_menu.is_paused(status))) + + async def _irrigation_act(self, action: str, arg: str, chat_id: int) -> str | None: + """Do what a button asks. Returns the toast to show, or None for a button that means nothing. Raises IrrigationError.""" + if action == "water" and arg.isdigit() and int(arg) in irrigation_menu.WATER_MINUTES: + await self.irrigation.water(int(arg)) + if self.watchdog: + self.watchdog.arm(int(arg)) + return f"Watering for {arg} minutes" + if action == "delay" and arg in irrigation_menu.DELAYS: + await self.irrigation.delay(arg) + return "Unpaused" if arg == "cancel" else f"Paused {arg}" + if action == "sw" and arg == "off": # there is no On: a run is always timed (Water). An old message's On button does nothing + await self.irrigation.set_switch(False) + if self.watchdog: + self.watchdog.disarm() + return "Switched off" + if action in ("batt", "pause") and arg in ("on", "off") and self.state and chat_id in self.state.chats: + self.state.set_kind(chat_id, "irrigation" if action == "batt" else "irrigation_pause", arg == "on") + return f"Irrigation {'battery' if action == 'batt' else 'pause'} alerts {arg} in this chat" + return None + + async def on_irrigation_button(self, update: Update, context: ContextTypes.DEFAULT_TYPE): + """A press on the irrigation buttons: open or close a list of options, or water, delay, switch, or (un)subscribe; the + message then shows the controller's fresh status.""" + query = update.callback_query + message = query.message + action, arg = ((query.data or "").split(":") + ["", ""])[1:3] + if not self.irrigation or not message: + await query.answer() + return + if action in ("open", "close"): + opened = arg if action == "open" and arg in irrigation_menu.SECTIONS else None + await query.answer() + paused = irrigation_menu.paused_in(message.text) # the message says it: no need to ask the controller to open a list + await self._edit_irrigation(query, None, self._irrigation_keyboard(message.chat_id, opened, paused)) + return + if action == "refresh": # read the controller again and redraw: nothing is sent to it, so no wait + try: + status = await self.irrigation.status() + except IrrigationError as e: + log.warning("Irrigation refresh failed: %s", e) + await query.answer("Couldn't reach the irrigation controller", show_alert=True) + return + await query.answer("Refreshed") + await self._edit_irrigation(query, self._irrigation_text(status, message.chat_id), + self._irrigation_keyboard(message.chat_id, None, irrigation_menu.is_paused(status))) + return + if action == "water" and arg.isdigit() and int(arg) in irrigation_menu.WATER_MINUTES: # not while the timer is paused + try: + status = await self.irrigation.status() + except IrrigationError as e: + log.warning("Irrigation water %s failed: %s", arg, e) + await query.answer("Couldn't reach the irrigation controller", show_alert=True) + return + if irrigation_menu.is_paused(status): + log.info("Irrigation: water %s refused, paused for %s (chat %s)", arg, status["weather_delay"], message.chat_id) + await query.answer(irrigation_menu.PAUSED_ERROR) + text, entities = irrigation_menu.with_error(self._irrigation_text(status, message.chat_id)) + await self._edit_irrigation(query, text, self._irrigation_keyboard(message.chat_id, None, True), entities) # nothing was sent: no wait + return + try: + toast = await self._irrigation_act(action, arg, message.chat_id) + except IrrigationError as e: + log.warning("Irrigation %s %s failed: %s", action, arg, e) + await query.answer("Couldn't reach the irrigation controller", show_alert=True) + return + if toast is None: + await query.answer() + return + log.info("Irrigation: %s %s in chat %s", action, arg, message.chat_id) + await query.answer(toast) + await asyncio.sleep(IRRIGATION_SETTLE_SECONDS) # every action, before the state is read + try: + status = await self.irrigation.status() + except IrrigationError as e: + log.warning("Irrigation status after %s %s failed: %s", action, arg, e) # the toast said what was done; the message stays as it was + return + stay = "alerts" if action in ("batt", "pause") else None # the Alerts menu stays open after a toggle, to show the new label + await self._edit_irrigation(query, self._irrigation_text(status, message.chat_id), self._irrigation_keyboard(message.chat_id, stay, irrigation_menu.is_paused(status))) + + @staticmethod + async def _edit_irrigation(query, text: str | None, markup, entities=None): + try: + if text is None: + await query.edit_message_reply_markup(reply_markup=markup) + else: + await query.edit_message_text(text, reply_markup=markup, **({"entities": entities} if entities else {})) + except BadRequest as e: + if "not modified" not in str(e).lower(): + raise + async def on_period_button(self, update: Update, context: ContextTypes.DEFAULT_TYPE): """A press on 1D / 7D / 1M / 3M / 1Y under a chart: ask the chart's question again for that period and put the new chart in the same message.""" @@ -380,9 +499,9 @@ async def on_start(self, update: Update, context: ContextTypes.DEFAULT_TYPE): private = update.effective_chat.type == ChatType.PRIVATE await update.effective_message.reply_text( HELP.format(user=update.effective_user.id, chat=update.effective_chat.id), - reply_markup=templates.keyboard() if private else None) + reply_markup=templates.keyboard(self.irrigation is not None) if private else None) if private and self.state: - self.state.set_keyboard(update.effective_chat.id, templates.VERSION) + self.state.set_keyboard(update.effective_chat.id, self.keyboard_version) async def refresh_keyboards(self, tg_bot) -> int: """At startup: tell each private chat whose buttons are out of date that they changed, with the new keyboard (silently). @@ -390,24 +509,24 @@ async def refresh_keyboards(self, tg_bot) -> int: if not self.state: return 0 sent = 0 - for chat_id in [c for c in self.state.chats if c > 0 and self.state.keyboard(c) not in (templates.VERSION, templates.HIDDEN)]: + for chat_id in [c for c in self.state.chats if c > 0 and self.state.keyboard(c) not in (self.keyboard_version, templates.HIDDEN)]: try: - await tg_bot.send_message(chat_id, "Buttons updated.", reply_markup=templates.keyboard(), disable_notification=True) - self.state.set_keyboard(chat_id, templates.VERSION) + await tg_bot.send_message(chat_id, "Buttons updated.", reply_markup=templates.keyboard(self.irrigation is not None), disable_notification=True) + self.state.set_keyboard(chat_id, self.keyboard_version) sent += 1 except Forbidden as e: # blocked the bot self.state.remove_chat(chat_id, f"can't post: {e}") except TelegramError as e: log.warning("Buttons update to %s failed: %s", chat_id, e) if sent: - log.info("Buttons: told %d private chat(s) the buttons changed (keyboard %s)", sent, templates.VERSION) + log.info("Buttons: told %d private chat(s) the buttons changed (keyboard %s)", sent, self.keyboard_version) return sent def _keyboard_stale(self, msg: Message) -> bool: """Does this private chat need the current buttons (it has never had them, or they have changed since)? Not when it hid them.""" return bool(self.state and msg.chat.type == ChatType.PRIVATE - and self.state.keyboard(msg.chat_id) not in (templates.VERSION, templates.HIDDEN)) + and self.state.keyboard(msg.chat_id) not in (self.keyboard_version, templates.HIDDEN)) async def on_keyboard(self, update: Update, context: ContextTypes.DEFAULT_TYPE): """/keyboard shows the buttons again, /keyboard off hides them (private chats only).""" @@ -419,9 +538,9 @@ async def on_keyboard(self, update: Update, context: ContextTypes.DEFAULT_TYPE): if self.state: self.state.set_keyboard(msg.chat_id, templates.HIDDEN) else: - await msg.reply_text("Here are the buttons.", reply_markup=templates.keyboard()) + await msg.reply_text("Here are the buttons.", reply_markup=templates.keyboard(self.irrigation is not None)) if self.state: - self.state.set_keyboard(msg.chat_id, templates.VERSION) + self.state.set_keyboard(msg.chat_id, self.keyboard_version) async def on_error(self, update: object, context: ContextTypes.DEFAULT_TYPE): """Errors raised inside handlers: network blips get one line, anything else a traceback.""" @@ -450,7 +569,9 @@ async def on_message(self, update: Update, context: ContextTypes.DEFAULT_TYPE): return self.remember_chat(update) if msg.chat.type == ChatType.PRIVATE: - if msg.text.strip() == templates.CAPABILITIES: # what it measures, then the alert settings (no model needed) + if self.irrigation and (msg.text.strip() == templates.IRRIGATION or irrigation_menu.asked(msg.text)): # the controller's status and buttons (no model needed) + await self.send_irrigation(msg) + elif msg.text.strip() == templates.CAPABILITIES: # what it measures, then the alert settings (no model needed) await msg.reply_text(templates.capabilities_text("Ecowitt" in self.by_name, "AirGradient" in self.by_name, "Pollen" in self.by_name, "Forecast" in self.by_name)) await self.on_alerts(update, context) @@ -462,7 +583,11 @@ async def on_message(self, update: Update, context: ContextTypes.DEFAULT_TYPE): replied_to_bot = (msg.reply_to_message is not None and msg.reply_to_message.from_user is not None and msg.reply_to_message.from_user.id == context.bot.id) if mention.search(msg.text) or replied_to_bot: - await self.respond(update, context, mention.sub("", msg.text)) + asked = mention.sub("", msg.text) + if self.irrigation and irrigation_menu.asked(asked): # "@bot irrigation": the controller, as in a private chat + await self.send_irrigation(msg) + else: + await self.respond(update, context, asked) def _content(self, msg: Message, text: str, bot_id: int) -> str: """The user turn: the message replied to (in any chat: "the lowest day" means the one in that answer), and @@ -616,7 +741,7 @@ async def respond(self, update: Update, context: ContextTypes.DEFAULT_TYPE, text used_air = any(tc["function"]["name"] == "air_quality" for m in working[new_from:] for tc in m.get("tool_calls") or []) air = self.by_name.get("AirGradient") - markup = templates.keyboard() if self._keyboard_stale(msg) else None + markup = templates.keyboard(self.irrigation is not None) if self._keyboard_stale(msg) else None row = periods.keyboard(intent.period_days(text, now)) if len(photos) == 1 else None # period buttons under a single chart sent_photo = [] if row and ok and not reply.strip(): # a chart and nothing else: keep it for the period buttons @@ -631,8 +756,8 @@ async def respond(self, update: Update, context: ContextTypes.DEFAULT_TYPE, text if self.state and ok and turn.forecast_shown: # it was sent: a later revision of these days is worth telling this chat self.state.record_forecast(msg.chat_id, turn.forecast_shown) if carried_keyboard: - log.info("Buttons: sent keyboard %s to chat %s (it had %s)", templates.VERSION, msg.chat_id, self.state.keyboard(msg.chat_id)) - self.state.set_keyboard(msg.chat_id, templates.VERSION) + log.info("Buttons: sent keyboard %s to chat %s (it had %s)", self.keyboard_version, msg.chat_id, self.state.keyboard(msg.chat_id)) + self.state.set_keyboard(msg.chat_id, self.keyboard_version) elif markup and not row: log.info("Buttons: this reply couldn't carry the keyboard (several charts); will try the next one") except TelegramError: diff --git a/lib/charts.py b/lib/charts.py index e6eae07..1098c97 100644 --- a/lib/charts.py +++ b/lib/charts.py @@ -349,7 +349,10 @@ def _pills(ax, lines: list[Line], drawn: list[tuple], tz: tzinfo, x0: float, x1: if p[4] == q[4] and abs(p[0] - q[0]) < (x1 - x0) * 0.08 and abs(p[1] - q[1]) < (y_hi - y_lo) * 0.15: left, right = (p, q) if p[0] <= q[0] else (q, p) if (left[0] - x0) / (x1 - x0) < 0.12 or (x1 - right[0]) / (x1 - x0) < 0.12: # no room to push sideways - (p if p[1] <= q[1] else q)[4] = False # the lower one's pill goes below its point + upper, lower = (p, q) if p[1] >= q[1] else (q, p) # one above its point, the other below, whatever side both began on + upper[4] = True + if lower[1] - y_lo >= 0.16 * (y_hi - y_lo): # not on the floor, where a pill below would run under the axis + lower[4] = False else: left[5], right[5] = "right", "left" for rx, ry, text, colour, above, ha in pills: diff --git a/lib/config.py b/lib/config.py index 0ec547d..e13a077 100644 --- a/lib/config.py +++ b/lib/config.py @@ -29,6 +29,12 @@ def _hours(value: str, default: tuple[int, int] | None) -> tuple[int, int] | Non return (start, end) if 0 <= start <= 23 and 0 <= end <= 24 and start != end else default +def _hour(value: str, default: int) -> int: + """"18" -> 18; anything unreadable or outside 0 to 23 -> the default.""" + number = _number(value) + return int(number) if number is not None and 0 <= number <= 23 else default + + def _number(value: str) -> float | None: try: return float(value) @@ -61,12 +67,21 @@ class Config: rain_quiet_hours: tuple[int, int] | None = (0, 6) # RAIN_QUIET_HOURS="0-6" (local hours; "off" for none): no rain alerts, a summary after chart_all_feels_like: bool = False # CHART_ALL_FEELS_LIKE=on: "weather all week" also draws the feels-like panel (off: it nearly repeats temperature) chart_all_vpd: bool = False # CHART_ALL_VPD=on: "weather all week" also draws the vapour pressure deficit panel (off: it is temperature and humidity combined) + tuya_device_id: str = "" # TUYA_DEVICE_ID, TUYA_CLIENT_ID, TUYA_CLIENT_SECRET: all three switch the irrigation controller on + tuya_client_id: str = "" + tuya_client_secret: str = "" + tuya_base_url: str = "https://openapi.tuyaeu.com" # TUYA_BASE_URL: the data centre's address (EU by default) + irrigation_check_hour: int = 18 # IRRIGATION_CHECK_HOUR: the local hour of the daily check (0 to 23) alert_cooldown_minutes: int = 30 # ALERT_COOLDOWN_MINUTES: the rain and temperature-crossing alerts: dry this long before "the rain has stopped", and this long between alerts (5 to 150) @property def ecowitt(self) -> bool: return bool(self.ecowitt_api_key and self.ecowitt_app_key) + @property + def irrigation(self) -> bool: + return bool(self.tuya_device_id and self.tuya_client_id and self.tuya_client_secret) + @property def airgradient(self) -> bool: return bool(self.airgradient_token and self.airgradient_location) @@ -94,6 +109,11 @@ def from_env(cls) -> "Config": rain_quiet_hours=_hours(env("RAIN_QUIET_HOURS"), (0, 6)), chart_all_feels_like=_on(env("CHART_ALL_FEELS_LIKE")), chart_all_vpd=_on(env("CHART_ALL_VPD")), + tuya_device_id=env("TUYA_DEVICE_ID"), + tuya_client_id=env("TUYA_CLIENT_ID"), + tuya_client_secret=env("TUYA_CLIENT_SECRET"), + tuya_base_url=env("TUYA_BASE_URL", "https://openapi.tuyaeu.com").rstrip("/"), + irrigation_check_hour=_hour(env("IRRIGATION_CHECK_HOUR"), 18), alert_cooldown_minutes=int(min(150, max(5, _number(env("ALERT_COOLDOWN_MINUTES")) or 30))), ) if not (cfg.ecowitt or cfg.airgradient): diff --git a/lib/ecowitt/query.py b/lib/ecowitt/query.py index da95dc0..d8b6176 100644 --- a/lib/ecowitt/query.py +++ b/lib/ecowitt/query.py @@ -473,7 +473,7 @@ async def _rain_bars(self) -> Bars: rain[day_bounds(day, self.tz)[0] + 12 * 3600] = mm self.rain_from_daily = True by = grouping_in(self.turn.text) if self.span > timedelta(days=31) else None - return rain_bars(rain, self.tz, self.start.date(), self.end.date(), until=hi, by=by) + return rain_bars(rain, self.tz, self.start.date(), self.end.date(), until=hi, by=by, since=lo) def _stack_spec(self, names: list[str]) -> Chart | None: """The readings asked for, one panel each on a shared time axis (rain behind the first line); None if fewer than diff --git a/lib/irrigation/__init__.py b/lib/irrigation/__init__.py new file mode 100644 index 0000000..3b5b96a --- /dev/null +++ b/lib/irrigation/__init__.py @@ -0,0 +1,4 @@ +from .tuya import IrrigationError, Tuya +from .watchdog import Watchdog + +__all__ = ["IrrigationError", "Tuya", "Watchdog"] diff --git a/lib/irrigation/menu.py b/lib/irrigation/menu.py new file mode 100644 index 0000000..90148bd --- /dev/null +++ b/lib/irrigation/menu.py @@ -0,0 +1,98 @@ +"""The irrigation status message and its buttons. Pure: it only builds the text and the keyboard. + +Collapsed: [💧 Water] [⏸ Pause] / [🔔 Alerts] [🔄 Refresh]. While the timer is paused the Pause button is [▶️ Unpause], +one press that ends the pause. A press on Water, Pause or Alerts opens its options under the rows (▾ marks the open one; pressing +it again closes them). Water lists 5, 10, 20 and 30 minutes, then Off for the valve itself (a run is +always timed). Alerts has one button per alert type, each naming what pressing it does for this chat (enable or disable). Callback data +(short, self-contained, so an old message's buttons still work after a restart): + ir:open: | ir:close | ir:refresh (read the state again, change nothing) + ir:water: | ir:delay:<24h|48h|72h|cancel> (cancel = unpause, the Unpause button) | ir:sw:off + ir:batt: | ir:pause: (the irrigation battery and irrigation pause alerts; on = subscribe) +""" + +import math +import re + +from telegram import InlineKeyboardButton, InlineKeyboardMarkup, MessageEntity + +TITLE = "🌱 Irrigation" +WATER_MINUTES = (5, 10, 20, 30) +DELAYS = ("24h", "48h", "72h", "cancel") +SECTIONS = ("water", "delay", "alerts") +LOW_BATTERY = 10 # below this the battery shows as 🪫 +PAUSED_ERROR = "error: can not turn on water while paused!" +KEYWORDS = re.compile(r"\b(irrigation|irrigate|sprinklers?|taps?|water(?:ing)?)\b", re.IGNORECASE) +MAX_WORDS = 6 # a longer message that happens to say "water" is a question for the model + + +def asked(text: str) -> bool: + """Does this short message ask for the controller (irrigation, water, tap, sprinkler)? The same as pressing the Irrigation button.""" + return 0 < len(text.split()) <= MAX_WORDS and bool(KEYWORDS.search(text)) + + +def is_paused(status: dict) -> bool: + """Is the timer paused (the controller has a weather delay other than "cancel")?""" + return status.get("weather_delay") not in (None, "cancel") + + +def paused_in(text: str | None) -> bool: + """Does a status message (as status_text wrote it) say the timer is paused? For redrawing only the buttons, without asking the controller. + Messages sent before the line was reworded ("pause time: inactive") still read right.""" + line = next((l for l in (text or "").splitlines() if l.startswith(("pause:", "pause time:"))), "") + value = line.partition(":")[2].strip() + return bool(value) and not value.startswith(("not paused", "inactive")) + + +def with_error(text: str, error: str = PAUSED_ERROR) -> tuple[str, list[MessageEntity]]: + """The status with an error under it as a bold footer. Plain text plus entities (no markup to escape); Telegram counts + positions in UTF-16 code units, where an emoji is two.""" + units = lambda s: len(s.encode("utf-16-le")) // 2 + head = f"{text}\n\n" + return head + error, [MessageEntity(MessageEntity.BOLD, units(head), units(error))] + + +def _button(text: str, data: str) -> InlineKeyboardButton: + return InlineKeyboardButton(text, callback_data=data) + + +def status_text(status: dict, alerts: bool | None = None, pause_alerts: bool | None = None) -> str: + """The controller's state, every line always there, in this order: + mode: auto + state: on ✅ (10 mins until off) (the minutes only while it is on and counting down) + pause: 24h (alerts: on) ("not paused" when there is no pause) + battery: 80% 🔋 (alerts: on) + `alerts` and `pause_alerts`: whether this chat gets the battery / pause alerts (the brackets are left out when not given).""" + on = bool(status.get("switch")) + countdown = status.get("countdown") + left = "" + if on and isinstance(countdown, (int, float)) and countdown > 0: + minutes = math.ceil(countdown / 60) + left = f" ({minutes} {'min' if minutes == 1 else 'mins'} until off)" + delay = status.get("weather_delay") + battery = status.get("battery_percentage") + level = f"{battery}% {'🔋' if battery >= LOW_BATTERY else '🪫'}" if battery is not None else "unknown" + bracket = lambda on_: "" if on_ is None else f" (alerts: {'on' if on_ else 'off'})" + return "\n".join([TITLE, + f"mode: {status.get('work_state') or 'unknown'}", + f"state: {'on ✅' if on else 'off ❌'}{left}", + f"pause: {'not paused' if delay in (None, 'cancel') else delay}{bracket(pause_alerts)}", + f"battery: {level}{bracket(alerts)}"]) + + +def keyboard(battery: bool, pause: bool, open: str | None = None, paused: bool = False) -> InlineKeyboardMarkup: + """The buttons under the status. `battery` and `pause`: this chat gets the irrigation battery / pause alerts; each Alerts button + names what pressing it does (disable when it is on, enable when it is off). `paused`: the timer is paused, so its button unpauses.""" + def head(name: str, text: str) -> InlineKeyboardButton: + return _button(("▾ " if open == name else "") + text, "ir:close" if open == name else f"ir:open:{name}") + delay = _button("▶️ Unpause", "ir:delay:cancel") if paused else head("delay", "⏸ Pause") + rows = [[head("water", "💧 Water"), delay], + [head("alerts", "🔔 Alerts"), _button("🔄 Refresh", "ir:refresh")]] + if open == "water": + rows.append([_button(f"{m} min", f"ir:water:{m}") for m in WATER_MINUTES]) + rows.append([_button("Off ❌", "ir:sw:off")]) + elif open == "delay" and not paused: + rows.append([_button(d, f"ir:delay:{d}") for d in DELAYS if d != "cancel"]) + elif open == "alerts": + for on, kind, name in ((battery, "batt", "battery"), (pause, "pause", "pause")): + rows.append([_button(f"🔕 Disable {name} alerts" if on else f"🔔 Enable {name} alerts", f"ir:{kind}:{'off' if on else 'on'}")]) + return InlineKeyboardMarkup(rows) diff --git a/lib/irrigation/tuya.py b/lib/irrigation/tuya.py new file mode 100644 index 0000000..37f9fc9 --- /dev/null +++ b/lib/irrigation/tuya.py @@ -0,0 +1,107 @@ +"""The irrigation controller, through the Tuya cloud API (a "smart irrigation timer", category sfkzq). + +Every request is signed with HMAC-SHA256 over the client id, the access token, a timestamp, a nonce and a description of the request +(method, hash of the body, path). The access token is fetched when needed and kept until shortly before it expires. The device's data +points used here: `switch` (valve on or off), `countdown` (seconds the run lasts), `weather_delay` (`cancel`, `24h`, `48h`, `72h`: the +device skips its own schedule for that long), `battery_percentage` and `work_state` (`auto`, `manual`, `idle`). +""" + +import hashlib +import hmac +import json +import logging +import time +import uuid + +import httpx + +from ..config import Config + +log = logging.getLogger(__name__) + +TOKEN_PATH = "/v1.0/token?grant_type=1" +TOKEN_INVALID = {1010, 1011} # Tuya's "token invalid" and "token expired" codes +DELAYS = ("24h", "48h", "72h", "cancel") # the device's weather_delay range +MAX_MINUTES = 60 # the longest run asked for from a button + + +class IrrigationError(Exception): + """The controller could not be read or told something. The message is safe to log (no secrets, no raw response).""" + + +def sign(client_id: str, secret: str, token: str, timestamp: str, nonce: str, method: str, path: str, body: bytes) -> str: + """Tuya's request signature: HMAC-SHA256, upper-case hex, of client_id + token + timestamp + nonce + the string describing the request.""" + to_sign = f"{method}\n{hashlib.sha256(body).hexdigest()}\n\n{path}" + return hmac.new(secret.encode(), (client_id + token + timestamp + nonce + to_sign).encode(), hashlib.sha256).hexdigest().upper() + + +class Tuya: + def __init__(self, cfg: Config, transport=None): + self.device = cfg.tuya_device_id + self.client_id, self.secret = cfg.tuya_client_id, cfg.tuya_client_secret + self.http = httpx.AsyncClient(base_url=cfg.tuya_base_url, timeout=20, transport=transport) + self.token = "" + self.token_expires = 0.0 + + async def close(self): + await self.http.aclose() + + async def _send(self, method: str, path: str, payload: dict | None, token: str): + body = json.dumps(payload, separators=(",", ":"), ensure_ascii=False).encode() if payload is not None else b"" + timestamp, nonce = str(int(time.time() * 1000)), uuid.uuid4().hex + headers = {"client_id": self.client_id, "sign_method": "HMAC-SHA256", "t": timestamp, "nonce": nonce, + "sign": sign(self.client_id, self.secret, token, timestamp, nonce, method, path, body)} + if token: + headers["access_token"] = token + if payload is not None: + headers["Content-Type"] = "application/json" + try: + response = await self.http.request(method, path, content=body if payload is not None else None, headers=headers) + response.raise_for_status() + return response.json() + except (httpx.HTTPError, ValueError) as e: # the URL and body stay out of the message + raise IrrigationError(f"Tuya request failed ({type(e).__name__})") from e + + async def _authenticate(self): + data = await self._send("GET", TOKEN_PATH, None, "") + if not data.get("success"): + raise IrrigationError(f"Tuya refused the credentials (code {data.get('code')}: {data.get('msg')})") + result = data["result"] + self.token = result["access_token"] + self.token_expires = time.time() + max(60, int(result.get("expire_time", 7200)) - 60) + + async def request(self, method: str, path: str, payload: dict | None = None, retry: bool = True): + """The `result` of a signed request. An expired token is replaced and the request sent once more.""" + if not self.token or time.time() >= self.token_expires: + await self._authenticate() + data = await self._send(method, path, payload, self.token) + if not data.get("success"): + if retry and data.get("code") in TOKEN_INVALID: + self.token = "" + return await self.request(method, path, payload, retry=False) + raise IrrigationError(f"Tuya error {data.get('code')}: {data.get('msg')}") + return data.get("result") + + async def status(self) -> dict: + """{code: value} of the device's status points (switch, battery_percentage, weather_delay, countdown, work_state, ...).""" + result = await self.request("GET", f"/v1.0/devices/{self.device}/status") + return {item["code"]: item["value"] for item in result or [] if "code" in item} + + async def command(self, *commands: tuple[str, object]): + """Send one or more (code, value) commands together.""" + await self.request("POST", f"/v1.0/devices/{self.device}/commands", + {"commands": [{"code": code, "value": value} for code, value in commands]}) + + async def water(self, minutes: int): + """Open the valve for this many minutes: the run's length and the switch, in one command.""" + if not 1 <= minutes <= MAX_MINUTES: + raise IrrigationError(f"A run must be 1 to {MAX_MINUTES} minutes") + await self.command(("countdown", minutes * 60), ("switch", True)) + + async def delay(self, duration: str): + if duration not in DELAYS: + raise IrrigationError(f"The delay must be one of {', '.join(DELAYS)}") + await self.command(("weather_delay", duration)) + + async def set_switch(self, on: bool): + await self.command(("switch", bool(on))) diff --git a/lib/irrigation/watchdog.py b/lib/irrigation/watchdog.py new file mode 100644 index 0000000..041be3c --- /dev/null +++ b/lib/irrigation/watchdog.py @@ -0,0 +1,47 @@ +"""Closing the valve after a run the bot started, even if the controller does not. + +`water` asks the device to run for N minutes (its `countdown`), but nothing independent checks that it stops. So the end time is +written to the bot's state file; a minute-by-minute check (cheap: it only talks to the controller once the time has passed) switches +the valve off if it is still on. Because the time is saved, a restart in the middle of a run still closes the valve. +""" + +import logging +import time + +from .tuya import Tuya + +log = logging.getLogger(__name__) + +GRACE_SECONDS = 30 # the device's own countdown gets this long to finish first +CHECK_SECONDS = 60 + + +class Watchdog: + def __init__(self, device: Tuya, state, clock=time.time): + self.device, self.state, self.clock = device, state, clock + + def _memory(self) -> dict: + return self.state.monitor.setdefault("irrigation", {}) + + def arm(self, minutes: int): + """A run of this many minutes has started.""" + self._memory()["off_at"] = self.clock() + minutes * 60 + self.state.save() + + def disarm(self): + if self._memory().pop("off_at", None) is not None: + self.state.save() + + @property + def off_at(self) -> float | None: + return self._memory().get("off_at") + + async def check(self): + """Switch the valve off if a run is over and it is still on. A failure is tried again at the next check.""" + due = self.off_at + if due is None or self.clock() < due + GRACE_SECONDS: + return + if (await self.device.status()).get("switch"): + await self.device.set_switch(False) + log.warning("Irrigation: the run should have ended; the valve was still open, so it was switched off") + self.disarm() diff --git a/lib/rain.py b/lib/rain.py index 7a4f793..b4c28cf 100644 --- a/lib/rain.py +++ b/lib/rain.py @@ -46,18 +46,22 @@ def bar_layout(tz: tzinfo, first: date, last: date) -> tuple[int, int, str]: {3600: "hour", 6 * 3600: "6 hours", 86400: "day"}[width]) -def rain_bars(rain: dict[int, float], tz: tzinfo, first: date, last: date, until: int | None = None, by: str | None = None) -> Bars: +def rain_bars(rain: dict[int, float], tz: tzinfo, first: date, last: date, until: int | None = None, by: str | None = None, + since: int | None = None) -> Bars: """Rain summed into bars (see bar_layout), one for every stretch of the period, dry ones included (a bar of 0), up to `until` - (epoch, default the end of the last day).""" + (epoch, default the end of the last day). The bars start at local midnight of the first day, or with `since` (epoch, the period's + own start) at the bar that holds it: a rolling last 24 hours has no bars left of its data.""" if by in ("month", "year"): # a figure per month or year was asked for: one bar for each, whatever the length return _calendar_bars(rain, tz, first, last, by) origin, width, per = bar_layout(tz, first, last) end = min(day_bounds(last, tz)[1], until) if until else day_bounds(last, tz)[1] - bars: dict[int, float] = dict.fromkeys(range(origin, end, width), 0.0) + begin = origin + (since - origin) // width * width if since is not None and since > origin else origin + bars: dict[int, float] = dict.fromkeys(range(begin, end, width), 0.0) for t, mm in rain.items(): if mm > 0: k = origin + (t - origin) // width * width - bars[k] = bars.get(k, 0.0) + mm + if k >= begin: # rain before the period's start is not part of it + bars[k] = bars.get(k, 0.0) + mm xs = sorted(bars) return Bars("Rain", "mm", xs, [round(bars[k], 2) for k in xs], width, per) diff --git a/lib/templates.py b/lib/templates.py index 146746f..784f7a7 100644 --- a/lib/templates.py +++ b/lib/templates.py @@ -6,9 +6,10 @@ from telegram import ReplyKeyboardMarkup CAPABILITIES = "\U0001f514 Help & Alerts" # answered with what the bot can do, then the /alerts status +IRRIGATION = "\U0001f331 Irrigation" # answered with the irrigation controller's status and buttons (only when it is configured) -# Every button but the first and last of the top row is a chart of the last day (the period buttons under it change that) -ROWS = [[(CAPABILITIES, "What can you do?"), ("\U0001f327️ Rain", "Rain chart 1d"), ("\U0001f4cb Status", "Report")], +# Every button but Help & Alerts and Status is a chart of the last day (the period buttons under it change that) +ROWS = [[(CAPABILITIES, "What can you do?"), ("\U0001f4cb Status", "Report"), ("\U0001f327️ Rain", "Rain chart 1d")], [("\U0001f326️ Weather", "Weather chart 1d"), ("\U0001f4c8 Temperature", "Temperature chart 1d"), ("\U0001f4a7 Humidity", "Humidity chart 1d")], [("\U0001f3ed Air Quality", "Air quality all metrics 1d"), ("\U0001f52c Particulates", "Particulates chart 1d"), @@ -17,12 +18,23 @@ LABELS = set(SENTENCES) # Telegram keeps the keyboard in each app until a message brings a new one; this changes whenever the buttons do, so a chat # whose stored value differs gets the current keyboard with its next reply. HIDDEN is what a chat that hid the buttons stores. -VERSION = hashlib.md5("|".join(label for row in ROWS for label, _ in row).encode()).hexdigest()[:8] HIDDEN = "hidden" -def keyboard() -> ReplyKeyboardMarkup: - return ReplyKeyboardMarkup([[label for label, _ in row] for row in ROWS], resize_keyboard=True, is_persistent=True, +def rows(irrigation: bool = False) -> list[list[tuple[str, str]]]: + """The button rows. With the irrigation controller configured, its button takes the place of Wind (the last one).""" + return [*ROWS[:-1], [*ROWS[-1][:-1], (IRRIGATION, "")]] if irrigation else ROWS + + +def version(irrigation: bool = False) -> str: + return hashlib.md5("|".join(label for row in rows(irrigation) for label, _ in row).encode()).hexdigest()[:8] + + +VERSION = version() + + +def keyboard(irrigation: bool = False) -> ReplyKeyboardMarkup: + return ReplyKeyboardMarkup([[label for label, _ in row] for row in rows(irrigation)], resize_keyboard=True, is_persistent=True, input_field_placeholder="Ask about the weather or air…") diff --git a/tests/test_chart_labels.py b/tests/test_chart_labels.py index e3495e2..a541605 100644 --- a/tests/test_chart_labels.py +++ b/tests/test_chart_labels.py @@ -54,3 +54,33 @@ def test_a_latest_value_of_zero_is_not_labelled_for_any_of_them_and_other_readin def test_an_end_value_is_never_written_in_exponent_form(): assert charts._figure(1014.8) == "1014.8" and charts._figure(1015.0) == "1015" and charts._figure(67.53) == "67.5" assert charts._figure(0.858) == "0.858" and charts._figure(2.0) == "2" and charts._figure(0.0) == "0" + + +def annotations_of(chart): + """[(text, which side of its point the pill sits)] for every label drawn.""" + import matplotlib.pyplot as plt + seen = [] + original = plt.Axes.annotate + + def record(self, text, *a, **k): + seen.append((text, k.get("va"))) + return original(self, text, *a, **k) + plt.Axes.annotate = record + try: + charts.render(chart, timezone.utc) + finally: + plt.Axes.annotate = original + return seen + + +def test_two_lows_that_end_together_at_the_right_edge_get_pills_on_opposite_sides(): + """Humidity outdoors and indoors both ending at their lows: the pills used to sit on top of each other (both below their points).""" + xs = list(range(1_790_000_000, 1_790_000_000 + 49 * 1800, 1800)) + steps = range(49) + outdoor = [90.0 - 44.7 * i / 48 for i in steps] # ends at its low, 45.3 + indoor = [58.0 - 10.0 * i / 48 for i in steps] # ends at its low, 48 + floor = [30.0 + (i % 3) for i in steps] # something lower, so neither low is on the floor + chart = Chart("T", "s", [Panel("Humidity", "%", [Line("Outdoor", xs, outdoor, reading="humidity"), Line("Indoor", xs, indoor, reading="humidity"), + Line("Other", xs, floor, reading="humidity")], reading="humidity")]) + sides = dict(annotations_of(chart)) + assert {sides["45.3 %"], sides["48.0 %"]} == {"top", "bottom"}, sides # never the same side diff --git a/tests/test_irrigation.py b/tests/test_irrigation.py new file mode 100644 index 0000000..d45853c --- /dev/null +++ b/tests/test_irrigation.py @@ -0,0 +1,844 @@ +"""The irrigation controller: the Tuya client, the keyboard, the daily check, the watchdog, and the buttons.""" + +import asyncio +import hashlib +import hmac +import json +from datetime import datetime +from types import SimpleNamespace as NS + +import httpx +import pytest +from telegram.error import BadRequest + +import lib.bot as bot_module +from lib import templates +from lib.alerts import AlertState, IrrigationMonitor +from lib.alerts import irrigation as monitor_module +from lib.alerts.menu import available_kinds +from lib.bot import Bot +from lib.config import Config +from lib.irrigation import IrrigationError, Tuya, Watchdog, menu +from lib.irrigation.tuya import sign +from tests.fakes import TZ, config + +@pytest.fixture(autouse=True) +def settle_at_once(monkeypatch): + """The irrigation buttons wait a couple of seconds before redrawing the message; the tests do not.""" + monkeypatch.setattr(bot_module, "IRRIGATION_SETTLE_SECONDS", 0) + + +SECRET = "s3cret" +STATUS = [{"code": "switch", "value": False}, {"code": "battery_percentage", "value": 100}, {"code": "weather_delay", "value": "cancel"}, + {"code": "countdown", "value": 0}, {"code": "work_state", "value": "idle"}, {"code": "use_time_one", "value": 40}] # as the controller answered + + +def cfg(tmp_path, **over): + return config(tmp_path, tuya_device_id="dev1", tuya_client_id="cid", tuya_client_secret=SECRET, **over) + + +class FakeTuya: + """Stands in for the Tuya cloud: records the signed requests it gets and checks each signature.""" + + def __init__(self, status=None, fail_token_once=False): + self.calls, self.status, self.token_n, self.fail_token_once = [], status if status is not None else STATUS, 0, fail_token_once + + def __call__(self, request: httpx.Request) -> httpx.Response: + h, path = request.headers, request.url.raw_path.decode() + body = request.content + token = h.get("access_token", "") + expected = sign("cid", SECRET, token, h["t"], h["nonce"], request.method, path, body) # signed over what was sent + assert h["sign"] == expected and h["client_id"] == "cid" and h["sign_method"] == "HMAC-SHA256" + if path.startswith("/v1.0/token"): + self.token_n += 1 + return httpx.Response(200, json={"success": True, "result": {"access_token": f"tok{self.token_n}", "expire_time": 7200}}) + self.calls.append((request.method, path, json.loads(body) if body else None, token)) + if self.fail_token_once and token == "tok1": + self.fail_token_once = False + return httpx.Response(200, json={"success": False, "code": 1010, "msg": "token invalid"}) + if path.endswith("/status"): + return httpx.Response(200, json={"success": True, "result": self.status}) + return httpx.Response(200, json={"success": True, "result": True}) + + +def tuya(tmp_path, **kw): + fake = FakeTuya(**kw) + return Tuya(cfg(tmp_path), transport=httpx.MockTransport(fake)), fake + + +def test_the_signature_is_hmac_sha256_of_the_ids_the_time_and_the_request(): + body = b'{"commands":[{"code":"switch","value":true}]}' + to_sign = f"POST\n{hashlib.sha256(body).hexdigest()}\n\n/v1.0/devices/d/commands" # the way the sample script builds it + want = hmac.new(b"sec", ("cid" + "tok" + "1700000000000" + "abc" + to_sign).encode(), hashlib.sha256).hexdigest().upper() + assert sign("cid", "sec", "tok", "1700000000000", "abc", "POST", "/v1.0/devices/d/commands", body) == want + empty = hashlib.sha256(b"").hexdigest() + assert sign("cid", "sec", "", "1", "n", "GET", "/v1.0/token?grant_type=1", b"") == hmac.new( + b"sec", f"cid1nGET\n{empty}\n\n/v1.0/token?grant_type=1".encode(), hashlib.sha256).hexdigest().upper() + + +async def test_status_is_read_as_code_and_value_and_the_token_is_fetched_once(tmp_path): + device, fake = tuya(tmp_path) + got = await device.status() + assert got == {"switch": False, "battery_percentage": 100, "weather_delay": "cancel", "countdown": 0, "work_state": "idle", "use_time_one": 40} + await device.status() + assert fake.token_n == 1 and [c[1] for c in fake.calls] == ["/v1.0/devices/dev1/status"] * 2 and fake.calls[0][3] == "tok1" + await device.close() + + +async def test_watering_sends_the_length_and_the_switch_in_one_command_and_other_commands_are_exact(tmp_path): + device, fake = tuya(tmp_path) + await device.water(10) + await device.delay("48h") + await device.set_switch(False) + assert [(m, p, b) for m, p, b, _ in fake.calls] == [ + ("POST", "/v1.0/devices/dev1/commands", {"commands": [{"code": "countdown", "value": 600}, {"code": "switch", "value": True}]}), + ("POST", "/v1.0/devices/dev1/commands", {"commands": [{"code": "weather_delay", "value": "48h"}]}), + ("POST", "/v1.0/devices/dev1/commands", {"commands": [{"code": "switch", "value": False}]})] + for bad in (0, 61, -5): + with pytest.raises(IrrigationError): + await device.water(bad) + with pytest.raises(IrrigationError): + await device.delay("96h") + assert len(fake.calls) == 3 # nothing was sent for the bad ones + + +async def test_an_expired_token_is_replaced_and_the_request_repeated_once(tmp_path): + device, fake = tuya(tmp_path, fail_token_once=True) + assert (await device.status())["battery_percentage"] == 100 + assert fake.token_n == 2 and [c[3] for c in fake.calls] == ["tok1", "tok2"] + + +async def test_errors_name_the_problem_without_the_secrets(tmp_path): + def refuse(request): + return httpx.Response(200, json={"success": False, "code": 1004, "msg": "sign invalid"}) + device = Tuya(cfg(tmp_path), transport=httpx.MockTransport(refuse)) + with pytest.raises(IrrigationError, match="1004"): + await device.status() + + def down(request): + raise httpx.ConnectError("no route to host https://openapi.tuyaeu.com?secret=" + SECRET) + device = Tuya(cfg(tmp_path), transport=httpx.MockTransport(down)) + with pytest.raises(IrrigationError) as e: + await device.status() + assert SECRET not in str(e.value) and "ConnectError" in str(e.value) + + +def test_the_controller_is_on_only_with_all_three_settings_and_the_hour_is_read_safely(monkeypatch, tmp_path): + assert cfg(tmp_path).irrigation and not config(tmp_path).irrigation and not config(tmp_path, tuya_device_id="d", tuya_client_id="c").irrigation + env = {"TELEGRAM_BOT_TOKEN": "t", "XAI_API_KEY": "k", "ECOWITT_API_KEY": "a", "ECOWITT_APP_KEY": "b"} + for k, v in env.items(): + monkeypatch.setenv(k, v) + for value, hour in (("", 18), ("6", 6), ("0", 0), ("23", 23), ("24", 18), ("noon", 18), ("-1", 18)): + monkeypatch.setenv("IRRIGATION_CHECK_HOUR", value) + assert Config.from_env().irrigation_check_hour == hour, value + monkeypatch.setenv("TUYA_BASE_URL", "https://openapi.tuyaus.com/") + monkeypatch.setenv("TUYA_DEVICE_ID", " dev ") + assert Config.from_env().tuya_base_url == "https://openapi.tuyaus.com" and Config.from_env().tuya_device_id == "dev" + + +# ---------- the status message and its buttons ---------- +def rows(markup): + return [[(b.text, b.callback_data) for b in row] for row in markup.inline_keyboard] + + +def test_the_status_always_shows_mode_state_pause_time_and_battery_in_that_order(): + assert menu.status_text({"switch": True, "battery_percentage": 73, "work_state": "auto", "weather_delay": "cancel"}) == ( + "🌱 Irrigation\nmode: auto\nstate: on ✅\npause: not paused\nbattery: 73% 🔋") + assert menu.status_text({"switch": False, "battery_percentage": 9, "work_state": "idle", "weather_delay": "24h"}) == ( + "🌱 Irrigation\nmode: idle\nstate: off ❌\npause: 24h\nbattery: 9% 🪫") + assert "🔋" in menu.status_text({"battery_percentage": 10}) and "🪫" in menu.status_text({"battery_percentage": 0}) + assert menu.status_text({}) == "🌱 Irrigation\nmode: unknown\nstate: off ❌\npause: not paused\nbattery: unknown" # every element, even unread + full = {"switch": True, "countdown": 600, "weather_delay": "48h", "work_state": "manual", "battery_percentage": 80} + assert menu.status_text(full, True, False) == ( # all of it, with the chat's alerts in brackets + "🌱 Irrigation\nmode: manual\nstate: on ✅ (10 mins until off)\npause: 48h (alerts: off)\nbattery: 80% 🔋 (alerts: on)") + assert menu.status_text({**full, "weather_delay": "cancel"}, False, True).splitlines()[3] == "pause: not paused (alerts: on)" + for countdown, shown in ((600, "10 mins"), (601, "11 mins"), (30, "1 min"), (1, "1 min"), (5400, "90 mins")): # whole minutes, a started one counts + assert f"state: on ✅ ({shown} until off)\n" in menu.status_text({**full, "countdown": countdown}) + for hidden in ({"countdown": 0}, {"countdown": None}, {"countdown": -5}, {"switch": False, "countdown": 600}, {"switch": False}): + assert "until off" not in menu.status_text({**full, **hidden}), hidden # only while it is on and counting down + assert menu.status_text({"switch": True}).splitlines()[2] == "state: on ✅" # on, but no countdown: just on + assert "(alerts" not in menu.status_text(full) # no chat given: no brackets + assert menu.status_text({"switch": False}, True).endswith("battery: unknown (alerts: on)") and "pause: not paused\n" in menu.status_text({}, True) + + +def test_the_buttons_open_one_section_at_a_time_and_every_callback_is_short(): + collapsed = [[("💧 Water", "ir:open:water"), ("⏸ Pause", "ir:open:delay")], [("🔔 Alerts", "ir:open:alerts"), ("🔄 Refresh", "ir:refresh")]] + assert rows(menu.keyboard(False, False)) == collapsed == rows(menu.keyboard(True, True)) # the alert states show only once Alerts is open + water = rows(menu.keyboard(True, True, "water")) + assert water[0][0] == ("▾ 💧 Water", "ir:close") and water[-2] == [(f"{m} min", f"ir:water:{m}") for m in (5, 10, 20, 30)] + assert water[-1] == [("Off ❌", "ir:sw:off")] # the valve itself: only off (a run is always timed) + assert "ir:sw:on" not in [data for row in water for _, data in row] + assert rows(menu.keyboard(True, True, "delay"))[-1] == [("24h", "ir:delay:24h"), ("48h", "ir:delay:48h"), ("72h", "ir:delay:72h")] + paused = rows(menu.keyboard(True, True, paused=True)) # paused: the button unpauses, in one press + assert paused[0] == [("💧 Water", "ir:open:water"), ("▶️ Unpause", "ir:delay:cancel")] and paused[1] == collapsed[1] + assert rows(menu.keyboard(True, True, "delay", paused=True)) == paused # no list of periods while paused + open_water = rows(menu.keyboard(True, True, "water", paused=True)) + assert open_water[0] == [("▾ 💧 Water", "ir:close"), ("▶️ Unpause", "ir:delay:cancel")] + assert menu.SECTIONS == ("water", "delay", "alerts") and len(rows(menu.keyboard(True, True, "switch"))) == 2 # no Switch menu any more + for battery, pause, battery_button, pause_button in ( + (False, False, ("🔔 Enable battery alerts", "ir:batt:on"), ("🔔 Enable pause alerts", "ir:pause:on")), + (True, False, ("🔕 Disable battery alerts", "ir:batt:off"), ("🔔 Enable pause alerts", "ir:pause:on")), + (False, True, ("🔔 Enable battery alerts", "ir:batt:on"), ("🔕 Disable pause alerts", "ir:pause:off")), + (True, True, ("🔕 Disable battery alerts", "ir:batt:off"), ("🔕 Disable pause alerts", "ir:pause:off"))): + alerts = rows(menu.keyboard(battery, pause, "alerts")) + assert alerts[:2] == collapsed[:1] + [[("▾ 🔔 Alerts", "ir:close"), ("🔄 Refresh", "ir:refresh")]] + assert alerts[2:] == [[battery_button], [pause_button]] # each names the action it takes + for section in (None, *menu.SECTIONS): + for battery in (True, False): + for pause in (True, False): + assert all(len(data.encode()) <= 64 for row in rows(menu.keyboard(battery, pause, section)) for _, data in row) + + +def test_the_irrigation_button_takes_wind_s_place_only_when_configured_and_changes_the_keyboard_version(): + plain, with_irrigation = templates.keyboard().keyboard, templates.keyboard(True).keyboard + assert [b.text for row in plain for b in row][-1] == "\U0001f4a8 Wind" and templates.IRRIGATION not in [b.text for row in plain for b in row] + labels = [b.text for row in with_irrigation for b in row] + assert labels[-1] == templates.IRRIGATION and "\U0001f4a8 Wind" not in labels and len(labels) == 9 and with_irrigation[:2] == plain[:2] + assert templates.version() == templates.VERSION != templates.version(True) + assert templates.sentence(templates.IRRIGATION) is None # not a question for the model + + +def test_the_alert_list_gets_irrigation_battery_only_when_there_is_a_controller(): + assert available_kinds({"Ecowitt", "Irrigation"})[-2:] == ["irrigation", "irrigation_pause"] + assert "irrigation" not in available_kinds({"Ecowitt"}) and "irrigation_pause" not in available_kinds({"Ecowitt"}) + from lib.alerts.menu import LABELS, keyboard + assert LABELS["irrigation"] == "irrigation battery" and LABELS["irrigation_pause"] == "irrigation pause" + unsub = rows(keyboard(set(), ["rain", "irrigation"], "unsub")) + assert ("irrigation battery", "al:off:irrigation:unsub") in [b for row in unsub for b in row] + + +# ---------- the daily check ---------- +class Device: + def __init__(self, **status): + self.status_now = {"switch": False, "battery_percentage": 100, "weather_delay": "cancel", "work_state": "idle", **status} + self.commands, self.down = [], False + + async def status(self): + if self.down: + raise IrrigationError("Tuya request failed (ConnectError)") + return dict(self.status_now) + + def _reachable(self): + if self.down: + raise IrrigationError("Tuya request failed (ConnectError)") + + async def delay(self, duration): + self._reachable() + self.commands.append(("delay", duration)) + self.status_now["weather_delay"] = duration # the controller reports it + + async def set_switch(self, on): + self._reachable() + self.commands.append(("switch", on)) + self.status_now["switch"] = on + + +class Station: + """The weather station: readings whose daily rain total rises by the given amounts.""" + + def __init__(self, *rises, fail=False): + total, self.rows, self.fail = 0.0, [], fail + for i, rise in enumerate(rises): + total += rise + self.rows.append((1_000_000 + i * 300, {"rainfall.daily": total})) + + async def recent(self, hours): + assert hours == 24 + if self.fail: + raise RuntimeError("Ecowitt is down") + return self.rows + + +class Forecast: + def __init__(self, *chances): + self.days = [{"date": datetime(2026, 10, 8 + i).date(), "rain_1mm_pct": c} for i, c in enumerate(chances)] + [ + {"date": datetime(2026, 10, 10).date(), "rain_1mm_pct": 90}] # the day after tomorrow never counts + + def now(self): + return datetime(2026, 10, 8, 18, 0) + + def upcoming(self, count=2): + return self.days[:count] + + +NOW = datetime(2026, 10, 8, 18, 0) + + +def daily(tmp_path, device, station=None, forecast=None): + state, sent = AlertState(tmp_path / "s.json"), [] + + async def notify(text, link=None, **kw): + sent.append((text, kw)) + return IrrigationMonitor(device, state, notify, TZ, 18, station, forecast), state, sent + + +@pytest.mark.parametrize("station, forecast, delayed", [ + (Station(0, 0.5, 0.4), Forecast(10, 20), False), # 0.9 mm: not more than 1 + (Station(0, 0.6, 0.6), Forecast(10, 20), True), # 1.2 mm fell + (Station(0, 0, 0), Forecast(10, 50), True), # tomorrow 50%: at the line + (Station(0, 0, 0), Forecast(55, 0), True), # today + (Station(0, 0, 0), Forecast(45, 49), False), # both under the line + (Station(0, 0, 0), Forecast(None, None), False), # no ensemble figure: nothing to go on + (None, Forecast(0, 80), True), # no station: the forecast decides + (Station(0, 3, 0), None, True), # no forecast: the measured rain decides + (Station(fail=True), Forecast(0, 80), True), # the station failing leaves the forecast + (Station(fail=True), Forecast(0, 0), False), + (None, None, False), + (Station(2, 0, 0), Forecast(0, 0), False), # the first reading's total fell before the window: only rises count +]) +async def test_the_check_delays_a_day_when_more_than_1_mm_fell_or_is_likely(tmp_path, station, forecast, delayed): + device = Device() + m, state, sent = daily(tmp_path, device, station, forecast) + await m.check(NOW) + assert device.commands == ([("delay", "24h")] if delayed else []) + assert [(text.startswith("☔ Irrigation paused for 24 hours: ") and text.endswith("."), kw) for text, kw in sent] == ( + [(True, {"kind": "irrigation_pause"})] if delayed else []) # told once when it paused, never otherwise + assert state.monitor["irrigation"]["checked"] == "2026-10-08" + + +async def test_a_delay_already_set_is_never_shortened_or_repeated(tmp_path): + device = Device(weather_delay="72h") + m, _, sent = daily(tmp_path, device, Station(0, 5), Forecast(90, 90)) + await m.check(NOW) + assert device.commands == [] and sent == [] # nothing changed: nothing to tell + + +async def test_a_battery_under_5_percent_is_told_once_a_day_to_the_irrigation_alert_type(tmp_path): + device = Device(battery_percentage=4) + m, state, sent = daily(tmp_path, device) + await m.check(NOW) + await m.check(NOW) + assert len(sent) == 1 and sent[0][1] == {"kind": "irrigation"} and "4%" in sent[0][0] and "🪫" in sent[0][0] + await m.check(datetime(2026, 10, 9, 18, 0)) # the next day: again, until it is charged + assert len(sent) == 2 + device.status_now["battery_percentage"] = 5 # 5% is not below 5% + await m.check(datetime(2026, 10, 10, 18, 0)) + assert len(sent) == 2 + + +async def test_the_battery_is_still_told_when_the_delay_cannot_be_sent_and_not_told_twice_on_the_retry(tmp_path): + class Failing(Device): + async def delay(self, duration): + raise IrrigationError("Tuya error 28841002: no permission") + m, state, sent = daily(tmp_path, Failing(battery_percentage=2), None, Forecast(90, 90)) + with pytest.raises(IrrigationError): + await m.check(NOW) + assert len(sent) == 1 and "checked" not in state.monitor["irrigation"] # not recorded: the next try repeats the check + with pytest.raises(IrrigationError): + await m.check(NOW) + assert len(sent) == 1 and sent[0][1] == {"kind": "irrigation"} # no "paused" alert for a pause that was not made + + +def test_a_check_is_due_once_a_day_after_the_hour_and_the_next_is_the_following_18_00(tmp_path): + m, state, _ = daily(tmp_path, Device()) + assert not m.due(datetime(2026, 10, 8, 17, 59)) and m.due(datetime(2026, 10, 8, 18, 0)) and m.due(datetime(2026, 10, 8, 23, 0)) + state.monitor["irrigation"] = {"checked": "2026-10-08"} + assert not m.due(datetime(2026, 10, 8, 23, 0)) and not m.due(datetime(2026, 10, 9, 3, 0)) and m.due(datetime(2026, 10, 9, 18, 0)) + assert m.seconds_until_next(datetime(2026, 10, 8, 17, 0)) == 3600 and m.seconds_until_next(datetime(2026, 10, 8, 18, 0)) == 86400 + assert m.seconds_until_next(datetime(2026, 10, 8, 23, 30)) == 18.5 * 3600 + + +async def test_run_checks_when_due_retries_after_a_failure_and_then_waits_for_tomorrow(tmp_path, monkeypatch): + device = Device() + device.down = True + m, state, _ = daily(tmp_path, device, None, Forecast(90, 90)) + sleeps = [] + + async def fake_sleep(seconds): + sleeps.append(seconds) + if len(sleeps) == 1: + device.down = False # the controller is back after the first wait + if len(sleeps) == 3: + raise asyncio.CancelledError + monkeypatch.setattr(monitor_module.asyncio, "sleep", fake_sleep) + monkeypatch.setattr(monitor_module, "now_local", lambda tz: NOW) + with pytest.raises(asyncio.CancelledError): + await m.run() + assert sleeps[0] == 1800 and device.commands == [("delay", "24h")] # tried, failed, waited 30 minutes, tried, did it + assert state.monitor["irrigation"]["checked"] == "2026-10-08" + assert sleeps[1] == 86400 # then until 18:00 tomorrow (now is exactly 18:00 here) + + +async def test_the_watchdog_closes_a_valve_that_is_still_open_after_the_run_and_survives_a_restart(tmp_path): + clock, state = [1000.0], AlertState(tmp_path / "s.json") + device = Device(switch=True) + dog = Watchdog(device, state, clock=lambda: clock[0]) + await dog.check() + assert device.commands == [] # nothing armed: the controller is not even asked + dog.arm(10) + clock[0] = 1000 + 600 + 29 + await dog.check() + assert device.commands == [] and dog.off_at # inside the grace period: the device's own countdown may still end it + clock[0] = 1000 + 600 + 31 + reborn = Watchdog(device, AlertState(tmp_path / "s.json"), clock=lambda: clock[0]) # the bot restarted in the middle of the run + await reborn.check() + assert device.commands == [("switch", False)] and reborn.off_at is None + + +async def test_the_watchdog_leaves_a_closed_valve_alone_tries_again_on_failure_and_disarms_on_request(tmp_path): + clock, state = [0.0], AlertState(tmp_path / "s.json") + device = Device(switch=False) + dog = Watchdog(device, state, clock=lambda: clock[0]) + dog.arm(5) + clock[0] = 400 + await dog.check() + assert device.commands == [] and dog.off_at is None # it ended by itself + dog.arm(5) + device.down, device.status_now["switch"] = True, True + clock[0] = 800 + with pytest.raises(IrrigationError): + await dog.check() + assert dog.off_at is not None # kept: the next minute tries again + device.down = False + await dog.check() + assert device.commands == [("switch", False)] + dog.arm(5) + dog.disarm() + assert dog.off_at is None + + +# ---------- the buttons in the bot ---------- +class Query: + def __init__(self, data, chat_id=1, chat_type="private", user=7): + self.data, self.message = data, NS(chat=NS(type=chat_type, id=chat_id), chat_id=chat_id, text="") + self.from_user, self.toast, self.edits, self.fail = NS(id=user), "unset", [], None + + async def answer(self, text=None, **kw): + self.toast = text + + async def edit_message_reply_markup(self, reply_markup=None): + if self.fail: + raise self.fail + self.edits.append(("markup", reply_markup)) + + async def edit_message_text(self, text, reply_markup=None, entities=None): + if self.fail: + raise self.fail + self.edits.append(("text", text, reply_markup) + ((entities,) if entities else ())) + + +class Remote(Device): + """The controller as the bot's buttons see it: also water().""" + + async def water(self, minutes): + self._reachable() + self.commands.append(("water", minutes)) + self.status_now.update(switch=True, work_state="manual", countdown=minutes * 60) + + +def irrigation_bot(tmp_path, device=None): + state = AlertState(tmp_path / "s.json") + state.add_chat(1, "Rob") + device = device or Remote() + return Bot(NS(tz=TZ), None, [NS(name="Ecowitt")], state, device), state, device + + +async def press(bot, data, **kw): + q = Query(data, **kw) + await bot.on_irrigation_button(NS(callback_query=q), NS()) # no chat-member lookup is available: none is made + return q + + +async def test_the_button_sends_the_status_with_its_buttons_and_a_failure_says_so(tmp_path): + bot, state, device = irrigation_bot(tmp_path) + sent = [] + + async def reply_text(body, **kw): + sent.append((body, kw)) + msg = NS(text=templates.IRRIGATION, chat=NS(type="private", id=1, title=None), chat_id=1, reply_text=reply_text, reply_to_message=None, + message_thread_id=None, is_topic_message=False) + update = NS(effective_message=msg, effective_chat=msg.chat, effective_user=NS(id=7, full_name="Rob", username="rob")) + await bot.on_message(update, NS(bot=NS(username="b", id=99))) # no model is involved (the agent is None) + assert sent[0][0].startswith("🌱 Irrigation\nmode: idle\nstate: off ❌\npause: not paused (alerts: on)\nbattery: 100% 🔋 (alerts: on)") + assert rows(sent[0][1]["reply_markup"])[0][0] == ("💧 Water", "ir:open:water") and rows(sent[0][1]["reply_markup"])[1][0] == ("🔔 Alerts", "ir:open:alerts") + device.down = True + await bot.on_message(update, NS(bot=NS(username="b", id=99))) + assert sent[1][0] == "Sorry, I couldn't reach the irrigation controller." and len(sent) == 2 + + +async def test_opening_and_closing_a_section_only_changes_the_buttons(tmp_path): + bot, _, device = irrigation_bot(tmp_path) + q = await press(bot, "ir:open:water") + assert q.edits[0][0] == "markup" and rows(q.edits[0][1])[-2][0] == ("5 min", "ir:water:5") and rows(q.edits[0][1])[-1][0] == ("Off ❌", "ir:sw:off") and device.commands == [] + q = await press(bot, "ir:close") + assert len(rows(q.edits[0][1])) == 2 + for gone in ("ir:open:bogus", "ir:open:switch"): # an unknown section, or an old message's Switch, just closes + q = await press(bot, gone) + assert len(rows(q.edits[0][1])) == 2 + + +async def test_watering_a_delay_and_the_switch_act_on_the_controller_and_refresh_the_message(tmp_path): + bot, state, device = irrigation_bot(tmp_path) + q = await press(bot, "ir:water:10") + assert device.commands == [("water", 10)] and q.toast == "Watering for 10 minutes" and bot.watchdog.off_at + assert q.edits[0][0] == "text" and "state: on ✅" in q.edits[0][1] and "mode: manual" in q.edits[0][1] + assert "state: on ✅ (10 mins until off)" in q.edits[0][1] + q = await press(bot, "ir:sw:off") + assert device.commands[-1] == ("switch", False) and q.toast == "Switched off" and bot.watchdog.off_at is None and "state: off ❌" in q.edits[0][1] + q = await press(bot, "ir:delay:72h") + assert device.commands[-1] == ("delay", "72h") and q.toast == "Paused 72h" + q = await press(bot, "ir:delay:cancel") + assert q.toast == "Unpaused" and device.commands[-1] == ("delay", "cancel") # unpause is the device's own "cancel" + n = len(device.commands) + for data in ("ir:water:7", "ir:water:x", "ir:delay:1h", "ir:sw:on", "ir:sw:maybe", "ir:what", "ir:", "garbage"): # nothing the buttons offer (On is gone) + q = await press(bot, data) + assert q.edits == [] and q.toast is None, data + assert len(device.commands) == n + + +async def test_the_alerts_menu_toggles_each_alert_type_for_this_chat_alone_and_stays_open(tmp_path): + bot, state, _ = irrigation_bot(tmp_path) + q = await press(bot, "ir:open:alerts") + assert q.edits[0][0] == "markup" and [r[0][0] for r in rows(q.edits[0][1])[2:]] == ["🔕 Disable battery alerts", "🔕 Disable pause alerts"] + q = await press(bot, "ir:batt:off") + assert state.muted(1) == {"irrigation"} and q.toast == "Irrigation battery alerts off in this chat" + assert rows(q.edits[0][2])[2:] == [[("🔔 Enable battery alerts", "ir:batt:on")], [("🔕 Disable pause alerts", "ir:pause:off")]] # still open + assert q.edits[0][1].endswith("pause: not paused (alerts: on)\nbattery: 100% 🔋 (alerts: off)") # the status text follows + q = await press(bot, "ir:pause:off") + assert state.muted(1) == {"irrigation", "irrigation_pause"} and q.toast == "Irrigation pause alerts off in this chat" + assert q.edits[0][1].endswith("pause: not paused (alerts: off)\nbattery: 100% 🔋 (alerts: off)") and rows(q.edits[0][2])[3] == [("🔔 Enable pause alerts", "ir:pause:on")] + q = await press(bot, "ir:batt:on") + assert state.muted(1) == {"irrigation_pause"} and q.toast == "Irrigation battery alerts on in this chat" # the other type is untouched + q = await press(bot, "ir:pause:on") + assert state.muted(1) == set() and state.alert_chats("irrigation") == [1] == state.alert_chats("irrigation_pause") + for kind, prefix in (("irrigation", "batt"), ("irrigation_pause", "pause")): # whatever a button says, pressing it does that + for _ in range(2): + alerts = rows(menu.keyboard(state.subscribed(1, "irrigation"), state.subscribed(1, "irrigation_pause"), "alerts")) + text, data = next(b for row in alerts[2:] for b in row if b[1].startswith(f"ir:{prefix}:")) + await press(bot, data) + assert state.subscribed(1, kind) is text.startswith("🔔 Enable"), (text, data) + other = await press(bot, "ir:water:5") + assert len(rows(other.edits[0][2])) == 2 # any other action redraws collapsed + other = await press(bot, "ir:refresh") + assert len(rows(other.edits[0][2])) == 2 + + +async def test_a_controller_that_cannot_be_reached_gives_a_toast_not_a_traceback_and_an_unchanged_message_is_fine(tmp_path): + bot, state, device = irrigation_bot(tmp_path) + device.down = True + q = await press(bot, "ir:delay:24h") + assert q.toast == "Couldn't reach the irrigation controller" and q.edits == [] + device.down = False + q = Query("ir:delay:24h") + q.fail = BadRequest("Message is not modified: specified new message content and reply markup are exactly the same") + await bot.on_irrigation_button(NS(callback_query=q), NS()) # swallowed + q.fail = BadRequest("Message to edit not found") + with pytest.raises(BadRequest): + await bot.on_irrigation_button(NS(callback_query=q), NS()) + plain = Bot(NS(tz=TZ), None, [], AlertState(tmp_path / "t.json")) # no controller configured: nothing happens + q = await press(plain, "ir:delay:24h") + assert q.edits == [] and plain.watchdog is None + + +async def test_the_handler_is_registered_and_the_alert_list_includes_irrigation_only_with_a_controller(tmp_path): + bot, state, _ = irrigation_bot(tmp_path) + added = [] + app = NS(add_handler=lambda h, group=0: added.append(h), add_error_handler=lambda h: None) + bot.register(app) + plain = Bot(NS(tz=TZ), None, [NS(name="Ecowitt")], AlertState(tmp_path / "t.json")) + added_plain = [] + plain.register(NS(add_handler=lambda h, group=0: added_plain.append(h), add_error_handler=lambda h: None)) + assert len(added) == len(added_plain) + 1 + assert "irrigation" in bot._alert_kinds() and "irrigation" not in plain._alert_kinds() + assert bot.keyboard_version == templates.version(True) != plain.keyboard_version == templates.VERSION + + +@pytest.mark.parametrize("text, yes", [ + ("irrigation", True), ("Irrigation", True), ("water", True), ("tap", True), ("sprinkler", True), ("sprinklers on?", True), + ("turn the sprinkler on", True), ("is the tap on", True), ("watering", True), ("irrigate the garden", True), ("🌱 Irrigation", True), + ("how much water did the rain add this week", False), # a long question is the model's + ("tapestry", False), ("waterfall", False), ("taping", False), ("tapped out", False), ("rain", False), ("", False), (" ", False), + ("temperature chart 1d", False), ("Weather", False)]) +def test_short_messages_with_a_keyword_ask_for_the_controller(text, yes): + assert menu.asked(text) is yes + + +def private_message(text): + sent = [] + + async def reply_text(body, **kw): + sent.append((body, kw)) + msg = NS(text=text, chat=NS(type="private", id=1, title=None), chat_id=1, reply_text=reply_text, reply_to_message=None, + message_thread_id=None, is_topic_message=False) + return NS(effective_message=msg, effective_chat=msg.chat, effective_user=NS(id=7, full_name="Rob", username="rob")), sent + + +def group_message(text, replied=False): + sent = [] + + async def reply_text(body, **kw): + sent.append((body, kw)) + reply = NS(from_user=NS(id=99)) if replied else None + msg = NS(text=text, chat=NS(type="supergroup", id=-5, title="Home"), chat_id=-5, reply_text=reply_text, reply_to_message=reply, + message_thread_id=None, is_topic_message=False) + return NS(effective_message=msg, effective_chat=msg.chat, effective_user=NS(id=7, full_name="Rob", username="rob")), sent + + +class NoModel: + async def run(self, *a, **k): + raise AssertionError("the model was asked") + + +async def test_typing_irrigation_does_what_the_button_does_in_private_chats_and_when_addressed_in_groups(tmp_path): + bot, state, device = irrigation_bot(tmp_path) + bot.agent = NoModel() + ctx = NS(bot=NS(username="testbot", id=99)) + for text in ("irrigation", "water", "turn the sprinkler on", templates.IRRIGATION): + update, sent = private_message(text) + await bot.on_message(update, ctx) + assert sent[0][0].startswith("🌱 Irrigation\nmode: idle\nstate: off ❌") and sent[0][1]["reply_markup"], text + update, sent = group_message("@testbot irrigation") + await bot.on_message(update, ctx) + assert sent[0][0].startswith("🌱 Irrigation") and rows(sent[0][1]["reply_markup"])[0][0] == ("💧 Water", "ir:open:water") + update, sent = group_message("water", replied=True) # a reply to the bot counts as addressing it + await bot.on_message(update, NS(bot=NS(username="testbot", id=99))) + assert sent == [] or sent[0][0].startswith("🌱 Irrigation") + update, sent = group_message("irrigation please") # not addressed: ignored, as every group message is + await bot.on_message(update, ctx) + assert sent == [] + + +async def test_other_questions_and_a_bot_without_a_controller_still_go_to_the_model(tmp_path): + asked = [] + + class Agent: + async def run(self, working, *a, **k): + asked.append(working[-1]["content"]) + return "ok" + state = AlertState(tmp_path / "s.json") + state.add_chat(1, "Rob") + bot = Bot(NS(tz=TZ), Agent(), [], state, Remote()) + update, sent = private_message("how much water did the rain add this week") + await bot.on_message(update, NS(bot=NS(username="testbot", id=99))) + assert asked == ["how much water did the rain add this week"] + plain = Bot(NS(tz=TZ), Agent(), [], AlertState(tmp_path / "t.json")) + update, sent = private_message("irrigation") + await plain.on_message(update, NS(bot=NS(username="testbot", id=99))) + assert asked[-1] == "irrigation" # nothing to show: the model answers as before + + +async def test_the_message_is_redrawn_a_couple_of_seconds_after_a_command_and_shows_the_chat_s_battery_alerts(tmp_path, monkeypatch): + monkeypatch.setattr(bot_module, "IRRIGATION_SETTLE_SECONDS", 3) + bot, state, device = irrigation_bot(tmp_path) + events = [] + + async def sleep(seconds): + events.append(("sleep", seconds)) + original_status = device.status + + async def status(): + events.append(("status",)) + return await original_status() + device.status = status + monkeypatch.setattr(bot_module.asyncio, "sleep", sleep) + q = await press(bot, "ir:water:5") + assert events == [("status",), ("sleep", 3), ("status",)] and q.toast == "Watering for 5 minutes" # a look at the pause, the command, a wait, the fresh state + for data in ("ir:delay:24h", "ir:delay:cancel", "ir:sw:off", "ir:batt:off", "ir:batt:on"): # every action waits the same 3 seconds + events.clear() + await press(bot, data) + assert events == [("sleep", 3), ("status",)], data + assert "(alerts: on)" in q.edits[0][1] + events.clear() + q = await press(bot, "ir:open:delay") # opening a list changes nothing: no wait + assert events == [] and q.edits[0][0] == "markup" + q = await press(bot, "ir:batt:off") # an action too, so the same wait + assert events == [("sleep", 3), ("status",)] and "(alerts: off)" in q.edits[0][1] + events.clear() + device.down = True + q = await press(bot, "ir:delay:24h") # a failed command: no wait, no redraw + assert events == [] and q.toast == "Couldn't reach the irrigation controller" and q.edits == [] + + +async def test_a_status_that_cannot_be_read_after_a_command_leaves_the_message_alone(tmp_path): + bot, state, device = irrigation_bot(tmp_path) + + async def delay(duration): + device.commands.append(("delay", duration)) + device.down = True # the command went through, then the line dropped + device.delay = delay + q = await press(bot, "ir:delay:48h") + assert device.commands == [("delay", "48h")] and q.toast == "Paused 48h" and q.edits == [] + + +@pytest.mark.parametrize("station, forecast, why", [ + (Station(0, 0.6, 0.6), None, "1.2 mm of rain in the last 24 hours"), + (None, Forecast(10, 50), "50% chance of at least 1 mm tomorrow"), + (None, Forecast(55, 0), "55% chance of at least 1 mm today"), + (Station(0, 3, 0), Forecast(90, 90), "3 mm of rain in the last 24 hours"), # what fell comes first +]) +async def test_the_pause_alert_says_why(tmp_path, station, forecast, why): + m, _, sent = daily(tmp_path, Device(), station, forecast) + await m.check(NOW) + assert sent == [(f"☔ Irrigation paused for 24 hours: {why}.", {"kind": "irrigation_pause"})] + + +async def test_a_pause_that_fails_and_is_retried_is_told_exactly_once(tmp_path): + class Flaky(Device): + failures = 1 # the first command is refused, the second goes through + + async def delay(self, duration): + if self.failures: + self.failures -= 1 + raise IrrigationError("Tuya request failed (ConnectError)") + await super().delay(duration) + device = Flaky() + m, state, sent = daily(tmp_path, device, None, Forecast(90, 90)) + with pytest.raises(IrrigationError): + await m.check(NOW) + assert sent == [] and "checked" not in state.monitor["irrigation"] # nothing told for a pause that was not made + await m.check(NOW) + assert device.commands == [("delay", "24h")] and len(sent) == 1 and state.monitor["irrigation"]["checked"] == "2026-10-08" + + +async def test_the_pause_alert_reaches_every_subscribed_chat_with_the_usual_menu(tmp_path): + from lib.alerts import Notifier + state, delivered = AlertState(tmp_path / "s.json"), [] + for chat in (1, 2, 3, -7): + state.add_chat(chat, str(chat)) + state.set_kind(2, "irrigation_pause", False) # this chat turned the pause alerts off + state.set_kind(3, "irrigation", False) # ... and this one only the battery alerts: it still gets the pause + + class Telegram: + async def send_message(self, chat_id, text, **kw): + delivered.append((chat_id, text, kw)) + notifier = Notifier(Telegram(), state, available_kinds({"Ecowitt", "Irrigation"})) + m = IrrigationMonitor(Device(), state, notifier, TZ, 18, None, Forecast(90, 90)) + await m.check(NOW) + assert sorted(c for c, _, _ in delivered) == [-7, 1, 3] and all(text.startswith("☔ Irrigation paused") for _, text, _ in delivered) + markup = delivered[0][2]["reply_markup"] + assert rows(markup) == [[("➕ Subscribe", "al:open:sub:irrigation_pause"), ("➖ Unsubscribe", "al:open:unsub:irrigation_pause")]] + from lib.alerts.menu import keyboard + kinds = available_kinds({"Ecowitt", "AirGradient", "Pollen", "Forecast", "Irrigation"}) + for section in (None, "sub", "unsub"): # every button's data fits Telegram's 64 bytes, for the new type too + for parent in (None, "irrigation_pause", "irrigation"): + assert all(len(data.encode()) <= 64 for row in rows(keyboard(set(), kinds, section, parent)) for _, data in row) + + +async def test_refresh_reads_the_controller_again_and_redraws_without_sending_anything_or_waiting(tmp_path, monkeypatch): + monkeypatch.setattr(bot_module, "IRRIGATION_SETTLE_SECONDS", 3) + slept = [] + + async def sleep(seconds): + slept.append(seconds) + monkeypatch.setattr(bot_module.asyncio, "sleep", sleep) + bot, state, device = irrigation_bot(tmp_path) + device.status_now.update(switch=True, countdown=300, work_state="auto") # changed on the controller (its own schedule, or the app) + q = await press(bot, "ir:refresh") + assert q.toast == "Refreshed" and device.commands == [] and slept == [] + assert q.edits[0][0] == "text" and "mode: auto\nstate: on ✅ (5 mins until off)" in q.edits[0][1] and "(alerts: on)" in q.edits[0][1] + assert rows(q.edits[0][2])[1][1] == ("🔄 Refresh", "ir:refresh") # the buttons come back collapsed + device.down = True + q = await press(bot, "ir:refresh") + assert q.toast == "Couldn't reach the irrigation controller" and q.edits == [] + q = Query("ir:refresh") # the same text again: Telegram says "not modified", which is fine + q.fail = BadRequest("Message is not modified: specified new message content and reply markup are exactly the same") + device.down = False + await bot.on_irrigation_button(NS(callback_query=q), NS()) + assert q.toast == "Refreshed" + + +async def test_every_member_of_a_group_may_use_the_irrigation_buttons(tmp_path): + bot, state, device = irrigation_bot(tmp_path) + state.add_chat(-5, "Home") + group = dict(chat_id=-5, chat_type="supergroup", user=42) # an ordinary member: no admin check is made + q = await press(bot, "ir:open:water", **group) + assert q.edits[0][0] == "markup" and rows(q.edits[0][1])[-2][0] == ("5 min", "ir:water:5") + q = await press(bot, "ir:water:10", **group) + assert device.commands == [("water", 10)] and q.toast == "Watering for 10 minutes" and "state: on ✅" in q.edits[0][1] + q = await press(bot, "ir:sw:off", **group) + assert device.commands[-1] == ("switch", False) and q.toast == "Switched off" + q = await press(bot, "ir:delay:24h", **group) + assert device.commands[-1] == ("delay", "24h") and q.toast == "Paused 24h" + q = await press(bot, "ir:delay:cancel", **group) + assert q.toast == "Unpaused" + q = await press(bot, "ir:refresh", **group) + assert q.toast == "Refreshed" and q.edits + q = await press(bot, "ir:batt:off", **group) + assert state.muted(-5) == {"irrigation"} and q.toast == "Irrigation battery alerts off in this chat" + + +def test_the_error_footer_is_bold_and_positioned_in_utf16_units(): + text, entities = menu.with_error("🌱 Irrigation\nmode: idle") + assert text == "🌱 Irrigation\nmode: idle\n\nerror: can not turn on water while paused!" + (bold,) = entities + units = lambda s: len(s.encode("utf-16-le")) // 2 + assert bold.type == "bold" and bold.length == units(menu.PAUSED_ERROR) == len(menu.PAUSED_ERROR) + assert bold.offset == units("🌱 Irrigation\nmode: idle\n\n") == len("🌱 Irrigation\nmode: idle\n\n") + 1 # the emoji counts twice + + +async def test_water_is_refused_while_paused_with_a_toast_and_a_bold_footer_until_it_is_unpaused(tmp_path, monkeypatch): + slept = [] + + async def sleep(seconds): + slept.append(seconds) + monkeypatch.setattr(bot_module, "IRRIGATION_SETTLE_SECONDS", 3) + monkeypatch.setattr(bot_module.asyncio, "sleep", sleep) + bot, state, device = irrigation_bot(tmp_path) + await press(bot, "ir:delay:48h") # paused + device.commands.clear() + slept.clear() + for minutes in (5, 30): + q = await press(bot, f"ir:water:{minutes}") + assert device.commands == [] and bot.watchdog.off_at is None and slept == [] # nothing sent, no run to watch, no wait + assert q.toast == "error: can not turn on water while paused!" + kind, text, markup, entities = q.edits[0] + assert text.startswith("🌱 Irrigation\nmode: idle\nstate: off ❌\npause: 48h") and text.endswith("\n\nerror: can not turn on water while paused!") + assert [(e.type, e.length) for e in entities] == [("bold", len(menu.PAUSED_ERROR))] and len(rows(markup)) == 2 # collapsed + q = await press(bot, "ir:sw:off") # switching off is still allowed while paused + assert device.commands == [("switch", False)] and "error:" not in q.edits[0][1] + q = await press(bot, "ir:refresh") + assert "error:" not in q.edits[0][1] # the footer goes with the next redraw + await press(bot, "ir:delay:cancel") # unpaused: water works again + q = await press(bot, "ir:water:10") + assert device.commands[-1] == ("water", 10) and q.toast == "Watering for 10 minutes" and "error:" not in q.edits[0][1] + assert bot.watchdog.off_at and slept[-1] == 3 + + +async def test_water_while_the_status_cannot_be_read_says_so_and_sends_nothing(tmp_path): + bot, state, device = irrigation_bot(tmp_path) + device.down = True + q = await press(bot, "ir:water:5") + assert q.toast == "Couldn't reach the irrigation controller" and q.edits == [] and device.commands == [] + + +def test_a_status_message_says_whether_it_is_paused(): + assert menu.is_paused({"weather_delay": "24h"}) and not menu.is_paused({"weather_delay": "cancel"}) and not menu.is_paused({}) + assert menu.paused_in(menu.status_text({"weather_delay": "72h"}, True, True)) is True + assert menu.paused_in(menu.status_text({"weather_delay": "cancel"}, True, True)) is False + assert menu.paused_in(menu.status_text({}, True, True)) is False and menu.paused_in("") is False and menu.paused_in(None) is False + assert menu.paused_in("mode: idle\npause time: 24h (alerts: on)") is True and menu.paused_in("pause time: inactive (alerts: on)") is False # an older message + + +async def test_while_paused_the_button_is_unpause_and_one_press_ends_the_pause(tmp_path): + bot, state, device = irrigation_bot(tmp_path) + update, sent = private_message("irrigation") + await bot.on_message(update, NS(bot=NS(username="testbot", id=99))) + assert rows(sent[0][1]["reply_markup"])[0][1] == ("⏸ Pause", "ir:open:delay") # not paused + q = await press(bot, "ir:open:delay") + assert [b[0] for b in rows(q.edits[0][1])[-1]] == ["24h", "48h", "72h"] + q = await press(bot, "ir:delay:48h") # pause it + assert q.toast == "Paused 48h" and "pause: 48h" in q.edits[0][1] + assert rows(q.edits[0][2])[0][1] == ("▶️ Unpause", "ir:delay:cancel") # the redrawn button follows the controller + shown = q.edits[0][1] + q = await press_on(bot, "ir:open:water", shown) # opening another list keeps the Unpause button + assert rows(q.edits[0][1])[0][1] == ("▶️ Unpause", "ir:delay:cancel") and rows(q.edits[0][1])[-2][0] == ("5 min", "ir:water:5") + q = await press_on(bot, "ir:close", shown) + assert rows(q.edits[0][1])[0][1] == ("▶️ Unpause", "ir:delay:cancel") + q = await press(bot, "ir:refresh") + assert rows(q.edits[0][2])[0][1] == ("▶️ Unpause", "ir:delay:cancel") + q = await press(bot, "ir:delay:cancel") # the Unpause button + assert q.toast == "Unpaused" and device.commands[-1] == ("delay", "cancel") and "pause: not paused" in q.edits[0][1] + assert rows(q.edits[0][2])[0][1] == ("⏸ Pause", "ir:open:delay") # and it is Pause again + device.status_now["weather_delay"] = "24h" # paused from the app + q = await press(bot, "ir:refresh") + assert rows(q.edits[0][2])[0][1] == ("▶️ Unpause", "ir:delay:cancel") + q = await press(bot, "ir:water:5") # a refused water press keeps it too + assert rows(q.edits[0][2])[0][1] == ("▶️ Unpause", "ir:delay:cancel") and q.edits[0][1].endswith("paused!") + + +async def press_on(bot, data, text): + """A press under a message that currently reads `text`.""" + q = Query(data) + q.message.text = text + await bot.on_irrigation_button(NS(callback_query=q), NS()) + return q diff --git a/tests/test_rain.py b/tests/test_rain.py index 199142d..eaf80b9 100644 --- a/tests/test_rain.py +++ b/tests/test_rain.py @@ -26,3 +26,40 @@ def test_rain_bars_by_month_or_year_are_one_bar_each_with_dry_ones_as_0(): years = rain_bars(rain, tz, date(2024, 6, 1), date(2025, 12, 31), by="year") assert years.y == [0.0, 9.5] and years.per == "year" assert rain_bars(rain, tz, date(2025, 1, 1), date(2025, 4, 30)).per == "day" # without by: the usual layout + + +def test_rain_bars_of_a_rolling_period_start_at_the_bar_holding_its_start_not_at_midnight(): + """The last 24 hours, asked at 7:40pm: there were 19 hours of empty bars left of the data (which stretched every panel's axis).""" + from datetime import date + from zoneinfo import ZoneInfo + from lib.rain import rain_bars + from lib.timeutil import day_bounds + tz = ZoneInfo("Australia/Melbourne") + midnight = day_bounds(date(2026, 10, 7), tz)[0] + since, until = midnight + 19 * 3600 + 40 * 60, midnight + 43 * 3600 + 40 * 60 # Wed 7:40pm to Thu 7:40pm + bars = rain_bars({midnight + 20 * 3600 + 600: 0.4}, tz, date(2026, 10, 7), date(2026, 10, 8), until=until, since=since) + assert bars.per == "hour" and bars.x[0] == midnight + 19 * 3600 and bars.x[-1] == midnight + 43 * 3600 and len(bars.x) == 25 + assert bars.y[1] == 0.4 and sum(bars.y) == 0.4 # the 8pm hour holds the rain + assert rain_bars({}, tz, date(2026, 10, 7), date(2026, 10, 8), until=until).x[0] == midnight # without `since`: from midnight, as before + assert rain_bars({}, tz, date(2026, 10, 7), date(2026, 10, 8), until=until, since=midnight - 3600).x[0] == midnight # never before the origin + assert rain_bars({}, tz, date(2026, 10, 7), date(2026, 10, 8), until=until, since=midnight).x[0] == midnight + weeks = rain_bars({}, tz, date(2026, 9, 27), date(2026, 10, 3), since=day_bounds(date(2026, 9, 27), tz)[0] + 7 * 3600) # 6-hourly: the bar holding 7am + assert weeks.per == "6 hours" and weeks.x[0] == day_bounds(date(2026, 9, 27), tz)[0] + 6 * 3600 and len(weeks.x) == 7 * 4 - 1 + by_month = rain_bars({}, tz, date(2025, 1, 1), date(2025, 4, 30), by="month", since=day_bounds(date(2025, 2, 10), tz)[0]) + assert len(by_month.x) == 4 # a figure per month ignores it + + +def test_a_stacked_chart_over_a_rolling_day_with_no_rain_has_no_axis_left_of_its_data(): + """The chart in the report: the x range is what the lines and bars draw, and the bars no longer reach back to midnight.""" + from datetime import date + from zoneinfo import ZoneInfo + from lib.rain import rain_bars + from lib.specs import Line, Panel + from lib.timeutil import day_bounds + tz = ZoneInfo("Australia/Melbourne") + midnight = day_bounds(date(2026, 10, 7), tz)[0] + since, until = midnight + 19 * 3600 + 40 * 60, midnight + 43 * 3600 + 40 * 60 + xs = list(range(since, until, 1800)) + bars = rain_bars({}, tz, date(2026, 10, 7), date(2026, 10, 8), until=until, since=since) + panel = Panel("Humidity", "%", [Line("Outdoor", xs, [60.0 + (i % 5) for i in range(len(xs))], reading="humidity")], bars=bars, reading="humidity") + assert min(panel.xs) == bars.x[0] and since - min(panel.xs) < 3600 # at most the hour the data starts in diff --git a/tests/test_startup.py b/tests/test_startup.py index d7a8a7f..1ab2b99 100644 --- a/tests/test_startup.py +++ b/tests/test_startup.py @@ -177,3 +177,40 @@ def test_vpd_in_chart_all_is_off_by_default_and_a_config_option_turns_it_on(monk assert Config.from_env().chart_all_vpd is False monkeypatch.setenv("CHART_ALL_VPD", "on") assert Config.from_env().chart_all_vpd is True + + +async def test_main_with_the_irrigation_controller_starts_its_check_and_watchdog_and_closes_it(tmp_path, monkeypatch, caplog): + import httpx + from lib.irrigation import tuya as tuya_module + caplog.set_level("INFO") + cfg = config(tmp_path, tuya_device_id="dev1", tuya_client_id="cid", tuya_client_secret="s") + eco_t, _ = ecowitt_transport(history_days=45) + air_t, _ = air_transport(oldest=datetime.now(timezone.utc) - timedelta(days=5)) + closed = [] + + def tuya(request): + if request.url.path.startswith("/v1.0/token"): + return httpx.Response(200, json={"success": True, "result": {"access_token": "t", "expire_time": 7200}}) + return httpx.Response(200, json={"success": True, "result": [{"code": "battery_percentage", "value": 80}]}) + app = FakeApp() + monkeypatch.setattr(ecobot.Config, "from_env", classmethod(lambda cls: cfg)) + for cls, transport in ((ecobot.Ecowitt, eco_t), (ecobot.AirGradient, air_t)): + monkeypatch.setattr(cls, "__init__", lambda self, c, transport=None, orig=cls.__init__, t=transport: orig(self, c, t)) + monkeypatch.setattr(tuya_module.Tuya, "__init__", lambda self, c, transport=None, orig=tuya_module.Tuya.__init__: orig(self, c, httpx.MockTransport(tuya))) + monkeypatch.setattr(tuya_module.Tuya, "close", lambda self, orig=tuya_module.Tuya.close: closed.append(1) or orig(self)) + monkeypatch.setattr(ecobot.Application, "builder", staticmethod(lambda: FakeBuilder(app))) + monkeypatch.setattr(archive, "PACE_SECONDS", 0) + monkeypatch.setattr(air_source, "BACKFILL_PACE", 0) + monkeypatch.setattr(air_source, "BACKFILL_EMPTY_STOP", 3) + + async def stop_when_archived(): + for _ in range(300): + if " req, held " in caplog.text and "day(s) cached" in caplog.text: + break + await asyncio.sleep(0.1) + os.kill(os.getpid(), signal.SIGTERM) + stopper = asyncio.create_task(stop_when_archived()) + await ecobot.main() + await stopper + assert "irrigation battery" in caplog.text # in the list of alert types + assert len(app.handlers) == 11 and closed == [1] # the irrigation buttons' handler, and the client closed at shutdown diff --git a/tests/test_templates.py b/tests/test_templates.py index 567368a..fcc276e 100644 --- a/tests/test_templates.py +++ b/tests/test_templates.py @@ -91,8 +91,8 @@ async def test_start_carries_the_keyboard_in_private_chats_only_and_keyboard_off -def test_status_is_on_the_right_hand_side_of_the_first_row(): - assert [label for label, _ in templates.ROWS[0]][-1].endswith("Status") +def test_status_is_in_the_middle_of_the_first_row_and_rain_on_the_right(): + assert [label.split(" ", 1)[1] for label, _ in templates.ROWS[0]] == ["Help & Alerts", "Status", "Rain"] class Replies: