One charged AirPod is zero AirPods.
A bud doesn't seat, only one charges, and you find out in the car. Oddpod notices in two minutes and tells you.
git clone https://github.com/chrisdeweese/oddpod
cd oddpod
./scripts/install.shThat's it — no dependencies, no build. Needs /usr/bin/python3, which arrives with the
Command Line Tools (xcode-select --install); the installer checks and stops if it's
missing rather than failing silently later.
One thing to check: notifications are delivered by Script Editor, so it must be enabled under System Settings → Notifications. Otherwise Oddpod fires silently forever.
Uninstall with ./scripts/install.sh --uninstall.
Every two minutes, a launch agent reads the left and right AirPod battery percentages — the same ones your battery widget shows — and keeps twenty minutes of history. When one bud climbs at least 3% while the other hasn't moved at all, that's not a slow charge: that's a bud that isn't seated. You get one native notification, at most once an hour, re-armed when the pair comes back in sync.
The failure is silent, and the moment you'd catch it is the moment you stopped paying attention. The case clicks the same either way. Nothing distinguishes "both charging" from "one charging" unless you go looking — and you only look after it's cost you.
Constants at the top of oddpod.py. Edit and re-run ./scripts/install.sh.
| Constant | Default | Meaning |
|---|---|---|
WINDOW |
20 min | How much history is kept |
MIN_SPAN |
10 min | History required before it will judge |
RISE |
3% | Climb that counts as charging |
FULL |
95% | A bud this high is done, not stuck |
COOLDOWN |
60 min | Minimum gap between repeat alerts |
Poll interval lives in scripts/com.oddpod.watch.plist (StartInterval, seconds).
Raise RISE or MIN_SPAN if you get false alarms; lower them if it's too slow to
notice. AirPods report in coarse steps, so very short windows are unreliable.
Deliberately boring, because it runs forever:
- No network access. It never opens a socket.
- No telemetry, no analytics, no account.
- No admin privileges. No helper tool, no privilege escalation, no kernel extension. It runs as you, in your login session.
- Reads one read-only command:
system_profiler SPBluetoothDataType -json. - Writes one file:
~/.oddpod.json, holding twenty minutes of percentages. - No menu bar icon, no window, no dock item.
The whole thing is under 100 lines in a single file. Reading it takes less time than reading this README.
macOS exposes no charging flag for AirPods, so Oddpod infers charging from rising percentages. It needs about ten minutes of history before it will call a bud stuck, and it errs toward silence: a genuinely stuck bud that reads identically to a full one is invisible to it.
Requires a device that macOS reports separate left and right batteries for — AirPods and AirPods Pro. AirPods Max report a single battery and are skipped.
launchctl list | grep oddpod # should print: - 0 com.oddpod.watch
cat /tmp/oddpod.err # stderr from the agent
python3 oddpod.py # run a poll by hand
python3 oddpod.py --selftest # exercise the detection logic
cat ~/.oddpod.json # what it has observed so farNo notification arriving? Check Script Editor under System Settings → Notifications
first — that's the usual cause. Delete ~/.oddpod.json to reset history.
Issues and pull requests welcome. python3 oddpod.py --selftest must pass; add an
assertion to selftest() for any change to the detection logic. Keep it one file with
no dependencies.
Oddpod is provided as is, with no warranty of any kind, and its authors are not liable for any damages arising from its use — see LICENSE for the binding text.
In plain terms: it is a best-effort convenience, not a guarantee. Don't make it the only thing standing between you and a dead AirPod.
MIT — see LICENSE. Design principles behind the tool and site are in DESIGN.md.
