Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
65 commits
Select commit Hold shift + click to select a range
6ad20cd
Simplify the button handlers: one message edit, one irrigation view, …
claude Oct 18, 2026
cef9c49
Ecowitt's API keys no longer reach the model or the log
claude Oct 18, 2026
0ebb910
A typo in an on/off setting keeps its default; a blank setting is its…
claude Oct 18, 2026
15e5808
The time zone is the weather station's own
claude Oct 18, 2026
7de0326
Questions as phones type them, and words that only look like others
claude Oct 18, 2026
7865509
A temperature low just above zero keeps its label
claude Oct 18, 2026
ba98be5
No promised chart that can't be drawn; bars below zero drawn as bars
claude Oct 18, 2026
262342e
Rain and rating bars keep to local days across daylight saving
claude Oct 18, 2026
9bd0e25
Charts are drawn in real time, with their ticks on local hours
claude Oct 18, 2026
36762d9
Rain charts include the newest rain; a past week's wind keeps its las…
claude Oct 18, 2026
179494b
Ecowitt retries server errors without blocking questions; outages cos…
claude Oct 18, 2026
b8a6a70
Waits, windows and dates in real time across daylight saving
claude Oct 18, 2026
bb94a9c
A failed cache write is rolled back; day figures read each field once
claude Oct 18, 2026
2f54551
A forecast revision no longer silences later ones
claude Oct 18, 2026
388c4d6
Rain totals cover the whole spell, and quiet hours across midnight ke…
claude Oct 18, 2026
a6e8e8c
A damaged state file is kept aside; one chat's failed alert no longer…
claude Oct 18, 2026
afe2ac9
An odd reply from the irrigation controller is an error the buttons r…
claude Oct 18, 2026
fd6df15
A second rainy day keeps irrigation paused; a stale forecast no longe…
claude Oct 18, 2026
730dcf7
A reworded pollen page keeps its asthma risk; last season's page is n…
claude Oct 18, 2026
c1d09ef
AirGradient: an odd reply is an error, not empty days; the reading st…
claude Oct 18, 2026
f6255e3
The air scan fits in memory and keeps to the clock; the rain analyses…
claude Oct 18, 2026
8bfe86c
Tracebacks are logged whole; LOG_WIDTH cuts only the message
claude Oct 18, 2026
382a891
Turning off every alert type one by one is unsubscribing from all
claude Oct 18, 2026
4085db0
A chat that blocks or removes the bot is marked gone and keeps its se…
claude Oct 18, 2026
a5d1e55
Drop the weather alerts' conversions of state saved by earlier versions
claude Oct 18, 2026
45c3db4
Bot tests are given a real AlertState, as the running bot is
claude Oct 18, 2026
0a24b1b
The Bot always has its state: the None guards go
claude Oct 18, 2026
712dc2d
Each setting is declared once, on its Config field; LOG_LEVEL joins them
claude Oct 18, 2026
c0cd545
Readings and air metrics are declared once, in series
claude Oct 18, 2026
f20f7bf
Days and times of day are written one way; a chart's caption takes it…
claude Oct 18, 2026
9bd9d0a
Ecowitt's resolutions are one table
claude Oct 18, 2026
668fe9a
The Ecowitt fetcher splits its own requests; one read tells what the …
claude Oct 18, 2026
b66c6c2
Ecowitt's day fields, link drivers and live groups from series
claude Oct 18, 2026
041b616
One JSON form for tool results; readings() walks the data with series_in
claude Oct 18, 2026
666fce5
Link charts shade a reading as plot_chart does
claude Oct 18, 2026
c521b41
HistoryQuery: chart_field read once; refine targets, by-day breakdown…
claude Oct 18, 2026
69b1a1f
The four data sources share a Source base
claude Oct 18, 2026
e168227
AirGradient's day bookkeeping: one unstored-days helper, counted fetc…
claude Oct 18, 2026
d20b6b8
Sources by name, not four booleans
claude Oct 18, 2026
c6345d2
Tool calls made in one place; a question's Turn built in one place
claude Oct 18, 2026
90f1ab2
Telegram entities in one module
claude Oct 18, 2026
a98f83d
Bot._respond in three parts: the question, its answer, sending it
claude Oct 18, 2026
f6d9ba9
Background loops: one refresh at a time, tracebacks for bugs, the arc…
claude Oct 18, 2026
e4bf554
Alert repetition: chart drawn only for a chat that gets it, to_chat f…
claude Oct 18, 2026
abed1e1
Alert types declared once; the irrigation controller's delays and pau…
claude Oct 18, 2026
09849af
ecobot.main made safe: whatever started is closed, however the bot stops
claude Oct 18, 2026
57a1b53
Charts drawn without pyplot
claude Oct 18, 2026
0f348df
charts.py: the panel kinds drawn apart, one pill and one legend, name…
claude Oct 18, 2026
920a0e7
intent.py: Reading is Intent; repeated tests and parsing written once…
claude Oct 18, 2026
a63e91f
report.py: rain and wind written once, the report's sections as a tab…
claude Oct 18, 2026
e47a6bf
compose.py: the date parameters written once; a chart's air panels fr…
claude Oct 18, 2026
0394877
daytable.py: a days question checked and filled in once for both tool…
claude Oct 18, 2026
fb63edf
lines.py: a reading is a Sample with named fields; unbanded() returns…
claude Oct 18, 2026
9294137
admins.py: the groups' admin lists asked for together, and one lookup…
claude Oct 18, 2026
833439a
timeutil.py is time again: chart bucketing moves to lines.py, the tok…
claude Oct 18, 2026
11b68f3
llm.py: one tool_choice for the model's first step, run()'s options b…
claude Oct 18, 2026
970bd51
prompt.py: the "not available" list built once; the alerts it describ…
claude Oct 18, 2026
e1cb880
periods.py: the periods come from their names; one test for a chart t…
claude Oct 18, 2026
b540375
snapshots.py: each source opens its own connection to the file; the s…
claude Oct 18, 2026
a670194
bot.py's _short is logs.fit on one line
claude Oct 18, 2026
2c64533
Dead code (1 of 3): parameters nothing passes, and defaults no caller…
claude Oct 18, 2026
ed156ac
Dead code (2 of 3): methods and fields that only the tests used
claude Oct 18, 2026
c573a28
Dead code (3 of 3): code that can never run, and a default that stood…
claude Oct 18, 2026
096a46b
Toggle buttons are blue (Telegram's primary style) while on, not green
claude Oct 19, 2026
c5bb51d
Irrigation menus: an open menu shows only its own button as a heading…
claude Oct 19, 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
12 changes: 7 additions & 5 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 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`).
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 blue 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, blue 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, and turning off every type one by one is the same as `/alerts off` (a type added later stays off too). A chat that blocks or removes the bot gets no more alerts but keeps its settings, which apply again when it next writes (or the bot is added back). `/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 All @@ -29,25 +29,27 @@ The state and caches go in `/data` (its `DATA_DIR`), here a named volume. A host
|---|---|
| `TELEGRAM_BOT_TOKEN`, `XAI_API_KEY` | required |
| `XAI_MODEL` (`grok-4.3`), `XAI_BASE_URL` (`https://api.x.ai/v1`) | model |
| `TZ` | e.g. `Australia/Melbourne` |
| `TZ` | only without an Ecowitt station: the station names its own time zone, which is used (a `TZ` that disagrees is logged). Without either, the system's zone (what `/etc/localtime` links to); e.g. `Australia/Melbourne` |
| `ECOWITT_API_KEY`, `ECOWITT_APP_KEY` | enables Ecowitt |
| `AIRGRADIENT_API_TOKEN`, `AIRGRADIENT_LOCATION_ID` | enables AirGradient |
| `DATA_DIR` | optional: the folder that holds the bot's state (`bot_state.json`) and its SQLite caches (`ecowitt_cache.sqlite`, `airgradient_cache.sqlite`, `conditions_cache.sqlite`), kept apart from the code so a container can mount it (e.g. `/data`). A relative path is also accepted and is taken from the working directory; the folder is created if missing. Unset, a systemd service with `StateDirectory=` uses that folder (`STATE_DIRECTORY`, e.g. `/var/lib/ecobot`), else the app's own folder. Matplotlib's font cache goes in a `matplotlib` sub-folder (unless `MPLCONFIGDIR` is set). Default: the app's own folder, where the files have always been |
| `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. 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; the others are East Gippsland, Mallee, North Central, North East, Northern Country, South West, West and South Gippsland and Wimmera, in any case; one the site doesn't have is warned about at start) |
| `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 |
| `ADMIN_CHAT_IDS` | optional, with `ADMIN_ONLY`: more ids allowed besides those admins (added to them, not instead of them), separated by commas or spaces. A user id (the same number as their private chat) lets someone in before the bot is in any group; a group id (negative) adds that group's admins and lets its anonymous admins (those who post "as the group") in |
| `ALERT_COOLDOWN_MINUTES` | optional: the rain and temperature-crossing alerts: how long it must stay dry before "the rain has stopped" is sent, and the least time between alerts of the same kind (default 30, from 5 to 150). A change that comes inside the cooldown is held and announced when it ends, if the state still differs from the last one alerted; if it is back where it was, nothing is sent |
| `RAIN_QUIET_HOURS` | optional: local hours with no rain alerts (started, stopped), as `0-6` (the default; `22-6` wraps midnight; `off` for none). Rain that fell in them is summed up in one message once they end |
| `CHART_ALL_FEELS_LIKE=on` | optional: "weather all week" (every reading, a panel each, sent with just its title; UV is left out as it has the same shape as solar) also draws the feels-like panel; off by default, as it nearly repeats temperature. Naming either ("plot feels like", "plot solar and uv") always works |
| `TUYA_DEVICE_ID`, `TUYA_CLIENT_ID`, `TUYA_CLIENT_SECRET` | enables the irrigation controller (a Tuya smart irrigation timer, through the Tuya cloud API): an Irrigation button in private chats (it replaces Wind) with its status and buttons to water for 5 to 30 minutes or switch the valve off, pause for 24 to 72 hours (the Pause button becomes Unpause while paused), refresh, and an Alerts menu to turn the chat's irrigation battery and irrigation pause alerts on or off (or type irrigation, water, tap or sprinkler); a daily check at 18:00 that delays it 24 hours when more than 1 mm fell in the last 24 hours or Open-Meteo gives today or tomorrow a 50% or better chance of 1 mm (needs Ecowitt and `FORECAST=on` for each half); an "irrigation pause" alert when it does, and an "irrigation battery" alert below 5%. `TUYA_BASE_URL` (default `https://openapi.tuyaeu.com`, the EU data centre) and `IRRIGATION_CHECK_HOUR` (default `18`) are optional |
| `TUYA_DEVICE_ID`, `TUYA_CLIENT_ID`, `TUYA_CLIENT_SECRET` | enables the irrigation controller (a Tuya smart irrigation timer, through the Tuya cloud API): an Irrigation button in private chats (it replaces Wind) with its status and buttons to water for 5 to 30 minutes or switch the valve off, pause for 24 to 72 hours (the Pause button becomes Unpause while paused), refresh, and an Alerts menu to turn the chat's irrigation battery and irrigation pause alerts on or off (or type irrigation, water, tap or sprinkler); a daily check at 18:00 that delays it 24 hours when more than 1 mm fell in the last 24 hours or Open-Meteo gives today or tomorrow a 50% or better chance of 1 mm (needs Ecowitt and `FORECAST=on` for each half; a forecast more than a day old doesn't count); on a second rainy day the bot's own delay is started again, and a delay you set is left alone; an "irrigation pause" alert when it does, and an "irrigation battery" alert below 5%. `TUYA_BASE_URL` (default `https://openapi.tuyaeu.com`, the EU data centre) and `IRRIGATION_CHECK_HOUR` (default `18`) are optional |
| `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 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. |
| `LOG_LEVEL` | optional: `DEBUG`, `INFO` (default), `WARNING` or `ERROR` (any other value keeps `INFO`, with a warning). `DEBUG` adds the bot's own debug lines (the chart timings, charts it couldn't draw ahead); the libraries stay at their usual level. |

An on/off setting takes `on`, `yes`, `true` or `1`, and `off`, `no`, `false` or `0`; any other value keeps its default (and the log says so), so a typo can't switch `ADMIN_ONLY` off. A setting left blank is its default.

## Layout

Expand Down
Loading
Loading