我把 macOS 的功耗数据做成了一个能在手机浏览器上实时看的仪表盘,含北京阶梯电价估


引子

我桌上摆着一台 M4 Mac mini 32GB,从 2026 年 6 月初开始就没关过机——跑 Docker、跑 ComfyUI 训练、跑后台服务。某天我突然好奇:它 7×24 小时开着,一个月到底花我多少钱电费?

这种问题,最朴素的做法是去查电表。但电表是整户楼的,单独拎出一台机器的耗电得自己测。我花了半个周末,从一行 powermetrics 命令开始,把它搭成了一个通过外网可以访问的实时 Web 仪表盘

23 天实测下来:日均耗电 0.184 kWh,日均电费 0.09 元,月预估 2.70 元(北京阶梯电价)——这是我做这个项目之前完全没想到的数字。

这篇文章不是 step-by-step 教程(代码全开源在 GitHub),而是讲清楚整个过程中的技术决策和踩坑实录。如果你也想给自己的 Mac 装一个,可以照着流程做;如果你只是想看看"从命令行工具演进到可视化系统"是怎么个过程,也许能从我的判断里捞到一些东西。

  • 最终成品在线看:https://hubianluanma.com/power/
  • 完整代码:https://github.com/hubianluanma/mac-power-monitor

一、为什么需要自己做

macOS 上能拿到实时功耗数据的工具不多,我挨个对比过:

工具类型能拿到的数据取舍
powermetrics系统 CLI(root)CPU/GPU/ANE 毫瓦级功耗数据最准,但要 sudo
pmset系统 CLI电池放电率、电源事件桌面机没电池,用不上
Stats(开源)菜单栏 app实时瓦数 + 温度装一个看瞬时值
Watts / iGlance菜单栏 app瓦数同 Stats,没特别优势
Activity Monitor → 能耗系统 GUIapp 级影响不直观,看不到总瓦数

为什么没直接买 iStat Menus?它确实是好软件,但有两个问题:

  1. 一次性付费后是"看不见摸不着"的数字,没有历史曲线
  2. 我想把数据外网可访问——出门在外也能刷一下看家里 Mac 在干嘛

结论:底层用 powermetrics 采样,加一层自己的 Web 展示层做历史和成本估算。菜单栏的 Stats 我顺便装一个看瞬时值,但不参与数据流。


二、架构:四层足够,不需要更多

演化到最后的完整结构是这样的:

┌─────────────────────┐
│ powermetrics (sudo) │  ← 每分钟采样 5s
└──────────┬──────────┘
           ↓
┌─────────────────────┐
│ SQLite 单文件 DB    │  ← /power/data/power.db
└──────────┬──────────┘
           ↓
┌─────────────────────┐
│ Flask (127.0.0.1)   │  ← 单文件服务,端口 7654
└──────────┬──────────┘
           ↓ proxy_pass /power/
┌─────────────────────┐
│ nginx (Docker) :80  │
└──────────┬──────────┘
           ↓
┌─────────────────────┐
│ Cloudflare Tunnel   │  ← HTTPS 终止
└──────────┬──────────┘
           ↓
     浏览器手机访问

为什么这样分层:采集层只关心拿数据,存储层只关心怎么存,Web 层只关心怎么展示。每层都能独立替换或测试。这个项目我做了一周,每晚只动其中一层。


三、采集器:powermetrics + NOPASSWD sudoers

powermetrics 需要 root 权限,但每分钟采一次还要人输密码就太蠢了。给当前用户配 NOPASSWD sudoers 是常规做法:

# /etc/sudoers.d/hermes-powermetrics
spiral ALL=(root) NOPASSWD: /usr/bin/powermetrics
Defaults!/usr/bin/powermetrics !logfile, !syslog

两个细节很关键:

  1. 限定到具体路径,不给完整 root NOPASSWD(最小权限原则)
  2. !logfile, !syslog 让这个命令不写系统日志,避免 syslog 被噪声淹没

