feat(battery): low-battery notifications at the bar's 25/10% thresholds (item 13 slice)
All checks were successful
Check / eval (push) Successful in 3m4s

The bar colored the battery at 25/10% but nothing notified. New
shellcheck-gated watcher (pkgs/nomarchy-battery-notify, overlay) polls
the same sysfs the bar reads — type=Battery, scope!=Device — and fires
one toast per downward crossing (normal at 25%, critical at 10%; swaync
keeps critical up until dismissed), re-armed by charging. Self-gates on
battery presence (the powerprofile pattern), so desktops get a silent
no-op. HM user unit behind nomarchy.batteryNotify.enable (default on)
with libnotify on the unit PATH; notify-send resolves from PATH so the
VM check can shim it. poweralertd rejected: its UPower thresholds
(20/5) wouldn't match the bar, and it toasts every plug/unplug.

Verified: V0 (flake check, bash -n); V1 (template-home builds, unit in
the generation); V2 GREEN (new checks.battery-notify, test_power fake
battery: self-gate, silence at 80%, one toast per crossing, critical
urgency, charging re-arms). Remains: V3 — real swaync toast on a
draining laptop (HARDWARE-QUEUE).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Bernardo Magri
2026-07-05 15:11:06 +01:00
parent 821032e81c
commit fb78c814cc
10 changed files with 195 additions and 5 deletions

View File

@@ -214,6 +214,7 @@ two tables below are split along exactly that line.
| `nomarchy.waybar.enable` | `true` | Nomarchy's Waybar |
| `nomarchy.rofi.enable` | `true` | Themed rofi launcher + `nomarchy-menu` dispatcher |
| `nomarchy.swaync.enable` | `true` | swaync notifications, themed |
| `nomarchy.batteryNotify.enable` | `true` | Low-battery toasts at the bar's thresholds — 25% low, 10% critical (stays up until dismissed); silent no-op on machines without a battery |
| `nomarchy.idle.enable` | `true` | hyprlock + hypridle (idle lock 5 min, display off 10, suspend 30) |
| `nomarchy.yazi.enable` | `true` | yazi TUI file manager, themed + curated plugins |
| `nomarchy.osd.enable` | `true` | swayosd on-screen display for volume/brightness/mute |

View File

@@ -28,11 +28,8 @@ next, in what order*.
### 13. Small niceties batch (one slice per iteration)
Each is a small, self-contained polish item in the existing patterns
(idle-inhibit/caffeine slice shipped 2026-07-04 — generated bar +
summer-day parity + cheatsheet row):
- **Low-battery notifications:** the bar colors at 25/10% but nothing
*notifies*; gate on `power.laptop` (poweralertd, or a small upower
watcher consistent with how the bar reads state).
(shipped: idle-inhibit/caffeine 2026-07-04; low-battery notifications
2026-07-05 — sysfs watcher + checks.battery-notify):
- **Color picker:** `hyprpicker` → clipboard as a Tools entry +
`SUPER+CTRL` bind; pairs naturally with theme work.

View File

@@ -140,6 +140,12 @@ QA machine), the **T14s** (webcam case).
now reflects the real state (the `= true` comparison fix).
— 2026-07-04 Bernardo: PASS — theme changes committed and the
toggle self-committed.
- [ ] **Low-battery toasts (item 13 slice)** — on battery, drain past
25%: one "Battery low" toast; past 10%: a critical "Battery
critical" toast that stays up until dismissed (swaync); plug in,
drain again → it re-notifies; no repeat toasts while it just keeps
draining. (Crossing logic VM-verified — this checks the real
swaync rendering in a session.)
## AMD dev box only
- [ ] **AMD runtime bits** — VA-API (`vainfo` → radeonsi), amd-pstate EPP

View File

@@ -17,6 +17,28 @@ Template:
---
## 2026-07-05 — low-battery notifications (iteration #24, item 13 slice)
- **Task:** BACKLOG NEXT#13 second slice — the bar colors the battery
at 25/10% but nothing *notified*.
- **Did:** pkgs/nomarchy-battery-notify (shellcheck-gated watcher:
polls the same sysfs the bar reads — type=Battery, scope≠Device —
one toast per downward crossing: normal@25, critical@10; re-armed by
charging; self-gates on battery presence → desktop no-op) + overlay.
HM unit in modules/home/battery-notify.nix behind
nomarchy.batteryNotify.enable (default on), graphical-session-bound,
libnotify on the unit PATH (notify-send resolved from PATH so the
check can shim it). README row. Chose the tiny watcher over
poweralertd: its UPower thresholds (20/5) wouldn't match the bar,
and it toasts every plug/unplug.
- **Verified:** V0; V1 — template-home builds, unit present; V2 GREEN —
new checks.battery-notify (test_power fake battery): self-gate exits
0 without a battery, silence at 80%, exactly one low toast crossing
25, one critical-urgency toast crossing 10, charging re-arms → a
second drain toasts again.
- **Pending:** V3 queued (real toast through swaync while draining).
- **Next suggestion:** item 13 final slice — hyprpicker color picker
(Tools entry + SUPER+CTRL bind).
## 2026-07-04 — caffeine/idle-inhibit toggle (iteration #23, item 13 slice)
- **Task:** BACKLOG NEXT#13 first slice — idle_inhibitor in the
generated bar (summer-night had it; reverse parity gap).

