Skip to content

Commit 462484e

Browse files
system-monitor-graph@rcassani: Add CPU fan monitoring (#1868)
* system-monitor-graph@rcassani: Add CPU fan monitoring * system-monitor-graph@rcassani: Add fan speed graph * system-monitor-graph@rcassani: Refine fan status display * system-monitor-graph@rcassani: Address fan review feedback * system-monitor-graph@rcassani: Use fixed fan graph scale * system-monitor-graph@rcassani: Rename fan setting
1 parent 8e2943e commit 462484e

18 files changed

Lines changed: 852 additions & 379 deletions

File tree

system-monitor-graph@rcassani/README.md

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,13 @@ Four instances of the Desklet in action.
1010
This project has been inspired from other Desklets such as [Disk Space](https://cinnamon-spices.linuxmint.com/desklets/view/39), [CPU Load](https://cinnamon-spices.linuxmint.com/desklets/view/44), [Simple system monitor](https://cinnamon-spices.linuxmint.com/desklets/view/29), [Network usage monitor](https://cinnamon-spices.linuxmint.com/desklets/view/15), [Top](https://cinnamon-spices.linuxmint.com/desklets/view/41), and the [Rainmeter Win10 Widgets](https://win10widgets.com/).
1111

1212
## Features
13-
### Variables to monitor (v2.0 - July 2025)
13+
### Variables to monitor (v2.2 - July 2026)
1414

1515
| System variable | Description |
1616
| ----------- | ----------- |
1717
| CPU | CPU usage in % |
18-
| CPU Temperature | CPU temperature C or F |
18+
| CPU Temperature | CPU temperature in C or F |
19+
| CPU Fan | CPU fan speed in RPM and on/off status |
1920
| RAM | Used RAM as % of total, and in GB |
2021
| Swap | Used Swap space as % of total, and in GB |
2122
| HDD | % of I/O activity, and free and total space in the filesystem (partition) indicated by the user |
@@ -27,6 +28,7 @@ This project has been inspired from other Desklets such as [Disk Space](https://
2728

2829
Each variable is calculated every `Refresh interval` seconds (Min. 1 s, Max. 60 s.), and the graph shows the last `Duration of the graph` period (Min. 30 s, Max. 60 min).
2930
GPU graphs work for NVIDIA, discrete AMD and built-in AMD.
31+
Fan monitoring uses CPU-labelled sensors and known laptop cooling drivers. It avoids guessing from unrelated unlabelled fans, and reports when an identifiable CPU fan is not detected.
3032

3133
### Customizable visual elements
3234
<p align="center">
@@ -57,6 +59,11 @@ Another screenshot.
5759
Screenshot with temperature and battery graphs.
5860
</p>
5961

62+
<p align="center">
63+
<img src="https://cinnamon-spices.linuxmint.com/git/desklets/system-monitor-graph@rcassani/screenshot5.png" width="700" align="middle"><br>
64+
CPU fan speed history while the fan is running and stopped.
65+
</p>
66+
6067
## TODO
6168
- [x] Add network
6269
- [x] Battery levels for main battery (laptop)

system-monitor-graph@rcassani/files/system-monitor-graph@rcassani/desklet.js

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,10 @@ const KIB_TO_B = 1024; // 1 KiB = 1,024 B
1818

1919
const CPU_TEMP_MIN = 20; // Minimum CPU temperature
2020
const CPU_TEMP_MAX = 100; // Maximum CPU temperature
21+
const CPU_FANSPEED_MAX = 4000; // CPU fan graph full-scale speed
22+
const CPU_FAN_RETRY_MIN = 5; // Initial retry delay in seconds
23+
const CPU_FAN_RETRY_MAX = 300; // Maximum retry delay in seconds
24+
const CPU_FAN_RETRY_MULTIPLIER = 2;
2125

2226
const UUID = "system-monitor-graph@rcassani";
2327
const DESKLET_PATH = imports.ui.deskletManager.deskletMeta[UUID].path;
@@ -190,6 +194,10 @@ SystemMonitorGraph.prototype = {
190194
// temperature values
191195
this.cpu_temperature = NaN;
192196
this.gpu_temperature = NaN;
197+
// cpu fan values
198+
this.cpu_fan_rpm = NaN;
199+
this.cpu_fan_file = "";
200+
this.cpu_fan_discovery_complete = false;
193201

194202
// set colors
195203
switch (this.type) {
@@ -232,6 +240,9 @@ SystemMonitorGraph.prototype = {
232240
});
233241
}
234242
});
243+
if (this.type == "cpu" && this.cpu_variable == "fan") {
244+
this.rediscover_cpu_fan();
245+
}
235246
// find file for overall AMD GPU temperature
236247
let gpu_path_temp = "/sys/class/drm/card" + this.gpu_id + "/device/hwmon/";
237248
this.get_temperature_file_by_label(gpu_path_temp, 'edge', (result) => {
@@ -290,6 +301,26 @@ SystemMonitorGraph.prototype = {
290301
text2 = Math.round(this.cpu_temperature * 9 / 5 + 32).toString() + "°F";
291302
}
292303
break;
304+
305+
case "fan":
306+
this.get_cpu_fan_speed(this.cpu_fan_file);
307+
value = isNaN(this.cpu_fan_rpm) ? 0 : this.cpu_fan_rpm / CPU_FANSPEED_MAX;
308+
value = value < 0 ? 0 : value > 1 ? 1 : value;
309+
text1 = _("CPU Fan");
310+
text2_size = Math.max(1, text2_size - 1);
311+
if (!isNaN(this.cpu_fan_rpm)) {
312+
if (this.cpu_fan_rpm >= 1) {
313+
text2 = this.cpu_fan_rpm + " " + _("RPM");
314+
text3 = "🟢 " + _("On");
315+
} else {
316+
text3 = "🔴 " + _("Off");
317+
}
318+
} else if (this.cpu_fan_discovery_complete) {
319+
text2 = _("Not detected");
320+
} else {
321+
text2 = _("Detecting...");
322+
}
323+
break;
293324
}
294325
break;
295326

@@ -1142,6 +1173,75 @@ SystemMonitorGraph.prototype = {
11421173
});
11431174
},
11441175