采集器是个简单的 Python 循环,对齐整分钟采样 5 秒,解析三个 Power 字段:

# CPU Power: 123 mW
# GPU Power: 45 mW
# ANE Power: 0 mW
# Combined Power (CPU + GPU + ANE): 168 mW

正则取出最后一次值(最稳定),写 SQLite。表结构只有 7 个字段,单文件 DB,零运维。

一个小坑:powermetrics 只给 SoC package 的功耗(CPU+GPU+ANE),不算屏幕、SSD、USB、Wi-Fi、DRAM 这些系统外设。M4 Mac mini 闲置时整机功耗实测 ~7-9W,但 SoC 端只有 0.1W 不到。差额主要是"系统开销",我硬编码了一个 SYSTEM_BIAS_W = 7.0 加进去当估算——比裸 SoC 数据更接近电表读数。这个参数通过环境变量可覆盖,不同 Mac 机型可以自己调。


四、Web 仪表盘:Flask + Chart.js 单文件

前端只有一页,没有用 React/Vue 这种重武器——单文件 HTML + Chart.js CDN 引一个就够。理由:

  • 项目定位是"自用工具",不是产品,不需要组件化
  • 一个 HTML 文件加 Chart.js 比 React + build pipeline 少 95% 的依赖
  • 真要做复杂交互(比如 KPI 卡片点击切换图表),几十行 JS 就够

实际仪表盘长这样

主视图包含四块:

  1. 顶部 4 个 KPI 卡片:当前功耗、今日耗电、今日平均、本月估算
  2. 实时功耗曲线:24 小时窗口,5 秒刷新
  3. 本月累计柱状图:按天累计电费
  4. 阶梯电价分档:北京年累计度数分档显示

一个交互细节:4 张 KPI 卡片是可点击的——点不同卡片会切换实时曲线的指标(瞬时功率 vs 今日累计 vs 5 点滑动平均 vs 月累计),这个交互我做了小动画,配上 localStorage 持久化用户选择。


五、电价模块:北京阶梯电价算法

中国阶梯电价是按年累计度数分档逐档累加的,不是简单的"超过 X 度就贵"。比如北京 2026 年标准:

年累计度数单价
0 - 2520 度0.4883 元/kWh
2521 - 4800 度0.5383 元/kWh
4801+ 度0.7883 元/kWh

所以代码不能写 if kwh > 2520: price = 0.5383,而要按档逐档累加

def beijing_cost(kwh):
    if kwh <= 2520:
        return kwh * 0.4883
    elif kwh <= 4800:
        return 2520 * 0.4883 + (kwh - 2520) * 0.5383
    else:
        return 2520 * 0.4883 + 2280 * 0.5383 + (kwh - 4800) * 0.7883

我把它做成了一个字典驱动模块——加一个城市只要在 pricing.py 里加一行配置就行,不动核心代码。现在支持北京、上海、深圳、广州、成都五座城市。


六、踩坑实录(重点)

这部分是全文最值得看的。

坑 1:Flask SCRIPT_NAME vs 反代路径

Flask 默认认为自己在根路径跑,但 nginx 反代在 /power/ 下。直接 url_for('static', ...) 会生成 /static/... 而不是 /power/static/...,导致静态文件 404。

解决方案:在 nginx 把 SCRIPT_NAME 通过 proxy_set_header 传过去,Flask 读 request.script_root

location /power/ {
    proxy_set_header X-Script-Name /power;
    proxy_pass http://127.0.0.1:7654/;
}

坑 2:Hermes 沙盒启动 Flask 服务的诡异行为

我原本想用 Hermes 的 delegate_task 让 cron 帮我拉起 Flask,结果发现 Hermes 沙盒环境里 Flask 启动后会"假死"——端口 listen 着但请求不响应。原因是沙盒的 stdout/stderr 缓冲和真实终端不一样。

最终方案:老老实实 launchd 拉起,不碰任何 agent 框架。launchd plist 模板我放在 repo 的 examples/ 下,复制改路径就能用。

