Skip to content

Commit 47b44a5

Browse files
committed
fix(cpu): add WSL temperature fallback
1 parent a803239 commit 47b44a5

3 files changed

Lines changed: 313 additions & 24 deletions

File tree

src/collectors/cpu.rs

Lines changed: 303 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
use std::fs;
2+
use std::path::Path;
3+
use std::process::Command;
24
use std::time::Instant;
35

46
use crate::domain::cpu::{CoreStats, CpuStats, CpuTimeSample};
@@ -11,6 +13,9 @@ pub struct CpuCollector {
1113
prev_total: Option<CpuTimeSample>,
1214
prev_cores: Vec<CpuTimeSample>,
1315
prev_time: Instant,
16+
is_wsl: bool,
17+
last_wsl_temp_check: Option<Instant>,
18+
cached_wsl_temp: Option<f64>,
1419
}
1520

1621
impl Default for CpuCollector {
@@ -25,6 +30,9 @@ impl CpuCollector {
2530
prev_total: None,
2631
prev_cores: Vec::new(),
2732
prev_time: Instant::now(),
33+
is_wsl: Self::detect_wsl(),
34+
last_wsl_temp_check: None,
35+
cached_wsl_temp: None,
2836
}
2937
}
3038

@@ -112,32 +120,209 @@ impl CpuCollector {
112120
None
113121
}
114122

