diff --git a/README.md b/README.md index 8d4dca6..f7c4609 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,7 @@ Under systemd see `ecobot.service` (it loads the environment file; the clone's l | `ECOWITT_API_KEY`, `ECOWITT_APP_KEY` | enables Ecowitt | | `AIRGRADIENT_API_TOKEN`, `AIRGRADIENT_LOCATION_ID` | enables AirGradient | | `AIRGRADIENT_DASHBOARD_URL` | optional "live chart" link under air-quality replies and alerts | -| `POLLEN=on` | optional: Melbourne grass pollen and thunderstorm asthma risk (scraped from melbournepollen.com.au) in the report, a `pollen_asthma` tool and an alert when High or Extreme. Off by default; `POLLEN_DISTRICT` picks the district (default `Central`, never printed) | +| `POLLEN=on` | optional: Melbourne grass pollen and thunderstorm asthma risk (scraped from melbournepollen.com.au) in the report, a `pollen_asthma` tool and an alert when High. Off by default; `POLLEN_DISTRICT` picks the district (default `Central`, never printed) | | `PLACE` | the name shown after the Pollen & asthma and Forecast headings in the report (default `Melbourne`) | | `FORECAST=on` | optional: the Open-Meteo daily forecast in the report and a `weather_forecast` tool. Off by default; `FORECAST_LAT` / `FORECAST_LON` set the location, else the weather station's own is used | | `ADMIN_ONLY` | on by default: the bot answers nobody (private chats and groups alike) except the admins and creators of the groups it is in; anyone else gets "Sorry, only group admins can use this bot." when they address it (ordinary group talk is ignored). The one exception is `/alerts` in a group, which anyone may open (its buttons and `/alerts on|off` stay admin-only). `ADMIN_ONLY=off` answers everyone, as before. The admins are asked of Telegram and remembered for 10 minutes; a group counts once the bot is added to it or someone speaks there | diff --git a/lib/alerts/notify.py b/lib/alerts/notify.py index 5d73dae..3a6bb5f 100644 --- a/lib/alerts/notify.py +++ b/lib/alerts/notify.py @@ -33,9 +33,10 @@ def __init__(self, path): except Exception: log.exception("Couldn't read %s - starting with no known chats", path) self.chats: dict[int, dict] = {int(k): v for k, v in data.get("chats", {}).items()} - for entry in self.chats.values(): # the old on/off flag: off becomes every type muted - if entry.pop("alerts", True) is False: - entry["muted"] = list(LABELS) + for entry in self.chats.values(): # the old on/off flag, and a chat with every type muted, are "unsubscribed from all" + if entry.pop("alerts", True) is False or set(entry.get("muted", [])) >= set(LABELS): + entry.pop("muted", None) + entry.update(optin=True, on=[]) self.monitor: dict = data.get("monitor", {}) self.charts: dict[str, str] = data.get("charts", {}) # the question behind each chart sent, for its period buttons @@ -74,14 +75,33 @@ def migrate_chat(self, old: int, new: int): self.save() def muted(self, chat_id: int) -> set[str]: - return set(self.chats.get(chat_id, {}).get("muted", [])) + """The types this chat does not get. A chat that unsubscribed from all is in opt-in mode (it keeps the types it turned on), + so a type added later is muted for it until it subscribes.""" + entry = self.chats.get(chat_id, {}) + if entry.get("optin"): + return set(LABELS) - set(entry.get("on", [])) + return set(entry.get("muted", [])) def set_kind(self, chat_id: int, kind: str, on: bool): - """Subscribe (on) or unsubscribe one alert type, or all of them (kind = "all").""" + """Subscribe (on) or unsubscribe one alert type, or all of them (kind = "all"). Unsubscribing from all also keeps + future types off for the chat; subscribing to all turns them on again.""" if chat_id not in self.chats: return + entry = self.chats[chat_id] + if kind == ALL: # on: back to the normal mode (new types come on); off: opt-in mode, where new types stay off + for key in ("muted", "optin", "on"): + entry.pop(key, None) + if not on: + entry.update(optin=True, on=[]) + self.save() + return + kinds = {kind} & set(LABELS) + if entry.get("optin"): + turned_on = set(entry.get("on", [])) | kinds if on else set(entry.get("on", [])) - kinds + entry["on"] = [k for k in LABELS if k in turned_on] + self.save() + return muted = self.muted(chat_id) - kinds = set(LABELS) if kind == ALL else {kind} & set(LABELS) muted = muted - kinds if on else muted | kinds if muted: self.chats[chat_id]["muted"] = [k for k in LABELS if k in muted] diff --git a/lib/alerts/pollen.py b/lib/alerts/pollen.py index e8f95a3..5cef778 100644 --- a/lib/alerts/pollen.py +++ b/lib/alerts/pollen.py @@ -1,9 +1,9 @@ """Pollen and thunderstorm asthma alerts, checked after every refresh of the Pollen source (every 30 minutes in the day, October to December only). -A warning when grass pollen or the thunderstorm asthma risk is High 🟠 or Extreme 🔴. One alert per metric, level and day: another -when it rises from High to Extreme, or on a new day that is still High or worse; none while it stays the same. Once it drops below -High the metric is ready to warn again. +A warning when grass pollen or the thunderstorm asthma risk is High 🔴 (the scale is Low 🟢, Moderate 🟠, High 🔴; "No data" counts +as no reading). One alert per metric and day: another on a new day that is still High; none while it stays the same. Once it drops +below High the metric is ready to warn again. """ import logging diff --git a/lib/ecowitt/query.py b/lib/ecowitt/query.py index d8b6176..0cbef41 100644 --- a/lib/ecowitt/query.py +++ b/lib/ecowitt/query.py @@ -254,7 +254,8 @@ async def _refine(self) -> int: refined += len(windows) for (cycle, w_start, w_end), targets in windows.items(): fine: dict = {} - collect(fine, await f.get(cycle, f.local(w_start), min(f.local(w_end), self.now)), cycle) + for a, b in spans(cycle, f.local(w_start), min(f.local(w_end), self.now)): # a UTC day is 25 local hours across a DST change + collect(fine, await f.get(cycle, a, b), cycle) for sid in targets: key, want = sid[1], sid[-1] found = series_extremes(fine[key], w_start, w_end).get(want) if key in fine else None diff --git a/lib/pollen/parse.py b/lib/pollen/parse.py index f26abed..dd28e76 100644 --- a/lib/pollen/parse.py +++ b/lib/pollen/parse.py @@ -7,8 +7,8 @@ from html.parser import HTMLParser from itertools import pairwise -LEVELS = ("Low", "Moderate", "High", "Extreme") -LEVEL_EMOJI = {"Low": "🟢", "Moderate": "🟡", "High": "🟠", "Extreme": "🔴"} +LEVELS = ("Low", "Moderate", "High") # the site's scale; "No data" is not a level: it reads as if there were no forecast +LEVEL_EMOJI = {"Low": "🟢", "Moderate": "🟠", "High": "🔴"} DISTRICTS = {"Central", "East Gippsland", "Mallee", "North Central", "North East", "Northern Country", "South West", "West and South Gippsland", "Wimmera"} diff --git a/lib/pollen/source.py b/lib/pollen/source.py index 1913fec..e591dee 100644 --- a/lib/pollen/source.py +++ b/lib/pollen/source.py @@ -27,7 +27,7 @@ SEASON_MONTHS = (10, 11, 12) # grass pollen and the thunderstorm asthma forecast run October to December: nothing else is fetched, shown or alerted OFF_SEASON = "Pollen and thunderstorm asthma forecasts only run from October to December." -DESCRIPTION = ("Melbourne's grass pollen level today and the thunderstorm asthma risk (Low, Moderate, High or Extreme), from " +DESCRIPTION = ("Melbourne's grass pollen level today and the thunderstorm asthma risk (Low, Moderate or High), from " "melbournepollen.com.au. Use it for pollen, hay fever and thunderstorm asthma questions. Takes no arguments. " "The result's \"lines\" are ready-made, emoji included: copy them as they are.") PARAMETERS = {"type": "object", "properties": {}} diff --git a/lib/prompt.py b/lib/prompt.py index 28c0406..356fbed 100644 --- a/lib/prompt.py +++ b/lib/prompt.py @@ -183,7 +183,7 @@ def capabilities(sources: list[str], brief: bool = False) -> str: "Use them for \"plot X against Y\", \"does rain affect air quality\" and \"is there a correlation between air " "quality and other metrics\"; a series or style not listed can't be plotted: say so.") if "melbournepollen" in have: - lines.append("- Pollen: Melbourne's grass pollen level and the thunderstorm asthma risk (Low, Moderate, High, Extreme), tool pollen_asthma. " + lines.append("- Pollen: Melbourne's grass pollen level and the thunderstorm asthma risk (Low, Moderate, High), tool pollen_asthma. " "Both forecasts only run from October to December: outside those months the tool says so.") if "Open-Meteo" in have: lines.append("- Forecast: today and the days ahead from Open-Meteo (summary, temperatures, chance of at least 1 mm of rain), tool weather_forecast.") @@ -191,7 +191,7 @@ def capabilities(sources: list[str], brief: bool = False) -> str: + "; \"weather all week\" draws every reading); wind as average speed with gusts beside a compass rose of directions; " "air quality with ratings.") lines.append("- Alerts, sent to chats automatically: rain starting or stopping, rain likely soon, wind gusts over 40 km/h, UV index of 10 or more, " - "indoor/outdoor temperature crossing, air-quality mask alerts" + (", pollen or thunderstorm asthma risk reaching High or Extreme" if "melbournepollen" in have else "") + ( + "indoor/outdoor temperature crossing, air-quality mask alerts" + (", pollen or thunderstorm asthma risk reaching High" if "melbournepollen" in have else "") + ( ", a forecast it sent being revised (rain, or max temperature over 2 degrees)" if "Open-Meteo" in have else "") + ". Each alert has buttons to subscribe or unsubscribe by type, and /alerts opens the same settings. Custom alerts " "(\"tell me when winds reach 100\", another limit) can't be added: say so.") missing = ["lightning", "soil or extra sensor channels", "indoor air quality", "other stations or places"] diff --git a/tests/test_agent.py b/tests/test_agent.py index aa79993..237a46f 100644 --- a/tests/test_agent.py +++ b/tests/test_agent.py @@ -263,7 +263,7 @@ def test_the_capabilities_mention_the_new_sources_only_when_they_are_on(): base = prompt.capabilities(["Ecowitt weather station"]) assert "pollen and thunderstorm asthma" in base.split("Not available:")[1] and "forecasts (only a short read" in base both = prompt.capabilities(["Ecowitt weather station", POLLEN_SOURCE, FORECAST_SOURCE]) - assert "tool pollen_asthma" in both and "tool weather_forecast" in both and "thunderstorm asthma risk reaching High or Extreme" in both + assert "tool pollen_asthma" in both and "tool weather_forecast" in both and "thunderstorm asthma risk reaching High" in both assert "pollen" not in both.split("Not available:")[1] and "forecasts" not in both.split("Not available:")[1] diff --git a/tests/test_alerts.py b/tests/test_alerts.py index 531c4b4..e45e809 100644 --- a/tests/test_alerts.py +++ b/tests/test_alerts.py @@ -410,28 +410,28 @@ async def notify(text, link=None, **kw): return PollenMonitor(pollen, state, notify), pollen, sent -async def test_high_or_extreme_pollen_and_asthma_alert_once_per_level_and_day(tmp_path): +async def test_high_pollen_and_asthma_alert_once_per_metric_and_day(tmp_path): mon, pollen, sent = pollen_monitor(tmp_path) - for grass, asthma in (("Low", "Low"), ("Moderate", "Moderate")): + for grass, asthma in (("Low", "Low"), ("Moderate", "Moderate")): # 🟢 and 🟠 are not warnings pollen.grass, pollen.asthma = grass, asthma await mon.check() assert sent == [] pollen.grass = "High" await mon.check() await mon.check() # unchanged: nothing more - assert sent == ["🟠 Grass pollen is High."] - pollen.grass, pollen.asthma = "Extreme", "Extreme" + assert sent == ["🔴 Grass pollen is High."] + pollen.asthma = "High" await mon.check() - assert sent[1:] == ["🔴 Grass pollen is Extreme.", "🔴 Thunderstorm asthma risk is Extreme. Check your asthma action plan."] + assert sent[1:] == ["🔴 Thunderstorm asthma risk is High. Check your asthma action plan."] pollen.grass, pollen.asthma = "Low", "Moderate" # dropped below High: ready to warn again await mon.check() pollen.grass = "High" await mon.check() - assert len(sent) == 4 and sent[-1] == "🟠 Grass pollen is High." + assert len(sent) == 3 and sent[-1] == "🔴 Grass pollen is High." pollen.today += timedelta(days=1) # a new day, still High pollen.fetched_at = time.time() await mon.check() - assert len(sent) == 5 and "Central" not in " ".join(sent) + assert len(sent) == 4 and "Central" not in " ".join(sent) async def test_a_restart_does_not_repeat_an_alert_and_a_missing_forecast_or_stale_page_is_ignored(tmp_path): @@ -444,14 +444,14 @@ async def test_a_restart_does_not_repeat_an_alert_and_a_missing_forecast_or_stal await again.check() assert len(sent) == 1 pollen.asthma = None # off-season: nothing to say - pollen.grass, pollen.fetched_at = "Extreme", time.time() - 3 * 3600 # and a page not fetched for 3 hours is not news + pollen.grass, pollen.fetched_at = "High", time.time() - 3 * 3600 # and a page not fetched for 3 hours is not news await again.check() assert len(sent) == 1 async def test_no_pollen_alert_out_of_season_even_with_a_high_reading_cached(tmp_path): mon, pollen, sent = pollen_monitor(tmp_path) - pollen.grass, pollen.asthma, pollen.season = "Extreme", "High", False + pollen.grass, pollen.asthma, pollen.season = "High", "High", False await mon.check() assert sent == [] pollen.season = True @@ -541,3 +541,40 @@ async def test_a_dry_night_has_no_summary_and_quiet_hours_can_be_off(tmp_path): m.station.data = night_rain(int(datetime(2026, 9, 30, 2, 0, tzinfo=TZ).timestamp()), 0, 0, 1) await m.check() assert len(sent) == 1 and "started raining" in sent[0] + + +def test_unsubscribing_from_all_keeps_future_alert_types_off_until_subscribed(tmp_path, monkeypatch): + from lib.alerts import menu, notify + state = notify.AlertState(tmp_path / "s.json") + for chat in (1, 2, 3): + state.add_chat(chat, str(chat)) + state.set_kind(1, "all", False) # unsubscribe from all + state.set_kind(2, "rain", False) # just one type: the normal mode + monkeypatch.setitem(menu.LABELS, "newthing", "new thing") + assert not state.subscribed(1, "newthing") and state.subscribed(2, "newthing") and state.subscribed(3, "newthing") + state.set_kind(1, "uv", True) # then one type back on: only that one + monkeypatch.setitem(menu.LABELS, "newer", "newer") + assert state.subscribed(1, "uv") and not state.subscribed(1, "rain") and not state.subscribed(1, "newer") and state.alert_chats("uv") == [1, 2, 3] + state.set_kind(1, "uv", False) + assert not state.subscribed(1) # nothing on at all + state.set_kind(1, "all", True) # subscribe to all: new types come on again + assert state.subscribed(1, "newer") and state.subscribed(1, "rain") + for kind in list(menu.LABELS): # turning every type off one by one is not "unsubscribe from all" + state.set_kind(3, kind, False) + monkeypatch.setitem(menu.LABELS, "newest", "newest") + assert state.subscribed(3, "newest") + + +def test_chats_that_already_unsubscribed_from_all_load_as_opt_in(tmp_path, monkeypatch): + import json + from lib.alerts import menu, notify + path = tmp_path / "s.json" + path.write_text(json.dumps({"chats": {"1": {"title": "a", "muted": list(menu.LABELS)}, "2": {"title": "b", "alerts": False}, + "3": {"title": "c", "muted": ["rain"]}}})) + state = notify.AlertState(path) + monkeypatch.setitem(menu.LABELS, "newthing", "new thing") + assert not state.subscribed(1, "newthing") and not state.subscribed(2, "newthing") and state.subscribed(3, "newthing") + state.set_kind(1, "uv", True) + 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") diff --git a/tests/test_pollen.py b/tests/test_pollen.py index e4720bc..d24a54f 100644 --- a/tests/test_pollen.py +++ b/tests/test_pollen.py @@ -4,7 +4,7 @@ import httpx -from lib.pollen import Pollen, parse_melbourne_pollen +from lib.pollen import LEVEL_EMOJI, LEVELS, Pollen, parse_melbourne_pollen from lib.warm import in_sync_hours from tests.fakes import TZ, config @@ -39,10 +39,10 @@ def make(tmp_path, html=None, hour=12, **over): def test_the_page_is_parsed_into_levels_districts_and_the_update_time(): - got = parse_melbourne_pollen(page(grass="High", asthma="Extreme")) + got = parse_melbourne_pollen(page(grass="High", asthma="High")) assert got["melbourne_grass"] == "High" and got["melbourne_date"] == SEASON_DAY assert got["district_grass"] == {"Central": "High", "Mallee": "Moderate"} - assert got["thunderstorm_asthma"] == {"Central": "Extreme", "Wimmera": "High"} + assert got["thunderstorm_asthma"] == {"Central": "High", "Wimmera": "High"} assert got["asthma_updated"] == "1 Oct 2026 4:00pm" @@ -53,9 +53,9 @@ def test_outside_the_asthma_season_there_is_no_asthma_forecast_and_an_unknown_pa async def test_the_lines_have_a_level_emoji_and_never_name_the_district(tmp_path): - pollen, _ = make(tmp_path, lambda: page(grass="High", asthma="Extreme")) + pollen, _ = make(tmp_path, lambda: page(grass="High", asthma="High")) await pollen.start() - assert pollen.lines() == ["Grass pollen: 🟠 High", "Thunderstorm asthma risk: 🔴 Extreme"] + assert pollen.lines() == ["Grass pollen: 🔴 High", "Thunderstorm asthma risk: 🔴 High"] out = json.loads(await pollen.handle({})) assert out["lines"] == pollen.lines() and "Central" not in json.dumps(out) and out["asthma_updated"] == "1 Oct 2026 4:00pm" await pollen.close() @@ -64,7 +64,7 @@ async def test_the_lines_have_a_level_emoji_and_never_name_the_district(tmp_path async def test_a_forecast_for_another_day_and_a_missing_asthma_forecast_are_said_so(tmp_path): pollen, _ = make(tmp_path, lambda: page(grass="Moderate", day=SEASON_DAY + timedelta(days=1), season=False)) await pollen.start() - assert pollen.lines() == ["Grass pollen: 🟡 Moderate (forecast for Wed 11 Nov)"] + assert pollen.lines() == ["Grass pollen: 🟠 Moderate (forecast for Wed 11 Nov)"] assert "asthma_note" in json.loads(await pollen.handle({})) await pollen.close() @@ -148,7 +148,7 @@ async def test_last_seasons_page_is_not_shown_and_the_report_leaves_the_block_ou from lib import report pollen, _ = make_on(tmp_path, date(2026, 12, 20), html=lambda: page(grass="High", day=date(2026, 12, 20))) await pollen.start() - assert pollen.lines()[0].startswith("Grass pollen: 🟠 High") + assert pollen.lines()[0].startswith("Grass pollen: 🔴 High") await pollen.close() january, calls = make_on(tmp_path, date(2027, 1, 5)) # the saved page is still in the database await january.start() @@ -170,3 +170,9 @@ async def test_the_first_warm_on_the_first_of_october_fetches_without_a_restart( await pollen.warm(True) assert len(calls) == 1 and pollen.lines() await pollen.close() + + +def test_no_data_is_not_a_level_so_it_reads_as_no_forecast(): + got = parse_melbourne_pollen(page(grass="No data", asthma="No data")) + assert got["melbourne_grass"] is None and got["thunderstorm_asthma"] == {"Wimmera": "High"} # Central has none: Wimmera is another district + assert LEVELS == ("Low", "Moderate", "High") and [LEVEL_EMOJI[k] for k in LEVELS] == ["🟢", "🟠", "🔴"] diff --git a/tests/test_snapshots.py b/tests/test_snapshots.py index 59594d4..d9f7c3b 100644 --- a/tests/test_snapshots.py +++ b/tests/test_snapshots.py @@ -38,18 +38,18 @@ def test_the_connection_is_shared_and_closed_by_the_last_user(tmp_path): async def test_a_restart_in_the_evening_starts_from_the_saved_pollen_page_without_fetching(tmp_path): - first, calls = make_pollen(tmp_path, lambda: page(grass="High", asthma="Extreme")) + first, calls = make_pollen(tmp_path, lambda: page(grass="High", asthma="High")) await first.start() assert len(calls) == 1 await first.close() again, more = make_pollen(tmp_path, lambda: page(grass="Low"), hour=22) await again.start() - assert more == [] and again.lines() == ["Grass pollen: 🟠 High", "Thunderstorm asthma risk: 🔴 Extreme"] + assert more == [] and again.lines() == ["Grass pollen: 🔴 High", "Thunderstorm asthma risk: 🔴 High"] assert again.current()["fetched_at"] == first.fetched_at again.now = lambda: datetime.combine(SEASON_DAY, dtime(9, 0)) await again.warm(True) # next morning: asks with the saved validator assert len(more) == 1 and more[0].headers["if-none-match"] == "v1" - assert again.lines()[0].startswith("Grass pollen: 🟠 High") # a 304: the saved page still stands + assert again.lines()[0].startswith("Grass pollen: 🔴 High") # a 304: the saved page still stands await again.close()