187 lines
5.6 KiB
Nix
187 lines
5.6 KiB
Nix
# 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 <n> -> miscellaneous battery charge control register writes
|
|
# \SBCO <n> -> 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;
|
|
};
|
|
};
|
|
};
|
|
}
|