1176+
get_cpu_fan_file: function(callback) {
1177+
// Prefer explicitly labelled CPU fans, then known laptop cooling drivers.
1178+
// Do not guess from an unrelated, unlabelled hwmon fan.
1179+
let argv = ['/bin/sh', '-c', "for fan_file in $({ grep -s -i -l -d skip -E 'cpu|processor' /sys/class/hwmon/hwmon*/fan*_label | sed 's/_label/_input/'; grep -s -l -d skip -E '^(dell_smm|thinkpad)$' /sys/class/hwmon/hwmon*/name | sed 's#/name#/fan1_input#'; }); do [ -r \"$fan_file\" ] && { printf '%s' \"$fan_file\"; break; }; done"];
1180+
spawnAsyncWithOutput(argv, (success, output) => {
1181+
callback(success && output ? output.trim() : "");
1182+
});
1183+
},
1184+
1185+
schedule_cpu_fan_retry: function() {
1186+
if (this.cpu_fan_retry_delay == null) this.cpu_fan_retry_delay = CPU_FAN_RETRY_MIN;
1187+
this.cpu_fan_file = "";
1188+
this.cpu_fan_rpm = NaN;
1189+
this.cpu_fan_discovery_complete = true;
1190+
this.cpu_fan_next_discovery_time = GLib.get_monotonic_time()
1191+
+ (this.cpu_fan_retry_delay * GLib.USEC_PER_SEC);
1192+
this.cpu_fan_retry_delay = Math.min(
1193+
this.cpu_fan_retry_delay * CPU_FAN_RETRY_MULTIPLIER,
1194+
CPU_FAN_RETRY_MAX);
1195+
},
1196+
1197+
rediscover_cpu_fan: function() {
1198+
// Clear a stale hwmon path first. Missing or failed sensors are retried
1199+
// with bounded backoff instead of spawning another lookup every refresh.
1200+
if (this.cpu_fan_discovery_in_progress) return;
1201+
if (this.cpu_fan_retry_delay == null) this.cpu_fan_retry_delay = CPU_FAN_RETRY_MIN;
1202+
this.cpu_fan_discovery_in_progress = true;
1203+
this.cpu_fan_file = "";
1204+
this.cpu_fan_rpm = NaN;
1205+
this.cpu_fan_discovery_complete = false;
1206+
this.get_cpu_fan_file((result) => {
1207+
this.cpu_fan_discovery_in_progress = false;
1208+
if (result == "") {
1209+
this.schedule_cpu_fan_retry();
1210+
} else {
1211+
this.cpu_fan_file = result;
1212+
this.cpu_fan_discovery_complete = true;
1213+
this.cpu_fan_next_discovery_time = 0;
1214+
}
1215+
});
1216+
},
1217+
1218+
get_cpu_fan_speed: function(fan_file) {
1219+
if(fan_file == null || fan_file == "") {
1220+
if (this.cpu_fan_discovery_complete
1221+
&& GLib.get_monotonic_time() >= this.cpu_fan_next_discovery_time) {
1222+
this.rediscover_cpu_fan();
1223+
}
1224+
return;
1225+
}
1226+
Gio.file_new_for_path(fan_file).load_contents_async(null, (file, response) => {
1227+
try {
1228+
let [success, contents, tag] = file.load_contents_finish(response);
1229+
if (success) {
1230+
let rpm = parseInt(ByteArray.toString(contents).trim());
1231+
this.cpu_fan_rpm = isNaN(rpm) ? NaN : Math.max(0, rpm);
1232+
this.cpu_fan_retry_delay = CPU_FAN_RETRY_MIN;
1233+
this.cpu_fan_next_discovery_time = 0;
1234+
} else {
1235+
this.schedule_cpu_fan_retry();
1236+
}
1237+
GLib.free(contents);
1238+
} catch(error) {
1239+
this.schedule_cpu_fan_retry();
1240+
global.log('CPU fan speed file read error: ' + error.toString());
1241+
}
1242+
});
1243+
},
1244+
11451245
get_temperature_file_by_label: function(path, label, callback) {
11461246
// search for temperture file: 'path'/*/temp*_input associated to file path/*/temp*_label with content 'label'
11471247
let argv = ['/bin/sh', '-c', "grep -s -l -d skip '" + label + "' " + path + "*/temp*_label | sed 's/_label/_input/' | head -n 1"];

system-monitor-graph@rcassani/files/system-monitor-graph@rcassani/metadata.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,6 @@
33
"uuid": "system-monitor-graph@rcassani",
44
"name": "System monitor graph",
55
"description": "Creates graphs for system variables.",
6-
"version": "2.1",
6+
"version": "2.2",
77
"prevent-decorations": true
88
}

