11use std:: fs;
2+ use std:: path:: Path ;
3+ use std:: process:: Command ;
24use std:: time:: Instant ;
35
46use 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
1621impl 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+
188469impl 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,
0 commit comments