Skip to content

Instantly share code, notes, and snippets.

@AustinSaintAubin
Created July 29, 2026 22:41
Show Gist options
  • Select an option

  • Save AustinSaintAubin/b9d71db31c3431cfc270c6e02d878231 to your computer and use it in GitHub Desktop.

Select an option

Save AustinSaintAubin/b9d71db31c3431cfc270c6e02d878231 to your computer and use it in GitHub Desktop.
ThinkPad fan control: timed/staged/indefinite PWM override with desktop notifications for Fedora KDE Plasma
#!/usr/bin/env bash
# Author: Austin St. Aubin <austinsaintaubin@gmail.com>
# Date: 2026-07-29
#
# fan-toggle: Toggle ThinkPad fans between MAX (for a duration) and AUTO.
# Usage:
# fan-toggle [DURATION] [SPEED]
# DURATION: 90, 90s, 5m, 1h, inf (or "indefinite") (default 5m)
# SPEED : 0-255 or N% (default 100%)
# inf duration skips the auto-revert timer — runs until toggled off.
# fan-toggle [DURATION] stage [SPEED...]
# fan-toggle stage [DURATION] [SPEED...]
# Cycles through the given speeds one per invocation, then back to
# AUTO on the next call after the last stage (repeat to start over).
# SPEED list: space- or comma-separated, each 0-255 or N% (default
# list: 25% 50% 75% 100%).
# fan-toggle on [DURATION] [SPEED]
# Force ON regardless of current state (stops/replaces whatever's
# active first) — for scripted callers that want "make sure it's at
# X", not toggle semantics.
# fan-toggle off
# fan-toggle auto
# Force AUTO regardless of current state. off/auto are synonyms.
#
# Keyboard shortcut: F12 / Favorite
# /usr/bin/sudo -n /usr/local/sbin/fan-toggle 6h stage
#
# Examples:
# fan-toggle # max for 5m (default), then auto
# fan-toggle 90s # max 90 seconds
# fan-toggle 10m 80% # 80% duty for 10 minutes
# fan-toggle 120 200 # raw PWM 200/255 for 120s
# fan-toggle 6h 100% # on/off toggle at 100%
# fan-toggle 6h stage 33% 66% 100% # each call advances a stage, then auto
# fan-toggle 6h stage # same, using default 25/50/75/100 stages
# fan-toggle inf 50% # 50% indefinitely (toggle again for auto)
# fan-toggle on 6h 100% # force on at 100%, no matter what's active
# fan-toggle auto # force back to auto, no matter what's active
set -euo pipefail
STATE_DIR=/run/fanblast
STATE_FILE="$STATE_DIR/state"
err() { printf 'ERROR: %s\n' "$*" >&2; exit 1; }
info() { printf '[fan-toggle] %s\n' "$*"; }
need_root() { [[ $EUID -eq 0 ]] || err "Run as root (pkexec or sudo)."; }
# Desktop popup for whoever owns the active graphical session. This script
# always runs as root (need_root), and D-Bus checks the connecting peer's
# real credentials (SO_PEERCRED), not just socket file permissions — so
# root must actually drop to the target uid via runuser rather than just
# pointing at the socket, or the connection is rejected with "Error
# sending credentials ... Broken pipe". Fully detached from stdio and
# timeout-guarded so a notification problem can never hold the invoking
# terminal/pty open or block the fan action.
notify() {
command -v notify-send >/dev/null 2>&1 || return 0
local title="$1" body="$2" bus uid user_name
for bus in /run/user/*/bus; do
[[ -S "$bus" ]] || continue
uid="$(basename "$(dirname "$bus")")"
[[ "$uid" == "0" ]] && continue
user_name="$(id -nu "$uid" 2>/dev/null)" || continue
timeout 2s runuser -u "$user_name" -- env DBUS_SESSION_BUS_ADDRESS="unix:path=$bus" \
notify-send -a "fan-toggle" -t 4000 -- "$title" "$body" </dev/null >/dev/null 2>&1 &
disown 2>/dev/null || true
return 0
done
}
parse_duration() {
local in="${1:-5m}" num unit
case "${in,,}" in inf|indefinite) echo "inf"; return;; esac
[[ "$in" =~ ^([0-9]+)([smhSMH]?)$ ]] || err "Invalid duration '$in' (use 90, 90s, 5m, 1h, inf)."
num="${BASH_REMATCH[1]}"; unit="${BASH_REMATCH[2],,}"
case "$unit" in ""|"s") echo "$num";; m) echo $((num*60));; h) echo $((num*3600));; esac
}
parse_speed() {
local in="${1:-100%}" val
if [[ "$in" =~ ^([0-9]{1,3})%$ ]]; then
local pct="${BASH_REMATCH[1]}"; ((pct>=0 && pct<=100)) || err "Percent 0–100."
val=$(( (pct * 255 + 50) / 100 ))
elif [[ "$in" =~ ^[0-9]{1,3}$ ]]; then
val="$in"; ((val>=0 && val<=255)) || err "PWM 0–255."
else
err "Invalid SPEED '$in' (use 0–255 or N%)."
fi
echo "$val"
}
pwm_pct() { echo $(( ($1 * 100 + 127) / 255 )); }
fmt_duration() {
local s="$1"
if [[ "$s" == "inf" ]]; then echo "indefinitely"; return; fi
if (( s > 0 && s % 3600 == 0 )); then echo "$((s/3600))h"
elif (( s > 0 && s % 60 == 0 )); then echo "$((s/60))m"
else echo "${s}s"
fi
}
# Return one of: none | integrity | confidentiality
lockdown_mode() {
local l="/sys/kernel/security/lockdown"
[[ -r "$l" ]] || { echo "none"; return; }
local s; s="$(cat "$l")"
if [[ "$s" =~ \[(none|integrity|confidentiality)\] ]]; then
echo "${BASH_REMATCH[1]}"
else
# some kernels only print the active word
echo "$s"
fi
}
check_lockdown() {
case "$(lockdown_mode)" in
integrity|confidentiality) err "Kernel lockdown is active; manual fan control is blocked." ;;
esac
}
ensure_fan_control() {
local p="/sys/module/thinkpad_acpi/parameters/fan_control"
if [[ -r "$p" && "$(cat "$p")" == "Y" ]]; then return 0; fi
# reload with param on-the-fly
modprobe -r thinkpad_acpi 2>/dev/null || true
modprobe thinkpad_acpi fan_control=1 || err "Failed to load thinkpad_acpi fan_control=1."
[[ -r "$p" && "$(cat "$p")" == "Y" ]] || err "fan_control still off; add kernel arg thinkpad_acpi.fan_control=1 and reboot."
}
find_hwmon() {
local f
for f in /sys/class/hwmon/*/name; do
[[ -f "$f" ]] || continue
[[ "$(cat "$f" 2>/dev/null)" == "thinkpad" ]] && { dirname "$f"; return 0; }
done
return 1
}
revert_auto() {
local d="$1"
[[ -w "$d/pwm1_enable" ]] || return 0
echo 2 > "$d/pwm1_enable" 2>/dev/null || echo 0 > "$d/pwm1_enable" 2>/dev/null || true
}
# Set PWM now; prints the hwmon dir used.
activate_pwm() {
local pwm="$1"
check_lockdown
ensure_fan_control
local HWMON_DIR; HWMON_DIR="$(find_hwmon)" || err "ThinkPad hwmon not found."
[[ -w "$HWMON_DIR/pwm1_enable" && -w "$HWMON_DIR/pwm1" ]] || err "Cannot write $HWMON_DIR/pwm1(_enable)."
mkdir -p "$STATE_DIR"
echo 1 > "$HWMON_DIR/pwm1_enable"
echo "$pwm" > "$HWMON_DIR/pwm1"
echo "$HWMON_DIR"
}
# Start the background auto-revert timer; prints its pid. On expiry, pops
# a desktop notification with the given label. seconds="inf" skips the
# timer entirely (runs until manually stopped) and returns the sentinel
# pid "-"; the existing `kill "$ST_PID" 2>/dev/null || true` call sites
# already no-op safely on that.
#
# Detached from stdin/stdout/stderr: without this, the timer subshell keeps
# the invoking terminal's pty open for its entire sleep, so e.g. `sudo -n`
# (which proxies a pty) never sees EOF and hangs until the timer fires.
start_timer() {
local seconds="$1" hwmon="$2" expiry_label="$3"
if [[ "$seconds" == "inf" ]]; then
echo "-"
return
fi
(
sleep "$seconds"
revert_auto "$hwmon"
rm -f "$STATE_FILE"
notify "Fan: AUTO" "$expiry_label"
) </dev/null >/dev/null 2>&1 &
disown 2>/dev/null || true
echo "$!"
}
activate_max_for() {
local seconds="$1" pwm="$2"
local hwmon; hwmon="$(activate_pwm "$pwm")"
local pid; pid="$(start_timer "$seconds" "$hwmon" "${seconds}s timer expired, back to AUTO.")"
echo "single $pid $pwm $hwmon" > "$STATE_FILE"
info "ON: PWM=$pwm for $(fmt_duration "$seconds") (hwmon: $hwmon). Toggle again to AUTO now."
notify "Fan: ON — $(pwm_pct "$pwm")%" "PWM $pwm for $(fmt_duration "$seconds"). Toggle again for AUTO."
}
activate_stage() {
local seconds="$1" pwm="$2" index="$3" total="$4" csv="$5"
local hwmon; hwmon="$(activate_pwm "$pwm")"
local pid; pid="$(start_timer "$seconds" "$hwmon" "Stage ${index}/${total} timer expired, back to AUTO.")"
echo "stage $pid $pwm $hwmon $index $seconds $csv" > "$STATE_FILE"
info "ON: stage ${index}/${total} PWM=$pwm for $(fmt_duration "$seconds") (hwmon: $hwmon). Toggle again for next stage."
notify "Fan: stage ${index}/${total} — $(pwm_pct "$pwm")%" "PWM $pwm for $(fmt_duration "$seconds"). Toggle again for next stage."
}
# Populate ST_* globals from STATE_FILE. Returns 1 if no state file.
read_state() {
ST_MODE="" ST_PID="" ST_PWM="" ST_HWMON="" ST_INDEX="" ST_DURATION="" ST_CSV=""
[[ -f "$STATE_FILE" ]] || return 1
local -a f
read -r -a f < "$STATE_FILE"
ST_MODE="${f[0]:-}"
ST_PID="${f[1]:-}"
ST_PWM="${f[2]:-}"
ST_HWMON="${f[3]:-}"
if [[ "$ST_MODE" == "stage" ]]; then
ST_INDEX="${f[4]:-}"
ST_DURATION="${f[5]:-}"
ST_CSV="${f[6]:-}"
fi
return 0
}
# Kill timer + revert to auto, no message. No-op if no state.
stop_silent() {
if read_state; then
kill "$ST_PID" 2>/dev/null || true
[[ -n "$ST_HWMON" ]] && revert_auto "$ST_HWMON"
rm -f "$STATE_FILE"
fi
}
deactivate_now() {
if read_state; then
kill "$ST_PID" 2>/dev/null || true
[[ -n "$ST_HWMON" ]] && revert_auto "$ST_HWMON"
rm -f "$STATE_FILE"
info "OFF: returned to AUTO."
notify "Fan: AUTO" "Returned to automatic control."
else
local d; d="$(find_hwmon 2>/dev/null || true)"
[[ -n "$d" ]] && revert_auto "$d"
info "OFF: AUTO (no active state)."
notify "Fan: AUTO" "Already on automatic control."
fi
}
# stage_toggle DURATION [SPEED...]
# Each call advances one stage; after the last stage, the next call reverts
# to AUTO. A subsequent call after that starts a fresh cycle at stage 1.
stage_toggle() {
local duration_arg="$1"; shift
local -a list=()
if [[ $# -eq 0 ]]; then
list=(25% 50% 75% 100%)
elif [[ $# -eq 1 && "$1" == *,* ]]; then
IFS=',' read -r -a list <<< "$1"
else
list=("$@")
fi
if read_state && [[ "$ST_MODE" == "stage" ]]; then
IFS=',' read -r -a list <<< "$ST_CSV"
local total=${#list[@]}
local next_index=$((ST_INDEX + 1))
kill "$ST_PID" 2>/dev/null || true
if (( next_index > total )); then
revert_auto "$ST_HWMON"
rm -f "$STATE_FILE"
info "OFF: returned to AUTO (stage cycle complete)."
notify "Fan: AUTO" "Stage cycle complete — back to automatic control."
return
fi
local pwm; pwm="$(parse_speed "${list[$((next_index - 1))]}")"
activate_stage "$ST_DURATION" "$pwm" "$next_index" "$total" "$ST_CSV"
return
fi
stop_silent # a non-stage toggle was active; clear it before starting fresh
local duration_s; duration_s="$(parse_duration "$duration_arg")"
local total=${#list[@]}
local csv; csv="$(IFS=,; echo "${list[*]}")"
local pwm; pwm="$(parse_speed "${list[0]}")"
activate_stage "$duration_s" "$pwm" 1 "$total" "$csv"
}
# Force ON at DURATION/SPEED regardless of current state — stops whatever's
# active first (single or stage) instead of toggling it off. For scripted/
# automated callers that want "make sure it's at X", not "flip it".
force_on() {
local duration_arg="$1" speed_arg="$2"
stop_silent
local duration_s; duration_s="$(parse_duration "$duration_arg")"
local pwm; pwm="$(parse_speed "$speed_arg")"
activate_max_for "$duration_s" "$pwm"
}
main() {
need_root
# fan-toggle off | fan-toggle auto — force AUTO regardless of current state
if [[ "${1:-}" == "off" || "${1:-}" == "auto" ]]; then
deactivate_now; exit 0
fi
# fan-toggle on [DURATION] [SPEED] — force ON regardless of current state
if [[ "${1:-}" == "on" ]]; then
shift
force_on "${1:-5m}" "${2:-100%}"
exit 0
fi
# fan-toggle stage [DURATION] [SPEED...]
if [[ "${1:-}" == "stage" || "${1:-}" == "stages" ]]; then
shift
local duration_arg="5m"
if [[ "${1:-}" =~ ^[0-9]+[smhSMH]?$ || "${1,,}" == "inf" || "${1,,}" == "indefinite" ]]; then
duration_arg="$1"; shift
fi
stage_toggle "$duration_arg" "$@"
exit 0
fi
# fan-toggle DURATION stage [SPEED...]
if [[ "${2:-}" == "stage" || "${2:-}" == "stages" ]]; then
local duration_arg="$1"
shift 2
stage_toggle "$duration_arg" "$@"
exit 0
fi
if [[ -f "$STATE_FILE" ]]; then
deactivate_now; exit 0
fi
local duration_s pwm
duration_s="$(parse_duration "${1:-5m}")"
pwm="$(parse_speed "${2:-100%}")"
activate_max_for "$duration_s" "$pwm"
}
main "$@"
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment