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
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@

A Telegram bot for a personal Ecowitt weather station and AirGradient air-quality sensor.
Ask it about the weather or air quality (text or charts); it also sends silent alerts to every
chat it is in: rain starting/stopping, gusts over 40 km/h, UV index of 10 or more, indoor/outdoor temperatures crossing (held back for the cooldown, then announced if it still stands),
and unhealthy air (with when it is safe again). Every alert carries Subscribe / Unsubscribe buttons that expand into the alert types (rain, gusts, UV, temperature crossing, particulates, pollen & asthma, forecast changes): Subscribe lists the types that are off, Unsubscribe the ones that are on; `/alerts` opens the same settings (in a private chat, a ⚙️ Settings button there and under each alert has Enable and Disable notification sounds sub-menus that list the alert types; alerts are silent unless you do; and `/alerts on|off` still subscribes or unsubscribes every type); `/usage` is the Help & Alerts button. In a group anyone can open `/alerts`, but only admins can change anything (`lib/alerts/menu.py`).
chat it is in: rain starting/stopping, gusts over 40 km/h (with the last day's wind chart), UV index of 10 or more in two readings in a row (with the last day's UV chart), indoor/outdoor temperatures crossing by more than 1°C (held back for the cooldown, then announced if it still stands, with the last day's temperature chart),
and unhealthy air (two readings in a row; with when it is safe again; both with the last day's particulates chart). Every alert carries Subscribe / Unsubscribe buttons that expand into the alert types (rain, gusts, UV, temperature crossing, particulates, pollen & asthma, forecast changes): Subscribe lists the types that are off, Unsubscribe the ones that are on; `/alerts` opens the same settings (in a private chat, a ⚙️ Settings button there and under each alert has Enable and Disable notification sounds sub-menus that list the alert types; alerts are silent unless you do; and `/alerts on|off` still subscribes or unsubscribes every type); `/usage` is the Help & Alerts button. In a group anyone can open `/alerts`, but only admins can change anything (`lib/alerts/menu.py`).

## Run

Expand Down
23 changes: 23 additions & 0 deletions lib/alerts/confirm.py
Original file line number Diff line number Diff line change
@@ -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)
31 changes: 22 additions & 9 deletions lib/alerts/notify.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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:
Expand Down
28 changes: 19 additions & 9 deletions lib/alerts/particulates.py
Original file line number Diff line number Diff line change
@@ -1,20 +1,21 @@
"""Particulates alerts (PM2.5 and PM10) from the AirGradient sensor, checked every 30 minutes.

"Wear a mask outside" when PM2.5 or PM10 reaches US AQI 151+ ("unhealthy", the official level
at which masks are advised for everyone) for two checks in a row; "back to safe" when both are at
AQI 100 or below for two checks in a row. Every episode gets the pair. Particles only: masks
at which masks are advised for everyone) in two readings in a row; "back to safe" when both are at
AQI 100 or below in two readings in a row (confirm.py: a check that brings no new reading counts for nothing). Every episode gets the pair. Particles only: masks
filter particles, not gases like CO2/VOC/NOx.
"""

import logging
from datetime import datetime

from . import confirm, pictures

log = logging.getLogger(__name__)

CHECK_SECONDS = 30 * 60
MASK_PM25, MASK_PM10 = 55.5, 255.0 # US AQI 151+
SAFE_PM25, SAFE_PM10 = 35.4, 154.0 # US AQI 100 or better
CONFIRM_CHECKS = 2 # consecutive checks, so a passing puff of smoke doesn't count
STALE_SECONDS = 2 * 3600 # ignore readings older than this (sensor offline)


Expand All @@ -36,19 +37,28 @@ async def check(self, reading: dict | None = None, now: datetime | None = None):
pm10 = (reading.get("pm10") or {}).get("value")
if pm25 is None and pm10 is None:
return
if when is not None and not confirm.new_reading(m, when.timestamp()): # the sensor has said nothing new since the last check
return
bad = (pm25 is not None and pm25 >= MASK_PM25) or (pm10 is not None and pm10 >= MASK_PM10)
good = (pm25 is None or pm25 <= SAFE_PM25) and (pm10 is None or pm10 <= SAFE_PM10)
m["above"] = m["above"] + 1 if bad else 0
m["below"] = m["below"] + 1 if good else 0
if not m["unsafe"] and m["above"] >= CONFIRM_CHECKS:
confirm.run(m, "above", bad)
confirm.run(m, "below", good)
if not m["unsafe"] and m["above"] >= confirm.CONFIRM_READINGS:
m["unsafe"] = True
photo = await self._picture()
await self.notify("\U0001f637 Unhealthy air outside — wear a P2/N95 mask today. " + self._levels(reading),
link=self.air.link, kind="particulates")
elif m["unsafe"] and m["below"] >= CONFIRM_CHECKS:
link=self.air.link, kind="particulates", **({"photo": photo} if photo else {}))
elif m["unsafe"] and m["below"] >= confirm.CONFIRM_READINGS:
m["unsafe"] = False
await self.notify("✅ Outdoor air is safe again. " + self._levels(reading), link=self.air.link, kind="particulates")
photo = await self._picture()
await self.notify("✅ Outdoor air is safe again. " + self._levels(reading), link=self.air.link, kind="particulates",
**({"photo": photo} if photo else {}))
self.state.save()