View File

@@ -84,6 +84,7 @@
themesDir = ./themes;
};
nomarchy-doctor = final.callPackage ./pkgs/nomarchy-doctor { };
nomarchy-battery-notify = final.callPackage ./pkgs/nomarchy-battery-notify { };
};
nixosModules.nomarchy = {
@@ -331,6 +332,68 @@
'';
};
# The low-battery watcher's crossing logic: one toast per downward
# crossing (normal at 25%, critical at 10%), re-armed by charging.
# test_power fakes a battery whose capacity/status we script;
# notify-send is shimmed to a log via PATH (the package resolves
# it from PATH for exactly this reason). What stays on-hardware:
# the real toast rendering through swaync in a session.
battery-notify = pkgs.testers.runNixOSTest {
name = "nomarchy-battery-notify";
nodes.machine = { ... }: {
boot.kernelModules = [ "test_power" ]; # fake battery + Mains
environment.systemPackages = [ pkgs.nomarchy-battery-notify ];
};
testScript = ''
machine.wait_for_unit("multi-user.target")
# notify-send shim: log the calls instead of needing a session bus.
machine.succeed(
"mkdir -p /shim && printf '#!/bin/sh\necho \"$*\" >> /tmp/notifications\n'"
" > /shim/notify-send && chmod +x /shim/notify-send"
)
# Self-gate: with the fake battery gone, the watcher exits 0 at once.
machine.succeed("modprobe -r test_power")
machine.succeed("timeout 5 nomarchy-battery-notify 1")
machine.succeed("modprobe test_power")
# Healthy level while discharging: silence.
machine.succeed("echo discharging > /sys/module/test_power/parameters/battery_status")
machine.succeed("echo 80 > /sys/module/test_power/parameters/battery_capacity")
machine.succeed(
"systemd-run --unit=batwatch --setenv=PATH=/shim:/run/current-system/sw/bin"
" /run/current-system/sw/bin/nomarchy-battery-notify 1"
)
machine.sleep(3)
machine.fail("test -s /tmp/notifications")
# Crossing 25% fires the low toast exactly once, not per poll.
machine.succeed("echo 20 > /sys/module/test_power/parameters/battery_capacity")
machine.wait_until_succeeds("grep -q 'Battery low' /tmp/notifications")
machine.succeed("echo 15 > /sys/module/test_power/parameters/battery_capacity")
machine.sleep(3)
assert machine.succeed("grep -c 'Battery low' /tmp/notifications").strip() == "1"
# Crossing 10% escalates to a critical-urgency toast, once.
machine.succeed("echo 5 > /sys/module/test_power/parameters/battery_capacity")
machine.wait_until_succeeds("grep -q 'Battery critical' /tmp/notifications")
assert "critical" in machine.succeed("grep 'Battery critical' /tmp/notifications")
machine.sleep(3)
assert machine.succeed("grep -c 'Battery critical' /tmp/notifications").strip() == "1"
# Back on the charger re-armed: the next drain notifies again.
machine.succeed("echo charging > /sys/module/test_power/parameters/battery_status")
machine.succeed("echo 80 > /sys/module/test_power/parameters/battery_capacity")
machine.sleep(3)
machine.succeed("echo discharging > /sys/module/test_power/parameters/battery_status")
machine.succeed("echo 20 > /sys/module/test_power/parameters/battery_capacity")
machine.wait_until_succeeds(
"test \"$(grep -c 'Battery low' /tmp/notifications)\" = 2"
)
'';
};
# Memory-pressure protection (oom.nix): earlyoom must kill a
# runaway allocator BEFORE the kernel thrash point — and must not
# take anything else with it. A bystander unit survives the kill,

View File

@@ -0,0 +1,29 @@
# Low-battery notifications (nomarchy.batteryNotify) — the bar colors the
# battery @warn/@bad at 25/10% (waybar.nix battery.states) but nothing
# *notified*; this watcher fires a toast at those same thresholds. Session-
# side and self-gating on a battery being present (the powerprofile-script
# pattern — no system→home coupling), so it's a silent no-op on desktops.
# swaync shows critical toasts until dismissed (timeout-critical = 0), so
# the 10% one can't slip by unseen. Logic lives in pkgs/nomarchy-battery-
# notify (overlay), where checks.battery-notify exercises it.
{ config, lib, pkgs, ... }:
{
config = lib.mkIf config.nomarchy.batteryNotify.enable {
systemd.user.services.nomarchy-battery-notify = {
Unit = {
Description = "Low-battery notifications (25/10%, the bar's thresholds)";
PartOf = [ "graphical-session.target" ];
After = [ "graphical-session.target" ];
};
Service = {
# The script resolves notify-send from PATH (that's what the VM
# check shims); here libnotify provides the real one.
Environment = "PATH=${lib.makeBinPath [ pkgs.libnotify ]}";
ExecStart = "${pkgs.nomarchy-battery-notify}/bin/nomarchy-battery-notify";
Restart = "on-failure";
};
Install.WantedBy = [ "graphical-session.target" ];
};
};
}

