Changelog¶
All notable changes to Eneru are documented here.
The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.
[Unreleased]¶
[6.1.9] - 2026-07-13¶
Fixed¶
- Fleet view no longer wears one UPS's name tag. "Fleet overview" now has a
fleet-level health summary and one compact comparison row per UPS instead of
presenting the first/worst UPS as if it represented the whole installation.
The always-visible health rail collapses a healthy fleet to one pill and names
only UPSes needing attention. Historical charts remain deliberately
single-series, but now expose and retain an explicit Chart UPS selector in
fleet mode rather than silently plotting the first UPS. The authenticated
Control tab now follows the same Fleet/Lab/APC view instead of always showing
every UPS. Line-quality states such as
BOOST,TRIM, andBYPASSremain usable for redundancy quorum but appear as warnings in the fleet summary. Changing View removes the previous UPS controls while the new controls load, and late responses cannot restore buttons for the wrong UPS. This addresses #101 without restoring the retired duplicate per-UPS card grid. - ARM64 missing from the official OCI artifact. Release tags now publish
one multi-platform manifest for
linux/amd64andlinux/arm64. Pulling the same tag selects the correct image automatically. PR CI builds AMD64 and ARM64 images in parallel on native GitHub-hosted runners, validates the bundled configuration, and imports the architecture-sensitivebcryptdependency.
Changed¶
- Readable GitHub Actions release refs. Workflows now use upstream version tags instead of opaque commit SHAs. Dependabot continues to propose weekly updates, with release-note review and the full CI matrix as the maintenance gate.
- Repeatable dashboard deployment audit. A camera pass for the dashboard is
now kept in the repository instead of improvised during each release.
tools/dashboard-audit.pycaptures every visible tab in both themes, safe drill-downs, per-UPS views, and a mobile viewport from the exact deployed assets. Its JSON report also records browser, request, and basic accessible- name findings. Authenticated runs prompt for the password, and the tool never invokes hardware-changing or destructive controls.
[6.1.8] - 2026-07-12¶
Fixed¶
- Remote shutdown always receives the expanded system
PATH. Eneru 6.1.7 added the expanded path after a Synology NAS failed to shut down during a real outage, but the final release made it opt-in. Existing configurations therefore kept sending bare commands through SSH without/usr/syno/sbin, andsynoshutdowncould still fail withcommand not found. Eneru now adds the standard system directories and Synology directories to every remote command. The short-livedaugment_remote_pathoption has been removed; 6.1.7 configurations that contain it still load, but its value is ignored.
[6.1.7] - 2026-07-10¶
A stabilizing bug-fix release built on a full-repository pre-release code review — the latest in the series of in-depth reviews that shaped the 6.1.x line, performed with Anthropic Claude Fable 5. As with 6.1.6 the whole remediation lands in one advertised bug-fix rather than being spread across several minors, so Eneru runs at the best possible level. No new features and no breaking config changes; behavioral changes are called out below. See Testing → Pre-release code review (v6.1.7).
Three config-loader Criticals headline the release: a scalar
filesystems.unmount.mounts was iterated character-by-character into per-letter
mount paths, safety-critical booleans accepted any type (a templated
local_shutdown.enabled: "false" stayed truthy-armed), and an explicit but
missing --config path started the daemon on all-default, shutdown-armed
config. They share one root cause and are fixed as a unit.
Fixed¶
- The config loader validates types and shapes before use (Fix Group A). A
declarative schema gate runs before dataclass construction and inside
validate_config: lists reject scalars (no more char-splitmounts/urls/compose_files), every safety-critical boolean rejects non-bool values, numeric fields (notifications.timeout/retry_interval, per-compose-filestop_timeout) are type-checked, scalar-where-mapping sections yield a pointed error instead of a raw traceback, and unknown keys are swept at every level — including the top level, so a misspelledlocal_shutdwn:is caught instead of silently ignored while poweroff stays armed. Upgrade note: configs that 6.1.6 accepted can now refuse to start — an unknown top-level key (previously ignored) is a fatal error, so runeneru validateagainst your config before restarting the daemon after the upgrade. YAML anchor/extension blocks are supported via the docker-compose convention: prefix them withx-(e.g.x-defaults:), which the sweep exempts at the top level. The notification timing knobs (notifications.timeout/retry_interval) accept both integers and floats, so a workingretry_interval: 2.5keeps working. - A missing explicit
--configpath is now fatal.run -c missing.yamlexits non-zero andvalidate -c missing.yamlfails, instead of quietly falling back to an all-default, shutdown-armed config. Default-path fallback is unchanged. - Notification store failures no longer drop messages silently. When the
stats store isn't open (e.g.
/var/lib/eneruunwritable at startup),send()buffers to the in-memory queue and replays once a store opens, preserving the lossless guarantee instead of discarding every notification. - A failed host poweroff is now observable. Both the single-UPS and coordinator paths check the poweroff command's exit code; on failure they log an error, send a failure notification, and remove the completion marker so the next start no longer reports a false "Recovered".
- Graceful VM shutdown waits work on dash/BusyBox remotes. The libvirt /
Proxmox / XCP-ng stop templates used bash-only
$SECONDS, which is empty on POSIX-sh remotes, so the grace loop was skipped andvirsh destroyfired immediately; the portable wait now runs inside an elapsed-time timeout that also bounds slow status commands. Localdocker/podman compose downnow passes-t <stop_timeout>instead of SIGKILLing after compose's own 10s default. - Coordinator loopback delegation no longer double-issues poweroff. A
containerized multi-UPS deployment with an
is_host_loopbackdelegate would run the poweroff binary inside the container after the host was already told to power off over SSH; the coordinator now skips the in-container poweroff on the delegated path, mirroring the single-UPS path. - Remote shutdown can resolve bare command names over SSH again. After a real
power outage, shutting down a Synology NAS failed with
sudo: synoshutdown: command not found, because the daemon's non-interactive SSH session has a minimalPATHthat lacks/usr/syno/sbin(Synology adds it via login-profile scripts anssh host "cmd"never runs). Eneru can now optionally augmentPATH— standard sbin dirs plus/usr/syno/sbin— so bare binaries resolve as they do in an interactive login. The option defaults off because POSIX syntax would break csh/Windows remotes; absolute paths remain the deterministic fallback. - Dashboard outage bands clear after a power-loss recovery. When an outage
triggered a shutdown, the host powered off and the restarted daemon booted
straight into on-line without ever emitting
POWER_RESTORED, so the Power/Battery/Energy charts shaded red to "now" forever. The daemon now emitsPOWER_RESTOREDon a power-loss recovery start, and the dashboard closes an open outage band at the shutdown/restart boundary (so historical outages clear too). Follow-up (F-094): the chart event feed now keeps those boundary rows — its tier-1 filter used to stripDAEMON_*events before the band math ever saw them, so an outage whose only closing event was a daemon restart still shaded to "now"; markers stay tier-1-only at draw time. - Assorted correctness fixes: quoted poweroff commands parse with
shlex; corrupt shutdown/upgrade markers that parse to non-dicts are treated as absent instead of crashing startup; VM force-destroy re-polls so a VM that stopped at the deadline isn't force-killed; battery depletion rate keys on a monotonic clock so an NTP step mid-outage can't skew it; the stats schema version is stamped in the same transaction as the DDL so a crash can't leave a versionless DB; a duplicate report digest can't re-send when the dedup-key write fails; the API 500 handler logs the traceback;command_existsusesshutil.which. - Notification worker keeps the structured logger across a config reload.
The v6.1.6 change routed worker warnings through the daemon's logger
(ISS-060) on startup, but the reload/rebuild paths in both the single-UPS
monitor and the multi-UPS coordinator still constructed the worker without
it, so after a
systemctl reload/ API reload its warnings fell back toprint. Both reload paths now pass the shared logger. Queue rows are also claimed before delivery, so a slow old worker and its reload replacement cannot both send the same message. - Periodic reports retry failed builds and cover the full previous day. Gathering or rendering errors no longer consume an entire daily, weekly, or monthly cadence. Daily power-event and uptime sections now summarize the previous local calendar day, so events after the morning send time are not omitted forever; current-day energy accounting is unchanged.
- Failed logins audit as
LOGIN_FAILUREwith intact IPv6 addresses. Audit rows for failed logins and the transition into throttling previously fell back to the genericCONTROLevent type, and IPv6 client addresses were truncated at the last colon (ISS-032 follow-up). Already-blocked retries no longer write one SQLite event per attacker request. - Container/VM shutdown summaries no longer claim ✅ after failures. The rootless-Podman and libvirt phase summaries now report a ⚠️ with the failure count when any stop/destroy command failed (ISS-040 follow-up).
- Neutral-status log throttle holds under alternating statuses. A UPS
flipping between two neutral statuses (e.g.
OFF↔BYPASS) every poll no longer re-logs the "neither on-line nor on-battery" info line each time; the transition itself is still logged (ISS-019 follow-up). - Docs/templates referenced a nonexistent
--validate-configflag. Remaining guidance inAGENTS.md, the PR template, and the E2E README now shows the realvalidatesubcommand (ISS-046 follow-up). - E2E notification check no longer hard-fails Dependabot PRs. Dependabot
runs receive Dependabot secrets, not Actions secrets, so
E2E_NOTIFICATION_URLis legitimately empty there; those runs now SKIP Test 7 like fork PRs instead of failing (ISS-051 follow-up).
Security¶
- Host-header validation closes a DNS-rebinding read of the API. A hostile
web page could rebind DNS and read the anonymous API (topology, SSH
usernames, events) from an operator's browser. Requests must now carry an
IP-literal Host,
localhost, or a name in the newapi.allowed_hostslist; others get 421. IP-literal and localhost Hosts are always accepted, so LAN browsing is unaffected. Eneru remains trusted-LAN-only by design — there is no TLS; front it with a reverse proxy to expose it off-host. Upgrade note: hostname, mDNS, and reverse-proxy access needs anapi.allowed_hostsentry; the first rejected Host is logged with that hint. - MQTT credentials no longer leak. The scheme-less-broker warning redacts
user:pass@userinfo before logging, and a username on a non-TLS broker triggers a one-time cleartext-credentials warning. - NUT credentials hardened.
NutControlConfigusername/password are kept out ofrepr(), and PTY echo is disabled before the NUT password is written so it can't be echoed into captured output. - Login throttle gains a global ceiling alongside the per-IP limit, closing
a source-IP-rotation bypass; the dashboard clears the login password field
after submit; a CI guard forbids
innerHTML/insertAdjacentHTML/evalunder the dashboard tree. - Release workflow passes secrets via
env:rather than interpolating them into shell text (template-injection pattern); the systemd unit addsProtectKernelLogs,ProtectControlGroups,ProtectClock,LockPersonality, andProtectHostname; and the remote Kubernetes manifests setautomountServiceAccountToken: false. - CI: don't persist the GitHub token on the release workflow's gh-pages
checkout. The deploy step pushes with an explicit
x-access-tokenURL, so the persisted credential was unused;persist-credentials: falsenarrows token exposure across the package-repository build steps (zizmorartipacked). - CI: don't persist the GitHub token on the release workflow's main checkout
either (ISS-010, minimal form). The build job installs the full dependency
tree and runs the test suite before publishing; every credentialed publish
step already passes the token explicitly, so the persisted copy in
.git/configwas pure ambient exposure. eneru validateredacts notification URLs with the shared helper. The config summary previously fell back to printing the first 20 characters of a scheme-less Apprise URL, which could leak webhook path fragments; it now always printsscheme://***.
Performance¶
- The daemon stays responsive under dashboard load. The API gained HTTP/1.1
keep-alive with a bounded request-processing pool (idle connections reaped by
a short timeout, so keep-alive can't starve it); the per-UPS energy block is
cached for ~10s instead of re-scanning today+month+year on every poll; the
dashboard now refreshes cached chart event markers only when an exact NUT
power-state transition occurs, so live outage bands appear without restoring
the old every-poll event scan, and guards against overlapping refreshes;
aggregate()releases its DB lock between tiers; the TUI reads recent events with aLIMIT; and runtime-context detection is memoized.
Packaging & CI¶
- Drain-path behavior changes now have real-service E2E coverage. CI sends a
custom SSH command with remote PATH augmentation disabled, removes a live
Compose stack through
down -t, and verifies that a coordinator loopback poweroff both reaches the target and writes the completion marker. -
Older standalone
podman-composemay rejectdown -t. Docker Compose v2 and current Podman providers support the configured timeout form; operators on older standalone releases should upgrade or keep the remaining-container fallback enabled. -
RHEL 8 gets a dedicated RPM that requires
python39/python39-pyyaml, so it installs Python 3.9 automatically instead of crash-looping on 3.6; RHEL 9/10 keep the default RPM. The:latestcontainer tag is now promoted only after the pushed image is verified, and a smoke job installs Eneru from the freshly-published apt/dnf repositories.
Changed¶
- CI hygiene (ISS-042/022 follow-ups). All
integration.ymljobs now carrytimeout-minutes; theupsc -ldiagnostic cooldown and the neutral-status log interval are named module constants inmonitor.py. - Internal: runtime-context and delegation helpers moved to a leaf
runtimemodule (removing a domain-core→CLI import inversion); deferred-delivery SQL promoted onto the stats-store API; per-grouptriggersare deep-copied to avoid shared mutable state; status-token matching and reload error-filtering unified behind single helpers. The standaloneCLAUDE.mdbridge files are now symlinks to theirAGENTS.md.
[6.1.6] - 2026-07-06¶
An unusually large "bug-fix" release: a full-repository adversarial audit (source, tests, CI, packaging, deployment manifests, config parsing) surfaced 64 findings, and rather than spread them across several minors the maintainer chose to land the entire remediation in one advertised bug-fix so Eneru runs at the best possible level. No new features and no breaking config changes; behavioral changes are called out below. See Testing → Pre-release code review (v6.1.6) for the audit methodology.
Fixed¶
- Shutdown decision path (Critical). A
SIGTERM/SIGINTarriving mid sequence no longer aborts an in-flight single-UPS poweroff (ISS-001); the multi-UPS coordinator validates the poweroff command before writing the "sequence complete" recovery marker, so an empty command can't make the next boot log a false "Recovered" (ISS-015); and a delegated loopback poweroff that was actually delivered is no longer misclassified as failed when a Phase-A drain hiccups (ISS-005). - Config validation. Scalar/null section values, non-numeric thresholds,
invalid remote/grace timeouts, and unknown keys in the legacy dict-form
ups:andnotifications:sections now produce a clean error instead of a raw traceback or a silent default (ISS-002/003/024/025/026). Config reloads are serialized so a SIGHUP and an API reload can't interleave (ISS-027), and reloaded remote-server changes propagate to redundancy executors (ISS-004). - Daemon robustness & visibility. A missing NUT dependency exits cleanly
instead of flapping (ISS-006); the API server bounds slow/stalled client
connections (ISS-007); startup connection waits are interruptible (ISS-021);
and log throttles/timers now use the monotonic clock with correct
first-fire behavior instead of wall-clock modulo or
0.0seeds that never fired on a fresh boot (ISS-016/018/019/020/022). - API/CLI hardening. Reload errors return a proper error envelope
(ISS-028); a per-IP login throttle returns 429 after repeated failures
(ISS-032); session validation uses constant-time comparison (ISS-061);
HEADis supported on/healthand/ready, and a chunked request body is rejected with 411 (ISS-061); the auth DB is created owner-only from the first byte (ISS-062); and CSV report cells are protected against spreadsheet formula injection (ISS-063). - Perf & de-duplication. Notification worker no longer re-scans the full backlog every tick and no longer leaks per-message backoff state (ISS-036); stats aggregation is incremental via a watermark instead of re-rolling every bucket each cycle, with no schema change (ISS-037); and the triplicated shutdown-path helpers were consolidated into single shared definitions (ISS-013).
- CI / tests / packaging. One dependency source of truth across CI
(ISS-043/044/048), a
pipDependabot ecosystem, systemd hardening (ISS-047), a corrected fresh-install banner command (ISS-046), the embedded Kubernetes ConfigMap is now validated in CI and its images are version-pinned (ISS-053), and several real-time thread tests were de-flaked (ISS-054).
Changed¶
notifications.enabledis now honored. A config withnotifications: {enabled: false, urls: [...]}no longer keeps notifying; the explicitenabledflag wins over the derived "has URLs" behavior (ISS-024). Configs that relied on the old ignore-the-flag behavior to keep notifications on despiteenabled: falsemust remove the flag.- UPS status dispatch is token-set based.
OFF,BYPASS, and bareDISCHRGstatuses are handled explicitly andCHRG/DISCHRGno longer substring-alias each other (ISS-019).
Notes¶
- Enforcement that was previously only documented is now real: per-file
coverage must stay ≥95% (ISS-012), the ghost
integrationpytest marker was removed (ISS-050), and a required E2E notification check fails loudly instead of silently skipping when its secret is missing on a non-fork run (ISS-051). - The base Docker image is intentionally left as a mutable tag (not
digest-pinned): for a base OS,
apt-get upgrade -yon a moving tag keeps security patches current, which beats a frozen digest that drifts stale (ISS-045).
[6.1.5] - 2026-07-05¶
Bug-fix release for APC self-test completion and the browser dashboard's power-quality display, plus clearer APC self-test configuration guidance from live hardware testing.
Fixed¶
- AVR boost/trim now count as active regulation in the Power tab. The API
reports AVR as
INACTIVE,BOOST, orTRIM, while bypass and overload use the binaryACTIVE/INACTIVEshape. The dashboard now treatsBOOSTandTRIMas active AVR states for the line-quality verdict and badge styling instead of only recognizing the literalACTIVEvalue. - API-started self-tests now finish their dashboard row. The API already
wrote a
runningrow when NUT accepted a self-test, but it did not persist the row id and result-poll timer the monitor needs to complete that row. The API and scheduler now share the same pending-test handoff, and the monitor can adopt the newest legacyrunningrow left behind by earlier 6.1.x builds.
Notes¶
- APC Back-UPS models driven by
usbhid-upscommonly exposetest.battery.start.quickandtest.battery.start.deep, not the generictest.battery.start. Setself_test.commandper UPS when enabling issued tests for those units.
[6.1.4] - 2026-07-02¶
Multi-UPS correctness and a dashboard overhaul for multi-UPS deployments, driven by adding a second, monitoring-only UPS to a running system. Shipped as a bug-fix release; no config or shutdown behavior changes.
Fixed¶
- Adding a second UPS orphaned all prior history. Going from one UPS to
multiple switched the stats store from
default.dbto per-UPS databases, so the pre-existing UPS's months of history, events, and aggregates vanished from the dashboard, API, CLI, TUI, and reports. On the first multi-UPS boot Eneru now migratesdefault.dbinto the first group's per-UPS database — idempotent and restart-safe — so history survives the transition. - Spurious 0% battery-charge spikes. A network-polled (monitoring-only) UPS
that returns a transient
0forbattery.chargeon a partial poll no longer writes a false zero while on line power (mirrors the existing input-voltage guard), so the charge graph and its AVG/MIN aggregates stay clean. - Missing power-quality telemetry rendered as bare units — "V" / "Hz" / "°C" with no number for a monitoring-only UPS; empty values now read "—".
- Battery-health factor meters rendered full-width regardless of value — the
strict CSP stripped their inline width, so the meters silently lied; the bars
now reflect the real 0-100 scores. The
data:favicon (also CSP-blocked) is now a packaged same-origin asset. - The line-quality verdict reads Unknown (not a confident green "Good") when a UPS reports too little AC telemetry to judge.
- Self-test result casing, a duplicated Config heading, and config-card spacing for long UPS names.
- Self-test "command not exposed" is now actionable. When the configured
self_test.commandisn't offered by a UPS — e.g. APC (viausbhid-ups) exposestest.battery.start.quick/.deep, not the baretest.battery.start— the API and CLI now list the battery-test commands the UPS does expose so you can set the right one (per-UPS in multi-UPS mode). - Battery age no longer looks broken. The age factor is a 0-100 sub-score where
0means "at/past expected life"; the Battery view now shows the actual age (e.g. "~10.0 yr / 5 yr") beside it, so a low score reads as a worn battery rather than "0 = new / not reported". Age is per-UPS: a UPS without its ownbattery_install_datereads "n/a" instead of borrowing another battery's date. - The empty "no redundancy groups configured" hint is gone from Overview for the majority who run independent UPSes; the section now appears only when redundancy groups actually exist.
Changed¶
- A global UPS selector in the header (default "All UPS") scopes the Power / Battery / Energy / Shutdown tabs and the per-UPS card grids; the per-tab UPS dropdowns are retired. Overview stays fleet-wide.
- Overview summarizes the whole fleet — a persistent fleet-status strip, a "monitoring only" badge on non-local UPSes, and headline KPIs that report the worst battery health, total energy, and worst self-test across all UPSes rather than a single one.
- Shutdown plans are per-UPS and discoverable — "All UPS" stacks every plan, and a monitoring-only UPS shows why it runs no local shutdown.
- Tab order moves Events and Config to the end; Events gains a Source column and is seeded from the global UPS selection.
- Status colors are calibrated: on-battery urgency (amber ring, "ON BATTERY"), confidence-tempered battery-health badges, neutral idle regulation states, and a neutral (not alarm) shutdown-trigger banner.
- Charts gain a time axis, break across data gaps instead of bridging them, and use a categorical (non-status) series palette.
- Accessibility: WCAG AA status-color contrast, a modal focus trap + Esc, visible
focus rings,
prefers-reduced-motion, ARIA on charts / modals / controls, and narrow-viewport handling. - The API listen backlog is raised so bursts of concurrent dashboard requests aren't refused.
- Documented per-UPS overrides (credentials, self-test command, battery install date) with a copy-pasteable multi-UPS example in the config reference and the nut-control / self-test guides — the capability already existed but was easy to miss.
Notes¶
- Deferred to a follow-up: hero instantaneous watts; a chart-core consolidation (small-multiples for Load & power, nice-number y-ticks, shape-encoded event markers, health-trend hover); and a couple of UI nits.
[6.1.3] - 2026-07-01¶
Two self-test bug fixes on top of 6.1.2, from live UniFi hardware. No shutdown behavior changes.
Fixed¶
- Self-test "does not expose" on UPSes that list commands without
descriptions.
upscmd -lwas only parsed in the standardname - descriptionform, so a UPS that lists bare command names (e.g. Ubiquiti/UniFi:test.battery.startwith no description) produced an empty list — a supported command looked unsupported and every Run-self-test failed. The parser now accepts both the described and description-less forms while still rejecting the header and prose lines. (The 6.1.2 credentialed-upscmd -lchange fixed a different case; this is the one that affected UniFi.) - Duplicated "Last self-test" on the Battery tab. Once 6.1.2 began recording device results, the battery-health card showed self-test twice — as a score-factor meter and again as the actual result. It now appears once, as the "Passed/Failed · date" result (the same duplication in the detail modal is gone too).
[6.1.2] - 2026-07-01¶
Self-test fixes driven by real UniFi UPS testing, on top of 6.1.1. No shutdown behavior changes.
Fixed¶
- Self-test discovery ignored NUT credentials.
upscmd -lran anonymously, so on an upsd that only lists instant commands to a logged-in client (notably UniFi's NUT) discovery came back empty and a supported test looked unsupported ("does not expose 'test.battery.start'"). Discovery now forwards thenut_controlcredentials toupscmd -l, with the password kept off argv. Anonymous listing is unchanged when no credentials are set. - Self-test validation ignored auth-DB users. Enabling
self_testdemandedapi.auth.enabled: trueeven when auth was already active because a user existed in the auth DB. Validation — and thenut_control→ auth rule — now honor the same effective-auth state the API enforces at runtime.
Changed¶
- Self-test no longer needs the full nut_control setup. Enabling
self_testis now its own narrow permission granting exactlyself_test.command; you no longer also have to setnut_control.enabledand list the command innut_control.allowed_commands. API authentication is still required, and the general control surface (arbitrary commands, variable writes) is untouched. Credentials forupscmdstill come fromnut_controlwhen your upsd requires a login.
Added¶
- Passive self-test observation. Eneru reads
ups.test.result/ups.test.dateon every poll and records asource: deviceresult whenever a new pass/fail appears, whether or not scheduled self-tests are enabled. A UPS that tests on its own cadence, or one you test by hand, now surfaces in the dashboard, API, Prometheus, and the battery-health score with no configuration. This path is covered end-to-end; issuing a test still is not, as the dummy NUT driver has no INSTCMD. - Daemon version + runtime in the dashboard footer. The footer shows the
running Eneru version and where it runs (baremetal / container / Kubernetes)
ahead of the update time. Both are top-level fields in
GET /api/v1/ups:version(new) andruntimeContext(mirrors the existingruntime.context).
[6.1.1] - 2026-06-30¶
Dashboard UX polish and bug fixes on top of 6.1.0. No config, API, or shutdown behavior changes.
Fixed¶
- Tab scroll jump on refresh. The 10s auto-refresh no longer yanks a scrolled tab (Battery, Energy, Events, …) back to the top; only explicit tab switches reset scroll.
- Sparse power events looked missing. Power/Battery/Energy charts default to a 24h window, so rare outages fell outside it. Charts now show a hint when tier-1 power events exist beyond the selected range ("N earlier power events outside this range — widen to view").
- Brief outages were invisible on charts. A short
ON_BATTERY→POWER_RESTOREDpair (often ~2s) collapsed onto one pixel and the restore marker hid the cut. Outages now render as a shaded span with a minimum width. - Unreadable frequency chart. Mains frequency is quantized (e.g. 50.0/50.2 Hz), so auto-scaling amplified it into noise on short ranges. The value axis now has a 2 Hz floor, rendering a calm line near nominal.
- Cryptic
upscSSL error. A failed STARTTLS handshake (notably UniFi UPS firmware once NUT login credentials are enabled) now logs an actionable hint pointing at the fix, instead of raw OpenSSL text. - Config-tab gear icon redrawn (was visibly skewed).
- Docs image 404 on Read the Docs. Dashboard / Grafana screenshots used a
relative
<img>path that broke under directory URLs; switched to Markdown image syntax.
Added¶
- Hover readout on charts. Mousing over any chart line shows the metric value and timestamp at that point (crosshair + floating tip), so per-point inspection no longer requires the Grafana/Prometheus stack. Coexists with event-marker and outage tooltips.
- Battery-health factor meters. Each scoring term (runtime, anomalies, age, …) now shows a small 0-100 bar beside its number, on the Battery tab and in the detail modal.
- Power-quality help hints. The detail modal's voltage / frequency / temperature rows carry "?" explanations, including how Eneru classifies brownout/surge and a note that some UPSes (incl. UniFi) report a fixed placeholder temperature.
- Clearer shutdown routing. "delegated to host" now reads "delegated to host OS" with a "?" explaining the container loopback delegation.
Docs¶
- Troubleshooting: expanded the
upscSSL/TLS section with the UniFi NUT-auth limitation and the disable-credentials workaround.
[6.1.0] - 2026-06-30¶
Battery intelligence, energy tracking, scheduled self-tests, periodic reports, and a rebuilt browser dashboard, on top of the v6.0 monitoring and control surface.
Added¶
- Battery health score. A composite 0-100 score per UPS, built from five
terms: capacity (runtime trend), runtime against nominal, last self-test
result, confirmed anomaly history, and battery age. Each term carries an
availability flag, so missing telemetry reports as unknown instead of
inflating the score, and a new battery reports unavailable until it has at
least
replacement.min_history_daysof history rather than being dragged down by early jitter. Least-squares replacement prediction warns when the score trends toward a configured threshold, and tieredwarn_score/critical_scorealerts fire on the current score (once each, re-arming on recovery). Newbattery_healthconfig section, per-UPS overridable, plusBATTERY_REPLACEMENT_PREDICTEDandBATTERY_HEALTH_WARNING/_CRITICALevents. - Energy tracking. kWh integrated from
ups.realpower, falling back toload% × power.nominal(flagged estimated) or a configuredenergy.nominal_powerrating when the UPS reports neither. Daemon-down gaps are capped so an outage doesn't invent consumption. Windows are calendar-based (today since local midnight, month since the 1st, plus a year-to-date) to match an electricity bill rather than a rolling average. Optional cost with per-currency and custom formatting; leavingcost_per_kwhunset disables cost entirely. Newenergyconfig section. - Scheduled self-tests. Issues a NUT battery self-test on a
daily/weekly/monthly/interval schedule and records the normalized result, gated
behind the same
nut_controlallowlist and API auth as manual control. It adapts to whateverupscmd -lexposes and self-disables otherwise, and an in-flight test is persisted and recovered after a restart so arunningrow is never orphaned. Newself_testsection (per-UPS overridable), an auth-gatedPOST /api/v1/ups/{name}/self-test, and a CLI:eneru self-test run(via the daemon API by default,--directover NUT) andeneru self-test status. - Periodic reports. Daily, weekly, and monthly digests covering power events,
battery health, energy, and uptime, delivered through the notification channel
with optional CSV. Multi-UPS deployments get one daemon-wide digest with a
section per UPS. New
reportssection, backed by a shared periodic scheduler whose last-run state lives in the statsmetatable so infrequent jobs fire correctly across restarts. - Rebuilt dashboard. The web UI is now a tabbed single-page app (Overview, Power, Battery, Energy, Events, Shutdown, Config) built as real ARIA tabs with arrow-key navigation and URL-hash routing, still vanilla JS and SVG with no build step. Overview leads with a battery ring gauge and drill-through KPI cards; an inline-SVG icon set replaces emoji; charts gain threshold bands, gradient fills, a "now" marker, and color-coded power-event overlays with instant themed tooltips. New widgets cover remote-server health, a derived line-quality (Good/Fair/Poor) read, a multi-year battery-health trend graph that marks the replacement threshold and the projected replacement date, a load%-and-power energy chart, a collapsible config JSON tree, and a window-independent Events tier filter.
- Shutdown plan view. A read-only, ordered view of exactly what runs on a
power-loss shutdown (VMs, containers, filesystem sync and unmount, remote
servers by phase, final sync, host poweroff) with parallel groups, per-phase
timeouts and a total estimate, and skipped phases shown with their reason. A
UPS selector covers multi-UPS and remote-only deployments, and each plan states
its trigger (a redundancy group's quorum loss or a standalone UPS's low-battery
/ FSD trigger). Backed by
GET /api/v1/ups/{name}/shutdown-plan, derived from config; the execution path is untouched, and raw shutdown commands are redacted on API reads. - More telemetry surfaces. New
batteryHealth,energy, andselfTeststatus blocks on the API and MQTT; Prometheus gauges for health score, energy kWh/cost (with aperiodlabel), self-test result, and replacement days remaining (unknown series omitted);battery-health-historyandpowertime-series endpoints; and output voltage, input/output frequency, battery voltage, temperature, and real power added to/history. - NUT name autodiscovery (issue #71). When a poll can't reach the configured
UPS, Eneru lists what
upsc -lactually exposes; if exactly one UPS exists and the configured name isn't it (the classic login-username-where-the-device-name -belongs mistake), it self-heals for the session and tells you to fixups.name. The configured name and on-disk state are never mutated.
Changed¶
- Stats schema v7 (additive, idempotent):
real_power/power_nominalsample columns and aggregates, plusbattery_healthandself_teststables, trimmed on the existing retention cadence. - Remote SSH host-key checking defaults to
accept-new(issue #73). Aremote_serversentry with nossh_optionsno longer inherits OpenSSH's interactiveask(which fails closed underBatchMode), so a fresh remote connects on first contact and learns the key. Bare-metal uses the running user'sknown_hosts; containers use the writable/var/lib/eneru/ssh/known_hosts; the Kubernetes sample backs state with a PVC so learned trust survives pod restarts. Any explicitStrictHostKeyCheckingorUserKnownHostsFileyou set is preserved. expected_host_identityauto-populates from anycat /path(issue #70), so marker-file setups no longer duplicate the value or mount over/etc/machine-id.energy,battery_health,self_test, andreportsare SAFE hot-reloads: their schedules are recomputed from config on each loop, so a SIGHUP applies on the next due-check with no scheduler re-register step.- Quieter NUT polling (
NUT_QUIET_INIT_SSL=true, benign SSL-init line filtered) and a faster CI/E2E pipeline (eight parallel E2E matrix jobs, buildx layer caching,pytest-xdist, a reduced PR Python matrix).
Fixed¶
- Replacement prediction fires for an already-below-threshold battery regardless of history depth, and the re-warning re-fires weekly rather than once per horizon.
Schedule.intervalkeeps fractional seconds; the scheduler discovers the self-test command before stamping last-run and stamps only on a successful issue, so a transient failure retries instead of burning a cadence.- Multi-UPS reports are a true daemon-wide digest, "Running since" survives long
uptimes, and
format: csvdelivery is honored. - Median sample-spacing averages the two middle values, and an auto-learned nominal runtime is not persisted when the config already pins one.
- Config validation rejects non-numeric and non-mapping v6.1 sections, requires
critical_scorestrictly belowwarn_score, and validates schedule/time strings up front instead of failing at runtime. - Stats write transactions re-check the connection under the DB lock, closing a shutdown-time race that could crash a transaction.
[6.0.0] - 2026-06-04¶
v6.0 turns Eneru from a shutdown daemon with observability into an operator tool you can drive from the browser. The API can now serve the dashboard, authenticate users and API keys, run allowlisted UPS control actions, delete selected event rows, and reload safe config changes without restarting the daemon.
The release also went through two pre-release audit rounds. The first audit focused on the shutdown path: like checking that a fire door still opens after you stack boxes beside it, it verified that config mistakes, slow drains, and thread races could not block the final host poweroff. The second audit focused on the new interactive surface: auth fail-closed behavior, dashboard state, SQLite/resource handling, package contents, and CI/E2E coverage. The fixes from both audits are included below.
Added¶
- Browser dashboard. The embedded API now serves a no-build, no-third-party
JavaScript dashboard when
api.enabledis on. It includes live UPS cards, drill-down panels, redundancy rollups, a shutdown banner, SVG history graphs, event filters with multi-type selection and wide-range paging, signed-in event deletion, UPS control, and Light / Dark / System themes. - Tiered API authentication. Local users and API keys live in a dedicated
SQLite auth DB. User passwords are bcrypt hashes; API keys are stored as
SHA-256 digests. Manage them with
eneru user ...andeneru apikey .... Reads stay open by default so Prometheus and status clients keep working;api.auth.require_for_reads: truegates reads too. Every write path requires auth, and UPS control cannot be enabled while auth is off. - New API endpoints and write paths. Added
GET /api/v1/auth/state,POST /api/v1/auth/login,POST /api/v1/auth/logout,POST /api/v1/config/reload,GET /api/v1/ups/{name}/commands,POST /api/v1/ups/{name}/command,GET /api/v1/ups/{name}/variables,PUT /api/v1/ups/{name}/variables/{var}, andDELETE /api/v1/ups/{name}/events. - UPS control via NUT.
nut_controlwraps the existingupscmdandupsrwtools for allowlisted instant commands and writable variables. Passwords are answered through a pseudo-terminal instead of being placed on argv. - Event management. Events now have stable, never-reused IDs.
/api/v1/eventssupportsfrom/torange filters and source-qualified cursor paging, and the dashboard can delete selected rows while preserving rows that the server did not confirm as deleted. - Config hot-reload.
systemctl reload eneru,SIGHUP,docker kill -s HUP, and authenticatedPOST /api/v1/config/reloadcan apply safe changes live: trigger thresholds,nut_control, notifications, MQTT, remote health, Prometheus, and stats retention. Unsafe changes are reported as restart-required. A bad reload keeps the previous config running. - Auth packaging. Added the
authextra for bcrypt. Debian packages depend onpython3-bcrypt, the OCI image bundles it, and RPM recommends it because EL package availability varies.
Removed¶
- NUT auto-discovery, previously listed for 6.0, was dropped: it duplicates
nut-scannerand does not fit Eneru's config-first model.
Fixed¶
- Dashboard first-user sign-in without restart. Creating the first user with
eneru user createnow refreshes the API's effective-auth probe immediately, so the dashboard can show Sign-in and accept login without restarting the daemon. - Dashboard runtime display. The web UI now formats UPS runtime as seconds, minutes, or hours/minutes in cards, drill-down details, and runtime graph labels while keeping API responses as raw seconds.
- Shutdown audit hardening. A group can no longer abort host poweroff by self-joining its own monitor thread during multi-UPS drain. Local drain phases are best-effort, bounded filesystem sync prevents a hung mount from wedging the sequence, remote pre-shutdown commands cannot spend the final poweroff budget, and SIGTERM/SIGINT now waits a bounded, config-derived deadline when a shutdown is already in flight.
- Config validation before outage-time crashes. Shutdown thresholds,
depletion settings, extended-time settings, drain timeouts, duplicate UPS
names, unknown per-UPS/per-redundancy keys, and empty
local_shutdown.commandvalues are now rejected at load instead of failing during an outage. - Redundancy and failsafe behavior. Redundancy groups wait for present
members to publish an initial snapshot before making a cold-start quorum
decision. On-battery hard NUT failures are debounced by
max_stale_data_tolerance, and the on-battery failsafe runs once per outage instead of repeating every poll in dry-run, delegated, or non-halting configs. - T3 depletion trigger at slow poll intervals. The depletion-rate trigger no
longer needs a fixed 30 samples. It now derives the needed sample count from
depletion.window / check_interval, with a floor of two samples, so slower polling does not silently disable the fast-drain trigger. - API/auth hardening. Request bodies are size-limited and read with a socket
deadline. Dynamic auth fails closed when an existing auth DB cannot be
inspected. Password resets and user deletion invalidate active user sessions.
Write/control paths re-check the account strictly and fail closed during auth
DB errors; reads stay lenient unless
require_for_readsis enabled. - Dashboard correctness. Redundancy cards use server-computed quorum health, advisory member triggers do not show a false shutdown-imminent banner while quorum is intact, event filtering and paging are source-exact, control panel rebuilds are cached without losing half-typed variable values, and auth state cleanup now clears stale tokens, event selections, and UPS-control UI together. The event table Time header can now toggle chronological order, and remote health rows from the API's uppercase status constants render as reachable when healthy.
- Remote operator output.
eneru remote listnow includes last-known remote health from the daemon sidecar when remote health is enabled, without running SSH probes. The non-rootuse_sudowarning now focuses on Eneru-generated privileged actions and plain shutdown commands, so custom NAS admin commands that work without sudo do not produce a misleading warning. - SQLite and deferred notifications. Stats open failures no longer leave a
half-open connection, failed flushes requeue samples, readonly opens return
Noneon SQLite failures,_safe_alter()only swallows duplicate-column errors, and monitor/coordinator shutdown closes stats handles. Deferred stop notification delivery now claims rows asdeliveringuntil Apprise succeeds, with stale-claim recovery on daemon startup. - Packaging, docs, and CI coverage. Dashboard assets and new Python modules
are included in both pip package data and nfpm package contents. Requirements
include MQTT/auth extras where needed. E2E coverage exercises auth, UPS
control, event deletion, and config reload against the Docker Compose NUT/SSH
environment. The Single UPS control E2E uses the dummy NUT server's control
credential, and PTY-backed
upscmd/upsrwfailures preserve NUT's response text for diagnostics.
Notes For Operators¶
- With auth disabled, the API remains read-only as in 5.x. The dashboard loads, but sign-in and write controls stay hidden.
- To enable dashboard login or UPS control, set
api.auth.enabled: true, create a user witheneru user create, and configurenut_controlallowlists. - Existing stats databases migrate automatically on first start. As always, take a backup before upgrading production monitoring hosts.
[5.5.1] - 2026-05-19¶
Fixed¶
- Container restarts and image upgrades now send one lifecycle notification instead of "Service Stopped" followed by Restarted/Upgraded. Container SIGTERM is like one doorbell wired to several doors: Eneru cannot tell stop from upgrade, so it leaves the stop row pending for the next container to supersede.
- Added regression coverage for explicit legacy logging paths so
migrated configs keep rewriting to
/var/{log,run}/eneru/...inside containers.
[5.5.0] - 2026-05-18¶
Added¶
- Containerized local-host ownership for Docker and Podman through a host-loopback SSH delegate. Eneru can now run as the slim non-root container while still shutting down the host, VMs, containers, compose stacks, and configured filesystems on the host.
- Zero-config root loopback synthesis for Docker/Podman local configs:
host: 127.0.0.1,user: root,ssh_key_path: /var/lib/eneru/ssh/id_loopback, andshutdown_command: "shutdown -h now". remote_servers[].use_sudofor non-root SSH users. It prefixes generated privileged actions and the final shutdown command withsudo -nwhen needed.remote_servers[].pre_shutdown_commands[].mountsfor theunmount_filesystemsaction, so ordinary remote servers can unmount specific filesystems before their final shutdown command. Loopback delegates still derive mounts from the localfilesystems.unmountconfig.- Loopback host identity validation using
/etc/machine-id, surfaced in remote health and/ready. - SQLite diagnostics events for slow NUT polls and successful but slow remote SSH health probes. These now appear in the TUI/API event stream at Diagnostics verbosity instead of only in journal/container logs.
- E2E coverage for root loopback, non-root sudo loopback, missing machine-id readiness, missing-loopback startup failure, and the generated VM/container/sync/unmount loopback action list.
Changed¶
- The OCI image is now slim and relies on the host loopback path for local host actions instead of shipping Docker, Podman, and libvirt clients inside the container.
- Docker image healthchecks now target
/ready, so orchestration checks the configured shutdown contract rather than only API liveness. /readynow evaluates shutdown capabilities, including native host binaries, remote SSH health, and loopback health for containerized local ownership.- Container runtimes suppress
wall(1)and the missinglogger(1)warning because those native host side channels are not useful inside OCI containers. - Kubernetes remains the remote-only container profile by default; local ownership in a pod requires an explicit loopback delegate.
Fixed¶
- Synthesized host-loopback delegate now ships with
StrictHostKeyChecking=no+UserKnownHostsFile=/dev/null. Without these the first SSH probe to127.0.0.1failed with "Host key verification failed" because the non-root container user has no~/.ssh/known_hosts. - Legacy native-install paths (
/var/log/ups-monitor.log,/var/run/ups-monitor.state,/var/run/ups-battery-history,/var/run/ups-shutdown-scheduled) now auto-rewrite to/var/{log,run}/eneru/equivalents under container runtime when the config still matches the dataclass default. Preserves the "no required YAML changes" migration promise for default configs. Operator-set paths are untouched. See docs/migrate-to-container.md for opt-out. docs/migrate-to-container.mdStep 6 had three bind mounts written as,Z(comma) instead of:Z(colon). On SELinux hosts Docker parsed the destination as the literal path/var/lib/eneru,Zand the real/var/lib/eneruinside the container stayed unmounted — so the carried-over stats DB at/srv/eneru/state/default.dbwas never reachable and the daemon created a fresh empty DB in the image's default directory instead. Corrected to:Z.- Removed the per-startup "v5.5: running non-root inside
; local-host actions will be delegated to @ via SSH" banner. In v5.5 the loopback path is taken regardless of euid, so the non-root vs root distinction was cosmetic and the banner spammed the logs on every restart. The privilege check still passes silently for the same scenario. - Removed the legacy-path auto-rewrite banner entirely. The rewrite
still fires (it preserves the migration promise), but it re-runs
in-memory on every container restart and the banner was log noise.
Behavior is documented in
docs/migrate-to-container.md. - Softened the non-loopback API auth-off log to an info note. With
auth disabled in v6.0, write endpoints are disabled and
/api/v1/configreturns the sanitized anonymous view; operators still get a reminder to keep unauthenticated read endpoints on a trusted network. - TUI (
eneru tui/eneru monitor) now sees the same legacy-path auto-rewrite as the daemon inside a container._cmd_monitorused to skip_prepare_runtime_config, so the TUI kept reading/var/run/ups-monitor.statewhile the daemon wrote to/var/run/eneru/ups-monitor.state, surfacing as "daemon not running" - "No data available" on the main UPS panel even though events,
graphs, REST API, and Prometheus all worked. Moved the rewrite into
_load_configso every subcommand inherits it without needing to remember a prepare call.
Migration notes¶
- Native deb/rpm/pip installs upgrade without YAML changes.
- Existing remote-only container deployments upgrade without YAML changes.
- Docker/Podman containers that own local host actions must provide a
working loopback SSH path and bind-mount the host
/etc/machine-idread-only. - Containers that drive other remote targets (NAS, secondary hosts)
must add an explicit
ssh_key_pathto eachremote_serversentry and bind-mount the operator SSH key into the container — root's~/.ssh/id_rsais not visible from the eneru user. See docs/migrate-to-container.md Step 2b. - Bind-mounted SSH keys must be readable by uid 10001 inside the
container. Hand the private key to uid 10001 with
chown 10001:10001and keep it at0400or0600; the matching.pubcan stay0644. Loosening the private key to0644to "make it work" exposes it to every local user on the host and isn't worth the convenience. - To carry forward existing TUI graphs, event log, and notification
history, copy
/var/lib/eneru/*.dbto the bind-mount source for/var/lib/eneru(e.g./srv/eneru/state/) andchown 10001:10001the files before starting the container. Skip if a clean history is acceptable. See Migrate to container. - To remove the deb/rpm package after the container is healthy, copy
/etc/ups-monitor/config.yamlto/srv/eneru/config.yamland point the container's bind mount at the copy. Decouples the daemon's configuration from the package's file ownership. See docs/migrate-to-container.md Step 3c. - Do not use
authorized_keys command="..."for loopback keys; it replaces Eneru's identity probe and generated shutdown actions. - Non-root loopback users should set
use_sudo: trueand use the documented NOPASSWD sudoers stanza for all enabled delegated actions.
[5.4.0] - 2026-05-15¶
Stable v5.4 release. This release adds the official container deployment path while keeping local host shutdown on native installs.
Added¶
- Official GHCR image for remote-only Docker, Podman, and Kubernetes deployments. The release workflow publishes exact
<version>tags,latestfor stable releases, andtestingfor pre-releases. eneru run --api,--api-bind, and--api-port, so container healthchecks and Kubernetes probes can enable the read-only API without editing YAML.remote_servers[].ssh_key_pathfor SSH private keys mounted from Docker bind mounts or Kubernetes Secrets.- Kubernetes
DeploymentandPodexamples with non-root security contexts, resource requests/limits, HTTP probes, SSH Secret mounts, and a/var/log/eneruvolume for retained log files.
Changed¶
- The OCI image is remote-only by design. Use native deb/rpm or PyPI installs when Eneru must stop local VMs, local containers, filesystems, or the host itself.
- Dependency checks now follow configured behavior: remote-only deployments no longer require local shutdown tooling, and the legacy
logger(1)syslog side-channel is best effort. - The container image uses the Python 3.12 slim Trixie base and runs
apt-get upgrade -yduring builds so release images pick up current Debian fixes. /api/v1now returns an endpoint index, and JSON 404 responses include the same endpoint list. When Prometheus is disabled,/metricsis omitted from both.- The reference Grafana dashboard adds power-event annotations, a battery/load/runtime "now" row, a
$upsselector, and a nominal-voltage overlay. eneru validatereports whether it is running in Docker, Podman, another container, systemd, or a bare process.
Fixed¶
- Remote-health startup probes no longer record a noisy
REMOTE_HEALTH_HEALTHYevent for the initialUNKNOWN -> HEALTHYbaseline next to everyDAEMON_START. Startup failures and later failure/recovery transitions are still recorded. - The SVG architecture diagram viewBox matches the drawn content width (782×550 instead of 960×550), removing the blank right-side margin in rendered docs.
Migration notes¶
- Existing native installs can upgrade without YAML changes.
- Use
ghcr.io/m4r1k/eneru:latestfor the latest stable image,ghcr.io/m4r1k/eneru:testingfor pre-releases, or pinghcr.io/m4r1k/eneru:<version>for immutable production deployments. - For local host shutdown, local VM/container teardown, or filesystem unmounts, keep Eneru on the host. The OCI image is for remote UPS monitoring, API/health endpoints, telemetry, and SSH shutdown of remote systems.
[5.3.0] - 2026-05-10¶
Stable v5.3 release. Drop-in for v5.2 users in most cases — see Migration notes below for two behaviour changes that may need a small adjustment to existing dashboards or RPM-based MQTT setups.
Added¶
- Read-only observability. The embedded API now serves
/health,/ready,/api/v1/ups,/api/v1/events,/api/v1/config,/api/v1/remote-health, and Prometheus/metrics. API and MQTT payloads share the same status model, including stablegroupIdvalues, redundancy-group rows, event verbosity tiers, and power-quality fields. - Power-quality metrics. API, MQTT, Prometheus, and the reference Grafana dashboard now expose input/output voltage, input/output frequency, battery voltage, UPS temperature, nominal voltage, derived warning thresholds, voltage state, AVR state, bypass state, and overload state.
- Remote SSH health. Remote health is enabled by default for configured remote servers. The daemon runs the safe SSH
probe_command(trueby default), writes live state plus sidecar JSON, marks failed probesDEGRADEDbeforeFAILED, records state transitions in SQLite events, and sends one failure notification per failed period plus one recovery notification. - Manual remote shutdown drill.
eneru shutdown remote --server ...exercises one configured remote through Eneru's SSH path.--dry-runexecutes no configured commands; real execution requires--i-really-want-to-proceed-with-remote-shutdown. - Target discovery and full-sequence rehearsal.
eneru remote listprints every configured remote target with its group, host, enabled flag, and effective shutdown order.eneru shutdown group --group ...rehearses the whole shutdown sequence (VMs, containers, filesystems, ordered remote shutdowns) for one UPS or redundancy group; dry-run by default and gated by--i-really-want-to-proceed-with-group-shutdownfor real execution. - JSON/syslog/MQTT/Grafana surfaces. JSON logs now carry structured fields where call sites provide them, syslog forwarding is configurable, MQTT reconnects with bounded backoff, and
examples/grafana-dashboard.jsonimports with a Prometheus datasource variable. - Slow NUT poll visibility. Slow
upsccalls now produce rate-limited log lines, and notifications require consecutive slow full-poll cycles. Operators get a journal breadcrumb for NUT latency without alert noise from a one-off slow read.
Changed¶
- Redundancy shutdown re-arms correctly. The redundancy executor's
/var/run/ups-shutdown-redundancy-{group}flag is now daemon-managed. Stale flags are cleared at coordinator startup, quorum recovery, and graceful signal exit; active PID-owned flags are refused. Repeated quorum-loss events now fire independently instead of staying pinned after the first event. This closes the issue #4 class of failures. - Runtime NUT visibility honors connection grace. Redundancy members with fresh prior data contribute
DEGRADEDduring connection grace and becomeUNKNOWNonly after the monitor marks the connection failed. This prevents short NUT flaps from bypassing the existing grace window. - TUI events are tiered by verbosity. Default event views show Power Events first.
-v/ first<V>adds Diagnostics,-vv/ second<V>adds Lifecycle, and--lengthnow consistently caps event rows in one-shot output. Live grouping keeps Power Events visible before lower-priority rows. - On-battery stabilization. Fresh on-battery transfers wait 30 seconds before charge, runtime, depletion-rate, or extended-time shutdown triggers can fire. FSD and on-battery connection-loss failsafe remain immediate.
- Remote shutdown accounting is stricter. Single-server phases use the same deadline-based worker path as multi-server phases, summaries count success/failure/timeout/worker-crash outcomes, and timeout logs include both configured and deadline-capped values.
- MQTT packaging on RPM is soft. Debian/Ubuntu packages still depend on
python3-paho-mqtt. RPM packages recommend it because RHEL 8 and 10 packaging coverage is uneven; if MQTT is enabled without paho, Eneru logs a warning and keeps running.
Fixed¶
- API and metrics fixes: single-UPS
/api/v1/remote-healthnow reads the live manager,/metricsincludes redundancy-group remote targets, every metric hasHELP/TYPE,/api/v1/eventscapslimitat 10000, and API worker threads no longer block daemon shutdown. - Config and validation fixes: non-mapping nested YAML falls back cleanly, unknown trigger/behavior keys are validation errors with suggestions, and unsafe
remote_health.probe_commandvalues with shell metacharacters are rejected. - TUI and stats fixes: malformed remote-health sidecar rows are ignored, per-group height includes the remote-health row, recent events have deterministic timestamp ties, Ghostty sessions fall back to
xterm-256colorwhenxterm-ghosttyterminfo is missing, andStatsStore.from_connection()no longer leaks an in-memory handle. - Logging and docs fixes: syslog-init failures use the configured logger, non-loopback API warnings describe the sanitized config data accurately, manual-drill docs now match
--serverexact-name lookup and dry-run precedence, and SELinux/AppArmor troubleshooting notes were added. - E2E hardening: Test 43 retries API endpoints, Test 44 uses nanosecond timing for bounded remote failure, Test 45 proves MQTT status payloads reach a broker with power-quality fields, and the v5.3 workflow grep is documented as a wiring guard.
Migration notes¶
- No YAML changes are required.
remote_health.enablednow defaults totrue, but only configured remote servers are probed, and probes use the harmlessprobe_command. Set it tofalseif you do not want periodic SSH connectivity checks. - If you were deleting
/var/run/ups-shutdown-redundancy-*manually after tests or issue #4-style no-op shutdowns, stop doing that. The daemon owns those flags now. - API, Prometheus, and MQTT remain read-only in v5.3. Authenticated control APIs are still planned for v6.
- Prometheus no-data semantics:
/metricsnow exportsNaNfor power-quality fields a UPS does not report (previously0.0). Alert rules and Grafana panels written against v5.2 that comparedeneru_ups_input_voltage < 200would never fire on missing data; under v5.3 they correctly stay quiet (no false alarm) but a< 200comparison againstNaNis alsofalse, so an under-voltage alert on a UPS that doesn't report voltage at all will not fire either. If you alert on these fields, audit the rule withabsent()oreneru_ups_input_voltage == eneru_ups_input_voltageto detect the missing-data case. - MQTT publishing requires
paho-mqtt: the dependency is a hard requirement on Debian/Ubuntu packages and on PyPI installs (pip install eneru[mqtt]). RPM packages list it as a softRecommends:because RHEL 8/10 packaging coverage is uneven; if you enablemqtt:on RPM, install paho explicitly:python3 -m pip install paho-mqtt(use--break-system-packageson EL10 per PEP 668). If MQTT is enabled without paho, Eneru logs a warning and keeps running; the publisher just stays disabled.
[5.2.2] - 2026-04-28¶
Bug-fix release. Drop-in upgrade.
Fixed¶
- Shutdown trigger never re-armed after
POWER_RESTOREDwhen the local-shutdown command didn't actually halt the host (bug #4). Single-UPS and multi-UPS coordinator paths both fixed. Gated re-triggers now log a warning instead of returning silently. eneru tui --graph voltagesilently ignored in interactive mode.- Phantom 0 V samples squashed the voltage graph into a one-row strip
at the top. Writer drops on-line
input.voltage <= 0(real outages still record the dip); graph uses 5th/95th percentile bounds. - Events panel showed daemon-lifecycle chatter instead of power events. Priority filter is now tiered: power events always survive the cap; daemon events fill remaining slots.
eneru tui --once --events-onlysilently fell back to log parsing because the default 1 h window was empty for sparse events. Events no longer use a time window at all.
Added¶
--verbose/-v: include low-priority events.<V>toggles in the live TUI.--length N: cap events output (default 30,0= no cap).
Changed¶
--timeand<T>apply to the graph only. Use--lengthfor events.- Events panel defaults to priority-only.
- Live TUI events cap raised (now
min(30, visible_panel_rows)so power events stay inside the visible window on smaller terminals;<M>still expands to 500 rows for full scrollable history).
Migration notes¶
- Scripts grepping
--events-onlyoutput for low-priority event types: add--verbose. - Scripts using
--timeto size events: switch to--length.
[5.2.1] - 2026-04-24¶
Bug-fix release for two v5.2.0 regressions. Drop-in upgrade. See
git log v5.2.0..v5.2.1 for per-commit detail.
Fixed¶
- Two notifications on every
systemctl restart/ package upgrade instead of the v5.2-promised single🔄 Restarted/📦 Upgraded. At SIGTERM the old daemon now picks the cheapest correct path based on systemd intent (systemctl show -p Job eneru.service):Job=stop→ ship eagerly (instant);Job=restart/ unknown → enqueue + schedule a transientsystemd-runtimer to deliver ~15 s later, cancelled by the next daemon's classifier if a replacement comes up. Containers / K8s / foregroundeneru run(no systemd) → always eager. New moduledeferred_delivery.py+ hidden CLI subcommandeneru _deliver-stop. 📦 Upgraded vunknown → v5.2.0on RPM. Newpreinstall.shcaptures the outgoing version viarpm -q enerubefore files unpack (RPM doesn't pass it in$2the way DEB does). Defensive fallback inlifecycle.classify_startupcovers paths that bypass scriptlets.
Documentation¶
docs/notifications.mdcaught up with the v5.0 / 5.1 / 5.2 architecture: SQLite-backed queue, exponential backoff, new config knobs, lifecycle classifier states, brief-power-outage / recovery coalescing. Comparison table extended to three columns (v4.6 / v4.7+ / v5.2+).
Migration notes¶
None.
[5.2.0] - 2026-04-24¶
Notifications get a rewrite. v5.1's were stateless and noisy: a systemctl restart emitted two unrelated events, a shutdown sequence emitted ~22 mid-flight "Shutdown Detail" lines that mirrored the log, and a power outage that took the internet down meant nothing was ever delivered. v5.2 makes them persistent, classified, and coalesced. See git log v5.1.2..v5.2.0 for per-commit detail.
Added¶
- Persistent SQLite-backed notification queue. Each notification is a
pendingrow in the per-UPS stats DB. The worker thread reads and writes through SQLite, so messages survive process death, network outages, and reboots; pending rows ship in age order once the endpoint is reachable. Per-message exponential backoff (capped atretry_backoff_max, default 5 min) so the worker doesn't hammer an unreachable endpoint while it's down. - Stateful lifecycle classifier. Replaces the unconditional "🚀 Started" with one of:
📦 Upgraded vX → vY,📊 Recovered(resumed after a power-loss-triggered shutdown),🔄 Restarted(graceful exit, downtime under 30 s),🚀 Restarted (fatal),🚀 Started (last seen Nh ago),🚀 Started (after crash), or plain🚀 Started. Uses an on-disk shutdown marker plusmeta.last_seen_versionin the stats DB. - Brief-outage coalescing. A pending
ON_BATTERY+POWER_RESTOREDpair from the same outage gets folded into one📊 Brief Power Outage: Ns on batterysummary before delivery. Same for the post-power-loss recovery: aRecoverednotification absorbs the previous instance's pending shutdown headline + summary into one message that includes the trigger reason and the downtime. - Outage-survival config knobs:
notifications.retention_days(default 7, applies to sent/cancelled only; pending is never pruned by TTL),max_attempts(default 0 = unlimited; Apprise's bool can't distinguish "bad URL" from "internet down"),max_age_days(default 30, the only cap on pending),max_pending(default 10000, backlog overflow),retry_backoff_max(default 300, 5 min). Sized for a long weekend with the internet down. flush(timeout=5)drain on shutdown. Wired into every shutdown path (signal + sequence-complete, single-UPS + coordinator). Closes the v5.1 "1 message pending" SIGTERM race; whatever doesn't drain stays in SQLite for the next start.- TUI events panel: full date. Rows render
YYYY-MM-DD HH:MM:SSso multi-day events are distinguishable.
Changed¶
- Shutdown notifications: 22 → 2. Dropped the per-
_log_messageauto-mirror. The channel now carries the headline (🚨 EMERGENCY SHUTDOWN INITIATED!with reason) and a single✅ Shutdown Sequence Completesummary at the end. The summary now also fires whenlocal_shutdown.enabled=false. Per-step detail stays in journalctl. wall(1)opt-in. Defaults to off vialocal_shutdown.wall: false. Holdover from the v2ups-monitorera when the shell was the only channel; Apprise covers the modern path.- Schema v3 → v4. New
notificationstable + index; append-only migration heals partial state viaCREATE TABLE IF NOT EXISTS. Seesrc/eneru/AGENTS.md"Stats schema evolution". _send_notificationAPI. Adds acategorykeyword (defaultgeneral); used by the coalescer and per-category queries. Theblockingparameter is now a back-compat shim because the v5.2 queue is always asynchronous.- Banner formatting cleanup. Dropped the
========== BANNER ==========padding frommonitor.pyandredundancy.py. The ALL CAPS body stays (grep-friendly).
Removed¶
get_retry_count()/get_queue_size()onNotificationWorker, replaced byget_pending_count().time.sleep(5)at the local-shutdown gate, replaced byflush(timeout=5)which returns as soon as pending hits 0.- Per-server "Remote Shutdown Sent" success notifications, covered by the aggregate summary; failures still notify.
Migration notes¶
- deb/rpm upgrades: postinstall now drops
/var/lib/eneru/.upgrade_marker.jsonbeforesystemctl restart, so the next start emits a single📦 Upgradednotification. Pip users get the same effect via themeta.last_seen_versioncomparison. - Wall broadcasts: if you relied on the v5.1 default of "wall fires on every shutdown", set
local_shutdown.wall: trueexplicitly. - Stats DB: schema bumps from v3 to v4 on first start. Idempotent and append-only; existing rows are preserved.
[5.1.2] - 2026-04-23¶
Bug-fix release for issue #4: voltage warning thresholds were misleading on narrow-firmware UPSes (US 120V APC defaults, EU managed units). Drop-in upgrade for the common wide-firmware case. Sites with narrow firmware get a one-time startup warning and a documented migration tip. See git log v5.1.1..v5.1.2 for per-commit detail.
Added¶
triggers.voltage_sensitivitypreset (per-UPS-group):tight(±5%),normal(±10%, default, matches EN 50160),loose(±15%). Strict-enum validated. Per-UPS so a clean PDU and a generator-fed leg can use different bands in the same daemon.- One-time startup migration warning with per-side delta when v5.1.1's algorithm would have produced a tighter band on the current UPS. Suppressed once
voltage_sensitivityis set explicitly in YAML.
Fixed¶
- Voltage warning band misleading on narrow-firmware UPSes. v5.1.1 picked the tighter of
nominal × (1 ± 0.10)orinput.transfer.{low,high} ± 5V, then unconditionally labelled the result(±10% nominal, EN 50160 envelope). The log lied whenever the transfer-derived candidate won. On a 120V grid with APC firmware (transfer 106/127), the band landed at 111/122; routine 122.4V utility readings trippedOVER_VOLTAGE_DETECTEDrepeatedly. Threshold derivation is now a single percentage formula. Transfer points stay informational only, still printed on the second startup-log line and quoted in event messages. - Brownout / over-voltage notification text no longer hardcodes
EN 50160 ±10% envelope. The wording is nowoutside the configured ±10% nominal band, which stays accurate under any preset.
Migration notes¶
None for wide-firmware UPSes (APC defaults of 170/280 on 230V, or no transfer points reported at all). Managed / narrow-firmware sites see the warning band widen on default (220/240 → 207/253 on a 230V/215/245 unit). Set voltage_sensitivity: tight to restore approximately the old behaviour, or voltage_sensitivity: normal to acknowledge the new default and silence the startup warning.
[5.1.1] - 2026-04-22¶
Bug-fix release with one small TUI improvement. Bundles fixes from a third-party AI code review (CodeRabbit Pro + Cubic.Dev) of the v5.1.0 codebase. Drop-in upgrade. See git log v5.1.0..v5.1.1 for per-commit detail with reviewer attribution.
Added¶
- TUI events panel: full history with arrow-key scrolling. Drops the 24h window;
↑/↓scrolls one row,PgUp/PgDnten,Home/Endjumps to oldest/newest.<M>still toggles between 8 and 500 visible rows.
Fixed¶
- XCP-ng VM shutdown silently no-op'd.
stop_xcpng_vmspassed UUIDs positionally toxe vm-shutdown uuid=;xeignored them. Now bound viaxargs -I {}withforce=true. - Redundancy
is_localquorum loss never powered off the host. The executor stopped local services and remote peers but skipped the local poweroff command. Now delegates to the coordinator's_handle_local_shutdown. - Multi-UPS state-file write race.
with_suffix('.tmp')collapsed every monitor's atomic-rename temp file onto a shared name. Same fix in the battery-history persist path. - TUI live-blending key mismatch. Graph right edges lagged ~10s behind SQLite because
_STATE_FILE_TO_COLUMNused NUT's dotted lowercase names but the daemon writes uppercase keys. virsh listfailure during VM wait loop produced false success. A wedgedlibvirtdmade the wait loop report "all VMs stopped" and skip force-destroy. Non-zero exit is now treated as transient.- Voltage severity escalation never fired when a brownout crossed the severe threshold AFTER the LOW state was already pending. Severity is now re-evaluated every poll.
- Silent config drops.
notifications.suppress,notifications.voltage_hysteresis_seconds, and per-groupstatisticsin multi-UPS mode were never read from YAML. All three now round-trip. - Legacy
ups-monitorsyslog tag renamed toeneruso journalctl filtering matches the rest of the daemon's output. - Smaller follow-up fixes across
shutdown/,health/,graph.py,logger.py,stats.py,utils.py,cli.py, and bash completion.
Security¶
stop_composeremote-shell injection. Template double-quoting didn't block$()/backticks/${...}.shlex.quotenow runs at the call site.- PyPI publish OIDC token scope. Workflow split so
pip install build twinecan't reach the publishing token; theworkflow_dispatchversion input is validated against PEP-440 before any shell interpolation. - CI supply-chain. Every third-party GitHub Actions invocation is SHA-pinned. nFPM version-pinned + checksum-verified. Dropped
git push -fto gh-pages and|| truemasks on dpkg/rpm install.
Packaging¶
- DEB lifecycle handling. prerm/postrm rewritten with explicit
casefor the full Debian Policy enumeration; the if/elif cascade was stopping the service on every non-removal lifecycle (failed-upgrade,deconfigure, etc.). - postinstall fresh-vs-upgrade disambiguated via
$2; chroot guard onsystemctl daemon-reload;pyproject.tomlreads version fromeneru.version.__version__so a future top-level__init__.pyimport can't break wheel builds.
Examples¶
- Reference / dual-UPS configs no longer ship
StrictHostKeyChecking=noas the default. config-reference.yaml:compose_files: [](wascompose_files:, parsing as YAML null).config-enterprise.yaml: Slack URL switched to the documented Apprise webhook form.config-homelab.yaml:parallel: falsedoes the OPPOSITE of "shut down LAST" — switched toshutdown_order: 2and corrected the misleading comment.
Test quality¶
Autouse fixture redirects StatsConfig / StatsStore defaults to a per-test tmp_path so tests can't leak SQLite files into /var/lib/eneru. Failsafe / redundancy-evaluator tests now exercise real code paths; e2e shell scripts assert exit codes via PIPESTATUS instead of swallowing them.
Migration notes¶
None.
[5.1.0] - 2026-04-21¶
Added¶
- Redundancy Groups: Protect resources fed by multiple UPS sources (dual-PSU servers, A+B feeds). Eneru only fires the group's shutdown when fewer than
min_healthymember UPSes still report healthy.RedundancyGroupConfigmirrorsUPSGroupConfigin full (remote_servers,virtual_machines,containers,filesystems)- Configurable quorum (
min_healthy, default1) plusdegraded_counts_asandunknown_counts_as(defaultcriticalfor fail-safe) - Per-UPS triggers become advisory for redundancy members; independent UPS groups remain byte-identical to single-UPS mode
RedundancyGroupExecutorcomposes the four shutdown mixins, inheriting multi-phase ordering verbatimeneru validatesummarises every redundancy group with its quorum policy- See
docs/redundancy-groups.md
- Per-UPS SQLite Statistics: Every poll buffered in-memory;
StatsWriterthread flushes to per-UPS DB every 10s with 5-min and hourly aggregation- Schema v3: 13 raw NUT metrics + Eneru-derived (
depletion_rate,time_on_battery,connection_state) +events.notification_sentaudit trail - Tier-aware retention: 24h raw + 30d 5-min + 5y hourly (~17 MB steady-state per UPS)
- WAL mode,
synchronous=NORMAL,PRAGMA busy_timeout=500for SD-card-friendly contention bounds - Failure isolation: SQLite outages cannot crash the daemon
- See
docs/statistics.md
- Schema v3: 13 raw NUT metrics + Eneru-derived (
- TUI Graphs:
BrailleGraphrenderer (Unicode U+2800-U+28FF; falls back to block characters onLANG=C)- New keybindings in
eneru monitor:<G>cycles metric (charge / load / voltage / runtime),<T>cycles range (1h / 6h / 24h / 7d / 30d),<U>cycles UPS in multi-UPS mode - Y-axis labels with units, now/min/max stat header,
data: Xh of Yd requestedfooter when sparse - Time-windowed X positioning so a 12h dataset in a 30d view stays in its actual time slice instead of stretching
- Live deque blends SQLite + state-file snapshots so the right edge stays current between flushes
- See
docs/tui-graphs.md
- New keybindings in
- TUI Events Panel from SQLite: Reads each UPS's
eventstable viaStatsStore.open_readonly, merges across UPSes (sorted by timestamp;[label]prefix in multi-UPS), falls back to log-tail parser when no DB exists- New
eneru monitor --once --graph <metric>and--events-onlyflags for scripts and CI
- New
- Multi-Phase Shutdown Ordering (
shutdown_order): Define shutdown phases for remote servers (#4)- Servers with the same
shutdown_orderrun in parallel; different orders run sequentially (ascending) - Enables dependency chains: e.g. compute (1) → storage (2) → network (3)
- Per-server
shutdown_safety_margin(seconds, default60) replaces a hard-coded constant; raise for slow-flushing storage, set0to opt out eneru validateshows a shutdown sequence tree with phase grouping
- Servers with the same
- Voltage Monitoring (#27): Auto-detect, hysteresis, severity bypass, grid-quality framing
- Auto-detect:
input.voltage.nominalsnaps to the nearest standard grid (100/110/115/120/127/200/208/220/230/240V). After ~10 polls, observed median is cross-checked. On disagreement >25V the nominal re-snaps and aVOLTAGE_AUTODETECT_MISMATCHevent is recorded. Catches the US-grid case where firmware reports 230V on a 120V UPS - Grid-quality warnings:
BROWNOUT_DETECTED/OVER_VOLTAGE_DETECTEDthresholds clamp to the tighter of EN 50160 / IEC 60038 ±10% andinput.transfer.{low,high}± 5V. Wide UPS firmware defaults (e.g. APC 170/280 on 230V) no longer mask real brownouts (now warn at 207/253). Managed UPSes with narrow transfer points keep their tighter thresholds - Notification hysteresis:
notifications.voltage_hysteresis_seconds(default 30s) defers notification dispatch on mild deviations. State log line + SQLite event row are always immediate. Sub-30s flap is filtered with aVOLTAGE_FLAP_SUPPRESSEDaudit row - Severity bypass: Deviations >±15% from nominal bypass the dwell entirely with a
(severe, X.X% below/above nominal)tag and anApproaching UPS battery-switch thresholdcallout - Per-event suppression:
notifications.suppress: [...]mutes informational events. Safety-critical names (OVER_VOLTAGE_DETECTED,BROWNOUT_DETECTED,OVERLOAD_ACTIVE,BYPASS_MODE_ACTIVE,ON_BATTERY,CONNECTION_LOST,SHUTDOWN_*) are validator-rejected.events.notification_sentrecords what was actually delivered - No user-tunable voltage thresholds are exposed; a misconfiguration there would mask real over-voltage events
- Auto-detect:
- CLI:
eneru tuiis an alias foreneru monitoreneru completion {bash,zsh,fish}prints a self-contained completion script (kubectl/helm pattern). nfpm.yaml drops them at the FHS paths so they auto-load when the host's completion framework is present. PyPI users source manually withsource <(eneru completion bash)
- Parallel E2E Matrix CI: The single
e2e-testjob is replaced by four parallel matrix jobs (E2E CLI,E2E UPS Single,E2E UPS Multi,E2E Redundancy and Stats). 32 tests total. Wall-clock is bounded by the slowest group instead of the sum
Changed¶
shutdown_orderandparallelare mutually exclusive. Setting both is a hard validation error (previously a warning)- Parallel-phase join is deadline-based. Restores the "dead hosts don't block" guarantee from v4.6 (a phase with N stuck servers no longer waits up to
N × max_timeout) MonitorStateexposes a lock-protected snapshot for cross-thread reads viasnapshot() -> HealthSnapshot. No behaviour change for legacy single-UPS deploymentsMultiUPSCoordinatoralso routes when onlyredundancy_groupsis set (single UPS + 1 redundancy group is now legal)- TUI events panel decoupled from graph timescale. Pressing
<T>no longer re-queries the events list (uses fixed 24h window);<M>still toggles 8 vs 50 max rows - TUI clip-by-display-cell-width. Emoji and CJK glyphs no longer spill past the gold panel's right edge
- Wide-transfer UPSes now receive grid-quality notifications. Operators with default-wide UPS firmware (e.g. APC 170/280 on 230V) previously got essentially zero
BROWNOUT_DETECTED/OVER_VOLTAGE_DETECTEDnotifications because warnings fired only just before the UPS reacted. After 5.1.0 they fire at the EN 50160 ±10% band (207 / 253 on 230V). If your mains is consistently outside that envelope, you'll start seeing it
Fixed¶
- Right-edge artifacts in the TUI events panel. Mobile SSH clients showed stray glyph fragments on the gold panel's right edge. Fixed by using
inschfor the bottom-right corner and padding event rows to full width - Proxmox
stop_proxmox_vms/stop_proxmox_ctswork for non-root SSH users (#4). Templates now invokeqm/pctviasudo. Root-SSH setups unchanged (Proxmox VE ships sudo withroot NOPASSWD: ALL); non-root users add a one-line sudoers entry — see Passwordless sudo → Proxmox VE
Migration notes¶
- Existing single-UPS configs continue to work unchanged.
redundancy_groups:is optional - Stats are on by default. First start creates
/var/lib/eneru/<sanitized-ups-name>.db. Override withstatistics.db_directory: <path>for an SSD on Pi-class hardware. Schema migrations from earlier 5.x are automatic and idempotent (additiveALTER TABLEonly) - Voltage notification behaviour change. Default
voltage_hysteresis_seconds=30mutes sub-30s flaps that previously emailed. Setnotifications.voltage_hysteresis_seconds: 0to restore legacy fire-immediately behaviour. The log line + SQLite event row are always immediate regardless - Branch protection on
mainis a one-time manual operator step. The olde2e-testrequired check is replaced by fourE2E ...matrix checks. Update branch protection in repo settings to swap them - Storage on small devices: per-UPS DB is ~17 MB steady-state. See
docs/statistics.md, "Storage on small devices (Raspberry Pi / SD card)"
Technical details¶
- New modules under
src/eneru/:health_model.py,redundancy.py,stats.py,graph.py. All wired intonfpm.yaml - Test counts: 751 unit tests across 28 files; 32 E2E scenarios across 4 parallel matrix jobs. New coverage in
tests/test_voltage.py(grid snap, auto-detect re-snap, notification hysteresis, severity bypass, threshold clamp),tests/test_stats.py::TestSchemaMigration(additiveALTER TABLEpath), andtests/test_packaging.pywhich asserts everysrc/eneru/**/*.pyis referenced bynfpm.yaml - Schema migration mechanic:
StatsStore._migrate_schemaapplies append-onlyALTER TABLEmigrations gated by_safe_alter(idempotent).meta.schema_versionis bumped after migrations succeed, so a crash mid-migration is replayed safely. Pattern documented insrc/eneru/AGENTS.md"Stats schema evolution" SAFETY_CRITICAL_EVENTS+SUPPRESSIBLE_EVENTSconstants insrc/eneru/config.pyenumerate the notification-suppression policy
[5.0.0] - 2026-04-11¶
Added¶
- Multi-UPS Monitoring: Monitor multiple UPS systems from a single Eneru instance (#4)
- New
UPSGroupConfigwithis_localflag to define which UPS powers the Eneru host - Per-UPS
display_namefor human-readable labels in logs and notifications - Per-UPS trigger overrides with global defaults inheritance
MultiUPSCoordinatorwith thread-per-group architecture and shared notification worker- Defense-in-depth local shutdown coordination (
threading.Lock+ filesystem flag file) prevents duplicate shutdown - Backward-compatible config detection: dict format = single-UPS (legacy), list format = multi-UPS
- Ownership validation: only the
is_localgroup can manage local resources (VMs, containers, filesystems) - New example configuration:
examples/config-dual-ups.yaml
- New
- TUI Dashboard (
eneru monitor): Real-time curses-based monitoring interface- Two-panel layout: gray config/status panel + gold events panel
- Reads daemon state files directly (no NUT polling, no contention with main daemon)
- Color-coded status badges: green (online), red + blink (on battery/critical), magenta (unknown)
- 256-color palette for consistent rendering across SSH sessions
- Interactive controls:
<Q>quit,<R>refresh,<M>toggle more logs --oncemode for scripts and cron health checks (single snapshot, no curses)- Auto-refresh every 5 seconds, configurable with
--interval - Multi-UPS display: shows all UPS groups in a single dashboard
- Battery Anomaly Detection: Identifies unexpected charge drops while on line power
- Detects >20% charge drops within 120 seconds while UPS reports OL/CHRG status
- Sustained-reading confirmation: requires 3 consecutive polls before firing alert
- Firmware jitter filtering for APC, CyberPower, and Ubiquiti UniFi UPS units after OB→OL transitions
- Catches firmware recalibrations, battery aging, and hardware issues
- Sends notification + log warning with charge delta and timing details
- CLI Subcommand Architecture: Modern command-line interface with dedicated subcommands
eneru run— start the UPS monitoring daemoneneru validate— validate configuration file and show overvieweneru monitor— launch the TUI dashboardeneru test-notifications— test notification channelseneru version— display version information- Bare
enerunow shows help instead of starting the daemon (prevents accidental start)
Changed¶
- Config Reference Relocated:
config.yaml→examples/config-reference.yaml(installed path/etc/ups-monitor/unchanged) - Systemd Service Relocated:
eneru.service→packaging/eneru.service(installed path/lib/systemd/system/unchanged) - Systemd Service Updated: ExecStart uses
eneru runsubcommand - Changelog Consolidated: Root
CHANGELOG.mdmerged intodocs/changelog.mdwith complete version history (v1.0 through v4.11) - Test Suite Expanded: 216 → 300 tests (+84 tests, 39% increase)
- 20+ multi-UPS tests: config parsing, trigger inheritance, ownership validation, coordinator routing, lock synchronization
- 26 monitor core tests: status state machine, shutdown triggers, FSD handling, failsafe, shutdown sequencing
- 23 TUI tests: state file parsing, log filtering, status mapping, color rendering,
--onceoutput
- E2E Tests Expanded: 7 → 18 tests with multi-UPS scenarios
- NUT dummy server extended with UPS1 and UPS2 driver entries and per-UPS state files
- New tests: multi-UPS config validation, UPS isolation (one fails, other unaffected), ownership validation, TUI
--once
Technical Details¶
- Thread-per-group model: each UPS group runs in a dedicated thread with its own
UPSGroupMonitorinstance - Per-group state files suffixed with sanitized UPS name (e.g.,
/var/run/ups-monitor.state.UPS1-192-168-1-10) - Single-UPS mode completely unchanged -- full backward compatibility
- Legacy config format (dict) auto-detected and supported alongside new list format
- At most one UPS group can be marked
is_local: true - Remote servers allowed on any group; local resources restricted to the
is_localgroup
Migration Notes¶
CLI invocation changed from bare command to subcommands:
# Before (v4.x)
eneru --config /etc/ups-monitor/config.yaml
eneru --validate-config --config /etc/ups-monitor/config.yaml
eneru --test-notifications --config /etc/ups-monitor/config.yaml
# After (v5.0)
eneru run --config /etc/ups-monitor/config.yaml
eneru validate --config /etc/ups-monitor/config.yaml
eneru test-notifications --config /etc/ups-monitor/config.yaml
- Package users (deb/rpm): Systemd service is updated automatically — no action needed
- Config format: Existing single-UPS configurations work without any modification
- No breaking changes for single-UPS deployments
[4.11.0] - 2026-04-02¶
Added¶
- Connection Loss Grace Period: Delays
CONNECTION_LOSTnotifications for flaky NUT servers- New
ups.connection_loss_grace_periodconfiguration section - Holds notifications during brief outages (default: 60 seconds)
- If connection recovers within the grace period, no notification is sent
- After grace period expires,
CONNECTION_LOSTnotification fires as normal - Flap Detection: Sends a
WARNINGafter repeated grace-period recoveries (default: 5 flaps within 24 hours) - Failsafe Unaffected: Failsafe (connection lost while on battery = immediate shutdown) bypasses the grace period
- New connection state:
GRACE_PERIOD(betweenOKandFAILED) - 26 new tests for grace period scenarios
- New
Changed¶
- Apprise Dependency: Bumped minimum version from 1.9.6 to 1.9.7
- CI Integration Matrix: Added Debian 11 (Bullseye) and Ubuntu 22.04 (Jammy) to integration tests
- Both pass
.debpackage installation with system Python - Debian 11 also tested with pip-in-container (system pip 20.3.4 works)
- Ubuntu 22.04 excluded from pip-in-container: system pip 22.0.2 has a regression with
pyproject.tomldynamic version metadata (workaround: upgrade pip) - Integration tests now cover 9 Linux distributions (was 7)
- Both pass
- CI Python 3.15: Added Python 3.15-dev to test matrix as non-blocking (
continue-on-error) - Documentation: Replaced PNG architecture diagram with SVG, fixed documentation formatting
Technical Details¶
- No breaking changes -- grace period is enabled by default
- All 216 tests pass (190 existing + 26 new)
- Flap counter uses a 24-hour TTL so rare, spread-out flaps do not trigger false warnings
[4.10.0] - 2026-01-18¶
Changed¶
- Modular Architecture: Split monolithic
monitor.pyinto focused modules for better maintainabilityversion.py- Version string (single source of truth)config.py- Configuration dataclasses + ConfigLoaderstate.py- MonitorState dataclasslogger.py- TimezoneFormatter + UPSLoggernotifications.py- NotificationWorker (Apprise integration)utils.py- Helper functions (run_command, command_exists, is_numeric, format_seconds)actions.py- REMOTE_ACTIONS templates for remote pre-shutdown commandsmonitor.py- UPSGroupMonitor class (core daemon logic)cli.py- CLI argument parsing + main()
- Developer Documentation: Add project guidance for Claude Code
Technical Details¶
- No breaking changes to public API or configuration format
- All 190 tests pass, E2E testing, and over two weeks of real-world testing
- Module imports maintain backwards compatibility via
__init__.pyexports
[4.9.0] - 2026-01-06¶
Added¶
- End-to-End (E2E) Test Suite: Comprehensive E2E testing infrastructure with real services
- NUT server with dummy driver for UPS state simulation (8 scenarios)
- SSH target container for remote shutdown command verification
- Docker Compose test environment for local and CI testing
- 7 automated tests covering config validation, power failure detection, SSH shutdown, FSD triggers, voltage events, and notifications
- New GitHub Actions workflow (
.github/workflows/e2e.yml) runs on every push/PR
--exit-after-shutdownCLI Flag: Exit after completing shutdown sequence instead of continuing to monitor- Useful for E2E testing and scripting scenarios
- Enables clean test completion in CI environments
Fixed¶
- Dry-run Mode: Wall broadcast messages are now skipped in dry-run mode to avoid false alerts during testing
[4.8.0] - 2026-01-04¶
Added¶
- PyPI Publishing: Eneru is now available on PyPI (
pip install eneru)- Automated publishing workflow on GitHub releases
- Optional
[notifications]extra for Apprise support - Supports Python 3.9, 3.10, 3.11, 3.12, 3.13, and 3.14
- Integration Testing Workflow: New CI workflow tests package installation on real Linux distributions
.debpackage testing on Debian 12/13, Ubuntu 24.04/26.04.rpmpackage testing on RHEL 8/9/10pip installtesting across 6 Python versions and 3 container distros
- Testing Documentation: New Testing page documenting CI/CD strategy
Changed¶
- Python Version: Minimum required Python version is now 3.9 (was 3.8)
- Installation Options: PyPI is now listed as a primary installation method alongside native packages
Removed¶
- Manual Installation Script: Removed
install.shin favor of native packages and pip
[4.7.0] - 2026-01-03¶
Added¶
- Persistent Notification Retry: Notifications are now retried until successful delivery
- Worker thread persistently retries failed notifications instead of dropping them
- FIFO queue ensures message order is preserved
- New
notifications.retry_intervalconfiguration option (default: 5 seconds) - New
get_queue_size()andget_retry_count()methods for monitoring - Guaranteed delivery during transient network outages (e.g., 30-second power blip)
- Expanded Test Suite: Added 7 new tests for notification retry behavior (185 total)
Changed¶
- Notification Architecture: Evolved from "fire-and-forget" to "persistent retry with ACK"
- Main thread still queues instantly (zero blocking on shutdown operations)
- Worker thread now retries each message until success before moving to next
- Stop signal interrupts retry wait immediately (no delay on shutdown)
- Pending message count now includes in-progress retries in stop log
[4.6.0] - 2025-12-31¶
Added¶
- Modern Python Packaging: Added
pyproject.tomlfor PEP 517/518 compliant packaging- Can now be installed via
pip install .from repository root - Entry point:
enerucommand available after pip install - Optional dependencies:
[notifications],[dev],[docs]
- Can now be installed via
- Package Structure: Reorganized codebase into proper Python package
- Source code moved to
src/eneru/directory __init__.pyexports all public APIs__main__.pyenablespython -m eneruinvocation
- Source code moved to
- Comprehensive Test Suite: Expanded to 178 tests covering:
run_commandandcommand_existshelper functions- CLI validation (
--validate-config,--test-notifications) - Remote pre-shutdown command templating
- Configuration parsing edge cases
Changed¶
- Script Renamed:
ups_monitor.py->src/eneru/monitor.py - Installed Path:
/opt/ups-monitor/ups_monitor.py->/opt/ups-monitor/eneru.py - Test Imports: Updated to use
from eneru import ...instead offrom ups_monitor import ... - Coverage Path: CI now reports coverage for
src/enerumodule
Migration Notes¶
- Existing installations via packages (deb/rpm) will be updated automatically
- Manual installations should update paths in any custom scripts or systemd overrides
- The installed script path changed from
ups_monitor.pytoeneru.py
[4.5.0] - 2025-12-30¶
Added¶
- Docker/Podman Compose Shutdown: Ordered shutdown of compose stacks before individual containers
- New
containers.compose_filesconfiguration for defining compose files to stop - Per-file timeout override support (
stop_timeout) - New
containers.shutdown_all_remaining_containersoption (default: true) - Compose availability check at startup with graceful fallback
- New
- Remote Server Pre-Shutdown Commands: Execute commands on remote servers before shutdown
- New
remote_servers[].pre_shutdown_commandsconfiguration - Predefined actions:
stop_containers,stop_vms,stop_proxmox_vms,stop_proxmox_cts,stop_xcpng_vms,stop_esxi_vms,stop_compose,sync - Custom command support with per-command timeout
- All pre-shutdown commands are best-effort (log and continue on failure)
- New
- Parallel Remote Server Shutdown: Concurrent shutdown of multiple remote servers using threads
- New
remote_servers[].paralleloption (default: true) - Servers with
parallel: falseshutdown sequentially first, then parallel batch runs concurrently - Useful for dependency ordering (e.g., shutdown NAS last after other servers unmount)
- New
Changed¶
- Sync Hardening: Added 2-second sleep after
os.sync()to allow storage controller caches (especially battery-backed RAID) to flush before power is cut - Notification Worker: Now logs pending message count when stopping during shutdown
Fixed¶
- GitHub Release Workflow: Added explicit
tag_nameto gh-release action
[4.4.0] - 2025-12-30¶
Added¶
- Read The Docs Integration: Documentation now hosted on Read The Docs with MkDocs Material theme
- Dark Mode Documentation: Material theme with automatic dark mode support
- Improved Search: Full-text search across all documentation pages
- Code Copy Buttons: One-click copy for all code blocks in documentation
- Tabbed Installation Instructions: Distro-specific tabs for Debian/Ubuntu, RHEL/Fedora, and manual install
- Upgrade/Uninstall Instructions: Previously missing documentation for upgrading and removing Eneru
- Dedicated Documentation Pages:
- Getting Started guide with step-by-step installation
- Configuration reference with all options
- Shutdown Triggers deep-dive with diagrams
- Notifications guide for Apprise setup
- Remote Servers SSH setup guide
- Troubleshooting with real log examples
Changed¶
- README Slimmed Down: Reduced from ~1200 lines to ~145 lines, linking to RTD for details
[4.3.0] - 2025-12-29¶
Added¶
- Native Package Distribution: Official
.deband.rpmpackages for easy installation - APT/DNF Repository: Packages available via GitHub Pages hosted repository for Debian, Ubuntu, RHEL, and Fedora
- Version CLI Option: New
-v/--versionflag to display current version - Version Display: Version now shown at service startup and in notifications
- nFPM Build System: Automated package building using nFPM for both Debian and RPM formats
- GitHub Release Automation: Packages automatically built and published on GitHub releases
- GPG Signed Repository: Repository metadata is GPG signed for security
- requirements.txt: Added for pip-based installations (
PyYAML>=5.4.1,apprise>=1.9.6) - No Docker Documentation: Explained why Eneru runs as a systemd daemon (chicken-and-egg problem with container shutdown)
Changed¶
- Service Name: Renamed from
ups-monitor.servicetoeneru.serviceto avoid conflict with nut-client's service - Installation Method: Package installation (deb/rpm) is now the recommended method
- Service Behavior: Packages install but do not auto-enable or auto-start the service (config must be edited first)
- Config File Handling: Package upgrades preserve existing
/etc/ups-monitor/config.yaml(marked as conffile) - Upgrade Behavior: Smart detection ensures running service restarts on upgrade, stopped service stays stopped
- Service File Location: Moved from
/etc/systemd/system/to/lib/systemd/system/for proper package management
Fixed¶
- Discord Mention Prevention: Added zero-width space after
@symbols in notification messages to prevent Discord from interpreting UPS names (e.g.,UPS@192.168.1.1) as user mentions - APT Repository Structure: Proper
dists/stable/main/binary-all/hierarchy for Debian/Ubuntu compatibility - RPM Repository GPG: Fixed gpgcheck configuration (repo metadata signed, individual packages served over HTTPS)
Migration from v4.2¶
If you installed manually, update your systemd service reference:
# Stop old service
sudo systemctl stop ups-monitor.service
sudo systemctl disable ups-monitor.service
# Remove old service file
sudo rm /etc/systemd/system/ups-monitor.service
# Install new package (recommended) or copy new service file
sudo cp eneru.service /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable eneru.service
sudo systemctl start eneru.service
Installation¶
Install via package manager (after adding the repository):
Or download packages directly from GitHub releases.
[4.2.0] - 2025-12-23¶
Added¶
- Apprise Integration: Support for 100+ notification services (Discord, Slack, Telegram, ntfy, Pushover, Email, Matrix, and more)
- Non-Blocking Notification Architecture: Notifications never delay critical shutdown operations
- Background Notification Worker: Dedicated thread processes notifications asynchronously
--test-notificationsCLI Option: Send test notification to verify configuration- Avatar URL Support: Configurable avatar/icon for supported services (Discord, Slack, etc.)
- Notification Title Option: Optional custom title for multi-instance deployments
- 5-Second Grace Period: Final grace period before shutdown allows queued notifications to send
- Architecture Documentation: ASCII diagram explaining non-blocking notification flow
- Test Suite: Comprehensive pytest test suite with 80+ unit and integration tests
- Code Coverage: Codecov integration for tracking test coverage
Changed¶
- Notification System: Migrated from native Discord webhooks to Apprise library
- Notification Behavior: All shutdown-related notifications are now fire-and-forget
- Configuration Format: New
notifications.urlsarray replacesnotifications.discord.webhook_url - Script Filename: Renamed
ups-monitor.pytoups_monitor.pyfor Python module compatibility - Dependency:
requestslibrary replaced withappriselibrary
Removed¶
- Native Discord Integration: Replaced by Apprise (Discord still fully supported via Apprise)
timeout_blockingConfig: No longer needed with non-blocking architecture
Backwards Compatibility¶
- Legacy
discord.webhook_urlconfiguration automatically converted to Apprise format - Legacy
notifications.discordsection still supported and auto-migrated - All existing functionality preserved
- Service file and install script updated for new filename
Why Non-Blocking Matters¶
During power outages, network connectivity is often unreliable. The previous blocking implementation could delay shutdown by 10-30+ seconds per notification if the network was down. The new architecture queues notifications immediately and processes them in the background, so critical shutdown work never waits on the network.
[4.1.0] - 2025-12-19¶
Added¶
- Native Podman support alongside Docker
- Container runtime auto-detection (prefers Podman over Docker)
- New
containers.runtimeconfiguration option:auto,docker, orpodman - Support for stopping rootless Podman user containers (
include_user_containers) - Comprehensive "Shutdown Triggers Explained" documentation section
- Detailed depletion rate calculation explanation with examples
- Grace period rationale and behavior documentation
- Trigger interaction and overlap analysis
- Recommended configurations (conservative, balanced, aggressive)
- Trigger evaluation flowchart
.gitignorefor common editor and Python files- "The Name" section explaining One Piece reference
Changed¶
- Project rebranded from "UPS Tower" to "Eneru"
- Configuration section
dockerrenamed tocontainers(backwards compatible) --validate-configoutput updated for containers section- Diagram renamed to
eneru-diagram.png - Updated all documentation with Eneru branding
- Changelog revamped to Keep a Changelog format with version comparisons
Fixed¶
--validate-configcrash when referencing oldconfig.dockerattribute
Backwards Compatibility¶
- Existing
docker:configuration sections continue to work - Technical paths unchanged (
ups-monitor.py,/opt/ups-monitor/,/etc/ups-monitor/)
[4.0.0] - 2025-12-17¶
Added¶
- External YAML configuration file support (
/etc/ups-monitor/config.yaml) - Multiple remote server shutdown with per-server custom commands
- Command-line arguments:
--config,--dry-run,--validate-config - Graceful degradation when optional dependencies (PyYAML, requests) missing
- Modular configuration classes
- GitHub Actions workflow for syntax and configuration validation (Python 3.9-3.12)
- Comprehensive README with badges and architecture diagram
- Complete version history with detailed changelogs
- Installation guide with multi-distro support
- Configuration reference with all options documented
- Troubleshooting guide for common issues
- Security considerations and best practices
- CONTRIBUTING.md with:
- Code style guidelines
- Testing requirements
- Commit message conventions
- Development setup instructions
- Pull request process
- Example configurations:
config-minimal.yaml- Basic single-server setupconfig-homelab.yaml- VMs, Docker, NAS, Discord notificationsconfig-enterprise.yaml- Multi-server enterprise deployment
- GitHub Issue Templates:
- Bug report template with environment details
- Feature request template with use case format
- Issue template chooser configuration
- GitHub Pull Request Template with testing checklist
Changed¶
- Configuration now loaded from external file instead of source code
- All features independently toggleable via configuration
- Install script preserves existing configuration on upgrade
- Install script auto-detects package manager (dnf, apt, pacman)
- License changed from Apache 2.0 to MIT
Removed¶
- Hardcoded configuration values in source code
- Single remote NAS limitation (now supports multiple servers)
- Apache 2.0 license (replaced with MIT)
[3.0.0] - 2025-12-15¶
Added¶
- Complete rewrite from Bash to Python 3.9+
- Native JSON handling via
requestslibrary - Native math operations (no external dependencies)
- Python dataclass configuration with type hints
- In-memory state management with file persistence
- Full type hints throughout codebase
- Python exception-based error handling
- Python string formatting (replacing shell variable expansion)
Changed¶
- Language: Bash 4.0+ -> Python 3.9+
- Configuration: Shell variables -> Python dataclass with type hints
- State management: File-based with shell parsing -> In-memory with file persistence
- Error handling: Shell traps -> Python exceptions
Removed¶
jqdependency (JSON now handled natively)bcdependency (math now handled natively)awkdependency (text processing now handled natively)grepdependency (pattern matching now handled natively)
Dependencies¶
- Added:
python3-requests - Removed:
jq,bc,awk,grep
[2.0.0] - 2025-10-22¶
Added¶
- Discord webhook integration with color-coded embeds
- Depletion rate grace period (prevents false triggers on power loss)
- Failsafe Battery Protection (FSB) - shutdown if connection lost while on battery
- FSD (Forced Shutdown) flag detection from UPS
- Configurable mount list with per-mount options (e.g., lazy unmount)
- Overload state tracking with resolution detection
- Bypass mode detection
- AVR (Automatic Voltage Regulation) Boost/Trim detection
- Service stop notifications
- Dynamic VM wait times (up to 30s with force destroy)
- Timeout-protected unmounting (hang-proof)
- Absolute threshold-based voltage monitoring
- Extended depletion tracking (300-second window, 30 samples)
- Stale data detection for connection handling
- Crisis reporting (elevated notifications during shutdown)
- Passwordless sudo configuration guide
Changed¶
- VM shutdown: fixed 10s wait -> dynamic wait up to 30s with force destroy
- NAS authentication: password in script (
sshpass) -> SSH key-based (no passwords stored) - Voltage monitoring: relative change detection -> absolute threshold-based detection
- Depletion rate: 60-second window, 15 samples -> 300-second window, 30 samples with grace period
- Connection handling: basic retry -> stale data detection with failsafe shutdown
- Shutdown triggers: 4 triggers -> 4 triggers + FSD flag detection
- Configuration: minimal -> includes mount options
Security¶
- Removed
sshpassand password storage - SSH key-based authentication for NAS
- Passwordless sudo configuration guide
Dependencies¶
- Added:
jq(for safer JSON generation) - Removed:
sshpass - Required: Bash 4.0+ (for associative arrays)
[1.0.0] - 2025-10-18¶
Added¶
- Initial implementation in Bash
- Basic UPS monitoring via NUT (Network UPS Tools)
- Battery depletion tracking (60-second window, 15 samples)
- Shutdown sequence:
- Virtual Machines (libvirt/KVM)
- Docker containers
- Remote NAS (via SSH with
sshpass)
- Basic logging
- systemd service integration
- 4 shutdown triggers:
- Low battery threshold
- Critical runtime threshold
- Depletion rate threshold
- Extended time on battery