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

battery-stats dashboard [days=14]
The only display command. Plotly charts for SOT, drain, sessions, charging bands, hourly heatmap, capacity decay. Same AMOLED theme as the claude-stats one.

PowerTop manual sudo

battery-stats powertop [sec=30] [notes...]
Run 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.
battery-stats powertop-show [run_id]
View a previous snapshot. Defaults to latest. The only surviving terminal-table command — kept because the dashboard does not currently render PowerTop data (todo).

Maintenance non-display

battery-stats ingest
One-time / catch-up bulk import from /var/lib/upower/history-*.dat.
battery-stats aggregate
Manual rebuild of discharge_sessions + daily_battery from raw samples.
battery-stats poll
Take one sample now (debug — the systemd timer normally does this).
Removed in the slim-down
Earlier versions had terminal-report subcommands (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.

read
/sys/class/power_supply/BAT0
energy_now, capacity, cycles, status
probe
screen state
loginctl + GNOME D-Bus
convert
µ→base units
awk arithmetic
insert
battery_samples
ON CONFLICT (ts) DO NOTHING
refresh
aggregator
flock -n + chained

What's read from sysfs

fileunitsused as
energy_nowµWhenergy_now_wh (÷1e6); used for drain calc
energy_fullµWhenergy_full_wh; current battery max
energy_full_designµWhenergy_full_design_wh; factory spec, used for health %
power_nowµWpower_now_w; instantaneous wattage
voltage_nowµVvoltage_v
capacity%capacity_pct
cycle_countintcycle_count; lifetime charge/discharge cycles
statusenumcharging/discharging/full/not-charging/unknown
/sys/class/power_supply/AC/online0/1on_ac bool
/sys/class/backlight/*/brightness0..maxbrightness_pct

Screen state — the honest "is the user actually here" check

Two signals combined:

  • loginctl show-session $XDG_SESSION_ID -p IdleHinttrue 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.GetActivetrue when the GNOME lock screen is up
  • screen_active = NOT idle_hint AND NOT screensaver_active
Why both signals
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

  1. Sort all samples by timestamp
  2. Tag each row with new_session = 1 when on_ac goes true → false (the unplug moment)
  3. session_id = SUM(new_session) OVER (ORDER BY ts) — running count
  4. Filter to on_ac = false samples only
  5. 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

parse
awk → CSV
epoch → ISO timestamp (UTC)
stage
DuckDB TEMP TABLE
COPY FROM csv
load
upower_rate / upower_charge
ON CONFLICT (ts, state) DO NOTHING
Permissions
/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.

elevate
SUDO_ASKPASS=… sudo -A
zenity GUI prompt
capture
powertop --csv
--time=N (default 30s)
parse
python heuristic
extract Top 10 section
store
powertop_runs +
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

columntypenotes
tsTIMESTAMPPK; UTC
energy_now_whDOUBLECurrent charge in Wh
energy_full_whDOUBLECurrent max capacity
energy_full_design_whDOUBLEFactory-spec capacity
power_now_wDOUBLEInstantaneous wattage (drain or charge magnitude)
voltage_vDOUBLEBattery terminal voltage
capacity_pctINTEGER0–100
cycle_countINTEGERLifetime cycles
stateVARCHARcharging / discharging / full / not-charging / unknown
on_acBOOLEANFrom /sys/class/power_supply/AC/online
screen_activeBOOLEANNOT idle_hint AND NOT screensaver_active
idle_hint, screensaver_activeBOOLEANRaw component signals
brightness_pctINTEGERFrom /sys/class/backlight

discharge_sessions derived from battery_samples

columntypenotes
session_idINTEGERPK; running count of unplug events
start_ts / end_tsTIMESTAMPBounds (UTC)
duration_secondsBIGINTSum of capped sample intervals
start_pct / end_pctINTEGERBattery % at session bounds
energy_used_whDOUBLEMAX(energy_now_wh) − MIN, ≥ 0
sot_seconds / screen_off_secondsBIGINTScreen-on / off subtotals
avg_drain_wDOUBLEWhole-session: energy_used_wh × 3600 / duration
avg_drain_w_sotDOUBLEScreen-on only — the "honest" number
projected_full_runtime_hoursDOUBLEenergy_full_design / avg_drain_w_sot

daily_battery per-IST-day rollup

columntypenotes
dateDATEPK; IST calendar day
sot_minutes / discharge_minutesINTEGERSums across the day's sessions
total_discharge_whDOUBLESum of energy_used_wh for the day
avg_drain_w_sotDOUBLEDay-level honest drain
n_sessionsINTEGERCount of discharge sessions starting that day
cycle_count_eod, energy_full_wh_eod, health_pctDOUBLEBackfilled from end-of-day raw sample

upower_rate, upower_charge UPower bulk import

columntypenotes
tsTIMESTAMPPK component (UTC, from epoch)
watts (rate) / percentage (charge)DOUBLEThe metric
stateVARCHARPK component; charging/discharging/etc

powertop_runs, powertop_top_processes

columntypenotes
run_idINTEGERPK; allocated as MAX+1
captured_at, duration_seconds, on_ac, notesmixedSnapshot metadata
rank, description, usage, wakeups_per_sec, pw_estimate_w, categorymixedPer-process row in powertop_top_processes