- 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 15 Sequoia or newer. Intel Macs expose different charge-control hardware and aren’t supported. macOS 27 (and the macOS 26.7 update) changed the charge-control firmware; Ampere 0.0.63 and later support it, see Firmware without CHTE.
macOS 14 is not supported: Homebrew and the in-app updater refuse it, and a copy of 0.0.65 or earlier already installed there still opens but gets no further updates (see Verified updates). macOS 15 is supported, with one caveat about charge control. Ampere needs either the CHTE key in the firmware or the macOS charge limit, and macOS 15 has only the first: the firmware that its later security updates install has no CHTE key, and the charge limit Ampere falls back on without it arrived in macOS 26.4. Ampere checks for both when it takes charge control and, finding neither, monitors the battery only and says so on the panel (see Firmware without CHTE). Ampere is developed on macOS 26 and 27.
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
On macOS 26.6 and earlier, all charge control comes down to two keys in the System Management Controller (macOS 27 removed one of them; see below):
| Key | Type | Purpose | Values |
|---|---|---|---|
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.
Firmware without CHTE (macOS 27)
The firmware that ships with macOS 27, and with the macOS 26.7 update, has no CHTE key: the SMC reports it as not found, so the inhibit write above has nothing to write to. Apple’s replacement for it is not available to third-party apps. CHIE still works.
On that firmware Ampere holds the battery through macOS’s own charge limit, the feature behind System Settings → Battery → Charge Limit. Ampere hands macOS the target it needs (the helper’s native-limit:<percent> command), and the firmware enforces it: charging stops at the target, including during sleep and across a reboot, and a battery above the target is drained down to it by the firmware itself, at about 2 A, starting when the firmware decides to (typically within a few minutes). macOS applies a new target at its next once-a-minute evaluation.
The bounds and toggles keep their meaning. The state machine decides exactly as before; only what it writes changes:
| State machine outcome | Target handed to macOS |
|---|---|
| Charge to Full | 100 |
| Charge to Upper Bound (explicit, or below the lower bound) | the upper bound |
| Above the upper bound with Discharge to Upper Bound on | the upper bound; the firmware drains to it, no CHIE, no sleep override |
| Any other paused state | the current level, held there |
| Manual mode, paused on AC | the current level |
| Manual mode, resumed | released: your own setting is back |
A hold follows the level up while the agent settles: macOS needs up to a minute to apply a target, and a percentage that ticks up meanwhile becomes the new target rather than being drained back to the old one, so a fresh hold ends a percent or two above where it began and stays there. The chase ends by itself, since charging stops the moment the limit lands; a dip under load on AC is not followed, the firmware charges it back. Off AC the next target is written ahead of time, so an armed charge starts the moment the adapter connects and a hold follows the falling level down. Sleep holds and the pre-sleep pause do not arm, because the firmware enforces the target through sleep. Before sleep, Ampere only waits for a hand-over still in progress and settles the target against the current intent, so a full charge cancelled during that hand-over cannot ride through sleep at 100%.
macOS’s calibration charge. Every few weeks while a charge limit is in use, macOS charges the battery to 100% once, to keep its state-of-charge estimate accurate. Apple documents this for the Charge Limit feature, and on this firmware that feature is what Ampere holds with, so the hold cannot prevent it. powerd decides it at a check it runs at most once every 12 hours, and asks for the charge when the last full charge is more than 21 days old (its log: Last full charge date was 21.4 days ago, try and charge to full); it then posts the Darwin notification com.apple.system.powersources.chargingtofulloverride with its state set to 1 and suspends its limit policy; PowerUIAgent keeps registering every target it is handed and logs Battery mitigations are in place, MCL will be ignored. The override is lifted by the clock, not by the battery: powerd notes the full charge at the next of its hourly alarms but re-decides only at its next check, so the limit stays ignored until about 12 hours after the charge began, later if the Mac sleeps through that alarm, however early the battery filled. Nothing ends it sooner: the request is saved to disk and re-applied at boot, and unplugging changes nothing. pmset -g battlimit lists the target as registered with no sign of the suspension, which is why the health check used to pass on it while the status line said holding. Ampere reads the notification’s state at every poll (it is public and needs no privileges) and, while it is set, the status line says Auto: overridden — macOS is charging to full to calibrate the battery (Pause overridden for a manual pause), and once the battery reports full Auto: overridden — calibration charge done, macOS lifts the override within about 12 hours; the estimate aims at full, the About panel explains the charge, and then the wait, under the mechanism line, and the health check reports suspended with the registered limit marked as ignored, repairing nothing, full battery or not. A Charge to Full is not reported as overridden, since it was going to 100% anyway. The state machine runs unchanged and the hold keeps following the level up, so the limit is at the level the battery has reached whenever macOS hands it back: the hold resumes right there, normally at 100%, exactly as after a Charge to Full, and Discharge to Upper Bound brings the battery back to the bound. The edges are logged with the level. Whether the calibration charge also overrides CHTE on firmware that has it is not known; Ampere watches the override only where the limit is the hold.
Before its first change the helper saves your original charge-limit setting (/Library/Application Support/az-ampere/saved-native-limit). Every restore path, launching, quitting, the crash watchdog, uninstalling, and the cleanup job, runs native-limit-release, which puts that setting back, so a limit of your own is switched on again with its value. A change made in System Settings while Ampere is managing is overwritten by the next target; the pre-session value is what comes back.
Which mechanism runs is decided when Ampere takes charge control, from what is present rather than from the macOS version, in this order: CHTE wherever the SMC has it (on macOS 26.4 to 26.6 both exist, and CHTE still wins, because it is immediate and exact); otherwise the macOS charge limit, but only when the private client class behind System Settings’ Charge Limit exists and answers the manual-limit calls the release path makes, and pmset -g battlimit answers, which the health check verifies against. Both checks need no admin rights and are skipped entirely on a Mac with CHTE. An SMC that does not answer the CHTE probe decides nothing: the probe is repeated, and if it stays unanswered charge control goes on hold (controls hidden, nothing written, no password asked, the status line says so) and Ampere asks again at every poll, so one failed read at launch cannot run a CHTE Mac on the macOS charge limit for the whole session. A Mac with neither, firmware that lost CHTE under a macOS that predates the charge limit, gets no charge control rather than a target nothing enforces: on such a Mac Ampere monitors only, with no helper prompt, no cleanup writes, no watchdog and nothing to restore at quit; the controls are hidden, and the status line and the About panel say why. On firmware that still has CHTE, none of this section runs and everything else on this page applies unchanged.
Also on macOS 27: the battery registry no longer carries the raw capacities or the temperature at the top level, so Health, Raw Charge, and Temperature are read from the BatteryData dictionary and the SMC’s TB0T sensor instead. The detailed battery data was not removed, only moved: it now sits on a child registry entry, AppleSmartBatteryPack, and that is where the app finds the gas gauge’s operating-hours counter (LifetimeData.TotalOperatingTime) that Battery Served is computed from. And because macOS 27 no longer treats SwiftUI content as draggable window background, a panel dragged off the menu bar is moved by a SwiftUI window-drag gesture that is live only while the panel is detached.
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 /Library/PrivilegedHelperTools/az-ampere-smc and owned by root.
- On first launch, Ampere installs the helper and a sudoers rule at
/etc/sudoers.d/az-ampere. This is the one admin password prompt. - 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.
- The install location is checked, not assumed. Before the helper is trusted, Ampere verifies that it and every directory above it are owned by root, carry no group or world write bits, grant no write access through ACLs, and involve no symlinks. The watchdog is only ever launched from that checked path.
- On every launch, the helper is re-verified, and so is the current account’s passwordless authorization. Unchanged helper, same account → no prompt. After an update ships a new helper, you’re asked exactly once to install it. The rule holds one line per account that has granted access, so another user on the same Mac is asked once and both stay authorized.
- A cleanup job removes everything once the app is gone. After your account can run the helper, Ampere registers a root launchd job over that same passwordless rule, so there is no extra prompt. The job watches the installed
Ampere.app; if the bundle has been gone for two minutes (counted from when the job first sees it missing, looking every five seconds, so abrew upgradethat replaces it within seconds never comes near the deadline) and no copy of Ampere is running from anywhere on disk (a copy deleted from under itself while running is waited for, and everything goes two minutes after it quits), it has the helper restore charging and sleep settings and remove the helper, the sudoers rule, the state directory, itself, and every account’s preferences and caches, so nothing is left behind. It appears under Ampere in System Settings › Login Items & Extensions.
Upgrading from a version that kept the helper in /usr/local/bin migrates it inside that same prompt: the replacement is verified before it is installed, the previous session’s charging and sleep state is restored, and the old helper and its watchdogs are retired.
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)
| |-- SMC write CHIE = 0x00 disable active discharge
| |-- pmset restore sleep settings only if save-sleep file exists
| |-- pkill watchdog only after successful restore
| \-- exit(0) failure returns nonzero
|
|-- sudo SMCWriter restore (quit / revoke / migration)
| |-- SMC write CHIE = 0x00 disable active discharge
| |-- SMC write CHTE = 0x00 allow charging
| |-- pmset restore sleep settings only after CHIE clears
| |-- pkill watchdog only after all restores succeed
| \-- exit(0) failure preserves recovery
|
|-- 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)
|
|-- sudo SMCWriter spawn-watchdog:<pid> (one-shot, root)
| |-- posix_spawn SMCWriter watchdog spawn safety net daemon
| \-- exit(0)
|
\-- SMCWriter watchdog:<app-pid> (daemon, root, detached)
|-- sleep(2) loop poll every 2 seconds
|-- if app PID gone:
| |-- SMC write CHIE = 0x00 stop discharge
| |-- SMC write CHTE = 0x00 allow charging
| |-- pmset restore sleep only if save-sleep file exists
| \-- exit(0) retry on failure
\-- (runs until app dies)
Two implementation details are load-bearing:
- The watchdog is spawned with
posix_spawn, neverfork()— 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/SIGHUPhandlers 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. The mid-charge sleep hold requires it, as the discharge command does: a spawn that failed at launch is retried before the first hold, and the hold waits until one is running, because the watchdog is the only thing that restores sleep if Ampere dies mid-hold. Every other charge-control write (inhibit, allow, a macOS charge-limit target) retries a failed spawn first but goes ahead without one, since whatever it sets is restored at the next launch; meanwhile the status line and the menu bar icon warn that a crash would leave it in place until then.
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
CHIE→ discharge stopped; - clears
CHTE→ charging allowed again; - 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 once every step has succeeded. If a step fails, it keeps the saved settings and tries again on the next tick rather than abandoning the only recovery path left.
On the next launch, Ampere clears stale SMC state, restores sleep settings if a marker is still outstanding, retires watchdogs from a previous crash once that cleanup has succeeded, 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 / state | CHTE (inhibit) | CHIE (discharge) |
|---|---|---|
| Manual — paused | 0x01 | 0x00 |
| Manual — resumed | 0x00 | 0x00 |
| Auto — below lower bound | 0x00 (charging to upper) | 0x00 |
| Auto — between bounds | 0x01, or 0x00 during “Charge to Upper Bound” | 0x00 |
| Auto — above upper, discharge off | 0x01 | 0x00 |
| Auto — above upper, discharge on | 0x01 | 0x08 |
| Auto — “Charge to Full” active | 0x00 until full | 0x00 (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:
- the battery drops below the lower bound — automatic, and it charges all the way to the upper bound;
- you toggle Charge to Upper Bound — explicit; it charges to the upper bound, then resets itself and re-inhibits;
- 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. Turning it off mid-charge holds the battery where it is and clears a “Charge to Upper Bound” that had been running underneath it (armed at a plug-in below the lower bound, or by its toggle, before the full charge started); below the lower bound the below-lower rule still charges to the upper bound.
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.
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: the switch is disabled on battery, and unplugging the adapter ends a running session outright, so a switch left on cannot flatten the battery in a bag, and plugging back in starts nothing until you turn it on again. A poll that cannot read the battery at all releases the assertion for that tick but keeps the session; only a Mac known to be on battery ends it. The duration is a wall-clock deadline rather than a stopwatch: it survives an app restart, and the switch turns itself off when it expires.
Keep Display On, a button with a display symbol beside the duration menu, is an option of the running session: it can only be turned on while Keep Awake is on and a power adapter is connected, and it is disabled otherwise. With it on, the session holds the display-sleep assertion instead, what caffeinate -d holds, which implies the system stays awake too: the display stays on, and the Mac does not lock on its own while the session runs. It is off by default, and turning it on shows a warning first, because it changes who can use the Mac, not just whether it sleeps: anyone at the Mac can use it until it is locked or the session ends; a screen saver that is set to start may still start and lock the screen; a closed lid, a hot corner, a manual lock, and a lock managed by an organization all work as before. After the warning the owner authenticates: Ampere asks LocalAuthentication for deviceOwnerAuthentication, the system prompt that takes Touch ID where a sensor is reachable and the account password otherwise (a Mac without a sensor, or a MacBook closed in clamshell mode), or Apple Watch where that is set up. The option turns on only when the prompt succeeds; cancelling it, or a prompt that fails, leaves the option off, with the system’s reason shown in the sheet when there is one. Every request prompts afresh, nothing is cached and nothing is stored, and because a prompt takes as long as the person takes, the session is checked again when the answer arrives: one that ended meanwhile, unplugged or expired, turns nothing on. Turning the option off asks nothing, and a restart mid-session resumes it without asking, since it is the owner’s own earlier choice. Whatever ends the session, the switch, the deadline, or unplugging, turns the option off with it, so it is never on without a session, at rest or after a restart. Switching the option mid-session swaps the held assertion, so the process holds exactly one at any time, and no lock setting is ever changed.
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:
- the display disconnects for a moment;
- macOS sees “no displays” and starts clamshell sleep;
- external monitors stay black until you open the lid.
The approaches that didn’t survive contact with reality:
| Attempt | Result |
|---|---|
caffeinate -dis / power assertions | Assertions don’t prevent PD-triggered clamshell sleep |
IOPMAssertionCreateWithName (root and GUI) | Same — insufficient for hardware-level PD events |
Setting IOPMrootDomain properties | Permission denied on Apple Silicon |
Writing CH0R instead of CHIE | No blackout — but no actual discharge either |
| Signal handlers for cleanup | Swift runtime isn’t async-signal-safe; crashed |
fork() to daemonize the watchdog | Swift/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.
/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.
| Scenario | Sleep → wake | Quit → 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. |
Readouts right after a wake. macOS rewrites the battery’s registry entry about once a minute, and the first snapshot after a wake can carry adapter figures the SMC has not sampled yet: 0 W in while the battery supplies nothing either, which no running Mac can do. Ampere treats that snapshot as a reading that was never taken rather than as a dead adapter: the Adapter Load, Adapter Voltage, Adapter Current and System Load cards show a dash and the power-flow diagram keeps the plug until the next snapshot. A real dead adapter, 0 W in with the battery draining, still shows the Mac on battery. The battery’s own current and voltage come from the gauge and are shown as read.
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. Each read checks both the kernel’s answer and the SMC’s own status, as every write does; a read the SMC refuses is skipped and logged, never taken for a reading of zeros.
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.
On firmware without CHTE (see above) the check compares the limit macOS is enforcing with the one Ampere last set, plus CHIE, which must be clear because nothing writes it in that mode. A mismatch inside a 150-second settle window after a write is not reported, since the agent needs up to a minute to apply a target; past it the target is re-issued, silently the first time, and only a mismatch that survives that shows the warning. The About panel names the mechanism in force. While macOS’s own calibration charge runs, the check reports suspended with the registered limit marked as ignored, repairs nothing, and resumes the comparison when the override clears.
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 closed | Panel open | |
|---|---|---|
| Poll interval | 60s | 10s |
| First health check | ~3 min after launch | ~30s after opening* |
| Health check interval | every 60s | every 10s |
Diagnostics: everything Ampere, its helper, and its watchdog log goes to the unified log under the subsystem com.az-code-lab.ampere, readable with log show --last 1h --predicate 'subsystem == "com.az-code-lab.ampere"'. (NSLog is no longer used; macOS 27 redacts its messages.)
* 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:
- downloads the release DMG from GitHub Releases (progress in the panel footer, with a cancel button);
- verifies the download four ways: the SHA-256 must match the Homebrew cask, the code signature must be intact, the Team ID must match the running app, and the macOS the new app asks for must not be newer than the one this Mac runs;
- 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.
A release is offered only to a Mac that can run it. The cask names the oldest macOS a release supports; when this Mac runs something older, no update is offered, the installed version stays, and a clicked Check for Updates answers Needs macOS N. brew upgrade refuses the same release from the same line of the cask, so both channels agree. The check on the downloaded app in step 2 is the one that matters most: it keeps a working copy from ever being replaced by an app macOS will not open.
Registration
Registering binds a license key to an email address and adds the Mac’s device serial to the key. After that, the app re-verifies about once a day, and each time you open its registration window, by sending the email and serial along with the app version and the macOS version; the key itself is never transmitted again. The macOS version shows us which systems registered Macs run, which is what decides the oldest macOS Ampere has to keep supporting. An unregistered copy contacts no license server at all. The fine print lists everything the server keeps.
- What it unlocks: custom charge bounds. An unregistered copy runs the default 40 to 60% range with everything else intact; a lapsed registration puts the default back, at once and again at the next launch.
- Network failures never clear a registration. Only a definitive “invalid” answer from the license server does — offline Macs stay registered.
- One key, several Macs: a key registers up to … Macs at once, each with the same email and key. Once it is full, another Mac is refused until one is deregistered from the panel or released under My Licenses at azcode.dev. The registration window shows how many Macs your key is in use on, out of how many it registers.Moving is built in: a key registers one Mac at a time. Deregister from the panel, or just register on the new Mac with the same email and key, and the key moves over.
- No analytics, no tracking. The daily verify and the update check are the app’s only network calls.
Clean uninstall
Delete Ampere.app from Applications or run brew uninstall ampere. Quit Ampere first if it is running (Finder refuses to trash a running app; Homebrew quits it). Quitting restores charging and sleep settings, and two minutes after the bundle is gone the cleanup job removes everything else, so the Mac ends up as if Ampere had never been installed:
/Library/PrivilegedHelperTools/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);/Library/LaunchDaemons/com.az-code-lab.ampere.cleanup.plist, the cleanup job itself;- every account’s
~/Library/Preferences/com.az-code-lab.ampere.plist, including the registration, plus the update check’s cache and HTTP storage and any saved window state under~/Library. For a logged-in account the preferences are cleared through that user’scfprefsdso a later reinstall does not read the old registration back from the running daemon.
A copy deleted while it was running (rm -rf, or an uninstaller that does not quit it first) is waited for. The job asks the kernel where each running copy’s executable is: a copy that still exists elsewhere on disk was moved, so everything stays and the copy re-registers the job on relaunch; a copy whose executable is gone, or sits in a Trash folder, is on its way out, and everything is removed two minutes after it quits. There is no zap stanza to remember; brew uninstall ampere alone is the complete removal. To remove everything right away instead, quit Ampere and run one command, which restores first and removes nothing if the restore fails:
sudo /Library/PrivilegedHelperTools/az-ampere-smc purge
To keep the app but drop its admin access, open Settings in the panel footer and click Revoke on the Admin Access row (one password prompt). Revoke restores charging and sleep settings first; if that restore fails, the helper and its watchdog stay in place and the app tells you, rather than deleting the only thing that can still undo the override. Preferences stay, since the app does. The one thing the job cannot reach is the Open at Login entry, which macOS manages: switch launch at login off before uninstalling, or remove the entry under System Settings › Login Items & Extensions.
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
./run.sh
run.sh hands the linker the SDK explicitly. Under Xcode 27’s toolchain a bare swift build stamps the binary with the deployment target as its SDK version, and AppKit and SwiftUI then run it in that older release’s compatibility mode; the script’s comment explains the flag.
If this page made claims you want to check, read the source — that’s what it’s there for.