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
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:
@@ -214,6 +214,7 @@ two tables below are split along exactly that line.
|
|||||||
| `nomarchy.waybar.enable` | `true` | Nomarchy's Waybar |
|
| `nomarchy.waybar.enable` | `true` | Nomarchy's Waybar |
|
||||||
| `nomarchy.rofi.enable` | `true` | Themed rofi launcher + `nomarchy-menu` dispatcher |
|
| `nomarchy.rofi.enable` | `true` | Themed rofi launcher + `nomarchy-menu` dispatcher |
|
||||||
| `nomarchy.swaync.enable` | `true` | swaync notifications, themed |
|
| `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.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.yazi.enable` | `true` | yazi TUI file manager, themed + curated plugins |
|
||||||
| `nomarchy.osd.enable` | `true` | swayosd on-screen display for volume/brightness/mute |
|
| `nomarchy.osd.enable` | `true` | swayosd on-screen display for volume/brightness/mute |
|
||||||
|
|||||||
@@ -28,11 +28,8 @@ next, in what order*.
|
|||||||
|
|
||||||
### 13. Small niceties batch (one slice per iteration)
|
### 13. Small niceties batch (one slice per iteration)
|
||||||
Each is a small, self-contained polish item in the existing patterns
|
Each is a small, self-contained polish item in the existing patterns
|
||||||
(idle-inhibit/caffeine slice shipped 2026-07-04 — generated bar +
|
(shipped: idle-inhibit/caffeine 2026-07-04; low-battery notifications
|
||||||
summer-day parity + cheatsheet row):
|
2026-07-05 — sysfs watcher + checks.battery-notify):
|
||||||
- **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).
|
|
||||||
- **Color picker:** `hyprpicker` → clipboard as a Tools › entry +
|
- **Color picker:** `hyprpicker` → clipboard as a Tools › entry +
|
||||||
`SUPER+CTRL` bind; pairs naturally with theme work.
|
`SUPER+CTRL` bind; pairs naturally with theme work.
|
||||||
|
|
||||||
|
|||||||
@@ -140,6 +140,12 @@ QA machine), the **T14s** (webcam case).
|
|||||||
now reflects the real state (the `= true` comparison fix).
|
now reflects the real state (the `= true` comparison fix).
|
||||||
— 2026-07-04 Bernardo: PASS — theme changes committed and the
|
— 2026-07-04 Bernardo: PASS — theme changes committed and the
|
||||||
toggle self-committed.
|
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 dev box only
|
||||||
- [ ] **AMD runtime bits** — VA-API (`vainfo` → radeonsi), amd-pstate EPP
|
- [ ] **AMD runtime bits** — VA-API (`vainfo` → radeonsi), amd-pstate EPP
|
||||||
|
|||||||
@@ -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)
|
## 2026-07-04 — caffeine/idle-inhibit toggle (iteration #23, item 13 slice)
|
||||||
- **Task:** BACKLOG NEXT#13 first slice — idle_inhibitor in the
|
- **Task:** BACKLOG NEXT#13 first slice — idle_inhibitor in the
|
||||||
generated bar (summer-night had it; reverse parity gap).
|
generated bar (summer-night had it; reverse parity gap).
|
||||||
|
|||||||
63
flake.nix
63
flake.nix
@@ -84,6 +84,7 @@
|
|||||||
themesDir = ./themes;
|
themesDir = ./themes;
|
||||||
};
|
};
|
||||||
nomarchy-doctor = final.callPackage ./pkgs/nomarchy-doctor { };
|
nomarchy-doctor = final.callPackage ./pkgs/nomarchy-doctor { };
|
||||||
|
nomarchy-battery-notify = final.callPackage ./pkgs/nomarchy-battery-notify { };
|
||||||
};
|
};
|
||||||
|
|
||||||
nixosModules.nomarchy = {
|
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
|
# Memory-pressure protection (oom.nix): earlyoom must kill a
|
||||||
# runaway allocator BEFORE the kernel thrash point — and must not
|
# runaway allocator BEFORE the kernel thrash point — and must not
|
||||||
# take anything else with it. A bystander unit survives the kill,
|
# take anything else with it. A bystander unit survives the kill,
|
||||||
|
|||||||
29
modules/home/battery-notify.nix
Normal file
29
modules/home/battery-notify.nix
Normal 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" ];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -26,6 +26,7 @@
|
|||||||
./viewers.nix # zathura (Stylix-themed) + imv — PDF/image viewing
|
./viewers.nix # zathura (Stylix-themed) + imv — PDF/image viewing
|
||||||
./mime.nix # default applications (mimeapps.list), degrades with the suite
|
./mime.nix # default applications (mimeapps.list), degrades with the suite
|
||||||
./recording.nix # nomarchy-record: screen recording behind Capture + the bar ⏺
|
./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
|
# Clipboard history (wl-paste watcher); browsed via the SUPER+CTRL+V
|
||||||
|
|||||||
@@ -242,6 +242,7 @@ in
|
|||||||
waybar.enable = lib.mkEnableOption "Nomarchy's Waybar configuration" // { default = true; };
|
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; };
|
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; };
|
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; };
|
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; };
|
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; };
|
osd.enable = lib.mkEnableOption "swayosd on-screen display for volume/brightness/mute" // { default = true; };
|
||||||
|
|||||||
12
pkgs/nomarchy-battery-notify/default.nix
Normal file
12
pkgs/nomarchy-battery-notify/default.nix
Normal 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;
|
||||||
|
}
|
||||||
58
pkgs/nomarchy-battery-notify/nomarchy-battery-notify.sh
Normal file
58
pkgs/nomarchy-battery-notify/nomarchy-battery-notify.sh
Normal 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
|
||||||
Reference in New Issue
Block a user