Skip to content

Repository files navigation

BatteryControl

BatteryControl app icon

I wanted my MacBook Air to stop sitting at 100% all day, and the existing charge-limit tools kept failing on my Macbook. So I wrote my own. BatteryControl lets you set a charge limit on Apple Silicon Macs and actually holds it — while the app is closed, the menu-bar icon is hidden, the Mac is asleep, or it just woke up.

It's a normal macOS app plus a batterycontrol command. A small privileged daemon does the hardware work; the app and CLI just talk to it.

Status: no longer actively developed

Development on BatteryControl has stopped. 1.3.4 is the same source code as 1.3.3 — this notice and the version numbers are the only changes — and it's the last planned release. The repo stays up and the project isn't going anywhere: the code, the docs, and the compatibility evidence remain available. If someone needs support on it, that's the kind of thing that could bring development back — but until that happens, expect no new releases, fixes, or features, and don't be surprised if issues and pull requests sit unanswered.

Why: I wrote BatteryControl because macOS had no way to hold a charge below 80%. macOS 26 now ships Apple's own Charge Limit (System Settings → Battery, 80–100%), my Mac is on it, and it covers what I needed — so I uninstalled the app and moved on.

What still holds:

  • Security reports still go through private vulnerability reporting and are read — but with no planned releases, a fix can't be promised. Read the compatibility section and its caveats before trusting the last build with your battery.
  • On macOS 26.4+, Apple's built-in Charge Limit is the supported way to cap at 80–100%. Below 80%, this last release still works as documented on the hardware described in the compatibility section — with the same one-machine validation caveat it always had.
  • It's MIT, so forks are welcome. If you take it further, keep the rules in CONTRIBUTING.md: runtime capability detection, readback verification on every write, and honest state reporting. They're what keep an SMC-writing tool from lying to you.

One honesty note on this build: the binaries were recompiled with Xcode 26.3 on macOS 26 and the full test suite passes, but the app is no longer installed on my machine, so nothing here was re-validated against real battery hardware. What was physically tested remains exactly what the compatibility section says: one M3 MacBook Air on macOS 15.8.

What it does

  • Charge limit. Pick a preset (50/60/70/75/80/85/90/95/100%) or a custom value. Charging stops at your limit and resumes at a lower limit (default: 2 points below). No micro-cycling. Limits below 80% are the point — Apple's own built-in Charge Limit only spans 80–100%.
  • Force discharge. Cuts adapter input so the Mac runs on battery down to a target while plugged in. Stops at the target, at the 20% floor, on unplug, and before sleep.
  • Charge to 100%. Temporary daemon-owned override. It ends when the target is reached, when cancelled, or after a selected 1-hour/2-hour lifetime; the saved policy is restored.
  • Calibration. Guided full-cycle gauge refresh. This retrains the gauge's estimate; it does not repair the battery.
  • Battery health. Charge state, cycle count, temperature, capacity values, and a clearly labeled capacity-ratio estimate when the underlying telemetry is available.
  • Daily presets. Daily (80/70), Battery Saver (70/60), Chronically Plugged In (50/48), Full Charge, and Custom. The 50% option is useful for a Mac that stays connected to power: spending less time at high charge and voltage can help reduce long-term battery wear. Presets use the same daemon path as manual limits and are not shown as active before verification.
  • Setup guide. First launch shows a real in-app setup guide: what the helper does, the three install steps, and live helper status with an Install button. No digging through Settings.
  • Local history and support bundle. A bounded, local-only event list and a sanitized JSON bundle for troubleshooting. Nothing is uploaded.
  • Diagnostics. What backend your Mac uses, whether control is verified right now, and an anonymized hardware report you can submit to the compatibility database.

Features

