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