Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
32 changes: 26 additions & 6 deletions lib/alerts/notify.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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]
Expand Down
6 changes: 3 additions & 3 deletions lib/alerts/pollen.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down
3 changes: 2 additions & 1 deletion lib/ecowitt/query.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions lib/pollen/parse.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"}

Expand Down
2 changes: 1 addition & 1 deletion lib/pollen/source.py
Original file line number Diff line number Diff line change
Expand Up @@ -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": {}}
Expand Down
4 changes: 2 additions & 2 deletions lib/prompt.py
Original file line number Diff line number Diff line change
Expand Up @@ -183,15 +183,15 @@ 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.")
lines.append("- Charts: any one reading, or several readings together on one time axis, one panel each (" + ", ".join(WEATHER_NAMES)
+ "; \"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"]
Expand Down
2 changes: 1 addition & 1 deletion tests/test_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]


Expand Down
55 changes: 46 additions & 9 deletions tests/test_alerts.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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
Expand Down Expand Up @@ -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")
20 changes: 13 additions & 7 deletions tests/test_pollen.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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"


Expand All @@ -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()
Expand All @@ -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()

Expand Down Expand Up @@ -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()
Expand All @@ -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] == ["🟢", "🟠", "🔴"]
Loading
Loading