Skip to content

Instantly share code, notes, and snippets.

@petermiles
Last active August 18, 2026 15:41
Show Gist options
  • Select an option

  • Save petermiles/12d1bfcec3b54b152f3ffe8f4170ad29 to your computer and use it in GitHub Desktop.

Select an option

Save petermiles/12d1bfcec3b54b152f3ffe8f4170ad29 to your computer and use it in GitHub Desktop.
battery-history — historical macOS battery usage from pmset + system_profiler (no deps)
#!/usr/bin/env python3
"""battery-history — historical battery usage for macOS.
Data sources (no third-party dependencies):
* `pmset -g log` -> charge % samples over time (weeks of history)
* `system_profiler SPPowerDataType` -> current health / adapter
Everything is derived from the "Using AC/Batt(Charge: N)" samples that
macOS power management writes to its log.
"""
import argparse
import datetime as dt
import html
import json
import os
import re
import subprocess
import sys
import tempfile
SAMPLE_RE = re.compile(
r"^(\d{4}-\d{2}-\d{2}) (\d{2}:\d{2}:\d{2}) ([-+]\d{4}).*?"
r"Using (AC|Batt)\(Charge:\s*(\d+)"
)
# macOS logs a 'Using AC/Batt' line only on a power event. A gap longer
# than this between two samples means no events (sleep/off), not runtime,
# so it is not counted as on-battery time and it ends the current session.
SLEEP_GAP_H = 1.0
def run(cmd):
try:
out = subprocess.run(
cmd, capture_output=True, text=True, timeout=60
)
return out.stdout
except Exception as exc: # noqa: BLE001
print(f"failed to run {' '.join(cmd)}: {exc}", file=sys.stderr)
return ""
def parse_samples(text):
samples = []
for line in text.splitlines():
m = SAMPLE_RE.match(line)
if not m:
continue
date_s, time_s, tz_s, source, charge = m.groups()
stamp = dt.datetime.strptime(
f"{date_s} {time_s} {tz_s}", "%Y-%m-%d %H:%M:%S %z"
)
samples.append(
{"t": stamp, "source": source, "charge": int(charge)}
)
samples.sort(key=lambda s: s["t"])
return samples
def build_sessions(samples):
"""Group consecutive same-source samples into sessions.
A gap longer than SLEEP_GAP_H means the machine was asleep or off, not
running. Such a gap ends the current session so sleep is never counted."""
sessions = []
cur = None
prev_t = None
for s in samples:
gap_h = (s["t"] - prev_t).total_seconds() / 3600.0 if prev_t else 0.0
prev_t = s["t"]
if cur is None or s["source"] != cur["source"] or gap_h > SLEEP_GAP_H:
if cur is not None:
sessions.append(cur)
cur = {
"source": s["source"],
"start": s["t"],
"end": s["t"],
"start_charge": s["charge"],
"end_charge": s["charge"],
"min_charge": s["charge"],
"max_charge": s["charge"],
"n": 1,
}
else:
cur["end"] = s["t"]
cur["end_charge"] = s["charge"]
cur["min_charge"] = min(cur["min_charge"], s["charge"])
cur["max_charge"] = max(cur["max_charge"], s["charge"])
cur["n"] += 1
if cur is not None:
sessions.append(cur)
for se in sessions:
se["dur_h"] = (se["end"] - se["start"]).total_seconds() / 3600.0
se["delta"] = se["end_charge"] - se["start_charge"]
se["rate"] = se["delta"] / se["dur_h"] if se["dur_h"] > 0 else 0.0
return sessions
def parse_health(text):
keys = {
"Cycle Count": "cycle_count",
"Condition": "condition",
"Maximum Capacity": "max_capacity",
"State of Charge (%)": "charge",
"Charging": "charging",
"Fully Charged": "fully_charged",
}
health = {}
adapter = {}
in_adapter = False
for line in text.splitlines():
stripped = line.strip()
if "AC Charger Information" in line:
in_adapter = True
for label, key in keys.items():
if stripped.startswith(label + ":"):
health[key] = stripped.split(":", 1)[1].strip()
if in_adapter:
if stripped.startswith("Connected:"):
adapter["connected"] = stripped.split(":", 1)[1].strip()
elif stripped.startswith("Wattage (W):"):
adapter["wattage"] = stripped.split(":", 1)[1].strip()
elif stripped.startswith("Name:"):
adapter["name"] = stripped.split(":", 1)[1].strip()
health["adapter"] = adapter
return health
def daily_rollup(samples):
"""Per calendar day: awake time on battery and % drained.
Time is summed interval by interval between consecutive samples. A gap
longer than SLEEP_GAP_H is sleep and is skipped. Each interval is credited
to the calendar day(s) it covers, so no day can exceed 24 hours."""
days = {}
def day(ds):
return days.setdefault(
ds, {"batt_h": 0.0, "drained": 0.0, "charged": 0.0,
"min": 100, "max": 0})
for s in samples:
d = day(s["t"].date().isoformat())
d["min"] = min(d["min"], s["charge"])
d["max"] = max(d["max"], s["charge"])
for a, b in zip(samples, samples[1:]):
gap_h = (b["t"] - a["t"]).total_seconds() / 3600.0
if gap_h <= 0 or gap_h > SLEEP_GAP_H:
continue
drop = a["charge"] - b["charge"]
seg_start = a["t"]
while seg_start < b["t"]:
midnight = dt.datetime.combine(
seg_start.date() + dt.timedelta(days=1),
dt.time.min, tzinfo=seg_start.tzinfo)
seg_end = min(b["t"], midnight)
seg_h = (seg_end - seg_start).total_seconds() / 3600.0
d = day(seg_start.date().isoformat())
if a["source"] == "Batt":
d["batt_h"] += seg_h
if drop > 0:
d["drained"] += drop * (seg_h / gap_h)
elif drop < 0:
d["charged"] += -drop * (seg_h / gap_h)
seg_start = seg_end
return days
def fmt_dur(h):
total_m = int(round(h * 60))
return f"{total_m // 60}h{total_m % 60:02d}m"
def progress(msg):
print(f"\u2192 {msg}", file=sys.stderr, flush=True)
def print_report(args):
progress("Reading power log (pmset)…")
log = run(["pmset", "-g", "log"])
samples = parse_samples(log)
progress(f"Parsed {len(samples)} charge samples")
if not samples:
print("No battery samples found in pmset log.", file=sys.stderr)
sys.exit(1)
if args.days:
cutoff = samples[-1]["t"] - dt.timedelta(days=args.days)
samples = [s for s in samples if s["t"] >= cutoff]
sessions = build_sessions(samples)
progress("Reading battery health (system_profiler)…")
health = parse_health(run(["system_profiler", "SPPowerDataType"]))
daily = daily_rollup(samples)
if args.json:
print(json.dumps(
{
"health": health,
"window": {
"from": samples[0]["t"].isoformat(),
"to": samples[-1]["t"].isoformat(),
"samples": len(samples),
},
"sessions": [
{
"source": s["source"],
"start": s["start"].isoformat(),
"end": s["end"].isoformat(),
"start_charge": s["start_charge"],
"end_charge": s["end_charge"],
"min_charge": s["min_charge"],
"dur_h": round(s["dur_h"], 3),
"delta": s["delta"],
"rate": round(s["rate"], 2),
}
for s in sessions
],
"daily": daily,
},
indent=2,
))
return
progress("Building HTML report…")
doc = html_report(samples, sessions, health, daily)
# Only --stdout emits raw HTML (for saving with `> report.html`). Otherwise
# always write the report to a file and open it in the browser.
if args.stdout:
sys.stdout.write(doc)
return
out = os.path.join(tempfile.gettempdir(), "battery-history.html")
with open(out, "w", encoding="utf-8") as f:
f.write(doc)
progress(f"Wrote {out}")
progress("Opening in browser…")
subprocess.run(["open", out], check=False)
HTML_TEMPLATE = """<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Battery History</title>
<style>
:root{
--bg:#0f1216; --panel:#161b22; --panel2:#1b212b; --border:#262c36;
--text:#e6e9ee; --muted:#9aa4b2; --dim:#6b7482;
--blue:#60a5fa; --green:#34d399; --amber:#fbbf24; --red:#f87171;
--sleep:#39414d;
}
*{box-sizing:border-box}
body{margin:0;background:var(--bg);color:var(--text);
font:14px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;
-webkit-font-smoothing:antialiased;}
.wrap{max-width:960px;margin:0 auto;padding:40px 24px 64px}
h1{font-size:22px;font-weight:650;letter-spacing:-.01em;margin:0}
.subtitle{color:var(--muted);margin:4px 0 28px;font-size:13px}
.mono{font-family:ui-monospace,SFMono-Regular,Menlo,monospace}
.cards{display:grid;grid-template-columns:repeat(3,1fr);gap:16px;margin-bottom:28px}
.card{background:var(--panel);border:1px solid var(--border);
border-radius:14px;padding:16px 18px}
.card .k{color:var(--muted);font-size:12px;text-transform:uppercase;
letter-spacing:.04em}
.card .v{font-size:30px;font-weight:650;margin-top:6px;letter-spacing:-.02em}
.card .v.small{font-size:16px;font-weight:550;margin-top:10px}
.card .sub{color:var(--dim);font-size:12px;margin-top:6px}
.bar{height:8px;border-radius:99px;background:var(--panel2);
margin-top:12px;overflow:hidden}
.bar span{display:block;height:100%;border-radius:99px}
.bar .green{background:var(--green)} .bar .amber{background:var(--amber)}
.bar .red{background:var(--red)}
.panel{background:var(--panel);border:1px solid var(--border);
border-radius:14px;padding:20px;margin-bottom:24px}
.panel h2{font-size:13px;font-weight:600;color:var(--muted);
text-transform:uppercase;letter-spacing:.04em;margin:0 0 14px}
.chart{width:100%;height:auto;display:block}
.chart .grid{stroke:#232a33;stroke-width:1}
.chart .axis{fill:var(--dim);font-size:11px;font-family:ui-monospace,monospace}
.chart .area{fill:var(--blue);opacity:.12}
.chart .line{fill:none;stroke:var(--blue);stroke-width:2;
stroke-linejoin:round;stroke-linecap:round}
.chart .band{opacity:.9}
.chart .band.ac{fill:var(--green)} .chart .band.batt{fill:var(--amber)}
.chart .band.sleep{fill:var(--sleep)}
.legend{display:flex;gap:18px;margin-top:12px;color:var(--muted);font-size:12px}
.legend i{display:inline-block;width:12px;height:12px;border-radius:3px;
margin-right:6px;vertical-align:-1px}
.legend .ac{background:var(--green)} .legend .batt{background:var(--amber)}
.legend .sleep{background:var(--sleep)}
table{width:100%;border-collapse:collapse}
th,td{text-align:left;padding:9px 10px;border-bottom:1px solid var(--border)}
th{color:var(--muted);font-size:11px;text-transform:uppercase;
letter-spacing:.04em;font-weight:600}
tr:last-child td{border-bottom:none}
td.right,th.right{text-align:right}
td.dim{color:var(--dim)}
.mini{display:inline-block;width:60px;height:6px;border-radius:99px;
background:var(--panel2);margin-left:8px;overflow:hidden;vertical-align:1px}
.mini>span{display:block;height:100%;background:var(--blue)}
.mini.amber>span{background:var(--amber)}
.mini.red>span{background:var(--red)}
.callout{font-size:13px;color:var(--muted)}
.callout b{color:var(--text);font-size:16px}
footer{color:var(--dim);font-size:12px;margin-top:8px;text-align:center}
</style>
</head>
<body>
<div class="wrap">
<h1>Battery History</h1>
<div class="subtitle">__SUBTITLE__</div>
<div class="cards">__CARDS__</div>
<div class="panel">
<h2>Charge over time</h2>
__CHART__
<div class="legend">
<span><i class="ac"></i>On adapter</span>
<span><i class="batt"></i>On battery</span>
<span><i class="sleep"></i>Asleep / no data</span>
</div>
</div>
<div class="panel">
<h2>Per-day battery use</h2>
<table>
<thead><tr>
<th>Day</th><th class="right">On battery</th>
<th class="right">Drained</th><th class="right">Min</th>
<th class="right">Max</th>
</tr></thead>
<tbody>__DAILY__</tbody>
</table>
</div>
<div class="panel">
<h2>Discharge sessions (last __NSESS__)</h2>
<table>
<thead><tr>
<th>Start</th><th class="right">Length</th>
<th class="right">Charge</th><th class="right">Drain rate</th>
</tr></thead>
<tbody>__SESSIONS__</tbody>
</table>
<div class="callout" style="margin-top:14px">
Average drain on battery: <b>__AVG__%/hr</b>
</div>
</div>
<footer>Generated __GENERATED__ &middot; pmset + system_profiler</footer>
</div>
</body>
</html>
"""
def _svg_chart(samples, width=960, height=300):
pad_l, pad_r, pad_t, pad_b = 46, 14, 16, 40
pw, ph = width - pad_l - pad_r, height - pad_t - pad_b
t0, t1 = samples[0]["t"], samples[-1]["t"]
span = (t1 - t0).total_seconds() or 1
def X(t):
return pad_l + (t - t0).total_seconds() / span * pw
def Y(c):
return pad_t + (1 - c / 100.0) * ph
parts = []
for c in (0, 25, 50, 75, 100):
y = Y(c)
parts.append(
f'<line class="grid" x1="{pad_l:.1f}" y1="{y:.1f}" '
f'x2="{pad_l + pw:.1f}" y2="{y:.1f}"/>')
parts.append(
f'<text class="axis" x="{pad_l - 8:.1f}" y="{y + 3:.1f}" '
f'text-anchor="end">{c}</text>')
band_y = pad_t + ph + 7
for a, b in zip(samples, samples[1:]):
x1, x2 = X(a["t"]), X(b["t"])
if x2 - x1 < 0.05:
continue
gap_h = (b["t"] - a["t"]).total_seconds() / 3600.0
cls = ("sleep" if gap_h > SLEEP_GAP_H
else "ac" if a["source"] == "AC" else "batt")
parts.append(
f'<rect class="band {cls}" x="{x1:.1f}" y="{band_y:.1f}" '
f'width="{x2 - x1:.1f}" height="9"/>')
pts = " ".join(f"{X(s['t']):.1f},{Y(s['charge']):.1f}" for s in samples)
area = f"{X(t0):.1f},{Y(0):.1f} " + pts + f" {X(t1):.1f},{Y(0):.1f}"
parts.append(f'<polygon class="area" points="{area}"/>')
parts.append(f'<polyline class="line" points="{pts}"/>')
seen = set()
for s in samples:
d = s["t"].date()
if d in seen:
continue
seen.add(d)
x = X(dt.datetime.combine(d, dt.time.min, tzinfo=t0.tzinfo))
x = max(pad_l, min(pad_l + pw, x))
parts.append(
f'<text class="axis" x="{x:.1f}" y="{height - 10:.1f}" '
f'text-anchor="middle">{d:%m-%d}</text>')
return (f'<svg viewBox="0 0 {width} {height}" class="chart" role="img">'
+ "".join(parts) + "</svg>")
def html_report(samples, sessions, health, days):
a = health.get("adapter", {})
now = health.get("charge", "?")
try:
lvl = int(str(now))
except ValueError:
lvl = 0
lvl_cls = "red" if lvl <= 20 else "amber" if lvl <= 50 else "green"
charging = health.get("charging", "?")
connected = a.get("connected") == "Yes"
adapter = (f"{a.get('name', 'Adapter')} ({a.get('wattage', '?')}W)"
if connected else "Not connected")
state = ("Charging" if charging == "Yes"
else "Idle on adapter" if connected else "On battery")
cards = (
'<div class="card"><div class="k">Now</div>'
f'<div class="v">{now}%</div>'
f'<div class="bar"><span class="{lvl_cls}" style="width:{lvl}%"></span></div>'
f'<div class="sub">{state}</div></div>'
'<div class="card"><div class="k">Battery health</div>'
f'<div class="v">{html.escape(str(health.get("max_capacity", "?")))}</div>'
f'<div class="sub">{html.escape(str(health.get("cycle_count", "?")))} cycles &middot; '
f'{html.escape(str(health.get("condition", "?")))}</div></div>'
'<div class="card"><div class="k">Power source</div>'
f'<div class="v small">{html.escape(adapter)}</div>'
f'<div class="sub">{len(samples)} samples</div></div>')
batt = [s for s in sessions
if s["source"] == "Batt" and s["delta"] < 0 and s["dur_h"] * 60 >= 1]
drains = [-s["rate"] for s in batt]
avg = sum(drains) / len(drains) if drains else 0.0
max_rate = max(drains) if drains else 1
max_batt = max((d["batt_h"] for d in days.values()), default=0) or 1
daily_rows = []
for day in sorted(days):
d = days[day]
dr = int(round(d["drained"]))
bh = fmt_dur(d["batt_h"]) if d["batt_h"] else "\u2014"
bw = min(100, d["batt_h"] / max_batt * 100)
daily_rows.append(
f'<tr><td class="mono">{day}</td>'
f'<td class="mono right">{bh}<span class="mini">'
f'<span style="width:{bw:.0f}%"></span></span></td>'
f'<td class="mono right">{dr}%<span class="mini amber">'
f'<span style="width:{min(100, dr):.0f}%"></span></span></td>'
f'<td class="mono right dim">{d["min"]}%</td>'
f'<td class="mono right dim">{d["max"]}%</td></tr>')
sess_rows = []
for s in batt[-20:]:
rate = -s["rate"]
rw = min(100, rate / max_rate * 100)
sess_rows.append(
f'<tr><td class="mono">{s["start"]:%m-%d %H:%M}</td>'
f'<td class="mono right">{fmt_dur(s["dur_h"])}</td>'
f'<td class="mono right">{s["start_charge"]}\u2192{s["end_charge"]}%</td>'
f'<td class="mono right">-{rate:.1f}%/hr<span class="mini red">'
f'<span style="width:{rw:.0f}%"></span></span></td></tr>')
return (HTML_TEMPLATE
.replace("__SUBTITLE__",
f"{samples[0]['t']:%b %-d, %H:%M} \u2192 "
f"{samples[-1]['t']:%b %-d, %H:%M} "
f"({len(samples)} samples)")
.replace("__CARDS__", cards)
.replace("__CHART__", _svg_chart(samples))
.replace("__DAILY__", "".join(daily_rows))
.replace("__NSESS__", str(len(batt)))
.replace("__SESSIONS__", "".join(sess_rows)
or '<tr><td colspan="4" class="dim">No discharge sessions</td></tr>')
.replace("__AVG__", f"{avg:.1f}")
.replace("__GENERATED__", f"{dt.datetime.now():%Y-%m-%d %H:%M}"))
def main():
p = argparse.ArgumentParser(
description="Historical battery usage for macOS "
"(from pmset + system_profiler).")
p.add_argument("--days", type=int, default=30,
help="limit to the last N days of history (0 = all)")
p.add_argument("--stdout", action="store_true",
help="write the HTML report to stdout instead of opening it")
p.add_argument("--json", action="store_true",
help="emit machine-readable JSON")
args = p.parse_args()
print_report(args)
if __name__ == "__main__":
main()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment