Notifications¶
Eneru sends notifications through Apprise, so one config format covers Discord, Slack, Telegram, ntfy, Pushover, email, Matrix, Gotify, Home Assistant, and many more services.
Notifications are not on the shutdown critical path. Eneru queues the message and continues shutting things down.
Basic config¶
notifications:
title: "Homelab UPS"
avatar_url: "https://raw.githubusercontent.com/m4r1k/Eneru/main/docs/images/eneru-avatar.png"
timeout: 10
urls:
- "discord://webhook_id/webhook_token"
- "ntfy://ntfy.sh/my-ups-topic"
If urls is empty, notifications are disabled.
Test delivery¶
Package install:
PyPI install:
Also run validation. It catches missing Apprise support and malformed config values:
Common service URLs¶
Discord¶
Create a webhook in Discord under Server Settings, Integrations, Webhooks. Convert the webhook URL:
Slack¶
Create an incoming webhook in a Slack app and use the token components from the webhook URL.
Telegram¶
Create a bot with BotFather, message it once, then get your chat ID with getUpdates.
ntfy¶
Pushover¶
Email¶
Apprise documents the full URL catalog in its service wiki.
Persistent retry¶
Eneru stores pending notifications in the per-UPS SQLite database. If the network is down, the worker retries with exponential backoff. If Eneru restarts before delivery, the next process resumes the queue.
ENERU NOTIFICATION PATH
+----------------------+ insert pending +----------------------+
| Main monitor thread +-------------------------->| Per-UPS SQLite DB |
| power/lifecycle | | notifications table |
| event occurs | | status = pending |
+----------+-----------+ +----------+-----------+
| ^
| continue safety work |
v | update row
+----------+-----------+ |
| Shutdown sequence | +----------+-----------+
| VMs, containers, SSH | | Worker thread |
| filesystems, local | | oldest due row |
| poweroff | | Apprise delivery |
+----------------------+ +----------+-----------+
|
+--------------+--------------+
| |
v v
+---------+----------+ +----------+---------+
| success | | failure |
| mark sent | | attempts += 1 |
+--------------------+ | schedule backoff |
+--------------------+
notifications:
retry_interval: 5
retry_backoff_max: 300
max_attempts: 0
max_age_days: 30
max_pending: 10000
retention_days: 7
| Key | Default | Meaning |
|---|---|---|
retry_interval |
5 |
First retry delay in seconds |
retry_backoff_max |
300 |
Maximum delay after exponential backoff |
max_attempts |
0 |
Attempt cap. 0 means unlimited until age/backlog policy cancels the row |
max_age_days |
30 |
Pending rows older than this are cancelled as too_old |
max_pending |
10000 |
Pending backlog cap. Oldest rows are cancelled as backlog_overflow |
retention_days |
7 |
Sent and cancelled row retention |
max_attempts defaults to unlimited because Apprise returns one boolean for success or failure. Eneru cannot reliably tell a bad URL from a temporary internet outage.
Notification architecture history¶
| Scenario | Fire-and-forget (v4.6) | Memory retry (v4.7+) | SQLite queue (v5.2+) |
|---|---|---|---|
| Short network blip | Message could be lost | Retried while process stayed alive | Retried until delivered |
| Router reboot during outage | Message could be lost | Retried while process stayed alive | Retried after network returns |
| Daemon restart mid-outage | In-memory message lost | In-memory message lost | Pending row survives restart |
| Host powers off before delivery | Message lost | Message lost | Row remains for next boot if disk survives |
| Multi-day endpoint outage | No queue | Repeated retries in memory | Per-message backoff capped by retry_backoff_max |
| Brief power outage with delayed internet | Separate messages or losses | Separate messages if delivered | Pending ON_BATTERY + POWER_RESTORED can coalesce |
systemctl restart eneru |
Stop/start noise | Stop/start noise | Single restart notification when classified |
| Package upgrade | Stop/start noise | Stop/start noise | Single upgraded notification when classified |
| Backlog growth | Not applicable | Process memory growth | Bounded by max_pending |
| Stale pending messages | Not applicable | Lost on process exit | Aged out by max_age_days |
The v5.2 queue is intentionally boring: SQLite row first, network later. Shutdown code should not block on Discord, email, DNS, or a phone push service during a power event.
Retry timeline¶
The retry timer is per message. A failed send does not block newer shutdown work, and it does not spin in a tight loop while the network is down. The first attempt is due immediately because a newly inserted row has no backoff entry. After a failure, retry_interval * 2^(attempts-1) is used, capped by retry_backoff_max.
| Time | Worker action | Row state |
|---|---|---|
| 0s | Event inserts notification | pending, attempts=0; worker is woken |
| Immediately or next 1s worker tick | First delivery attempt fails | attempts=1, next attempt in 5s by default |
| About 5s later | Second attempt fails | attempts=2, next attempt in 10s |
| About 10s later | Third attempt fails | attempts=3, next attempt in 20s |
| About 20s later | Fourth attempt fails | Backoff continues, capped by retry_backoff_max |
| Network returns | Next due attempt succeeds | Row becomes sent |
max_age_days reached and prune runs |
Message is too old | Row becomes cancelled, reason too_old |
Coalescing¶
Eneru folds related pending events before delivery when doing so gives the operator a clearer message.
| Pattern | Result |
|---|---|
| Power lost and restored while both messages are still pending | One brief-outage summary |
| Shutdown summary pending when the daemon starts after power recovery | One recovery notification with downtime and reason |
| Stop message pending and a new daemon starts quickly | One restarted, upgraded, or recovered notification |
Already-delivered messages are not rewritten.
Brief outage coalescing timeline¶
This is the common "power came back fast, internet came back slowly" case. Coalescing only happens if both rows are still pending when the worker sees them; already-sent rows are left alone.
| Time | Event | Notification result |
|---|---|---|
| 0s | UPS goes on battery | ON_BATTERY row is inserted as pending |
| 5s | Worker tries delivery | Network is still down, so the row remains pending |
| 28s | Power returns | POWER_RESTORED row is inserted as pending |
| Later worker pass | Network or endpoint is usable again | Worker sees both rows still pending |
| Same pass | Coalescer runs before delivery | Original rows are cancelled as coalesced and a summary row is inserted |
| Same or next pass | Summary row is due | One brief-outage notification is delivered |
Lifecycle notifications¶
Eneru classifies daemon starts instead of blindly sending "stopped" and "started" every time. The classifier picks one of eight messages based on the on-disk shutdown marker and the previous-version metadata in the stats DB. Logic lives in src/eneru/lifecycle.py.
| Notification | When it fires | How Eneru recognizes it |
|---|---|---|
📦 Eneru Upgraded vX → vY |
deb/rpm package upgrade | .upgrade_marker.json written by packaging/scripts/postinstall.sh; old version captured by preinstall.sh |
📦 Eneru Upgraded vX → vY (pip path) |
pip install --upgrade eneru followed by restart |
meta.last_seen_version in the stats DB differs from the current __version__ |
📊 Eneru Recovered |
Daemon back after a power-loss-triggered shutdown | Shutdown marker with reason: sequence_complete. Folds the prior shutdown headline and summary into one richer message |
🔄 Eneru Restarted (downtime: Ns) |
systemctl restart eneru within 30 seconds |
Shutdown marker with reason: signal and downtime under 30 s |
🚀 Eneru Restarted (after fatal exit) |
Daemon back after a crash that wrote a fatal marker |
Shutdown marker with reason: fatal |
🚀 Eneru Started (last seen Nh ago) |
Daemon started after a long graceful gap | Shutdown marker present with downtime ≥ 30 s |
🚀 Eneru vX Started (after crash) |
Daemon back without ever writing a marker | No shutdown marker but meta.last_seen_version is set |
🚀 Eneru vX Started |
First-ever start | No markers and no last_seen_version |
The goal is one useful lifecycle message per transition. The 📊 Recovered path also absorbs the prior instance's pending 🚨 EMERGENCY SHUTDOWN INITIATED! and ✅ Shutdown Sequence Complete rows so an outage produces a single combined message rather than three.
Restart classification timeline¶
This is the systemd restart path. The deferred timer delay is DEFAULT_DEFER_SECS = 15 in src/eneru/deferred_delivery.py; plain systemctl stop and non-systemd foreground runs send the stop notification eagerly instead.
| Time | Event | Notification behavior |
|---|---|---|
| 0s | Old daemon exits under systemd restart or unknown intent | Stop row is queued and a 15s systemd-run delivery timer is scheduled |
| Before 15s | Replacement daemon starts | Startup classifier reads the previous shutdown marker |
| Before 15s | Classifier recognizes restart, upgrade, or recovery | Pending stop row is cancelled as superseded |
| Before 15s | Replacement notification is queued | A single restarted, upgraded, or recovered message is sent |
| 15s | No replacement cancelled the original row | Deferred delivery timer fires and the stopped message is sent |
Alert noise tuning¶
Voltage hysteresis¶
Voltage events are logged immediately. The notification waits until the condition has persisted for the configured number of seconds. If voltage returns to normal before the dwell expires, Eneru records VOLTAGE_FLAP_SUPPRESSED and sends no alert.
Severe deviations beyond +/- 15% of nominal bypass the dwell and notify immediately.
Voltage hysteresis timeline¶
The default dwell is 30 seconds. The log and events table are immediate; only notification delivery waits.
| Time | Voltage state | Notification behavior |
|---|---|---|
| 0s | Input crosses warning threshold | Log and SQLite event are written immediately |
| 10s | Voltage returns to normal | No notification is sent; VOLTAGE_FLAP_SUPPRESSED is recorded |
| 0s | Input crosses warning threshold again | New dwell window starts |
| 30s | Still outside threshold | Notification is sent with a persisted-duration note |
| Any time | Severe deviation exceeds +/- 15% nominal | Notification bypasses dwell and sends immediately |
Suppression list¶
Suppression mutes notifications only. The log and SQLite events table still record the event with notification_sent=0.
Allowed event names:
| Event |
|---|
POWER_RESTORED |
VOLTAGE_NORMALIZED |
AVR_BOOST_ACTIVE |
AVR_TRIM_ACTIVE |
AVR_INACTIVE |
BYPASS_MODE_INACTIVE |
OVERLOAD_RESOLVED |
CONNECTION_RESTORED |
VOLTAGE_AUTODETECT_MISMATCH |
VOLTAGE_FLAP_SUPPRESSED |
Safety-critical events cannot be suppressed. Validation rejects them:
| Blocked event |
|---|
OVER_VOLTAGE_DETECTED |
BROWNOUT_DETECTED |
OVERLOAD_ACTIVE |
BYPASS_MODE_ACTIVE |
ON_BATTERY |
CONNECTION_LOST |
Any event starting with SHUTDOWN_ |
Inspect notification history¶
Each UPS database contains notification rows. The exact file name is the sanitized UPS label.
sqlite3 /var/lib/eneru/UPS-192-168-1-100.db \
"SELECT ts, status, attempts, cancel_reason, title FROM notifications ORDER BY ts DESC LIMIT 20;"
Muted events are in the events table:
sqlite3 /var/lib/eneru/UPS-192-168-1-100.db \
"SELECT event_type, COUNT(*) FROM events WHERE notification_sent = 0 GROUP BY event_type;"
Troubleshooting¶
| Symptom | Check |
|---|---|
| Test command says Apprise is missing | Install the native package with notification dependencies, or use eneru[notifications] for PyPI |
| URL rejected | Compare the URL against the Apprise wiki for that service |
| Test sends but production events do not arrive | Check notifications.suppress, service rate limits, and outbound HTTPS/DNS |
| Messages arrive late | Check network recovery during outages and pending rows in SQLite |
| Duplicate lifecycle messages | Check whether multiple Eneru instances are running against the same config |