# HP battery charge limiter (HP ENVY x360 / similar, board 81AD). # # HP firmware exposes no percentage-based charge threshold (no # charge_control_end_threshold sysfs on this machine). Instead the DSDT # exposes ACPI charge-mode methods in the root scope (\): # # \SBCC -> miscellaneous battery charge control register writes # \SBCO -> set battery charge operation: # 0x0500 = inhibit charge (stop charging / hold level) # 0x0000 = auto (normal charging) <-- restores on 81AD # 0x0200 = force discharge (drain while on AC) # \GBCC -> read back charge control # \GBCO -> read back charge operation mode # # These are driven from userspace through the `acpi_call` kernel module # (/proc/acpi/call). The "limit to 80%" is implemented as a policy on top: # a root systemd poller inhibits charge once capacity >= 80% and re-enables # auto charging below ~75%. This mirrors what the HP kernel-driver RFC # (platform/x86: hp-wmi charge behaviour support) describes leaving to # userspace while only the mode toggles live in the firmware. # # Control from the shell / CLI: # battery-charge-limit on|off|status # `on`/`off` persist the choice to /var/lib/hp-battery-charge-limit/state # and are applied immediately; the systemd service re-applies the policy # on a poll tick. petere has NOPASSWD sudo so the QuickShell cog can call # `sudo battery-charge-limit on/off` directly. { config, lib, pkgs, ... }: let cfg = config.my.hardware.hpBatteryLimit; stateDir = "/var/lib/hp-battery-charge-limit"; stateFile = "${stateDir}/state"; # charge mode constants read from Arg0 by SBCO (high byte). acpiInhibit = ''\\SBCO 0x0500''; acpiAuto = ''\\SBCO 0x0000''; controlScript = pkgs.writeShellScriptBin "battery-charge-limit" '' set -eu state_dir=${stateDir} state_file=${stateFile} battery=/sys/class/power_supply/BAT0 acpi_proc=/proc/acpi/call stop_at=${toString cfg.stopAt} resume_at=${toString cfg.resumeAt} acpi() { # $1 = ACPI method + args, e.g. "\SBCO 0x0500" if [ ! -e "$acpi_proc" ]; then echo "acpi_call module not loaded" >&2 return 1 fi printf '%s\n' "$1" > "$acpi_proc" || return 1 cat "$acpi_proc" || true } read_capacity() { cat "$battery/capacity" 2>/dev/null | tr -d '\n' || echo 0 } ensure_state() { mkdir -p "$state_dir" if [ ! -f "$state_file" ]; then echo "${if cfg.defaultOn then "on" else "off"}" > "$state_file" fi } get_state() { ensure_state cat "$state_file" } apply_policy() { local state capacity state=$(get_state) capacity=$(read_capacity) case "$state" in on) if [ "$capacity" -ge "$stop_at" ]; then acpi "${acpiInhibit}" || true elif [ "$capacity" -le "$resume_at" ]; then acpi "${acpiAuto}" || true fi ;; *) # off (or unknown) -> normal charging acpi "${acpiAuto}" || true ;; esac } case "$1" in on) ensure_state echo on > "$state_file" apply_policy echo on ;; off) ensure_state echo off > "$state_file" apply_policy echo off ;; status) get_state ;; apply) apply_policy ;; *) echo "usage: battery-charge-limit {on|off|status|apply}" >&2 exit 2 ;; esac ''; in { options.my.hardware.hpBatteryLimit = { enable = lib.mkEnableOption "HP battery charge limiter (stop charging at stopAt%, re-enable below resumeAt)."; stopAt = lib.mkOption { type = lib.types.int; default = 80; description = "Charge capacity % at which charging is inhibited."; }; resumeAt = lib.mkOption { type = lib.types.int; default = 75; description = "Charge capacity % below which auto charging is re-enabled."; }; defaultOn = lib.mkOption { type = lib.types.bool; default = false; description = "Start with limiting active on first boot (state file not present yet)."; }; pollInterval = lib.mkOption { type = lib.types.int; default = 120; description = "Seconds between policy re-applications by the systemd poller."; }; }; config = lib.mkIf cfg.enable { # acpi_call -> /proc/acpi/call so userspace can invoke SBCC/SBCO. boot.extraModulePackages = [ config.boot.kernelPackages.acpi_call ]; boot.kernelModules = [ "acpi_call" ]; environment.systemPackages = [ controlScript # Compatibility alias: older QuickShell configs referenced the # hp-prefixed binary name; keep it working until all hosts redeploy. (pkgs.writeShellScriptBin "hp-battery-charge-limit" '' exec ${controlScript}/bin/battery-charge-limit "$@" '') ]; systemd.services.battery-charge-limit = { description = "HP battery charge limiter (${toString cfg.stopAt}% cap)"; wantedBy = [ "multi-user.target" ]; after = [ "systemd-modules-load.service" ]; wants = [ "systemd-modules-load.service" ]; serviceConfig = { Type = "simple"; ExecStart = pkgs.writeShellScript "battery-charge-limit-loop" '' set -eu trap 'exit 0' TERM INT while true; do ${controlScript}/bin/battery-charge-limit apply >/dev/null || true sleep ${toString cfg.pollInterval} done ''; Restart = "on-failure"; RemainAfterExit = false; }; }; }; }