system-monitor-graph@rcassani/files/system-monitor-graph@rcassani/po/ca.po

Lines changed: 58 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,8 @@
66
msgid ""
77
msgstr ""
88
"Project-Id-Version: \n"
9-
"Report-Msgid-Bugs-To: https://github.com/linuxmint/cinnamon-spices-desklets/"
10-
"issues\n"
11-
"POT-Creation-Date: 2026-03-23 21:24-0400\n"
9+
"Report-Msgid-Bugs-To: https://github.com/linuxmint/cinnamon-spices-desklets/issues\n"
10+
"POT-Creation-Date: 2026-07-22 14:45+0530\n"
1211
"PO-Revision-Date: 2025-09-29 04:41+0200\n"
1312
"Last-Translator: Daniel <d3vf4n@tutanota.com>\n"
1413
"Language-Team: \n"
@@ -20,104 +19,128 @@ msgstr ""
2019
"X-Generator: Poedit 3.6\n"
2120

2221
#. settings-schema.json->type->options
23-
#. desklet.js:275
22+
#. desklet.js:288
2423
msgid "CPU"
2524
msgstr "CPU"
2625

27-
#. desklet.js:284
26+
#. desklet.js:297
2827
msgid "CPU Temperature"
2928
msgstr ""
3029

31-
#. desklet.js:301 desklet.js:319 desklet.js:339 desklet.js:380 desklet.js:684
30+
#. desklet.js:309
31+
msgid "CPU Fan"
32+
msgstr ""
33+
34+
#. desklet.js:313
35+
msgid "RPM"
36+
msgstr ""
37+
38+
#. desklet.js:314
39+
msgid "On"
40+
msgstr ""
41+
42+
#. desklet.js:316
43+
msgid "Off"
44+
msgstr ""
45+
46+
#. desklet.js:319
47+
msgid "Not detected"
48+
msgstr ""
49+
50+
#. desklet.js:321
51+
msgid "Detecting..."
52+
msgstr ""
53+
54+
#. desklet.js:334 desklet.js:352 desklet.js:372 desklet.js:413 desklet.js:717
3255
msgid "GB"
3356
msgstr "GB"
3457

35-
#. desklet.js:304 desklet.js:322 desklet.js:342 desklet.js:383 desklet.js:698
58+
#. desklet.js:337 desklet.js:355 desklet.js:375 desklet.js:416 desklet.js:731
3659
msgid "GiB"
3760
msgstr "GiB"
3861

