Last active
May 20, 2026 00:19
-
-
Save dsbaars/ced0d882a872dfbed12499b7811cf3c1 to your computer and use it in GitHub Desktop.
smc-power: read USB-C adapter load + system power from Apple SMC on Linux (Apple Mac, T2 + pre-T2 supported)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env python3 | |
| """Show USB-C adapter load + system power via Apple SMC. | |
| Auto-detects sysfs base (legacy applesmc.<N> on pre-T2, ACPI APP0001:00 on T2) | |
| and SMC endianness (T2 stores ui/si little-endian; sp* and flt stay big- and | |
| little-endian respectively on all Apple SMC variants). | |
| Usage: | |
| smc-power.py # one-shot | |
| smc-power.py --watch # live every 1s (Ctrl-C to stop) | |
| smc-power.py --raw KEY # dump one key's raw bytes (debug) | |
| Requires the applesmc kernel module to be loaded. | |
| """ | |
| import argparse, struct, sys, time | |
| from pathlib import Path | |
| CANDIDATE_BASES = [ | |
| Path("/sys/devices/platform/applesmc.768"), | |
| Path("/sys/bus/acpi/devices/APP0001:00"), | |
| ] | |
| def find_base(): | |
| for p in CANDIDATE_BASES: | |
| if (p / "key_at_index").exists(): | |
| return p | |
| return None | |
| class SMC: | |
| def __init__(self, base): | |
| self.base = base | |
| self._idx = base / "key_at_index" | |
| self._name = base / "key_at_index_name" | |
| self._type = base / "key_at_index_type" | |
| self._len = base / "key_at_index_data_length" | |
| self._data = base / "key_at_index_data" | |
| self.count = int((base / "key_count").read_text().strip()) | |
| self.cache = {} | |
| def _read_at(self, i): | |
| self._idx.write_text(str(i)) | |
| n = self._name.read_text().strip() | |
| t = self._type.read_text().strip() | |
| sz = int(self._len.read_text().strip()) | |
| d = self._data.read_bytes()[:sz] | |
| return n, t, d | |
| def refresh(self, names=None): | |
| """Walk all keys, fill cache. If names given, only cache those (faster).""" | |
| wanted = set(names) if names else None | |
| write_errors = 0 | |
| for i in range(self.count): | |
| try: | |
| name, typ, raw = self._read_at(i) | |
| except OSError: | |
| write_errors += 1 | |
| continue | |
| if wanted is None or name in wanted: | |
| self.cache[name] = (typ, raw) | |
| if wanted is not None and len(self.cache) >= len(wanted): | |
| return | |
| if write_errors == self.count and not self.cache: | |
| print( | |
| "error: every applesmc key read failed (probably running inside an\n" | |
| " unprivileged container where writes to key_at_index are denied).\n" | |
| " Run this on the host, not inside the LXC/Docker namespace.", | |
| file=sys.stderr, | |
| ) | |
| sys.exit(4) | |
| def raw(self, name): | |
| return self.cache.get(name) | |
| # Apple SMC stores integers (ui/si) big-endian on legacy Macs, little-endian | |
| # on T2. Signed-point (sp) and float (flt) are always stored as Apple | |
| # documents them (sp = big-endian fixed, flt = little-endian IEEE-754). | |
| def autodetect_int_endian(smc): | |
| """Apple chargers report rated current `D1IR` between 1000 and 6000 mA | |
| (5W .. ~120W at 20V). Whichever endian gives a value in that range is | |
| the right one.""" | |
| pair = smc.raw("D1IR") | |
| if pair is None: | |
| return "big" | |
| raw = pair[1] | |
| big = int.from_bytes(raw, "big") | |
| lit = int.from_bytes(raw, "little") | |
| if 1000 <= big <= 6000: | |
| return "big" | |
| if 1000 <= lit <= 6000: | |
| return "little" | |
| return "big" | |
| def decode(typ, raw, int_end): | |
| if not raw: | |
| return None | |
| try: | |
| if typ.startswith("ui"): | |
| return int.from_bytes(raw, int_end, signed=False) | |
| if typ.startswith("si"): | |
| return int.from_bytes(raw, int_end, signed=True) | |
| if typ.startswith("sp"): | |
| fbits = int(typ[3], 16) | |
| return int.from_bytes(raw, "big", signed=True) / (1 << fbits) | |
| if typ.startswith("fp"): | |
| fbits = int(typ[3], 16) | |
| return int.from_bytes(raw, "big", signed=False) / (1 << fbits) | |
| if typ.strip() == "flt": | |
| return struct.unpack("<f", raw)[0] | |
| if typ in ("ch8*", "char"): | |
| return raw.split(b"\x00", 1)[0].decode("ascii", "replace") | |
| return raw.hex() | |
| except Exception as e: | |
| return f"<err:{e}>" | |
| # Keys we always want — SMC. Battery values are read from | |
| # /sys/class/power_supply/BAT0 instead because the kernel driver decodes them | |
| # correctly while the SMC layout for B* keys is endian-inconsistent on T2. | |
| STATIC = ["D1im", "D1in", "D1is", "D1ih", "D1if", "D1ii", "D1IR", "D1VR"] | |
| DYNAMIC = ["VD0R", "ID0R", "PD0R", "PSTR", "PCPT", "PPBR", "VP0R"] | |
| ALL_WANTED = STATIC + DYNAMIC | |
| def read_kernel_battery(): | |
| out = {} | |
| p = Path("/sys/class/power_supply/BAT0/uevent") | |
| if not p.exists(): | |
| return out | |
| for line in p.read_text().splitlines(): | |
| if "=" in line: | |
| k, v = line.split("=", 1) | |
| out[k.replace("POWER_SUPPLY_", "")] = v | |
| return out | |
| def read_kernel_adapter(): | |
| p = Path("/sys/class/power_supply/ADP1/uevent") | |
| if not p.exists(): | |
| return {} | |
| out = {} | |
| for line in p.read_text().splitlines(): | |
| if "=" in line: | |
| k, v = line.split("=", 1) | |
| out[k.replace("POWER_SUPPLY_", "")] = v | |
| return out | |
| def get(smc, end, name): | |
| pair = smc.raw(name) | |
| return decode(pair[0], pair[1], end) if pair else None | |
| def fmt_val(v, ndig=2): | |
| if v is None: | |
| return " - " | |
| if isinstance(v, float): | |
| return f"{v:.{ndig}f}" | |
| return str(v) | |
| def report(smc, end, header=True): | |
| if header: | |
| print(f"# {time.strftime('%Y-%m-%d %H:%M:%S')} sysfs={smc.base} endian={end}/big/<f") | |
| smc.refresh(ALL_WANTED) | |
| ir = get(smc, end, "D1IR") | |
| vr = get(smc, end, "D1VR") | |
| rated_w = (ir or 0) * (vr or 0) / 1e6 | |
| print(f"Adapter: {get(smc, end, 'D1im')} {get(smc, end, 'D1in')}") | |
| print(f" HW {get(smc, end, 'D1ih')} FW {get(smc, end, 'D1if')} " | |
| f"ID {get(smc, end, 'D1ii')} S/N {get(smc, end, 'D1is')}") | |
| print(f" Rated: {ir/1000:.2f} A @ {vr/1000:.2f} V = {rated_w:.0f} W" | |
| if ir and vr else " (no rated info)") | |
| print() | |
| pd0r = get(smc, end, "PD0R") | |
| util = 100.0 * pd0r / rated_w if rated_w and pd0r else None | |
| bat = read_kernel_battery() | |
| # kernel BAT0 reports voltage in µV and current in µA. | |
| bat_v = int(bat["VOLTAGE_NOW"]) / 1_000_000 if bat.get("VOLTAGE_NOW") else None | |
| bat_i_uA = int(bat.get("CURRENT_NOW", 0)) if bat.get("CURRENT_NOW") else 0 | |
| bat_status = bat.get("STATUS", "?") | |
| bat_pct = bat.get("CAPACITY", "?") | |
| bat_cycles = bat.get("CYCLE_COUNT", "?") | |
| bat_temp = int(bat.get("TEMP", 0)) / 10 if bat.get("TEMP") else None | |
| print(" value unit source") | |
| rows = [ | |
| ("Adapter voltage", get(smc, end, "VD0R"), "V", "smc VD0R"), | |
| ("Adapter current", get(smc, end, "ID0R"), "A", "smc ID0R"), | |
| ("Adapter power", pd0r, "W", "smc PD0R"), | |
| ("Adapter utilization", util, "%", "PD0R / rated"), | |
| ("System total", get(smc, end, "PSTR"), "W", "smc PSTR"), | |
| ("CPU package", get(smc, end, "PCPT"), "W", "smc PCPT"), | |
| ("Battery rail draw", get(smc, end, "PPBR"), "W", "smc PPBR"), | |
| ("Battery status", bat_status, "", "kernel BAT0"), | |
| ("Battery charge", bat_pct, "%", "kernel BAT0"), | |
| ("Battery voltage", bat_v, "V", "kernel BAT0"), | |
| ("Battery current", bat_i_uA / 1000 if bat_i_uA else 0, "mA", "kernel BAT0"), | |
| ("Battery temp", bat_temp, "°C", "kernel BAT0"), | |
| ("Battery cycles", bat_cycles, "", "kernel BAT0"), | |
| ] | |
| for label, val, unit, src in rows: | |
| print(f" {label:<22} {fmt_val(val):>8} {unit:<5} {src}") | |
| def main(): | |
| ap = argparse.ArgumentParser() | |
| ap.add_argument("--watch", action="store_true", | |
| help="loop every 1s; clear screen each tick") | |
| ap.add_argument("--raw", metavar="KEY", | |
| help="dump one key's raw bytes (debug)") | |
| args = ap.parse_args() | |
| base = find_base() | |
| if base is None: | |
| print("error: applesmc sysfs not found — `modprobe applesmc` first", | |
| file=sys.stderr) | |
| sys.exit(2) | |
| smc = SMC(base) | |
| if args.raw: | |
| smc.refresh([args.raw]) | |
| pair = smc.raw(args.raw) | |
| if not pair: | |
| print(f"{args.raw}: not found") | |
| sys.exit(3) | |
| typ, raw = pair | |
| end = autodetect_int_endian(smc) | |
| print(f"{args.raw} type={typ!r} bytes={raw.hex()} len={len(raw)} " | |
| f"decoded={decode(typ, raw, end)!r}") | |
| return | |
| # endian probe uses D1IR which is rarely 0 on an Apple adapter | |
| smc.refresh(["D1IR"]) | |
| end = autodetect_int_endian(smc) | |
| if args.watch: | |
| try: | |
| while True: | |
| print("\033[2J\033[H", end="") | |
| report(smc, end) | |
| time.sleep(1.0) | |
| except KeyboardInterrupt: | |
| pass | |
| else: | |
| report(smc, end) | |
| if __name__ == "__main__": | |
| main() |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment