Files
Nix-Vibe/modules/hardware/thinkpad-battery-limit.nix

144 lines
4.2 KiB
Nix

# ThinkPad battery charge limiter (native sysfs thresholds).
#
# Unlike the HP module (modules/hardware/hp-battery-limit.nix), ThinkPad
# firmware exposes the battery charge thresholds directly via sysfs:
#
# /sys/class/power_supply/BAT0/charge_control_start_threshold (resume point)
# /sys/class/power_supply/BAT0/charge_control_end_threshold (stop point)
#
# Writing a value < 100 to the end threshold stops charging at that capacity;
# charging resumes once capacity drops to/below the start threshold. The EC
# persists these across reboots (firmware NVRAM), so unlike the HP /proc magic
# no periodic poller is required — a one-shot `apply` at boot re-asserts the
# persisted choice in case the firmware was reset.
#
# Control from the shell / CLI (same interface as the HP module, so the
# QuickShell cog can drive either host):
# battery-charge-limit on|off|status|apply
# `on`/`off` persist the choice to /var/lib/battery-charge-limit/state and are
# applied immediately. 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.thinkpadBatteryLimit;
stateDir = "/var/lib/battery-charge-limit";
stateFile = "${stateDir}/state";
sysfs = "/sys/class/power_supply/BAT0";
controlScript = pkgs.writeShellScriptBin "battery-charge-limit" ''
set -eu
state_dir=${stateDir}
state_file=${stateFile}
start_threshold=${sysfs}/charge_control_start_threshold
end_threshold=${sysfs}/charge_control_end_threshold
capacity=${sysfs}/capacity
stop_at=${toString cfg.stopAt}
resume_at=${toString cfg.resumeAt}
read_end_threshold() {
cat "$end_threshold" 2>/dev/null || echo 100
}
ensure_state() {
mkdir -p "$state_dir"
if [ ! -f "$state_file" ]; then
# Infer the current firmware state so the toggle reflects reality on
# first run (e.g. a limit set from BIOS/Vantage persists in the EC).
if [ "$(read_end_threshold)" -lt "$stop_at" ]; then
echo on > "$state_file"
else
echo off > "$state_file"
fi
fi
}
get_state() {
ensure_state
cat "$state_file"
}
apply_policy() {
local state
state=$(get_state)
case "$state" in
on)
# Enable the limit: resume charging below resume_at, stop at stop_at.
echo "$resume_at" > "$start_threshold"
echo "$stop_at" > "$end_threshold"
;;
*)
# off (or unknown) -> full charge range (0..100).
echo 0 > "$start_threshold"
echo 100 > "$end_threshold"
;;
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.thinkpadBatteryLimit = {
enable = lib.mkEnableOption "ThinkPad 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 (charge_control_end_threshold).";
};
resumeAt = lib.mkOption {
type = lib.types.int;
default = 75;
description = "Charge capacity % below which auto charging is re-enabled (charge_control_start_threshold).";
};
};
config = lib.mkIf cfg.enable {
environment.systemPackages = [ controlScript ];
# One-shot re-assert at boot (ThinkPad EC persists thresholds, so no
# poller is needed; this only recovers from firmware resets).
systemd.services.battery-charge-limit = {
description = "ThinkPad battery charge limiter (${toString cfg.stopAt}% cap)";
wantedBy = [ "multi-user.target" ];
serviceConfig = {
Type = "oneshot";
RemainAfterExit = true;
ExecStart = "${controlScript}/bin/battery-charge-limit apply";
};
};
};
}