Feature What it does Caveats
Fixed Charge Limit "Set my Mac to 80%" — presets (50–100%), custom, configurable lower limit Charging can overshoot by a fraction of a percent; the gauge refreshes about once a minute
Lower limit Charging resumes only after the battery falls to it (hysteresis) —
Fixed target Holds "about 80%" with a small internal band (±3%) The dashboard shows the real state, not a fake exact number
Force discharge Runs on battery down to a target on AC Slow (~1% per 5–10 min idle); auto-stops at target/floor/unplug/sleep
Below-floor discharge (opt-in) Red "Remove safety floor" switch allows draining below 20% Wears the battery out faster. Explicit confirmation required; the daemon checks the consent itself. The below-floor warning appears only when the target or the battery is actually below 20%
Charge to 100% Daemon-owned override until target, cancellation, or 1/2-hour deadline Hardware state is still verified; expired sessions restore the saved policy
Calibration Discharge to 20% → charge to 100% → hold 3 hours → drop to your limit, with safety aborts Re-trains the gauge estimate; does not fix physical battery health
Presets Daily (80/70), Battery Saver (70/60), Chronically Plugged In (50/48), Full Charge, Custom Shown as active only after the daemon verifies hardware state

Supported Macs

  • Apple Silicon M1, M2, M3, M4, M5 (Pro/Max/Ultra included)
  • macOS 14 Sonoma, 15 Sequoia, 26 Tahoe, or 27

Intel Macs, macOS 13 or earlier, and macOS 28 or newer are out of scope. The app checks at startup and refuses to touch battery hardware outside that range — it shows this instead:

BatteryControl supports Apple Silicon Macs from M1 through M5 running macOS 14 Sonoma, macOS 15 Sequoia, macOS 26 Tahoe, or macOS 27.

Chip generation is admission, not capability — an M5 Mac is treated exactly like any other machine: runtime probing decides what it can do, and nothing is claimed as verified without evidence. (macOS 14 support may be refined as machines become available to test on.)

One important distinction: a supported OS version gets you in the door — nothing more. What BatteryControl can actually control is decided at runtime by probing which SMC mechanisms your firmware exposes, and every write is read back and verified. A Mac on macOS 27 with an unknown key signature gets read-only diagnostics, exactly like a Mac on macOS 15 with the same signature. The OS never grants capability.

On macOS 26.4 and later, Apple ships its own Charge Limit (80–100%) in System Settings. BatteryControl never writes that setting. When no BatteryControl limit is active, the app and CLI tell you who is actually managing charging; when you set a BatteryControl limit, the daemon verifies it against the real battery state, and it's the authoritative controller. Turn BatteryControl off and macOS default charging (including Apple's limit, if you enabled it) applies again.

Installation

Two editions, same codebase, same daemon:

BatteryControl (GUI + CLI) BatteryControl CLI
Best for Most people Scripting, SSH, headless Macs
App + menu bar ✓ —
batterycontrol command ✓ ✓
Privileged daemon installed by the app's setup flow uses the daemon if present, read-only status otherwise
Package BatteryControl-<version>.pkg BatteryControlCLI-<version>.pkg

To install the GUI edition:

  1. Grab BatteryControl-1.3.4.pkg from the latest release (check it against SHA256SUMS if you want).
  2. Run the installer. It puts the app in /Applications and the CLI in /usr/local/bin.
  3. Launch the app. Click Install Helper and type your administrator password when macOS asks. That's one SMAppService registration — one password prompt, and the password is never stored. No sudoers entries.
  4. The setup banner goes away once the daemon is up. batterycontrol status should say Connected.
  5. Set a limit and watch the dashboard until it says the limit is active and hardware-verified.

The packages aren't signed installers, so Gatekeeper may ask you to approve the app under System Settings → Privacy & Security on first launch. See Signing status.

How the daemon works

BatteryControl.app (SwiftUI, window + optional menu-bar icon)
        │  XPC (secure-coded, validated objects)
        ▼
com.batterycontrol.daemon  (root LaunchDaemon, KeepAlive)
        │  ChargingBackend protocol
        ▼
Backends: Firmware-managed limit (bfF0/bfD0/bfE0 + CHIE/CH0J/CH0I discharge)
        │  SMC inhibit (CH0B/CH0C + CH0I)  │  CHWA (probed, off by default)
        │  Power assertions (unverified on 15.5+)  │  Observation-only fallback
        ▼
AppleSMC (IOKit user client, root-only writes)

The daemon lives at /Library/PrivilegedHelperTools/com.batterycontrol.daemon with its launchd plist in /Library/LaunchDaemons. It runs as root and holds the policy in a root-owned JSON file, re-applying it on boot, wake, power-source change, and every 20-second tick. Kill the daemon and launchd brings it back; it re-verifies and re-applies the persisted policy on its own.

