Skip to content

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, and BYPASS remain 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/amd64 and linux/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-sensitive bcrypt dependency.

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.py captures 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, and synoshutdown could still fail with command not found. Eneru now adds the standard system directories and Synology directories to every remote command. The short-lived augment_remote_path option 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-split mounts/urls/compose_files), every safety-critical boolean rejects non-bool values, numeric fields (notifications.timeout/retry_interval, per-compose-file stop_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 misspelled local_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 run eneru validate against your config before restarting the daemon after the upgrade. YAML anchor/extension blocks are supported via the docker-compose convention: prefix them with x- (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 working retry_interval: 2.5 keeps working.
  • A missing explicit --config path is now fatal. run -c missing.yaml exits non-zero and validate -c missing.yaml fails, 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/eneru unwritable 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 and virsh destroy fired immediately; the portable wait now runs inside an elapsed-time timeout that also bounds slow status commands. Local docker/podman compose down now 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_loopback delegate 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 minimal PATH that lacks /usr/syno/sbin (Synology adds it via login-profile scripts an ssh host "cmd" never runs). Eneru can now optionally augment PATH — 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 emits POWER_RESTORED on 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 strip DAEMON_* 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_exists uses shutil.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 to print. 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_FAILURE with intact IPv6 addresses. Audit rows for failed logins and the transition into throttling previously fell back to the generic CONTROL event 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. OFFBYPASS) 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-config flag. Remaining guidance in AGENTS.md, the PR template, and the E2E README now shows the real validate subcommand (ISS-046 follow-up).
  • E2E notification check no longer hard-fails Dependabot PRs. Dependabot runs receive Dependabot secrets, not Actions secrets, so E2E_NOTIFICATION_URL is 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 new api.allowed_hosts list; 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 an api.allowed_hosts entry; 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. NutControlConfig username/password are kept out of repr(), 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/eval under the dashboard tree.
  • Release workflow passes secrets via env: rather than interpolating them into shell text (template-injection pattern); the systemd unit adds ProtectKernelLogs, ProtectControlGroups, ProtectClock, LockPersonality, and ProtectHostname; and the remote Kubernetes manifests set automountServiceAccountToken: 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-token URL, so the persisted credential was unused; persist-credentials: false narrows token exposure across the package-repository build steps (zizmor artipacked).
  • 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/config was pure ambient exposure.
  • eneru validate redacts 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 prints scheme://***.

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 a LIMIT; 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-compose may reject down -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 :latest container 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.yml jobs now carry timeout-minutes; the upsc -l diagnostic cooldown and the neutral-status log interval are named module constants in monitor.py.
  • Internal: runtime-context and delegation helpers moved to a leaf runtime module (removing a domain-core→CLI import inversion); deferred-delivery SQL promoted onto the stats-store API; per-group triggers are deep-copied to avoid shared mutable state; status-token matching and reload error-filtering unified behind single helpers. The standalone CLAUDE.md bridge files are now symlinks to their AGENTS.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/SIGINT arriving 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: and notifications: 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.0 seeds 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); HEAD is supported on /health and /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 pip Dependabot 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.enabled is now honored. A config with notifications: {enabled: false, urls: [...]} no longer keeps notifying; the explicit enabled flag wins over the derived "has URLs" behavior (ISS-024). Configs that relied on the old ignore-the-flag behavior to keep notifications on despite enabled: false must remove the flag.
  • UPS status dispatch is token-set based. OFF, BYPASS, and bare DISCHRG statuses are handled explicitly and CHRG/DISCHRG no 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 integration pytest 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 -y on 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, or TRIM, while bypass and overload use the binary ACTIVE / INACTIVE shape. The dashboard now treats BOOST and TRIM as active AVR states for the line-quality verdict and badge styling instead of only recognizing the literal ACTIVE value.
  • API-started self-tests now finish their dashboard row. The API already wrote a running row 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 legacy running row left behind by earlier 6.1.x builds.

