mirror of
https://github.com/morgan9e/linux-sys-telemetry
synced 2026-04-14 00:04:07 +09:00
Init
This commit is contained in:
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;
|
||||
Reference in New Issue
Block a user