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.
  • An in-progress charge never overshoots the upper bound while the Mac sleeps — it pauses before sleep, or holds the Mac awake until it completes.
  • 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)
  |
  |-- sudo SMCWriter hold-sleep              (one-shot, root)
  |     |-- save pmset markers               same markers as discharge
  |     |-- pmset -a sleep 0 disablesleep 1  keep Mac awake mid-charge
  |     \-- exit(0)                          (display sleep untouched)
  |
  |-- sudo SMCWriter release-sleep-hold      (one-shot, root)
  |     |-- 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 or the mid-charge sleep hold 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 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
Auto — “Charge to Full” active0x00 until full0x00 (turned off on activation)

Micro-charge prevention

Between the bounds, charging is inhibited by default — including right after a restart or crash. Exactly three 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;
  3. you toggle Charge to Full — explicit; a one-shot charge to 100% that leaves the bounds untouched, then resets itself the same way.

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

Charge to Full

For days when you need a full battery, “Charge to Full” charges to 100% without touching your bounds. While it runs, the upper-bound dragger is hidden and the slider’s target range extends to 100%; when the charge completes, the toggle clears itself and normal management resumes exactly as configured. Activating it also turns Discharge to Upper Bound off (the setting itself, not a temporary suspension): draining a battery you just asked to fill is never the goal, so re-enable discharge when you actually want it again.

“Full” means the displayed 100% or the battery’s own FullyCharged flag, whichever comes first. Worn batteries can terminate charging below a displayed 100%; without the BMS signal, the charge state would stay open forever, trickle-charging at the top. The same rule covers a configured upper bound of 100. A glitched full signal fails safe: charging stops early and can be restarted with one click, while the opposite failure would not be self-limiting.

The one-shot is tied to its AC session: unplugging cancels it (below the lower bound it downgrades to “Charge to Upper Bound”, matching what the below-lower rule would decide there anyway), while an app restart mid-charge resumes it.

Sleep & mid-charge protection

The upper bound is enforced in software: a poll has to observe the crossing and write the inhibit key. While the Mac sleeps, no polls run — but the SMC keeps charging whenever CHTE allows it. So a charge left running at sleep time used to sail straight past the bound: close the lid charging to 50%, come back to 65%. Since 0.0.56, two mechanisms close that gap.

Between the bounds: pause, sleep, resume

macOS announces sleep to apps a few seconds before it happens; an app can react to the announcement, but not refuse it. If a charge is running at or above the lower bound, Ampere writes the inhibit inside that grace window and lets the Mac sleep. On wake it re-asserts the expected state — charging allowed — and the charge finishes at the upper bound. The net effect: the charge pauses while the lid is closed and completes after you return.

Below the lower bound: hold the Mac awake

A charge that starts below the lower bound takes the opposite approach, because neither simple option works there: pausing at 20% would leave the battery below your range all night, and charging through sleep would overshoot. Instead, the moment such a charge starts, the helper disables system sleep (pmset -a sleep 0 disablesleep 1 — the same override discharge uses, without the display-sleep part, so the screen still sleeps normally). The override is armed ahead of time precisely because the sleep announcement can’t be refused: a lid close during the charge is simply absorbed, and the Mac stays awake, charging.

At the upper bound the order of operations is load-bearing: the inhibit lands first, sleep is restored second — so a closed-lid Mac falls asleep already parked at the bound, never the other way around. Once the battery climbs back above the lower bound mid-charge, the hold persists only while the lid stays closed (evidence that a sleep attempt was absorbed); with the lid open it releases, and the pre-sleep pause covers any later sleep. While the hold is active the panel shows the same orange “sleep is disabled” warning as discharge.

The hold is released by reaching the upper bound, unplugging, disabling auto charge, or quitting — and it shares the persistent pmset markers with discharge, so the watchdog restores your sleep settings even after a crash. The hold intent itself is persisted too: a restart mid-charge (an in-app update, say, with the lid closed) re-arms it. It never engages on battery power. Charge to Full is exempt — its ceiling is 100%, so a sleeping Mac can’t overshoot it, and charging overnight is the point of that feature.

Sharing the keep-awake bit

disablesleep writes a single system-wide flag that every keep-awake tool shares (Lidless, for one), and the flag has no owner: the last writer wins. So Ampere reads its value before raising it and puts that value back on release, instead of forcing sleep back on and cancelling a hold that was already there, which would sleep a lid-closed Mac out from under the other tool. The reverse case is covered too: while Ampere’s hold is engaged, the flag is re-checked every poll and re-applied if another tool’s auto-off timer, quit, or crash watchdog cleared it. And a captured 1 is honored only if the flag still reads 1 when the hold releases, so a tool that let go mid-hold leaves you with a Mac that sleeps normally, never one that can’t sleep at all.

Edge cases. Plugging in a Mac that is already asleep charges it with no app awake to manage the bounds; that is corrected as soon as Ampere runs again, at wake or relaunch. A firmware flip of CHTE during sleep no longer waits for the next state change either: the health check writes the expected value back. One gap is left by design, though: only the charge hold re-verifies the shared sleep flag, so an external clear during an active discharge can still let a lid-closed Mac sleep, until the wake handler re-asserts the discharge state.

Keep Awake, the manual switch

Everything above is automatic and tied to a charge. Keep Awake, added in 0.0.58, is the manual counterpart: a switch on the panel that keeps the Mac awake while a power adapter is connected, for a duration you pick or until you turn it off. It deliberately does not reuse the machinery above. It holds an ordinary IOPMAssertionCreateWithName power assertion, the same mechanism behind caffeinate, which buys three things the pmset override cannot: no admin rights are involved, the assertion belongs to the process so it can never trample another app’s hold, and the kernel drops it the instant Ampere exits, crash included. No system setting is changed, so there is nothing to restore and nothing for the watchdog to clean up.

The trade-off is the one the table below records: an assertion cannot override a lid close. Keep Awake prevents idle sleep only, which is precisely why the charge hold and discharge still use the clamshell-proof pmset override. It is AC-only by design, so on battery the assertion is released and the Mac sleeps normally; a switch left on cannot flatten the battery in a bag. The duration is a wall-clock deadline rather than a stopwatch: it survives an app restart, keeps counting while you are on battery, and switches itself off when it expires.

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, along with the display-sleep value and the shared disablesleep flag (see sharing the keep-awake bit). macOS wipes /tmp at boot while pmset -a overrides persist across reboots — so a crash + reboot during discharge would otherwise lose the saved values and leave sleep permanently disabled. With the persistent markers, the next launch finds them and restores your settings. (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 Pauses just before sleep and resumes on wake, finishing at the upper bound. A charge that began below the lower bound instead keeps the Mac awake until the bound is reached — see sleep & mid-charge protection. Resumes automatically — the toggle is persisted, so an in-progress charge continues to the upper bound instead of parking at the current level. The sleep hold re-arms too, if one was active.
Charge to Full in progress Charging continues; the wake handler keeps the SMC in “allow” until the battery is full. Resumes automatically, even past the configured upper bound. The one-shot is tied to its AC session: a relaunch that finds the Mac on battery clears it instead.
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. Health checks only run while a power adapter is connected — on battery the app isn’t managing charging, so there is no expected state to verify.

A CHTE mismatch is repaired, not just reported. Firmware or a USB‑C Power Delivery renegotiation can silently reset the key, and the state machine that normally writes it is edge-triggered: it never re-issues a write for a state it already believes is in force. A drifted key would therefore stay drifted until the next sleep and wake, charging past your upper bound or refusing to finish a charge in the meantime. The health check already computes the expected value every tick, so it writes it back and re-verifies on the next cycle. The first repair is silent (a transient reset that the very next check finds fixed isn’t worth an alarm); only a mismatch that survives a repair shows the warning in the panel and turns the menu-bar icon orange, and at that point the helper itself is suspect, which is what the warning’s advice to revoke and re-grant admin addresses. CHIE mismatches are reported but never auto-repaired: starting or stopping a discharge belongs to the state machine, with its sleep override and watchdog attached.

Polls also fire on power-source change notifications from IOKit, not just the timer — plugging in or unplugging is handled within about a second, so a short unplug between ticks still cancels Charge to Full and stops an active discharge.

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 admin access, and after re-granting it from inside the app; after a relaunch, the first check follows the warm-up schedule above.

Verified updates

Ampere checks the Homebrew cask for a new version 5 minutes after launch and about once a day. A pending update shows as a small blue badge dot on the menu bar icon (hover for the version). Clicking Update in the panel:

  1. downloads the release DMG from GitHub Releases (progress in the panel footer, with a cancel button);
  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 one atomic exchange — at no instant is the app missing from disk (volumes without swap support fall back to two 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 — open Settings in the panel footer and click Revoke on the Admin Access row (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 or the mid-charge sleep hold 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?