Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
25a5beb
Alert menu: no Sub button when every type is on; Sub, Unsub and Setti…
claude Oct 18, 2026
6ae6e7d
Alert menu: Sub and Unsub are back, with a bell and a crossed-out bell
claude Oct 18, 2026
157be07
Alert menu: Sub and Unsub are just the bell and the crossed-out bell
claude Oct 18, 2026
bf26beb
Alert menu: no Sub under an alert; its bell opens the alert's own typ…
claude Oct 18, 2026
fc43cc6
Log the "Keeping warm:" and "Alerts:" lists on one line, never cut
claude Oct 18, 2026
31f5b89
LOG_WIDTH applies to every log line, whatever its level
claude Oct 18, 2026
8bf0918
Alerts carry a sound toggle instead of the gear; /alerts keeps the fu…
claude Oct 18, 2026
f207595
Individual alert menu redesigned: one gear opening an Alert and a Sou…
claude Oct 18, 2026
ac15012
/alerts: two buttons, Alerts and Sound, each a full list of struck-th…
claude Oct 18, 2026
7598ed8
/alerts Sound menu lists only the alerts that are on
claude Oct 18, 2026
faada1c
/alerts menus: a tick or a cross before each type, and the opening bu…
claude Oct 18, 2026
a895f43
/alerts lists: a type that is on is a green button, one that is off a…
claude Oct 18, 2026
66f1ea1
Every toggle button is a green tick or a red cross
claude Oct 18, 2026
ca69802
Toggle buttons: no tick or cross; green when on, red and struck throu…
claude Oct 18, 2026
3c75c90
/alerts menus: no Back button; the open Alerts or Sound button closes…
claude Oct 18, 2026
644bc20
Individual alert menu opens with a bell; toggles are plain when on an…
claude Oct 18, 2026
6550a86
Toggles: green when on, struck through and uncoloured when off
claude Oct 18, 2026
233135d
Consolidate the button code: shared helpers, one callback-data scheme…
claude Oct 18, 2026
c5e4a6a
Two Ecowitt tests no longer fail in the first hours of the local day
claude Oct 18, 2026
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 @@ -3,7 +3,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 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`).
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 one 🔔 button that opens up to two toggles for that alert type: Alert (on or off in this chat) and, while that is on and in a private chat, Sound (whether it makes a notification sound; alerts are silent unless you turn it on). Every toggle in the bot is a green button while on and a plain one with its name struck through while off. `/alerts` has two buttons, 🔔 Alerts and (in a private chat) 🔊 Sound; each opens a list of alert types (rain, gusts, UV, temperature crossing, particulates, pollen & asthma, forecast changes; Sound lists only those that are on) as toggles, green while on and with the name struck through while off, while one is open its own button stays (press it to close the list) and the other is hidden until you do; `/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 Expand Up @@ -46,7 +46,7 @@ The state and caches go in `/data` (its `DATA_DIR`), here a named volume. A host
| `CHART_ALL_VPD=on` | optional: "weather all week" also draws the vapour pressure deficit panel; off by default, as it is temperature and humidity combined (the station reports it for the outdoor sensor only). Naming it ("plot vpd") always works |
| `TELEGRAM_PRELOAD_CHAT` | optional: a chat id (negative for a group or channel) the bot silently uploads charts to ahead of need. With it set, each chart sent with period buttons has its other periods drawn and uploaded there in the background, each on its own the moment it is drawn, starting as soon as the chart is sent and whatever else is being answered, so pressing a period button edits the message with Telegram's copy and uploads nothing. A chart uploaded this way is used for 30 minutes, then drawn afresh. The bot must be able to post in that chat; the uploaded messages are left there. If Telegram refuses an upload (the bot can't post there) none is tried again until a restart; a flood limit or a timeout only skips that photo. Unset: off |
| `ANTISPIKE_REQUIRE_CONSECUTIVE` | optional: how many readings in a row a rise of the particles (PM1, PM2.5, PM10) needs to count as the air rather than a glitch of the sensor. Default `2`: a single-reading spike (at least 2.5 times, and 5 µg/m³ above, the readings either side) is left out of PM charts and of every air figure (highs, lows, averages, days over a limit), and the unhealthy-air alert counts a check only when the sensor's last 2 readings are all over the mask level. `3` also drops two-reading spikes, and so on; `1` or `off` turns the rule off. The live "air now" reading and the "safe again" check are unchanged |
| `LOG_WIDTH` | optional: the most characters of a log line, cut with an ellipsis (…); `0` cuts none. Default 71. Every INFO line obeys it; a `WARN` or `ERROR` entry is never cut (a warning is printed as `WARN`); a request, chat message, reply or alert is also put on one line, and the startup "Alerts:" list wraps at it (at 90 when `0`) |
| `LOG_WIDTH` | optional: the most characters of a log line, cut with an ellipsis (…); `0` cuts none. Default 71. Every log line obeys it, whatever its level (a warning is printed as `WARN`); a request, chat message, reply or alert is also put on one line |
| `LOG_LEVEL` | optional: `DEBUG`, `INFO` (default), `WARNING` or `ERROR`. `DEBUG` adds the bot's own debug lines (the chart timings, charts it couldn't draw ahead); the libraries stay at their usual level. |

