From 0554d193e1b4bdcc0c676a9a55a57dd27bbf0cc0 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 08:35:03 +0000 Subject: [PATCH 1/7] Send the 1D temperature chart with the temperature-crossing alert The alert goes out as the chart with its text as the caption (and the usual buttons); a chart that cannot be made, or a picture Telegram refuses, falls back to the text alone. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01WBwHofjb1g9onaYRdLZMbf --- README.md | 2 +- lib/alerts/notify.py | 31 +++++++++++++++++-------- lib/alerts/weather.py | 23 +++++++++++++++++-- tests/test_alert_menu.py | 32 ++++++++++++++++++++++++++ tests/test_alerts.py | 49 ++++++++++++++++++++++++++++++++++++++++ 5 files changed, 125 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index d0a5ed2..7e893b2 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ 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), +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, with the last day's temperature chart), 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`). ## Run 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/weather.py b/lib/alerts/weather.py index dee3ee7..ef44faa 100644 --- a/lib/alerts/weather.py +++ b/lib/alerts/weather.py @@ -12,13 +12,17 @@ announced if the state still differs from the one last alerted; if it is back where it was, nothing is sent. """ +import asyncio import logging from datetime import timedelta +from .. import intent +from ..charts import render from ..ecowitt.glance import UVI_ALERT from .backoff import due, remember from ..ecowitt.rain import Rows, duration, rain_amount, wet_flags -from ..timeutil import to_local +from ..timeutil import now_local, to_local +from ..tools import Turn log = logging.getLogger(__name__) @@ -206,6 +210,21 @@ 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._temperature_chart() 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 _temperature_chart(self) -> bytes | None: + """The "Temperature chart 1d" picture (outdoor and indoor, the last 24 hours), drawn as a typed request for it would be; None when + it cannot be made (the alert then goes out as text).""" + try: + text = "Temperature chart 1d" + read = intent.read(text, now_local(self.tz), True, False) + turn = Turn(chart_asked=read.chart_asked, chart_field=read.chart_field, chart_fields=read.chart_fields, text=text) + history = next(t for t in self.station.tools if t.name == "weather_history") + await history.handler(read.fast[1], turn) + return await asyncio.to_thread(render, turn.charts[0], self.tz) + except Exception as e: + log.warning("The temperature chart for the crossing alert couldn't be made: %s: %s", type(e).__name__, e) + return None diff --git a/tests/test_alert_menu.py b/tests/test_alert_menu.py index 894c771..288d38a 100644 --- a/tests/test_alert_menu.py +++ b/tests/test_alert_menu.py @@ -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..320291a 100644 --- a/tests/test_alerts.py +++ b/tests/test_alerts.py @@ -527,3 +527,52 @@ 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") + + +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 + + async def chart(): + return b"png" + m._temperature_chart = chart + 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"] + m.station.data = temps(10, 20, T0 + 6 * 3600) # and back, with no chart this time: the alert still goes out, as text + m._temperature_chart = lambda: _none() + await m.check() + assert len(sent) == 2 and photos[1] is None + + +async def _none(): + return 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_temperature_chart_is_the_one_day_chart_drawn_from_the_stations_history(tmp_path, archived_cache): + from tests.fakes import archived_station + eco, _ = await archived_station(tmp_path, archived_cache) + m = WeatherMonitor(eco, AlertState(tmp_path / "s.json"), None) + png = await m._temperature_chart() + assert png and png[:4] == b"\x89PNG" + await eco.close() + assert await WeatherMonitor(FakeStation(), AlertState(tmp_path / "t.json"), None)._temperature_chart() is None # a station without the tool: no chart, no error From 1b04acb54e4db6d46a42edbd580afa36e2cdd4f9 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 11:16:07 +0000 Subject: [PATCH 2/7] Attach the 1D wind chart to the gusts alert and the 1D particulates chart to the particulates alerts One helper (lib/alerts/pictures.py) makes the chart a typed request for it would give; the crossing alert uses it too. A chart that cannot be made leaves the alert as text. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01WBwHofjb1g9onaYRdLZMbf --- README.md | 4 +- lib/alerts/particulates.py | 13 +++++- lib/alerts/pictures.py | 27 ++++++++++++ lib/alerts/weather.py | 28 ++++--------- tests/test_alerts.py | 84 ++++++++++++++++++++++++++++++++------ 5 files changed, 119 insertions(+), 37 deletions(-) create mode 100644 lib/alerts/pictures.py diff --git a/README.md b/README.md index 7e893b2..272e155 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, with the last day's temperature chart), -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, indoor/outdoor temperatures crossing (held back for the cooldown, then announced if it still stands, with the last day's temperature chart), +and unhealthy air (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/particulates.py b/lib/alerts/particulates.py index db021c8..9850916 100644 --- a/lib/alerts/particulates.py +++ b/lib/alerts/particulates.py @@ -9,6 +9,8 @@ import logging from datetime import datetime +from . import pictures + log = logging.getLogger(__name__) CHECK_SECONDS = 30 * 60 @@ -42,13 +44,20 @@ async def check(self, reading: dict | None = None, now: datetime | None = None): m["below"] = m["below"] + 1 if good else 0 if not m["unsafe"] and m["above"] >= CONFIRM_CHECKS: 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") + link=self.air.link, kind="particulates", **({"photo": photo} if photo else {})) elif m["unsafe"] and m["below"] >= CONFIRM_CHECKS: 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 ef44faa..91da7ff 100644 --- a/lib/alerts/weather.py +++ b/lib/alerts/weather.py @@ -12,17 +12,14 @@ announced if the state still differs from the one last alerted; if it is back where it was, nothing is sent. """ -import asyncio import logging from datetime import timedelta -from .. import intent -from ..charts import render from ..ecowitt.glance import UVI_ALERT +from . import pictures from .backoff import due, remember from ..ecowitt.rain import Rows, duration, rain_amount, wet_flags -from ..timeutil import now_local, to_local -from ..tools import Turn +from ..timeutil import to_local log = logging.getLogger(__name__) @@ -169,8 +166,9 @@ 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: @@ -210,21 +208,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._temperature_chart() + 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", **({"photo": photo} if photo else {})) remember(c, current, ts) - async def _temperature_chart(self) -> bytes | None: - """The "Temperature chart 1d" picture (outdoor and indoor, the last 24 hours), drawn as a typed request for it would be; None when - it cannot be made (the alert then goes out as text).""" - try: - text = "Temperature chart 1d" - read = intent.read(text, now_local(self.tz), True, False) - turn = Turn(chart_asked=read.chart_asked, chart_field=read.chart_field, chart_fields=read.chart_fields, text=text) - history = next(t for t in self.station.tools if t.name == "weather_history") - await history.handler(read.fast[1], turn) - return await asyncio.to_thread(render, turn.charts[0], self.tz) - except Exception as e: - log.warning("The temperature chart for the crossing alert couldn't be made: %s: %s", type(e).__name__, e) - return None + 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/tests/test_alerts.py b/tests/test_alerts.py index 320291a..876babf 100644 --- a/tests/test_alerts.py +++ b/tests/test_alerts.py @@ -529,6 +529,17 @@ def test_chats_that_already_unsubscribed_from_all_load_as_opt_in(tmp_path, monke 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 = [] @@ -537,22 +548,60 @@ async def notify(text, link=None, **kw): sent.append(text) photos.append(kw.get("photo")) m.notify = notify - - async def chart(): - return b"png" - m._temperature_chart = chart + 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"] + 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 - m._temperature_chart = lambda: _none() + asking(m, photo=None) await m.check() assert len(sent) == 2 and photos[1] is None -async def _none(): - return 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): @@ -568,11 +617,20 @@ async def notify(text, link=None, **kw): assert seen and all("photo" not in kw for kw in seen) -async def test_the_temperature_chart_is_the_one_day_chart_drawn_from_the_stations_history(tmp_path, archived_cache): - from tests.fakes import archived_station +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) - m = WeatherMonitor(eco, AlertState(tmp_path / "s.json"), None) - png = await m._temperature_chart() + for text in ("Temperature chart 1d", "Wind 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() - assert await WeatherMonitor(FakeStation(), AlertState(tmp_path / "t.json"), None)._temperature_chart() is None # a station without the tool: no chart, no error + await air.close() From 3ff67c61dff016fc1022d655a0d6a4a931ace159 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 00:22:04 +0000 Subject: [PATCH 3/7] Fake air sensor reports PM1, so the particulates chart is tested with all three lines Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01WBwHofjb1g9onaYRdLZMbf --- tests/fakes.py | 4 ++-- tests/test_alerts.py | 17 +++++++++++++++++ 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/tests/fakes.py b/tests/fakes.py index 4a422e9..2f1b674 100644 --- a/tests/fakes.py +++ b/tests/fakes.py @@ -119,9 +119,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_alerts.py b/tests/test_alerts.py index 876babf..7ebdb6f 100644 --- a/tests/test_alerts.py +++ b/tests/test_alerts.py @@ -634,3 +634,20 @@ async def test_the_alert_charts_are_the_typed_one_day_charts_drawn_from_each_sou 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() From 275b50f25225ac3fa63510ff12cb10becc23213d Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 00:40:06 +0000 Subject: [PATCH 4/7] Attach the 1D UV chart to the UV alert The fake station now reports a UV index (dark at night, a daytime hump), so the UV chart is tested against real-looking data. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01WBwHofjb1g9onaYRdLZMbf --- README.md | 2 +- lib/alerts/weather.py | 4 +++- tests/fakes.py | 10 +++++++++- tests/test_alerts.py | 32 +++++++++++++++++++++++++++++++- 4 files changed, 44 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 272e155..ea6fd80 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ 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 (with the last day's wind chart), UV index of 10 or more, indoor/outdoor temperatures crossing (held back for the cooldown, then announced if it still stands, with the last day's temperature chart), +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 (with the last day's UV chart), indoor/outdoor temperatures crossing (held back for the cooldown, then announced if it still stands, with the last day's temperature chart), and unhealthy air (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/weather.py b/lib/alerts/weather.py index 91da7ff..b239c82 100644 --- a/lib/alerts/weather.py +++ b/lib/alerts/weather.py @@ -185,8 +185,10 @@ async def _uv(self, rows: Rows): if not m["active"]: m["active"] = True uv, ts = max(over) + 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") + + f": very high. Sunscreen, a hat and shade (alerts at {UVI_ALERT} and above).", kind="uv", + **({"photo": photo} if photo else {})) elif m["active"]: m["calm_since"] = m["calm_since"] or latest_ts if latest_ts - m["calm_since"] >= UV_REARM_SECONDS: diff --git a/tests/fakes.py b/tests/fakes.py index 2f1b674..4aa1a45 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)]} fields: dict = {} for name, fn in named.get(group, [("temperature", temp)]): for ts in range(first, end + 1, step): diff --git a/tests/test_alerts.py b/tests/test_alerts.py index 7ebdb6f..36d7bb2 100644 --- a/tests/test_alerts.py +++ b/tests/test_alerts.py @@ -622,7 +622,7 @@ async def test_the_alert_charts_are_the_typed_one_day_charts_drawn_from_each_sou 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"): + 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)) @@ -651,3 +651,33 @@ async def test_the_particulates_chart_draws_pm1_pm25_and_pm10_together(tmp_path) (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) + await m.check() + assert len(seen) == 1 and "UV index 11" in seen[0][0] and seen[0][1] == b"png" and asked == ["UV chart 1d"] + m.station.data = uv(3, 5, 11, 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) + await plain.check() + assert got == [{"kind": "uv"}] # no chart: text only From c6528eac3efe64e99c456aea250939ec77bc9775 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 01:04:48 +0000 Subject: [PATCH 5/7] Label a single-reading chart with the reading, not its group (UV chart legend: UV index, not Solar and uvi) Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01WBwHofjb1g9onaYRdLZMbf --- lib/ecowitt/query.py | 3 +++ tests/fakes.py | 2 +- tests/test_ecowitt.py | 20 ++++++++++++++++++++ 3 files changed, 24 insertions(+), 1 deletion(-) 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/tests/fakes.py b/tests/fakes.py index 4aa1a45..7e88afa 100644 --- a/tests/fakes.py +++ b/tests/fakes.py @@ -108,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)], "solar_and_uvi": [("uvi", uv_index)]} + 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): 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() From 3067d9c5066b9bc11d2996110b55ebdbdedf2309 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 01:26:24 +0000 Subject: [PATCH 6/7] UV and particulates need two distinct readings in a row (one shared rule); temperature crossing needs more than 1 degree lib/alerts/confirm.py: a condition must hold for two readings in a row, counted by the reading's own timestamp, so a check that sees the same reading again counts for nothing. The run is saved, so it survives checks and restarts. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01WBwHofjb1g9onaYRdLZMbf --- README.md | 4 +- lib/alerts/confirm.py | 23 ++++++++++ lib/alerts/particulates.py | 17 ++++---- lib/alerts/weather.py | 42 +++++++++++------- tests/test_alert_menu.py | 4 +- tests/test_alerts.py | 87 +++++++++++++++++++++++++++++++++++--- 6 files changed, 143 insertions(+), 34 deletions(-) create mode 100644 lib/alerts/confirm.py diff --git a/README.md b/README.md index ea6fd80..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 (with the last day's wind chart), UV index of 10 or more (with the last day's UV chart), indoor/outdoor temperatures crossing (held back for the cooldown, then announced if it still stands, with the last day's temperature chart), -and unhealthy air (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`). +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/particulates.py b/lib/alerts/particulates.py index 9850916..0c1059d 100644 --- a/lib/alerts/particulates.py +++ b/lib/alerts/particulates.py @@ -1,22 +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 pictures +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) @@ -38,16 +37,18 @@ 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", **({"photo": photo} if photo else {})) - elif m["unsafe"] and m["below"] >= CONFIRM_CHECKS: + elif m["unsafe"] and m["below"] >= confirm.CONFIRM_READINGS: m["unsafe"] = False photo = await self._picture() await self.notify("✅ Outdoor air is safe again. " + self._levels(reading), link=self.air.link, kind="particulates", diff --git a/lib/alerts/weather.py b/lib/alerts/weather.py index b239c82..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,7 +18,7 @@ from datetime import timedelta from ..ecowitt.glance import UVI_ALERT -from . import pictures +from . import confirm, pictures from .backoff import due, remember from ..ecowitt.rain import Rows, duration, rain_amount, wet_flags from ..timeutil import to_local @@ -24,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 @@ -175,24 +177,34 @@ async def _gusts(self, rows: Rows): 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) - 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 {})) 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] diff --git a/tests/test_alert_menu.py b/tests/test_alert_menu.py index 288d38a..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) diff --git a/tests/test_alerts.py b/tests/test_alerts.py index 36d7bb2..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 @@ -663,10 +667,10 @@ async def notify(text, link=None, **kw): m.notify = notify asked = asking(m) await m.check() # the first look sets the mark - m.station.data = uv(3, 5, 11) + m.station.data = uv(3, 5, 11, 12) await m.check() - assert len(seen) == 1 and "UV index 11" in seen[0][0] and seen[0][1] == b"png" and asked == ["UV chart 1d"] - m.station.data = uv(3, 5, 11, 12) # the same spell: nothing more, and no second chart is made + 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() @@ -678,6 +682,75 @@ async def notify2(text, link=None, **kw): plain.notify = notify2 asking(plain, photo=None) await plain.check() - plain.station.data = uv(3, 5, 11) + 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] From cb604f47d0b2bed3c051c95c1f4e255fd55d731e Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 01:38:03 +0000 Subject: [PATCH 7/7] Live readings log line: just the summary Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01WBwHofjb1g9onaYRdLZMbf --- lib/ecowitt/source.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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