Skip to content

Commit e631b68

Browse files
authored
fix(fan): detect missing fan_control=1 instead of blaming permissions (#23)
Likely the most common reason fan control does not work, and the app currently sends users somewhere that cannot possibly fix it. thinkpad_acpi's fan_set_level() opens with: if (!fan_control_allowed) return -EPERM; and the parameter is module_param_named(fan_control, ..., 0444). Mode 0444 means it is not writable at runtime -- it needs a modprobe.d entry plus a module reload or reboot. Nothing in this repo ever set it, so on a stock ThinkPad every write fails and set_fan_speed reported 'Permission denied. Click Grant Permissions', which can never help because the obstacle is the kernel module, not polkit. Detection is read-only and needs no privileges. The driver zeroes fan_control_commands when the parameter is off, and fan_read() only emits the 'commands:' lines when it is set, so their absence means writes will fail. Verified against a machine with fan_control=1 set: three commands: lines present. Adds: get_fan_capability() -- Ready / NeedsModuleParam / NoThinkpadFan, with a message that names the real obstacle enable_fan_control() -- writes the modprobe.d entry and attempts a reload, re-probing afterwards rather than assuming it worked. A busy module cannot be reloaded, so that case reports 'reboot to activate' instead of claiming success. set_fan_speed now checks this first and returns the accurate message. The three-state readiness matters: 'could not check' must never render as 'not supported'.
1 parent 3cddb07 commit e631b68

2 files changed

Lines changed: 182 additions & 0 deletions

File tree

src-tauri/src/fan_control.rs

Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,146 @@ fn parse_fan_proc(content: &str) -> HashMap<String, String> {
9898
fans
9999
}
100100

101+
/// Path to the modprobe config that enables fan control at boot.
102+
pub const MODPROBE_CONF_PATH: &str = "/etc/modprobe.d/thinkpad_acpi.conf";
103+
104+
/// Whether the kernel module will accept fan writes at all.
105+
///
106+
/// thinkpad_acpi's `fan_set_level()` returns -EPERM unless the module was loaded
107+
/// with `fan_control=1`, and that parameter is mode 0444 — it cannot be toggled
108+
/// at runtime, so no amount of privilege escalation fixes it.
109+
///
110+
/// The driver zeroes `fan_control_commands` when the parameter is off, and
111+
/// `fan_read()` only emits the `commands:` lines when it is set. So the presence
112+
/// of those lines is a reliable, read-only probe for "writes will succeed".
113+
///
114+
/// Without this check the app reports EPERM as "Permission denied. Click Grant
115+
/// Permissions" — advice that can never work, because the problem is the module,
116+
/// not polkit.
117+
fn fan_control_is_enabled(proc_fan_content: &str) -> bool {
118+
proc_fan_content
119+
.lines()
120+
.any(|l| l.trim_start().starts_with("commands:"))
121+
}
122+
123+
/// What is standing between the user and working fan control.
124+
#[derive(Debug, Serialize, Deserialize, PartialEq)]
125+
pub enum FanReadiness {
126+
/// Writes should work.
127+
Ready,
128+
/// The module needs `fan_control=1`; polkit cannot help.
129+
NeedsModuleParam,
130+
/// No thinkpad_acpi fan interface — not a supported ThinkPad, or the module
131+
/// is not loaded.
132+
NoThinkpadFan,
133+
}
134+
135+
#[derive(Debug, Serialize, Deserialize)]
136+
pub struct FanCapability {
137+
pub readiness: FanReadiness,
138+
/// True once the modprobe config exists, meaning the fix is applied but a
139+
/// reboot or module reload is still needed for it to take effect.
140+
pub modprobe_conf_present: bool,
141+
pub message: String,
142+
}
143+
144+
#[tauri::command]
145+
pub fn get_fan_capability() -> ApiResponse<FanCapability> {
146+
let modprobe_conf_present = fs::read_to_string(MODPROBE_CONF_PATH)
147+
.map(|c| c.contains("fan_control=1"))
148+
.unwrap_or(false);
149+
150+
let (readiness, message) = match fs::read_to_string(PROC_FAN) {
151+
Err(_) => (
152+
FanReadiness::NoThinkpadFan,
153+
"No ThinkPad fan interface found. Load the thinkpad_acpi module, or this model may not be supported.".to_string(),
154+
),
155+
Ok(content) if fan_control_is_enabled(&content) => {
156+
(FanReadiness::Ready, "Fan control is available.".to_string())
157+
}
158+
Ok(_) if modprobe_conf_present => (
159+
FanReadiness::NeedsModuleParam,
160+
"Fan control is configured but not active yet. Reboot, or reload the thinkpad_acpi module.".to_string(),
161+
),
162+
Ok(_) => (
163+
FanReadiness::NeedsModuleParam,
164+
"The thinkpad_acpi module was loaded without fan_control=1, so it will refuse fan changes. This is a kernel module setting, not a permissions problem.".to_string(),
165+
),
166+
};
167+
168+
ApiResponse {
169+
success: true,
170+
data: Some(FanCapability {
171+
readiness,
172+
modprobe_conf_present,
173+
message,
174+
}),
175+
error: None,
176+
}
177+
}
178+
179+
/// Write the modprobe config and try to reload the module.
180+
#[tauri::command]
181+
pub async fn enable_fan_control() -> ApiResponse<String> {
182+
// The reload can fail if the module is busy (an open /proc handle, a laptop
183+
// dock driver holding it). That is not an error worth failing on -- the
184+
// config file is written either way, so a reboot will apply it.
185+
let script = format!(
186+
"#!/bin/bash\nset -e\nprintf 'options thinkpad_acpi fan_control=1\\n' > {}\nmodprobe -r thinkpad_acpi 2>/dev/null && modprobe thinkpad_acpi 2>/dev/null || true\nexit 0\n",
187+
MODPROBE_CONF_PATH
188+
);
189+
190+
let temp_script = match create_secure_temp_script(&script) {
191+
Ok(p) => p,
192+
Err(e) => {
193+
return ApiResponse {
194+
success: false,
195+
data: None,
196+
error: Some(e),
197+
}
198+
}
199+
};
200+
201+
let result = tokio::process::Command::new("pkexec")
202+
.arg("bash")
203+
.arg(&temp_script)
204+
.output()
205+
.await;
206+
let _ = fs::remove_file(&temp_script);
207+
208+
match result {
209+
Ok(output) if output.status.success() => {
210+
// Re-probe rather than assume the reload worked.
211+
let now_ready = fs::read_to_string(PROC_FAN)
212+
.map(|c| fan_control_is_enabled(&c))
213+
.unwrap_or(false);
214+
215+
ApiResponse {
216+
success: true,
217+
data: Some(if now_ready {
218+
"Fan control enabled.".to_string()
219+
} else {
220+
"Fan control configured. Reboot to activate it — the module could not be reloaded while in use.".to_string()
221+
}),
222+
error: None,
223+
}
224+
}
225+
Ok(output) => ApiResponse {
226+
success: false,
227+
data: None,
228+
error: Some(format!(
229+
"Could not enable fan control: {}",
230+
String::from_utf8_lossy(&output.stderr)
231+
)),
232+
},
233+
Err(e) => ApiResponse {
234+
success: false,
235+
data: None,
236+
error: Some(format!("Could not enable fan control: {}", e)),
237+
},
238+
}
239+
}
240+
101241
#[derive(Debug, Serialize, Deserialize)]
102242
pub struct SensorData {
103243
pub temps: HashMap<String, String>,
@@ -226,6 +366,21 @@ pub async fn set_fan_speed(speed: String) -> ApiResponse<String> {
226366
println!("[Fan] Setting speed to: {}", speed);
227367
let command_str = format!("level {}", speed);
228368

369+
// Check the module parameter before trying anything. If fan_control=1 is
370+
// missing the kernel returns -EPERM no matter who we are, and telling the
371+
// user to grant permissions sends them somewhere that cannot help.
372+
if let Ok(content) = fs::read_to_string(PROC_FAN) {
373+
if !fan_control_is_enabled(&content) {
374+
return ApiResponse {
375+
success: false,
376+
data: None,
377+
error: Some(
378+
"The thinkpad_acpi module was loaded without fan_control=1, so the kernel will refuse fan changes. Enable it from the Fan Control page — this is a module setting, not a permissions problem.".to_string(),
379+
),
380+
};
381+
}
382+
}
383+
229384
// 1. Try direct write (no elevation needed)
230385
if fs::write(PROC_FAN, &command_str).is_ok() {
231386
println!("[Fan] ✓ Speed set successfully");
@@ -425,6 +580,31 @@ commands:\twatchdog <timeout> (0 disables, timeout is 0-120)
425580
assert_eq!(fans.get("Fan1").map(String::as_str), Some("4500 RPM"));
426581
}
427582

583+
// -- fan_control=1 module parameter detection --
584+
585+
/// The whole point of this probe: without fan_control=1 the driver zeroes
586+
/// fan_control_commands and fan_read() omits the commands: lines. Their
587+
/// absence means every write will return -EPERM, and no amount of polkit
588+
/// will change that.
589+
#[test]
590+
fn detects_fan_control_disabled_by_absent_commands_lines() {
591+
let without = "status:\t\tenabled\nspeed:\t\t2413\nlevel:\t\tauto\n";
592+
assert!(!fan_control_is_enabled(without));
593+
}
594+
595+
#[test]
596+
fn detects_fan_control_enabled_by_commands_lines() {
597+
assert!(fan_control_is_enabled(SAMPLE_PROC_FAN));
598+
}
599+
600+
#[test]
601+
fn fan_control_detection_tolerates_empty_and_partial_input() {
602+
assert!(!fan_control_is_enabled(""));
603+
assert!(!fan_control_is_enabled("status:\tenabled"));
604+
// A value that merely mentions the word must not count as a commands line.
605+
assert!(!fan_control_is_enabled("level:\tcommands: not really"));
606+
}
607+
428608
// -- The installed privileged helper --
429609

430610
/// Write HELPER_SCRIPT to a temp file and run it, so we test the bash that

src-tauri/src/lib.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,8 @@ pub fn run() {
179179
settings::update_setting,
180180
// Fan Control
181181
fan_control::get_sensor_data,
182+
fan_control::get_fan_capability,
183+
fan_control::enable_fan_control,
182184
fan_control::set_fan_speed,
183185
fan_control::check_permissions,
184186
// Fan Curve

0 commit comments

Comments
 (0)