The GUI and CLI never touch hardware. XPC exposes a fixed set of named operations with validated objects — no raw SMC access. The daemon also re-validates every value it receives, and it checks who's calling (same signing team, or same bundle for ad-hoc developer builds).

Closing the app changes nothing about enforcement. The menu bar is just a window into the daemon.

GUI

  • Dashboard: battery level, power state, your limit, and whether control is verified on hardware right now. If the daemon can't verify, the app says so — it doesn't show "active" on faith.
  • Charging settings: the presets and the lower limit. Setting a limit is one click.
  • Discharge controls: with the safety floor. Below the floor needs the red "Remove safety floor" switch plus a confirmation dialog.
  • Battery Info: available health telemetry without turning missing values into fake zeros.
  • Diagnostics: detected backend, compatibility tier, verification matrix, recent daemon log, and a sanitized support-bundle export.
  • The app lives in the menu bar, not the Dock. Closing the window is fine. Settings → Show menu bar icon hides the icon if you want — that's cosmetic, the daemon keeps working. If you hide the icon, just launch BatteryControl again to get the window back.
  • Repair Helper… reinstalls the daemon if it's missing or outdated; Remove Helper… uninstalls it and hands charging back to macOS.

CLI

Same daemon, so same rules. The commands:

batterycontrol status                     # battery, power, limit, verification state
batterycontrol limit status               # current limit policy and hardware state
batterycontrol limit set 80               # Fixed Charge Limit at 80% (default hysteresis)
batterycontrol limit set 80 --resume 70   # custom lower limit
batterycontrol limit off --confirm         # hand charging back to macOS
                                           # (--confirm required while a limit
                                           #  is active, so cron/scripts can't
                                           #  silently remove it)
batterycontrol discharge status           # force-discharge session state
batterycontrol discharge start 60         # run on battery down to 60% while on AC
batterycontrol discharge stop             # end the discharge, restore adapter input
batterycontrol charge start               # charge past the limit to 100% until target
batterycontrol charge start 90            # charge past the limit to 90%
batterycontrol charge start 100 --for 1h   # daemon-owned one-hour override
batterycontrol charge stop                # end a force-charge, restore the limit
batterycontrol calibration status         # gauge-calibration session state
batterycontrol calibration start          # full calibration cycle (discharge → 100% → hold 3h → limit)
batterycontrol calibration cancel         # cancel; normal charging resumes
batterycontrol diagnostics                # full control/backend/verification report
batterycontrol compatibility              # which control mechanisms this Mac supports
batterycontrol compatibility --report     # machine-evidence JSON for the compatibility database
batterycontrol database install db.json   # install a reviewed compatibility database
batterycontrol update-check               # ask GitHub if a newer release exists
batterycontrol version                    # CLI and daemon versions
batterycontrol help                       # usage

discharge start takes --floor <pct> (default 20%) and --allow-below-floor for draining below the floor. The output warns you it wears the battery out, and the daemon enforces the consent requirement itself — a client can't skip it.

limit off behaves the same way when a limit is active: it refuses without --confirm. That keeps cron jobs, stale scripts, and other unattended callers from silently removing a deliberate limit. With no limit active it stays a harmless no-op that needs no flag.

The menu bar also exposes 1-hour and 2-hour temporary charge overrides. The daemon stores that deadline atomically, so a restart does not extend an expired override. If the saved policy cannot be restored, BatteryControl reports the failure instead of claiming success.

Exit codes for scripting: 0 ok, 2 bad arguments, 3 daemon unavailable, 4 unsupported hardware, 5 authorization failure, 6 safety rejection, 7 write failure, 8 verification failure, 9 communication failure.

Battery health and history

The Info tab shows telemetry BatteryControl can actually read from AppleSmartBattery. A capacity ratio is calculated as full-charge capacity divided by design capacity; it is an estimate and is not Apple's private Battery Health value. Missing or malformed telemetry is shown as Unavailable, not as zero.

A small local event history records control changes, verification outcomes, recovery, and session boundaries. It is bounded, stays on this Mac, and can be cleared from Settings. The support bundle contains the compatibility report, the last daemon-confirmed status (policy, override, telemetry, verification), and this sanitized history; it does not upload anything or include raw unrelated system logs.

Safety

