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 @@ + + + coolant + Coolant + water-droplet-symbolic + + + 10 + 10 + + + Monitor coolant + show-coolant + + + center + + + + + + + Unit + coolant-unit + + + center + + °C + °F + + + + + + + + + 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 unit The 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 + true Monitor voltage Display voltage of various components + + true + Monitor coolant + Display liquid cooling loop coolant temperature + + + true + Monitor pump + Display liquid cooling pump rotation per minute + true Monitor fan @@ -179,6 +194,16 @@ Fan color thresholds Color 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