mirror of
https://github.com/morgan9e/linux-sys-telemetry
synced 2026-04-13 15:55:04 +09:00
Add GetMeta introspection, drop comments and °F support
This commit is contained in:
@@ -13,101 +13,67 @@ import {Extension, gettext as _} from 'resource:///org/gnome/shell/extensions/ex
|
|||||||
import SensorClient from './sensorClient.js';
|
import SensorClient from './sensorClient.js';
|
||||||
import SensorItem from './sensorItem.js';
|
import SensorItem from './sensorItem.js';
|
||||||
|
|
||||||
// ---------- helpers ----------
|
|
||||||
|
|
||||||
// Gio.icon_new_for_string only takes one name; use ThemedIcon for fallbacks
|
|
||||||
function safeIcon(names) {
|
function safeIcon(names) {
|
||||||
if (typeof names === 'string')
|
if (typeof names === 'string')
|
||||||
names = [names];
|
names = [names];
|
||||||
return new Gio.ThemedIcon({ names });
|
return new Gio.ThemedIcon({ names });
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------- category config ----------
|
function formatByUnit(unit, val, dec) {
|
||||||
|
if (!unit)
|
||||||
|
return (dec ? '%.2f' : '%.1f').format(val);
|
||||||
|
|
||||||
const CATEGORIES = {
|
switch (unit) {
|
||||||
Thermal: {
|
case '%':
|
||||||
icon: ['sensors-temperature-symbolic', 'temperature-symbolic', 'dialog-warning-symbolic'],
|
return (dec ? '%.1f' : '%.0f').format(val) + '%';
|
||||||
format: (v, dec, unit) => {
|
case 'W':
|
||||||
if (unit === 1) v = v * 9 / 5 + 32;
|
return (dec ? '%.2f' : '%.1f').format(val) + ' W';
|
||||||
return (dec ? '%.1f' : '%.0f').format(v) + (unit === 1 ? '\u00b0F' : '\u00b0C');
|
case 'Wh':
|
||||||
},
|
return '%.1f Wh'.format(val);
|
||||||
convert: (v, unit) => unit === 1 ? v * 9 / 5 + 32 : v,
|
case '\u00b0C':
|
||||||
summary: (r) => Math.max(...Object.values(r)),
|
return (dec ? '%.1f' : '%.0f').format(val) + '\u00b0C';
|
||||||
sortOrder: 0,
|
case 'bytes':
|
||||||
},
|
if (val >= 1073741824) return '%.1f GiB'.format(val / 1073741824);
|
||||||
Cpu: {
|
if (val >= 1048576) return '%.0f MiB'.format(val / 1048576);
|
||||||
icon: ['utilities-system-monitor-symbolic', 'org.gnome.SystemMonitor-symbolic', 'computer-symbolic'],
|
return '%.0f KiB'.format(val / 1024);
|
||||||
format: (v, dec) => (dec ? '%.1f' : '%.0f').format(v) + '%',
|
case 'count':
|
||||||
summary: (r) => r['total'] ?? null,
|
return '%.0f'.format(val);
|
||||||
sortOrder: 1,
|
case 'bool':
|
||||||
},
|
return val ? 'Yes' : 'No';
|
||||||
Power: {
|
default:
|
||||||
icon: ['battery-full-charged-symbolic', 'battery-symbolic', 'plug-symbolic'],
|
if (unit.startsWith('enum:')) {
|
||||||
format: (v, dec) => (dec ? '%.2f' : '%.1f').format(v) + ' W',
|
let names = unit.slice(5).split(',');
|
||||||
summary: (r) => r['package-0'] ?? null,
|
let idx = Math.round(val) - 1;
|
||||||
sortOrder: 2,
|
return (idx >= 0 && idx < names.length) ? names[idx] : 'Unknown';
|
||||||
},
|
}
|
||||||
Memory: {
|
return (dec ? '%.2f' : '%.1f').format(val) + ' ' + unit;
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatSensor(cat, key, val, dec, unit) {
|
function autoSummary(readings, units) {
|
||||||
let cfg = catCfg(cat);
|
if (!readings || !units)
|
||||||
let v = cfg.convert ? cfg.convert(val, unit) : val;
|
return null;
|
||||||
return cfg.format(v, dec, unit, key);
|
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 {
|
class SensorTrayButton extends PanelMenu.Button {
|
||||||
|
|
||||||
@@ -122,31 +88,26 @@ class SensorTrayButton extends PanelMenu.Button {
|
|||||||
this._path = path;
|
this._path = path;
|
||||||
this._client = new SensorClient();
|
this._client = new SensorClient();
|
||||||
|
|
||||||
// menu state
|
this._subMenus = {};
|
||||||
this._subMenus = {}; // category → PopupSubMenuMenuItem
|
this._menuItems = {};
|
||||||
this._menuItems = {}; // fullKey → SensorItem
|
|
||||||
this._lastKeys = null;
|
this._lastKeys = null;
|
||||||
|
|
||||||
// panel state
|
|
||||||
this._panelBox = new St.BoxLayout();
|
this._panelBox = new St.BoxLayout();
|
||||||
this.add_child(this._panelBox);
|
this.add_child(this._panelBox);
|
||||||
this._hotLabels = {}; // fullKey → St.Label
|
this._hotLabels = {};
|
||||||
this._hotIcons = {}; // fullKey → St.Icon
|
this._hotIcons = {};
|
||||||
|
|
||||||
this._buildPanel();
|
this._buildPanel();
|
||||||
|
|
||||||
// settings
|
|
||||||
this._sigIds = [];
|
this._sigIds = [];
|
||||||
this._connectSetting('hot-sensors', () => { this._buildPanel(); this._updatePanel(); this._syncPinOrnaments(); });
|
this._connectSetting('hot-sensors', () => { this._buildPanel(); this._updatePanel(); this._syncPinOrnaments(); });
|
||||||
this._connectSetting('show-icon-on-panel', () => { this._buildPanel(); this._updatePanel(); });
|
this._connectSetting('show-icon-on-panel', () => { this._buildPanel(); this._updatePanel(); });
|
||||||
this._connectSetting('panel-spacing', () => { 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('show-decimal-value', () => this._refresh());
|
||||||
this._connectSetting('position-in-panel', () => this._reposition());
|
this._connectSetting('position-in-panel', () => this._reposition());
|
||||||
this._connectSetting('panel-box-index', () => this._reposition());
|
this._connectSetting('panel-box-index', () => this._reposition());
|
||||||
this._connectSetting('update-interval', () => this._restartRefreshTimer());
|
this._connectSetting('update-interval', () => this._restartRefreshTimer());
|
||||||
|
|
||||||
// throttle UI repaints via a timer
|
|
||||||
this._dirty = false;
|
this._dirty = false;
|
||||||
this._refreshTimerId = 0;
|
this._refreshTimerId = 0;
|
||||||
this._startRefreshTimer();
|
this._startRefreshTimer();
|
||||||
@@ -166,8 +127,6 @@ class SensorTrayButton extends PanelMenu.Button {
|
|||||||
this._sigIds.push(this._settings.connect('changed::' + key, cb));
|
this._sigIds.push(this._settings.connect('changed::' + key, cb));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- panel (top bar): pinned sensors ----
|
|
||||||
|
|
||||||
_buildPanel() {
|
_buildPanel() {
|
||||||
this._panelBox.destroy_all_children();
|
this._panelBox.destroy_all_children();
|
||||||
this._hotLabels = {};
|
this._hotLabels = {};
|
||||||
@@ -179,17 +138,17 @@ class SensorTrayButton extends PanelMenu.Button {
|
|||||||
if (hot.length === 0) {
|
if (hot.length === 0) {
|
||||||
this._panelBox.add_child(new St.Icon({
|
this._panelBox.add_child(new St.Icon({
|
||||||
style_class: 'system-status-icon',
|
style_class: 'system-status-icon',
|
||||||
gicon: safeIcon(CATEGORIES.Thermal.icon),
|
gicon: safeIcon(['sensors-temperature-symbolic', 'dialog-information-symbolic']),
|
||||||
}));
|
}));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let meta = this._client.meta;
|
||||||
|
|
||||||
for (let i = 0; i < hot.length; i++) {
|
for (let i = 0; i < hot.length; i++) {
|
||||||
let fullKey = hot[i];
|
let fullKey = hot[i];
|
||||||
let cat = fullKey.split('/')[0];
|
let cat = fullKey.split('/')[0];
|
||||||
let cfg = catCfg(cat);
|
|
||||||
|
|
||||||
// spacer between pinned items
|
|
||||||
if (i > 0) {
|
if (i > 0) {
|
||||||
let spacing = this._settings.get_int('panel-spacing');
|
let spacing = this._settings.get_int('panel-spacing');
|
||||||
this._panelBox.add_child(new St.Widget({ width: spacing }));
|
this._panelBox.add_child(new St.Widget({ width: spacing }));
|
||||||
@@ -198,7 +157,7 @@ class SensorTrayButton extends PanelMenu.Button {
|
|||||||
if (showIcon) {
|
if (showIcon) {
|
||||||
let icon = new St.Icon({
|
let icon = new St.Icon({
|
||||||
style_class: 'system-status-icon',
|
style_class: 'system-status-icon',
|
||||||
gicon: safeIcon(cfg.icon),
|
gicon: safeIcon(catIcon(cat, meta)),
|
||||||
});
|
});
|
||||||
this._hotIcons[fullKey] = icon;
|
this._hotIcons[fullKey] = icon;
|
||||||
this._panelBox.add_child(icon);
|
this._panelBox.add_child(icon);
|
||||||
@@ -217,7 +176,7 @@ class SensorTrayButton extends PanelMenu.Button {
|
|||||||
|
|
||||||
_updatePanel() {
|
_updatePanel() {
|
||||||
let dec = this._settings.get_boolean('show-decimal-value');
|
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)) {
|
for (let [fullKey, label] of Object.entries(this._hotLabels)) {
|
||||||
let parts = fullKey.split('/');
|
let parts = fullKey.split('/');
|
||||||
@@ -230,12 +189,10 @@ class SensorTrayButton extends PanelMenu.Button {
|
|||||||
continue;
|
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) {
|
_onSensorChanged(category, _readings) {
|
||||||
if (category === null) {
|
if (category === null) {
|
||||||
this._menuItems = {};
|
this._menuItems = {};
|
||||||
@@ -276,9 +233,9 @@ class SensorTrayButton extends PanelMenu.Button {
|
|||||||
_sortedEntries() {
|
_sortedEntries() {
|
||||||
let entries = [];
|
let entries = [];
|
||||||
for (let [cat, readings] of this._client.readings) {
|
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))
|
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) => {
|
entries.sort((a, b) => {
|
||||||
if (a.sortOrder !== b.sortOrder) return a.sortOrder - b.sortOrder;
|
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');
|
let hot = this._settings.get_strv('hot-sensors');
|
||||||
|
|
||||||
// group by category
|
|
||||||
let grouped = new Map();
|
let grouped = new Map();
|
||||||
for (let e of entries) {
|
for (let e of entries) {
|
||||||
if (!grouped.has(e.cat))
|
if (!grouped.has(e.cat))
|
||||||
@@ -310,17 +266,18 @@ class SensorTrayButton extends PanelMenu.Button {
|
|||||||
grouped.get(e.cat).push(e);
|
grouped.get(e.cat).push(e);
|
||||||
}
|
}
|
||||||
|
|
||||||
for (let [cat, catEntries] of grouped) {
|
let meta = this._client.meta;
|
||||||
let cfg = catCfg(cat);
|
|
||||||
|
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);
|
let sub = new PopupMenu.PopupSubMenuMenuItem(cat, true);
|
||||||
sub.icon.gicon = safeIcon(cfg.icon);
|
sub.icon.gicon = safeIcon(iconNames);
|
||||||
this._subMenus[cat] = sub;
|
this._subMenus[cat] = sub;
|
||||||
this.menu.addMenuItem(sub);
|
this.menu.addMenuItem(sub);
|
||||||
|
|
||||||
for (let e of catEntries) {
|
for (let e of catEntries) {
|
||||||
let gicon = safeIcon(cfg.icon);
|
let gicon = safeIcon(iconNames);
|
||||||
let item = new SensorItem(gicon, e.fullKey, e.key, '\u2026');
|
let item = new SensorItem(gicon, e.fullKey, e.key, '\u2026');
|
||||||
|
|
||||||
if (hot.includes(e.fullKey))
|
if (hot.includes(e.fullKey))
|
||||||
@@ -333,7 +290,6 @@ class SensorTrayButton extends PanelMenu.Button {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// settings footer
|
|
||||||
this.menu.addMenuItem(new PopupMenu.PopupSeparatorMenuItem());
|
this.menu.addMenuItem(new PopupMenu.PopupSeparatorMenuItem());
|
||||||
let settingsItem = new PopupMenu.PopupMenuItem(_('Settings'));
|
let settingsItem = new PopupMenu.PopupMenuItem(_('Settings'));
|
||||||
settingsItem.connect('activate', () => {
|
settingsItem.connect('activate', () => {
|
||||||
@@ -351,24 +307,22 @@ class SensorTrayButton extends PanelMenu.Button {
|
|||||||
|
|
||||||
_updateValues() {
|
_updateValues() {
|
||||||
let dec = this._settings.get_boolean('show-decimal-value');
|
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) {
|
for (let [cat, readings] of this._client.readings) {
|
||||||
|
let units = meta.get(cat)?.units;
|
||||||
|
|
||||||
for (let [key, val] of Object.entries(readings)) {
|
for (let [key, val] of Object.entries(readings)) {
|
||||||
let item = this._menuItems[cat + '/' + key];
|
let item = this._menuItems[cat + '/' + key];
|
||||||
if (item)
|
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];
|
let sub = this._subMenus[cat];
|
||||||
if (sub && sub.status) {
|
if (sub && sub.status) {
|
||||||
let cfg = catCfg(cat);
|
let s = autoSummary(readings, units);
|
||||||
if (cfg.summary) {
|
if (s)
|
||||||
let sv = cfg.summary(readings);
|
sub.status.text = formatByUnit(units?.[s.key], s.val, dec);
|
||||||
if (sv !== null)
|
|
||||||
sub.status.text = formatSensor(cat, cfg.summaryKey || '', sv, dec, unit);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -397,8 +351,6 @@ class SensorTrayButton extends PanelMenu.Button {
|
|||||||
this._updatePanel();
|
this._updatePanel();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- panel position ----
|
|
||||||
|
|
||||||
_reposition() {
|
_reposition() {
|
||||||
try {
|
try {
|
||||||
if (!this.container?.get_parent()) return;
|
if (!this.container?.get_parent()) return;
|
||||||
@@ -417,8 +369,6 @@ class SensorTrayButton extends PanelMenu.Button {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- cleanup ----
|
|
||||||
|
|
||||||
_onDestroy() {
|
_onDestroy() {
|
||||||
this._client.destroy();
|
this._client.destroy();
|
||||||
if (this._refreshTimerId) {
|
if (this._refreshTimerId) {
|
||||||
@@ -435,8 +385,6 @@ class SensorTrayButton extends PanelMenu.Button {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------- extension entry point ----------
|
|
||||||
|
|
||||||
export default class SensorTrayExtension extends Extension {
|
export default class SensorTrayExtension extends Extension {
|
||||||
|
|
||||||
enable() {
|
enable() {
|
||||||
|
|||||||
@@ -123,13 +123,6 @@ export default class SensorTrayPreferences extends ExtensionPreferences {
|
|||||||
_createDisplayGroup() {
|
_createDisplayGroup() {
|
||||||
let group = new Adw.PreferencesGroup({ title: _('Display') });
|
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 Decimal Values'), 'show-decimal-value'));
|
||||||
group.add(this._switch(_('Show Icon on Panel'), 'show-icon-on-panel'));
|
group.add(this._switch(_('Show Icon on Panel'), 'show-icon-on-panel'));
|
||||||
|
|
||||||
|
|||||||
@@ -5,35 +5,18 @@ const BUS_NAME = 'org.sensord';
|
|||||||
const OBJECT_PATH = '/org/sensord';
|
const OBJECT_PATH = '/org/sensord';
|
||||||
const IFACE_PREFIX = '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 {
|
export default class SensorClient {
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this._conn = null;
|
this._conn = null;
|
||||||
this._signalIds = [];
|
this._signalIds = [];
|
||||||
// category → { key: value } e.g. "Power" → { "package-0": 42.3 }
|
|
||||||
this._readings = new Map();
|
this._readings = new Map();
|
||||||
|
this._meta = new Map();
|
||||||
this._onChanged = null;
|
this._onChanged = null;
|
||||||
this._available = false;
|
this._available = false;
|
||||||
this._nameWatchId = 0;
|
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) {
|
start(onChanged) {
|
||||||
this._onChanged = onChanged;
|
this._onChanged = onChanged;
|
||||||
|
|
||||||
@@ -44,7 +27,6 @@ export default class SensorClient {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Watch for sensord appearing/disappearing on the bus
|
|
||||||
this._nameWatchId = Gio.bus_watch_name_on_connection(
|
this._nameWatchId = Gio.bus_watch_name_on_connection(
|
||||||
this._conn,
|
this._conn,
|
||||||
BUS_NAME,
|
BUS_NAME,
|
||||||
@@ -63,17 +45,15 @@ export default class SensorClient {
|
|||||||
this._available = false;
|
this._available = false;
|
||||||
this._unsubscribeAll();
|
this._unsubscribeAll();
|
||||||
this._readings.clear();
|
this._readings.clear();
|
||||||
|
this._meta.clear();
|
||||||
if (this._onChanged)
|
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() {
|
_discover() {
|
||||||
this._unsubscribeAll();
|
this._unsubscribeAll();
|
||||||
this._readings.clear();
|
this._readings.clear();
|
||||||
|
this._meta.clear();
|
||||||
|
|
||||||
let introXml;
|
let introXml;
|
||||||
try {
|
try {
|
||||||
@@ -89,7 +69,6 @@ export default class SensorClient {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse interface names from XML — simple regex is fine here
|
|
||||||
let ifaces = [];
|
let ifaces = [];
|
||||||
let re = /interface\s+name="(org\.sensord\.[^"]+)"/g;
|
let re = /interface\s+name="(org\.sensord\.[^"]+)"/g;
|
||||||
let m;
|
let m;
|
||||||
@@ -97,9 +76,8 @@ export default class SensorClient {
|
|||||||
ifaces.push(m[1]);
|
ifaces.push(m[1]);
|
||||||
|
|
||||||
for (let iface of ifaces) {
|
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(
|
let sid = this._conn.signal_subscribe(
|
||||||
BUS_NAME, iface, 'Changed', OBJECT_PATH,
|
BUS_NAME, iface, 'Changed', OBJECT_PATH,
|
||||||
null, Gio.DBusSignalFlags.NONE,
|
null, Gio.DBusSignalFlags.NONE,
|
||||||
@@ -112,7 +90,26 @@ export default class SensorClient {
|
|||||||
);
|
);
|
||||||
this._signalIds.push(sid);
|
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(
|
this._conn.call(
|
||||||
BUS_NAME, OBJECT_PATH, iface, 'GetReadings',
|
BUS_NAME, OBJECT_PATH, iface, 'GetReadings',
|
||||||
null, GLib.VariantType.new('(a{sd})'),
|
null, GLib.VariantType.new('(a{sd})'),
|
||||||
@@ -140,18 +137,9 @@ export default class SensorClient {
|
|||||||
this._signalIds = [];
|
this._signalIds = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @returns {boolean} true if sensord is on the bus */
|
get available() { return this._available; }
|
||||||
get available() {
|
get readings() { return this._readings; }
|
||||||
return this._available;
|
get meta() { return this._meta; }
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @returns {Map<string, Object<string,number>>}
|
|
||||||
* category → { key: value } snapshot of all current readings
|
|
||||||
*/
|
|
||||||
get readings() {
|
|
||||||
return this._readings;
|
|
||||||
}
|
|
||||||
|
|
||||||
destroy() {
|
destroy() {
|
||||||
this._unsubscribeAll();
|
this._unsubscribeAll();
|
||||||
@@ -162,5 +150,6 @@ export default class SensorClient {
|
|||||||
this._conn = null;
|
this._conn = null;
|
||||||
this._onChanged = null;
|
this._onChanged = null;
|
||||||
this._readings.clear();
|
this._readings.clear();
|
||||||
|
this._meta.clear();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ Interfaces:
|
|||||||
|
|
||||||
Each interface exposes:
|
Each interface exposes:
|
||||||
GetReadings() → a{sd}
|
GetReadings() → a{sd}
|
||||||
|
GetMeta() → a{sv} (icon: s, units: a{ss})
|
||||||
signal Changed(a{sd})
|
signal Changed(a{sd})
|
||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
@@ -51,6 +52,7 @@ POLICY = """\
|
|||||||
def make_iface_xml(name):
|
def make_iface_xml(name):
|
||||||
return f"""<interface name="org.sensord.{name}">
|
return f"""<interface name="org.sensord.{name}">
|
||||||
<method name="GetReadings"><arg direction="out" type="a{{sd}}"/></method>
|
<method name="GetReadings"><arg direction="out" type="a{{sd}}"/></method>
|
||||||
|
<method name="GetMeta"><arg direction="out" type="a{{sv}}"/></method>
|
||||||
<signal name="Changed"><arg type="a{{sd}}"/></signal>
|
<signal name="Changed"><arg type="a{{sd}}"/></signal>
|
||||||
</interface>"""
|
</interface>"""
|
||||||
|
|
||||||
@@ -73,6 +75,7 @@ class PowerSensor:
|
|||||||
"""RAPL energy counters → watts."""
|
"""RAPL energy counters → watts."""
|
||||||
|
|
||||||
RAPL_BASE = "/sys/class/powercap/intel-rapl"
|
RAPL_BASE = "/sys/class/powercap/intel-rapl"
|
||||||
|
ICON = "battery-full-charged-symbolic"
|
||||||
|
|
||||||
class Zone:
|
class Zone:
|
||||||
__slots__ = ("name", "fd", "wrap", "prev_e", "prev_t")
|
__slots__ = ("name", "fd", "wrap", "prev_e", "prev_t")
|
||||||
@@ -119,7 +122,7 @@ class PowerSensor:
|
|||||||
if "energy_uj" in files:
|
if "energy_uj" in files:
|
||||||
try:
|
try:
|
||||||
z = self.Zone(root)
|
z = self.Zone(root)
|
||||||
z.sample() # prime
|
z.sample()
|
||||||
self.zones.append(z)
|
self.zones.append(z)
|
||||||
print(f" power: {z.name}", file=sys.stderr)
|
print(f" power: {z.name}", file=sys.stderr)
|
||||||
except OSError as e:
|
except OSError as e:
|
||||||
@@ -129,6 +132,9 @@ class PowerSensor:
|
|||||||
def available(self):
|
def available(self):
|
||||||
return bool(self.zones)
|
return bool(self.zones)
|
||||||
|
|
||||||
|
def units(self):
|
||||||
|
return {z.name: "W" for z in self.zones}
|
||||||
|
|
||||||
def sample(self):
|
def sample(self):
|
||||||
r = {}
|
r = {}
|
||||||
for z in self.zones:
|
for z in self.zones:
|
||||||
@@ -146,6 +152,7 @@ class ThermalSensor:
|
|||||||
"""hwmon temperature sensors → °C."""
|
"""hwmon temperature sensors → °C."""
|
||||||
|
|
||||||
HWMON_BASE = "/sys/class/hwmon"
|
HWMON_BASE = "/sys/class/hwmon"
|
||||||
|
ICON = "sensors-temperature-symbolic"
|
||||||
|
|
||||||
class Chip:
|
class Chip:
|
||||||
__slots__ = ("label", "fd")
|
__slots__ = ("label", "fd")
|
||||||
@@ -182,7 +189,7 @@ class ThermalSensor:
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
chip = self.Chip(full_label, path)
|
chip = self.Chip(full_label, path)
|
||||||
chip.read() # test
|
chip.read()
|
||||||
self.chips.append(chip)
|
self.chips.append(chip)
|
||||||
print(f" thermal: {full_label}", file=sys.stderr)
|
print(f" thermal: {full_label}", file=sys.stderr)
|
||||||
except OSError as e:
|
except OSError as e:
|
||||||
@@ -200,6 +207,9 @@ class ThermalSensor:
|
|||||||
def available(self):
|
def available(self):
|
||||||
return bool(self.chips)
|
return bool(self.chips)
|
||||||
|
|
||||||
|
def units(self):
|
||||||
|
return {c.label: "°C" for c in self.chips}
|
||||||
|
|
||||||
def sample(self):
|
def sample(self):
|
||||||
r = {}
|
r = {}
|
||||||
for c in self.chips:
|
for c in self.chips:
|
||||||
@@ -217,13 +227,15 @@ class ThermalSensor:
|
|||||||
class CpuSensor:
|
class CpuSensor:
|
||||||
"""/proc/stat → per-core and total CPU usage %."""
|
"""/proc/stat → per-core and total CPU usage %."""
|
||||||
|
|
||||||
|
ICON = "utilities-system-monitor-symbolic"
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.fd = None
|
self.fd = None
|
||||||
self.prev = {}
|
self.prev = {}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
self.fd = os.open("/proc/stat", os.O_RDONLY)
|
self.fd = os.open("/proc/stat", os.O_RDONLY)
|
||||||
self._read_stat() # prime
|
self._read_stat()
|
||||||
print(f" cpu: {len(self.prev)} entries", file=sys.stderr)
|
print(f" cpu: {len(self.prev)} entries", file=sys.stderr)
|
||||||
except OSError as e:
|
except OSError as e:
|
||||||
print(f" cpu skip: {e}", file=sys.stderr)
|
print(f" cpu skip: {e}", file=sys.stderr)
|
||||||
@@ -242,12 +254,18 @@ class CpuSensor:
|
|||||||
parts = line.split()
|
parts = line.split()
|
||||||
name = parts[0]
|
name = parts[0]
|
||||||
vals = [int(v) for v in parts[1:]]
|
vals = [int(v) for v in parts[1:]]
|
||||||
# user nice system idle iowait irq softirq steal
|
|
||||||
idle = vals[3] + vals[4] if len(vals) > 4 else vals[3]
|
idle = vals[3] + vals[4] if len(vals) > 4 else vals[3]
|
||||||
total = sum(vals)
|
total = sum(vals)
|
||||||
entries[name] = (idle, total)
|
entries[name] = (idle, total)
|
||||||
return entries
|
return entries
|
||||||
|
|
||||||
|
def units(self):
|
||||||
|
r = {}
|
||||||
|
for name in self.prev:
|
||||||
|
label = "total" if name == "cpu" else name
|
||||||
|
r[label] = "%"
|
||||||
|
return r
|
||||||
|
|
||||||
def sample(self):
|
def sample(self):
|
||||||
cur = self._read_stat()
|
cur = self._read_stat()
|
||||||
r = {}
|
r = {}
|
||||||
@@ -270,13 +288,14 @@ class CpuSensor:
|
|||||||
class MemorySensor:
|
class MemorySensor:
|
||||||
"""/proc/meminfo → memory stats in bytes and usage %."""
|
"""/proc/meminfo → memory stats in bytes and usage %."""
|
||||||
|
|
||||||
|
ICON = "drive-harddisk-symbolic"
|
||||||
KEYS = ("MemTotal", "MemAvailable", "MemFree", "SwapTotal", "SwapFree")
|
KEYS = ("MemTotal", "MemAvailable", "MemFree", "SwapTotal", "SwapFree")
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.fd = None
|
self.fd = None
|
||||||
try:
|
try:
|
||||||
self.fd = os.open("/proc/meminfo", os.O_RDONLY)
|
self.fd = os.open("/proc/meminfo", os.O_RDONLY)
|
||||||
self.sample() # test
|
self.sample()
|
||||||
print(" memory: ok", file=sys.stderr)
|
print(" memory: ok", file=sys.stderr)
|
||||||
except OSError as e:
|
except OSError as e:
|
||||||
print(f" memory skip: {e}", file=sys.stderr)
|
print(f" memory skip: {e}", file=sys.stderr)
|
||||||
@@ -285,6 +304,13 @@ class MemorySensor:
|
|||||||
def available(self):
|
def available(self):
|
||||||
return self.fd is not None
|
return self.fd is not None
|
||||||
|
|
||||||
|
def units(self):
|
||||||
|
return {
|
||||||
|
"total": "bytes", "available": "bytes", "used": "bytes",
|
||||||
|
"percent": "%",
|
||||||
|
"swap_total": "bytes", "swap_used": "bytes", "swap_percent": "%",
|
||||||
|
}
|
||||||
|
|
||||||
def sample(self):
|
def sample(self):
|
||||||
os.lseek(self.fd, 0, os.SEEK_SET)
|
os.lseek(self.fd, 0, os.SEEK_SET)
|
||||||
raw = os.read(self.fd, 4096).decode()
|
raw = os.read(self.fd, 4096).decode()
|
||||||
@@ -294,7 +320,7 @@ class MemorySensor:
|
|||||||
parts = line.split()
|
parts = line.split()
|
||||||
key = parts[0].rstrip(":")
|
key = parts[0].rstrip(":")
|
||||||
if key in self.KEYS:
|
if key in self.KEYS:
|
||||||
vals[key] = int(parts[1]) * 1024 # kB → bytes
|
vals[key] = int(parts[1]) * 1024
|
||||||
|
|
||||||
r = {}
|
r = {}
|
||||||
mt = vals.get("MemTotal", 0)
|
mt = vals.get("MemTotal", 0)
|
||||||
@@ -323,8 +349,8 @@ class BatterySensor:
|
|||||||
"""power_supply sysfs → battery state."""
|
"""power_supply sysfs → battery state."""
|
||||||
|
|
||||||
PS_BASE = "/sys/class/power_supply"
|
PS_BASE = "/sys/class/power_supply"
|
||||||
|
ICON = "battery-symbolic"
|
||||||
|
|
||||||
# status string → numeric code for a{sd}
|
|
||||||
STATUS_MAP = {
|
STATUS_MAP = {
|
||||||
"Charging": 1.0, "Discharging": 2.0,
|
"Charging": 1.0, "Discharging": 2.0,
|
||||||
"Not charging": 3.0, "Full": 4.0,
|
"Not charging": 3.0, "Full": 4.0,
|
||||||
@@ -365,6 +391,20 @@ class BatterySensor:
|
|||||||
def available(self):
|
def available(self):
|
||||||
return any(s.is_battery for s in self.supplies)
|
return any(s.is_battery for s in self.supplies)
|
||||||
|
|
||||||
|
def units(self):
|
||||||
|
r = {}
|
||||||
|
for s in self.supplies:
|
||||||
|
if s.is_battery:
|
||||||
|
r[f"{s.name}/percent"] = "%"
|
||||||
|
r[f"{s.name}/status"] = "enum:Charging,Discharging,Not charging,Full"
|
||||||
|
r[f"{s.name}/power"] = "W"
|
||||||
|
r[f"{s.name}/energy_now"] = "Wh"
|
||||||
|
r[f"{s.name}/energy_full"] = "Wh"
|
||||||
|
r[f"{s.name}/cycles"] = "count"
|
||||||
|
else:
|
||||||
|
r[f"{s.name}/online"] = "bool"
|
||||||
|
return r
|
||||||
|
|
||||||
def sample(self):
|
def sample(self):
|
||||||
r = {}
|
r = {}
|
||||||
for s in self.supplies:
|
for s in self.supplies:
|
||||||
@@ -404,7 +444,7 @@ class BatterySensor:
|
|||||||
|
|
||||||
|
|
||||||
SENSORS = {
|
SENSORS = {
|
||||||
"Power": (PowerSensor, 1), # iface name, interval (sec)
|
"Power": (PowerSensor, 1),
|
||||||
"Thermal": (ThermalSensor, 2),
|
"Thermal": (ThermalSensor, 2),
|
||||||
"Cpu": (CpuSensor, 1),
|
"Cpu": (CpuSensor, 1),
|
||||||
"Memory": (MemorySensor, 2),
|
"Memory": (MemorySensor, 2),
|
||||||
@@ -416,9 +456,9 @@ class Daemon:
|
|||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.loop = GLib.MainLoop()
|
self.loop = GLib.MainLoop()
|
||||||
self.bus = None
|
self.bus = None
|
||||||
self.sensors = {} # name → sensor instance
|
self.sensors = {}
|
||||||
self.readings = {} # name → latest {key: value}
|
self.readings = {}
|
||||||
self.pending = {} # name → (cls, interval) — not yet available
|
self.pending = {}
|
||||||
self.node = Gio.DBusNodeInfo.new_for_xml(INTROSPECTION)
|
self.node = Gio.DBusNodeInfo.new_for_xml(INTROSPECTION)
|
||||||
|
|
||||||
for name, (cls, interval) in SENSORS.items():
|
for name, (cls, interval) in SENSORS.items():
|
||||||
@@ -458,6 +498,13 @@ class Daemon:
|
|||||||
name = iface.rsplit(".", 1)[-1]
|
name = iface.rsplit(".", 1)[-1]
|
||||||
if method == "GetReadings":
|
if method == "GetReadings":
|
||||||
invocation.return_value(GLib.Variant("(a{sd})", (self.readings.get(name, {}),)))
|
invocation.return_value(GLib.Variant("(a{sd})", (self.readings.get(name, {}),)))
|
||||||
|
elif method == "GetMeta":
|
||||||
|
meta = {}
|
||||||
|
sensor = self.sensors.get(name)
|
||||||
|
if sensor:
|
||||||
|
meta["icon"] = GLib.Variant("s", sensor.ICON)
|
||||||
|
meta["units"] = GLib.Variant("a{ss}", sensor.units())
|
||||||
|
invocation.return_value(GLib.Variant("(a{sv})", (meta,)))
|
||||||
else:
|
else:
|
||||||
invocation.return_dbus_error("org.freedesktop.DBus.Error.UnknownMethod", method)
|
invocation.return_dbus_error("org.freedesktop.DBus.Error.UnknownMethod", method)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user