mirror of
https://github.com/morgan9e/linux-sys-telemetry
synced 2026-04-14 16:24:17 +09:00
Init
This commit is contained in:
27
org.sensord/gnome-sensor-tray/Makefile
Normal file
27
org.sensord/gnome-sensor-tray/Makefile
Normal file
@@ -0,0 +1,27 @@
|
||||
UUID = sensor-tray@local
|
||||
SRC = src
|
||||
INSTALL_DIR = $(HOME)/.local/share/gnome-shell/extensions/$(UUID)
|
||||
|
||||
.PHONY: all schemas install uninstall zip clean test
|
||||
|
||||
all: schemas
|
||||
|
||||
schemas:
|
||||
glib-compile-schemas $(SRC)/schemas/
|
||||
|
||||
install: schemas
|
||||
rm -rf $(INSTALL_DIR)
|
||||
cp -r ./$(SRC) $(INSTALL_DIR)
|
||||
|
||||
uninstall:
|
||||
rm -rf $(INSTALL_DIR)
|
||||
|
||||
zip: schemas
|
||||
cd $(SRC) && zip -x "*.pot" -x "*.po" -r ../$(UUID).zip *
|
||||
|
||||
clean:
|
||||
rm -f $(UUID).zip
|
||||
rm -f $(SRC)/schemas/gschemas.compiled
|
||||
|
||||
test: install
|
||||
dbus-run-session -- gnome-shell --devkit --wayland
|
||||
424
org.sensord/gnome-sensor-tray/src/extension.js
Normal file
424
org.sensord/gnome-sensor-tray/src/extension.js
Normal file
@@ -0,0 +1,424 @@
|
||||
import Clutter from 'gi://Clutter';
|
||||
import Gio from 'gi://Gio';
|
||||
import GLib from 'gi://GLib';
|
||||
import GObject from 'gi://GObject';
|
||||
import St from 'gi://St';
|
||||
|
||||
import * as PanelMenu from 'resource:///org/gnome/shell/ui/panelMenu.js';
|
||||
import * as PopupMenu from 'resource:///org/gnome/shell/ui/popupMenu.js';
|
||||
import * as Main from 'resource:///org/gnome/shell/ui/main.js';
|
||||
|
||||
import {Extension, gettext as _} from 'resource:///org/gnome/shell/extensions/extension.js';
|
||||
|
||||
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 ----------
|
||||
|
||||
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,
|
||||
},
|
||||
};
|
||||
|
||||
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) {
|
||||
let cfg = catCfg(cat);
|
||||
let v = cfg.convert ? cfg.convert(val, unit) : val;
|
||||
return cfg.format(v, dec, unit, key);
|
||||
}
|
||||
|
||||
// ---------- panel button ----------
|
||||
|
||||
class SensorTrayButton extends PanelMenu.Button {
|
||||
|
||||
static {
|
||||
GObject.registerClass(this);
|
||||
}
|
||||
|
||||
constructor(settings, path) {
|
||||
super(0);
|
||||
|
||||
this._settings = settings;
|
||||
this._path = path;
|
||||
this._client = new SensorClient();
|
||||
|
||||
// menu state
|
||||
this._subMenus = {}; // category → PopupSubMenuMenuItem
|
||||
this._menuItems = {}; // fullKey → SensorItem
|
||||
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._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();
|
||||
|
||||
this._client.start((cat, readings) => this._onSensorChanged(cat, readings));
|
||||
|
||||
this.connect('destroy', () => this._onDestroy());
|
||||
|
||||
this._repositionTimeoutId = GLib.timeout_add(GLib.PRIORITY_DEFAULT, 500, () => {
|
||||
this._reposition();
|
||||
this._repositionTimeoutId = 0;
|
||||
return GLib.SOURCE_REMOVE;
|
||||
});
|
||||
}
|
||||
|
||||
_connectSetting(key, cb) {
|
||||
this._sigIds.push(this._settings.connect('changed::' + key, cb));
|
||||
}
|
||||
|
||||
// ---- panel (top bar): pinned sensors ----
|
||||
|
||||
_buildPanel() {
|
||||
this._panelBox.destroy_all_children();
|
||||
this._hotLabels = {};
|
||||
this._hotIcons = {};
|
||||
|
||||
let hot = this._settings.get_strv('hot-sensors');
|
||||
let showIcon = this._settings.get_boolean('show-icon-on-panel');
|
||||
|
||||
if (hot.length === 0) {
|
||||
this._panelBox.add_child(new St.Icon({
|
||||
style_class: 'system-status-icon',
|
||||
gicon: safeIcon(CATEGORIES.Thermal.icon),
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
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 }));
|
||||
}
|
||||
|
||||
if (showIcon) {
|
||||
let icon = new St.Icon({
|
||||
style_class: 'system-status-icon',
|
||||
gicon: safeIcon(cfg.icon),
|
||||
});
|
||||
this._hotIcons[fullKey] = icon;
|
||||
this._panelBox.add_child(icon);
|
||||
}
|
||||
|
||||
let label = new St.Label({
|
||||
text: '\u2026',
|
||||
y_expand: true,
|
||||
y_align: Clutter.ActorAlign.CENTER,
|
||||
style_class: showIcon ? 'sensortray-panel-icon-label' : 'sensortray-panel-label',
|
||||
});
|
||||
this._hotLabels[fullKey] = label;
|
||||
this._panelBox.add_child(label);
|
||||
}
|
||||
}
|
||||
|
||||
_updatePanel() {
|
||||
let dec = this._settings.get_boolean('show-decimal-value');
|
||||
let unit = this._settings.get_int('unit');
|
||||
|
||||
for (let [fullKey, label] of Object.entries(this._hotLabels)) {
|
||||
let parts = fullKey.split('/');
|
||||
let cat = parts[0];
|
||||
let key = parts.slice(1).join('/');
|
||||
let readings = this._client.readings.get(cat);
|
||||
|
||||
if (!readings || !(key in readings)) {
|
||||
label.text = '\u2026';
|
||||
continue;
|
||||
}
|
||||
|
||||
label.text = formatSensor(cat, key, readings[key], dec, unit);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- dropdown: collapsed submenus per category ----
|
||||
|
||||
_onSensorChanged(category, _readings) {
|
||||
if (category === null) {
|
||||
this._menuItems = {};
|
||||
this._subMenus = {};
|
||||
this._lastKeys = null;
|
||||
this.menu.removeAll();
|
||||
this.menu.addMenuItem(new PopupMenu.PopupMenuItem(_('sensord is not running')));
|
||||
for (let l of Object.values(this._hotLabels))
|
||||
l.text = '\u26a0';
|
||||
return;
|
||||
}
|
||||
|
||||
this._dirty = true;
|
||||
}
|
||||
|
||||
_startRefreshTimer() {
|
||||
let seconds = this._settings.get_int('update-interval');
|
||||
let intervalMs = seconds * 1000;
|
||||
this._refreshTimerId = GLib.timeout_add(GLib.PRIORITY_DEFAULT, intervalMs, () => {
|
||||
if (this._dirty) {
|
||||
this._dirty = false;
|
||||
this._rebuildMenuIfNeeded();
|
||||
this._updateValues();
|
||||
this._updatePanel();
|
||||
}
|
||||
return GLib.SOURCE_CONTINUE;
|
||||
});
|
||||
}
|
||||
|
||||
_restartRefreshTimer() {
|
||||
if (this._refreshTimerId) {
|
||||
GLib.Source.remove(this._refreshTimerId);
|
||||
this._refreshTimerId = 0;
|
||||
}
|
||||
this._startRefreshTimer();
|
||||
}
|
||||
|
||||
_sortedEntries() {
|
||||
let entries = [];
|
||||
for (let [cat, readings] of this._client.readings) {
|
||||
let cfg = catCfg(cat);
|
||||
for (let key of Object.keys(readings))
|
||||
entries.push({ cat, key, fullKey: cat + '/' + key, sortOrder: cfg.sortOrder });
|
||||
}
|
||||
entries.sort((a, b) => {
|
||||
if (a.sortOrder !== b.sortOrder) return a.sortOrder - b.sortOrder;
|
||||
if (a.cat !== b.cat) return a.cat.localeCompare(b.cat);
|
||||
return a.key.localeCompare(b.key, undefined, { numeric: true });
|
||||
});
|
||||
return entries;
|
||||
}
|
||||
|
||||
_rebuildMenuIfNeeded() {
|
||||
let entries = this._sortedEntries();
|
||||
let keyStr = entries.map(e => e.fullKey).join('\n');
|
||||
|
||||
if (this._lastKeys === keyStr)
|
||||
return;
|
||||
|
||||
this._lastKeys = keyStr;
|
||||
this.menu.removeAll();
|
||||
this._menuItems = {};
|
||||
this._subMenus = {};
|
||||
|
||||
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))
|
||||
grouped.set(e.cat, []);
|
||||
grouped.get(e.cat).push(e);
|
||||
}
|
||||
|
||||
for (let [cat, catEntries] of grouped) {
|
||||
let cfg = catCfg(cat);
|
||||
|
||||
// create a collapsed submenu for this category
|
||||
let sub = new PopupMenu.PopupSubMenuMenuItem(cat, true);
|
||||
sub.icon.gicon = safeIcon(cfg.icon);
|
||||
this._subMenus[cat] = sub;
|
||||
this.menu.addMenuItem(sub);
|
||||
|
||||
for (let e of catEntries) {
|
||||
let gicon = safeIcon(cfg.icon);
|
||||
let item = new SensorItem(gicon, e.fullKey, e.key, '\u2026');
|
||||
|
||||
if (hot.includes(e.fullKey))
|
||||
item.pinned = true;
|
||||
|
||||
item.connect('activate', () => this._togglePin(item));
|
||||
|
||||
this._menuItems[e.fullKey] = item;
|
||||
sub.menu.addMenuItem(item);
|
||||
}
|
||||
}
|
||||
|
||||
// settings footer
|
||||
this.menu.addMenuItem(new PopupMenu.PopupSeparatorMenuItem());
|
||||
let settingsItem = new PopupMenu.PopupMenuItem(_('Settings'));
|
||||
settingsItem.connect('activate', () => {
|
||||
try {
|
||||
Gio.Subprocess.new(
|
||||
['gnome-extensions', 'prefs', 'sensor-tray@local'],
|
||||
Gio.SubprocessFlags.NONE,
|
||||
);
|
||||
} catch (e) {
|
||||
console.error('sensor-tray: cannot open prefs:', e.message);
|
||||
}
|
||||
});
|
||||
this.menu.addMenuItem(settingsItem);
|
||||
}
|
||||
|
||||
_updateValues() {
|
||||
let dec = this._settings.get_boolean('show-decimal-value');
|
||||
let unit = this._settings.get_int('unit');
|
||||
|
||||
for (let [cat, readings] of this._client.readings) {
|
||||
for (let [key, val] of Object.entries(readings)) {
|
||||
let item = this._menuItems[cat + '/' + key];
|
||||
if (item)
|
||||
item.value = formatSensor(cat, key, val, dec, unit);
|
||||
}
|
||||
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_syncPinOrnaments() {
|
||||
let hot = this._settings.get_strv('hot-sensors');
|
||||
for (let [key, item] of Object.entries(this._menuItems))
|
||||
item.pinned = hot.includes(key);
|
||||
}
|
||||
|
||||
_togglePin(item) {
|
||||
let hot = this._settings.get_strv('hot-sensors');
|
||||
|
||||
if (item.pinned)
|
||||
hot = hot.filter(k => k !== item.key);
|
||||
else
|
||||
hot.push(item.key);
|
||||
|
||||
this._settings.set_strv('hot-sensors', hot);
|
||||
}
|
||||
|
||||
_refresh() {
|
||||
this._lastKeys = null;
|
||||
this._rebuildMenuIfNeeded();
|
||||
this._updateValues();
|
||||
this._updatePanel();
|
||||
}
|
||||
|
||||
// ---- panel position ----
|
||||
|
||||
_reposition() {
|
||||
try {
|
||||
if (!this.container?.get_parent()) return;
|
||||
this.container.get_parent().remove_child(this.container);
|
||||
|
||||
let boxes = {
|
||||
0: Main.panel._leftBox,
|
||||
1: Main.panel._centerBox,
|
||||
2: Main.panel._rightBox,
|
||||
};
|
||||
let pos = this._settings.get_int('position-in-panel');
|
||||
let idx = this._settings.get_int('panel-box-index');
|
||||
(boxes[pos] || boxes[2]).insert_child_at_index(this.container, idx);
|
||||
} catch (e) {
|
||||
console.error('sensor-tray: reposition failed:', e.message);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- cleanup ----
|
||||
|
||||
_onDestroy() {
|
||||
this._client.destroy();
|
||||
if (this._refreshTimerId) {
|
||||
GLib.Source.remove(this._refreshTimerId);
|
||||
this._refreshTimerId = 0;
|
||||
}
|
||||
if (this._repositionTimeoutId) {
|
||||
GLib.Source.remove(this._repositionTimeoutId);
|
||||
this._repositionTimeoutId = 0;
|
||||
}
|
||||
for (let id of this._sigIds)
|
||||
this._settings.disconnect(id);
|
||||
this._sigIds = [];
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- extension entry point ----------
|
||||
|
||||
export default class SensorTrayExtension extends Extension {
|
||||
|
||||
enable() {
|
||||
this._button = new SensorTrayButton(this.getSettings(), this.path);
|
||||
Main.panel.addToStatusArea('sensor-tray', this._button);
|
||||
}
|
||||
|
||||
disable() {
|
||||
this._button?.destroy();
|
||||
this._button = null;
|
||||
}
|
||||
}
|
||||
8
org.sensord/gnome-sensor-tray/src/metadata.json
Normal file
8
org.sensord/gnome-sensor-tray/src/metadata.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"uuid": "sensor-tray@local",
|
||||
"name": "Sensor Tray",
|
||||
"description": "System sensor monitor powered by sensord. Displays CPU, thermal, power, and memory readings from org.sensord D-Bus interfaces.",
|
||||
"shell-version": ["45", "46", "47", "48", "49", "50"],
|
||||
"settings-schema": "org.gnome.shell.extensions.sensortray",
|
||||
"url": ""
|
||||
}
|
||||
186
org.sensord/gnome-sensor-tray/src/prefs.js
Normal file
186
org.sensord/gnome-sensor-tray/src/prefs.js
Normal file
@@ -0,0 +1,186 @@
|
||||
import Gio from 'gi://Gio';
|
||||
import Gtk from 'gi://Gtk';
|
||||
import Adw from 'gi://Adw';
|
||||
|
||||
import {ExtensionPreferences, gettext as _} from 'resource:///org/gnome/Shell/Extensions/js/extensions/prefs.js';
|
||||
|
||||
export default class SensorTrayPreferences extends ExtensionPreferences {
|
||||
|
||||
fillPreferencesWindow(window) {
|
||||
this._settings = this.getSettings();
|
||||
|
||||
let page = new Adw.PreferencesPage({
|
||||
title: _('Sensor Tray'),
|
||||
icon_name: 'utilities-system-monitor-symbolic',
|
||||
});
|
||||
|
||||
page.add(this._createPinnedGroup());
|
||||
page.add(this._createDisplayGroup());
|
||||
page.add(this._createPositionGroup());
|
||||
|
||||
window.add(page);
|
||||
}
|
||||
|
||||
_createPinnedGroup() {
|
||||
let group = new Adw.PreferencesGroup({
|
||||
title: _('Pinned Sensors'),
|
||||
description: _('Sensors shown in the top bar. Toggle pins from the dropdown menu.'),
|
||||
});
|
||||
|
||||
this._pinnedGroup = group;
|
||||
this._rebuildPinnedRows();
|
||||
|
||||
this._settings.connect('changed::hot-sensors', () => this._rebuildPinnedRows());
|
||||
|
||||
return group;
|
||||
}
|
||||
|
||||
_rebuildPinnedRows() {
|
||||
// clear existing rows
|
||||
if (this._pinnedRows) {
|
||||
for (let row of this._pinnedRows)
|
||||
this._pinnedGroup.remove(row);
|
||||
}
|
||||
this._pinnedRows = [];
|
||||
|
||||
let hot = this._settings.get_strv('hot-sensors');
|
||||
|
||||
if (hot.length === 0) {
|
||||
let empty = new Adw.ActionRow({
|
||||
title: _('No sensors pinned'),
|
||||
subtitle: _('Click sensors in the dropdown menu to pin them'),
|
||||
});
|
||||
this._pinnedGroup.add(empty);
|
||||
this._pinnedRows.push(empty);
|
||||
return;
|
||||
}
|
||||
|
||||
for (let idx = 0; idx < hot.length; idx++) {
|
||||
let fullKey = hot[idx];
|
||||
// show "Category / key" as title/subtitle
|
||||
let parts = fullKey.split('/');
|
||||
let cat = parts[0];
|
||||
let key = parts.slice(1).join('/');
|
||||
|
||||
let row = new Adw.ActionRow({
|
||||
title: key,
|
||||
subtitle: cat,
|
||||
});
|
||||
|
||||
let btnBox = new Gtk.Box({
|
||||
orientation: Gtk.Orientation.HORIZONTAL,
|
||||
spacing: 4,
|
||||
valign: Gtk.Align.CENTER,
|
||||
});
|
||||
|
||||
let upBtn = new Gtk.Button({
|
||||
icon_name: 'go-up-symbolic',
|
||||
css_classes: ['flat'],
|
||||
sensitive: idx > 0,
|
||||
});
|
||||
upBtn.connect('clicked', () => {
|
||||
let arr = this._settings.get_strv('hot-sensors');
|
||||
let i = arr.indexOf(fullKey);
|
||||
if (i > 0) {
|
||||
[arr[i - 1], arr[i]] = [arr[i], arr[i - 1]];
|
||||
this._settings.set_strv('hot-sensors', arr);
|
||||
}
|
||||
});
|
||||
|
||||
let downBtn = new Gtk.Button({
|
||||
icon_name: 'go-down-symbolic',
|
||||
css_classes: ['flat'],
|
||||
sensitive: idx < hot.length - 1,
|
||||
});
|
||||
downBtn.connect('clicked', () => {
|
||||
let arr = this._settings.get_strv('hot-sensors');
|
||||
let i = arr.indexOf(fullKey);
|
||||
if (i >= 0 && i < arr.length - 1) {
|
||||
[arr[i], arr[i + 1]] = [arr[i + 1], arr[i]];
|
||||
this._settings.set_strv('hot-sensors', arr);
|
||||
}
|
||||
});
|
||||
|
||||
let removeBtn = new Gtk.Button({
|
||||
icon_name: 'edit-delete-symbolic',
|
||||
css_classes: ['flat'],
|
||||
});
|
||||
removeBtn.connect('clicked', () => {
|
||||
let arr = this._settings.get_strv('hot-sensors');
|
||||
this._settings.set_strv('hot-sensors', arr.filter(k => k !== fullKey));
|
||||
});
|
||||
|
||||
btnBox.append(upBtn);
|
||||
btnBox.append(downBtn);
|
||||
btnBox.append(removeBtn);
|
||||
row.add_suffix(btnBox);
|
||||
|
||||
this._pinnedGroup.add(row);
|
||||
this._pinnedRows.push(row);
|
||||
}
|
||||
}
|
||||
|
||||
_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'));
|
||||
|
||||
let spacingRow = new Adw.SpinRow({
|
||||
title: _('Panel Spacing'),
|
||||
subtitle: _('Pixels between pinned values'),
|
||||
adjustment: new Gtk.Adjustment({
|
||||
lower: 0, upper: 32, value: 8, step_increment: 1,
|
||||
}),
|
||||
});
|
||||
this._settings.bind('panel-spacing', spacingRow, 'value', Gio.SettingsBindFlags.DEFAULT);
|
||||
group.add(spacingRow);
|
||||
|
||||
let intervalRow = new Adw.SpinRow({
|
||||
title: _('Update Interval'),
|
||||
subtitle: _('Seconds between UI updates'),
|
||||
adjustment: new Gtk.Adjustment({
|
||||
lower: 1, upper: 10, value: 1, step_increment: 1,
|
||||
}),
|
||||
});
|
||||
this._settings.bind('update-interval', intervalRow, 'value', Gio.SettingsBindFlags.DEFAULT);
|
||||
group.add(intervalRow);
|
||||
|
||||
return group;
|
||||
}
|
||||
|
||||
_createPositionGroup() {
|
||||
let group = new Adw.PreferencesGroup({ title: _('Panel Position') });
|
||||
|
||||
let posRow = new Adw.ComboRow({
|
||||
title: _('Position'),
|
||||
model: new Gtk.StringList({ strings: [_('Left'), _('Center'), _('Right')] }),
|
||||
});
|
||||
this._settings.bind('position-in-panel', posRow, 'selected', Gio.SettingsBindFlags.DEFAULT);
|
||||
group.add(posRow);
|
||||
|
||||
let idxRow = new Adw.SpinRow({
|
||||
title: _('Index'),
|
||||
adjustment: new Gtk.Adjustment({
|
||||
lower: -1, upper: 25, value: 0, step_increment: 1,
|
||||
}),
|
||||
});
|
||||
this._settings.bind('panel-box-index', idxRow, 'value', Gio.SettingsBindFlags.DEFAULT);
|
||||
group.add(idxRow);
|
||||
|
||||
return group;
|
||||
}
|
||||
|
||||
_switch(title, key) {
|
||||
let row = new Adw.SwitchRow({ title });
|
||||
this._settings.bind(key, row, 'active', Gio.SettingsBindFlags.DEFAULT);
|
||||
return row;
|
||||
}
|
||||
}
|
||||
BIN
org.sensord/gnome-sensor-tray/src/schemas/gschemas.compiled
Normal file
BIN
org.sensord/gnome-sensor-tray/src/schemas/gschemas.compiled
Normal file
Binary file not shown.
@@ -0,0 +1,52 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<schemalist>
|
||||
|
||||
<schema id="org.gnome.shell.extensions.sensortray"
|
||||
path="/org/gnome/shell/extensions/sensortray/">
|
||||
|
||||
<key name="hot-sensors" type="as">
|
||||
<default>[]</default>
|
||||
<summary>Sensors pinned to the panel</summary>
|
||||
<description>Ordered list of sensor keys shown in the top bar (e.g. "Thermal/coretemp/Core 0", "Cpu/total")</description>
|
||||
</key>
|
||||
|
||||
<key name="position-in-panel" type="i">
|
||||
<default>2</default>
|
||||
<summary>Panel position (0=left, 1=center, 2=right)</summary>
|
||||
</key>
|
||||
|
||||
<key name="panel-box-index" type="i">
|
||||
<default>0</default>
|
||||
<summary>Index within the panel box</summary>
|
||||
</key>
|
||||
|
||||
<key name="show-icon-on-panel" type="b">
|
||||
<default>true</default>
|
||||
<summary>Show icon next to each pinned sensor value</summary>
|
||||
</key>
|
||||
|
||||
<key name="unit" type="i">
|
||||
<default>0</default>
|
||||
<summary>Temperature unit (0=Celsius, 1=Fahrenheit)</summary>
|
||||
</key>
|
||||
|
||||
<key name="show-decimal-value" type="b">
|
||||
<default>false</default>
|
||||
<summary>Show one decimal place</summary>
|
||||
</key>
|
||||
|
||||
<key name="panel-spacing" type="i">
|
||||
<default>8</default>
|
||||
<summary>Spacing between pinned values in pixels</summary>
|
||||
</key>
|
||||
|
||||
<key name="update-interval" type="i">
|
||||
<default>1</default>
|
||||
<summary>Seconds between UI updates (1–10)</summary>
|
||||
<description>How many seconds between panel and menu repaints. D-Bus signals still arrive immediately; only the visual repaint is throttled.</description>
|
||||
<range min="1" max="10"/>
|
||||
</key>
|
||||
|
||||
</schema>
|
||||
|
||||
</schemalist>
|
||||
166
org.sensord/gnome-sensor-tray/src/sensorClient.js
Normal file
166
org.sensord/gnome-sensor-tray/src/sensorClient.js
Normal file
@@ -0,0 +1,166 @@
|
||||
import Gio from 'gi://Gio';
|
||||
import GLib from 'gi://GLib';
|
||||
|
||||
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._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;
|
||||
|
||||
try {
|
||||
this._conn = Gio.bus_get_sync(Gio.BusType.SYSTEM, null);
|
||||
} catch (e) {
|
||||
console.error('sensortray: cannot connect to system bus:', e.message);
|
||||
return;
|
||||
}
|
||||
|
||||
// Watch for sensord appearing/disappearing on the bus
|
||||
this._nameWatchId = Gio.bus_watch_name_on_connection(
|
||||
this._conn,
|
||||
BUS_NAME,
|
||||
Gio.BusNameWatcherFlags.NONE,
|
||||
() => this._onNameAppeared(),
|
||||
() => this._onNameVanished(),
|
||||
);
|
||||
}
|
||||
|
||||
_onNameAppeared() {
|
||||
this._available = true;
|
||||
this._discover();
|
||||
}
|
||||
|
||||
_onNameVanished() {
|
||||
this._available = false;
|
||||
this._unsubscribeAll();
|
||||
this._readings.clear();
|
||||
if (this._onChanged)
|
||||
this._onChanged(null, null); // signal "all gone"
|
||||
}
|
||||
|
||||
/**
|
||||
* Introspect /org/sensord, find all org.sensord.* interfaces,
|
||||
* call GetReadings on each, subscribe to Changed.
|
||||
*/
|
||||
_discover() {
|
||||
this._unsubscribeAll();
|
||||
this._readings.clear();
|
||||
|
||||
let introXml;
|
||||
try {
|
||||
let result = this._conn.call_sync(
|
||||
BUS_NAME, OBJECT_PATH,
|
||||
'org.freedesktop.DBus.Introspectable', 'Introspect',
|
||||
null, GLib.VariantType.new('(s)'),
|
||||
Gio.DBusCallFlags.NONE, 3000, null,
|
||||
);
|
||||
[introXml] = result.deep_unpack();
|
||||
} catch (e) {
|
||||
console.error('sensortray: introspect failed:', e.message);
|
||||
return;
|
||||
}
|
||||
|
||||
// Parse interface names from XML — simple regex is fine here
|
||||
let ifaces = [];
|
||||
let re = /interface\s+name="(org\.sensord\.[^"]+)"/g;
|
||||
let m;
|
||||
while ((m = re.exec(introXml)) !== null)
|
||||
ifaces.push(m[1]);
|
||||
|
||||
for (let iface of ifaces) {
|
||||
let category = iface.slice(IFACE_PREFIX.length); // "Power", "Thermal", etc.
|
||||
|
||||
// Subscribe to Changed signal
|
||||
let sid = this._conn.signal_subscribe(
|
||||
BUS_NAME, iface, 'Changed', OBJECT_PATH,
|
||||
null, Gio.DBusSignalFlags.NONE,
|
||||
(_conn, _sender, _path, _iface, _signal, params) => {
|
||||
let [readings] = params.deep_unpack();
|
||||
this._readings.set(category, readings);
|
||||
if (this._onChanged)
|
||||
this._onChanged(category, readings);
|
||||
},
|
||||
);
|
||||
this._signalIds.push(sid);
|
||||
|
||||
// Fetch initial state
|
||||
this._conn.call(
|
||||
BUS_NAME, OBJECT_PATH, iface, 'GetReadings',
|
||||
null, GLib.VariantType.new('(a{sd})'),
|
||||
Gio.DBusCallFlags.NONE, 3000, null,
|
||||
(conn, res) => {
|
||||
try {
|
||||
let result = conn.call_finish(res);
|
||||
let [readings] = result.deep_unpack();
|
||||
this._readings.set(category, readings);
|
||||
if (this._onChanged)
|
||||
this._onChanged(category, readings);
|
||||
} catch (e) {
|
||||
console.error(`sensortray: GetReadings(${iface}) failed:`, e.message);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
_unsubscribeAll() {
|
||||
if (!this._conn)
|
||||
return;
|
||||
for (let sid of this._signalIds)
|
||||
this._conn.signal_unsubscribe(sid);
|
||||
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;
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this._unsubscribeAll();
|
||||
if (this._nameWatchId) {
|
||||
Gio.bus_unwatch_name(this._nameWatchId);
|
||||
this._nameWatchId = 0;
|
||||
}
|
||||
this._conn = null;
|
||||
this._onChanged = null;
|
||||
this._readings.clear();
|
||||
}
|
||||
}
|
||||
37
org.sensord/gnome-sensor-tray/src/sensorItem.js
Normal file
37
org.sensord/gnome-sensor-tray/src/sensorItem.js
Normal file
@@ -0,0 +1,37 @@
|
||||
import Clutter from 'gi://Clutter';
|
||||
import GObject from 'gi://GObject';
|
||||
import St from 'gi://St';
|
||||
|
||||
import * as PopupMenu from 'resource:///org/gnome/shell/ui/popupMenu.js';
|
||||
|
||||
export default class SensorItem extends PopupMenu.PopupBaseMenuItem {
|
||||
|
||||
static {
|
||||
GObject.registerClass(this);
|
||||
}
|
||||
|
||||
constructor(gicon, key, label, value) {
|
||||
super();
|
||||
this._key = key;
|
||||
this._gicon = gicon;
|
||||
this._pinned = false;
|
||||
|
||||
this.add_child(new St.Icon({ style_class: 'popup-menu-icon', gicon }));
|
||||
this._label = new St.Label({ text: label, x_expand: true });
|
||||
this.add_child(this._label);
|
||||
this._value = new St.Label({ text: value });
|
||||
this.add_child(this._value);
|
||||
}
|
||||
|
||||
get key() { return this._key; }
|
||||
get gicon() { return this._gicon; }
|
||||
|
||||
get pinned() { return this._pinned; }
|
||||
set pinned(v) {
|
||||
this._pinned = v;
|
||||
this.setOrnament(v ? PopupMenu.Ornament.DOT : PopupMenu.Ornament.NONE);
|
||||
}
|
||||
|
||||
set value(v) { this._value.text = v; }
|
||||
set label(v) { this._label.text = v; }
|
||||
}
|
||||
7
org.sensord/gnome-sensor-tray/src/stylesheet.css
Normal file
7
org.sensord/gnome-sensor-tray/src/stylesheet.css
Normal file
@@ -0,0 +1,7 @@
|
||||
.sensortray-panel-label {
|
||||
padding: 0 6px;
|
||||
}
|
||||
|
||||
.sensortray-panel-icon-label {
|
||||
padding: 0 2px 0 0;
|
||||
}
|
||||
13
org.sensord/org.sensord.service
Normal file
13
org.sensord/org.sensord.service
Normal file
@@ -0,0 +1,13 @@
|
||||
[Unit]
|
||||
Description=System Sensor Bridge for D-Bus
|
||||
After=dbus.service
|
||||
|
||||
[Service]
|
||||
Type=dbus
|
||||
BusName=org.sensord
|
||||
ExecStart=/usr/local/bin/sensord
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
418
org.sensord/sensord.py
Normal file
418
org.sensord/sensord.py
Normal file
@@ -0,0 +1,418 @@
|
||||
#!/usr/bin/python3 -sP
|
||||
"""sensord — system sensor bridge for D-Bus.
|
||||
|
||||
Reads hardware sensors from sysfs/procfs and exposes them
|
||||
as D-Bus interfaces for sandboxed and desktop consumers.
|
||||
|
||||
Bus: org.sensord
|
||||
Object: /org/sensord
|
||||
|
||||
Interfaces:
|
||||
org.sensord.Power — RAPL power draw (W)
|
||||
org.sensord.Thermal — hwmon temperatures (°C)
|
||||
org.sensord.Cpu — per-core and total usage (%)
|
||||
org.sensord.Memory — memory utilization (bytes/%)
|
||||
|
||||
Each interface exposes:
|
||||
GetReadings() → a{sd}
|
||||
signal Changed(a{sd})
|
||||
|
||||
Usage:
|
||||
sensord --setup install D-Bus policy
|
||||
sensord start daemon (via systemd)
|
||||
"""
|
||||
|
||||
import os, sys # noqa: E401
|
||||
|
||||
import gi
|
||||
gi.require_version("Gio", "2.0")
|
||||
from gi.repository import Gio, GLib # noqa: E402
|
||||
|
||||
DBUS_NAME = "org.sensord"
|
||||
DBUS_PATH = "/org/sensord"
|
||||
DBUS_CONF = "/etc/dbus-1/system.d/org.sensord.conf"
|
||||
|
||||
POLICY = """\
|
||||
<!DOCTYPE busconfig PUBLIC
|
||||
"-//freedesktop//DTD D-BUS Bus Configuration 1.0//EN"
|
||||
"http://www.freedesktop.org/standards/dbus/1.0/busconfig.dtd">
|
||||
<busconfig>
|
||||
<policy user="root">
|
||||
<allow own="org.sensord"/>
|
||||
</policy>
|
||||
<policy context="default">
|
||||
<allow send_destination="org.sensord"/>
|
||||
</policy>
|
||||
</busconfig>
|
||||
"""
|
||||
|
||||
|
||||
def make_iface_xml(name):
|
||||
return f"""<interface name="org.sensord.{name}">
|
||||
<method name="GetReadings"><arg direction="out" type="a{{sd}}"/></method>
|
||||
<signal name="Changed"><arg type="a{{sd}}"/></signal>
|
||||
</interface>"""
|
||||
|
||||
|
||||
INTROSPECTION = f"""
|
||||
<node>
|
||||
{make_iface_xml("Power")}
|
||||
{make_iface_xml("Thermal")}
|
||||
{make_iface_xml("Cpu")}
|
||||
{make_iface_xml("Memory")}
|
||||
</node>
|
||||
"""
|
||||
|
||||
|
||||
# ── sensors ───────────────────────────────────────────────────
|
||||
|
||||
|
||||
class PowerSensor:
|
||||
"""RAPL energy counters → watts."""
|
||||
|
||||
RAPL_BASE = "/sys/class/powercap/intel-rapl"
|
||||
|
||||
class Zone:
|
||||
__slots__ = ("name", "fd", "wrap", "prev_e", "prev_t")
|
||||
|
||||
def __init__(self, path):
|
||||
self.name = self._read(path, "name") or os.path.basename(path)
|
||||
self.fd = os.open(os.path.join(path, "energy_uj"), os.O_RDONLY)
|
||||
self.wrap = int(self._read(path, "max_energy_range_uj") or 1 << 32)
|
||||
self.prev_e = self.prev_t = None
|
||||
|
||||
@staticmethod
|
||||
def _read(path, name):
|
||||
try:
|
||||
with open(os.path.join(path, name)) as f:
|
||||
return f.read().strip()
|
||||
except OSError:
|
||||
return None
|
||||
|
||||
def sample(self):
|
||||
os.lseek(self.fd, 0, os.SEEK_SET)
|
||||
e = int(os.read(self.fd, 64))
|
||||
t = GLib.get_monotonic_time()
|
||||
|
||||
if self.prev_e is None:
|
||||
self.prev_e, self.prev_t = e, t
|
||||
return None
|
||||
|
||||
dE = e - self.prev_e
|
||||
dt = t - self.prev_t
|
||||
self.prev_e, self.prev_t = e, t
|
||||
|
||||
if dE < 0:
|
||||
dE += self.wrap
|
||||
return dE / dt if dt > 0 else None
|
||||
|
||||
def close(self):
|
||||
os.close(self.fd)
|
||||
|
||||
def __init__(self):
|
||||
self.zones = []
|
||||
if not os.path.isdir(self.RAPL_BASE):
|
||||
return
|
||||
for root, _, files in os.walk(self.RAPL_BASE):
|
||||
if "energy_uj" in files:
|
||||
try:
|
||||
z = self.Zone(root)
|
||||
z.sample() # prime
|
||||
self.zones.append(z)
|
||||
print(f" power: {z.name}", file=sys.stderr)
|
||||
except OSError as e:
|
||||
print(f" power skip: {e}", file=sys.stderr)
|
||||
|
||||
@property
|
||||
def available(self):
|
||||
return bool(self.zones)
|
||||
|
||||
def sample(self):
|
||||
r = {}
|
||||
for z in self.zones:
|
||||
w = z.sample()
|
||||
if w is not None:
|
||||
r[z.name] = round(w, 2)
|
||||
return r
|
||||
|
||||
def close(self):
|
||||
for z in self.zones:
|
||||
z.close()
|
||||
|
||||
|
||||
class ThermalSensor:
|
||||
"""hwmon temperature sensors → °C."""
|
||||
|
||||
HWMON_BASE = "/sys/class/hwmon"
|
||||
|
||||
class Chip:
|
||||
__slots__ = ("label", "fd")
|
||||
|
||||
def __init__(self, label, path):
|
||||
self.label = label
|
||||
self.fd = os.open(path, os.O_RDONLY)
|
||||
|
||||
def read(self):
|
||||
os.lseek(self.fd, 0, os.SEEK_SET)
|
||||
return int(os.read(self.fd, 32)) / 1000.0
|
||||
|
||||
def close(self):
|
||||
os.close(self.fd)
|
||||
|
||||
def __init__(self):
|
||||
self.chips = []
|
||||
if not os.path.isdir(self.HWMON_BASE):
|
||||
return
|
||||
|
||||
for hwmon in os.listdir(self.HWMON_BASE):
|
||||
hwdir = os.path.join(self.HWMON_BASE, hwmon)
|
||||
chip_name = self._read_file(os.path.join(hwdir, "name")) or hwmon
|
||||
|
||||
for f in sorted(os.listdir(hwdir)):
|
||||
if not f.startswith("temp") or not f.endswith("_input"):
|
||||
continue
|
||||
|
||||
path = os.path.join(hwdir, f)
|
||||
idx = f.replace("temp", "").replace("_input", "")
|
||||
label_path = os.path.join(hwdir, f"temp{idx}_label")
|
||||
label = self._read_file(label_path) or f"temp{idx}"
|
||||
full_label = f"{chip_name}/{label}"
|
||||
|
||||
try:
|
||||
chip = self.Chip(full_label, path)
|
||||
chip.read() # test
|
||||
self.chips.append(chip)
|
||||
print(f" thermal: {full_label}", file=sys.stderr)
|
||||
except OSError as e:
|
||||
print(f" thermal skip: {e}", file=sys.stderr)
|
||||
|
||||
@staticmethod
|
||||
def _read_file(path):
|
||||
try:
|
||||
with open(path) as f:
|
||||
return f.read().strip()
|
||||
except OSError:
|
||||
return None
|
||||
|
||||
@property
|
||||
def available(self):
|
||||
return bool(self.chips)
|
||||
|
||||
def sample(self):
|
||||
r = {}
|
||||
for c in self.chips:
|
||||
try:
|
||||
r[c.label] = round(c.read(), 1)
|
||||
except (OSError, ValueError):
|
||||
pass
|
||||
return r
|
||||
|
||||
def close(self):
|
||||
for c in self.chips:
|
||||
c.close()
|
||||
|
||||
|
||||
class CpuSensor:
|
||||
"""/proc/stat → per-core and total CPU usage %."""
|
||||
|
||||
def __init__(self):
|
||||
self.fd = None
|
||||
self.prev = {}
|
||||
|
||||
try:
|
||||
self.fd = os.open("/proc/stat", os.O_RDONLY)
|
||||
self._read_stat() # prime
|
||||
print(f" cpu: {len(self.prev)} entries", file=sys.stderr)
|
||||
except OSError as e:
|
||||
print(f" cpu skip: {e}", file=sys.stderr)
|
||||
|
||||
@property
|
||||
def available(self):
|
||||
return self.fd is not None
|
||||
|
||||
def _read_stat(self):
|
||||
os.lseek(self.fd, 0, os.SEEK_SET)
|
||||
raw = os.read(self.fd, 8192).decode()
|
||||
entries = {}
|
||||
for line in raw.splitlines():
|
||||
if not line.startswith("cpu"):
|
||||
break
|
||||
parts = line.split()
|
||||
name = parts[0]
|
||||
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]
|
||||
total = sum(vals)
|
||||
entries[name] = (idle, total)
|
||||
return entries
|
||||
|
||||
def sample(self):
|
||||
cur = self._read_stat()
|
||||
r = {}
|
||||
for name, (idle, total) in cur.items():
|
||||
if name in self.prev:
|
||||
pi, pt = self.prev[name]
|
||||
dt = total - pt
|
||||
di = idle - pi
|
||||
if dt > 0:
|
||||
label = "total" if name == "cpu" else name
|
||||
r[label] = round(100.0 * (1.0 - di / dt), 1)
|
||||
self.prev = cur
|
||||
return r
|
||||
|
||||
def close(self):
|
||||
if self.fd is not None:
|
||||
os.close(self.fd)
|
||||
|
||||
|
||||
class MemorySensor:
|
||||
"""/proc/meminfo → memory stats in bytes and usage %."""
|
||||
|
||||
KEYS = ("MemTotal", "MemAvailable", "MemFree", "SwapTotal", "SwapFree")
|
||||
|
||||
def __init__(self):
|
||||
self.fd = None
|
||||
try:
|
||||
self.fd = os.open("/proc/meminfo", os.O_RDONLY)
|
||||
self.sample() # test
|
||||
print(" memory: ok", file=sys.stderr)
|
||||
except OSError as e:
|
||||
print(f" memory skip: {e}", file=sys.stderr)
|
||||
|
||||
@property
|
||||
def available(self):
|
||||
return self.fd is not None
|
||||
|
||||
def sample(self):
|
||||
os.lseek(self.fd, 0, os.SEEK_SET)
|
||||
raw = os.read(self.fd, 4096).decode()
|
||||
|
||||
vals = {}
|
||||
for line in raw.splitlines():
|
||||
parts = line.split()
|
||||
key = parts[0].rstrip(":")
|
||||
if key in self.KEYS:
|
||||
vals[key] = int(parts[1]) * 1024 # kB → bytes
|
||||
|
||||
r = {}
|
||||
mt = vals.get("MemTotal", 0)
|
||||
ma = vals.get("MemAvailable", 0)
|
||||
st = vals.get("SwapTotal", 0)
|
||||
sf = vals.get("SwapFree", 0)
|
||||
|
||||
if mt:
|
||||
r["total"] = float(mt)
|
||||
r["available"] = float(ma)
|
||||
r["used"] = float(mt - ma)
|
||||
r["percent"] = round(100.0 * (1.0 - ma / mt), 1)
|
||||
if st:
|
||||
r["swap_total"] = float(st)
|
||||
r["swap_used"] = float(st - sf)
|
||||
r["swap_percent"] = round(100.0 * (1.0 - sf / st), 1) if st else 0.0
|
||||
|
||||
return r
|
||||
|
||||
def close(self):
|
||||
if self.fd is not None:
|
||||
os.close(self.fd)
|
||||
|
||||
|
||||
# ── daemon ────────────────────────────────────────────────────
|
||||
|
||||
|
||||
SENSORS = {
|
||||
"Power": (PowerSensor, 1), # iface name, interval (sec)
|
||||
"Thermal": (ThermalSensor, 2),
|
||||
"Cpu": (CpuSensor, 1),
|
||||
"Memory": (MemorySensor, 2),
|
||||
}
|
||||
|
||||
|
||||
class Daemon:
|
||||
def __init__(self):
|
||||
self.loop = GLib.MainLoop()
|
||||
self.bus = None
|
||||
self.sensors = {} # name → sensor instance
|
||||
self.readings = {} # name → latest {key: value}
|
||||
self.node = Gio.DBusNodeInfo.new_for_xml(INTROSPECTION)
|
||||
|
||||
for name, (cls, _) in SENSORS.items():
|
||||
sensor = cls()
|
||||
if sensor.available:
|
||||
self.sensors[name] = sensor
|
||||
self.readings[name] = {}
|
||||
|
||||
if not self.sensors:
|
||||
raise RuntimeError("no sensors available")
|
||||
|
||||
Gio.bus_own_name(
|
||||
Gio.BusType.SYSTEM, DBUS_NAME, Gio.BusNameOwnerFlags.NONE,
|
||||
self._on_bus, None, lambda *_: self.loop.quit(),
|
||||
)
|
||||
|
||||
def _on_bus(self, conn, _name):
|
||||
self.bus = conn
|
||||
iface_map = {i.name: i for i in self.node.interfaces}
|
||||
for name in self.sensors:
|
||||
conn.register_object(
|
||||
DBUS_PATH, iface_map[f"org.sensord.{name}"],
|
||||
self._on_call, None, None,
|
||||
)
|
||||
print(f"sensord: {', '.join(self.sensors)}", file=sys.stderr)
|
||||
|
||||
def _on_call(self, conn, sender, path, iface, method, params, invocation):
|
||||
name = iface.rsplit(".", 1)[-1]
|
||||
if method == "GetReadings" and name in self.sensors:
|
||||
invocation.return_value(GLib.Variant("(a{sd})", (self.readings[name],)))
|
||||
else:
|
||||
invocation.return_dbus_error("org.freedesktop.DBus.Error.UnknownMethod", method)
|
||||
|
||||
def _make_tick(self, name):
|
||||
def tick():
|
||||
r = self.sensors[name].sample()
|
||||
if r:
|
||||
self.readings[name] = r
|
||||
if self.bus:
|
||||
self.bus.emit_signal(
|
||||
None, DBUS_PATH, f"org.sensord.{name}", "Changed",
|
||||
GLib.Variant.new_tuple(GLib.Variant("a{sd}", r)),
|
||||
)
|
||||
return GLib.SOURCE_CONTINUE
|
||||
return tick
|
||||
|
||||
def run(self):
|
||||
for name in self.sensors:
|
||||
GLib.timeout_add_seconds(SENSORS[name][1], self._make_tick(name))
|
||||
|
||||
GLib.unix_signal_add(GLib.PRIORITY_DEFAULT, 2, self.loop.quit)
|
||||
GLib.unix_signal_add(GLib.PRIORITY_DEFAULT, 15, self.loop.quit)
|
||||
self.loop.run()
|
||||
|
||||
for s in self.sensors.values():
|
||||
s.close()
|
||||
|
||||
|
||||
# ── entry ─────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def setup():
|
||||
with open(DBUS_CONF, "w") as f:
|
||||
f.write(POLICY)
|
||||
os.chmod(DBUS_CONF, 0o644)
|
||||
print(f"wrote {DBUS_CONF}", file=sys.stderr)
|
||||
|
||||
|
||||
def main():
|
||||
if os.geteuid() != 0:
|
||||
print("run as root", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
if "--setup" in sys.argv:
|
||||
setup()
|
||||
return
|
||||
|
||||
Daemon().run()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user