The channel sends through the Bot API with httpx only: HTML with escaped text,
cut to fit a message, silent for a warning, a short 429 waited out once, and
errors that say what to do about a blocked bot, a lost chat or a bad token and
never quote the request URL. The bot token is a stored secret: saving a
channel refuses a literal one, the card's "Store token" writes the secret and
a reference, and no endpoint returns it.
A chat id is never typed. Connect starts a ten minute code, and the chat that
sends `/start <code>` back through the bot is the one connected, so it is a
chat somebody opened a link for. The engine fetches updates only while a code
is waiting (no standing poll, no webhook, no inbound address) and ignores
everything else without a reply. Connect needs a saved form, and the form
takes the connected chat in without touching what was typed since.
The token is part of every Bot API URL, so the httpx logger gets the same
filter the request lines have, which now also masks an httpx.URL argument.
The alert manager kept its cooldowns and its ceiling per hour in memory, so a
node that stayed down was announced again by every restart. Each delivered
alert is now a row in the new alert_sent table (additive migration), loaded
for the last seven days when the manager starts and pruned as new ones come
in. A ledger that fails never stops an alert. The manager's clock is wall
time for the same reason.
A node_health "down" alert also waits 60 s (HEALTH_GRACE_S) in a pending task
per node; a later event that is not "down" cancels it, as does stopping the
manager, so a broker restarting with the stack alerts nobody.
The cooldown used to be stamped before the ceiling per hour was checked, so
an alert the ceiling held back still spent its cooldown. It is stamped only
once the alert is sent now.
Follows the portal: the noun is "instance" everywhere the app says it —
UI strings, CLI output, error details, docs and comments. The wire keys
(`instance_id`, `instance_token`) and the hub route this calls move with it.
An existing cloud.json is adopted rather than refused: without the key
alias the dataclass fails to parse, which the caller swallows and reads as
"never enrolled" instead of "reconnect".
`instance_key` on a node type becomes `target_key`. It means the outside
thing a node points at, which is a different sense of the word, and keeping
both would put two meanings of "instance" in one codebase.
A `webpush` alert channel, and the PWA it needs to arrive. The payload is
encrypted to the subscription (RFC 8291) and the request signed with this
installation's own keypair (RFC 8292), both over `http-ece` — `pywebpush`
does the same in one call but brings `requests` and `aiohttp` with it, two
HTTP stacks beside httpx on a machine that may be a Raspberry Pi.
The manifest and the worker are hand-written rather than `vite-plugin-pwa`:
there is nothing worth precaching when the page carrying the credential is
`no-store`, so the worker handles `push` and `notificationclick` and nothing
else. `registration.scope` is the app's root in both places it runs, which is
why the payload carries no URL.
A run finishing in error is the first event worth waking someone for; `ok`
and `cancelled` describe to nothing, so a nightly batch that works stays
quiet. The events were already on the bus — only the filter changed.
`WEBPUSH_FILE` is a derived path, so the keypair lands on the data volume
with the alerts beside it. Off it, a rebuild would silently stop every phone
being notified: the key they subscribed against would be gone.
A house's inverter broker dropped at 04:27 and the power flow was quarantined
20 seconds later. Quarantine was terminal — the supervised task returned and
only a publish or an engine restart could bring it back — so five hours of
power and battery readings are missing, and what ended it was an unrelated
`git pull` restarting uvicorn.
Two changes, both in that path:
- the failure budget is per task, not per flow. `power` runs an MQTT subscriber
and a Victron keepalive publisher against the same broker; they died together
and spent one shared budget in 41s, giving up before the 60s backoff step was
ever reached.
- quarantine is now a rest. The task sits out 5min, then 15, then an hour, and
each time gets its budget back and tries again, so a broker that comes back
is picked up without anyone watching. `quarantined` reads from whichever
tasks are currently resting.
The alert for it says when it will try again.
A wheel whose top-level module is `app` collides with anything else in a
user's venv, so the package that is about to be published takes the name
it is published under. Only the Python package moves; the repo, the
Docker WORKDIR and the compose project keep theirs.