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