Notes

  • APC Back-UPS models driven by usbhid-ups commonly expose test.battery.start.quick and test.battery.start.deep, not the generic test.battery.start. Set self_test.command per 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.db to 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 migrates default.db into 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 0 for battery.charge on 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.command isn't offered by a UPS — e.g. APC (via usbhid-ups) exposes test.battery.start.quick / .deep, not the bare test.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 0 means "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 own battery_install_date reads "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 -l was only parsed in the standard name - description form, so a UPS that lists bare command names (e.g. Ubiquiti/UniFi: test.battery.start with 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 -l change 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 -l ran 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 the nut_control credentials to upscmd -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_test demanded api.auth.enabled: true even when auth was already active because a user existed in the auth DB. Validation — and the nut_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_test is now its own narrow permission granting exactly self_test.command; you no longer also have to set nut_control.enabled and list the command in nut_control.allowed_commands. API authentication is still required, and the general control surface (arbitrary commands, variable writes) is untouched. Credentials for upscmd still come from nut_control when your upsd requires a login.

Added

  • Passive self-test observation. Eneru reads ups.test.result / ups.test.date on every poll and records a source: device result 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) and runtimeContext (mirrors the existing runtime.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_BATTERYPOWER_RESTORED pair (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 upsc SSL 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 upsc SSL/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_days of history rather than being dragged down by early jitter. Least-squares replacement prediction warns when the score trends toward a configured threshold, and tiered warn_score / critical_score alerts fire on the current score (once each, re-arming on recovery). New battery_health config section, per-UPS overridable, plus BATTERY_REPLACEMENT_PREDICTED and BATTERY_HEALTH_WARNING / _CRITICAL events.
  • Energy tracking. kWh integrated from ups.realpower, falling back to load% × power.nominal (flagged estimated) or a configured energy.nominal_power rating 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; leaving cost_per_kwh unset disables cost entirely. New energy config 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_control allowlist and API auth as manual control. It adapts to whatever upscmd -l exposes and self-disables otherwise, and an in-flight test is persisted and recovered after a restart so a running row is never orphaned. New self_test section (per-UPS overridable), an auth-gated POST /api/v1/ups/{name}/self-test, and a CLI: eneru self-test run (via the daemon API by default, --direct over NUT) and eneru 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 reports section, backed by a shared periodic scheduler whose last-run state lives in the stats meta table 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, and selfTest status blocks on the API and MQTT; Prometheus gauges for health score, energy kWh/cost (with a period label), self-test result, and replacement days remaining (unknown series omitted); battery-health-history and power time-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 -l actually 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 fix ups.name. The configured name and on-disk state are never mutated.

Changed

  • Stats schema v7 (additive, idempotent): real_power / power_nominal sample columns and aggregates, plus battery_health and self_tests tables, trimmed on the existing retention cadence.
  • Remote SSH host-key checking defaults to accept-new (issue #73). A remote_servers entry with no ssh_options no longer inherits OpenSSH's interactive ask (which fails closed under BatchMode), so a fresh remote connects on first contact and learns the key. Bare-metal uses the running user's known_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 explicit StrictHostKeyChecking or UserKnownHostsFile you set is preserved.
  • expected_host_identity auto-populates from any cat /path (issue #70), so marker-file setups no longer duplicate the value or mount over /etc/machine-id.
  • energy, battery_health, self_test, and reports are 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.interval keeps 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: csv delivery 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_score strictly below warn_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.enabled is 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 ... and eneru apikey .... Reads stay open by default so Prometheus and status clients keep working; api.auth.require_for_reads: true gates 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}, and DELETE /api/v1/ups/{name}/events.
  • UPS control via NUT. nut_control wraps the existing upscmd and upsrw tools 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/events supports from/to range 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 authenticated POST /api/v1/config/reload can 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 auth extra for bcrypt. Debian packages depend on python3-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-scanner and does not fit Eneru's config-first model.

Fixed

  • Dashboard first-user sign-in without restart. Creating the first user with eneru user create now 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.command values 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_reads is 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 list now includes last-known remote health from the daemon sidecar when remote health is enabled, without running SSH probes. The non-root use_sudo warning 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 None on SQLite failures, _safe_alter() only swallows duplicate-column errors, and monitor/coordinator shutdown closes stats handles. Deferred stop notification delivery now claims rows as delivering until 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/upsrw failures 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 with eneru user create, and configure nut_control allowlists.
  • 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, and shutdown_command: "shutdown -h now".
  • remote_servers[].use_sudo for non-root SSH users. It prefixes generated privileged actions and the final shutdown command with sudo -n when needed.
  • remote_servers[].pre_shutdown_commands[].mounts for the unmount_filesystems action, so ordinary remote servers can unmount specific filesystems before their final shutdown command. Loopback delegates still derive mounts from the local filesystems.unmount config.
  • 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.
  • /ready now evaluates shutdown capabilities, including native host binaries, remote SSH health, and loopback health for containerized local ownership.
  • Container runtimes suppress wall(1) and the missing logger(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 to 127.0.0.1 failed 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.md Step 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,Z and the real /var/lib/eneru inside the container stayed unmounted — so the carried-over stats DB at /srv/eneru/state/default.db was 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/config returns 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_monitor used to skip _prepare_runtime_config, so the TUI kept reading /var/run/ups-monitor.state while 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_config so 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-id read-only.
  • Containers that drive other remote targets (NAS, secondary hosts) must add an explicit ssh_key_path to each remote_servers entry and bind-mount the operator SSH key into the container — root's ~/.ssh/id_rsa is 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:10001 and keep it at 0400 or 0600; the matching .pub can stay 0644. Loosening the private key to 0644 to "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/*.db to the bind-mount source for /var/lib/eneru (e.g. /srv/eneru/state/) and chown 10001:10001 the 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.yaml to /srv/eneru/config.yaml and 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: true and 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, latest for stable releases, and testing for 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_path for SSH private keys mounted from Docker bind mounts or Kubernetes Secrets.
  • Kubernetes Deployment and Pod examples with non-root security contexts, resource requests/limits, HTTP probes, SSH Secret mounts, and a /var/log/eneru volume 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 -y during builds so release images pick up current Debian fixes.
  • /api/v1 now returns an endpoint index, and JSON 404 responses include the same endpoint list. When Prometheus is disabled, /metrics is omitted from both.
  • The reference Grafana dashboard adds power-event annotations, a battery/load/runtime "now" row, a $ups selector, and a nominal-voltage overlay.
  • eneru validate reports 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_HEALTHY event for the initial UNKNOWN -> HEALTHY baseline next to every DAEMON_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:latest for the latest stable image, ghcr.io/m4r1k/eneru:testing for pre-releases, or pin ghcr.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 stable groupId values, 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 (true by default), writes live state plus sidecar JSON, marks failed probes DEGRADED before FAILED, 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-run executes no configured commands; real execution requires --i-really-want-to-proceed-with-remote-shutdown.
  • Target discovery and full-sequence rehearsal. eneru remote list prints 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-shutdown for 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.json imports with a Prometheus datasource variable.
  • Slow NUT poll visibility. Slow upsc calls 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 DEGRADED during connection grace and become UNKNOWN only 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 --length now 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-health now reads the live manager, /metrics includes redundancy-group remote targets, every metric has HELP/TYPE, /api/v1/events caps limit at 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_command values 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-256color when xterm-ghostty terminfo is missing, and StatsStore.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 --server exact-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.enabled now defaults to true, but only configured remote servers are probed, and probes use the harmless probe_command. Set it to false if 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: /metrics now exports NaN for power-quality fields a UPS does not report (previously 0.0). Alert rules and Grafana panels written against v5.2 that compared eneru_ups_input_voltage < 200 would never fire on missing data; under v5.3 they correctly stay quiet (no false alarm) but a < 200 comparison against NaN is also false, 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 with absent() or eneru_ups_input_voltage == eneru_ups_input_voltage to 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 soft Recommends: because RHEL 8/10 packaging coverage is uneven; if you enable mqtt: on RPM, install paho explicitly: python3 -m pip install paho-mqtt (use --break-system-packages on 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_RESTORED when 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 voltage silently 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-only silently 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

  • --time and <T> apply to the graph only. Use --length for 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-only output for low-priority event types: add --verbose.
  • Scripts using --time to 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 transient systemd-run timer to deliver ~15 s later, cancelled by the next daemon's classifier if a replacement comes up. Containers / K8s / foreground eneru run (no systemd) → always eager. New module deferred_delivery.py + hidden CLI subcommand eneru _deliver-stop.
  • 📦 Upgraded vunknown → v5.2.0 on RPM. New preinstall.sh captures the outgoing version via rpm -q eneru before files unpack (RPM doesn't pass it in $2 the way DEB does). Defensive fallback in lifecycle.classify_startup covers paths that bypass scriptlets.

Documentation

  • docs/notifications.md caught 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 pending row 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 at retry_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 plus meta.last_seen_version in the stats DB.
  • Brief-outage coalescing. A pending ON_BATTERY + POWER_RESTORED pair from the same outage gets folded into one 📊 Brief Power Outage: Ns on battery summary before delivery. Same for the post-power-loss recovery: a Recovered notification 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:SS so multi-day events are distinguishable.

Changed

  • Shutdown notifications: 22 → 2. Dropped the per-_log_message auto-mirror. The channel now carries the headline (🚨 EMERGENCY SHUTDOWN INITIATED! with reason) and a single ✅ Shutdown Sequence Complete summary at the end. The summary now also fires when local_shutdown.enabled=false. Per-step detail stays in journalctl.
  • wall(1) opt-in. Defaults to off via local_shutdown.wall: false. Holdover from the v2 ups-monitor era when the shell was the only channel; Apprise covers the modern path.
  • Schema v3 → v4. New notifications table + index; append-only migration heals partial state via CREATE TABLE IF NOT EXISTS. See src/eneru/AGENTS.md "Stats schema evolution".
  • _send_notification API. Adds a category keyword (default general); used by the coalescer and per-category queries. The blocking parameter is now a back-compat shim because the v5.2 queue is always asynchronous.
  • Banner formatting cleanup. Dropped the ========== BANNER ========== padding from monitor.py and redundancy.py. The ALL CAPS body stays (grep-friendly).

Removed

  • get_retry_count() / get_queue_size() on NotificationWorker, replaced by get_pending_count().
  • time.sleep(5) at the local-shutdown gate, replaced by flush(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.json before systemctl restart, so the next start emits a single 📦 Upgraded notification. Pip users get the same effect via the meta.last_seen_version comparison.
  • Wall broadcasts: if you relied on the v5.1 default of "wall fires on every shutdown", set local_shutdown.wall: true explicitly.
  • 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_sensitivity preset (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_sensitivity is set explicitly in YAML.

Fixed

  • Voltage warning band misleading on narrow-firmware UPSes. v5.1.1 picked the tighter of nominal × (1 ± 0.10) or input.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 tripped OVER_VOLTAGE_DETECTED repeatedly. 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 now outside 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/240207/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/PgDn ten, Home/End jumps to oldest/newest. <M> still toggles between 8 and 500 visible rows.

Fixed

  • XCP-ng VM shutdown silently no-op'd. stop_xcpng_vms passed UUIDs positionally to xe vm-shutdown uuid=; xe ignored them. Now bound via xargs -I {} with force=true.
  • Redundancy is_local quorum 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_COLUMN used NUT's dotted lowercase names but the daemon writes uppercase keys.
  • virsh list failure during VM wait loop produced false success. A wedged libvirtd made 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-group statistics in multi-UPS mode were never read from YAML. All three now round-trip.
  • Legacy ups-monitor syslog tag renamed to eneru so 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_compose remote-shell injection. Template double-quoting didn't block $()/backticks/${...}. shlex.quote now runs at the call site.
  • PyPI publish OIDC token scope. Workflow split so pip install build twine can't reach the publishing token; the workflow_dispatch version 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 -f to gh-pages and || true masks on dpkg/rpm install.

Packaging

  • DEB lifecycle handling. prerm/postrm rewritten with explicit case for 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 on systemctl daemon-reload; pyproject.toml reads version from eneru.version.__version__ so a future top-level __init__.py import can't break wheel builds.

Examples

  • Reference / dual-UPS configs no longer ship StrictHostKeyChecking=no as the default.
  • config-reference.yaml: compose_files: [] (was compose_files:, parsing as YAML null).
  • config-enterprise.yaml: Slack URL switched to the documented Apprise webhook form.
  • config-homelab.yaml: parallel: false does the OPPOSITE of "shut down LAST" — switched to shutdown_order: 2 and 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_healthy member UPSes still report healthy.
    • RedundancyGroupConfig mirrors UPSGroupConfig in full (remote_servers, virtual_machines, containers, filesystems)
    • Configurable quorum (min_healthy, default 1) plus degraded_counts_as and unknown_counts_as (default critical for fail-safe)
    • Per-UPS triggers become advisory for redundancy members; independent UPS groups remain byte-identical to single-UPS mode
    • RedundancyGroupExecutor composes the four shutdown mixins, inheriting multi-phase ordering verbatim
    • eneru validate summarises every redundancy group with its quorum policy
    • See docs/redundancy-groups.md
  • Per-UPS SQLite Statistics: Every poll buffered in-memory; StatsWriter thread 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_sent audit trail
    • Tier-aware retention: 24h raw + 30d 5-min + 5y hourly (~17 MB steady-state per UPS)
    • WAL mode, synchronous=NORMAL, PRAGMA busy_timeout=500 for SD-card-friendly contention bounds
    • Failure isolation: SQLite outages cannot crash the daemon
    • See docs/statistics.md
  • TUI Graphs: BrailleGraph renderer (Unicode U+2800-U+28FF; falls back to block characters on LANG=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 requested footer 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
  • TUI Events Panel from SQLite: Reads each UPS's events table via StatsStore.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-only flags for scripts and CI
  • Multi-Phase Shutdown Ordering (shutdown_order): Define shutdown phases for remote servers (#4)
    • Servers with the same shutdown_order run in parallel; different orders run sequentially (ascending)
    • Enables dependency chains: e.g. compute (1) → storage (2) → network (3)
    • Per-server shutdown_safety_margin (seconds, default 60) replaces a hard-coded constant; raise for slow-flushing storage, set 0 to opt out
    • eneru validate shows a shutdown sequence tree with phase grouping
  • Voltage Monitoring (#27): Auto-detect, hysteresis, severity bypass, grid-quality framing
    • Auto-detect: input.voltage.nominal snaps 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 a VOLTAGE_AUTODETECT_MISMATCH event is recorded. Catches the US-grid case where firmware reports 230V on a 120V UPS
    • Grid-quality warnings: BROWNOUT_DETECTED / OVER_VOLTAGE_DETECTED thresholds clamp to the tighter of EN 50160 / IEC 60038 ±10% and input.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 a VOLTAGE_FLAP_SUPPRESSED audit row
    • Severity bypass: Deviations >±15% from nominal bypass the dwell entirely with a (severe, X.X% below/above nominal) tag and an Approaching UPS battery-switch threshold callout
    • 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_sent records what was actually delivered
    • No user-tunable voltage thresholds are exposed; a misconfiguration there would mask real over-voltage events
  • CLI:
    • eneru tui is an alias for eneru monitor
    • eneru 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 with source <(eneru completion bash)
  • Parallel E2E Matrix CI: The single e2e-test job 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_order and parallel are 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)
  • MonitorState exposes a lock-protected snapshot for cross-thread reads via snapshot() -> HealthSnapshot. No behaviour change for legacy single-UPS deployments
  • MultiUPSCoordinator also routes when only redundancy_groups is 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_DETECTED notifications 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 insch for the bottom-right corner and padding event rows to full width
  • Proxmox stop_proxmox_vms / stop_proxmox_cts work for non-root SSH users (#4). Templates now invoke qm / pct via sudo. Root-SSH setups unchanged (Proxmox VE ships sudo with root 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 with statistics.db_directory: <path> for an SSD on Pi-class hardware. Schema migrations from earlier 5.x are automatic and idempotent (additive ALTER TABLE only)
  • Voltage notification behaviour change. Default voltage_hysteresis_seconds=30 mutes sub-30s flaps that previously emailed. Set notifications.voltage_hysteresis_seconds: 0 to restore legacy fire-immediately behaviour. The log line + SQLite event row are always immediate regardless
  • Branch protection on main is a one-time manual operator step. The old e2e-test required check is replaced by four E2E ... 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 into nfpm.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 (additive ALTER TABLE path), and tests/test_packaging.py which asserts every src/eneru/**/*.py is referenced by nfpm.yaml
  • Schema migration mechanic: StatsStore._migrate_schema applies append-only ALTER TABLE migrations gated by _safe_alter (idempotent). meta.schema_version is bumped after migrations succeed, so a crash mid-migration is replayed safely. Pattern documented in src/eneru/AGENTS.md "Stats schema evolution"
  • SAFETY_CRITICAL_EVENTS + SUPPRESSIBLE_EVENTS constants in src/eneru/config.py enumerate 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 UPSGroupConfig with is_local flag to define which UPS powers the Eneru host
    • Per-UPS display_name for human-readable labels in logs and notifications
    • Per-UPS trigger overrides with global defaults inheritance
    • MultiUPSCoordinator with 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_local group can manage local resources (VMs, containers, filesystems)
    • New example configuration: examples/config-dual-ups.yaml
  • 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
    • --once mode 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 daemon
    • eneru validate — validate configuration file and show overview
    • eneru monitor — launch the TUI dashboard
    • eneru test-notifications — test notification channels
    • eneru version — display version information
    • Bare eneru now shows help instead of starting the daemon (prevents accidental start)

Changed

  • Config Reference Relocated: config.yamlexamples/config-reference.yaml (installed path /etc/ups-monitor/ unchanged)
  • Systemd Service Relocated: eneru.servicepackaging/eneru.service (installed path /lib/systemd/system/ unchanged)
  • Systemd Service Updated: ExecStart uses eneru run subcommand
  • Changelog Consolidated: Root CHANGELOG.md merged into docs/changelog.md with 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, --once output
  • 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 UPSGroupMonitor instance
  • 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_local group

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_LOST notifications for flaky NUT servers
    • New ups.connection_loss_grace_period configuration 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_LOST notification fires as normal
    • Flap Detection: Sends a WARNING after 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 (between OK and FAILED)
    • 26 new tests for grace period scenarios

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 .deb package 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.toml dynamic version metadata (workaround: upgrade pip)
    • Integration tests now cover 9 Linux distributions (was 7)
  • 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.py into focused modules for better maintainability
    • version.py - Version string (single source of truth)
    • config.py - Configuration dataclasses + ConfigLoader
    • state.py - MonitorState dataclass
    • logger.py - TimezoneFormatter + UPSLogger
    • notifications.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 commands
    • monitor.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__.py exports

[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-shutdown CLI 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
    • .deb package testing on Debian 12/13, Ubuntu 24.04/26.04
    • .rpm package testing on RHEL 8/9/10
    • pip install testing 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.sh in 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_interval configuration option (default: 5 seconds)
    • New get_queue_size() and get_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.toml for PEP 517/518 compliant packaging
    • Can now be installed via pip install . from repository root
    • Entry point: eneru command available after pip install
    • Optional dependencies: [notifications], [dev], [docs]
  • Package Structure: Reorganized codebase into proper Python package
    • Source code moved to src/eneru/ directory
    • __init__.py exports all public APIs
    • __main__.py enables python -m eneru invocation
  • Comprehensive Test Suite: Expanded to 178 tests covering:
    • run_command and command_exists helper 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 of from ups_monitor import ...
  • Coverage Path: CI now reports coverage for src/eneru module

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.py to eneru.py

[4.5.0] - 2025-12-30

Added

  • Docker/Podman Compose Shutdown: Ordered shutdown of compose stacks before individual containers
    • New containers.compose_files configuration for defining compose files to stop
    • Per-file timeout override support (stop_timeout)
    • New containers.shutdown_all_remaining_containers option (default: true)
    • Compose availability check at startup with graceful fallback
  • Remote Server Pre-Shutdown Commands: Execute commands on remote servers before shutdown
    • New remote_servers[].pre_shutdown_commands configuration
    • 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)
  • Parallel Remote Server Shutdown: Concurrent shutdown of multiple remote servers using threads
    • New remote_servers[].parallel option (default: true)
    • Servers with parallel: false shutdown sequentially first, then parallel batch runs concurrently
    • Useful for dependency ordering (e.g., shutdown NAS last after other servers unmount)

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_name to 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 .deb and .rpm packages for easy installation
  • APT/DNF Repository: Packages available via GitHub Pages hosted repository for Debian, Ubuntu, RHEL, and Fedora
  • Version CLI Option: New -v/--version flag 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.service to eneru.service to 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):

# Debian/Ubuntu
apt install eneru

# RHEL/Fedora
dnf install eneru

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-notifications CLI 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.urls array replaces notifications.discord.webhook_url
  • Script Filename: Renamed ups-monitor.py to ups_monitor.py for Python module compatibility
  • Dependency: requests library replaced with apprise library

Removed

  • Native Discord Integration: Replaced by Apprise (Discord still fully supported via Apprise)
  • timeout_blocking Config: No longer needed with non-blocking architecture

Backwards Compatibility

  • Legacy discord.webhook_url configuration automatically converted to Apprise format
  • Legacy notifications.discord section 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.runtime configuration option: auto, docker, or podman
  • 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
  • .gitignore for common editor and Python files
  • "The Name" section explaining One Piece reference

Changed

  • Project rebranded from "UPS Tower" to "Eneru"
  • Configuration section docker renamed to containers (backwards compatible)
  • --validate-config output 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-config crash when referencing old config.docker attribute

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 setup
    • config-homelab.yaml - VMs, Docker, NAS, Discord notifications
    • config-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 requests library
  • 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

  • jq dependency (JSON now handled natively)
  • bc dependency (math now handled natively)
  • awk dependency (text processing now handled natively)
  • grep dependency (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 sshpass and 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