3962
#. settings-schema.json->type->options
40-
#. desklet.js:306
63+
#. desklet.js:339
4164
msgid "RAM"
4265
msgstr "RAM"
4366

4467
#. settings-schema.json->type->options
45-
#. desklet.js:324
68+
#. desklet.js:357
4669
msgid "Swap"
4770
msgstr "Swap"
4871

49-
#. desklet.js:347
72+
#. desklet.js:380
5073
msgid "free of"
5174
msgstr "lliure de"
5275

53-
#. desklet.js:363
76+
#. desklet.js:396
5477
msgid "GPU Usage"
5578
msgstr "Ús de GPU"
5679

57-
#. desklet.js:385
80+
#. desklet.js:418
5881
msgid "GPU Memory"
5982
msgstr "Memòria de la GPU"
6083

61-
#. desklet.js:401
84+
#. desklet.js:434
6285
#, fuzzy
6386
msgid "GPU Temperature"
6487
msgstr "Fabricant de la GPU"
6588

6689
#. settings-schema.json->type->options
67-
#. desklet.js:415
90+
#. desklet.js:448
6891
msgid "Network"
6992
msgstr "Xarxa"
7093

7194
#. settings-schema.json->type->options
72-
#. desklet.js:428
95+
#. desklet.js:461
7396
msgid "Battery"
7497
msgstr ""
7598

76-
#. desklet.js:431
99+
#. desklet.js:464
77100
msgid "Fully charged"
78101
msgstr ""
79102

80-
#. desklet.js:432
103+
#. desklet.js:465
81104
msgid "Not charging"
82105
msgstr ""
83106

84-
#. desklet.js:434
107+
#. desklet.js:467
85108
msgid " hrs"
86109
msgstr ""
87110

88-
#. desklet.js:655 desklet.js:678
111+
#. desklet.js:688 desklet.js:711
89112
msgid "b"
90113
msgstr "b"
91114

92-
#. desklet.js:657 desklet.js:692 desklet.js:706
115+
#. desklet.js:690 desklet.js:725 desklet.js:739
93116
msgid "B"
94117
msgstr "B"
95118

96-
#. desklet.js:670
119+
#. desklet.js:703
97120
msgid "Gb"
98121
msgstr "Gb"
99122

100-
#. desklet.js:673
123+
#. desklet.js:706
101124
msgid "Mb"
102125
msgstr "Mb"
103126

104-
#. desklet.js:676
127+
#. desklet.js:709
105128
msgid "Kb"
106129
msgstr "Kb"
107130

108-
#. desklet.js:687
131+
#. desklet.js:720
109132
msgid "MB"
110133
msgstr "MB"
111134

112-
#. desklet.js:690
135+
#. desklet.js:723
113136
msgid "KB"
114137
msgstr "KB"
115138

116-
#. desklet.js:701
139+
#. desklet.js:734
117140
msgid "MiB"
118141
msgstr "MiB"
119142

120-
#. desklet.js:704
143+
#. desklet.js:737
121144
msgid "KiB"
122145
msgstr "KiB"
123146

@@ -173,6 +196,10 @@ msgstr "Ús"
173196
msgid "Temperature"
174197
msgstr ""
175198

199+
#. settings-schema.json->cpu-variable->options
200+
msgid "Fan Speed"
201+
msgstr ""
202+
176203
#. settings-schema.json->temperature-units-cpu->options
177204
#. settings-schema.json->temperature-units-gpu->options
178205
msgid "°C"
@@ -252,10 +279,11 @@ msgstr "Interfície de xarxa a monitorar"
252279

253280
#. settings-schema.json->network-interface->tooltip
254281
msgid ""
255-
"Leave empty to monitor all interfaces combined. Examples: eth0, wlan0, enp0s3"
282+
"Leave empty to monitor all interfaces combined. Examples: eth0, wlan0, "
283+
"enp0s3"
256284
msgstr ""
257-
"Deixeu-ho en blanc per a monitorar totes les interfícies. Per exemple: eth0, "
258-
"wlan0, enp0s3"
285+
"Deixeu-ho en blanc per a monitorar totes les interfícies. Per exemple: eth0,"
286+
" wlan0, enp0s3"
259287

260288
#. settings-schema.json->battery-name->description
261289
#, fuzzy

0 commit comments

Comments
 (0)