From aa9d6e39568356f64af3370db871dfd142f1110f Mon Sep 17 00:00:00 2001
From: 420Coupe <420coupe@protonmail.com>
Date: Wed, 5 Aug 2026 01:59:44 -0400
Subject: [PATCH] feature: add coolant and pump sensors with Aquacomputer
Vision support
Read coolant temperature from an Aquacomputer Next Vision and give liquid
cooling its own sensor groups, so coolant and pump no longer borrow the
threshold colors of chip temperatures and case fans.
The Vision has no hwmon driver, so the reading comes from raw HID. It is a
composite device that also exposes keyboard and consumer control
interfaces, and only the vendor defined one reports telemetry, so match the
report descriptor usage page as well as the USB vendor and product id.
Reading either of the other interfaces blocks forever because they never
send a report. Telemetry arrives as a 64 byte report with id 0x01 holding
the coolant temperature at offset 0x37, big endian, in hundredths of a
degree.
Coolant runs far cooler than a CPU and a pump far faster than a case fan,
so sharing temperature-colors and fan-colors left their breakpoints
useless. Promote both to entries in sensorCatalog. The preference sidebar,
threshold color editors, changed:: signal wiring and dropdown menu groups
are all derived from that catalog, so each gains a page, a show-* toggle
and a *-colors key. The pump arrives on a fan input and is reassigned to
its own group by label, and fan inputs are now discovered when either group
is enabled.
colorsKeyForSensor() resolved format 'temp' to temperature-colors before
consulting the sensor's group, which made a per group scale impossible.
Check the group first and keep the format fallback so GPU temperatures
still share the temperature scale.
Coolant also gets its own unit. Threshold colors are matched against the
displayed value, so the unit and the breakpoints have to agree. The unit
key is resolved from the catalog, leaving every other temperature source on
the main unit.
Add an 'aggregate' catalog flag marking groups populated by hardware
monitor discovery, replacing the hardcoded group lists in the sensor query
dispatch and in the group average, minimum and maximum block.
sensorGroupFromType() only stripped '-group' and a trailing '#N', so a
suffixed type resolved to itself and missed its catalog entry. Fall back to
the leading segment.
Panel icon margins are declared per sensor type and also zero the padding,
so the new icons inherited the shell's system-status-icon padding and sat
wider apart than the rest. Add matching rules.
Ship water droplet and pump icons in both icon styles, and label several
nct6799 inputs for this board, including the fan7 input the pump reports
through.
---
extension.js | 2 +-
helpers/catalog.js | 28 ++-
icons/gnome/pump-symbolic.svg | 1 +
icons/gnome/water-droplet-symbolic.svg | 3 +
icons/original/pump-symbolic.svg | 1 +
icons/original/water-droplet-symbolic.svg | 3 +
prefs.js | 5 +-
prefs.ui | 63 +++++++
....gnome.shell.extensions.vitals.gschema.xml | 25 +++
sensors.js | 166 ++++++++++++++++--
stylesheet.css | 2 +
values.js | 14 +-
12 files changed, 287 insertions(+), 26 deletions(-)
create mode 100644 icons/gnome/pump-symbolic.svg
create mode 100644 icons/gnome/water-droplet-symbolic.svg
create mode 100644 icons/original/pump-symbolic.svg
create mode 100644 icons/original/water-droplet-symbolic.svg
diff --git a/extension.js b/extension.js
index 87695818..4882e319 100644
--- a/extension.js
+++ b/extension.js
@@ -80,7 +80,7 @@ var VitalsMenuButton = GObject.registerClass({
this);
let settings = [ 'use-higher-precision', 'alphabetize', 'hide-zeros',
- 'fixed-widths', 'hide-icons', 'unit',
+ 'fixed-widths', 'hide-icons', 'unit', 'coolant-unit',
'memory-measurement', 'include-public-ip', 'network-public-ip-interval',
'network-public-ip-show-flag', 'network-public-ip-provider', 'network-speed-format', 'network-speed-unit', 'storage-measurement',
'include-static-info', 'include-static-gpu-info' ];
diff --git a/helpers/catalog.js b/helpers/catalog.js
index 36ce0a53..2ce876b7 100644
--- a/helpers/catalog.js
+++ b/helpers/catalog.js
@@ -1,8 +1,11 @@
/* Shared sensor catalog for shell and preferences. */
export const sensorCatalog = {
- 'temperature' : { 'icon': 'temperature-symbolic.svg', colorFormats: ['temp'] },
- 'voltage' : { 'icon': 'voltage-symbolic.svg' },
- 'fan' : { 'icon': 'fan-symbolic.svg', colorFormats: ['fan'] },
+ 'temperature' : { 'icon': 'temperature-symbolic.svg', colorFormats: ['temp'], aggregate: true },
+ 'coolant' : { 'icon': 'water-droplet-symbolic.svg', colorFormats: ['temp'], aggregate: true,
+ unitSetting: 'coolant-unit' },
+ 'voltage' : { 'icon': 'voltage-symbolic.svg', aggregate: true },
+ 'fan' : { 'icon': 'fan-symbolic.svg', colorFormats: ['fan'], aggregate: true },
+ 'pump' : { 'icon': 'pump-symbolic.svg', colorFormats: ['fan'], aggregate: true },
'memory' : { 'icon': 'memory-symbolic.svg', colorFormats: ['percent'] },
'processor' : { 'icon': 'cpu-symbolic.svg', colorFormats: ['percent'] },
'system' : { 'icon': 'system-symbolic.svg', colorFormats: ['load'] },
@@ -268,7 +271,14 @@ export function sensorGroupFromType(type) {
let group = (type || '').replace(/-group$/, '');
if (group.startsWith('gpu'))
return 'gpu';
- return group.replace(/#\d+$/, '');
+ group = group.replace(/#\d+$/, '');
+
+ // types may carry a suffix, eg 'network-rx' or 'network-us'. Every catalog
+ // group is a single word, so fall back to the leading segment.
+ if (!(group in sensorCatalog))
+ group = group.split('-')[0];
+
+ return group;
}
export function colorSettingsKeys() {
@@ -276,3 +286,13 @@ export function colorSettingsKeys() {
.filter(group => sensorCatalog[group].colorFormats)
.map(group => `${group}-colors`);
}
+
+// groups discovered from hardware monitors and summarized by a group average
+export function isAggregateGroup(group) {
+ return !!sensorCatalog[group]?.aggregate;
+}
+
+// a group may pick its own temperature unit, eg coolant reads coolant-unit
+export function unitSettingForType(type) {
+ return sensorCatalog[sensorGroupFromType(type)]?.unitSetting ?? 'unit';
+}
diff --git a/icons/gnome/pump-symbolic.svg b/icons/gnome/pump-symbolic.svg
new file mode 100644
index 00000000..a64d0d17
--- /dev/null
+++ b/icons/gnome/pump-symbolic.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/icons/gnome/water-droplet-symbolic.svg b/icons/gnome/water-droplet-symbolic.svg
new file mode 100644
index 00000000..9d6199a9
--- /dev/null
+++ b/icons/gnome/water-droplet-symbolic.svg
@@ -0,0 +1,3 @@
+
\ No newline at end of file
diff --git a/icons/original/pump-symbolic.svg b/icons/original/pump-symbolic.svg
new file mode 100644
index 00000000..a64d0d17
--- /dev/null
+++ b/icons/original/pump-symbolic.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/icons/original/water-droplet-symbolic.svg b/icons/original/water-droplet-symbolic.svg
new file mode 100644
index 00000000..9d6199a9
--- /dev/null
+++ b/icons/original/water-droplet-symbolic.svg
@@ -0,0 +1,3 @@
+
\ No newline at end of file
diff --git a/prefs.js b/prefs.js
index 341194fb..107cda1d 100644
--- a/prefs.js
+++ b/prefs.js
@@ -150,8 +150,10 @@ const Settings = new GObject.Class({
this._sensorPageGates = {
'temperature': { toggle: 'show-temperature', widgets: ['unit'] },
+ 'coolant': { toggle: 'show-coolant', widgets: ['coolant-unit'] },
'voltage': { toggle: 'show-voltage', widgets: [] },
'fan': { toggle: 'show-fan', widgets: [] },
+ 'pump': { toggle: 'show-pump', widgets: [] },
'memory': { toggle: 'show-memory', widgets: ['memory-measurement'] },
'processor': { toggle: 'show-processor', widgets: ['include-static-info'] },
'system': { toggle: 'show-system', widgets: ['monitor-cmd'] },
@@ -237,6 +239,7 @@ const Settings = new GObject.Class({
// process sensor toggles
let sensors = [ 'show-temperature', 'show-voltage', 'show-fan',
+ 'show-coolant', 'show-pump',
'show-memory', 'show-processor', 'show-system',
'show-network', 'show-storage', 'use-higher-precision',
'alphabetize', 'hide-zeros', 'include-public-ip',
@@ -264,7 +267,7 @@ const Settings = new GObject.Class({
// process individual drop down sensor preferences
sensors = [
- 'position-in-panel', 'unit', 'network-speed-format', 'network-speed-unit',
+ 'position-in-panel', 'unit', 'coolant-unit', 'network-speed-format', 'network-speed-unit',
'memory-measurement', 'storage-measurement', 'battery-slot', 'icon-style',
'network-public-ip-provider'
];
diff --git a/prefs.ui b/prefs.ui
index c8d1c19b..098e3c91 100644
--- a/prefs.ui
+++ b/prefs.ui
@@ -202,6 +202,45 @@
+
+
+
voltage
@@ -250,6 +289,30 @@
+
+
+ pump
+ Pump
+ pump-symbolic
+
+
+ 10
+ 10
+
+
+ Monitor pump
+ show-pump
+
+
+ center
+
+
+
+
+
+
+
+
memory
diff --git a/schemas/org.gnome.shell.extensions.vitals.gschema.xml b/schemas/org.gnome.shell.extensions.vitals.gschema.xml
index 3bb9fa5d..6e523479 100644
--- a/schemas/org.gnome.shell.extensions.vitals.gschema.xml
+++ b/schemas/org.gnome.shell.extensions.vitals.gschema.xml
@@ -41,11 +41,26 @@
Temperature unitThe unit ('centigrade' or 'fahrenheit') the extension should display the temperature in
+
+ 0
+ Coolant temperature unit
+ The unit ('centigrade' or 'fahrenheit') the extension should display the coolant temperature in
+ trueMonitor voltageDisplay voltage of various components
+
+ true
+ Monitor coolant
+ Display liquid cooling loop coolant temperature
+
+
+ true
+ Monitor pump
+ Display liquid cooling pump rotation per minute
+ trueMonitor fan
@@ -179,6 +194,16 @@
Fan color thresholdsColor rules matched against fan RPM. Entries are "threshold r g b".
+
+ ["35 0.9647058844566345 0.8274509906768799 0.1764705926179886", "40 1 0.47058823704719543 0", "45 0.8784313797950745 0.10588235408067703 0.1411764770746231"]
+ Coolant color thresholds
+ Color rules matched against the displayed coolant temperature. Entries are "threshold r g b".
+
+
+ []
+ Pump color thresholds
+ Color rules matched against pump RPM. Entries are "threshold r g b".
+ ["65 0.9647058844566345 0.8274509906768799 0.1764705926179886", "80 1 0.47058823704719543 0", "92 0.8784313797950745 0.10588235408067703 0.1411764770746231"]Memory usage color thresholds
diff --git a/sensors.js b/sensors.js
index 8c303931..5da28130 100644
--- a/sensors.js
+++ b/sensors.js
@@ -26,8 +26,10 @@
import GLib from 'gi://GLib';
import GObject from 'gi://GObject';
+import Gio from 'gi://Gio';
import * as SubProcessModule from './helpers/subprocess.js';
import * as FileModule from './helpers/file.js';
+import { isAggregateGroup } from './helpers/catalog.js';
import { gettext as _ } from 'resource:///org/gnome/shell/extensions/extension.js';
let GTop, hasGTop = true;
@@ -134,8 +136,8 @@ export const Sensors = GObject.registerClass({
for (let sensor in this._sensorIcons) {
if (this._settings.get_boolean('show-' + sensor)) {
- if (sensor == 'temperature' || sensor == 'voltage' || sensor == 'fan') {
- // for temp, volt, fan, we have a shared handler
+ if (isAggregateGroup(sensor)) {
+ // hardware monitor sensors share a handler
this._queryTempVoltFan(callback, sensor);
} else {
// directly call queryFunction below
@@ -150,6 +152,11 @@ export const Sensors = GObject.registerClass({
for (let label in this._tempVoltFanSensors[type]) {
let sensor = this._tempVoltFanSensors[type][label];
+ if (sensor['aquacomputer']) {
+ this._readAquacomputerSensor(callback, label, sensor, type);
+ continue;
+ }
+
new FileModule.File(sensor['path']).read().then(value => {
this._returnValue(callback, label, value, type, sensor['format']);
}).catch(err => {
@@ -158,6 +165,53 @@ export const Sensors = GObject.registerClass({
}
}
+ // The Aquacomputer Vision publishes a 64 byte HID report rather than hwmon
+ // entries. Byte 0 is the report id and offset 0x37 holds the coolant
+ // temperature as a big endian value in hundredths of a degree.
+ _readAquacomputerSensor(callback, label, sensor, type) {
+ const REPORT_LENGTH = 64;
+ const REPORT_ID = 0x01;
+ const COOLANT_TEMP_OFFSET = 0x37;
+
+ let disabled = () => this._returnValue(callback, label, 'disabled', type, sensor['format']);
+
+ try {
+ Gio.File.new_for_path(sensor['path']).read_async(GLib.PRIORITY_DEFAULT, null, (file, res) => {
+ let stream;
+
+ try {
+ stream = file.read_finish(res);
+ } catch (e) {
+ disabled();
+ return;
+ }
+
+ stream.read_bytes_async(REPORT_LENGTH, GLib.PRIORITY_DEFAULT, null, (input, res2) => {
+ try {
+ let data = new Uint8Array(input.read_bytes_finish(res2).get_data());
+
+ if (data.length < REPORT_LENGTH || data[0] !== REPORT_ID) {
+ disabled();
+ return;
+ }
+
+ let raw = (data[COOLANT_TEMP_OFFSET] << 8) | data[COOLANT_TEMP_OFFSET + 1];
+
+ // hundredths of a degree to millidegrees
+ this._returnValue(callback, label, raw * 10, type, sensor['format']);
+ } catch (e) {
+ disabled();
+ } finally {
+ // the device is polled on every refresh, so don't leak the fd
+ input.close_async(GLib.PRIORITY_DEFAULT, null, null);
+ }
+ });
+ });
+ } catch (e) {
+ disabled();
+ }
+ }
+
_queryMemory(callback) {
// check memory info
new FileModule.File('/proc/meminfo').read().then(lines => {
@@ -802,7 +856,8 @@ export const Sensors = GObject.registerClass({
}
_discoverHardwareMonitors(callback) {
- this._tempVoltFanSensors = { 'temperature': {}, 'voltage': {}, 'fan': {} };
+ this._tempVoltFanSensors = { 'temperature': {}, 'voltage': {}, 'fan': {},
+ 'coolant': {}, 'pump': {} };
let hwbase = '/sys/class/hwmon/';
@@ -815,7 +870,8 @@ export const Sensors = GObject.registerClass({
if (this._settings.get_boolean('show-voltage'))
sensor_types['in'] = 'voltage';
- if (this._settings.get_boolean('show-fan'))
+ // the pump reports through a fan input, so discover fans for either group
+ if (this._settings.get_boolean('show-fan') || this._settings.get_boolean('show-pump'))
sensor_types['fan'] = 'fan';
// a little informal, but this code has zero I/O block
@@ -891,6 +947,79 @@ export const Sensors = GObject.registerClass({
this._reconfigureNvidiaSmiProcess();
this._discoverGpuDrm();
this._initFrameMonitor();
+ this._discoverAquacomputerVision();
+ }
+
+ // The Aquacomputer Vision has no hwmon driver, so find its raw HID node by
+ // walking /sys/class/hidraw and matching the USB vendor and product id.
+ // It is a composite device that also exposes keyboard and consumer control
+ // interfaces, and only the vendor defined one carries telemetry, so match
+ // the report descriptor's usage page too. Reading the wrong interface would
+ // block forever because it never sends a report.
+ _discoverAquacomputerVision() {
+ const VENDOR_ID = '0c70';
+ const PRODUCT_ID = 'f00c';
+ const HIDRAW_CLASS = '/sys/class/hidraw/';
+
+ // Usage Page (Vendor Defined 0xFF00)
+ const VENDOR_USAGE_PAGE = [ 0x06, 0x00, 0xff ];
+
+ let enumerator;
+
+ try {
+ enumerator = Gio.File.new_for_path(HIDRAW_CLASS).enumerate_children(
+ 'standard::name', Gio.FileQueryInfoFlags.NONE, null);
+ } catch (e) {
+ // no hidraw devices present, or we can't enumerate them
+ return;
+ }
+
+ try {
+ let info;
+ while ((info = enumerator.next_file(null)) !== null) {
+ let name = info.get_name();
+ let device;
+
+ try {
+ // resolves to something like ../../devices/.../0003:0C70:F00C.000B
+ device = GLib.file_read_link(HIDRAW_CLASS + name + '/device');
+ } catch (e) {
+ continue;
+ }
+
+ let ids = device.substr(device.lastIndexOf('/') + 1)
+ .match(/^[0-9A-Fa-f]+:([0-9A-Fa-f]{4}):([0-9A-Fa-f]{4})\./);
+
+ if (!ids || ids[1].toLowerCase() != VENDOR_ID || ids[2].toLowerCase() != PRODUCT_ID)
+ continue;
+
+ let descriptor;
+
+ try {
+ let [ok, contents] = GLib.file_get_contents(
+ HIDRAW_CLASS + name + '/device/report_descriptor');
+
+ if (!ok) continue;
+ descriptor = contents;
+ } catch (e) {
+ continue;
+ }
+
+ if (!VENDOR_USAGE_PAGE.every((byte, i) => descriptor[i] === byte))
+ continue;
+
+ this._addTempVoltFan(null, {
+ 'type': 'coolant',
+ 'format': 'temp',
+ 'input': '/dev/' + name,
+ 'aquacomputer': true
+ }, 'AC Vision', 'Coolant Temp', '', 0);
+
+ break;
+ }
+ } finally {
+ enumerator.close(null);
+ }
}
_discoverGpuDrm() {
@@ -1045,18 +1174,26 @@ export const Sensors = GObject.registerClass({
if (label == 'iwlwifi_1 temp1') label = 'Wireless Adapter';
if (label == 'Package id 0') label = 'Processor 0';
if (label == 'Package id 1') label = 'Processor 1';
+ if (label == 'nct6799 fan1') label = 'VRM HeatSink Fan';
+ if (label == 'nct6799 fan2') label = 'Radiator Fan(s)';
+ if (label == 'nct6799 fan6') label = 'Chipset/NVMe Fan';
+ if (label == 'nct6799 fan7') label = 'AIO Pump';
+ if (label == 'nct6799 SYSTIN') label = 'Motherboard Temp';
+ if (label == 'nct6799 CPUTIN') label = 'CPU Socket Temp';
label = label.replace('Package id', 'CPU');
- let types = [ 'temperature', 'voltage', 'fan' ];
- for (let type of types) {
+ // the pump arrives on a fan input but belongs to its own group
+ let sensorType = (label == 'AIO Pump') ? 'pump' : obj['type'];
+
+ for (let group in this._tempVoltFanSensors) {
// check if this label already exists
- if (label in this._tempVoltFanSensors[type]) {
+ if (label in this._tempVoltFanSensors[group]) {
for (let i = 2; i <= 9; i++) {
// append an incremented number to end
let new_label = label + ' ' + i;
// if new label is available, use it
- if (!(new_label in this._tempVoltFanSensors[type])) {
+ if (!(new_label in this._tempVoltFanSensors[group])) {
label = new_label;
break;
}
@@ -1065,11 +1202,14 @@ export const Sensors = GObject.registerClass({
}
// update screen on initial build to prevent delay on update
- this._returnValue(callback, label, value, obj['type'], obj['format']);
-
- this._tempVoltFanSensors[obj['type']][label] = {
- 'format': obj['format'],
- 'path': obj['input']
+ // raw HID sensors are registered without a value, so skip them here
+ if (callback && !obj['aquacomputer'])
+ this._returnValue(callback, label, value, sensorType, obj['format']);
+
+ this._tempVoltFanSensors[sensorType][label] = {
+ 'format': obj['format'],
+ 'path': obj['input'],
+ 'aquacomputer': obj['aquacomputer'] || false
};
}
diff --git a/stylesheet.css b/stylesheet.css
index 17879c21..63853699 100644
--- a/stylesheet.css
+++ b/stylesheet.css
@@ -1,8 +1,10 @@
.vitals-panel-item{spacing: 0;}
.vitals-panel-menu{spacing: 11px; padding: 3px; }
.vitals-panel-icon-temperature { margin: 0 1px 0 0; padding: 0; }
+.vitals-panel-icon-coolant { margin: 0 1px 0 0; padding: 0; }
.vitals-panel-icon-voltage { margin: 0 0 0 0; padding: 0; }
.vitals-panel-icon-fan { margin: 0 4px 0 0; padding: 0; }
+.vitals-panel-icon-pump { margin: 0 4px 0 0; padding: 0; }
.vitals-panel-icon-memory { margin: 0 2px 0 0; padding: 0; }
.vitals-panel-icon-processor { margin: 0 3px 0 0; padding: 0; }
.vitals-panel-icon-system { margin: 0 3px 0 0; padding: 0; }
diff --git a/values.js b/values.js
index 1255e8ce..388391f2 100644
--- a/values.js
+++ b/values.js
@@ -26,7 +26,7 @@
import GObject from 'gi://GObject';
-import {sensorCatalog, sensorGroupFromType} from './helpers/catalog.js';
+import {sensorCatalog, sensorGroupFromType, isAggregateGroup, unitSettingForType} from './helpers/catalog.js';
const cbFun = (d, c) => {
let bb = d[1] % c[0],
@@ -94,15 +94,15 @@ function getUsageColor(value, colors) {
}
function colorsKeyForSensor(type, format) {
- // All temperatures share the temperature threshold UI, including GPU rows.
- if (format === 'temp')
- return 'temperature-colors';
-
const group = sensorGroupFromType(type);
const formats = sensorCatalog[group]?.colorFormats;
if (formats && formats.includes(format))
return `${group}-colors`;
+ // remaining temperatures share the temperature threshold UI, including GPU rows
+ if (format === 'temp')
+ return 'temperature-colors';
+
return null;
}
@@ -150,7 +150,7 @@ export const Values = GObject.registerClass({
ending = '°C';
// are we converting to fahrenheit?
- if (this._settings.get_int('unit') == 1) {
+ if (this._settings.get_int(unitSettingForType(type)) == 1) {
value = ((9 / 5) * value + 32);
ending = '°F';
}
@@ -346,7 +346,7 @@ export const Values = GObject.registerClass({
this._history[historyType][key] = [legible.text, value];
// process average, min and max values
- if (type == 'temperature' || type == 'voltage' || type == 'fan') {
+ if (isAggregateGroup(type)) {
let vals = Object.values(this._history[type]).map(x => parseFloat(x[1]));
// show value in group even if there is one value present