The short version: the daemon never trusts a write.

  • Every hardware write is read back and checked against the actual battery state (charging flag, power source, amperage) after a settle window. If the hardware doesn't report the expected state, BatteryControl doesn't pretend the change worked — it retries a few times, then reports "not verified" and deactivates the mechanism.
  • The discharge floor is checked in the UI, at the XPC boundary, and again in the daemon. Adapter cuts are released on daemon start, shutdown, sleep, and SMC failure, so a crashed session can't leave your Mac off wall power. Below-floor consent only lives as long as the discharge session.
  • Invalid policies fail toward "normal charging". If anything is ambiguous, the default is the safe state.
  • One SMAppService registration, no sudoers entries, no stored passwords, no permanent root shells.

Security

Found something that looks like a security problem? Please don't open a public issue for it — use private vulnerability reporting so it can be fixed before it's a recipe. What counts as in scope (privilege boundary, XPC validation, safety-check bypasses) is written up in SECURITY.md, along with what a useful report includes. Feature requests and regular bugs should stay public issues.

Compatibility

This is the part you should actually read.

macOS has no public API for capping charge, so every tool in this space (this one included) talks to the SMC directly. Which SMC keys exist varies by firmware — newer Apple firmware removed the old charging keys and added its own firmware-managed limit. So support is decided at runtime, by probing which keys your firmware actually exposes. Not by model number, not by macOS version.

There's a small compatibility database of hardware profiles behind that:

Tier Meaning
Verified Exercised on that exact machine and firmware build, with readback and observed enforcement
Compatible by capability Key signature matches a known family; that exact build hasn't been exercised. Every write is still verified at runtime
Untested Unknown key signature. Read-only diagnostics, no control writes
Unsupported Outside the platform gate, or no usable mechanism

Where things stand:

  • Physically tested: one machine. My M3 MacBook Air (Mac15,13), mBoot-20457.1.29, macOS 15.8 (24H23). The firmware-managed limit and CHIE discharge both work on it, with full write/readback evidence recorded in the profile.
  • Probably works: Macs whose SMC signature matches a known family — other 20xxx firmware, or older-firmware Macs with the CH0B/CH0C/CH0I keys. Those get control with per-write verification. If the hardware doesn't honor a write, the app says so instead of claiming success.
  • Doesn't claim to work: everything else, whichever OS or chip it's running. It runs, it diagnoses, it stays read-only until there's evidence. There are portability and capability tests in the suite, but those prove the software decides correctly — they are not a substitute for testing real hardware.
  • OS versions beyond 15 and M5-generation Macs are admitted but unverified. The classification logic is OS- and chip-agnostic and is tested for macOS 14/26/27 and M5, and the per-write verification is exactly the same everywhere. But no macOS 14/26/27 machine and no M5 Mac has physically run BatteryControl yet — treat those as capability-tested only until someone actually does.

You can check what your Mac got: batterycontrol compatibility, or the Diagnostics page.

The one verified profile, with the actual evidence:

Profile Hardware Firmware What was proven
apple-silicon-20xxx-firmware-limit (bfF0/bfD0/bfE0 + CHIE/CH0J) MacBook Air M3 (Mac15,13) mBoot-20457.1.29 · macOS 15.8 (24H23) Programmed 80/70; every write readback-verified; charging refused above the limit on wall power (−390 mA at 92% on AC); the SMC enforces the band by itself, across processes

For that profile: bfF0 is 0x00 inactive / 0x02 active, bfD0/bfE0 are the upper/lower percentages (ui32, little-endian — unlike normal SMC ui32 keys), written in the order deactivate → upper → lower → activate, with readback after every write and automatic deactivation if anything doesn't check out.

One verified profile doesn't mean every 20xxx firmware behaves the same. That's why unknown firmware stays read-only.

