Add GetMeta introspection, drop comments and °F support

This commit is contained in:
2026-04-01 03:08:28 +09:00
parent a5259088d6
commit 3e7e68a486
4 changed files with 162 additions and 185 deletions

View File

@@ -13,101 +13,67 @@ import {Extension, gettext as _} from 'resource:///org/gnome/shell/extensions/ex
import SensorClient from './sensorClient.js';
import SensorItem from './sensorItem.js';
// ---------- helpers ----------
// Gio.icon_new_for_string only takes one name; use ThemedIcon for fallbacks
function safeIcon(names) {
if (typeof names === 'string')
names = [names];
return new Gio.ThemedIcon({ names });
}
// ---------- category config ----------
function formatByUnit(unit, val, dec) {
if (!unit)
return (dec ? '%.2f' : '%.1f').format(val);
const CATEGORIES = {
Thermal: {
icon: ['sensors-temperature-symbolic', 'temperature-symbolic', 'dialog-warning-symbolic'],
format: (v, dec, unit) => {
if (unit === 1) v = v * 9 / 5 + 32;
return (dec ? '%.1f' : '%.0f').format(v) + (unit === 1 ? '\u00b0F' : '\u00b0C');
},
convert: (v, unit) => unit === 1 ? v * 9 / 5 + 32 : v,
summary: (r) => Math.max(...Object.values(r)),
sortOrder: 0,
},
Cpu: {
icon: ['utilities-system-monitor-symbolic', 'org.gnome.SystemMonitor-symbolic', 'computer-symbolic'],
format: (v, dec) => (dec ? '%.1f' : '%.0f').format(v) + '%',
summary: (r) => r['total'] ?? null,
sortOrder: 1,
},
Power: {
icon: ['battery-full-charged-symbolic', 'battery-symbolic', 'plug-symbolic'],
format: (v, dec) => (dec ? '%.2f' : '%.1f').format(v) + ' W',
summary: (r) => r['package-0'] ?? null,
sortOrder: 2,
},
Memory: {
icon: ['drive-harddisk-symbolic', 'media-memory-symbolic', 'computer-symbolic'],
format: (v, dec, _unit, key) => {
if (key === 'percent' || key === 'swap_percent')
return (dec ? '%.1f' : '%.0f').format(v) + '%';
if (v >= 1073741824)
return '%.1f GiB'.format(v / 1073741824);
if (v >= 1048576)
return '%.0f MiB'.format(v / 1048576);
return '%.0f KiB'.format(v / 1024);
},
summary: (r) => r['percent'] ?? null,
summaryKey: 'percent',
sortOrder: 3,
},
Battery: {
icon: ['battery-symbolic', 'battery-full-charged-symbolic', 'plug-symbolic'],
// status codes: 1=Charging, 2=Discharging, 3=Not charging, 4=Full
_statusNames: { 1: 'Charging', 2: 'Discharging', 3: 'Not charging', 4: 'Full' },
format: function (v, dec, _unit, key) {
if (key.endsWith('/percent'))
return (dec ? '%.1f' : '%.0f').format(v) + '%';
if (key.endsWith('/status'))
return this._statusNames[v] ?? 'Unknown';
if (key.endsWith('/power'))
return (dec ? '%.2f' : '%.1f').format(v) + ' W';
if (key.endsWith('/energy_now') || key.endsWith('/energy_full'))
return '%.1f Wh'.format(v);
if (key.endsWith('/cycles'))
return '%.0f'.format(v);
if (key.endsWith('/online'))
return v ? 'Yes' : 'No';
return (dec ? '%.2f' : '%.1f').format(v);
},
summary: (r) => {
for (let k of Object.keys(r))
if (k.endsWith('/percent')) return r[k];
return null;
},
summaryKey: 'percent',
sortOrder: 4,
},
};
const DEFAULT_CATEGORY = {
icon: ['dialog-information-symbolic'],
format: (v, dec) => (dec ? '%.2f' : '%.1f').format(v),
sortOrder: 99,
};
function catCfg(cat) {
return CATEGORIES[cat] || DEFAULT_CATEGORY;
switch (unit) {
case '%':
return (dec ? '%.1f' : '%.0f').format(val) + '%';
case 'W':
return (dec ? '%.2f' : '%.1f').format(val) + ' W';
case 'Wh':
return '%.1f Wh'.format(val);
case '\u00b0C':
return (dec ? '%.1f' : '%.0f').format(val) + '\u00b0C';
case 'bytes':
if (val >= 1073741824) return '%.1f GiB'.format(val / 1073741824);
if (val >= 1048576) return '%.0f MiB'.format(val / 1048576);
return '%.0f KiB'.format(val / 1024);
case 'count':
return '%.0f'.format(val);
case 'bool':
return val ? 'Yes' : 'No';
default:
if (unit.startsWith('enum:')) {
let names = unit.slice(5).split(',');
let idx = Math.round(val) - 1;
return (idx >= 0 && idx < names.length) ? names[idx] : 'Unknown';
}
return (dec ? '%.2f' : '%.1f').format(val) + ' ' + unit;
}
}
function formatSensor(cat, key, val, dec, unit) {
let cfg = catCfg(cat);
let v = cfg.convert ? cfg.convert(val, unit) : val;
return cfg.format(v, dec, unit, key);
function autoSummary(readings, units) {
if (!readings || !units)
return null;
if ('total' in readings) return { key: 'total', val: readings['total'] };
if ('percent' in readings) return { key: 'percent', val: readings['percent'] };
for (let k of Object.keys(readings))
if (k.endsWith('/percent')) return { key: k, val: readings[k] };
let k = Object.keys(readings)[0];
return k ? { key: k, val: readings[k] } : null;
}
// ---------- panel button ----------
const SORT_ORDER = { Thermal: 0, Cpu: 1, Power: 2, Memory: 3, Battery: 4 };
function catIcon(cat, meta) {
let m = meta?.get(cat);
if (m?.icon)
return [m.icon, 'dialog-information-symbolic'];
return ['dialog-information-symbolic'];
}
function formatSensor(cat, key, val, dec, meta) {
let unit = meta?.get(cat)?.units?.[key] ?? null;
return formatByUnit(unit, val, dec);
}
class SensorTrayButton extends PanelMenu.Button {
@@ -122,31 +88,26 @@ class SensorTrayButton extends PanelMenu.Button {
this._path = path;
this._client = new SensorClient();
// menu state
this._subMenus = {}; // category → PopupSubMenuMenuItem
this._menuItems = {}; // fullKey → SensorItem
this._subMenus = {};
this._menuItems = {};
this._lastKeys = null;
// panel state
this._panelBox = new St.BoxLayout();
this.add_child(this._panelBox);
this._hotLabels = {}; // fullKey → St.Label
this._hotIcons = {}; // fullKey → St.Icon
this._hotLabels = {};
this._hotIcons = {};
this._buildPanel();
// settings
this._sigIds = [];
this._connectSetting('hot-sensors', () => { this._buildPanel(); this._updatePanel(); this._syncPinOrnaments(); });
this._connectSetting('show-icon-on-panel', () => { this._buildPanel(); this._updatePanel(); });
this._connectSetting('panel-spacing', () => { this._buildPanel(); this._updatePanel(); });
this._connectSetting('unit', () => this._refresh());
this._connectSetting('show-decimal-value', () => this._refresh());
this._connectSetting('position-in-panel', () => this._reposition());
this._connectSetting('panel-box-index', () => this._reposition());
this._connectSetting('update-interval', () => this._restartRefreshTimer());
// throttle UI repaints via a timer
this._dirty = false;
this._refreshTimerId = 0;
this._startRefreshTimer();
@@ -166,8 +127,6 @@ class SensorTrayButton extends PanelMenu.Button {
this._sigIds.push(this._settings.connect('changed::' + key, cb));
}
// ---- panel (top bar): pinned sensors ----
_buildPanel() {
this._panelBox.destroy_all_children();
this._hotLabels = {};
@@ -179,17 +138,17 @@ class SensorTrayButton extends PanelMenu.Button {
if (hot.length === 0) {
this._panelBox.add_child(new St.Icon({
style_class: 'system-status-icon',
gicon: safeIcon(CATEGORIES.Thermal.icon),
gicon: safeIcon(['sensors-temperature-symbolic', 'dialog-information-symbolic']),
}));
return;
}
let meta = this._client.meta;
for (let i = 0; i < hot.length; i++) {
let fullKey = hot[i];
let cat = fullKey.split('/')[0];
let cfg = catCfg(cat);
// spacer between pinned items
if (i > 0) {
let spacing = this._settings.get_int('panel-spacing');
this._panelBox.add_child(new St.Widget({ width: spacing }));
@@ -198,7 +157,7 @@ class SensorTrayButton extends PanelMenu.Button {
if (showIcon) {
let icon = new St.Icon({
style_class: 'system-status-icon',
gicon: safeIcon(cfg.icon),
gicon: safeIcon(catIcon(cat, meta)),
});
this._hotIcons[fullKey] = icon;
this._panelBox.add_child(icon);
@@ -217,7 +176,7 @@ class SensorTrayButton extends PanelMenu.Button {
_updatePanel() {
let dec = this._settings.get_boolean('show-decimal-value');
let unit = this._settings.get_int('unit');
let meta = this._client.meta;
for (let [fullKey, label] of Object.entries(this._hotLabels)) {
let parts = fullKey.split('/');
@@ -230,12 +189,10 @@ class SensorTrayButton extends PanelMenu.Button {
continue;
}
label.text = formatSensor(cat, key, readings[key], dec, unit);
label.text = formatSensor(cat, key, readings[key], dec, meta);
}
}
// ---- dropdown: collapsed submenus per category ----
_onSensorChanged(category, _readings) {
if (category === null) {
this._menuItems = {};
@@ -276,9 +233,9 @@ class SensorTrayButton extends PanelMenu.Button {
_sortedEntries() {
let entries = [];
for (let [cat, readings] of this._client.readings) {
let cfg = catCfg(cat);
let order = SORT_ORDER[cat] ?? 99;
for (let key of Object.keys(readings))
entries.push({ cat, key, fullKey: cat + '/' + key, sortOrder: cfg.sortOrder });
entries.push({ cat, key, fullKey: cat + '/' + key, sortOrder: order });
}
entries.sort((a, b) => {
if (a.sortOrder !== b.sortOrder) return a.sortOrder - b.sortOrder;
@@ -302,7 +259,6 @@ class SensorTrayButton extends PanelMenu.Button {
let hot = this._settings.get_strv('hot-sensors');
// group by category
let grouped = new Map();
for (let e of entries) {
if (!grouped.has(e.cat))
@@ -310,17 +266,18 @@ class SensorTrayButton extends PanelMenu.Button {
grouped.get(e.cat).push(e);
}
for (let [cat, catEntries] of grouped) {
let cfg = catCfg(cat);
let meta = this._client.meta;
for (let [cat, catEntries] of grouped) {
let iconNames = catIcon(cat, meta);
// create a collapsed submenu for this category
let sub = new PopupMenu.PopupSubMenuMenuItem(cat, true);
sub.icon.gicon = safeIcon(cfg.icon);
sub.icon.gicon = safeIcon(iconNames);
this._subMenus[cat] = sub;
this.menu.addMenuItem(sub);
for (let e of catEntries) {
let gicon = safeIcon(cfg.icon);
let gicon = safeIcon(iconNames);
let item = new SensorItem(gicon, e.fullKey, e.key, '\u2026');
if (hot.includes(e.fullKey))
@@ -333,7 +290,6 @@ class SensorTrayButton extends PanelMenu.Button {
}
}
// settings footer
this.menu.addMenuItem(new PopupMenu.PopupSeparatorMenuItem());
let settingsItem = new PopupMenu.PopupMenuItem(_('Settings'));
settingsItem.connect('activate', () => {
@@ -351,24 +307,22 @@ class SensorTrayButton extends PanelMenu.Button {
_updateValues() {
let dec = this._settings.get_boolean('show-decimal-value');
let unit = this._settings.get_int('unit');
let meta = this._client.meta;
for (let [cat, readings] of this._client.readings) {
let units = meta.get(cat)?.units;
for (let [key, val] of Object.entries(readings)) {
let item = this._menuItems[cat + '/' + key];
if (item)
item.value = formatSensor(cat, key, val, dec, unit);
item.value = formatSensor(cat, key, val, dec, meta);
}
// update submenu header with a summary (e.g. max temp)
let sub = this._subMenus[cat];
if (sub && sub.status) {
let cfg = catCfg(cat);
if (cfg.summary) {
let sv = cfg.summary(readings);
if (sv !== null)
sub.status.text = formatSensor(cat, cfg.summaryKey || '', sv, dec, unit);
}
let s = autoSummary(readings, units);
if (s)
sub.status.text = formatByUnit(units?.[s.key], s.val, dec);
}
}
}
@@ -397,8 +351,6 @@ class SensorTrayButton extends PanelMenu.Button {
this._updatePanel();
}
// ---- panel position ----
_reposition() {
try {
if (!this.container?.get_parent()) return;
@@ -417,8 +369,6 @@ class SensorTrayButton extends PanelMenu.Button {
}
}
// ---- cleanup ----
_onDestroy() {
this._client.destroy();
if (this._refreshTimerId) {
@@ -435,8 +385,6 @@ class SensorTrayButton extends PanelMenu.Button {
}
}
// ---------- extension entry point ----------
export default class SensorTrayExtension extends Extension {
enable() {

View File

@@ -123,13 +123,6 @@ export default class SensorTrayPreferences extends ExtensionPreferences {
_createDisplayGroup() {
let group = new Adw.PreferencesGroup({ title: _('Display') });
let unitRow = new Adw.ComboRow({
title: _('Temperature Unit'),
model: new Gtk.StringList({ strings: ['\u00b0C', '\u00b0F'] }),
});
this._settings.bind('unit', unitRow, 'selected', Gio.SettingsBindFlags.DEFAULT);
group.add(unitRow);
group.add(this._switch(_('Show Decimal Values'), 'show-decimal-value'));
group.add(this._switch(_('Show Icon on Panel'), 'show-icon-on-panel'));

View File

@@ -5,35 +5,18 @@ const BUS_NAME = 'org.sensord';
const OBJECT_PATH = '/org/sensord';
const IFACE_PREFIX = 'org.sensord.';
/**
* Generic client for all org.sensord.* D-Bus interfaces.
*
* Every interface has the same shape:
* method GetReadings() → a{sd}
* signal Changed(a{sd})
*
* The client introspects the object once, discovers all sensor interfaces,
* fetches initial readings, then subscribes to Changed signals.
* Callers get a flat Map<category, Map<key, double>> that stays current.
*/
export default class SensorClient {
constructor() {
this._conn = null;
this._signalIds = [];
// category → { key: value } e.g. "Power" → { "package-0": 42.3 }
this._readings = new Map();
this._meta = new Map();
this._onChanged = null;
this._available = false;
this._nameWatchId = 0;
}
/**
* Connect to the system bus and start receiving sensor data.
* @param {function(string, Object<string,number>)} onChanged
* Called with (category, readings) whenever a sensor interface emits Changed
* and also once per interface after initial GetReadings.
*/
start(onChanged) {
this._onChanged = onChanged;
@@ -44,7 +27,6 @@ export default class SensorClient {
return;
}
// Watch for sensord appearing/disappearing on the bus
this._nameWatchId = Gio.bus_watch_name_on_connection(
this._conn,
BUS_NAME,
@@ -63,17 +45,15 @@ export default class SensorClient {
this._available = false;
this._unsubscribeAll();
this._readings.clear();
this._meta.clear();
if (this._onChanged)
this._onChanged(null, null); // signal "all gone"
this._onChanged(null, null);
}
/**
* Introspect /org/sensord, find all org.sensord.* interfaces,
* call GetReadings on each, subscribe to Changed.
*/
_discover() {
this._unsubscribeAll();
this._readings.clear();
this._meta.clear();
let introXml;
try {
@@ -89,7 +69,6 @@ export default class SensorClient {
return;
}
// Parse interface names from XML — simple regex is fine here
let ifaces = [];
let re = /interface\s+name="(org\.sensord\.[^"]+)"/g;
let m;
@@ -97,9 +76,8 @@ export default class SensorClient {
ifaces.push(m[1]);
for (let iface of ifaces) {
let category = iface.slice(IFACE_PREFIX.length); // "Power", "Thermal", etc.
let category = iface.slice(IFACE_PREFIX.length);
// Subscribe to Changed signal
let sid = this._conn.signal_subscribe(
BUS_NAME, iface, 'Changed', OBJECT_PATH,
null, Gio.DBusSignalFlags.NONE,
@@ -112,7 +90,26 @@ export default class SensorClient {
);
this._signalIds.push(sid);
// Fetch initial state
this._conn.call(
BUS_NAME, OBJECT_PATH, iface, 'GetMeta',
null, GLib.VariantType.new('(a{sv})'),
Gio.DBusCallFlags.NONE, 3000, null,
(conn, res) => {
try {
let result = conn.call_finish(res);
let [meta] = result.deep_unpack();
let parsed = {};
if (meta['icon'])
parsed.icon = meta['icon'].deep_unpack();
if (meta['units'])
parsed.units = meta['units'].deep_unpack();
this._meta.set(category, parsed);
} catch (e) {
console.debug(`sensortray: GetMeta(${iface}) unavailable:`, e.message);
}
},
);
this._conn.call(
BUS_NAME, OBJECT_PATH, iface, 'GetReadings',
null, GLib.VariantType.new('(a{sd})'),
@@ -140,18 +137,9 @@ export default class SensorClient {
this._signalIds = [];
}
/** @returns {boolean} true if sensord is on the bus */
get available() {
return this._available;
}
/**
* @returns {Map<string, Object<string,number>>}
* category → { key: value } snapshot of all current readings
*/
get readings() {
return this._readings;
}
get available() { return this._available; }
get readings() { return this._readings; }
get meta() { return this._meta; }
destroy() {
this._unsubscribeAll();
@@ -162,5 +150,6 @@ export default class SensorClient {
this._conn = null;
this._onChanged = null;
this._readings.clear();
this._meta.clear();
}
}