## Layout
Expand Down
23 changes: 3 additions & 20 deletions ecobot.py
Original file line number Diff line number Diff line change
Expand Up @@ -95,27 +95,10 @@ def commit_in(git_dir) -> str:
return "unknown"


INDENT = 13 # the continuation lines of a wrapped log line


def wrapped(head: str, items: list[str], tail: str = "", width: int = 90) -> str:
"""head + the items joined by ", " + tail, over several lines when it is longer than `width`: a line breaks only between items,
and the next one is indented."""
lines, line = [], head
for i, item in enumerate(items):
piece = item + (", " if i < len(items) - 1 else tail)
if line.strip() != head.strip() and len(line + piece.rstrip()) > width:
lines.append(line.rstrip())
line = " " * INDENT
line += piece
return "\n".join([*lines, line])


async def main():
log.info("Starting ecobot %s", version())
cfg = Config.from_env()
_handler.formatter.width = cfg.log_width # every log line is cut to it from here on
WRAP = max(cfg.log_width - len("INFO "), 30) if cfg.log_width else 90 # where the lists in the log wrap
air_metrics.REQUIRE_CONSECUTIVE = cfg.antispike_require_consecutive
intent.FEELS_LIKE_IN_ALL = cfg.chart_all_feels_like
intent.VPD_IN_ALL = cfg.chart_all_vpd
Expand Down Expand Up @@ -161,7 +144,7 @@ async def main():
log.info("@%s + %s", app.bot.username, cfg.xai_model)
log.info("Sources: %s", ", ".join(s.name for s in sources))
kinds = kinds_for((s.name for s in sources), irrigation is not None)
notify = Notifier(app.bot, state, kinds)
notify = Notifier(app.bot, state)
await bot.refresh_keyboards(app.bot) # chats whose buttons are out of date are told, with the new ones

# Alerts
Expand All @@ -186,7 +169,7 @@ async def main():

# Keeping warm: everything questions need, refreshed before they arrive
tasks.extend(asyncio.create_task(s.warmer.run()) for s in sources)
log.info("%s", wrapped("Keeping warm: ", [f"{s.name} {s.warmer.interval:.0f}s" for s in sources], "", WRAP))
log.info("Keeping warm: %s", ", ".join(f"{s.name} {s.warmer.interval:.0f}s" for s in sources))

async def startup_warmup():
"""Fetch everything once, together, and log one summary line."""
Expand Down Expand Up @@ -226,7 +209,7 @@ async def air_backfill():
time.monotonic() - started)
tasks.append(asyncio.create_task(every(PREDRAW_SECONDS, bot.predraw))) # the button charts, ready before they are pressed
on = [k for k in LABELS if k in running] # the line says what started, not what is configured
log.info("%s", wrapped("Alerts: ", [LABELS[k] for k in on] or ["none"], f": {len(state.alert_chats())} chats", WRAP))
log.info("Alerts: %s: %d chats", ", ".join(LABELS[k] for k in on) or "none", len(state.alert_chats()))
if missing := [LABELS[k] for k in kinds if k not in running]:
log.warning("Alerts NOT running: %s (see the error above)", ", ".join(missing))

Expand Down
Loading
Loading