Nothing in the control path is machine-specific — no model IDs, firmware builds, or test values in the backend/engine/XPC code (there's a structural test that fails if that ever changes). Machine facts live only in the evidence database. A community database at /Library/Application Support/BatteryControl/compatibility.json (schema in Support/CompatibilityDatabase.json) can add profiles without an app update.

If your Mac reports as untested and you want to help: export the report and open an issue with the JSON. Three ways, same output:

batterycontrol compatibility --report                                    # needs the daemon running
sudo com.batterycontrol.daemon --export-compat-report ~/Desktop/report.json  # works without it

or Diagnostics → Export Compatibility Report… in the app. It contains your chip, model identifier, OS and firmware builds, and the SMC key signature. No serial numbers, UUIDs, username, or paths. Use the Compatibility report issue template — its fields match the JSON, so reports land ready to review. More in CONTRIBUTING.md.

The community database (Support/CompatibilityDatabase.json in the repo, schema version 1) can also be installed on a machine without waiting for an app release:

batterycontrol database install ~/Downloads/compatibility.json

The privileged daemon validates the file and is the only thing that can write it. A database entry broadens recognition only — your Mac's hardware is still probed and verified at runtime before any control is allowed. There's an "Install Database File…" button in Settings for the non-terminal route.

Building from source

You need Xcode 16+ on Apple Silicon.

git clone https://github.com/Ednk-1312/BatteryControl.git
cd BatteryControl
open BatteryControl.xcodeproj     # then Cmd+R

or:

xcodebuild -project BatteryControl.xcodeproj -scheme BatteryControl -destination 'platform=macOS' build

The app bundle embeds the daemon and its launchd plist at Contents/Library/LaunchDaemons/.

Testing

xcodebuild -project BatteryControl.xcodeproj -scheme BatteryControl \
    -destination 'platform=macOS' test

285 tests in Tests/BatteryCoreTests: policy engine, backend selection, verification, firmware-limit semantics, calibration, CLI parsing, XPC envelopes, the platform gate, portability, and the failure paths (wrong key width, readback mismatch, activation that doesn't stick, unknown signatures). The control logic that matters lives in BatteryCore so it can be tested without hardware.

Release artifacts

scripts/build-release.sh 1.3.4 builds everything into dist/:

File Contents
BatteryControl-1.3.4.pkg App + CLI + embedded daemon
BatteryControlCLI-1.3.4.pkg Just the CLI, no daemon
BatteryControl-1.3.4.zip The app + CLI as a zip
SHA256SUMS Hashes of all three

The script also expands the packages and checks their contents (the CLI package must not contain a daemon, and it fails the build if it does).

Signing status

The binaries are signed with an Apple Development certificate when one is in the keychain (the CLI falls back to ad-hoc). The .pkg installers are unsigned and nothing is notarized — that needs a paid Apple Developer account and Developer ID certificates, which this project doesn't have yet. I'm not going to fake it. Practical consequence: Gatekeeper may ask you to approve the app on first install, and SHA256SUMS is there so you can check what you downloaded.

Official releases only

The MIT license means forks and reuploads are allowed, and some will exist. If you got an installer from anywhere other than the releases page of this repository, it isn't mine and I can't vouch for what's in it — that includes "mirror" download sites and anything bundled with other software. The official artifacts are the ones attached to releases in this repo, and each release includes a SHA256SUMS file; comparing the hash of what you downloaded takes ten seconds (shasum -a 256 <file>).

Uninstalling

Use Settings → Uninstall BatteryControl…. BatteryControl first asks the daemon to restore normal charging and verify that state. It then removes the privileged helper, LaunchDaemon configuration, root-owned policy/compatibility state, the CLI, and the app. It does not claim success until the helper binary, the plist, and the loaded launch job are all confirmed gone.

You get two data choices:

  • Remove BatteryControl and its local data removes the app's settings and local event history.
  • Remove BatteryControl but keep settings/history preserves user-owned preferences and history; it never preserves credentials, authorization state, or temporary privileged policy.

The CLI-only installation supports the same daemon-owned cleanup with an explicit confirmation:

batterycontrol uninstall --confirm
batterycontrol uninstall --confirm --remove-data

The CLI command removes the privileged daemon, launch configuration, and CLI. If the GUI edition is also installed, use the GUI uninstall action to remove the app bundle too. Without --confirm, the CLI refuses to do anything.

If safe charging restoration or privileged removal cannot be verified, BatteryControl stops and explains what remains rather than deleting files blindly. After successful removal, macOS and the hardware resume their normal charging management. No administrator password is stored and no sudoers entry is created.

Troubleshooting

  • CLI says daemon unavailable (exit 3). The helper isn't installed or isn't running. Repair Helper in the app, or check sudo launchctl print system/com.batterycontrol.daemon.

  • Dashboard shows unavailable. The app can't reach the daemon. It won't claim anything is enforced while it can't verify — that's on purpose. Fix the daemon and the dashboard catches up.

  • App says it was "updated while it was running" (blue banner). The app was upgraded in place while it was open, so the daemon refuses its connections until it's relaunched — one click on Reopen fixes it. This is the signature check doing its job, not a bug.

  • "Pending verification" takes a minute or two. Real hardware doesn't switch instantly: when the firmware pauses an active charge, the current tapers over up to ~2 minutes before macOS reports the change, and charging up takes a similar moment to ramp. The status stays honest meanwhile — it does not show "Verified" until the battery state actually confirms the change.

  • A limit is set but "hardware verified" is missing. The daemon couldn't confirm the hardware state (common right after wake). It retries on its tick. If it doesn't clear, look at batterycontrol diagnostics.

  • Limit doesn't seem to hold. Check batterycontrol compatibility first. On untested firmware it stays read-only by design. On known firmware, check the hardware directly: sudo com.batterycontrol.daemon --read-firmware-limit.

  • Mac won't charge after a crash or discharge. batterycontrol discharge stop, or reboot. BatteryControl releases adapter cuts on daemon start and shutdown, so this should be rare. sudo com.batterycontrol.daemon --disable-firmware-limit turns off a firmware limit.

  • Logs. /var/log/batterycontrol-daemon.log (empty is good), or Diagnostics → Recent helper log, or log show --predicate 'subsystem == "com.batterycontrol.daemon"' --last 1h.

  • Check charging yourself:

    ioreg -rn AppleSmartBattery | grep -i -e IsCharging -e ExternalConnected

    With a limit set, battery above it, on AC: IsCharging should be No.

For poking at hardware directly (root required):

sudo com.batterycontrol.daemon --diag-smc                # which control families this firmware exposes
sudo com.batterycontrol.daemon --read-firmware-limit     # bfF0/bfD0/bfE0 state (read-only)
sudo com.batterycontrol.daemon --program-firmware-limit 80 70   # program + verify a band
sudo com.batterycontrol.daemon --disable-firmware-limit  # deactivate, restore normal charging

--program-firmware-limit runs the full safety chain and refuses to run on machines whose tier doesn't allow control writes (--experimental exists for supervised verification sessions only). --diag-smc is read-only and reports the detected family: firmwareLimit (bf* keys + adapter cut), legacy (CH0B/CH0C + CH0I), legacyTahoe (CHTE + CHIE), or none.

Known limitations

  • No public macOS API for this, so it's undocumented SMC interfaces all the way down. Apple can change them whenever. That's exactly why every operation is verified and the app degrades to "unverified" instead of guessing.
  • The CHWA backend is probed but never selected — no verified probe for it yet; the SMC inhibit mechanism covers the same ground. The old BCLM trick doesn't work on macOS 15+ at all (kernel blocks it).
  • Physically validated on one machine (mine). Everything else is capability-based with per-write verification. Compatibility reports are how this gets better.
  • Firmware limits are a band, not an exact pin. That's the firmware's design and it's what avoids micro-cycling.
  • Force discharge is slow, and it stops before sleep. That's deliberate.
  • Unsigned installers, no notarization (see above).

Contributing

Development has stopped (see Status), so reports and pull requests may sit unanswered, and there are no scheduled releases to merge anything into. The rules in CONTRIBUTING.md still matter if you fork this: capability detection at runtime only (never model/OS-based), readback verification on every write path, honest state reporting, and tests for anything that touches selection/classification/validation.

License

MIT — see LICENSE.

Attribution

Pieces of this adapt or build on MIT-licensed projects: SMCKit (the SMC user-client basics), battery-limiter (the CH0B/CH0C/CH0I semantics and the daemon/app split), smctl (verify-after-write discipline), bclm, actuallymentor/battery, and BatFi. The firmware-limit implementation is my own, written from documented behavioral research — no GPL code (the batt project) was used. Full detail in ATTRIBUTION.md.

About

Charge limit control for Apple Silicon Macs (M1–M5, macOS 14/15/26/27). SwiftUI app + CLI on top of a small privileged daemon.

Topics

Resources

Contributing

Security policy

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages