Skip to content
Under the hood

How Ampere works.

Ampere writes to low-level charge controls, so you deserve the full story: which hardware keys it touches, how root access is contained, and how every change is guaranteed to be undone — even if the app dies mid-flight. It’s all open source, so you can check every claim on this page against the code.

At a glance
  • Charging is paused and resumed by writing two SMC keys via IOKit — nothing is patched, injected, or persistent.
  • Root access is confined to one tiny helper binary, pinned to its SHA-256 digest in sudoers.
  • A detached watchdog daemon undoes everything within seconds if Ampere is killed or crashes.
  • Settings and in-progress charges survive restarts; quitting always restores macOS defaults.
  • In-app updates are verified against the Homebrew cask’s SHA-256 and Apple’s code signature.

Requirements & scope

Ampere runs on Apple Silicon Macs (M1 and later) with macOS 14 Sonoma or newer. Intel Macs expose different charge-control hardware and aren’t supported.

Reading battery statistics requires no special privileges. Only charge control — pausing, resuming, and discharging — needs admin rights, because writing to the SMC requires root. If you never grant admin access, Ampere still works as a monitoring app.

The SMC keys

All charge control comes down to two keys in the System Management Controller:

KeyTypePurposeValues
CHTE ui32 (4 bytes) Charge inhibit 0x01 00 00 00 paused · 0x00 00 00 00 allowed
CHIE hex_ (1 byte) Active discharge 0x08 discharging · 0x00 normal

Both keys are written through IOKit’s IOConnectCallStructMethod (selector 2) against the AppleSMCKeysEndpoint service, falling back to AppleSMC. Writing requires root; reading doesn’t. There are no kernel extensions, no daemons that outlive the app, and no system files modified beyond the two helper files described next.

The privileged helper

Ampere never runs as root itself. SMC writes go through SMCWriter — a minimal helper binary with no AppKit or SwiftUI dependencies, installed at /usr/local/bin/az-ampere-smc and owned by root.

  1. On first launch, Ampere installs the helper and a sudoers rule at /etc/sudoers.d/az-ampere. This is the one admin password prompt.
  2. The sudoers rule is pinned to the helper’s SHA-256 digest. sudo itself refuses to run a swapped or tampered binary at that path — replacing the file breaks the rule instead of inheriting its privileges.
  3. On every launch, the helper is re-verified. Unchanged helper → no prompt. After an update ships a new helper, you’re asked exactly once to install it.

Each SMC operation then runs as a short-lived sudo az-ampere-smc <command> process that writes its key and exits — root privileges exist for milliseconds at a time.

Process architecture

The GUI app spawns one-shot root commands for each operation, plus a long-lived watchdog as the safety net:

Ampere (GUI, user)
  |
  |-- sudo SMCWriter inhibit                 (one-shot, root)
  |     |-- SMC write CHTE = 0x01            pause charging
  |     \-- exit(0)
  |
  |-- sudo SMCWriter allow                   (one-shot, root)
  |     |-- SMC write CHTE = 0x00            allow charging
  |     \-- exit(0)
  |
  |-- sudo SMCWriter discharge:<app-pid>     (one-shot, root)
  |     |-- pmset -a sleep 0 disablesleep 1  disable sleep (clamshell fix)
  |     |-- SMC write CHIE = 0x08            enable active discharge
  |     |-- posix_spawn SMCWriter watchdog   spawn safety net daemon
  |     \-- exit(0)
  |
  |-- sudo SMCWriter nodischarge             (one-shot, root)
  |     |-- pkill watchdog                   kill existing watchdog
  |     |-- SMC write CHIE = 0x00            disable active discharge
  |     |-- pmset restore sleep settings     only if save-sleep file exists
  |     \-- exit(0)
  |
  \-- SMCWriter watchdog:<app-pid>           (daemon, root, detached)
        |-- sleep(2) loop                    poll every 2 seconds
        |-- if app PID gone: restore CHTE,
        |   CHIE, and sleep settings
        \-- exit(0)                          clean exit

Two implementation details are load-bearing:

  • The watchdog is spawned with posix_spawn, never fork() — the Swift/Objective-C runtime is not fork-safe, and forked children crash when touching Foundation or IOKit.
  • No cleanup lives in signal handlers. SIGTERM/SIGHUP handlers can only call async-signal-safe C functions — not Swift, Foundation, or IOKit — so cleanup is delegated to the watchdog process instead.

The watchdog daemon

A root watchdog runs the entire time Ampere is active: spawned at launch, re-spawned after discharge stops, and by the discharge command itself. It is fully detached — independent of the app and of the sudo process chain.

Every 2 seconds it checks whether the app’s PID is still alive. If Ampere dies for any reason — crash, kill -9, Ctrl-C — the watchdog cleans up within seconds:

  • clears CHTE → charging allowed again;
  • clears CHIE → discharge stopped;
  • restores sleep settings via pmset — but only if the saved-sleep marker exists (i.e. discharge had actually overridden them);
  • exits cleanly, leaving no orphaned processes or files.

On the next launch, Ampere kills any orphaned watchdogs from a previous crash, clears stale SMC state, and spawns a fresh one before doing anything else.

Auto charge logic

With auto charge management on, Ampere holds your battery between a lower and an upper bound. What it writes, and when:

Mode / stateCHTE (inhibit)CHIE (discharge)
Manual — paused0x010x00
Manual — resumed0x000x00
Auto — below lower bound0x00 (charging to upper)0x00
Auto — between bounds0x01, or 0x00 during “Charge to Upper Bound”0x00
Auto — above upper, discharge off0x010x00
Auto — above upper, discharge on0x010x08

Micro-charge prevention

Between the bounds, charging is inhibited by default — including right after a restart or crash. Exactly two things start a charge:

  1. the battery drops below the lower bound — automatic, and it charges all the way to the upper bound;
  2. you toggle Charge to Upper Bound — explicit; it charges to the upper bound, then resets itself and re-inhibits.

The result: cycles always run full, lower → upper, instead of fragmenting into shallow top-ups every time you plug in.

Discharge & the clamshell problem

“Discharge to Upper Bound” actively drains the battery while plugged in, by setting CHIE = 0x08. That write has a spicy side effect: it triggers a USB-C Power Delivery renegotiation, which briefly drops the display signal on Thunderbolt ports. In clamshell mode that brief drop cascades:

  1. the display disconnects for a moment;
  2. macOS sees “no displays” and starts clamshell sleep;
  3. external monitors stay black until you open the lid.

The approaches that didn’t survive contact with reality:

AttemptResult
caffeinate -dis / power assertionsAssertions don’t prevent PD-triggered clamshell sleep
IOPMAssertionCreateWithName (root and GUI)Same — insufficient for hardware-level PD events
Setting IOPMrootDomain propertiesPermission denied on Apple Silicon
Writing CH0R instead of CHIENo blackout — but no actual discharge either
Signal handlers for cleanupSwift runtime isn’t async-signal-safe; crashed
fork() to daemonize the watchdogSwift/ObjC runtime isn’t fork-safe; crashed

The fix that works: pmset -a sleep 0 disablesleep 1 before the CHIE write, so macOS can’t sleep through the PD blip (system sleep is disabled while discharge runs — the UI shows a warning). When discharge stops, the original sleep setting is restored.

Why the marker file lives outside /tmp. The original sleep value is saved to /Library/Application Support/az-ampere/saved-sleep before being overridden. macOS wipes /tmp at boot while pmset -a overrides persist across reboots — so a crash + reboot during discharge would otherwise lose the saved value and leave sleep permanently disabled. With the persistent marker, the next launch finds it and restores your setting. (Markers written by older builds to /tmp are still honored.)

State across sleep, wake, quit, and restart

Two invariants drive the design: the app returns to its last state after any restart, and the system returns to its defaults after the app closes — gracefully or not.

ScenarioSleep → wakeQuit → restart
Charge to Upper Bound in progress Charging continues; the wake handler confirms the SMC is still in “allow” and takes no further action. Resumes automatically — the toggle is persisted, so an in-progress charge continues to the upper bound instead of parking at the current level.
Discharge to Upper Bound active Discharge continues (sleep is disabled during discharge; if forced by a lid close, the wake handler re-asserts the SMC state). Resumes automatically — stale SMC state is cleared on launch, then the first refresh detects the battery is still above the upper bound and restarts discharge.
Quit / crash All SMC overrides and sleep changes are reverted on the way down; if the process was killed, the watchdog does it within seconds. Bounds and toggles are restored when the app relaunches.

Health checks

Ampere doesn’t assume its writes stuck. Every poll cycle (after a 3-cycle warm-up that lets launch cleanup settle), it reads CHTE and CHIE back and compares them with what the current mode expects. A mismatch shows a warning in the panel and turns the menu-bar icon orange.

Panel closedPanel open
Poll interval60s10s
First health check~3 min after launch~30s after opening*
Health check intervalevery 60severy 10s

* The cycle counter is global, so if the app has been running a while, the first check after opening the panel comes sooner. Health checks also run immediately after revoking or re-granting admin access.

Verified updates

Ampere checks the Homebrew cask for a new version 5 minutes after launch and about once a day. Clicking Update in the panel:

  1. downloads the release DMG from GitHub Releases;
  2. verifies the download three ways: the SHA-256 must match the Homebrew cask, the code signature must be intact, and the Team ID must match the running app;
  3. swaps the new bundle into place with two atomic renames and relaunches.

The relaunch takes the normal quit → restart path, so SMC state is restored on the way down and your settings resume in the new version. If any step fails, nothing is changed and brew upgrade --cask ampere always works as a fallback.

Registration

A license key is registered with an email address and bound to the Mac’s device serial. After that, the app re-verifies about once a day by sending only the email and serial — the key itself is never transmitted again.

  • Network failures never clear a registration. Only a definitive “invalid” answer from the license server does — offline Macs stay registered.
  • Moving is built in: deregister from the panel, or just register on the new Mac with the same email and key — the seat moves over.
  • No analytics, no tracking. The daily verify and the update check are the app’s only network calls.

Clean uninstall

Charge control installs exactly three privileged artifacts, and the app can remove all of them itself — click Revoke Admin Access in the panel (one password prompt):

  • /usr/local/bin/az-ampere-smc — the helper binary;
  • /etc/sudoers.d/az-ampere — the pinned sudoers rule;
  • /Library/Application Support/az-ampere/ — the saved-sleep marker directory (only exists if discharge was ever used).

Or from the command line:

sudo rm -f /usr/local/bin/az-ampere-smc
sudo rm -f /etc/sudoers.d/az-ampere
sudo rm -rf '/Library/Application Support/az-ampere'

Then remove the app with brew uninstall ampere or by deleting Ampere.app from Applications. Preferences live in ~/Library/Preferences/com.az-code-lab.ampere.plist.

Build from source

Ampere is MIT-licensed. The whole app — including SMCWriter and the watchdog — is in one repository:

git clone https://github.com/az-code-lab/ampere
cd ampere
swift build -c debug
.build/debug/Ampere

If this page made claims you want to check, read the source — that’s what it’s there for.

Convinced by the engineering?