diff --git a/README.md b/README.md index d0a5ed2..342d0a8 100644 --- a/README.md +++ b/README.md @@ -2,8 +2,8 @@ A Telegram bot for a personal Ecowitt weather station and AirGradient air-quality sensor. Ask it about the weather or air quality (text or charts); it also sends silent alerts to every -chat it is in: rain starting/stopping, gusts over 40 km/h, UV index of 10 or more, indoor/outdoor temperatures crossing (held back for the cooldown, then announced if it still stands), -and unhealthy air (with when it is safe again). Every alert carries Subscribe / Unsubscribe buttons that expand into the alert types (rain, gusts, UV, temperature crossing, particulates, pollen & asthma, forecast changes): Subscribe lists the types that are off, Unsubscribe the ones that are on; `/alerts` opens the same settings (in a private chat, a ⚙️ Settings button there and under each alert has Enable and Disable notification sounds sub-menus that list the alert types; alerts are silent unless you do; and `/alerts on|off` still subscribes or unsubscribes every type); `/usage` is the Help & Alerts button. In a group anyone can open `/alerts`, but only admins can change anything (`lib/alerts/menu.py`). +chat it is in: rain starting/stopping, gusts over 40 km/h (with the last day's wind chart), UV index of 10 or more in two readings in a row (with the last day's UV chart), indoor/outdoor temperatures crossing by more than 1°C (held back for the cooldown, then announced if it still stands, with the last day's temperature chart), +and unhealthy air (two readings in a row; with when it is safe again; both with the last day's particulates chart). Every alert carries Subscribe / Unsubscribe buttons that expand into the alert types (rain, gusts, UV, temperature crossing, particulates, pollen & asthma, forecast changes): Subscribe lists the types that are off, Unsubscribe the ones that are on; `/alerts` opens the same settings (in a private chat, a ⚙️ Settings button there and under each alert has Enable and Disable notification sounds sub-menus that list the alert types; alerts are silent unless you do; and `/alerts on|off` still subscribes or unsubscribes every type); `/usage` is the Help & Alerts button. In a group anyone can open `/alerts`, but only admins can change anything (`lib/alerts/menu.py`). ## Run diff --git a/lib/alerts/confirm.py b/lib/alerts/confirm.py new file mode 100644 index 0000000..664e860 --- /dev/null +++ b/lib/alerts/confirm.py @@ -0,0 +1,23 @@ +"""One rule for the alerts that must not fire on a single odd reading (UV, particulates): the condition has to hold for CONFIRM_READINGS +distinct readings in a row, "distinct" meaning by the reading's own timestamp, not by how many times it was looked at. A reading already +counted (the same or an older time) is ignored; a reading that does not meet the condition ends the run; a reading with no value is skipped +(it neither extends nor ends the run). The run lives in the monitor's saved dict, so it carries across checks and restarts. + +(Rain and the temperature crossing use the back-off rule in backoff.py instead: a change that must stand for a cooldown.)""" + +CONFIRM_READINGS = 2 + + +def new_reading(saved: dict, stamp: float) -> bool: + """Is `stamp` (the time of a reading) newer than the last one counted? If so it is remembered, and the caller counts the reading.""" + if stamp <= saved.get("stamp", float("-inf")): + return False + saved["stamp"] = stamp + return True + + +def run(saved: dict, key: str, hit: bool | None) -> int: + """Add a reading to the run kept in saved[key]: it grows when `hit`, ends (0) when not, and stays when `hit` is None. Returns the run.""" + if hit is not None: + saved[key] = saved.get(key, 0) + 1 if hit else 0 + return saved.get(key, 0) diff --git a/lib/alerts/notify.py b/lib/alerts/notify.py index 3a6bb5f..66f8a41 100644 --- a/lib/alerts/notify.py +++ b/lib/alerts/notify.py @@ -194,11 +194,12 @@ def __init__(self, bot, state: AlertState, kinds: list[str] | None = None): """kinds: the alert types this bot sends (the settings buttons under each alert list them).""" self.bot, self.state, self.kinds = bot, state, kinds or list(LABELS) - async def __call__(self, text: str, link: tuple[str, str] | None = None, kind: str | None = None): + async def __call__(self, text: str, link: tuple[str, str] | None = None, kind: str | None = None, photo: bytes | None = None): """Send an alert (silently) to every chat subscribed to its type; forget chats the bot can no - longer post to. link = (label, url) adds a clickable label to the footer. Each carries the settings buttons.""" + longer post to. link = (label, url) adds a clickable label to the footer. Each carries the settings buttons. + photo: a picture the text is the caption of (the text alone if Telegram refuses the picture).""" text, entities = with_footer(text, link) - sent = sum([await self._send(chat_id, text, entities, kind) for chat_id in self.state.alert_chats(kind)]) + sent = sum([await self._send(chat_id, text, entities, kind, photo) for chat_id in self.state.alert_chats(kind)]) log.info("Alert sent to %d chat(s): %s", sent, text.replace("\n", " ")) async def to_chat(self, chat_id: int, text: str, link: tuple[str, str] | None = None, kind: str | None = None) -> bool: @@ -211,16 +212,28 @@ async def to_chat(self, chat_id: int, text: str, link: tuple[str, str] | None = log.info("Alert %s chat %s: %s", "sent to" if sent else "NOT sent to", chat_id, text.replace("\n", " ")) return sent - async def _send(self, chat_id: int, text: str, entities, kind: str | None = None) -> bool: - """Send silently, with the settings buttons (which highlight this alert's own type); with sound only if the chat turned it on for this type; forget a chat the bot can no longer post to.""" + async def _post(self, chat_id: int, text: str, entities, kind: str | None, photo: bytes | None): + """The message itself: silent unless the chat turned sound on for this type, with the settings buttons (which highlight this alert's own + type). With a photo it is the picture with the text as its caption; a picture Telegram refuses falls back to the text alone.""" + common = dict(disable_notification=kind not in self.state.loud(chat_id), + reply_markup=keyboard(self.state.muted(chat_id), self.kinds, parent=kind, loud=self.state.loud(chat_id) if chat_id > 0 else None)) + if photo: + try: + return await self.bot.send_photo(chat_id, photo, caption=text, caption_entities=entities, **common) + except BadRequest as e: + if "not found" in str(e).lower(): + raise + log.warning("Alert picture to %s failed (%s); sending the text alone", chat_id, e) + return await self.bot.send_message(chat_id, text, entities=entities, disable_web_page_preview=True, **common) + + async def _send(self, chat_id: int, text: str, entities, kind: str | None = None, photo: bytes | None = None) -> bool: + """Send an alert (see _post); forget a chat the bot can no longer post to.""" try: - await self.bot.send_message(chat_id, text, entities=entities, disable_notification=kind not in self.state.loud(chat_id), - disable_web_page_preview=True, - reply_markup=keyboard(self.state.muted(chat_id), self.kinds, parent=kind, loud=self.state.loud(chat_id) if chat_id > 0 else None)) + await self._post(chat_id, text, entities, kind, photo) return True except ChatMigrated as e: # the group became a supergroup: follow it, and send again there self.state.migrate_chat(chat_id, e.new_chat_id) - return await self._send(e.new_chat_id, text, entities, kind) if e.new_chat_id != chat_id else False + return await self._send(e.new_chat_id, text, entities, kind, photo) if e.new_chat_id != chat_id else False except Forbidden as e: # kicked from the group, or blocked in a private chat self.state.remove_chat(chat_id, f"can't post: {e}") except BadRequest as e: diff --git a/lib/alerts/particulates.py b/lib/alerts/particulates.py index db021c8..0c1059d 100644 --- a/lib/alerts/particulates.py +++ b/lib/alerts/particulates.py @@ -1,20 +1,21 @@ """Particulates alerts (PM2.5 and PM10) from the AirGradient sensor, checked every 30 minutes. "Wear a mask outside" when PM2.5 or PM10 reaches US AQI 151+ ("unhealthy", the official level -at which masks are advised for everyone) for two checks in a row; "back to safe" when both are at -AQI 100 or below for two checks in a row. Every episode gets the pair. Particles only: masks +at which masks are advised for everyone) in two readings in a row; "back to safe" when both are at +AQI 100 or below in two readings in a row (confirm.py: a check that brings no new reading counts for nothing). Every episode gets the pair. Particles only: masks filter particles, not gases like CO2/VOC/NOx. """ import logging from datetime import datetime +from . import confirm, pictures + log = logging.getLogger(__name__) CHECK_SECONDS = 30 * 60 MASK_PM25, MASK_PM10 = 55.5, 255.0 # US AQI 151+ SAFE_PM25, SAFE_PM10 = 35.4, 154.0 # US AQI 100 or better -CONFIRM_CHECKS = 2 # consecutive checks, so a passing puff of smoke doesn't count STALE_SECONDS = 2 * 3600 # ignore readings older than this (sensor offline) @@ -36,19 +37,28 @@ async def check(self, reading: dict | None = None, now: datetime | None = None): pm10 = (reading.get("pm10") or {}).get("value") if pm25 is None and pm10 is None: return + if when is not None and not confirm.new_reading(m, when.timestamp()): # the sensor has said nothing new since the last check + return bad = (pm25 is not None and pm25 >= MASK_PM25) or (pm10 is not None and pm10 >= MASK_PM10) good = (pm25 is None or pm25 <= SAFE_PM25) and (pm10 is None or pm10 <= SAFE_PM10) - m["above"] = m["above"] + 1 if bad else 0 - m["below"] = m["below"] + 1 if good else 0 - if not m["unsafe"] and m["above"] >= CONFIRM_CHECKS: + confirm.run(m, "above", bad) + confirm.run(m, "below", good) + if not m["unsafe"] and m["above"] >= confirm.CONFIRM_READINGS: m["unsafe"] = True + photo = await self._picture() await self.notify("\U0001f637 Unhealthy air outside — wear a P2/N95 mask today. " + self._levels(reading), - link=self.air.link, kind="particulates") - elif m["unsafe"] and m["below"] >= CONFIRM_CHECKS: + link=self.air.link, kind="particulates", **({"photo": photo} if photo else {})) + elif m["unsafe"] and m["below"] >= confirm.CONFIRM_READINGS: m["unsafe"] = False - await self.notify("✅ Outdoor air is safe again. " + self._levels(reading), link=self.air.link, kind="particulates") + photo = await self._picture() + await self.notify("✅ Outdoor air is safe again. " + self._levels(reading), link=self.air.link, kind="particulates", + **({"photo": photo} if photo else {})) self.state.save() + async def _picture(self) -> bytes | None: + """The 1-day particulates chart (what the Particulates button gives), for the alert; None when it cannot be made.""" + return await pictures.chart(self.air, "Particulates chart 1d", self.air.tz) + @staticmethod def _levels(reading: dict) -> str: """Short summary: PM2.5 (with AQI), plus PM10 only when it's elevated.""" diff --git a/lib/alerts/pictures.py b/lib/alerts/pictures.py new file mode 100644 index 0000000..c745467 --- /dev/null +++ b/lib/alerts/pictures.py @@ -0,0 +1,27 @@ +"""The chart that goes with an alert: the same picture a typed request for it would give ("Wind chart 1d" is what the Wind button sends), so what an +alert shows is defined once, by the button sentences and lib/intent.""" + +import asyncio +import logging + +from .. import intent +from ..charts import render +from ..timeutil import now_local +from ..tools import Turn + +log = logging.getLogger(__name__) + + +async def chart(source, text: str, tz) -> bytes | None: + """The PNG for the request `text`, made from `source`'s tool the way the bot would answer it; None when it cannot be made (the alert then + goes out as text). `source` is the station or the air sensor: the tool is found in its `tools` by the name the request resolves to.""" + try: + read = intent.read(text, now_local(tz), True, True) + tool, args = read.fast[0], read.fast[1] + turn = Turn(chart_asked=read.chart_asked, chart_field=read.chart_field, chart_fields=read.chart_fields, text=text) + handler = next(t for t in source.tools if t.name == tool).handler + await handler(args, turn) + return await asyncio.to_thread(render, turn.charts[0], tz) + except Exception as e: + log.warning("The chart for the alert (%s) couldn't be made: %s: %s", text, type(e).__name__, e) + return None diff --git a/lib/alerts/weather.py b/lib/alerts/weather.py index dee3ee7..64fa084 100644 --- a/lib/alerts/weather.py +++ b/lib/alerts/weather.py @@ -6,8 +6,10 @@ rain is sent after they end. - Strong gusts: one alert when a gust goes over 40 km/h, and no more until the gusts have stayed at or under it for an hour, so a blustery afternoon is one message, not twenty. - - Strong sun: one alert when the UV index reaches 10, and no more until it has stayed below 10 for an hour. - - Temperatures crossing: outdoor becomes warmer than indoor (or cooler); a 0.3 degree margin stops sensor noise flip-flopping. + - Strong sun: one alert when the UV index is 10 or more in two readings in a row (confirm.py), and no more until it has stayed + below 10 for an hour. + - Temperatures crossing: outdoor becomes warmer than indoor (or cooler); it has to differ by more than 1 degree, which stops + sensor noise flip-flopping. Rain and crossing share one back-off rule (backoff.py): after an alert, a change is held for ALERT_COOLDOWN_MINUTES and then announced if the state still differs from the one last alerted; if it is back where it was, nothing is sent. """ @@ -16,6 +18,7 @@ from datetime import timedelta from ..ecowitt.glance import UVI_ALERT +from . import confirm, pictures from .backoff import due, remember from ..ecowitt.rain import Rows, duration, rain_amount, wet_flags from ..timeutil import to_local @@ -23,7 +26,7 @@ log = logging.getLogger(__name__) ALERT_COOLDOWN_SECONDS = 30 * 60 # the default; ALERT_COOLDOWN_MINUTES sets it (the readings looked at go back 3 hours, so 150 minutes is the most) -CROSS_MARGIN = 0.3 +CROSS_MARGIN = 1.0 GUST_ALERT_KMH = 40 GUST_REARM_SECONDS = 3600 UV_REARM_SECONDS = 3600 @@ -165,30 +168,43 @@ async def _gusts(self, rows: Rows): if not m["active"]: m["active"] = True gust, ts = max(over) + photo = await self._picture("Wind chart 1d") await self.notify(f"\U0001f4a8 Strong gusts: {gust:.0f} km/h at {to_local(ts, self.tz):%-I:%M%p}".replace("AM", "am").replace("PM", "pm") - + f" (alerts above {GUST_ALERT_KMH} km/h).", kind="gusts") + + f" (alerts above {GUST_ALERT_KMH} km/h).", kind="gusts", **({"photo": photo} if photo else {})) elif m["active"]: m["calm_since"] = m["calm_since"] or latest_ts if latest_ts - m["calm_since"] >= GUST_REARM_SECONDS: m.update(active=False, calm_since=None) async def _uv(self, rows: Rows): - """Like the gusts: only readings since the last check count, one alert per spell at or above UVI_ALERT.""" + """One alert per spell at or above UVI_ALERT, once it holds for two readings in a row (confirm.py); only readings newer than the last + one counted are looked at, so a restart doesn't re-announce an old spell.""" m = self.state.monitor.setdefault("uv", {"active": False, "calm_since": None}) latest_ts = rows[-1][0] - seen, m["checked"] = m.get("checked", latest_ts), latest_ts - over = [(u, ts) for ts, r in rows if ts > seen and (u := r.get("solar_and_uvi.uvi")) is not None and u >= UVI_ALERT] - if over: + m.setdefault("stamp", m.pop("checked", latest_ts)) # the first look only sets the mark + high = False + for ts, r in rows: + if not confirm.new_reading(m, ts): + continue + u = r.get("solar_and_uvi.uvi") + hit = None if u is None else u >= UVI_ALERT + streak = confirm.run(m, "streak", hit) + if hit and (streak == 1 or u > m["peak"][0]): # the run's highest reading is the one told + m["peak"] = [u, ts] + high = high or bool(hit) + if high: m["calm_since"] = None - if not m["active"]: - m["active"] = True - uv, ts = max(over) - await self.notify(f"\U0001f9f4 UV index {uv:g} at {to_local(ts, self.tz):%-I:%M%p}".replace("AM", "am").replace("PM", "pm") - + f": very high. Sunscreen, a hat and shade (alerts at {UVI_ALERT} and above).", kind="uv") elif m["active"]: m["calm_since"] = m["calm_since"] or latest_ts if latest_ts - m["calm_since"] >= UV_REARM_SECONDS: m.update(active=False, calm_since=None) + if m.get("streak", 0) >= confirm.CONFIRM_READINGS and not m["active"]: + m["active"] = True + uv, ts = m["peak"] + photo = await self._picture("UV chart 1d") + await self.notify(f"\U0001f9f4 UV index {uv:g} at {to_local(ts, self.tz):%-I:%M%p}".replace("AM", "am").replace("PM", "pm") + + f": very high. Sunscreen, a hat and shade (alerts at {UVI_ALERT} and above).", kind="uv", + **({"photo": photo} if photo else {})) async def _cross(self, rows: Rows): ts, r = rows[-1] @@ -206,6 +222,11 @@ async def _cross(self, rows: Rows): held = ts - c["alerted_time"] # how long the side we last told about has stood was = f"{int(held // 86400)} days" if held >= 2 * 86400 else duration(held) icon = "\U0001f321️" if current == "warmer" else "\u2744\ufe0f" # a thermometer for warmer, a snowflake for cooler + photo = await self._picture("Temperature chart 1d") await self.notify(f"{icon} It's now {abs(o - i):.1f}°C {current} outside than inside " - f"({o:.1f}°C vs {i:.1f}°C). It had been {'cooler' if current == 'warmer' else 'warmer'} for {was}.", kind="temps") + f"({o:.1f}°C vs {i:.1f}°C). It had been {'cooler' if current == 'warmer' else 'warmer'} for {was}.", kind="temps", **({"photo": photo} if photo else {})) remember(c, current, ts) + + async def _picture(self, text: str) -> bytes | None: + """The chart a typed `text` ("Wind chart 1d") would give, for an alert; None when it cannot be made.""" + return await pictures.chart(self.station, text, self.tz) diff --git a/lib/ecowitt/query.py b/lib/ecowitt/query.py index b072459..9b76ad8 100644 --- a/lib/ecowitt/query.py +++ b/lib/ecowitt/query.py @@ -435,6 +435,9 @@ def _chart_spec(self, series_out: dict, field: str | None = None) -> Chart | Non lines.append(line) if not lines: return None + group = keys[0].split(".", 1)[0] + if len(lines) == 1 and group not in ("outdoor", "indoor") and (reading := find(field)): + lines[0].label = reading.label # "UV index", not the group's name ("Solar and uvi"); outdoor and indoor keep theirs: they tell the lines apart title = field.replace("_", " ").capitalize() subtitle = (f"{period_text(self.start.date(), self.end.date())} · {resolution}" + (", shaded up to the gusts" if banded and ranged else ", range shaded" if ranged else "") diff --git a/lib/ecowitt/source.py b/lib/ecowitt/source.py index 16dd52e..b48ccd7 100644 --- a/lib/ecowitt/source.py +++ b/lib/ecowitt/source.py @@ -232,9 +232,9 @@ async def live_rain(self) -> tuple[int, dict] | None: self._live_ok = False return None if self._live_ok is None: - log.info("Working (%s)", live_summary(data)) + log.info("%s", live_summary(data)) elif not self._live_ok: - log.info("Recovered (%s)", live_summary(data)) + log.info("Recovered: %s", live_summary(data)) self._live_ok = True return ts, row diff --git a/tests/fakes.py b/tests/fakes.py index 4a422e9..7e88afa 100644 --- a/tests/fakes.py +++ b/tests/fakes.py @@ -65,6 +65,14 @@ def wind_speed(ts: int) -> float: return 0.0 if hour < 6 else 10.0 if hour < 18 else 25.0 +@lru_cache(maxsize=None) +def uv_index(ts: int) -> float: + """Dark until 7am local, a daytime hump peaking at 11 around 1pm, dark again from 7pm.""" + local = datetime.fromtimestamp(ts, timezone.utc).astimezone(TZ) + hours = local.hour + local.minute / 60 + return 0.0 if hours < 7 or hours > 19 else round(11 * (1 - ((hours - 13) / 6) ** 2), 1) + + class FakeEcowitt: """Handles /device/list, /device/real_time and /device/history like api.ecowitt.net.""" @@ -100,7 +108,7 @@ def _history(p: dict) -> dict: first = start - start % step if p["cycle_type"] == "1day" else start # 1day buckets are UTC days out: dict = {} for group in p["call_back"].split(","): - named = {"outdoor": [("temperature", temp), ("dew_point", temp)], "rainfall": [("daily", rain_day)], "pressure": [("relative", pressure)], "wind": [("wind_gust", gust), ("wind_direction", direction), ("wind_speed", wind_speed)]} + named = {"outdoor": [("temperature", temp), ("dew_point", temp)], "rainfall": [("daily", rain_day)], "pressure": [("relative", pressure)], "wind": [("wind_gust", gust), ("wind_direction", direction), ("wind_speed", wind_speed)], "solar_and_uvi": [("uvi", uv_index), ("solar", lambda ts: uv_index(ts) * 80)]} fields: dict = {} for name, fn in named.get(group, [("temperature", temp)]): for ts in range(first, end + 1, step): @@ -119,9 +127,9 @@ def ecowitt_transport(fake: FakeEcowitt | None = None, history_days: int = HISTO return httpx.MockTransport(fake), fake -def air_row(ts: datetime, pm25: float = 5.0, pm10: float = 8.0) -> dict: +def air_row(ts: datetime, pm25: float = 5.0, pm10: float = 8.0, pm1: float = 3.0) -> dict: return {"timestamp": ts.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.000Z"), "locationName": "Outdoor", - "locationType": "outdoor", "pm02_corrected": pm25, "pm10_corrected": pm10, "rco2_corrected": 450, + "locationType": "outdoor", "pm02_corrected": pm25, "pm10_corrected": pm10, "pm01_corrected": pm1, "rco2_corrected": 450, "tvocIndex": 100, "noxIndex": 1} diff --git a/tests/test_alert_menu.py b/tests/test_alert_menu.py index 894c771..19a9e5f 100644 --- a/tests/test_alert_menu.py +++ b/tests/test_alert_menu.py @@ -214,7 +214,7 @@ async def reply_text(body, **kw): async def test_every_monitors_alert_names_its_type(tmp_path): from lib.alerts import ParticulatesMonitor, ForecastMonitor, PollenAndAsthmaMonitor, WeatherMonitor - from tests.test_alerts import FakePollen, FakeStation, air_reading, gusts, rain, uv + from tests.test_alerts import FakePollen, FakeStation, T0, air_reading, gusts, rain, uv from tests.test_forecast_alert import FakeForecast, day state = AlertState(tmp_path / "s.json") state.add_chat(1, "chat") @@ -231,7 +231,7 @@ async def weather(first, then): await weather(rain(0, 0, 0), rain(0, 0, 1)) await weather(rain(0, 0, 1, 1), rain(0, 0, 1, 1, *[0] * 13)) await weather(gusts(10, 12), gusts(10, 12, 60)) - await weather(uv(3, 5), uv(3, 5, 10)) + await weather(uv(3, 5, start=T0 + 86400), uv(3, 5, 10, 10, start=T0 + 86400)) # later than the readings above: the monitors share one state assert kinds == ["rain", "rain", "gusts", "uv"] kinds.clear() mask = ParticulatesMonitor(NS(tz=TZ, link=None), state, notify) @@ -466,3 +466,35 @@ def test_every_alert_type_sent_is_registered_and_each_monitor_declares_what_it_s for m in monitors: sent = set(re.findall(r'kind="(\w+)"', Path(__import__(m.__module__, fromlist=["x"]).__file__).read_text())) assert sent == set(m.KINDS), m.__name__ + + +class SentWithPhotos(Sent): + def __init__(self, refuse=None): + super().__init__() + self.photos, self.refuse = [], refuse + + async def send_photo(self, chat_id, photo, **kw): + if self.refuse: + raise self.refuse + self.photos.append((chat_id, photo, kw)) + + +async def test_an_alert_with_a_picture_is_the_picture_with_the_text_as_its_caption_and_the_buttons(tmp_path): + state, bot = AlertState(tmp_path / "s.json"), SentWithPhotos() + state.add_chat(1, "one") + await Notifier(bot, state, KINDS)("It's warmer outside", kind="temps", photo=b"png") + assert bot.sent == [] and len(bot.photos) == 1 + chat, photo, kw = bot.photos[0] + assert (chat, photo, kw["caption"]) == (1, b"png", "It's warmer outside") and kw["disable_notification"] is True + assert rows(kw["reply_markup"])[0][0] == ("➕ Sub ▸", "al:open:sub:temps") # the alert's own buttons ride along + + +async def test_a_picture_telegram_refuses_falls_back_to_the_text_but_a_missing_chat_is_still_forgotten(tmp_path): + from telegram.error import BadRequest + state, bot = AlertState(tmp_path / "s.json"), SentWithPhotos(refuse=BadRequest("Wrong file identifier")) + state.add_chat(1, "one") + await Notifier(bot, state, KINDS)("It's warmer outside", kind="temps", photo=b"png") + assert bot.photos == [] and [(c, t) for c, t, _ in bot.sent] == [(1, "It's warmer outside")] + gone = SentWithPhotos(refuse=BadRequest("Chat not found")) + await Notifier(gone, state, KINDS)("again", kind="temps", photo=b"png") + assert gone.sent == [] and 1 not in state.chats diff --git a/tests/test_alerts.py b/tests/test_alerts.py index 1093e3e..9bf0d2e 100644 --- a/tests/test_alerts.py +++ b/tests/test_alerts.py @@ -312,13 +312,17 @@ async def step(*more): await step(9.9) assert sent == [] await step(10) - assert len(sent) == 1 and "UV index 10" in sent[0] and "1:15pm" in sent[0] and "alerts at 10 and above" in sent[0] - await step(11, 7, *[6] * 11) # still the same spell, then under an hour below 10 - await step(10.5) + assert sent == [] # one reading at 10 is not enough + await step(11) + assert len(sent) == 1 and "UV index 11" in sent[0] and "1:20pm" in sent[0] and "alerts at 10 and above" in sent[0] # the run's highest + await step(7, *[6] * 11) # still the same spell, then under an hour below 10 + await step(10.5, 10.5) assert len(sent) == 1 await step(*[5] * 12) # a calm hour starts counting await step(*[5] * 12) # an hour below 10 re-arms it await step(10) + assert len(sent) == 1 # one reading again + await step(10) assert len(sent) == 2 @@ -527,3 +531,226 @@ def test_chats_that_already_unsubscribed_from_all_load_as_opt_in(tmp_path, monke state.save() again = notify.AlertState(path) # and it survives a restart assert again.subscribed(1, "uv") and not again.subscribed(1, "rain") and not again.subscribed(1, "newthing") + + +def asking(mon, photo=b"png"): + """Replace a monitor's chart maker with one that records what was asked and answers `photo`; returns the list of requests.""" + asked = [] + + async def picture(*text): + asked.append(text[-1] if text else "particulates") + return photo + mon._picture = picture + return asked + + +async def test_the_crossing_alert_carries_the_temperature_chart_when_there_is_one(tmp_path): + m, state, sent = monitor(tmp_path, temps(10, 20)) + photos = [] + + async def notify(text, link=None, **kw): + sent.append(text) + photos.append(kw.get("photo")) + m.notify = notify + asked = asking(m) + await m.check() # learns: outdoor cooler + m.station.data = temps(25, 20, T0 + 3 * 3600) + await m.check() + assert len(sent) == 1 and photos == [b"png"] and asked == ["Temperature chart 1d"] + m.station.data = temps(10, 20, T0 + 6 * 3600) # and back, with no chart this time: the alert still goes out, as text + asking(m, photo=None) + await m.check() + assert len(sent) == 2 and photos[1] is None + + +async def test_the_gusts_alert_carries_the_wind_chart(tmp_path): + vals = [10, 12] + m, state, sent = monitor(tmp_path, [(T0 + i * 300, {"wind.wind_gust": v}) for i, v in enumerate(vals)]) + seen = [] + + async def notify(text, link=None, **kw): + seen.append((text, kw.get("photo"))) + m.notify = notify + asked = asking(m) + await m.check() # the first look sets the mark + m.station.data = [(T0 + i * 300, {"wind.wind_gust": v}) for i, v in enumerate([10, 12, 55])] + await m.check() + assert len(seen) == 1 and "Strong gusts" in seen[0][0] and seen[0][1] == b"png" and asked == ["Wind chart 1d"] + (tmp_path / "b").mkdir() + m2, _, _ = monitor(tmp_path / "b", [(T0, {"wind.wind_gust": 10}), (T0 + 300, {"wind.wind_gust": 12})]) + plain = [] + + async def notify2(text, link=None, **kw): + plain.append(kw) + m2.notify = notify2 + asking(m2, photo=None) + await m2.check() + m2.station.data = [(T0, {"wind.wind_gust": 10}), (T0 + 300, {"wind.wind_gust": 12}), (T0 + 600, {"wind.wind_gust": 70})] + await m2.check() + assert plain == [{"kind": "gusts"}] # no chart: text only + + +async def test_both_particulates_alerts_carry_the_particulates_chart(tmp_path): + state, seen = AlertState(tmp_path / "s.json"), [] + + async def notify(text, link=None, **kw): + seen.append((text, kw.get("photo"))) + mon = ParticulatesMonitor(FakeAir(), state, notify) + asked = asking(mon) + for pm in (90, 90, 30, 30): + await mon.check(air_reading(pm)) + assert [("mask" in t, "safe again" in t, p) for t, p in seen] == [(True, False, b"png"), (False, True, b"png")] and asked == ["particulates"] * 2 + quiet = ParticulatesMonitor(FakeAir(), AlertState(tmp_path / "t.json"), notify) + asking(quiet, photo=None) + seen.clear() + for pm in (90, 90): + await quiet.check(air_reading(pm)) + assert seen == [(seen[0][0], None)] + + +async def test_other_alerts_send_no_picture(tmp_path): + m, state, sent = monitor(tmp_path, None) + seen = [] + + async def notify(text, link=None, **kw): + seen.append(kw) + m.notify = notify + for data in (rain(0, 0, 0), rain(0, 0, 1)): + m.station.data = data + await m.check() + assert seen and all("photo" not in kw for kw in seen) + + +async def test_the_alert_charts_are_the_typed_one_day_charts_drawn_from_each_sources_history(tmp_path, archived_cache): + from lib.airgradient import AirGradient + from lib.alerts import pictures + from tests.fakes import air_transport, archived_station, config + eco, _ = await archived_station(tmp_path, archived_cache) + for text in ("Temperature chart 1d", "Wind chart 1d", "UV chart 1d"): + png = await pictures.chart(eco, text, TZ) + assert png and png[:4] == b"\x89PNG", text + transport, _ = air_transport(oldest=datetime.now(timezone.utc) - timedelta(days=5)) + (tmp_path / "air").mkdir() + air = AirGradient(config(tmp_path / "air"), transport=transport) + await air.start() + png = await pictures.chart(air, "Particulates chart 1d", TZ) + assert png and png[:4] == b"\x89PNG" + assert await pictures.chart(FakeStation(), "Wind chart 1d", TZ) is None # a source without the tool: no chart, no error + await eco.close() + await air.close() + + +async def test_the_particulates_chart_draws_pm1_pm25_and_pm10_together(tmp_path): + from lib import intent + from lib.airgradient import AirGradient + from lib.timeutil import now_local + from lib.tools import Turn + from tests.fakes import air_transport, config + transport, _ = air_transport(oldest=datetime.now(timezone.utc) - timedelta(days=5)) + air = AirGradient(config(tmp_path), transport=transport) + await air.start() + read = intent.read("Particulates chart 1d", now_local(TZ), True, True) + turn = Turn(chart_asked=read.chart_asked, text="Particulates chart 1d") + await air.handle(read.fast[1], turn) + (panel,) = turn.charts[0].panels + assert sorted(line.label for line in panel.lines) == ["PM1", "PM10", "PM2.5"] + await air.close() + + +async def test_the_uv_alert_carries_the_uv_chart_once_per_spell(tmp_path): + vals = [3, 5] + m, state, sent = monitor(tmp_path, uv(*vals)) + seen = [] + + async def notify(text, link=None, **kw): + seen.append((text, kw.get("photo"))) + m.notify = notify + asked = asking(m) + await m.check() # the first look sets the mark + m.station.data = uv(3, 5, 11, 12) + await m.check() + assert len(seen) == 1 and "UV index 12" in seen[0][0] and seen[0][1] == b"png" and asked == ["UV chart 1d"] + m.station.data = uv(3, 5, 11, 12, 12) # the same spell: nothing more, and no second chart is made + await m.check() + assert len(seen) == 1 and asked == ["UV chart 1d"] + (tmp_path / "b").mkdir() + plain, _, _ = monitor(tmp_path / "b", uv(3, 5)) + got = [] + + async def notify2(text, link=None, **kw): + got.append(kw) + plain.notify = notify2 + asking(plain, photo=None) + await plain.check() + plain.station.data = uv(3, 5, 11, 12) + await plain.check() + assert got == [{"kind": "uv"}] # no chart: text only + + +def test_the_confirmation_rule_counts_distinct_readings_in_a_row(): + from lib.alerts import confirm + saved = {} + assert confirm.new_reading(saved, 100) and not confirm.new_reading(saved, 100) and not confirm.new_reading(saved, 50) and confirm.new_reading(saved, 101) + assert confirm.run(saved, "n", True) == 1 and confirm.run(saved, "n", True) == 2 + assert confirm.run(saved, "n", None) == 2 # a reading with no value changes nothing + assert confirm.run(saved, "n", False) == 0 and confirm.run(saved, "n", True) == 1 + + +async def test_uv_needs_two_distinct_readings_even_across_checks_and_a_restart(tmp_path): + m, state, sent = monitor(tmp_path, uv(3, 5)) + await m.check() # the first look sets the mark + m.station.data = uv(3, 5, 10) + await m.check() + await m.check() # the same reading looked at again does not count twice + assert sent == [] + restarted = WeatherMonitor(m.station, AlertState(state.path), m.notify) # a restart: the run is in the state file + sent2 = [] + + async def notify(text, link=None, **kw): + sent2.append(text) + restarted.notify = notify + restarted.station.data = uv(3, 5, 10, 10.5) + await restarted.check() + assert len(sent2) == 1 and "UV index 10.5" in sent2[0] + (tmp_path / "x").mkdir() + m3, _, sent3 = monitor(tmp_path / "x", uv(3, 5)) + await m3.check() + m3.station.data = [(T0 + 600, {"solar_and_uvi.uvi": 11}), (T0 + 900, {"solar_and_uvi.uvi": 9.9}), (T0 + 1200, {"solar_and_uvi.uvi": 11})] + await m3.check() + assert sent3 == [] # a reading below 10 ends the run + m3.station.data += [(T0 + 1500, {}), (T0 + 1800, {"solar_and_uvi.uvi": 12})] + await m3.check() + assert len(sent3) == 1 # a reading with no value is skipped: 11 then 12 is a run + + +async def test_a_difference_of_a_degree_or_less_is_not_a_crossing(tmp_path): + from lib.alerts.weather import side + assert side({"outdoor.temperature": 21.0, "indoor.temperature": 20.0}) is None and side({"outdoor.temperature": 19.0, "indoor.temperature": 20.0}) is None + assert side({"outdoor.temperature": 21.1, "indoor.temperature": 20.0}) == "warmer" and side({"outdoor.temperature": 18.9, "indoor.temperature": 20.0}) == "cooler" + m, state, sent = monitor(tmp_path, temps(10, 20)) + await m.check() + m.station.data = temps(20.9, 20, T0 + 3 * 3600) # only 0.9 degrees: not warmer + await m.check() + assert sent == [] + m.station.data = temps(21.1, 20, T0 + 3 * 3600 + 300) + await m.check() + assert len(sent) == 1 and "warmer outside" in sent[0] + + +async def test_particulates_count_readings_not_checks(tmp_path): + state, sent = AlertState(tmp_path / "s.json"), [] + + async def notify(text, link=None, **kw): + sent.append(text) + mon = ParticulatesMonitor(FakeAir(), state, notify) + first = air_reading(90) + await mon.check(first) + await mon.check(first) # the sensor said nothing new: still one reading + assert sent == [] + await mon.check(air_reading(30)) # a reading that is not unhealthy ends the run + await mon.check(first) # and an old reading is not counted again + assert sent == [] + again = ParticulatesMonitor(FakeAir(), AlertState(tmp_path / "s.json"), notify) # a restart keeps what was counted + await again.check(air_reading(90)) + await again.check(air_reading(90)) + assert len(sent) == 1 and "mask" in sent[0] diff --git a/tests/test_ecowitt.py b/tests/test_ecowitt.py index ba1f84c..fdee33e 100644 --- a/tests/test_ecowitt.py +++ b/tests/test_ecowitt.py @@ -1222,3 +1222,23 @@ def test_the_live_summary_gives_rain_first_then_a_few_readings_and_skips_what_is "wind": {"wind_speed": cell("12", "km/h")}, "pressure": {"relative": cell("1013.2", "hPa")}} assert live_summary(data) == "rain 0.0 mm/h, 0.8 mm today, 20.3 °C, 62 %, wind 12 km/h, 1013.2 hPa" assert live_summary({"rainfall": {"daily": cell("0.8", "mm")}}) == "0.8 mm today" and live_summary({}) == "no readings" + + +async def test_a_single_reading_chart_is_labelled_with_the_reading_not_its_group(tmp_path, archived_cache): + from datetime import datetime + from lib import intent + from lib.timeutil import now_local + from tests.fakes import archived_station + eco, _ = await archived_station(tmp_path, archived_cache) + + async def lines(text): + read = intent.read(text, now_local(eco.tz), True, True) + turn = Turn(chart_asked=read.chart_asked, chart_field=read.chart_field, chart_fields=read.chart_fields, text=text) + await eco.tools[1].handler(read.fast[1], turn) + (panel,) = turn.charts[0].panels + return [line.label for line in panel.lines] + assert await lines("UV chart 1d") == ["UV index"] # not "Solar and uvi" + assert await lines("Solar radiation chart 1d") == ["Solar radiation"] + assert sorted(await lines("Temperature chart 1d")) == ["Indoor", "Outdoor"] # these two tell the lines apart, so they keep their names + assert await lines("Wind chart 1d") == ["Wind"] + await eco.close()