Created
July 29, 2026 22:41
-
-
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/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