Battery health monitor & hardware flight recorder for Linux laptops.
batmon is a zero-dependency, low-overhead background daemon that continuously records power, thermal, and system telemetry to local SQLite databases.
It operates as a high-frequency flight recorder, capturing hardware metrics every second to preserve the exact state of the machine in the event of a crash or kernel panic, while simultaneously maintaining a permanent, downsampled historical log for tracking long-term component wear and battery degradation.
curl -fsSL https://raw.githubusercontent.com/InvictusNavarchus/batmon/master/install.sh | bashThe installer automatically detects your architecture (x86_64 or aarch64), downloads the pre-compiled static binary to ~/.local/bin/batmon, configures and starts the systemd user service, and verifies the installation.
Downloads require curl, tar, and sha256sum. The installer resolves one
release tag and verifies its archive against that release's checksum before
changing an existing installation. Missing or invalid checksums stop the update.
Run the same installation command again to update to the latest stable release.
Your databases are preserved; the installer stops the old service, installs the
verified binary, and starts the updated service. No checkout or Rust compiler is
needed. From a checkout, ./install.sh also downloads a release; only
./install.sh --build compiles local source.
Set BIN_DIR on the installer command to choose another binary directory. That
location is recorded for future updates, the service, and uninstalling; subsequent
updates can use the normal installation command without repeating BIN_DIR.
batmon captures telemetry using two distinct tiers:
┌───────────────────────────────────────────────┐
│ batmon Daemon (Rust) │
└───────┬───────────────────────────────┬───────┘
│ (Every 1 sec) │ (Every 60 sec)
▼ ▼
┌──────────────────────────────┐ ┌──────────────────────────────┐
│ debug.db (Flight Log) │ │ battery.db (Historical DB) │
├──────────────────────────────┤ ├──────────────────────────────┤
│ • 1s sample resolution │ │ • 60s sample resolution │
│ • SQLite WAL + sync=NORMAL │ │ • SQLite WAL + sync=NORMAL │
│ • Keeps last 6h of recording │ │ • Permanent wear records │
│ • Crash & panic forensics │ │ • Cycle count & degradation │
└──────────────────────────────┘ └──────────────────────────────┘
-
High-Frequency Flight Recorder (
debug.db):
Records every 1 second directly to SQLite using WAL mode (PRAGMA synchronous = NORMAL). Commits are written to the Linux kernel page cache viawrite()without invoking anfsync()on each tick, consuming negligible disk I/O and power (<15 mW). Data is immediately queryable across processes. Disk durability operates on a best-effort basis via SQLite's automatic WAL checkpoints (wal_autocheckpoint = 100pages) and standard Linux dirty page writeback—preventing database corruption during crashes while trading immediate per-second fsync persistence for drive longevity. Keeps the most recent 6 hours of recording (21,600 rows at 1 Hz), pruning by row count rather than wall-clock age: time spent powered off or suspended does not count against the window, so the run-up to a crash is still there however long the machine stays off afterwards. -
Long-Term Historical Telemetry (
battery.db):
Records downsampled samples every 60 seconds. Tracks long-term battery degradation, design wear capacity, and software-integrated cycle count over months and years.
| Category | Metric | Source | Description |
|---|---|---|---|
| Electrical & Power | power_state |
sysfs / state machine | Rail power status (charging, discharging, ac_idle, unknown) |
ac_online |
sysfs (external supplies) | External power connection, independent of charging; nullable | |
voltage_v |
sysfs (battery) | Instantaneous battery rail voltage (V) | |
power_w |
sysfs (battery) | Discharge / charge rate (Watts) | |
charge_pct |
sysfs (battery) | Current state of charge (%) | |
energy_wh |
sysfs (battery) | Remaining energy (Wh) | |
energy_full_wh |
sysfs (battery) | Current full charge capacity (Wh) | |
energy_design_wh |
sysfs (battery) | Factory nominal design capacity (Wh) | |
voltage_design_v |
sysfs (battery) | Factory design voltage (V) | |
is_charging |
sysfs (battery) | Charge state boolean | |
| Thermal Environment | cpu_temp_c |
sysfs (hwmon) |
CPU package / core temperature (e.g. AMD Tctl / Intel Package id) (°C) |
gpu_temp_c |
sysfs (hwmon) |
GPU temperature (e.g. AMD edge / Intel package) (°C) | |
nvme_temp_c |
sysfs (hwmon) |
NVMe composite temperature (°C) | |
battery_temp_c |
sysfs (battery / hwmon) |
Battery sensor temperature (if present) | |
| Clock & SoC Power | cpu_freq_mhz |
sysfs (cpufreq) / /proc
|
Instantaneous CPU clock frequency (MHz) |
gpu_power_w |
sysfs (hwmon) |
AMD APU / GPU package power (PPT via amdgpu) (Watts) | |
gpu_pct |
sysfs (DRM) | GPU compute / shader utilization (%) | |
| System Load & Host | cpu_pct |
/proc/stat |
Global CPU utilization (%) |
iowait_pct |
/proc/stat |
CPU I/O wait utilization (%) | |
mem_pct |
/proc/meminfo |
Global Memory utilization (%) | |
dirty_kb |
/proc/meminfo |
Uncommitted dirty writeback memory (kB) | |
load1 |
/proc/loadavg |
1-minute system load average | |
procs_blocked |
/proc/stat |
Count of processes currently waiting for I/O (nr_iowait) |
|
psi_io_some |
/proc/pressure/io |
Linux PSI I/O pressure some (10s rolling avg %) |
|
psi_io_full |
/proc/pressure/io |
Linux PSI I/O pressure full (10s rolling avg %) |
|
psi_mem_some |
/proc/pressure/memory |
Linux PSI memory pressure some (10s rolling avg %) |
|
psi_mem_full |
/proc/pressure/memory |
Linux PSI memory pressure full (10s rolling avg %) |
|
boot_id |
/proc/sys/kernel/random/boot_id |
Linux kernel boot session UUID | |
uptime_s |
/proc/uptime |
Monotonic system uptime (seconds) | |
top_processes |
/proc/[pid]/stat |
Top 5 aggregated process groups by 1s CPU delta (JSON) | |
| Health & Wear | health_pct |
sysfs | Full charge capacity vs design capacity (%) |
battery_health |
sysfs (battery) | Raw driver fault status; nullable | |
battery_id |
sysfs (battery) | Manufacturer/model/serial identity as JSON; nullable | |
cycle_count |
sysfs | Hardware cycle count (if reported by BMS) | |
estimated_cycle_count |
Integrator | Calculated cycle count via energy throughput ( |
|
| Runtime Estimates | time_to_empty_s |
UPower D-Bus | Smoothed discharge runtime estimate (seconds) |
time_to_full_s |
UPower D-Bus | Smoothed charge completion estimate (seconds) |
- Auto-detects
energy_*(µWh) vscharge_*(µAh) battery drivers. - Low-Overhead Native Reads: All CPU, memory, clock, GPU, thermal, and process metrics are gathered directly via Linux kernel VFS interfaces (
/procand/sys) and standard POSIX process accounting (~12 ms per sample cycle) with zero child processes. UPower and desktop notifications are spoken to over D-Bus directly rather than by spawningbusctlandnotify-send. Resident memory is ~6.6 MB (median over a 12-hour dual-daemon run spanning a reboot). See empirical evaluations on Kernel VFS vs. Glances, Sysfs Hwmon vs. lm-sensors, and TypeScript vs. Rust parity for detailed benchmark results. - Automatic Migrations: Database schema updates and column additions are handled seamlessly and automatically on startup using SQLite's native
user_versiontracking with zero manual migration steps required.
Battery read failures do not interrupt system recording. Unavailable battery
measurements are stored as SQL NULL; alerts preserve their latches on missing
readings, and estimated cycles do not integrate across missing energy readings.
Historical-store read/write failures are logged without interrupting a healthy
flight recorder; its cycle baseline still advances after each persisted sample.
Historical cycle totals snapshot the per-second integral, so minute-sampled
rows cannot bridge read failures. Older stored zeros
and health defaults cannot be retroactively distinguished from measurements.
ac_online records external power independently of battery charging status;
missing adapter attributes remain NULL. A plugged-in battery may discharge.
battery_health preserves the kernel battery fault status (for example Good
or Overheat). It is distinct from capacity-based health_pct; unsupported
attributes remain NULL. Recording this status does not introduce new alerts.
battery_id is a JSON array of manufacturer, model and serial number. Missing,
empty, zero-only and common placeholder serials, or unreadable manufacturer/model
attributes, leave identity unknown; capacity changes never imply replacement. Each identifiable pack keeps its own observed
cycle total in battery_cycles, including after debug rows are pruned. These
are observed cycles since monitoring began, not lifetime BMS cycles. Unknown
packs cannot be distinguished reliably; identity transitions break integration.
The first identifiable pack starts a separate total from legacy unidentified data.
During a known pack’s identity read failure, battery_id and
estimated_cycle_count are NULL; the durable pack total remains intact.
Continuous legacy unidentified histories still estimate observed discharge.
On upgrade to the identity schema, legacy missing cycle baselines start at zero;
subsequent uncertain totals remain NULL across restarts.
Run after rebooting from the crash. By then the daemon is already recording the new boot, so the newest rows are not the crash; this finds the boot before the current one and shows how it ended. A boot that died within seconds yields fewer than 30 rows rather than borrowing from an older boot.
sqlite3 ~/.local/share/batmon/debug.db "
WITH previous_boot AS (
SELECT boot_id FROM samples
WHERE boot_id IS NOT '$(cat /proc/sys/kernel/random/boot_id)'
ORDER BY id DESC
LIMIT 1
)
SELECT ts, power_w, voltage_v, cpu_freq_mhz, cpu_temp_c, gpu_power_w, cpu_pct, iowait_pct, procs_blocked, psi_io_full, psi_mem_full, top_processes
FROM samples
JOIN previous_boot USING (boot_id)
ORDER BY id DESC
LIMIT 30;"sqlite3 ~/.local/share/batmon/battery.db "
SELECT ts, charge_pct, health_pct, cycle_count, estimated_cycle_count, energy_full_wh, energy_design_wh
FROM samples
ORDER BY id DESC
LIMIT 10;"sqlite3 ~/.local/share/batmon/debug.db "
SELECT ts, power_w, cpu_temp_c, top_processes
FROM samples
WHERE power_w > 30.0
ORDER BY id DESC
LIMIT 5;"batmon features a stateful alerting engine with deadband hysteresis, debouncing, and priority escalation to prevent notification storms from flapping sensors:
-
High Battery Temp Warning: Alert when battery temp
$\ge 45^\circ\text{C}$ (Critical at$50^\circ\text{C}$ with contextual cooling advice; re-arms below$42^\circ\text{C}$ /$47^\circ\text{C}$ ). -
Charging While Hot (Heat-Soak): Alert when charging while CPU
$\ge 85^\circ\text{C}$ (re-arms below$80^\circ\text{C}$ ). -
Charge Limits: Reminders to unplug at
$\ge 80%$ (re-arms below$75%$ ) and plug in at$\le 20%$ (Critical at$\le 10%$ suppresses normal low alert; re-arms above$25%$ ). - Over-Voltage Charging: Alert when charging voltage exceeds 15% above design voltage (re-arms at or below 10% above design voltage).
-
Battery Health Degradation: Warning when full capacity drops below
$80%$ of factory design (re-arms above$82%$ ).
Re-fired alerts replace their previous notification rather than stacking beside it, so a flapping sensor cannot bury the desktop even if it defeats the deadband.
Note: The current alert rules focus on battery protection, because by the time the voltage or power really drops, the system will be shutting down anyway. The idea is to prevent these issues from happening in the first place, not to detect them after the fact. The flight recorder is there to capture the data in case something does happen.
- Linux with systemd (Fedora, Ubuntu, Debian, Arch, etc.)
-
Rust toolchain (
$\ge 1.87$ ) (optional) — only required if building from source. The pre-compiled release binary is fully static with SQLite compiled in, linking nothing beyond standard system interfaces. -
UPower (optional) — supplies smoothed runtime estimates. Without it,
batmonfalls back to dividing remaining energy by present draw. - A notification server (optional) — any desktop provides one. Without it, alerts are still written to the journal.
-
sqlite3CLI (optional, for querying databases):sudo dnf install sqlite
If you prefer compiling locally rather than downloading pre-compiled binaries:
git clone https://github.com/InvictusNavarchus/batmon.git
cd batmon
./install.sh --buildUpgrading from a Bun-based installation is handled automatically: the superseded TypeScript sources are removed and your existing databases are kept and continued.
# Check service status
systemctl --user status batmon.service
# View live logs
journalctl --user -u batmon.service -f
# Run a one-off diagnostic sample
batmon --oneshot
# Raise log verbosity (standard tracing filter syntax)
BATMON_LOG=batmon=debug batmon# Run the full test suite (unit, property, and end-to-end binary tests)
cargo test
# Lints, denied in the pre-commit hook
cargo clippy --all-targets -- -D warnings
# Formatting
cargo fmt --allTests are unit tests in the same crate, because most of them exercise private items — a sibling module cannot see them, so the test module has to stay a descendant of the module it tests.
A test module of 100 lines or more moves into its own file, declared as a child module:
// src/telemetry/thermal.rs
#[cfg(test)]
mod tests; // -> src/telemetry/thermal/tests.rssuper still resolves to the parent module, so imports are unaffected and
test paths are unchanged. Below 100 lines the tests stay inline at the bottom
of the file, where colocation is still worth more than the indirection; four
modules are in that category today, and all four are under 200 lines total.
When adding a test file, check that the mod declaration exists. A test
file that is never declared compiles, runs nothing, and leaves the suite green
at a lower count — the one failure mode here that no assertion catches.
The test modules were extracted in refactor/extract-test-modules, which moved
4,884 lines without changing any test logic. Plain git blame on those files
reports only the move. To see who actually wrote a line:
git blame -w -C -C -C src/telemetry/thermal/tests.rs-w is the part that matters: extraction dedented every line by one level, and
without it the copy detection in -C finds nothing. A .git-blame-ignore-revs
file does not help here — the content landed in new files, so ignoring the
extraction commit leaves blame with nothing earlier to attribute to.
batmon-uninstallNo checkout is needed. If the binary directory is not on your PATH, run
bash ~/.local/share/batmon/uninstall.sh. Existing databases are preserved.
For older installations without a stored uninstaller, run ./uninstall.sh from
a source checkout; supply BIN_DIR if it used a custom location.
- Battery cell-level data (individual cell voltages, internal impedance, BMS balancing status) is not available through the Linux
power_supplysysfs interface and cannot be collected. - VRM rail voltages and transient events below ~1 s are not exposed by the kernel on most laptop hardware. The 1-second flight recorder can catch sustained voltage sag but not microsecond-scale transients.
- The
battery_temp_csensor is absent on many laptops. When unavailable, battery thermal protection relies on ambient correlation with CPU/GPU temperatures. mem_pctandload1are recorded for forensic completeness but are rarely primary indicators of hardware failure.