View File

@@ -26,6 +26,7 @@
./viewers.nix # zathura (Stylix-themed) + imv — PDF/image viewing
./mime.nix # default applications (mimeapps.list), degrades with the suite
./recording.nix # nomarchy-record: screen recording behind Capture + the bar ⏺
./battery-notify.nix # low-battery toasts at the bar's 25/10% thresholds
];
# Clipboard history (wl-paste watcher); browsed via the SUPER+CTRL+V

View File

@@ -242,6 +242,7 @@ in
waybar.enable = lib.mkEnableOption "Nomarchy's Waybar configuration" // { default = true; };
rofi.enable = lib.mkEnableOption "Nomarchy's themed rofi launcher + the nomarchy-menu dispatcher" // { default = true; };
swaync.enable = lib.mkEnableOption "swaync notifications, themed from the state file" // { default = true; };
batteryNotify.enable = lib.mkEnableOption "low-battery notifications at the bar's thresholds (25% low, 10% critical that one stays up until dismissed); self-gating, a silent no-op on machines without a battery" // { default = true; };
idle.enable = lib.mkEnableOption "hyprlock + hypridle (idle lock, display off, suspend)" // { default = true; };
yazi.enable = lib.mkEnableOption "the yazi TUI file manager, themed with a curated plugin set" // { default = true; };
osd.enable = lib.mkEnableOption "swayosd on-screen display for volume/brightness/mute" // { default = true; };

View File

@@ -0,0 +1,12 @@
# Low-battery notification watcher (nomarchy.batteryNotify). A package
# (not an inline script) so the VM check can exercise the crossing logic
# on a minimal node against a test_power fake battery — same reasoning
# as nomarchy-doctor. libnotify is deliberately NOT a runtimeInput: the
# user unit puts it on PATH; the check shims notify-send instead.
{ writeShellApplication, coreutils }:
writeShellApplication {
name = "nomarchy-battery-notify";
runtimeInputs = [ coreutils ];
text = builtins.readFile ./nomarchy-battery-notify.sh;
}

View File

@@ -0,0 +1,58 @@
# Low-battery notifications — the session-side complement to the bar's
# battery colors: Waybar paints the module @warn at 25% and @bad at 10%
# (waybar.nix battery.states) but nothing *notified*. This watcher polls
# the same sysfs state the bar reads and fires exactly one notification
# per downward crossing — normal at 25%, critical at 10% (swaync keeps
# critical toasts up until dismissed) — re-arming once the charger lands.
#
# notify-send is resolved from PATH on purpose (not a runtimeInput): the
# unit (battery-notify.nix) provides libnotify; the VM check shims it to
# capture the calls.
#
# usage: nomarchy-battery-notify [poll-interval-seconds]
interval="${1:-30}"
warn=25
crit=10
# System batteries only: type Battery, and not scope=Device (how
# peripheral batteries — mice, headsets — report). Name-agnostic on
# purpose: BAT0, dual-battery BAT0+BAT1, CMB0, test_battery all match.
batteries() {
local d
for d in /sys/class/power_supply/*/; do
[ "$(cat "$d/type" 2>/dev/null)" = Battery ] || continue
[ "$(cat "$d/scope" 2>/dev/null || echo System)" = Device ] && continue
printf '%s\n' "$d"
done
}
# Self-gate: no battery, not a laptop — a clean no-op on desktops.
[ -n "$(batteries)" ] || exit 0
state=ok # ok → warn → crit, one notification per downward crossing
while :; do
sum=0 n=0 discharging=
while IFS= read -r d; do
cap=$(cat "$d/capacity" 2>/dev/null) || continue
sum=$((sum + cap)); n=$((n + 1))
[ "$(cat "$d/status" 2>/dev/null)" = Discharging ] && discharging=1
done < <(batteries)
if [ "$n" -gt 0 ] && [ -n "$discharging" ]; then
cap=$((sum / n))
if [ "$cap" -le "$crit" ] && [ "$state" != crit ]; then
notify-send -u critical -a Nomarchy "Battery critical: ${cap}%" \
"Plug in now."
state=crit
elif [ "$cap" -le "$warn" ] && [ "$state" = ok ]; then
notify-send -u normal -a Nomarchy "Battery low: ${cap}%" \
"Consider plugging in."
state=warn
fi
else
# On the charger (or nothing readable): re-arm for the next drain.
state=ok
fi
sleep "$interval"
done