坑 3:JS fetch().json() 异步链

前端每 5 秒拉一次 /api/summary,但忘了 await 直接 .json(),控制台一片红。最后改成 async/await 才稳定。这种坑在原生 JS 里特别隐蔽。

坑 4:Flask 模板缓存(Py 3.14 + Werkzeug 3.1)

我用的 Python 3.14 + Werkzeug 3.1,改了 HTML 模板刷新浏览器不生效——缓存异常激进。临时方案:开发时设 TEMPLATES_AUTO_RELOAD=True;生产部署靠 release 时清缓存目录。

坑 5:powermetrics 偶发输出截断

采样 5 秒但偶发只输出 3 秒就退出,导致正则取不到 Combined Power 字段。容错做法:取最后一次出现的 Combined Power,找不到就跳过这一分钟(数据缺失但不报错)。

坑 6:Cloudflare Tunnel 的免费额度

外网访问走 Cloudflare Tunnel 免费版,每月 100 万次请求额度。我的访问量不大完全够,但如果做成公开服务被刷流量就不行了。


七、实测数据(23 天)

跑了 23 天(2026-06-22 到 2026-07-15),总共 34,186 个采样点。结果有点出乎意料:

指标数值
平均功耗7.68 W
最低功耗7.02 W(闲置态)
最高功耗30.39 W(6/26 那次 ComfyUI 训练)
日均耗电0.184 kWh
日均电费¥0.09
月预估电费¥2.70
累计耗电4.25 kWh
累计电费¥2.07

几个反直觉的发现

  1. 闲置功耗远低于想象:7W 是什么概念?相当于一个普通 LED 灯泡。Mac mini 的待机能耗控制是真不错。
  2. 高负载占比极低:>30W 的样本只占总样本的 0.05%(ComfyUI 训练那次 16 个样本)。平时即使开着 IDE + Docker + 浏览器,功耗也很少突破 15W。
  3. 峰值时段在凌晨 4-9 点:一开始我以为是 bug,后来查发现是 macOS 的 powermetrics 在系统空闲时反而采样更频繁(默认行为)——不是真的功耗高。
  4. 网络请求占总功耗的隐性大头:每分钟一次的 fetch + 5 秒刷新图表,单这一项就让"完全闲置"的功耗从 7.0W 升到 7.7W 左右。

八、做这事值不值?

我从两个维度算过账:

时间成本

  • 写采集器:2 小时
  • 写 Web 仪表盘:4 小时
  • 配置 nginx + Cloudflare Tunnel:1 小时
  • 调试踩坑:3 小时
  • 写文档 + 截图:2 小时
  • 总计:约 12 小时

电费节省

  • 这个项目没有节省任何电费——Mac 该跑还是跑。
  • 让我重新评估了"7×24 开机"的成本——2.7 元/月完全可接受,比想象中便宜得多。以前我因为怕电费每天下班关机的习惯,现在是不必要的内耗

情感收益

  • 看着 dashboard 上曲线平稳流动、CPU/SoC 一起跑长任务,有点莫名其妙的安全感。
  • 偶尔出门在外刷一下 /power/,确认家里 Mac 还在正常工作,也是一种小确幸。

九、给也想做的人

如果你也想给自己的 Mac 装一个,不要先看代码,先想清楚你要回答什么问题。是"今天耗电多少"?是"哪个 app 最耗电"?是"Mac 关不关机划算"?这三个问题对应三个完全不同的设计。

我的项目只回答第一个问题。如果你想要"app 级耗电",要么买 iStat Menus,要么自己扩采集器加 ps -ax 关联 PID——这个我下个版本可能会做。

代码放在 https://github.com/hubianluanma/mac-power-monitor,MIT 协议,欢迎 fork。如果你的 Mac 不是 M4 mini,可以调 SYSTEM_BIAS_W 这个环境变量适配你的机型。

如果你部署过程中遇到问题,欢迎评论区交流,我看到会回。