Both plans were generated during their respective implementation runs but never committed. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
13 KiB
Fan-Profile "Auto" Mode Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Add a virtual auto strategy to scripts/fan-profile.sh that mirrors the active power-profiles-daemon profile to a corresponding fw-fanctrl strategy, reconciling on every waybar refresh.
Architecture: Three additions to a single bash script — a state-file constant, a profile→strategy mapping function, and two new conditional branches (one in the --menu handler, one in the status emit). State lives in ~/.local/state/fan-profile-auto. No new files, no new daemons, no fw-fanctrl config edits.
Tech Stack: bash, fw-fanctrl, powerprofilesctl, wofi, waybar.
Spec: docs/superpowers/specs/2026-05-01-fan-profile-auto-design.md
Verification model: Bash script — no unit-test framework. Each task ends with manual bash scripts/fan-profile.sh invocations under representative state (auto-on / auto-off / power-profile mismatch / powerprofilesctl failure simulated by overriding PATH), inspecting JSON output and side-effects on fw-fanctrl.
Mapping (locked from spec):
| power-profiles-daemon | fw-fanctrl |
|---|---|
power-saver |
lazy |
balanced |
medium |
performance |
agile |
File Structure
- Modify:
scripts/fan-profile.sh— add a constant, a function, and modify two existing branches.
No other files.
Task 1: Add state-file constant and mapping function
After this task, the script has a place to store auto-mode state and a pure function to map a power profile to a fan strategy. No behavior change yet — these are unused until Tasks 2 and 3 wire them in.
Files:
-
Modify:
scripts/fan-profile.sh(insert after the#!/bin/bashshebang on line 1) -
Step 1: Add the constant and function at the top of the script
Open scripts/fan-profile.sh. After the shebang line (#!/bin/bash) and the blank line that follows, insert:
STATE_FILE="${XDG_STATE_HOME:-$HOME/.local/state}/fan-profile-auto"
map_profile_to_strategy() {
case "$1" in
power-saver) echo lazy ;;
balanced) echo medium ;;
performance) echo agile ;;
*) return 1 ;;
esac
}
The script's first 4 lines should now read:
#!/bin/bash
STATE_FILE="${XDG_STATE_HOME:-$HOME/.local/state}/fan-profile-auto"
map_profile_to_strategy() {
- Step 2: Sanity-check the function
Run:
bash -c 'source scripts/fan-profile.sh; map_profile_to_strategy power-saver; map_profile_to_strategy balanced; map_profile_to_strategy performance; map_profile_to_strategy bogus; echo "exit=$?"'
Expected output:
lazy
medium
agile
exit=1
(bogus produces no stdout and exits 1; the other three each print one strategy name on its own line.)
Note: sourcing the script will also execute the rest of it (the status emit). That's fine — its output gets mixed in. Only inspect that the four lines above appear in the right order somewhere.
- Step 3: Verify status emit still works
Run the existing status path:
bash scripts/fan-profile.sh
Expected: a single JSON line like {"text": " medium", "tooltip": "Fan strategy: medium\nSpeed: 28%\nClick to change", "class": "medium"} — same shape as before. The script's behavior is unchanged because nothing references STATE_FILE or map_profile_to_strategy yet.
- Step 4: Commit
git add scripts/fan-profile.sh
git commit -m "fan-profile: add state path and profile→strategy mapping (unused yet)"
Task 2: Wire auto into the wofi menu
After this task, picking auto from the menu enables auto mode (creates the state file and applies the mapped strategy immediately). Picking any other strategy disables auto mode (deletes the state file). The status emit still behaves as before — auto-mode rendering comes in Task 3.
Files:
-
Modify:
scripts/fan-profile.sh— replace the existing--menublock (currently lines ~3–16 of the post-Task-1 file) with the version below. -
Step 1: Replace the
--menubranch
Find the existing block:
if [ "$1" = "--menu" ]; then
CHOICE=$(fw-fanctrl print list 2>/dev/null \
| grep "^-" \
| sed 's/^- //' \
| wofi --dmenu \
--prompt "Fan Strategy:" \
--width 260 \
--height 300 \
--hide-scroll \
--no-actions \
--insensitive)
[ -n "$CHOICE" ] && fw-fanctrl use "$CHOICE" && pkill -RTMIN+9 waybar
exit
fi
Replace it with:
if [ "$1" = "--menu" ]; then
STRATS=$(fw-fanctrl print list 2>/dev/null | grep "^-" | sed 's/^- //')
CHOICE=$(printf 'auto\n%s\n' "$STRATS" \
| wofi --dmenu \
--prompt "Fan Strategy:" \
--width 260 \
--height 320 \
--hide-scroll \
--no-actions \
--insensitive)
[ -z "$CHOICE" ] && exit
if [ "$CHOICE" = "auto" ]; then
mkdir -p "$(dirname "$STATE_FILE")"
touch "$STATE_FILE"
PROFILE=$(powerprofilesctl get 2>/dev/null) \
&& MAPPED=$(map_profile_to_strategy "$PROFILE") \
&& fw-fanctrl use "$MAPPED"
else
rm -f "$STATE_FILE"
fw-fanctrl use "$CHOICE"
fi
pkill -RTMIN+9 waybar
exit
fi
Three substantive changes:
auto\nis prepended to the strategy list piped into wofi.- Empty selection (user cancels) now exits early instead of falling through.
autoand non-autoselections are routed differently — auto creates the state file and applies the mapped strategy; everything else removes the state file (if present) and uses fw-fanctrl directly.
The wofi --height was bumped from 300 → 320 to accommodate the extra row.
- Step 2: Verify the menu branch in dry-run
You can't fully exercise wofi without a display, but you can verify the strategy list construction:
bash -c 'STRATS=$(fw-fanctrl print list 2>/dev/null | grep "^-" | sed "s/^- //"); printf "auto\n%s\n" "$STRATS"'
Expected output (one strategy per line, auto first):
auto
laziest
lazy
medium
agile
very-agile
deaf
aeolus
- Step 3: Verify state-file logic by simulating "auto" selection
Manually run the auto branch:
mkdir -p "${XDG_STATE_HOME:-$HOME/.local/state}"
rm -f "${XDG_STATE_HOME:-$HOME/.local/state}/fan-profile-auto"
bash -c 'source scripts/fan-profile.sh; CHOICE=auto
if [ "$CHOICE" = "auto" ]; then
mkdir -p "$(dirname "$STATE_FILE")"
touch "$STATE_FILE"
echo "state file created at: $STATE_FILE"
fi'
ls -la "${XDG_STATE_HOME:-$HOME/.local/state}/fan-profile-auto"
Expected: file exists.
Now simulate selecting a real strategy:
rm -f "${XDG_STATE_HOME:-$HOME/.local/state}/fan-profile-auto"
touch "${XDG_STATE_HOME:-$HOME/.local/state}/fan-profile-auto"
bash -c 'source scripts/fan-profile.sh; CHOICE=lazy
if [ "$CHOICE" != "auto" ]; then
rm -f "$STATE_FILE"
echo "state file removed"
fi'
ls -la "${XDG_STATE_HOME:-$HOME/.local/state}/fan-profile-auto" 2>&1
Expected: cannot access ... No such file or directory (file removed). Then clean up:
rm -f "${XDG_STATE_HOME:-$HOME/.local/state}/fan-profile-auto"
- Step 4: Commit
git add scripts/fan-profile.sh
git commit -m "fan-profile: route 'auto' menu choice through state file"
Task 3: Render auto mode in the status emit
After this task, when the state file exists the status emit reconciles fw-fanctrl to the power profile's mapped strategy and renders auto in the bar. When the state file is absent, behavior is unchanged.
Files:
-
Modify:
scripts/fan-profile.sh— insert a new conditional block before the existing status emit (theOUTPUT=$(fw-fanctrl print 2>/dev/null)line and everything after it). -
Step 1: Insert the auto-mode status block
Find this section in the script (it's the part that runs when no --menu arg was passed):
OUTPUT=$(fw-fanctrl print 2>/dev/null)
CURRENT=$(echo "$OUTPUT" | awk -F"'" '/^Strategy:/{print $2}')
SPEED=$(echo "$OUTPUT" | awk '/^Speed:/{print $2}')
Immediately before that OUTPUT= line, insert:
if [ -f "$STATE_FILE" ]; then
PROFILE=$(powerprofilesctl get 2>/dev/null)
MAPPED=$(map_profile_to_strategy "$PROFILE" 2>/dev/null)
if [ -n "$MAPPED" ]; then
ACTIVE=$(fw-fanctrl print 2>/dev/null | awk -F"'" '/^Strategy:/{print $2}')
[ "$ACTIVE" != "$MAPPED" ] && fw-fanctrl use "$MAPPED" >/dev/null 2>&1
printf '{"text": " auto", "tooltip": "Auto fan strategy\\nProfile: %s → %s\\nClick to change", "class": "auto"}\n' "$PROFILE" "$MAPPED"
else
printf '{"text": " auto (?)", "tooltip": "Auto: power-profiles-daemon unreachable or unknown profile\\nClick to change", "class": "auto"}\n'
fi
exit
fi
(Trailing blank line included so there's separation between the new block and the existing OUTPUT= line.)
- Step 2: Verify auto-off path is unchanged
Make sure the state file does NOT exist:
rm -f "${XDG_STATE_HOME:-$HOME/.local/state}/fan-profile-auto"
bash scripts/fan-profile.sh
Expected: same JSON as before Task 1 — {"text": " <strategy>", "tooltip": "Fan strategy: <strategy>\nSpeed: <pct>%\nClick to change", "class": "<level>"}.
- Step 3: Verify auto-on happy path
Create the state file and run the script:
mkdir -p "${XDG_STATE_HOME:-$HOME/.local/state}"
touch "${XDG_STATE_HOME:-$HOME/.local/state}/fan-profile-auto"
bash scripts/fan-profile.sh
Expected output (assuming powerprofilesctl get returns a known profile, e.g. balanced):
{"text": " auto", "tooltip": "Auto fan strategy\nProfile: balanced → medium\nClick to change", "class": "auto"}
Also verify the side-effect — fw-fanctrl should now be on the mapped strategy:
fw-fanctrl print | grep '^Strategy:'
Expected: Strategy: 'medium' (or lazy/agile depending on your active power profile).
- Step 4: Verify auto-on error path (unknown profile)
Simulate powerprofilesctl returning something the mapping doesn't know. We do this by stubbing powerprofilesctl via PATH:
mkdir -p /tmp/fan-stub
cat >/tmp/fan-stub/powerprofilesctl <<'STUB'
#!/bin/bash
echo bogus-profile
STUB
chmod +x /tmp/fan-stub/powerprofilesctl
PATH=/tmp/fan-stub:$PATH bash scripts/fan-profile.sh
Expected:
{"text": " auto (?)", "tooltip": "Auto: power-profiles-daemon unreachable or unknown profile\nClick to change", "class": "auto"}
- Step 5: Verify auto-on error path (powerprofilesctl missing)
cat >/tmp/fan-stub/powerprofilesctl <<'STUB'
#!/bin/bash
exit 1
STUB
PATH=/tmp/fan-stub:$PATH bash scripts/fan-profile.sh
Expected: same auto (?) output as above (since PROFILE ends up empty, map_profile_to_strategy returns 1, MAPPED is empty, error branch fires).
Clean up:
rm -rf /tmp/fan-stub
rm -f "${XDG_STATE_HOME:-$HOME/.local/state}/fan-profile-auto"
- Step 6: Reload waybar to confirm end-to-end
Restart waybar so it picks up the script changes (the script file itself is loaded each invocation, so this is mostly to refresh the bar widget visually):
waybar-restart
Then enable auto mode without going through wofi:
mkdir -p "${XDG_STATE_HOME:-$HOME/.local/state}"
touch "${XDG_STATE_HOME:-$HOME/.local/state}/fan-profile-auto"
pkill -RTMIN+9 waybar
The bar's fan-profile widget should now read auto (within ~5 s — the next waybar poll). Hover the widget; the tooltip should show Profile: <name> → <strategy>. Switch your power profile (e.g. powerprofilesctl set performance) and within ~5 s the tooltip should update to Profile: performance → agile and fw-fanctrl print should confirm the strategy switched.
To turn auto off and verify the original behaviour returns:
rm "${XDG_STATE_HOME:-$HOME/.local/state}/fan-profile-auto"
pkill -RTMIN+9 waybar
The bar should revert to showing the underlying strategy name.
- Step 7: Commit
git add scripts/fan-profile.sh
git commit -m "fan-profile: render auto mode and reconcile fw-fanctrl on poll"
Final verification
- Auto persists across waybar restarts. With the state file present, run
waybar-restart. The bar should come back showingauto(within 5 s of restart). - Auto persists across reboot. Optional but the design promises it. The state file lives in
~/.local/state/which survives reboot. - Wofi menu shows
autofirst. Click the fan-profile widget; the wofi menu should listautoat the top followed by the seven fw-fanctrl strategies. - Selecting
autofrom the menu works end-to-end. Pickauto→ bar updates toautowithin seconds,fw-fanctrl printshows the mapped strategy. - Selecting a non-auto strategy from the menu disables auto. Pick e.g.
aeolus→ bar updates toaeolus, state file is gone (ls ~/.local/state/fan-profile-autoreturns "No such file or directory"), and subsequent power-profile changes do NOT swap the fan strategy automatically.