This commit is contained in:
2026-04-01 14:42:47 +09:00
parent 2a7398a1c9
commit 6b727c4fd0
2 changed files with 88 additions and 22 deletions

View File

@@ -63,6 +63,12 @@ class BatteryClient:
'(a(dddds))')
return list(r[0]) if r else []
def get_events(self, date_str):
"""Returns list of (ts, type_str)"""
r = self._call('GetEvents', GLib.Variant('(s)', (date_str,)),
'(a(ds))')
return list(r[0]) if r else []
def get_current(self):
r = self._call('GetCurrent', None, '(dddds)')
if r:
@@ -433,9 +439,10 @@ class SessionRow(Adw.ActionRow):
class GapRow(Adw.ActionRow):
"""Inferred gap between sessions (suspend/shutdown)."""
"""Gap between sessions — labeled from events if available, otherwise inferred."""
def __init__(self, prev_end_ts, prev_end_level, next_start_ts, next_start_level):
def __init__(self, prev_end_ts, prev_end_level, next_start_ts, next_start_level,
gap_event=None):
super().__init__()
t_start = datetime.fromtimestamp(prev_end_ts).strftime('%H:%M')
@@ -443,8 +450,20 @@ class GapRow(Adw.ActionRow):
duration = next_start_ts - prev_end_ts
delta = next_start_level - prev_end_level
if gap_event == 'suspend':
if delta > 0.5:
kind = 'Suspended (charged)'
icon = 'battery-level-50-charging-symbolic'
else:
kind = 'Suspended'
icon = 'media-playback-pause-symbolic'
elif gap_event == 'shutdown':
kind = 'Shutdown'
icon = 'system-shutdown-symbolic'
else:
# fallback: infer from level delta
if abs(delta) < 0.5:
kind = 'Shutdown / Hibernate'
kind = 'Shutdown / Suspend'
icon = 'system-shutdown-symbolic'
elif delta > 0:
kind = 'Suspended (charged)'
@@ -596,6 +615,7 @@ class BatteryWindow(Adw.ApplicationWindow):
samples = self.client.get_samples(date_str)
sessions = self.client.get_sessions(date_str)
events = self.client.get_events(date_str)
day_start = datetime.combine(
self.current_date, datetime.min.time()).timestamp()
@@ -624,39 +644,53 @@ class BatteryWindow(Adw.ApplicationWindow):
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
MERGE_GAP = 180 # seconds — merge same-status if gap < this (not a real suspend)
GAP_THRESHOLD = 180 # show gap row if gap >= this
TRIVIAL_DUR = 300 # seconds — trivial if 0% change AND shorter than this
merged = []
# pass 1: drop trivial sessions (0% change + short duration)
filtered = []
for s in sessions:
start_ts, end_ts, start_lvl, end_lvl, status = s
dur = end_ts - start_ts
if abs(end_lvl - start_lvl) < 0.5 and dur < TRIVIAL_DUR:
continue
filtered.append(s)
# pass 2: merge adjacent same-status sessions with small gaps
merged = []
for s in filtered:
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)
def find_gap_event(gap_start, gap_end):
"""Find a suspend or shutdown event within a gap period."""
for ts, etype in events:
if gap_start <= ts <= gap_end and etype in ('suspend', 'shutdown'):
return etype
return None
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)
gap_event = find_gap_event(prev_end, start_ts)
self.session_group.add(
GapRow(prev_end, prev_end_lvl, start_ts, start_lvl,
gap_event))
row = SessionRow(start_ts, end_ts, start_lvl, end_lvl, status)
self.session_group.add(row)
self.session_group.add(
SessionRow(start_ts, end_ts, start_lvl, end_lvl, status))
prev = s
if not merged:

32
org.batteryd/batteryd Normal file → Executable file
View File

@@ -48,6 +48,10 @@ DBUS_IFACE_XML = '''<node>
<arg direction="in" name="date" type="s"/>
<arg direction="out" name="sessions" type="a(dddds)"/>
</method>
<method name="GetEvents">
<arg direction="in" name="date" type="s"/>
<arg direction="out" name="events" type="a(ds)"/>
</method>
<method name="GetCurrent">
<arg direction="out" name="level" type="d"/>
<arg direction="out" name="energy_wh" type="d"/>
@@ -131,6 +135,13 @@ def open_db():
)
''')
db.execute('CREATE INDEX IF NOT EXISTS idx_sessions_ts ON sessions (start_ts)')
db.execute('''
CREATE TABLE IF NOT EXISTS events (
ts REAL NOT NULL,
type TEXT NOT NULL
)
''')
db.execute('CREATE INDEX IF NOT EXISTS idx_events_ts ON events (ts)')
# migrations
cols = {r[1] for r in db.execute('PRAGMA table_info(samples)')}
@@ -203,6 +214,11 @@ class Battery:
(self.session_start_ts, end_ts,
self.session_start_level, end_level, status))
def _log_event(self, event_type):
now = time.time()
self.db.execute('INSERT INTO events (ts, type) VALUES (?, ?)',
(now, event_type))
def on_suspend(self):
reading = read_battery(self.sys_bus, self.bat_path)
if reading:
@@ -210,6 +226,7 @@ class Battery:
now = time.time()
if self.current and self.last_state is not None:
self._close_session(now, self.current['level'])
self._log_event('suspend')
self.flush()
self.session_start_ts = 0
self.last_state = None
@@ -217,6 +234,7 @@ class Battery:
self.current['level'] if self.current else 0)
def on_resume(self):
self._log_event('resume')
reading = read_battery(self.sys_bus, self.bat_path)
if reading is None:
return
@@ -249,6 +267,7 @@ class Battery:
def shutdown(self):
if self.current and self.last_state is not None:
self._close_session(time.time(), self.current['level'])
self._log_event('shutdown')
self.flush()
self.db.commit()
@@ -270,6 +289,7 @@ class Battery:
reading['power_w'], reading['voltage_v'],
STATE_NAMES.get(reading['state'], '?'))
self._log_event('start')
self.flush_timer_id = GLib.timeout_add_seconds(
FLUSH_INTERVAL, self._on_flush_timer)
@@ -307,6 +327,15 @@ class Battery:
return [(float(s), float(e), float(sl), float(el), st)
for s, e, sl, el, st in rows]
def query_events(self, date_str):
day_start, day_end = _local_day_range(date_str)
rows = self.db.execute('''
SELECT ts, type FROM events
WHERE ts >= ? AND ts < ?
ORDER BY ts
''', (day_start, day_end)).fetchall()
return [(float(ts), t) for ts, t in rows]
def main():
logging.basicConfig(
level=logging.DEBUG if os.environ.get('BATTERYD_DEBUG') else logging.INFO,
@@ -355,6 +384,9 @@ def main():
elif method == 'GetSessions':
rows = tracker.query_sessions(params.unpack()[0])
invocation.return_value(GLib.Variant('(a(dddds))', (rows,)))
elif method == 'GetEvents':
rows = tracker.query_events(params.unpack()[0])
invocation.return_value(GLib.Variant('(a(ds))', (rows,)))
elif method == 'GetCurrent':
if tracker.current:
r = tracker.current