battery-stats
Local battery analytics from sysfs + Wayland screen state. A 5-minute poller writes raw samples; a nightly aggregator derives discharge sessions and per-day rollups. UPower's bulk historical data is imported once at install for backfill. PowerTop snapshots are captured on demand.
CLI commands
Fish function at ~/.config/fish/functions/battery-stats.fish.
All read-only against ~/Documents/battery-stats/battery.duckdb
unless noted.
The display surface interactive
PowerTop manual sudo
powertop --csv for N seconds, parse Top 10
Power Consumers, store as a powertop_runs
snapshot. Uses zenity askpass (no terminal sudo). Notes are
free-form text saved alongside the snapshot.
Maintenance non-display
/var/lib/upower/history-*.dat.discharge_sessions + daily_battery from raw samples.now, sot, sessions,
worst, drain, health) and
gnuplot-Braille charts (graph upower /
graph drain / etc). They were dropped — the dashboard
covers all of them with hover/zoom you can't get in a terminal.
How poll.sh works every 5 min
Single sysfs sample + Wayland screen-state probe → one row in
battery_samples. Triggered by
battery-stats-poll.timer.
energy_now, capacity, cycles, status
loginctl + GNOME D-Bus
awk arithmetic
ON CONFLICT (ts) DO NOTHING
flock -n + chained
What's read from sysfs
| file | units | used as |
|---|---|---|
| energy_now | µWh | → energy_now_wh (÷1e6); used for drain calc |
| energy_full | µWh | → energy_full_wh; current battery max |
| energy_full_design | µWh | → energy_full_design_wh; factory spec, used for health % |
| power_now | µW | → power_now_w; instantaneous wattage |
| voltage_now | µV | → voltage_v |
| capacity | % | → capacity_pct |
| cycle_count | int | → cycle_count; lifetime charge/discharge cycles |
| status | enum | charging/discharging/full/not-charging/unknown |
| /sys/class/power_supply/AC/online | 0/1 | → on_ac bool |
| /sys/class/backlight/*/brightness | 0..max | → brightness_pct |
Screen state — the honest "is the user actually here" check
Two signals combined:
loginctl show-session $XDG_SESSION_ID -p IdleHint→ true means the desktop session has been idle past the threshold (locked or auto-locked)gdbus call --session --dest org.gnome.ScreenSaver --method org.gnome.ScreenSaver.GetActive→ true when the GNOME lock screen is upscreen_active = NOT idle_hint AND NOT screensaver_active
IdleHint alone misses cases where you manually lock with
Super+L before the idle threshold expires. GetActive alone
misses long-tail "screen turned off but not locked" states. Together
they're a reliable "user is consuming this battery right now" signal.
The aggregator-after-each-poll trick
# Refresh derived tables so dashboard reflects every poll.
# Use flock so concurrent runs (e.g. manual + timer) don't collide on the DB.
LOCK="/tmp/battery-stats-aggregate.lock"
flock -n "$LOCK" -c "$HOME/Documents/battery-stats/bin/aggregate-daily.sh >/dev/null 2>&1" || true
Each poll re-runs the aggregator under flock -n (non-blocking).
That means battery-stats dashboard always shows fresh data,
not yesterday's nightly rollup. The flock prevents two concurrent
aggregator runs from corrupting the DB.
How aggregate-daily.sh works nightly + after every poll
Wipes and rebuilds two derived tables from the raw battery_samples:
discharge_sessions (one row per AC-off → AC-on) and
daily_battery (per-day rollup in IST).
Session detection
- Sort all samples by timestamp
- Tag each row with
new_session = 1whenon_acgoestrue → false(the unplug moment) session_id = SUM(new_session) OVER (ORDER BY ts)— running count- Filter to
on_ac = falsesamples only - Group by session_id → MIN/MAX timestamps, capacity start/end, energy used
SOT integration
For each sample, compute interval_seconds = time-to-next-sample,
capped at 600s. The cap handles laptop suspends (gap of hours
appears as one 10-minute interval, not 4 hours of fictitious drain).
SOT is the sum of intervals where screen_active = true.
LEAST(
COALESCE(EXTRACT(EPOCH FROM (LEAD(ts) OVER (ORDER BY ts) - ts)), 300),
600
) AS interval_seconds
Phantom session filter
Sessions with duration ≤ 60s OR energy_delta ≤ 0.05 Wh
are dropped via HAVING. This eliminates flicker sessions
(briefly unplug-and-replug) that would otherwise dominate the
dashboard's "worst sessions" panel with bogus 0Wh / infinite-runtime entries.
IST date bucketing for daily_battery
DATE_TRUNC('day', (start_ts AT TIME ZONE 'UTC') AT TIME ZONE 'Asia/Kolkata')::DATE AS date
The double AT TIME ZONE matters — see
Timezone gotcha.
Health backfill
After daily rollup, a second UPDATE pulls end-of-day cycle count and
energy_full_wh from the raw samples (FIRST() ORDER BY ts DESC),
and computes health_pct = energy_full / energy_full_design × 100.
Tracks battery decay over time.
How ingest-upower.sh works one-time backfill
UPower (the Linux battery daemon) keeps its own historical TSV files
at /var/lib/upower/history-*.dat. We bulk-import them once
at install so the dashboard has data going back weeks/months even
before our own poller starts.
File detection
UPower names files history-{rate,charge}-{MODEL}-{SERIAL}.dat.
We auto-detect the model:
BATTERY_MODEL="${BATTERY_MODEL:-$(cat /sys/class/power_supply/BAT0/model_name | tr -d '[:space:]')}"
RATE_FILE=$(ls "$HIST_DIR"/history-rate-"$BATTERY_MODEL"-*.dat | head -1)
CHARGE_FILE=$(ls "$HIST_DIR"/history-charge-"$BATTERY_MODEL"-*.dat | head -1)
Override with BATTERY_MODEL=<tag> env var if your distro
names files differently. Don't hardcode a serial — moves between laptops break.
TSV format
Each line is epoch \t value \t state. value is
watts for the rate file, percentage for charge. state is
charging/discharging/fully-charged/etc.
Pipeline
epoch → ISO timestamp (UTC)
COPY FROM csv
ON CONFLICT (ts, state) DO NOTHING
/var/lib/upower is root-owned by default. Install grants
a per-user ACL: sudo setfacl -m u:$USER:rx /var/lib/upower
+ sudo setfacl -m u:$USER:r /var/lib/upower/history-*.dat.
One-time prompt during install.sh.
How powertop-capture.sh works manual
On-demand snapshot of the OS-level top energy consumers (processes,
devices, tunables). Needs root, so it routes through the
SUDO_ASKPASS + zenity popup defined in
~/.local/bin/sudo-askpass.
zenity GUI prompt
--time=N (default 30s)
extract Top 10 section
powertop_top_processes
Why python parses, not jq
powertop's CSV is hostile — semicolon-delimited with multi-row section
headers like ______; ; ;. The parser scans for the
"Top 10 Power Consumers" line, then collects rows until it hits an
underline or blank. For each row it heuristically picks:
- description = longest text field
- pw_estimate_w = first field matching
^[\d.]+\s*W$ - wakeups_per_sec = first plain numeric field
Imperfect, but good enough to spot "this Discord build is burning 4W
idle." For deeper analysis, run powertop directly
(interactive mode) and use this snapshot as a historical anchor.
Schema
Six tables. Source-of-truth at
~/Documents/pulse/battery-stats/schema.sql.
battery_samples populated by poll.sh, every 5 min
| column | type | notes |
|---|---|---|
| ts | TIMESTAMP | PK; UTC |
| energy_now_wh | DOUBLE | Current charge in Wh |
| energy_full_wh | DOUBLE | Current max capacity |
| energy_full_design_wh | DOUBLE | Factory-spec capacity |
| power_now_w | DOUBLE | Instantaneous wattage (drain or charge magnitude) |
| voltage_v | DOUBLE | Battery terminal voltage |
| capacity_pct | INTEGER | 0–100 |
| cycle_count | INTEGER | Lifetime cycles |
| state | VARCHAR | charging / discharging / full / not-charging / unknown |
| on_ac | BOOLEAN | From /sys/class/power_supply/AC/online |
| screen_active | BOOLEAN | NOT idle_hint AND NOT screensaver_active |
| idle_hint, screensaver_active | BOOLEAN | Raw component signals |
| brightness_pct | INTEGER | From /sys/class/backlight |
discharge_sessions derived from battery_samples
| column | type | notes |
|---|---|---|
| session_id | INTEGER | PK; running count of unplug events |
| start_ts / end_ts | TIMESTAMP | Bounds (UTC) |
| duration_seconds | BIGINT | Sum of capped sample intervals |
| start_pct / end_pct | INTEGER | Battery % at session bounds |
| energy_used_wh | DOUBLE | MAX(energy_now_wh) − MIN, ≥ 0 |
| sot_seconds / screen_off_seconds | BIGINT | Screen-on / off subtotals |
| avg_drain_w | DOUBLE | Whole-session: energy_used_wh × 3600 / duration |
| avg_drain_w_sot | DOUBLE | Screen-on only — the "honest" number |
| projected_full_runtime_hours | DOUBLE | energy_full_design / avg_drain_w_sot |
daily_battery per-IST-day rollup
| column | type | notes |
|---|---|---|
| date | DATE | PK; IST calendar day |
| sot_minutes / discharge_minutes | INTEGER | Sums across the day's sessions |
| total_discharge_wh | DOUBLE | Sum of energy_used_wh for the day |
| avg_drain_w_sot | DOUBLE | Day-level honest drain |
| n_sessions | INTEGER | Count of discharge sessions starting that day |
| cycle_count_eod, energy_full_wh_eod, health_pct | DOUBLE | Backfilled from end-of-day raw sample |
upower_rate, upower_charge UPower bulk import
| column | type | notes |
|---|---|---|
| ts | TIMESTAMP | PK component (UTC, from epoch) |
| watts (rate) / percentage (charge) | DOUBLE | The metric |
| state | VARCHAR | PK component; charging/discharging/etc |
powertop_runs, powertop_top_processes
| column | type | notes |
|---|---|---|
| run_id | INTEGER | PK; allocated as MAX+1 |
| captured_at, duration_seconds, on_ac, notes | mixed | Snapshot metadata |
| rank, description, usage, wakeups_per_sec, pw_estimate_w, category | mixed | Per-process row in powertop_top_processes |