async def _picture(self) -> bytes | None:
"""The 1-day particulates chart (what the Particulates button gives), for the alert; None when it cannot be made."""
return await pictures.chart(self.air, "Particulates chart 1d", self.air.tz)

@staticmethod
def _levels(reading: dict) -> str:
"""Short summary: PM2.5 (with AQI), plus PM10 only when it's elevated."""
Expand Down
27 changes: 27 additions & 0 deletions lib/alerts/pictures.py
Original file line number Diff line number Diff line change
@@ -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
49 changes: 35 additions & 14 deletions lib/alerts/weather.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
"""
Expand All @@ -16,14 +18,15 @@
from datetime import timedelta

from ..ecowitt.glance import UVI_ALERT
from . import confirm, pictures
from .backoff import due, remember
from ..ecowitt.rain import Rows, duration, rain_amount, wet_flags
from ..timeutil import to_local

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
Expand Down Expand Up @@ -165,30 +168,43 @@ async def _gusts(self, rows: Rows):
if not m["active"]:
m["active"] = True
gust, ts = max(over)
photo = await self._picture("Wind chart 1d")
await self.notify(f"\U0001f4a8 Strong gusts: {gust:.0f} km/h at {to_local(ts, self.tz):%-I:%M%p}".replace("AM", "am").replace("PM", "pm")
+ f" (alerts above {GUST_ALERT_KMH} km/h).", kind="gusts")
+ f" (alerts above {GUST_ALERT_KMH} km/h).", kind="gusts", **({"photo": photo} if photo else {}))
elif m["active"]:
m["calm_since"] = m["calm_since"] or latest_ts
if latest_ts - m["calm_since"] >= GUST_REARM_SECONDS:
m.update(active=False, calm_since=None)

async def _uv(self, rows: Rows):
"""Like the gusts: only readings since the last check count, one alert per spell at or above UVI_ALERT."""
"""One alert per spell at or above UVI_ALERT, once it holds for two readings in a row (confirm.py); only readings newer than the last
one counted are looked at, so a restart doesn't re-announce an old spell."""
m = self.state.monitor.setdefault("uv", {"active": False, "calm_since": None})
latest_ts = rows[-1][0]
seen, m["checked"] = m.get("checked", latest_ts), latest_ts
over = [(u, ts) for ts, r in rows if ts > seen and (u := r.get("solar_and_uvi.uvi")) is not None and u >= UVI_ALERT]
if over:
m.setdefault("stamp", m.pop("checked", latest_ts)) # the first look only sets the mark
high = False
for ts, r in rows:
if not confirm.new_reading(m, ts):
continue
u = r.get("solar_and_uvi.uvi")
hit = None if u is None else u >= UVI_ALERT
streak = confirm.run(m, "streak", hit)
if hit and (streak == 1 or u > m["peak"][0]): # the run's highest reading is the one told
m["peak"] = [u, ts]
high = high or bool(hit)
if high:
m["calm_since"] = None
if not m["active"]:
m["active"] = True
uv, ts = max(over)
await self.notify(f"\U0001f9f4 UV index {uv:g} at {to_local(ts, self.tz):%-I:%M%p}".replace("AM", "am").replace("PM", "pm")
+ f": very high. Sunscreen, a hat and shade (alerts at {UVI_ALERT} and above).", kind="uv")
elif m["active"]:
m["calm_since"] = m["calm_since"] or latest_ts
if latest_ts - m["calm_since"] >= UV_REARM_SECONDS:
m.update(active=False, calm_since=None)
if m.get("streak", 0) >= confirm.CONFIRM_READINGS and not m["active"]:
m["active"] = True
uv, ts = m["peak"]
photo = await self._picture("UV chart 1d")
await self.notify(f"\U0001f9f4 UV index {uv:g} at {to_local(ts, self.tz):%-I:%M%p}".replace("AM", "am").replace("PM", "pm")
+ f": very high. Sunscreen, a hat and shade (alerts at {UVI_ALERT} and above).", kind="uv",
**({"photo": photo} if photo else {}))

async def _cross(self, rows: Rows):
ts, r = rows[-1]
Expand All @@ -206,6 +222,11 @@ async def _cross(self, rows: Rows):
held = ts - c["alerted_time"] # how long the side we last told about has stood
was = f"{int(held // 86400)} days" if held >= 2 * 86400 else duration(held)
icon = "\U0001f321️" if current == "warmer" else "\u2744\ufe0f" # a thermometer for warmer, a snowflake for cooler
photo = await self._picture("Temperature chart 1d")
await self.notify(f"{icon} It's now {abs(o - i):.1f}°C {current} outside than inside "
f"({o:.1f}°C vs {i:.1f}°C). It had been {'cooler' if current == 'warmer' else 'warmer'} for {was}.", kind="temps")
f"({o:.1f}°C vs {i:.1f}°C). It had been {'cooler' if current == 'warmer' else 'warmer'} for {was}.", kind="temps", **({"photo": photo} if photo else {}))
remember(c, current, ts)

async def _picture(self, text: str) -> bytes | None:
"""The chart a typed `text` ("Wind chart 1d") would give, for an alert; None when it cannot be made."""
return await pictures.chart(self.station, text, self.tz)
3 changes: 3 additions & 0 deletions lib/ecowitt/query.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 "")
Expand Down
4 changes: 2 additions & 2 deletions lib/ecowitt/source.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Loading
Loading