From 8d77a62e268e993e6ec3dca3a16d66ded2214c6b Mon Sep 17 00:00:00 2001 From: Morgan Date: Wed, 11 Mar 2026 11:52:02 +0900 Subject: [PATCH] Init --- README.md | 22 + org.batteryd/battery-viewer | 603 ++++++++++++++++++ org.batteryd/batteryd | 392 ++++++++++++ org.batteryd/org.batteryd.Viewer.destkop | 7 + org.sensord/gnome-sensor-tray/Makefile | 27 + .../gnome-sensor-tray/src/extension.js | 424 ++++++++++++ .../gnome-sensor-tray/src/metadata.json | 8 + org.sensord/gnome-sensor-tray/src/prefs.js | 186 ++++++ .../src/schemas/gschemas.compiled | Bin 0 -> 717 bytes ...me.shell.extensions.sensortray.gschema.xml | 52 ++ .../gnome-sensor-tray/src/sensorClient.js | 166 +++++ .../gnome-sensor-tray/src/sensorItem.js | 37 ++ .../gnome-sensor-tray/src/stylesheet.css | 7 + org.sensord/org.sensord.service | 13 + org.sensord/sensord.py | 418 ++++++++++++ screentimed/Makefile | 41 ++ screentimed/app-tracker@local/extension.js | 83 +++ screentimed/app-tracker@local/metadata.json | 7 + screentimed/org.screentimed.desktop | 7 + screentimed/org.screentimed.service | 13 + screentimed/screentime-viewer | 436 +++++++++++++ screentimed/screentimed | 428 +++++++++++++ 22 files changed, 3377 insertions(+) create mode 100644 README.md create mode 100644 org.batteryd/battery-viewer create mode 100644 org.batteryd/batteryd create mode 100644 org.batteryd/org.batteryd.Viewer.destkop create mode 100644 org.sensord/gnome-sensor-tray/Makefile create mode 100644 org.sensord/gnome-sensor-tray/src/extension.js create mode 100644 org.sensord/gnome-sensor-tray/src/metadata.json create mode 100644 org.sensord/gnome-sensor-tray/src/prefs.js create mode 100644 org.sensord/gnome-sensor-tray/src/schemas/gschemas.compiled create mode 100644 org.sensord/gnome-sensor-tray/src/schemas/org.gnome.shell.extensions.sensortray.gschema.xml create mode 100644 org.sensord/gnome-sensor-tray/src/sensorClient.js create mode 100644 org.sensord/gnome-sensor-tray/src/sensorItem.js create mode 100644 org.sensord/gnome-sensor-tray/src/stylesheet.css create mode 100644 org.sensord/org.sensord.service create mode 100644 org.sensord/sensord.py create mode 100644 screentimed/Makefile create mode 100644 screentimed/app-tracker@local/extension.js create mode 100644 screentimed/app-tracker@local/metadata.json create mode 100644 screentimed/org.screentimed.desktop create mode 100644 screentimed/org.screentimed.service create mode 100644 screentimed/screentime-viewer create mode 100644 screentimed/screentimed diff --git a/README.md b/README.md new file mode 100644 index 0000000..fe366cb --- /dev/null +++ b/README.md @@ -0,0 +1,22 @@ +## System Status Telemetry tools + +### org.sensord + +Collects Cpu (Usage), Thermal, and Power (Intel RAPL) and expose it via D-Bus + +- Extension fro gnome-shell that receives D-Bus sensords events to display them on tray + +### org.batteryd + +Collects battery information from UPower D-Bus + +- Simple GTK app that displays statistics + + +### org.screentimed + +Collects opened applications from gnome-shell extension (app-tracker) + +- Extension for gnome-shell that exposes opened application via D-Bus + +- Simple GTK app that displays statistics diff --git a/org.batteryd/battery-viewer b/org.batteryd/battery-viewer new file mode 100644 index 0000000..8d26636 --- /dev/null +++ b/org.batteryd/battery-viewer @@ -0,0 +1,603 @@ +#!/usr/bin/env python3 +""" +Battery Viewer — GUI for batteryd data +""" + +import sys +from datetime import date, datetime, timedelta + +import gi +gi.require_version('Gtk', '4.0') +gi.require_version('Adw', '1') +gi.require_version('Gio', '2.0') +gi.require_version('GLib', '2.0') +from gi.repository import Gtk, Adw, Gio, GLib, Gdk + +DBUS_BUS = 'org.batteryd' +DBUS_PATH = '/org/batteryd' +DBUS_IFACE = 'org.batteryd' + +STATE_NAMES = { + 0: 'Unknown', 1: 'Charging', 2: 'Discharging', + 3: 'Empty', 4: 'Full', 5: 'PendingCharge', 6: 'PendingDischarge', +} + + +def format_duration(seconds): + seconds = int(seconds) + if seconds < 60: + return f'{seconds}s' + m, s = divmod(seconds, 60) + h, m = divmod(m, 60) + if h: + return f'{h}h {m}m' + return f'{m}m' + + +# ── D-Bus client ────────────────────────────────────────── + +class BatteryClient: + def __init__(self): + self.bus = Gio.bus_get_sync(Gio.BusType.SESSION) + + def _call(self, method, args=None, reply_type=None): + try: + result = self.bus.call_sync( + DBUS_BUS, DBUS_PATH, DBUS_IFACE, method, + args, GLib.VariantType(reply_type) if reply_type else None, + Gio.DBusCallFlags.NONE, -1, None) + return result.unpack() + except GLib.Error as e: + print(f'D-Bus error: {e.message}', file=sys.stderr) + return None + + def get_samples(self, date_str): + """Returns list of (ts, level, energy_wh, power_w, voltage_v, state)""" + r = self._call('GetSamples', GLib.Variant('(s)', (date_str,)), + '(a(dddddd))') + return list(r[0]) if r else [] + + def get_sessions(self, date_str): + """Returns list of (start_ts, end_ts, start_level, end_level, status)""" + r = self._call('GetSessions', GLib.Variant('(s)', (date_str,)), + '(a(dddds))') + return list(r[0]) if r else [] + + def get_current(self): + r = self._call('GetCurrent', None, '(dddds)') + if r: + return {'level': r[0], 'energy_wh': r[1], + 'power_w': r[2], 'voltage_v': r[3], 'status': r[4]} + return None + + +# ── Level + Energy chart ────────────────────────────────── + +class BatteryChart(Gtk.DrawingArea): + def __init__(self): + super().__init__() + self.samples = [] + self.day_start = 0 + self.set_content_height(200) + self.set_draw_func(self._draw) + + self.set_has_tooltip(True) + motion = Gtk.EventControllerMotion() + motion.connect('motion', self._on_motion) + motion.connect('leave', lambda c: self.set_tooltip_markup(None)) + self.add_controller(motion) + + def set_data(self, samples, day_start): + self.samples = samples + self.day_start = day_start + self.queue_draw() + + def _on_motion(self, ctrl, x, y): + if not self.samples or not self.day_start: + self.set_tooltip_markup(None) + return + + width = self.get_width() + margin_l, margin_r = 40, 48 + cw = width - margin_l - margin_r + if cw <= 0: + return + + frac = (x - margin_l) / cw + if frac < 0 or frac > 1: + self.set_tooltip_markup(None) + return + + t_min, t_max = self._time_range() + target_ts = t_min + frac * (t_max - t_min) + + best = min(self.samples, key=lambda s: abs(s[0] - target_ts)) + if abs(best[0] - target_ts) > 600: + self.set_tooltip_markup(None) + return + + ts, level, energy, power, voltage, state = best + t = datetime.fromtimestamp(ts).strftime('%H:%M:%S') + status = STATE_NAMES.get(int(state), '?') + self.set_tooltip_markup( + f'{t}\n' + f'{level:.1f}% · {energy:.2f} Wh\n' + f'{power:.1f} W · {voltage:.2f} V · {status}') + + def _time_range(self): + """Compute time axis range: fit to data with padding, snap to hours.""" + if not self.samples: + return self.day_start, self.day_start + 86400 + + first_ts = self.samples[0][0] + last_ts = self.samples[-1][0] + span = last_ts - first_ts + + # pad 10% on each side, minimum 10 min + pad = max(span * 0.1, 600) + t_min = first_ts - pad + t_max = last_ts + pad + + # snap to hour boundaries + t_min = t_min - (t_min % 3600) + t_max = t_max + (3600 - t_max % 3600) if t_max % 3600 else t_max + + # clamp to day + day_end = self.day_start + 86400 + t_min = max(t_min, self.day_start) + t_max = min(t_max, day_end) + + return t_min, t_max + + def _draw(self, area, cr, width, height): + if len(self.samples) < 2: + fg = self.get_color() + cr.set_source_rgba(fg.red, fg.green, fg.blue, 0.3) + cr.set_font_size(12) + cr.move_to(width / 2 - 40, height / 2) + cr.show_text('No data yet') + return + + ml, mr, mt, mb = 40, 48, 12, 24 + cw = width - ml - mr + ch = height - mt - mb + + fg = self.get_color() + t_min, t_max = self._time_range() + t_span = t_max - t_min + + energies = [s[2] for s in self.samples] + e_min = min(energies) + e_max = max(energies) + if e_max <= e_min: + e_max = e_min + 1 + + def tx(ts): + return ml + ((ts - t_min) / t_span) * cw + + def ty_pct(pct): + return mt + (1 - pct / 100) * ch + + def ty_energy(e): + return mt + (1 - (e - e_min) / (e_max - e_min)) * ch + + # background grid — horizontal + cr.set_source_rgba(fg.red, fg.green, fg.blue, 0.06) + cr.set_line_width(0.5) + for pct in (25, 50, 75): + y = ty_pct(pct) + cr.move_to(ml, y) + cr.line_to(width - mr, y) + cr.stroke() + + # vertical grid — pick interval based on span + hours_span = t_span / 3600 + if hours_span <= 2: + tick_secs = 900 # 15 min + elif hours_span <= 6: + tick_secs = 1800 # 30 min + elif hours_span <= 12: + tick_secs = 3600 # 1 hour + else: + tick_secs = 3 * 3600 # 3 hours + + # first tick aligned to interval + first_tick = t_min - (t_min % tick_secs) + tick_secs + t = first_tick + while t < t_max: + x = tx(t) + cr.set_source_rgba(fg.red, fg.green, fg.blue, 0.06) + cr.move_to(x, mt) + cr.line_to(x, mt + ch) + cr.stroke() + t += tick_secs + + # charging regions (green tint) + for i in range(len(self.samples) - 1): + s0, s1 = self.samples[i], self.samples[i + 1] + if int(s0[5]) == 1: # Charging (state is now index 5) + x1 = max(ml, tx(s0[0])) + x2 = min(width - mr, tx(s1[0])) + cr.set_source_rgba(0.3, 0.8, 0.3, 0.06) + cr.rectangle(x1, mt, x2 - x1, ch) + cr.fill() + + # percentage line (blue) + cr.set_source_rgba(0.2, 0.6, 1.0, 0.9) + cr.set_line_width(1.5) + for i, s in enumerate(self.samples): + x = tx(s[0]) + y = ty_pct(s[1]) + if i == 0: + cr.move_to(x, y) + else: + cr.line_to(x, y) + cr.stroke() + + # energy line (orange) + cr.set_source_rgba(1.0, 0.6, 0.2, 0.7) + cr.set_line_width(1) + for i, s in enumerate(self.samples): + x = tx(s[0]) + y = ty_energy(s[2]) + if i == 0: + cr.move_to(x, y) + else: + cr.line_to(x, y) + cr.stroke() + + # left axis: percentage + cr.set_source_rgba(0.2, 0.6, 1.0, 0.6) + cr.set_font_size(9) + for pct in (0, 25, 50, 75, 100): + y = ty_pct(pct) + 3 + cr.move_to(4, y) + cr.show_text(f'{pct}%') + + # right axis: energy Wh + cr.set_source_rgba(1.0, 0.6, 0.2, 0.6) + for i in range(5): + e = e_min + (e_max - e_min) * i / 4 + y = ty_energy(e) + 3 + cr.move_to(width - mr + 6, y) + cr.show_text(f'{e:.0f}Wh') + + # bottom axis labels + cr.set_source_rgba(fg.red, fg.green, fg.blue, 0.4) + cr.set_font_size(9) + t = first_tick + while t < t_max: + x = tx(t) + label = datetime.fromtimestamp(t).strftime('%H:%M') + cr.move_to(x - 12, height - 6) + cr.show_text(label) + t += tick_secs + + +# ── Power chart ─────────────────────────────────────────── + +class PowerChart(Gtk.DrawingArea): + def __init__(self): + super().__init__() + self.samples = [] + self.day_start = 0 + self.set_content_height(90) + self.set_draw_func(self._draw) + + self.set_has_tooltip(True) + motion = Gtk.EventControllerMotion() + motion.connect('motion', self._on_motion) + motion.connect('leave', lambda c: self.set_tooltip_markup(None)) + self.add_controller(motion) + + def set_data(self, samples, day_start): + self.samples = samples + self.day_start = day_start + self.queue_draw() + + def _time_range(self): + if not self.samples: + return self.day_start, self.day_start + 86400 + first_ts = self.samples[0][0] + last_ts = self.samples[-1][0] + span = last_ts - first_ts + pad = max(span * 0.1, 600) + t_min = first_ts - pad + t_max = last_ts + pad + t_min = t_min - (t_min % 3600) + t_max = t_max + (3600 - t_max % 3600) if t_max % 3600 else t_max + day_end = self.day_start + 86400 + t_min = max(t_min, self.day_start) + t_max = min(t_max, day_end) + return t_min, t_max + + def _on_motion(self, ctrl, x, y): + if not self.samples: + self.set_tooltip_markup(None) + return + + ml, mr = 40, 12 + cw = self.get_width() - ml - mr + if cw <= 0: + return + + frac = (x - ml) / cw + if frac < 0 or frac > 1: + self.set_tooltip_markup(None) + return + + t_min, t_max = self._time_range() + target_ts = t_min + frac * (t_max - t_min) + best = min(self.samples, key=lambda s: abs(s[0] - target_ts)) + if abs(best[0] - target_ts) > 600: + self.set_tooltip_markup(None) + return + + t = datetime.fromtimestamp(best[0]).strftime('%H:%M:%S') + self.set_tooltip_markup(f'{t}\n{best[3]:.2f} W') + + def _draw(self, area, cr, width, height): + if len(self.samples) < 2: + return + + ml, mr, mt, mb = 40, 12, 8, 8 + cw = width - ml - mr + ch = height - mt - mb + + t_min, t_max = self._time_range() + t_span = t_max - t_min + + powers = [s[3] for s in self.samples] + p_max = max(max(powers), 0.1) + + def tx(ts): + return ml + ((ts - t_min) / t_span) * cw + + def ty(pw): + return mt + (1 - pw / p_max) * ch + + # filled area + cr.set_source_rgba(0.9, 0.3, 0.3, 0.12) + cr.move_to(tx(self.samples[0][0]), mt + ch) + for s in self.samples: + cr.line_to(tx(s[0]), ty(s[3])) + cr.line_to(tx(self.samples[-1][0]), mt + ch) + cr.close_path() + cr.fill() + + # line + cr.set_source_rgba(0.9, 0.3, 0.3, 0.8) + cr.set_line_width(1) + for i, s in enumerate(self.samples): + x, y = tx(s[0]), ty(s[3]) + if i == 0: + cr.move_to(x, y) + else: + cr.line_to(x, y) + cr.stroke() + + # axis + fg = self.get_color() + cr.set_source_rgba(fg.red, fg.green, fg.blue, 0.4) + cr.set_font_size(9) + cr.move_to(4, mt + 10) + cr.show_text(f'{p_max:.1f}W') + cr.move_to(4, mt + ch) + cr.show_text('0W') + + +# ── Session row ─────────────────────────────────────────── + +class SessionRow(Adw.ActionRow): + def __init__(self, start_ts, end_ts, start_level, end_level, status): + super().__init__() + + t_start = datetime.fromtimestamp(start_ts).strftime('%H:%M') + t_end = datetime.fromtimestamp(end_ts).strftime('%H:%M') + duration = end_ts - start_ts + + if status == 'Discharging': + icon = 'battery-level-50-symbolic' + arrow = '↓' + elif status == 'Charging': + icon = 'battery-level-50-charging-symbolic' + arrow = '↑' + else: + icon = 'battery-level-100-symbolic' + arrow = '→' + + self.set_title(status) + self.set_subtitle(f'{t_start} – {t_end} · {format_duration(duration)}') + + img = Gtk.Image.new_from_icon_name(icon) + img.set_pixel_size(24) + self.add_prefix(img) + + label = Gtk.Label() + label.set_markup(f'{start_level:.0f}% {arrow} {end_level:.0f}%') + label.add_css_class('caption') + label.set_valign(Gtk.Align.CENTER) + self.add_suffix(label) + + +# ── Main window ─────────────────────────────────────────── + +class BatteryWindow(Adw.ApplicationWindow): + def __init__(self, app): + super().__init__(application=app, title='Battery', + default_width=460, default_height=650) + + self.client = BatteryClient() + self.current_date = date.today() + + css = Gtk.CssProvider() + css.load_from_string(''' + .big-level { font-size: 2em; font-weight: 800; } + .legend-level { + background: rgba(51,153,255,0.9); + border-radius: 2px; min-width: 10px; min-height: 10px; + } + .legend-energy { + background: rgba(255,153,51,0.7); + border-radius: 2px; min-width: 10px; min-height: 10px; + } + .legend-power { + background: rgba(230,77,77,0.7); + border-radius: 2px; min-width: 10px; min-height: 10px; + } + ''') + Gtk.StyleContext.add_provider_for_display( + Gdk.Display.get_default(), css, + Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION) + + toolbar = Adw.ToolbarView() + header = Adw.HeaderBar() + toolbar.add_top_bar(header) + + # nav + nav = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8) + nav.set_halign(Gtk.Align.CENTER) + prev_btn = Gtk.Button(icon_name='go-previous-symbolic') + prev_btn.connect('clicked', lambda _: self._change_date(-1)) + nav.append(prev_btn) + self.date_label = Gtk.Label() + self.date_label.add_css_class('title-4') + nav.append(self.date_label) + next_btn = Gtk.Button(icon_name='go-next-symbolic') + next_btn.connect('clicked', lambda _: self._change_date(1)) + nav.append(next_btn) + header.set_title_widget(nav) + + # content + scroll = Gtk.ScrolledWindow(vexpand=True) + scroll.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC) + self.content = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0) + self.content.set_margin_start(16) + self.content.set_margin_end(16) + self.content.set_margin_top(16) + self.content.set_margin_bottom(16) + scroll.set_child(self.content) + toolbar.set_content(scroll) + + # current + self.status_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=4) + self.status_box.set_halign(Gtk.Align.CENTER) + self.status_box.set_margin_bottom(16) + self.level_label = Gtk.Label() + self.level_label.add_css_class('big-level') + self.status_box.append(self.level_label) + self.status_sub = Gtk.Label() + self.status_sub.add_css_class('dim-label') + self.status_box.append(self.status_sub) + self.content.append(self.status_box) + + # level chart + legend + chart_group = Adw.PreferencesGroup(title='Battery Level') + legend = Gtk.Box(spacing=12) + legend.set_halign(Gtk.Align.END) + for css_cls, text in [('legend-level', 'Level %'), ('legend-energy', 'Energy Wh')]: + item = Gtk.Box(spacing=4) + dot = Gtk.Box() + dot.set_size_request(10, 10) + dot.set_valign(Gtk.Align.CENTER) + dot.add_css_class(css_cls) + item.append(dot) + lbl = Gtk.Label(label=text) + lbl.add_css_class('caption') + lbl.add_css_class('dim-label') + item.append(lbl) + legend.append(item) + chart_group.set_header_suffix(legend) + self.chart = BatteryChart() + chart_group.add(self.chart) + self.content.append(chart_group) + + # power chart + power_group = Adw.PreferencesGroup(title='Power Draw') + power_group.set_margin_top(16) + self.power_chart = PowerChart() + power_group.add(self.power_chart) + self.content.append(power_group) + + # sessions + self.session_group = Adw.PreferencesGroup(title='Sessions') + self.session_group.set_margin_top(16) + self.content.append(self.session_group) + + self.set_content(toolbar) + self._refresh() + + def _change_date(self, delta): + self.current_date += timedelta(days=delta) + self._refresh() + + def _refresh(self): + date_str = self.current_date.isoformat() + + if self.current_date == date.today(): + self.date_label.set_label('Today') + elif self.current_date == date.today() - timedelta(days=1): + self.date_label.set_label('Yesterday') + else: + self.date_label.set_label( + self.current_date.strftime('%a, %b %-d')) + + samples = self.client.get_samples(date_str) + sessions = self.client.get_sessions(date_str) + day_start = datetime.combine( + self.current_date, datetime.min.time()).timestamp() + + # current (today only) + if self.current_date == date.today(): + cur = self.client.get_current() + if cur: + self.level_label.set_label(f'{cur["level"]:.0f}%') + self.status_sub.set_label( + f'{cur["energy_wh"]:.1f} Wh · ' + f'{cur["power_w"]:.1f} W · ' + f'{cur["voltage_v"]:.2f} V · {cur["status"]}') + self.status_box.set_visible(True) + else: + self.status_box.set_visible(False) + else: + self.status_box.set_visible(False) + + # charts + self.chart.set_data(samples, day_start) + self.power_chart.set_data(samples, day_start) + + # sessions list + self.content.remove(self.session_group) + self.session_group = Adw.PreferencesGroup(title='Sessions') + self.session_group.set_margin_top(16) + self.content.append(self.session_group) + + for s in sessions: + start_ts, end_ts, start_lvl, end_lvl, status = s + if end_ts - start_ts < 30: + continue + row = SessionRow(start_ts, end_ts, start_lvl, end_lvl, status) + self.session_group.add(row) + + if not sessions: + empty = Adw.ActionRow(title='No sessions recorded') + empty.add_css_class('dim-label') + self.session_group.add(empty) + + +# ── App ─────────────────────────────────────────────────── + +class BatteryApp(Adw.Application): + def __init__(self): + super().__init__(application_id='org.batteryd.Viewer') + + def do_activate(self): + win = self.get_active_window() + if not win: + win = BatteryWindow(self) + win.present() + + +if __name__ == '__main__': + app = BatteryApp() + app.run(sys.argv) diff --git a/org.batteryd/batteryd b/org.batteryd/batteryd new file mode 100644 index 0000000..935ea44 --- /dev/null +++ b/org.batteryd/batteryd @@ -0,0 +1,392 @@ +#!/usr/bin/env python3 +""" +batteryd — battery tracking daemon + +Subscribes to UPower's PropertiesChanged on the battery device, +logs percentage + energy_wh + power_w on each change. +Stores in SQLite for long-term retention. +""" + +import logging +import os +import signal +import sqlite3 +import time +from datetime import datetime + +import gi +gi.require_version('Gio', '2.0') +gi.require_version('GLib', '2.0') +from gi.repository import Gio, GLib + +log = logging.getLogger('batteryd') + +DB_DIR = os.path.join(GLib.get_user_data_dir(), 'batteryd') +DB_PATH = os.path.join(DB_DIR, 'battery.db') + +FLUSH_INTERVAL = 5 * 60 +MIN_SAMPLE_INTERVAL = 10 + +UPOWER_BUS = 'org.freedesktop.UPower' +UPOWER_DEVICE_IFACE = 'org.freedesktop.UPower.Device' +DBUS_PROPS_IFACE = 'org.freedesktop.DBus.Properties' + +STATE_NAMES = { + 0: 'Unknown', 1: 'Charging', 2: 'Discharging', + 3: 'Empty', 4: 'Full', 5: 'PendingCharge', 6: 'PendingDischarge', +} + +DBUS_BUS = 'org.batteryd' +DBUS_PATH = '/org/batteryd' +DBUS_IFACE_XML = ''' + + + + + + + + + + + + + + + + + +''' + + +def _local_day_range(date_str): + dt = datetime.strptime(date_str, '%Y-%m-%d') + start = dt.timestamp() + return start, start + 86400 + + +def find_battery(sys_bus): + try: + result = sys_bus.call_sync( + UPOWER_BUS, '/org/freedesktop/UPower', + 'org.freedesktop.UPower', 'EnumerateDevices', + None, GLib.VariantType('(ao)'), + Gio.DBusCallFlags.NONE, -1, None) + for path in result.unpack()[0]: + props = sys_bus.call_sync( + UPOWER_BUS, path, DBUS_PROPS_IFACE, 'Get', + GLib.Variant('(ss)', (UPOWER_DEVICE_IFACE, 'Type')), + GLib.VariantType('(v)'), + Gio.DBusCallFlags.NONE, -1, None) + if props.unpack()[0] == 2: + return path + except GLib.Error as e: + log.error('UPower: %s', e.message) + return None + + +def read_battery(sys_bus, bat_path): + try: + result = sys_bus.call_sync( + UPOWER_BUS, bat_path, DBUS_PROPS_IFACE, 'GetAll', + GLib.Variant('(s)', (UPOWER_DEVICE_IFACE,)), + GLib.VariantType('(a{sv})'), + Gio.DBusCallFlags.NONE, -1, None) + props = result.unpack()[0] + return { + 'level': props.get('Percentage', 0.0), + 'energy_wh': props.get('Energy', 0.0), + 'power_w': props.get('EnergyRate', 0.0), + 'voltage_v': props.get('Voltage', 0.0), + 'state': props.get('State', 0), + } + except GLib.Error as e: + log.error('read: %s', e.message) + return None + + +def open_db(): + os.makedirs(DB_DIR, exist_ok=True) + db = sqlite3.connect(DB_PATH) + db.execute('PRAGMA journal_mode=WAL') + db.execute(''' + CREATE TABLE IF NOT EXISTS samples ( + ts REAL NOT NULL, + level REAL NOT NULL, + energy_wh REAL NOT NULL, + power_w REAL NOT NULL, + voltage_v REAL NOT NULL DEFAULT 0, + state INTEGER NOT NULL + ) + ''') + db.execute('CREATE INDEX IF NOT EXISTS idx_samples_ts ON samples (ts)') + db.execute(''' + CREATE TABLE IF NOT EXISTS sessions ( + id INTEGER PRIMARY KEY, + start_ts REAL NOT NULL, + end_ts REAL NOT NULL, + start_level REAL NOT NULL, + end_level REAL NOT NULL, + status TEXT NOT NULL + ) + ''') + db.execute('CREATE INDEX IF NOT EXISTS idx_sessions_ts ON sessions (start_ts)') + + # migrations + cols = {r[1] for r in db.execute('PRAGMA table_info(samples)')} + if 'voltage_v' not in cols: + db.execute('ALTER TABLE samples ADD COLUMN voltage_v REAL NOT NULL DEFAULT 0') + log.info('migrated: added voltage_v column') + if 'energy_wh' not in cols: + db.execute('ALTER TABLE samples ADD COLUMN energy_wh REAL NOT NULL DEFAULT 0') + log.info('migrated: added energy_wh column') + + db.commit() + return db + + +class Battery: + def __init__(self, db, sys_bus, bat_path): + self.db = db + self.sys_bus = sys_bus + self.bat_path = bat_path + + self.buf = [] + self.last_sample_ts = 0 + self.last_state = None + self.session_start_ts = 0 + self.session_start_level = 0.0 + + self.current = None + self.flush_timer_id = 0 + + def on_props_changed(self, changed_props): + now = time.time() + + new_state = changed_props.get('State') + state_changed = new_state is not None and new_state != self.last_state + + if not state_changed and (now - self.last_sample_ts) < MIN_SAMPLE_INTERVAL: + return + + reading = read_battery(self.sys_bus, self.bat_path) + if reading is None: + return + + self.current = reading + state = reading['state'] + + self.buf.append(( + now, reading['level'], reading['energy_wh'], + reading['power_w'], reading['voltage_v'], float(state))) + self.last_sample_ts = now + + log.debug('%.1f%% %.3fWh %.2fW %.3fV %s', + reading['level'], reading['energy_wh'], + reading['power_w'], reading['voltage_v'], + STATE_NAMES.get(state, '?')) + + if state_changed: + if self.last_state is not None: + self._close_session(now, reading['level']) + self.session_start_ts = now + self.session_start_level = reading['level'] + self.last_state = state + + def _close_session(self, end_ts, end_level): + if self.session_start_ts == 0: + return + status = STATE_NAMES.get(self.last_state, 'Unknown') + self.db.execute( + 'INSERT INTO sessions (start_ts, end_ts, start_level, end_level, status)' + ' VALUES (?, ?, ?, ?, ?)', + (self.session_start_ts, end_ts, + self.session_start_level, end_level, status)) + + def on_suspend(self): + reading = read_battery(self.sys_bus, self.bat_path) + if reading: + self.current = reading + now = time.time() + if self.current and self.last_state is not None: + self._close_session(now, self.current['level']) + self.flush() + self.session_start_ts = 0 + self.last_state = None + log.debug('suspended at %.1f%%', + self.current['level'] if self.current else 0) + + def on_resume(self): + reading = read_battery(self.sys_bus, self.bat_path) + if reading is None: + return + self.current = reading + now = time.time() + state = reading['state'] + + self.buf.append(( + now, reading['level'], reading['energy_wh'], + reading['power_w'], reading['voltage_v'], float(state))) + self.last_sample_ts = now + + self.last_state = state + self.session_start_ts = now + self.session_start_level = reading['level'] + log.debug('resumed: %.1f%% %s', reading['level'], + STATE_NAMES.get(state, '?')) + + def flush(self): + if not self.buf: + return + self.db.executemany( + 'INSERT INTO samples (ts, level, energy_wh, power_w, voltage_v, state)' + ' VALUES (?, ?, ?, ?, ?, ?)', + self.buf) + self.db.commit() + log.debug('flushed %d samples', len(self.buf)) + self.buf.clear() + + def shutdown(self): + if self.current and self.last_state is not None: + self._close_session(time.time(), self.current['level']) + self.flush() + self.db.commit() + + def start(self): + reading = read_battery(self.sys_bus, self.bat_path) + if reading: + self.current = reading + now = time.time() + self.last_state = reading['state'] + self.session_start_ts = now + self.session_start_level = reading['level'] + self.buf.append(( + now, reading['level'], reading['energy_wh'], + reading['power_w'], reading['voltage_v'], + float(reading['state']))) + self.last_sample_ts = now + log.info('initial: %.1f%% %.3fWh %.2fW %.3fV %s', + reading['level'], reading['energy_wh'], + reading['power_w'], reading['voltage_v'], + STATE_NAMES.get(reading['state'], '?')) + + self.flush_timer_id = GLib.timeout_add_seconds( + FLUSH_INTERVAL, self._on_flush_timer) + + def _on_flush_timer(self): + self.flush() + return True + + def query_samples(self, date_str): + day_start, day_end = _local_day_range(date_str) + + rows = self.db.execute(''' + SELECT ts, level, energy_wh, power_w, voltage_v, state FROM samples + WHERE ts >= ? AND ts < ? + ORDER BY ts + ''', (day_start, day_end)).fetchall() + + for s in self.buf: + if day_start <= s[0] < day_end: + rows.append(s) + rows.sort(key=lambda r: r[0]) + + return [(float(ts), float(lv), float(e), float(pw), float(v), float(st)) + for ts, lv, e, pw, v, st in rows] + + def query_sessions(self, date_str): + day_start, day_end = _local_day_range(date_str) + + rows = self.db.execute(''' + SELECT start_ts, end_ts, start_level, end_level, status + FROM sessions + WHERE end_ts > ? AND start_ts < ? + ORDER BY start_ts + ''', (day_start, day_end)).fetchall() + + return [(float(s), float(e), float(sl), float(el), st) + for s, e, sl, el, st in rows] + +def main(): + logging.basicConfig( + level=logging.DEBUG if os.environ.get('BATTERYD_DEBUG') else logging.INFO, + format='%(name)s: %(message)s', + ) + + sys_bus = Gio.bus_get_sync(Gio.BusType.SYSTEM) + bat_path = find_battery(sys_bus) + if not bat_path: + log.error('no battery found, exiting') + return + + log.info('tracking: %s', bat_path) + + db = open_db() + tracker = Battery(db, sys_bus, bat_path) + tracker.start() + + loop = GLib.MainLoop() + bus = Gio.bus_get_sync(Gio.BusType.SESSION) + + sys_bus.signal_subscribe( + UPOWER_BUS, DBUS_PROPS_IFACE, 'PropertiesChanged', + bat_path, None, Gio.DBusSignalFlags.NONE, + lambda conn, sender, path, iface, sig, params: ( + tracker.on_props_changed(dict(params.unpack()[1])) + )) + + # handle suspend/resume + sys_bus.signal_subscribe( + 'org.freedesktop.login1', 'org.freedesktop.login1.Manager', + 'PrepareForSleep', + '/org/freedesktop/login1', None, Gio.DBusSignalFlags.NONE, + lambda conn, sender, path, iface, sig, params: ( + tracker.on_suspend() if params.unpack()[0] + else tracker.on_resume() + )) + + node = Gio.DBusNodeInfo.new_for_xml(DBUS_IFACE_XML) + + def on_method_call(conn, sender, path, iface, method, params, invocation): + try: + if method == 'GetSamples': + rows = tracker.query_samples(params.unpack()[0]) + invocation.return_value(GLib.Variant('(a(dddddd))', (rows,))) + elif method == 'GetSessions': + rows = tracker.query_sessions(params.unpack()[0]) + invocation.return_value(GLib.Variant('(a(dddds))', (rows,))) + elif method == 'GetCurrent': + if tracker.current: + r = tracker.current + invocation.return_value(GLib.Variant('(dddds)', ( + r['level'], r['energy_wh'], r['power_w'], + r['voltage_v'], + STATE_NAMES.get(r['state'], 'Unknown')))) + else: + invocation.return_value(GLib.Variant('(dddds)', ( + 0.0, 0.0, 0.0, 0.0, 'Unknown'))) + except Exception as e: + log.error('D-Bus method %s: %s', method, e) + invocation.return_dbus_error('org.batteryd.Error', str(e)) + + bus.register_object_with_closures2( + DBUS_PATH, node.interfaces[0], + on_method_call, None, None) + + Gio.bus_own_name_on_connection( + bus, DBUS_BUS, Gio.BusNameOwnerFlags.NONE, None, None) + + def on_term(): + tracker.shutdown() + loop.quit() + + GLib.unix_signal_add(GLib.PRIORITY_DEFAULT, signal.SIGTERM, on_term) + GLib.unix_signal_add(GLib.PRIORITY_DEFAULT, signal.SIGINT, on_term) + + log.info('archiving to %s', DB_PATH) + loop.run() + log.info('stopped') + + +if __name__ == '__main__': + main() diff --git a/org.batteryd/org.batteryd.Viewer.destkop b/org.batteryd/org.batteryd.Viewer.destkop new file mode 100644 index 0000000..76dff84 --- /dev/null +++ b/org.batteryd/org.batteryd.Viewer.destkop @@ -0,0 +1,7 @@ +[Desktop Entry] +Name=Battery +Comment=View battery usage history +Exec=battery-viewer +Icon=battery-level-50-symbolic +Type=Application +Categories=GNOME;GTK;Utility;System; diff --git a/org.sensord/gnome-sensor-tray/Makefile b/org.sensord/gnome-sensor-tray/Makefile new file mode 100644 index 0000000..37927d0 --- /dev/null +++ b/org.sensord/gnome-sensor-tray/Makefile @@ -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 diff --git a/org.sensord/gnome-sensor-tray/src/extension.js b/org.sensord/gnome-sensor-tray/src/extension.js new file mode 100644 index 0000000..75361d0 --- /dev/null +++ b/org.sensord/gnome-sensor-tray/src/extension.js @@ -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; + } +} diff --git a/org.sensord/gnome-sensor-tray/src/metadata.json b/org.sensord/gnome-sensor-tray/src/metadata.json new file mode 100644 index 0000000..01c4f93 --- /dev/null +++ b/org.sensord/gnome-sensor-tray/src/metadata.json @@ -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": "" +} diff --git a/org.sensord/gnome-sensor-tray/src/prefs.js b/org.sensord/gnome-sensor-tray/src/prefs.js new file mode 100644 index 0000000..76e6c27 --- /dev/null +++ b/org.sensord/gnome-sensor-tray/src/prefs.js @@ -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; + } +} diff --git a/org.sensord/gnome-sensor-tray/src/schemas/gschemas.compiled b/org.sensord/gnome-sensor-tray/src/schemas/gschemas.compiled new file mode 100644 index 0000000000000000000000000000000000000000..f85b9d2bfee0525c1abbdda939d04d5f35a3c86c GIT binary patch literal 717 zcmYLHJxjzu6kO%bPefD%#ac;}B%KyEVxxt%2wJ%2mdnCj60$ixL9N8f!bU7@1jR}_ z%WFg%|AUR4prr_+Gn+enWNwDZdv7=I-O9Smw2X7njtTHap`GrI2`-Nd@rx+lt=XI{ z_&<7#C4zcdtcqjsRhJNWM3PngYMj)SZz5Hz`Kp<#*ytoSzJW-xJd^v#LpTEk+h3mB zK^eLS=mYxOSPW+z_UAbxVplwXTR`WI)IzLT?-lxU;kHhTdKmei;4h$ccr?gwqAtVd zp|imE$I~(_tC_z9ZxeW&ygZ;!U4p+2j)2+c>wEgtgYcW+L*RYtcaA>wDEu?uQD4hZK@M(9&B{~7!Oq>0fv#^&kROJ%HT$bpTiX_vF1=6o2v0O!QHl>nRX z?IcYUS5y-Y0(E{W^9VA)k^;M=z%D8HUsCYDq(BJMNGmc|Xp^gKSMm-F-ZyKr-JzqM zeMf88bsMp6|E+Ldl;mE~&#(-4kj6DpvgcJ)sOz%kp=U$!#g1EC+N3hnan)uQ--kZ7 PM28p#p3X3k(#5(zP_vR+ literal 0 HcmV?d00001 diff --git a/org.sensord/gnome-sensor-tray/src/schemas/org.gnome.shell.extensions.sensortray.gschema.xml b/org.sensord/gnome-sensor-tray/src/schemas/org.gnome.shell.extensions.sensortray.gschema.xml new file mode 100644 index 0000000..52193d1 --- /dev/null +++ b/org.sensord/gnome-sensor-tray/src/schemas/org.gnome.shell.extensions.sensortray.gschema.xml @@ -0,0 +1,52 @@ + + + + + + + [] + Sensors pinned to the panel + Ordered list of sensor keys shown in the top bar (e.g. "Thermal/coretemp/Core 0", "Cpu/total") + + + + 2 + Panel position (0=left, 1=center, 2=right) + + + + 0 + Index within the panel box + + + + true + Show icon next to each pinned sensor value + + + + 0 + Temperature unit (0=Celsius, 1=Fahrenheit) + + + + false + Show one decimal place + + + + 8 + Spacing between pinned values in pixels + + + + 1 + Seconds between UI updates (1–10) + How many seconds between panel and menu repaints. D-Bus signals still arrive immediately; only the visual repaint is throttled. + + + + + + diff --git a/org.sensord/gnome-sensor-tray/src/sensorClient.js b/org.sensord/gnome-sensor-tray/src/sensorClient.js new file mode 100644 index 0000000..8397ae1 --- /dev/null +++ b/org.sensord/gnome-sensor-tray/src/sensorClient.js @@ -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> 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)} 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>} + * 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(); + } +} diff --git a/org.sensord/gnome-sensor-tray/src/sensorItem.js b/org.sensord/gnome-sensor-tray/src/sensorItem.js new file mode 100644 index 0000000..6a2dec9 --- /dev/null +++ b/org.sensord/gnome-sensor-tray/src/sensorItem.js @@ -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; } +} diff --git a/org.sensord/gnome-sensor-tray/src/stylesheet.css b/org.sensord/gnome-sensor-tray/src/stylesheet.css new file mode 100644 index 0000000..a29808d --- /dev/null +++ b/org.sensord/gnome-sensor-tray/src/stylesheet.css @@ -0,0 +1,7 @@ +.sensortray-panel-label { + padding: 0 6px; +} + +.sensortray-panel-icon-label { + padding: 0 2px 0 0; +} diff --git a/org.sensord/org.sensord.service b/org.sensord/org.sensord.service new file mode 100644 index 0000000..0ead5eb --- /dev/null +++ b/org.sensord/org.sensord.service @@ -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 diff --git a/org.sensord/sensord.py b/org.sensord/sensord.py new file mode 100644 index 0000000..b4bc56a --- /dev/null +++ b/org.sensord/sensord.py @@ -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 = """\ + + + + + + + + + +""" + + +def make_iface_xml(name): + return f""" + + +""" + + +INTROSPECTION = f""" + + {make_iface_xml("Power")} + {make_iface_xml("Thermal")} + {make_iface_xml("Cpu")} + {make_iface_xml("Memory")} + +""" + + +# ── 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() diff --git a/screentimed/Makefile b/screentimed/Makefile new file mode 100644 index 0000000..1bb60b0 --- /dev/null +++ b/screentimed/Makefile @@ -0,0 +1,41 @@ +PREFIX ?= $(HOME)/.local +BINDIR = $(PREFIX)/bin +EXTENSIONDIR = $(HOME)/.local/share/gnome-shell/extensions/ +SERVICEDIR = $(HOME)/.config/systemd/user +DESKTOPDIR = $(HOME)/.local/share/applications + +EXTENTION = "app-tracker@local" + +.PHONY: install install-extension install-daemon install-viewer uninstall + +install: install-extension install-daemon install-viewer + +install-extension: + mkdir -p $(EXTENSIONDIR) + cp $(EXTENSION)/extension.js $(EXTENSIONDIR)/$(EXTENSION) + cp $(EXTENSION)/metadata.json $(EXTENSIONDIR)/$(EXTENSION) + +install-daemon: + mkdir -p $(BINDIR) + cp screentimed $(BINDIR)/screentimed + chmod +x $(BINDIR)/screentimed + mkdir -p $(SERVICEDIR) + cp org.screentimed.service $(SERVICEDIR)/ + +install-viewer: + mkdir -p $(BINDIR) + cp screentime-viewer $(BINDIR)/screentime-viewer + chmod +x $(BINDIR)/screentime-viewer + mkdir -p $(DESKTOPDIR) + cp screentime-viewer.desktop $(DESKTOPDIR)/ + +uninstall: + systemctl --user disable --now screentimed || true + gnome-extensions disable $(EXTENSION) || true + rm -v $(BINDIR)/screentimed + rm -v $(BINDIR)/screentime-viewer + rm -v $(SERVICEDIR)/org.screentimed.service + rm -v $(DESKTOPDIR)/screentime-viewer.desktop + rm -rv $(EXTENSIONDIR)/$(EXTENSION) + systemctl --user daemon-reload + @echo "uninstalled." diff --git a/screentimed/app-tracker@local/extension.js b/screentimed/app-tracker@local/extension.js new file mode 100644 index 0000000..217614a --- /dev/null +++ b/screentimed/app-tracker@local/extension.js @@ -0,0 +1,83 @@ +import GLib from 'gi://GLib'; +import Gio from 'gi://Gio'; +import Shell from 'gi://Shell'; +import {Extension} from 'resource:///org/gnome/shell/extensions/extension.js'; + +const IFACE = ` + + + + + + + + + + + + + + +`; + +export default class FocusTracker extends Extension { + enable() { + this._currentApp = ''; + this._tracker = Shell.WindowTracker.get_default(); + this._appSystem = Shell.AppSystem.get_default(); + + this._dbus = Gio.DBusExportedObject.wrapJSObject(IFACE, this); + this._dbus.export(Gio.DBus.session, '/org/gnome/Shell/UsageTracker'); + + this._focusSig = this._tracker.connect('notify::focus-app', () => { + const id = this._resolveFocusId(); + if (id !== this._currentApp) { + this._currentApp = id; + this._dbus.emit_signal('FocusChanged', + new GLib.Variant('(s)', [id])); + } + }); + + this._appSig = this._appSystem.connect('app-state-changed', () => { + const ids = this._appSystem.get_running().map(a => this._resolveAppId(a)); + this._dbus.emit_signal('RunningAppsChanged', + new GLib.Variant('(as)', [ids])); + }); + } + + _resolveFocusId() { + const app = this._tracker.focus_app; + if (!app) + return ''; + return this._resolveAppId(app); + } + + _resolveAppId(app) { + const id = app.get_id(); + if (id.endsWith('.desktop')) + return id; + // fallback: use window class + const windows = app.get_windows(); + if (windows.length > 0) { + const wmClass = windows[0].get_wm_class(); + if (wmClass) + return `${wmClass}.desktop`; + } + return id; + } + + disable() { + this._tracker.disconnect(this._focusSig); + this._appSystem.disconnect(this._appSig); + this._dbus.unexport(); + this._dbus = null; + } + + GetFocus() { + return this._currentApp; + } + + GetRunningApps() { + return this._appSystem.get_running().map(a => this._resolveAppId(a)); + } +} diff --git a/screentimed/app-tracker@local/metadata.json b/screentimed/app-tracker@local/metadata.json new file mode 100644 index 0000000..1ff9365 --- /dev/null +++ b/screentimed/app-tracker@local/metadata.json @@ -0,0 +1,7 @@ +{ + "uuid": "app-tracker@local", + "name": "Application Tracker", + "description": "Exposes active app over D-Bus", + "shell-version": ["48", "49", "50"], + "version": 1 +} diff --git a/screentimed/org.screentimed.desktop b/screentimed/org.screentimed.desktop new file mode 100644 index 0000000..0ef785a --- /dev/null +++ b/screentimed/org.screentimed.desktop @@ -0,0 +1,7 @@ +[Desktop Entry] +Name=ScreenTime +Comment=View your screen time usage +Exec=screentime-viewer +Icon=preferences-system-time-symbolic +Type=Application +Categories=GNOME;GTK;Utility; diff --git a/screentimed/org.screentimed.service b/screentimed/org.screentimed.service new file mode 100644 index 0000000..c2c898b --- /dev/null +++ b/screentimed/org.screentimed.service @@ -0,0 +1,13 @@ +[Unit] +Description=Screen Time Tracking Daemon +After=gnome-shell.service +PartOf=graphical-session.target + +[Service] +Type=simple +ExecStart=%h/.local/bin/screentimed +Restart=on-failure +RestartSec=5 + +[Install] +WantedBy=graphical-session.target diff --git a/screentimed/screentime-viewer b/screentimed/screentime-viewer new file mode 100644 index 0000000..5f23987 --- /dev/null +++ b/screentimed/screentime-viewer @@ -0,0 +1,436 @@ +#!/usr/bin/python3 -sP + +import sys +from datetime import date, timedelta + +import gi +gi.require_version('Gtk', '4.0') +gi.require_version('Adw', '1') +gi.require_version('Gio', '2.0') +gi.require_version('GLib', '2.0') +try: + gi.require_version('GioUnix', '2.0') + from gi.repository import GioUnix + _DesktopAppInfo = GioUnix.DesktopAppInfo +except (ValueError, ImportError): + from gi.repository import Gio as _Gio + _DesktopAppInfo = _Gio.DesktopAppInfo +from gi.repository import Gtk, Adw, Gio, GLib, Gdk + +SCREENTIMED_BUS = 'org.gnome.ScreenTime' +SCREENTIMED_PATH = '/org/gnome/ScreenTime' +SCREENTIMED_IFACE = 'org.gnome.ScreenTime' + + +def format_duration(seconds): + if seconds < 60: + return f'{seconds}s' + m, s = divmod(seconds, 60) + h, m = divmod(m, 60) + if h: + return f'{h}h {m}m' + return f'{m}m' + + +def get_app_info(app_id): + """Return (display_name, icon) from .desktop file, with fallbacks.""" + if not app_id.endswith('.desktop'): + return None, None # not a real app + try: + info = _DesktopAppInfo.new(app_id) + if info: + name = info.get_display_name() or info.get_name() + icon = info.get_icon() + return name, icon + except Exception: + pass + name = app_id.replace('.desktop', '').split('.')[-1] + return name, None + + +def merge_unknown(usage): + """Collapse entries without .desktop into a single 'Unknown' entry.""" + merged = {} + unknown = 0 + for app_id, secs in usage.items(): + if app_id.endswith('.desktop'): + merged[app_id] = secs + else: + unknown += secs + if unknown: + merged['__unknown__'] = unknown + return merged + + +# ── D-Bus client ────────────────────────────────────────── + +class ScreenTimeClient: + def __init__(self): + self.bus = Gio.bus_get_sync(Gio.BusType.SESSION) + + def _call(self, method, args=None, reply_type=None): + try: + result = self.bus.call_sync( + SCREENTIMED_BUS, SCREENTIMED_PATH, SCREENTIMED_IFACE, + method, + args, GLib.VariantType(reply_type) if reply_type else None, + Gio.DBusCallFlags.NONE, -1, None) + return result.unpack() + except GLib.Error as e: + print(f'D-Bus error: {e.message}', file=sys.stderr) + return None + + def get_usage(self, date_str): + r = self._call('GetUsage', GLib.Variant('(s)', (date_str,)), + '(a{su}a{su})') + if r: + return dict(r[0]), dict(r[1]) + return {}, {} + + def get_usage_by_hour(self, date_str): + r = self._call('GetUsageByHour', GLib.Variant('(s)', (date_str,)), + '(aa{su}aa{su})') + if r: + return [dict(h) for h in r[0]], [dict(h) for h in r[1]] + return [{} for _ in range(24)], [{} for _ in range(24)] + + +# ── Hourly bar chart ────────────────────────────────────── + +class HourlyChart(Gtk.DrawingArea): + def __init__(self): + super().__init__() + self.focused_hours = [{} for _ in range(24)] + self.running_hours = [{} for _ in range(24)] + self.set_content_height(110) + self.set_draw_func(self._draw) + + self.set_has_tooltip(True) + self.connect('query-tooltip', self._on_tooltip) + + def set_data(self, focused_hours, running_hours): + self.focused_hours = focused_hours + self.running_hours = running_hours + self.queue_draw() + + def _on_tooltip(self, widget, x, y, keyboard, tooltip): + width = self.get_width() + h = int((x / width) * 24) + h = max(0, min(23, h)) + + focused = self.focused_hours[h] + running = self.running_hours[h] + + if not sum(focused.values()) and not sum(running.values()): + return False + + lines = [f'{h:02d}:00 – {h + 1:02d}:00'] + + all_apps = set(focused.keys()) | set(running.keys()) + sorted_apps = sorted(all_apps, + key=lambda a: focused.get(a, 0), + reverse=True) + for app_id in sorted_apps[:6]: + f = focused.get(app_id, 0) + r = running.get(app_id, 0) + if app_id.endswith('.desktop'): + name, _ = get_app_info(app_id) + name = name or app_id + else: + name = 'Unknown' + if r > f + 30: + lines.append(f'{name}: {format_duration(f)} / {format_duration(r)}') + elif f: + lines.append(f'{name}: {format_duration(f)}') + elif r: + lines.append(f'{name}: running {format_duration(r)}') + + tooltip.set_markup('\n'.join(lines)) + return True + + def _get_colors(self): + """Get accent and foreground RGBA for Cairo drawing.""" + fg = self.get_color() # GTK4.10+ + + # try Adw 1.6+ accent API + accent = None + try: + sm = Adw.StyleManager.get_default() + ac = sm.get_accent_color() + accent = ac.to_rgba(sm.get_dark()) + except (AttributeError, TypeError): + pass + + if accent is None: + accent = Gdk.RGBA() + accent.parse('#3584e4') + + return accent, fg + + def _draw(self, area, cr, width, height): + f_totals = [sum(h.values()) for h in self.focused_hours] + r_totals = [sum(h.values()) for h in self.running_hours] + peak = max(max(r_totals), max(f_totals), 1) + + bar_w = width / 24 + pad = 2 + label_h = 14 + chart_h = height - label_h + + accent, fg = self._get_colors() + + # running bars + cr.set_source_rgba(accent.red, accent.green, accent.blue, 0.2) + + for i, total in enumerate(r_totals): + if total == 0: + continue + bar_h = (total / peak) * (chart_h - 4) + cr.rectangle(i * bar_w + pad, chart_h - bar_h, + bar_w - pad * 2, bar_h) + cr.fill() + + # focused bars + cr.set_source_rgba(accent.red, accent.green, accent.blue, 0.85) + + for i, total in enumerate(f_totals): + if total == 0: + continue + bar_h = (total / peak) * (chart_h - 4) + cr.rectangle(i * bar_w + pad, chart_h - bar_h, + bar_w - pad * 2, bar_h) + cr.fill() + + # labels + cr.set_source_rgba(fg.red, fg.green, fg.blue, 0.5) + cr.set_font_size(9) + for i in (0, 6, 12, 18): + cr.move_to(i * bar_w + bar_w / 2 - 6, height - 2) + cr.show_text(f'{i:02d}') + + +# ── App usage row ───────────────────────────────────────── + +class AppUsageRow(Adw.ActionRow): + def __init__(self, app_id, focused_secs, running_secs, max_running): + super().__init__() + + if app_id == '__unknown__': + name = 'Unknown' + icon = None + self.set_subtitle('Apps without .desktop entries') + else: + name, icon = get_app_info(app_id) + self.set_subtitle(app_id) + + self.set_title(name or app_id) + + if icon: + img = Gtk.Image.new_from_gicon(icon) + img.set_pixel_size(32) + self.add_prefix(img) + else: + img = Gtk.Image.new_from_icon_name('application-x-executable-symbolic') + img.set_pixel_size(32) + self.add_prefix(img) + + box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=4) + box.set_valign(Gtk.Align.CENTER) + + BAR_W = 100 + r_frac = min(1.0, running_secs / max_running) if max_running else 0 + f_frac = min(1.0, focused_secs / max_running) if max_running else 0 + + bar_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2) + bar_box.set_size_request(BAR_W, -1) + + f_bar = Gtk.Box() + f_bar.set_size_request(max(2, int(BAR_W * f_frac)), 6) + f_bar.set_halign(Gtk.Align.START) + f_bar.add_css_class('usage-bar-focused') + bar_box.append(f_bar) + + if running_secs > focused_secs + 30: + r_bar = Gtk.Box() + r_bar.set_size_request(max(2, int(BAR_W * r_frac)), 4) + r_bar.set_halign(Gtk.Align.START) + r_bar.add_css_class('usage-bar-running') + bar_box.append(r_bar) + + box.append(bar_box) + + label_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6) + f_label = Gtk.Label(label=format_duration(focused_secs)) + f_label.add_css_class('caption') + label_box.append(f_label) + + if running_secs > focused_secs + 30: + r_label = Gtk.Label(label=f'/ {format_duration(running_secs)}') + r_label.add_css_class('caption') + r_label.add_css_class('dim-label') + label_box.append(r_label) + + box.append(label_box) + self.add_suffix(box) + + +# ── Main window ─────────────────────────────────────────── + +class ScreenTimeWindow(Adw.ApplicationWindow): + def __init__(self, app): + super().__init__(application=app, title='Screen Time', + default_width=420, default_height=600) + + self.client = ScreenTimeClient() + self.current_date = date.today() + + css = Gtk.CssProvider() + css.load_from_string(''' + .usage-bar-focused { + background: @accent_bg_color; + border-radius: 3px; + min-height: 6px; + } + .usage-bar-running { + background: alpha(@accent_bg_color, 0.3); + border-radius: 3px; + min-height: 4px; + } + .total-time { + font-size: 2em; + font-weight: 800; + } + ''') + Gtk.StyleContext.add_provider_for_display( + Gdk.Display.get_default(), css, + Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION) + + toolbar = Adw.ToolbarView() + header = Adw.HeaderBar() + toolbar.add_top_bar(header) + + # navigation + nav_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8) + nav_box.set_halign(Gtk.Align.CENTER) + + prev_btn = Gtk.Button(icon_name='go-previous-symbolic') + prev_btn.connect('clicked', lambda _: self._change_date(-1)) + nav_box.append(prev_btn) + + self.date_label = Gtk.Label() + self.date_label.add_css_class('title-4') + nav_box.append(self.date_label) + + next_btn = Gtk.Button(icon_name='go-next-symbolic') + next_btn.connect('clicked', lambda _: self._change_date(1)) + nav_box.append(next_btn) + + header.set_title_widget(nav_box) + + # content + scroll = Gtk.ScrolledWindow(vexpand=True) + scroll.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC) + self.content = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0) + self.content.set_margin_start(16) + self.content.set_margin_end(16) + self.content.set_margin_top(16) + self.content.set_margin_bottom(16) + scroll.set_child(self.content) + toolbar.set_content(scroll) + + # total + self.total_label = Gtk.Label() + self.total_label.add_css_class('total-time') + self.total_label.set_margin_bottom(4) + self.content.append(self.total_label) + + self.total_sub = Gtk.Label() + self.total_sub.add_css_class('dim-label') + self.total_sub.set_margin_bottom(16) + self.content.append(self.total_sub) + + # hourly chart + hr_group = Adw.PreferencesGroup(title='Hourly') + self.hourly = HourlyChart() + hr_group.add(self.hourly) + self.content.append(hr_group) + + # app list + self.app_group = Adw.PreferencesGroup(title='Apps') + self.app_group.set_margin_top(16) + self.content.append(self.app_group) + + self.set_content(toolbar) + self._refresh() + + def _change_date(self, delta): + self.current_date += timedelta(days=delta) + self._refresh() + + def _refresh(self): + date_str = self.current_date.isoformat() + + if self.current_date == date.today(): + self.date_label.set_label('Today') + elif self.current_date == date.today() - timedelta(days=1): + self.date_label.set_label('Yesterday') + else: + self.date_label.set_label( + self.current_date.strftime('%a, %b %-d')) + + focused, running = self.client.get_usage(date_str) + focused = merge_unknown(focused) + running = merge_unknown(running) + f_hours, r_hours = self.client.get_usage_by_hour(date_str) + + # totals + total_focused = sum(focused.values()) + total_running = sum(running.values()) + self.total_label.set_label(format_duration(total_focused)) + if total_running > total_focused + 60: + self.total_sub.set_label( + f'{format_duration(total_running)} total running') + self.total_sub.set_visible(True) + else: + self.total_sub.set_visible(False) + + # hourly + self.hourly.set_data(f_hours, r_hours) + + # app list + self.content.remove(self.app_group) + self.app_group = Adw.PreferencesGroup(title='Apps') + self.app_group.set_margin_top(16) + self.content.append(self.app_group) + + all_apps = set(focused.keys()) | set(running.keys()) + sorted_apps = sorted(all_apps, + key=lambda a: focused.get(a, 0), + reverse=True) + max_running = max(running.values()) if running else 1 + + for app_id in sorted_apps: + f = focused.get(app_id, 0) + r = running.get(app_id, 0) + if r < 1 and f < 1: + continue + row = AppUsageRow(app_id, f, r, max_running) + self.app_group.add(row) + + +# ── App ─────────────────────────────────────────────────── + +class ScreenTimeApp(Adw.Application): + def __init__(self): + super().__init__(application_id='org.gnome.ScreenTime.Viewer') + + def do_activate(self): + win = self.get_active_window() + if not win: + win = ScreenTimeWindow(self) + win.present() + + +if __name__ == '__main__': + app = ScreenTimeApp() + app.run(sys.argv) diff --git a/screentimed/screentimed b/screentimed/screentimed new file mode 100644 index 0000000..23fc4f2 --- /dev/null +++ b/screentimed/screentimed @@ -0,0 +1,428 @@ +#!/usr/bin/python3 -sP + +import logging +import os +import signal +import sqlite3 +import time + +import gi +gi.require_version('Gio', '2.0') +gi.require_version('GLib', '2.0') +from gi.repository import Gio, GLib + +log = logging.getLogger('screentimed') + +DB_DIR = os.path.join(GLib.get_user_data_dir(), 'screentime') +DB_PATH = os.path.join(DB_DIR, 'usage.db') + +FLUSH_INTERVAL = 60 + +TRACKER_BUS = 'org.gnome.Shell' +TRACKER_IFACE = 'org.gnome.Shell.UsageTracker' +TRACKER_PATH = '/org/gnome/Shell/UsageTracker' + +SCREENTIMED_BUS = 'org.gnome.ScreenTime' +SCREENTIMED_PATH = '/org/gnome/ScreenTime' +SCREENTIMED_IFACE_XML = ''' + + + + + + + + + + + + + + + + + + + + + +''' + + +# ── database ────────────────────────────────────────────── + +def open_db(): + os.makedirs(DB_DIR, exist_ok=True) + db = sqlite3.connect(DB_PATH) + db.execute('PRAGMA journal_mode=WAL') + db.execute(''' + CREATE TABLE IF NOT EXISTS focus_sessions ( + id INTEGER PRIMARY KEY, + app_id TEXT NOT NULL, + start_ts REAL NOT NULL, + end_ts REAL NOT NULL + ) + ''') + db.execute(''' + CREATE TABLE IF NOT EXISTS running_sessions ( + id INTEGER PRIMARY KEY, + app_id TEXT NOT NULL, + start_ts REAL NOT NULL, + end_ts REAL NOT NULL + ) + ''') + db.execute(''' + CREATE INDEX IF NOT EXISTS idx_focus_time + ON focus_sessions (start_ts, end_ts) + ''') + db.execute(''' + CREATE INDEX IF NOT EXISTS idx_running_time + ON running_sessions (start_ts, end_ts) + ''') + # migrate from old schema + try: + db.execute(''' + INSERT INTO focus_sessions (app_id, start_ts, end_ts) + SELECT app_id, start_ts, end_ts FROM sessions + ''') + db.execute('DROP TABLE sessions') + db.commit() + log.info('migrated old sessions table') + except sqlite3.OperationalError: + pass + db.commit() + return db + + +def _date_to_ts(date): + return time.mktime(time.strptime(date, '%Y-%m-%d')) + + +def _day_range(date): + start = _date_to_ts(date) + return start, start + 86400 + + +def _query_db(db, table, date): + return db.execute(f''' + SELECT app_id, start_ts, end_ts + FROM {table} + WHERE end_ts > unixepoch(:date) + AND start_ts < unixepoch(:date, '+1 day') + ''', {'date': date}).fetchall() + + +def _aggregate_daily(sessions, day_start, day_end): + usage = {} + for app_id, start, end in sessions: + overlap = min(end, day_end) - max(start, day_start) + if overlap > 0: + usage[app_id] = usage.get(app_id, 0) + int(overlap) + return usage + + +def _aggregate_hourly(sessions, day_start): + hours = [{} for _ in range(24)] + for app_id, start, end in sessions: + for h in range(24): + h_start = day_start + h * 3600 + h_end = h_start + 3600 + overlap = min(end, h_end) - max(start, h_start) + if overlap > 0: + hours[h][app_id] = hours[h].get(app_id, 0) + int(overlap) + return hours + + +def _clip_to_day(sessions, day_start, day_end): + """Return (app_id, clipped_start, clipped_end) tuples.""" + out = [] + for app_id, start, end in sessions: + s = max(start, day_start) + e = min(end, day_end) + if e > s: + out.append((app_id, s, e)) + return out + + +# ── tracker ─────────────────────────────────────────────── + +class Tracker: + def __init__(self, db): + self.db = db + self.focus_buf = [] + self.running_buf = [] + + self.current_app = '' + self.session_start = time.time() + self.mono_start = time.monotonic() + + self.running_apps = {} # app_id -> (wall_start, mono_start) + + self.idle = False + self.flush_timer_id = 0 + + # ── focus ── + + def on_focus_changed(self, app_id): + if self.idle: + return + self._close_focus() + self.current_app = app_id + self.session_start = time.time() + self.mono_start = time.monotonic() + log.debug('focus: %s', app_id or '(none)') + + def _close_focus(self): + elapsed = time.monotonic() - self.mono_start + if not self.current_app or elapsed < 1: + return + end_ts = self.session_start + elapsed + self.focus_buf.append((self.current_app, self.session_start, end_ts)) + + # ── running ── + + def on_running_changed(self, app_ids): + now_wall = time.time() + now_mono = time.monotonic() + + current = set(app_ids) + previous = set(self.running_apps.keys()) + + # closed apps + for app_id in previous - current: + wall_start, mono_start = self.running_apps.pop(app_id) + end_ts = wall_start + (now_mono - mono_start) + if end_ts - wall_start >= 1: + self.running_buf.append((app_id, wall_start, end_ts)) + log.debug('closed: %s', app_id) + + # new apps + for app_id in current - previous: + self.running_apps[app_id] = (now_wall, now_mono) + log.debug('opened: %s', app_id) + + def _close_all_running(self): + now_wall = time.time() + now_mono = time.monotonic() + for app_id, (wall_start, mono_start) in self.running_apps.items(): + end_ts = wall_start + (now_mono - mono_start) + if end_ts - wall_start >= 1: + self.running_buf.append((app_id, wall_start, end_ts)) + # reopen immediately + for app_id in list(self.running_apps): + self.running_apps[app_id] = (now_wall, now_mono) + + # ── idle ── + + def on_idle(self): + if self.idle: + return + self._close_focus() + self._close_all_running() + self.idle = True + self._flush_to_db() + self._stop_timer() + log.debug('idle (locked/blank)') + + def on_active(self): + if not self.idle: + return + self.idle = False + now_wall = time.time() + now_mono = time.monotonic() + self.session_start = now_wall + self.mono_start = now_mono + # reopen running timestamps + for app_id in list(self.running_apps): + self.running_apps[app_id] = (now_wall, now_mono) + self._start_timer() + log.debug('active (unlocked)') + + # ── flush ── + + def flush(self): + if self.idle: + return + self._close_focus() + self._close_all_running() + self._flush_to_db() + self.session_start = time.time() + self.mono_start = time.monotonic() + + def _flush_to_db(self): + if not self.focus_buf and not self.running_buf: + return + if self.focus_buf: + self.db.executemany( + 'INSERT INTO focus_sessions (app_id, start_ts, end_ts) VALUES (?, ?, ?)', + self.focus_buf) + if self.running_buf: + self.db.executemany( + 'INSERT INTO running_sessions (app_id, start_ts, end_ts) VALUES (?, ?, ?)', + self.running_buf) + self.db.commit() + log.debug('flushed %d focus + %d running', + len(self.focus_buf), len(self.running_buf)) + self.focus_buf.clear() + self.running_buf.clear() + + def _start_timer(self): + if self.flush_timer_id == 0: + self.flush_timer_id = GLib.timeout_add_seconds( + FLUSH_INTERVAL, self._on_timer) + + def _stop_timer(self): + if self.flush_timer_id != 0: + GLib.source_remove(self.flush_timer_id) + self.flush_timer_id = 0 + + def _on_timer(self): + self.flush() + return True + + # ── queries ── + + def _all_sessions(self, table, buf, date): + day_start, day_end = _day_range(date) + sessions = _query_db(self.db, table, date) + + for s in buf: + if s[2] > day_start and s[1] < day_end: + sessions.append(s) + + return sessions + + def _all_focus(self, date): + sessions = self._all_sessions('focus_sessions', self.focus_buf, date) + # add live focus + if not self.idle and self.current_app: + elapsed = time.monotonic() - self.mono_start + end_ts = self.session_start + elapsed + day_start, day_end = _day_range(date) + if end_ts > day_start and self.session_start < day_end: + sessions.append((self.current_app, self.session_start, end_ts)) + return sessions + + def _all_running(self, date): + sessions = self._all_sessions('running_sessions', self.running_buf, date) + # add live running + if not self.idle: + now_mono = time.monotonic() + day_start, day_end = _day_range(date) + for app_id, (wall_start, mono_start) in self.running_apps.items(): + end_ts = wall_start + (now_mono - mono_start) + if end_ts > day_start and wall_start < day_end: + sessions.append((app_id, wall_start, end_ts)) + return sessions + + def get_usage(self, date): + day_start, day_end = _day_range(date) + focused = _aggregate_daily(self._all_focus(date), day_start, day_end) + running = _aggregate_daily(self._all_running(date), day_start, day_end) + return focused, running + + def get_usage_by_hour(self, date): + day_start, _ = _day_range(date) + focused = _aggregate_hourly(self._all_focus(date), day_start) + running = _aggregate_hourly(self._all_running(date), day_start) + return focused, running + + def get_timeline(self, date): + day_start, day_end = _day_range(date) + focused = _clip_to_day(self._all_focus(date), day_start, day_end) + running = _clip_to_day(self._all_running(date), day_start, day_end) + return focused, running + + +# ── main ────────────────────────────────────────────────── + +def main(): + logging.basicConfig( + level=logging.DEBUG if os.environ.get('SCREENTIMED_DEBUG') else logging.INFO, + format='%(name)s: %(message)s', + ) + + db = open_db() + tracker = Tracker(db) + loop = GLib.MainLoop() + bus = Gio.bus_get_sync(Gio.BusType.SESSION) + + # listen to the extension + bus.signal_subscribe( + TRACKER_BUS, TRACKER_IFACE, 'FocusChanged', + TRACKER_PATH, None, Gio.DBusSignalFlags.NONE, + lambda *a: tracker.on_focus_changed(a[5].unpack()[0])) + + bus.signal_subscribe( + TRACKER_BUS, TRACKER_IFACE, 'RunningAppsChanged', + TRACKER_PATH, None, Gio.DBusSignalFlags.NONE, + lambda *a: tracker.on_running_changed(a[5].unpack()[0])) + + # pause tracking on lock/blank + bus.signal_subscribe( + 'org.gnome.ScreenSaver', 'org.gnome.ScreenSaver', 'ActiveChanged', + '/org/gnome/ScreenSaver', None, Gio.DBusSignalFlags.NONE, + lambda *a: tracker.on_idle() if a[5].unpack()[0] else tracker.on_active()) + + # fetch initial state + try: + result = bus.call_sync( + TRACKER_BUS, TRACKER_PATH, TRACKER_IFACE, 'GetFocus', + None, GLib.VariantType('(s)'), + Gio.DBusCallFlags.NONE, -1, None) + tracker.current_app = result.unpack()[0] + + result = bus.call_sync( + TRACKER_BUS, TRACKER_PATH, TRACKER_IFACE, 'GetRunningApps', + None, GLib.VariantType('(as)'), + Gio.DBusCallFlags.NONE, -1, None) + now_wall = time.time() + now_mono = time.monotonic() + for app_id in result.unpack()[0]: + tracker.running_apps[app_id] = (now_wall, now_mono) + + log.debug('initial: focus=%s, running=%s', + tracker.current_app, list(tracker.running_apps.keys())) + except GLib.Error: + log.debug('extension not yet available') + + # expose analytics over d-bus + node = Gio.DBusNodeInfo.new_for_xml(SCREENTIMED_IFACE_XML) + + def on_method_call(conn, sender, path, iface, method, params, invocation): + if method == 'GetUsageToday': + f, r = tracker.get_usage(time.strftime('%Y-%m-%d')) + invocation.return_value(GLib.Variant('(a{su}a{su})', (f, r))) + elif method == 'GetUsage': + f, r = tracker.get_usage(params.unpack()[0]) + invocation.return_value(GLib.Variant('(a{su}a{su})', (f, r))) + elif method == 'GetUsageByHour': + f, r = tracker.get_usage_by_hour(params.unpack()[0]) + invocation.return_value(GLib.Variant('(aa{su}aa{su})', (f, r))) + elif method == 'GetTimeline': + f, r = tracker.get_timeline(params.unpack()[0]) + invocation.return_value(GLib.Variant('(a(sdd)a(sdd))', (f, r))) + + bus.register_object_with_closures2( + SCREENTIMED_PATH, node.interfaces[0], + on_method_call, None, None) + + Gio.bus_own_name_on_connection( + bus, SCREENTIMED_BUS, Gio.BusNameOwnerFlags.NONE, None, None) + + tracker._start_timer() + + def on_term(): + tracker.flush() + loop.quit() + + GLib.unix_signal_add(GLib.PRIORITY_DEFAULT, signal.SIGTERM, on_term) + GLib.unix_signal_add(GLib.PRIORITY_DEFAULT, signal.SIGINT, on_term) + + log.info('tracking to %s', DB_PATH) + loop.run() + log.info('stopped') + + +if __name__ == '__main__': + main()