diff --git a/README.md b/README.md index 801cfb7..8d5644a 100644 --- a/README.md +++ b/README.md @@ -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 | diff --git a/agent/BACKLOG.md b/agent/BACKLOG.md index d657c05..330dc03 100644 --- a/agent/BACKLOG.md +++ b/agent/BACKLOG.md @@ -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. diff --git a/agent/HARDWARE-QUEUE.md b/agent/HARDWARE-QUEUE.md index fba5c06..76c2178 100644 --- a/agent/HARDWARE-QUEUE.md +++ b/agent/HARDWARE-QUEUE.md @@ -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 diff --git a/agent/JOURNAL.md b/agent/JOURNAL.md index 08896ff..c40146e 100644 --- a/agent/JOURNAL.md +++ b/agent/JOURNAL.md @@ -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). diff --git a/flake.nix b/flake.nix index 9d06ddb..b644813 100644 --- a/flake.nix +++ b/flake.nix @@ -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, diff --git a/modules/home/battery-notify.nix b/modules/home/battery-notify.nix new file mode 100644 index 0000000..fecd92d --- /dev/null +++ b/modules/home/battery-notify.nix @@ -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" ]; + }; + }; +} diff --git a/modules/home/default.nix b/modules/home/default.nix index 4e94d2c..296941c 100644 --- a/modules/home/default.nix +++ b/modules/home/default.nix @@ -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 diff --git a/modules/home/options.nix b/modules/home/options.nix index 4daf7ed..10dd1f2 100644 --- a/modules/home/options.nix +++ b/modules/home/options.nix @@ -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; }; diff --git a/pkgs/nomarchy-battery-notify/default.nix b/pkgs/nomarchy-battery-notify/default.nix new file mode 100644 index 0000000..edb60df --- /dev/null +++ b/pkgs/nomarchy-battery-notify/default.nix @@ -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; +} diff --git a/pkgs/nomarchy-battery-notify/nomarchy-battery-notify.sh b/pkgs/nomarchy-battery-notify/nomarchy-battery-notify.sh new file mode 100644 index 0000000..33bc0ae --- /dev/null +++ b/pkgs/nomarchy-battery-notify/nomarchy-battery-notify.sh @@ -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