#!/usr/bin/env python3
"""
Battery Viewer — GUI for batteryd data
"""
import sys
from datetime import date, datetime, timedelta
import gi
gi.require_version('Gtk', '4.0')
gi.require_version('Adw', '1')
gi.require_version('Gio', '2.0')
gi.require_version('GLib', '2.0')
from gi.repository import Gtk, Adw, Gio, GLib, Gdk
DBUS_BUS = 'org.batteryd'
DBUS_PATH = '/org/batteryd'
DBUS_IFACE = 'org.batteryd'
STATE_NAMES = {
0: 'Unknown', 1: 'Charging', 2: 'Discharging',
3: 'Empty', 4: 'Full', 5: 'PendingCharge', 6: 'PendingDischarge',
}
def format_duration(seconds):
seconds = int(seconds)
if seconds < 60:
return f'{seconds}s'
m, s = divmod(seconds, 60)
h, m = divmod(m, 60)
if h:
return f'{h}h {m}m'
return f'{m}m'
# ── D-Bus client ──────────────────────────────────────────
class BatteryClient:
def __init__(self):
self.bus = Gio.bus_get_sync(Gio.BusType.SESSION)
def _call(self, method, args=None, reply_type=None):
try:
result = self.bus.call_sync(
DBUS_BUS, DBUS_PATH, DBUS_IFACE, method,
args, GLib.VariantType(reply_type) if reply_type else None,
Gio.DBusCallFlags.NONE, -1, None)
return result.unpack()
except GLib.Error as e:
print(f'D-Bus error: {e.message}', file=sys.stderr)
return None
def get_samples(self, date_str):
"""Returns list of (ts, level, energy_wh, power_w, voltage_v, state)"""
r = self._call('GetSamples', GLib.Variant('(s)', (date_str,)),
'(a(dddddd))')
return list(r[0]) if r else []
def get_sessions(self, date_str):
"""Returns list of (start_ts, end_ts, start_level, end_level, status)"""
r = self._call('GetSessions', GLib.Variant('(s)', (date_str,)),
'(a(dddds))')
return list(r[0]) if r else []
def get_current(self):
r = self._call('GetCurrent', None, '(dddds)')
if r:
return {'level': r[0], 'energy_wh': r[1],
'power_w': r[2], 'voltage_v': r[3], 'status': r[4]}
return None
# ── Level + Energy chart ──────────────────────────────────
class BatteryChart(Gtk.DrawingArea):
def __init__(self):
super().__init__()
self.samples = []
self.day_start = 0
self.set_content_height(200)
self.set_draw_func(self._draw)
self.set_has_tooltip(True)
motion = Gtk.EventControllerMotion()
motion.connect('motion', self._on_motion)
motion.connect('leave', lambda c: self.set_tooltip_markup(None))
self.add_controller(motion)
def set_data(self, samples, day_start):
self.samples = samples
self.day_start = day_start
self.queue_draw()
def _on_motion(self, ctrl, x, y):
if not self.samples or not self.day_start:
self.set_tooltip_markup(None)
return
width = self.get_width()
margin_l, margin_r = 40, 48
cw = width - margin_l - margin_r
if cw <= 0:
return
frac = (x - margin_l) / cw
if frac < 0 or frac > 1:
self.set_tooltip_markup(None)
return
t_min, t_max = self._time_range()
target_ts = t_min + frac * (t_max - t_min)
best = min(self.samples, key=lambda s: abs(s[0] - target_ts))
if abs(best[0] - target_ts) > 600:
self.set_tooltip_markup(None)
return
ts, level, energy, power, voltage, state = best
t = datetime.fromtimestamp(ts).strftime('%H:%M:%S')
status = STATE_NAMES.get(int(state), '?')
self.set_tooltip_markup(
f'{t}\n'
f'{level:.1f}% · {energy:.2f} Wh\n'
f'{power:.1f} W · {voltage:.2f} V · {status}')
def _time_range(self):
"""Compute time axis range: fit to data with padding, snap to hours."""
if not self.samples:
return self.day_start, self.day_start + 86400
first_ts = self.samples[0][0]
last_ts = self.samples[-1][0]
span = last_ts - first_ts
# pad 10% on each side, minimum 10 min
pad = max(span * 0.1, 600)
t_min = first_ts - pad
t_max = last_ts + pad
# snap to hour boundaries
t_min = t_min - (t_min % 3600)
t_max = t_max + (3600 - t_max % 3600) if t_max % 3600 else t_max
# clamp to day
day_end = self.day_start + 86400
t_min = max(t_min, self.day_start)
t_max = min(t_max, day_end)
return t_min, t_max
def _draw(self, area, cr, width, height):
if len(self.samples) < 2:
fg = self.get_color()
cr.set_source_rgba(fg.red, fg.green, fg.blue, 0.3)
cr.set_font_size(12)
cr.move_to(width / 2 - 40, height / 2)
cr.show_text('No data yet')
return
ml, mr, mt, mb = 40, 48, 12, 24
cw = width - ml - mr
ch = height - mt - mb
fg = self.get_color()
t_min, t_max = self._time_range()
t_span = t_max - t_min
energies = [s[2] for s in self.samples]
e_min = 0
e_max = max(energies)
if e_max <= e_min:
e_max = e_min + 1
def tx(ts):
return ml + ((ts - t_min) / t_span) * cw
def ty_pct(pct):
return mt + (1 - pct / 100) * ch
def ty_energy(e):
return mt + (1 - (e - e_min) / (e_max - e_min)) * ch
# background grid — horizontal
cr.set_source_rgba(fg.red, fg.green, fg.blue, 0.06)
cr.set_line_width(0.5)
for pct in (25, 50, 75):
y = ty_pct(pct)
cr.move_to(ml, y)
cr.line_to(width - mr, y)
cr.stroke()
# vertical grid — pick interval based on span
hours_span = t_span / 3600
if hours_span <= 2:
tick_secs = 900 # 15 min
elif hours_span <= 6:
tick_secs = 1800 # 30 min
elif hours_span <= 12:
tick_secs = 3600 # 1 hour
else:
tick_secs = 3 * 3600 # 3 hours
# first tick aligned to interval
first_tick = t_min - (t_min % tick_secs) + tick_secs
t = first_tick
while t < t_max:
x = tx(t)
cr.set_source_rgba(fg.red, fg.green, fg.blue, 0.06)
cr.move_to(x, mt)
cr.line_to(x, mt + ch)
cr.stroke()
t += tick_secs
# charging regions (green tint)
for i in range(len(self.samples) - 1):
s0, s1 = self.samples[i], self.samples[i + 1]
if int(s0[5]) == 1: # Charging (state is now index 5)
x1 = max(ml, tx(s0[0]))
x2 = min(width - mr, tx(s1[0]))
cr.set_source_rgba(0.3, 0.8, 0.3, 0.06)
cr.rectangle(x1, mt, x2 - x1, ch)
cr.fill()
# percentage line (blue)
cr.set_source_rgba(0.2, 0.6, 1.0, 0.9)
cr.set_line_width(1.5)
for i, s in enumerate(self.samples):
x = tx(s[0])
y = ty_pct(s[1])
if i == 0:
cr.move_to(x, y)
else:
cr.line_to(x, y)
cr.stroke()
# energy line (orange)
cr.set_source_rgba(1.0, 0.6, 0.2, 0.7)
cr.set_line_width(1)
for i, s in enumerate(self.samples):
x = tx(s[0])
y = ty_energy(s[2])
if i == 0:
cr.move_to(x, y)
else:
cr.line_to(x, y)
cr.stroke()
# left axis: percentage
cr.set_source_rgba(0.2, 0.6, 1.0, 0.6)
cr.set_font_size(9)
for pct in (0, 25, 50, 75, 100):
y = ty_pct(pct) + 3
cr.move_to(4, y)
cr.show_text(f'{pct}%')
# right axis: energy Wh
cr.set_source_rgba(1.0, 0.6, 0.2, 0.6)
for i in range(5):
e = e_min + (e_max - e_min) * i / 4
y = ty_energy(e) + 3
cr.move_to(width - mr + 6, y)
cr.show_text(f'{e:.0f}Wh')
# bottom axis labels
cr.set_source_rgba(fg.red, fg.green, fg.blue, 0.4)
cr.set_font_size(9)
t = first_tick
while t < t_max:
x = tx(t)
label = datetime.fromtimestamp(t).strftime('%H:%M')
cr.move_to(x - 12, height - 6)
cr.show_text(label)
t += tick_secs
# ── Power chart ───────────────────────────────────────────
class PowerChart(Gtk.DrawingArea):
def __init__(self):
super().__init__()
self.samples = []
self.day_start = 0
self.set_content_height(90)
self.set_draw_func(self._draw)
self.set_has_tooltip(True)
motion = Gtk.EventControllerMotion()
motion.connect('motion', self._on_motion)
motion.connect('leave', lambda c: self.set_tooltip_markup(None))
self.add_controller(motion)
def set_data(self, samples, day_start):
self.samples = samples
self.day_start = day_start
self.queue_draw()
def _time_range(self):
if not self.samples:
return self.day_start, self.day_start + 86400
first_ts = self.samples[0][0]
last_ts = self.samples[-1][0]
span = last_ts - first_ts
pad = max(span * 0.1, 600)
t_min = first_ts - pad
t_max = last_ts + pad
t_min = t_min - (t_min % 3600)
t_max = t_max + (3600 - t_max % 3600) if t_max % 3600 else t_max
day_end = self.day_start + 86400
t_min = max(t_min, self.day_start)
t_max = min(t_max, day_end)
return t_min, t_max
def _on_motion(self, ctrl, x, y):
if not self.samples:
self.set_tooltip_markup(None)
return
ml, mr = 40, 12
cw = self.get_width() - ml - mr
if cw <= 0:
return
frac = (x - ml) / cw
if frac < 0 or frac > 1:
self.set_tooltip_markup(None)
return
t_min, t_max = self._time_range()
target_ts = t_min + frac * (t_max - t_min)
best = min(self.samples, key=lambda s: abs(s[0] - target_ts))
if abs(best[0] - target_ts) > 600:
self.set_tooltip_markup(None)
return
t = datetime.fromtimestamp(best[0]).strftime('%H:%M:%S')
self.set_tooltip_markup(f'{t}\n{best[3]:.2f} W')
def _draw(self, area, cr, width, height):
if len(self.samples) < 2:
return
ml, mr, mt, mb = 40, 12, 8, 8
cw = width - ml - mr
ch = height - mt - mb
t_min, t_max = self._time_range()
t_span = t_max - t_min
powers = [s[3] for s in self.samples]
p_max = max(max(powers), 0.1)
def tx(ts):
return ml + ((ts - t_min) / t_span) * cw
def ty(pw):
return mt + (1 - pw / p_max) * ch
# filled area
cr.set_source_rgba(0.9, 0.3, 0.3, 0.12)
cr.move_to(tx(self.samples[0][0]), mt + ch)
for s in self.samples:
cr.line_to(tx(s[0]), ty(s[3]))
cr.line_to(tx(self.samples[-1][0]), mt + ch)
cr.close_path()
cr.fill()
# line
cr.set_source_rgba(0.9, 0.3, 0.3, 0.8)
cr.set_line_width(1)
for i, s in enumerate(self.samples):
x, y = tx(s[0]), ty(s[3])
if i == 0:
cr.move_to(x, y)
else:
cr.line_to(x, y)
cr.stroke()
# axis
fg = self.get_color()
cr.set_source_rgba(fg.red, fg.green, fg.blue, 0.4)
cr.set_font_size(9)
cr.move_to(4, mt + 10)
cr.show_text(f'{p_max:.1f}W')
cr.move_to(4, mt + ch)
cr.show_text('0W')
# ── Session row ───────────────────────────────────────────
class SessionRow(Adw.ActionRow):
def __init__(self, start_ts, end_ts, start_level, end_level, status):
super().__init__()
t_start = datetime.fromtimestamp(start_ts).strftime('%H:%M')
t_end = datetime.fromtimestamp(end_ts).strftime('%H:%M')
duration = end_ts - start_ts
hours = duration / 3600
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 = '→'
subtitle = f'{t_start} – {t_end} · {format_duration(duration)}'
if hours > 0 and status in ('Discharging', 'Charging'):
rate = abs(end_level - start_level) / hours
subtitle += f' · {rate:.1f}%/hr'
self.set_title(status)
self.set_subtitle(subtitle)
img = Gtk.Image.new_from_icon_name(icon)
img.set_pixel_size(24)
self.add_prefix(img)
label = Gtk.Label()
delta = end_level - start_level
if status == 'Discharging':
label.set_markup(f'−{abs(delta):.0f}%')
elif status == 'Charging':
label.set_markup(f'+{abs(delta):.0f}%')
else:
label.set_markup(f'{abs(delta):.0f}%')
label.add_css_class('caption')
label.set_valign(Gtk.Align.CENTER)
self.add_suffix(label)
class GapRow(Adw.ActionRow):
"""Inferred gap between sessions (suspend/shutdown)."""
def __init__(self, prev_end_ts, prev_end_level, next_start_ts, next_start_level):
super().__init__()
t_start = datetime.fromtimestamp(prev_end_ts).strftime('%H:%M')
t_end = datetime.fromtimestamp(next_start_ts).strftime('%H:%M')
duration = next_start_ts - prev_end_ts
delta = next_start_level - prev_end_level
if abs(delta) < 0.5:
kind = 'Shutdown / Hibernate'
icon = 'system-shutdown-symbolic'
elif delta > 0:
kind = 'Suspended (charged)'
icon = 'battery-level-50-charging-symbolic'
else:
kind = 'Suspended'
icon = 'media-playback-pause-symbolic'
self.set_title(kind)
self.set_subtitle(f'{t_start} – {t_end} · {format_duration(duration)}')
self.add_css_class('dim-label')
img = Gtk.Image.new_from_icon_name(icon)
img.set_pixel_size(24)
self.add_prefix(img)
if abs(delta) >= 0.5:
label = Gtk.Label()
if delta > 0:
label.set_markup(f'+{abs(delta):.0f}%')
else:
label.set_markup(f'−{abs(delta):.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)
# merge adjacent sessions with same status and small gaps
MERGE_GAP = 180 # 3 minutes — merge if same status and gap < this
GAP_THRESHOLD = 180 # show gap row if gap >= this between different statuses
LVL_THRESHOLD = 0
merged = []
for s in sessions:
start_ts, end_ts, start_lvl, end_lvl, status = s
if merged:
p_start, p_end, p_slvl, p_elvl, p_status = merged[-1]
gap = start_ts - p_end
if p_status == status and gap < MERGE_GAP:
# extend previous session
merged[-1] = (p_start, end_ts, p_slvl, end_lvl, status)
continue
merged.append(s)
prev = None
for s in merged:
start_ts, end_ts, start_lvl, end_lvl, status = s
if end_ts - start_ts < 30:
prev = s
continue
if prev is not None:
_, prev_end, _, prev_end_lvl, _ = prev
gap = start_ts - prev_end
if gap > GAP_THRESHOLD:
gap_row = GapRow(prev_end, prev_end_lvl, start_ts, start_lvl)
self.session_group.add(gap_row)
row = SessionRow(start_ts, end_ts, start_lvl, end_lvl, status)
self.session_group.add(row)
prev = s
if not merged:
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)