115-
fn read_cpu_temperature() -> Option<f64> {
116-
// Try common thermal zone paths
117-
for i in 0..20 {
118-
let type_path = format!("/sys/class/thermal/thermal_zone{i}/type");
119-
let temp_path = format!("/sys/class/thermal/thermal_zone{i}/temp");
120-
121-
if let Ok(zone_type) = fs::read_to_string(&type_path) {
122-
let zone_type = zone_type.trim().to_lowercase();
123-
// Look for CPU-related thermal zones
124-
if (zone_type.contains("cpu")
125-
|| zone_type.contains("x86_pkg")
126-
|| zone_type.contains("coretemp")
127-
|| zone_type.contains("soc"))
128-
&& let Ok(temp_str) = fs::read_to_string(&temp_path)
129-
&& let Ok(millideg) = temp_str.trim().parse::<f64>()
130-
{
131-
return Some(millideg / 1000.0);
123+
fn normalize_temperature(raw: f64) -> Option<f64> {
124+
let normalized = if raw.abs() > 250.0 { raw / 1000.0 } else { raw };
125+
if (-40.0..=150.0).contains(&normalized) {
126+
Some(normalized)
127+
} else {
128+
None
129+
}
130+
}
131+
132+
fn cpu_temp_match_score(name: Option<&str>, label: Option<&str>) -> u8 {
133+
let mut score = 0;
134+
135+
let update_score = |text: &str, score: &mut u8| {
136+
if text.contains("package") || text.contains("x86_pkg") {
137+
*score = (*score).max(100);
138+
} else if text.contains("tdie") || text.contains("tctl") {
139+
*score = (*score).max(95);
140+
} else if text.contains("cpu") || text.contains("core") || text.contains("ccd") {
141+
*score = (*score).max(85);
142+
} else if text.contains("soc") {
143+
*score = (*score).max(70);
144+
} else if text.contains("coretemp")
145+
|| text.contains("k10temp")
146+
|| text.contains("zenpower")
147+
|| text.contains("cpu_thermal")
148+
|| text.contains("cpu-thermal")
149+
{
150+
*score = (*score).max(60);
151+
}
152+
};
153+
154+
if let Some(name) = name {
155+
update_score(name, &mut score);
156+
}
157+
if let Some(label) = label {
158+
update_score(label, &mut score);
159+
}
160+
161+
score
162+
}
163+
164+
fn pick_best_temperature(candidates: impl IntoIterator<Item = (u8, f64)>) -> Option<f64> {
165+
let mut best_match: Option<(u8, f64)> = None;
166+
let mut fallback: Option<f64> = None;
167+
168+
for (score, temp) in candidates {
169+
if score > 0 {
170+
match best_match {
171+
Some((best_score, best_temp))
172+
if score < best_score || (score == best_score && temp <= best_temp) => {}
173+
_ => best_match = Some((score, temp)),
174+
}
175+
} else {
176+
fallback = Some(match fallback {
177+
Some(best_temp) => best_temp.max(temp),
178+
None => temp,
179+
});
180+
}
181+
}
182+
183+
best_match.map(|(_, temp)| temp).or(fallback)
184+
}
185+
186+
fn read_cpu_temperature_from_hwmon_root(root: &Path) -> Option<f64> {
187+
let mut candidates = Vec::new();
188+
189+
for entry in fs::read_dir(root).ok()?.flatten() {
190+
let hwmon_path = entry.path();
191+
if !hwmon_path.is_dir() {
192+
continue;
193+
}
194+
195+
let sensor_name = fs::read_to_string(hwmon_path.join("name"))
196+
.ok()
197+
.map(|s| s.trim().to_lowercase());
198+
199+
let Ok(files) = fs::read_dir(&hwmon_path) else {
200+
continue;
201+
};
202+
203+
for file in files.flatten() {
204+
let file_name = file.file_name();
205+
let file_name = file_name.to_string_lossy();
206+
207+
let Some(sensor_prefix) = file_name.strip_suffix("_input") else {
208+
continue;
209+
};
210+
if !sensor_prefix.starts_with("temp") {
211+
continue;
132212
}
213+
214+
let Some(temp) = fs::read_to_string(file.path())
215+
.ok()
216+
.and_then(|s| s.trim().parse::<f64>().ok())
217+
.and_then(Self::normalize_temperature)
218+
else {
219+
continue;
220+
};
221+
222+
let label = fs::read_to_string(hwmon_path.join(format!("{sensor_prefix}_label")))
223+
.ok()
224+
.map(|s| s.trim().to_lowercase());
225+
let score = Self::cpu_temp_match_score(sensor_name.as_deref(), label.as_deref());
226+
candidates.push((score, temp));
227+
}
228+
}
229+
230+
Self::pick_best_temperature(candidates)
231+
}
232+
233+
fn read_cpu_temperature_from_thermal_root(root: &Path) -> Option<f64> {
234+
let mut candidates = Vec::new();
235+
236+
for entry in fs::read_dir(root).ok()?.flatten() {
237+
let zone_path = entry.path();
238+
if !zone_path.is_dir() {
239+
continue;
133240
}
241+
242+
let zone_type = fs::read_to_string(zone_path.join("type"))
243+
.ok()
244+
.map(|s| s.trim().to_lowercase());
245+
let Some(temp) = fs::read_to_string(zone_path.join("temp"))
246+
.ok()
247+
.and_then(|s| s.trim().parse::<f64>().ok())
248+
.and_then(Self::normalize_temperature)
249+
else {
250+
continue;
251+
};
252+
253+
let score = Self::cpu_temp_match_score(zone_type.as_deref(), None);
254+
candidates.push((score, temp));
134255
}
135256

136-
// Fallback: use first thermal zone
137-
fs::read_to_string("/sys/class/thermal/thermal_zone0/temp")
257+
Self::pick_best_temperature(candidates)
258+
}
259+
260+
fn detect_wsl() -> bool {
261+
if std::env::var_os("WSL_DISTRO_NAME").is_some() {
262+
return true;
263+
}
264+
265+
fs::read_to_string("/proc/sys/kernel/osrelease")
138266
.ok()
139-
.and_then(|s| s.trim().parse::<f64>().ok())
140-
.map(|millideg| millideg / 1000.0)
267+
.map(|s| s.to_ascii_lowercase().contains("microsoft"))
268+
.unwrap_or(false)
269+
|| fs::read_to_string("/proc/version")
270+
.ok()
271+
.map(|s| s.to_ascii_lowercase().contains("microsoft"))
272+
.unwrap_or(false)
273+
}
274+
275+
fn read_wsl_temperature_via_powershell() -> Option<f64> {
276+
let script = concat!(
277+
"$temps = Get-CimInstance -ClassName ",
278+
"Win32_PerfFormattedData_Counters_ThermalZoneInformation ",
279+
"| ForEach-Object { [math]::Round($_.Temperature - 273.15, 1) }; ",
280+
"if ($temps) { ($temps | Measure-Object -Maximum).Maximum }"
281+
);
282+
283+
let output = Command::new("powershell.exe")
284+
.args(["-NoProfile", "-Command", script])
285+
.output()
286+
.ok()?;
287+
288+
if !output.status.success() {
289+
return None;
290+
}
291+
292+
String::from_utf8_lossy(&output.stdout)
293+
.trim()
294+
.parse::<f64>()
295+
.ok()
296+
.and_then(Self::normalize_temperature)
297+
}
298+
299+
fn read_wsl_temperature_cached(&mut self) -> Option<f64> {
300+
const WSL_TEMP_CACHE_SECS: u64 = 3;
301+
302+
if let Some(last) = self.last_wsl_temp_check
303+
&& last.elapsed().as_secs() < WSL_TEMP_CACHE_SECS
304+
{
305+
return self.cached_wsl_temp;
306+
}
307+
308+
let temp = Self::read_wsl_temperature_via_powershell();
309+
self.cached_wsl_temp = temp;
310+
self.last_wsl_temp_check = Some(Instant::now());
311+
temp
312+
}
313+
314+
fn read_cpu_temperature(&mut self) -> Option<f64> {
315+
Self::read_cpu_temperature_from_hwmon_root(Path::new("/sys/class/hwmon"))
316+
.or_else(|| {
317+
Self::read_cpu_temperature_from_thermal_root(Path::new("/sys/class/thermal"))
318+
})
319+
.or_else(|| {
320+
if self.is_wsl {
321+
self.read_wsl_temperature_cached()
322+
} else {
323+
None
324+
}
325+
})
141326
}
142327

143328
/// Parse /proc/loadavg: "0.50 0.60 0.70 3/500 12345"
@@ -185,6 +370,102 @@ impl CpuCollector {
185370
}
186371
}
187372

373+
#[cfg(test)]
374+
mod tests {
375+
use std::path::{Path, PathBuf};
376+
use std::time::{SystemTime, UNIX_EPOCH};
377+
378+
use super::CpuCollector;
379+
380+
fn make_temp_dir(name: &str) -> PathBuf {
381+
let unique = SystemTime::now()
382+
.duration_since(UNIX_EPOCH)
383+
.expect("clock drift")
384+
.as_nanos();
385+
let dir =
386+
std::env::temp_dir().join(format!("dgxtop-{name}-{}-{unique}", std::process::id()));
387+
std::fs::create_dir_all(&dir).expect("failed to create temp dir");
388+
dir
389+
}
390+
391+
fn write_file(path: &Path, contents: &str) {
392+
if let Some(parent) = path.parent() {
393+
std::fs::create_dir_all(parent).expect("failed to create parent dir");
394+
}
395+
std::fs::write(path, contents).expect("failed to write test file");
396+
}
397+
398+
fn assert_temp_eq(actual: Option<f64>, expected: f64) {
399+
let actual = actual.expect("expected a temperature");
400+
assert!(
401+
(actual - expected).abs() < f64::EPSILON,
402+
"expected {expected}, got {actual}"
403+
);
404+
}
405+
406+
#[test]
407+
fn hwmon_prefers_cpu_package_sensor() {
408+
let root = make_temp_dir("cpu-hwmon-package");
409+
410+
write_file(&root.join("hwmon0/name"), "nvme\n");
411+
write_file(&root.join("hwmon0/temp1_input"), "42000\n");
412+
413+
write_file(&root.join("hwmon1/name"), "coretemp\n");
414+
write_file(&root.join("hwmon1/temp1_input"), "63500\n");
415+
write_file(&root.join("hwmon1/temp1_label"), "Package id 0\n");
416+
417+
assert_temp_eq(
418+
CpuCollector::read_cpu_temperature_from_hwmon_root(&root),
419+
63.5,
420+
);
421+
422+
std::fs::remove_dir_all(root).expect("failed to clean temp dir");
423+
}
424+
425+
#[test]
426+
fn hwmon_accepts_unlabelled_cpu_sensor_names() {
427+
let root = make_temp_dir("cpu-hwmon-unlabelled");
428+
429+
write_file(&root.join("hwmon0/name"), "k10temp\n");
430+
write_file(&root.join("hwmon0/temp1_input"), "58750\n");
431+
432+
assert_temp_eq(
433+
CpuCollector::read_cpu_temperature_from_hwmon_root(&root),
434+
58.75,
435+
);
436+
437+
std::fs::remove_dir_all(root).expect("failed to clean temp dir");
438+
}
439+
440+
#[test]
441+
fn thermal_zone_fallback_prefers_cpu_like_types() {
442+
let root = make_temp_dir("cpu-thermal-zone");
443+
444+
write_file(&root.join("thermal_zone0/type"), "acpitz\n");
445+
write_file(&root.join("thermal_zone0/temp"), "27000\n");
446+
447+
write_file(&root.join("thermal_zone1/type"), "x86_pkg_temp\n");
448+
write_file(&root.join("thermal_zone1/temp"), "68000\n");
449+
450+
assert_temp_eq(
451+
CpuCollector::read_cpu_temperature_from_thermal_root(&root),
452+
68.0,
453+
);
454+
455+
std::fs::remove_dir_all(root).expect("failed to clean temp dir");
456+
}
457+
458+
#[test]
459+
fn normalize_temperature_accepts_millidegree_values() {
460+
assert_temp_eq(CpuCollector::normalize_temperature(63500.0), 63.5);
461+
}
462+
463+
#[test]
464+
fn normalize_temperature_rejects_unreasonable_values() {
465+
assert!(CpuCollector::normalize_temperature(500000.0).is_none());
466+
}
467+
}
468+
188469
impl Collector for CpuCollector {
189470
type Output = CpuStats;
190471

@@ -257,7 +538,7 @@ impl Collector for CpuCollector {
257538
idle_percent,
258539
frequency_mhz: avg_freq,
259540
frequency_max_mhz: Self::read_max_frequency().unwrap_or(0.0),
260-
temperature_celsius: Self::read_cpu_temperature(),
541+
temperature_celsius: self.read_cpu_temperature(),
261542
core_count,
262543
cores: per_core_stats,
263544
load_avg_1m,

src/ui/views/overview.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -323,6 +323,7 @@ fn render_cpu_info_line(
323323
.add_modifier(Modifier::BOLD),
324324
),
325325
Span::styled(" ", Style::default()),
326+
Span::styled("temp ", Style::default().fg(theme.text_muted)),
326327
Span::styled(format!(" {temp_str}"), Style::default().fg(temp_color)),
327328
Span::styled(" ", Style::default()),
328329
Span::styled(format!(" {freq_str}"), Style::default().fg(theme.text_dim)),

0 commit comments

Comments
 (0)