.box.warn { border-left: 3px solid #f85149; }
.env-tbl th { text-align: left; padding: 8px 10px; border-bottom: 1px solid #30363d; color: #8b949e; font-weight: 600; font-size: 13px; }
.wide-tbl td:first-child { width: auto; white-space: normal; }
.tbl-wrap { overflow-x: auto; }
.toc { columns: 2; font-size: 14px; }
.toc a { color: #58a6ff; text-decoration: none; }
@media (max-width: 760px) { .toc { columns: 1; } .page { padding: 20px 16px 48px; } }
ol { margin: 0; padding-left: 22px; } ol li { margin: 6px 0; font-size: 14px; }
Garmin / Connect IQ Widget · fr955
Garmin AI 用量表盘 — 项目交接
在 Garmin Forerunner 955 手表上一屏显示 Claude Code 和 Codex(GPT) 的 5 小时 / 7 天剩余额度和重置时间,同时显示时间、心率、电量。
VPS 定时在 tmux 里启动两个 CLI、抓取 /usage、/status 的屏幕输出,解析后写成 JSON;手表通过手机上的 Garmin Connect 拉这份 JSON。
本页可以独立读懂,不依赖任何历史会话。
VPS 数据链路在线(cron 白天每 5 分钟)
fr955 真机在用
09-30 IQ! 崩溃已修(VPS 侧,免重装)
修复后真机待确认
widget 源码修正待 Mac 编译
显示时区固定为曼谷,是否改待定
目录
① 新会话从这里开始
- 先判断是哪一边的问题:手表屏幕上是右上角小红 !(网络请求失败)还是整屏 IQ! 大图标(程序崩溃)?处理路径完全不同,见 ⑧。
- 在 VPS 上看数据:
curl https://www.edmundhuang.xyz/void/api/v2/garmin/usage,看是不是 200、updated_at 新不新、字段格式有没有变化;再 tail -4 logs/garmin-usage.log 看 cron 有没有按时写。
- 改 VPS 端(解析、格式、频率):直接在 VPS 上改
workspace/garmin-watchface/vps/parse_usage.py 或 crontab,立即生效,手表不用动。
- 改手表端(布局、逻辑):只能在 Mac 上编译(VPS 没有 SDK),流程见 ⑨。改完先在模拟器验证,再侧载到真机。
- 能在 VPS 端解决的,优先在 VPS 端解决:手表重新侧载要连 Mac、走 MTP,成本高。
② 架构与数据链路
┌─ VPS(日本东京,系统时区 Asia/Tokyo)────────────────────────────┐
│ cron → vps/parse_usage.py │
│ ├─ tmux 里跑 `claude` → 发 /usage → capture-pane → 正则解析 │
│ ├─ tmux 里跑 `codex` → 发 /status → capture-pane → 正则解析 │
│ ├─ 时区换算 Asia/Tokyo → Asia/Bangkok,7D 重置时间转成星期 │
│ └─ 写 vps/usage.json(某字段解析失败 → 沿用上一轮旧值) │
│ void-webui(FastAPI)GET /void/api/v2/garmin/usage → 原样返回该文件 │
│ nginx https://www.edmundhuang.xyz 反代 │
└─────────────────────────────────────────────────────────────────────┘
▲ HTTPS(UA: ConnectMobile,机主手机是 iPhone)
┌─ 手机 Garmin Connect ──┐ 手表本身不能上网,全靠手机中转;
└────────────▲───────────┘ App 在后台被杀或蓝牙断了,请求就失败
│ 蓝牙
┌─ fr955 手表 AIUsage widget ─────────────────────────────────────────┐
│ 打开时拉一次(按 UP/DOWN/SELECT 手动刷新)→ DataModel 缓存 │
│ UsageView 每秒重绘(时钟、心率、电量都在手表本地读) │
└─────────────────────────────────────────────────────────────────────┘
要点:手表拿到的永远是 VPS 上的缓存,不是实时值,最坏会滞后一个 cron 间隔。接口不加鉴权,因为 widget 发不了 Authorization 头;数据只有用量百分比,不敏感。
③ JSON 数据约定(VPS ⇄ 手表的唯一接口)
{
"claude": { "5h_pct": 97, "7d_pct": 91, "reset_5h": "21:59", "reset_7d": "TUE 11:00" },
"gpt": { "5h_pct": 100, "7d_pct": 99, "reset_5h": "21:55", "reset_7d": "MON 08:27" },
"updated_at": "2026-09-30 19:28"
}
| 5h_pct / 7d_pct | 整数,剩余百分比(100 = 完全没用)。Claude 的输出是「NN% used」,脚本用 100 - used 换算;Codex 的输出本来就是「NN% left」,直接用。解析不到时为 null,手表显示 --。 |
| reset_5h | "HH:MM",已换算成曼谷时间。 |
| reset_7d | 自 2026-09-30 起是 "TUE 11:00"(星期大写缩写 + 曼谷时间),手表原样显示。此前是 "10/6 11:00"(月/日),由手表自己换算星期,结果两位数月份触发崩溃。不要改回月/日格式,除非手表端的新版本已经侧载。 |
| updated_at | VPS 本地时间(东京)YYYY-MM-DD HH:MM。排错第一步先看它是否新鲜。 |
| 手表判定成功 | HTTP 200、响应非空、同时含 claude 和 gpt 两个 key。否则点亮右上角红「!」,继续显示旧缓存。 |
⚠️ 任何字段类型或格式的改动都可能让已经装在表上的旧 widget 崩溃(Monkey C 没有异常兜底,一出错就是 IQ!)。改格式前先读 UsageView.mc 里对应字段的处理代码。
④ VPS 端:parse_usage.py
抓取方式
- 两个 CLI 都是交互式 TUI,直接抓原始输出流会因为光标定位把空格吃掉,所以改用 tmux:新建 120×45 的独立 session → 启动 CLI(等 10 秒)→ 发 slash 命令 → 等 5 秒 →
capture-pane 抓渲染后的屏幕 → 关掉 session。
- 启动时会 unset
CLAUDECODE / CLAUDE_CODE_ENTRYPOINT,否则在 CC 里嵌套运行时 claude 会拒绝启动。
- Codex 发现新版本时会在启动时跑 npm install,然后因权限不足退出。
ensure_codex_no_autoupdate() 把 ~/.codex/version.json 的 dismissed_version 设成 latest_version 来规避。
- 单次运行约 30 秒(两个 CLI 串行)。
解析与换算
- Claude
/usage:Current session … NN% used … Resets 7:30pm;Current week … Resets Jun 9, 1pm,或者新格式 Resets 1pm (Asia/Tokyo)(不带日期,按 7 天内最近的一次推算)。
- Codex
/status:5h limit: … 99% left (resets 21:15);Weekly limit: … 80% left (resets 14:48 on 11 Jun)。
- CLI 按 VPS 时区(东京)显示时间,脚本统一换算成曼谷时间(
TARGET_TZ)。
format_7d_reset() 用固定的 WEEKDAYS 表输出星期,不用 %a,避免受 locale 影响。
merge_with_previous():某个区块全部解析失败就整块沿用旧值,部分字段失败则逐字段补齐,保证手表上不会出现空白。
| 接口实现 | core/webui_v2/routes.py 的 garmin_usage():读 workspace/garmin-watchface/vps/usage.json 原样返回;文件不存在时返回 503 {"error":"no data yet"}。运行在 pm2 的 void-webui 进程里。 |
| cron | ubuntu 用户的 crontab,小时按东京时间:*/5 10-22 * * *(白天每 5 分钟)+ 0 0-9,23 * * *(夜间每小时)。日志追加到 logs/garmin-usage.log。改频率只动 crontab,不用重新编译手表。 |
| 依赖 | /usr/bin/python3(系统 Python,只用标准库)、tmux,以及 VPS 上已登录的 claude 和 codex CLI(用的是机主本人账号的额度)。 |
| 类型 / 设备 | type="widget",minApiLevel 3.4.0;manifest 里登记了 fr955(260×260,真机)和 epix2pro51mm(454×454,只编译过)。应用名 AI Usage,产物 AIUsage.prg。 |
| 权限 | Communications(发网络请求)、SensorHistory(读心率历史)。 |
| UsageApp.mc | 入口,返回 [UsageView, UsageDelegate]。 |
| DataModel.mc | makeWebRequest GET 接口(JSON 响应),回调 onReceive 按 ③ 的规则判定成功与否,把字段存成成员变量。数据只在内存里,不持久化:每次打开 widget 都从空开始,重新拉取。 |
| UsageView.mc | onShow 拉一次数据,并启动每秒一次的 Timer 触发重绘;onHide 停掉 Timer。onUpdate 依次画电量、Claude 区块、时间带、GPT 区块、心率;出错时画右上角红「!」。 |
| UsageDelegate.mc | UP / DOWN / SELECT 手动刷新;BACK 键是 KEY_ESC,触发 popView(SLIDE_RIGHT) 退出。 |
| 心率 / 电量 / 时间 | 心率取 ActivityMonitor.getHeartRateHistory(1, true) 最新一条(0–250 之外视为无效);电量取 System.getSystemStats().battery;时间是手表本地时钟,24 小时制。 |
| reset7dDisplay | 旧逻辑:把 "M/D HH:MM" 换算成星期。VPS 改为直接输出星期后,这段只作旧格式兜底(字符串不含 / 就原样返回)。仓库里的版本已经修正了 substring 参数,但还没编译进表里。 |
⑥ 界面布局与缩放
自适应缩放
- 所有坐标按 454px 基准来写,
onUpdate 每帧计算 _S = 屏幕宽 / 454,再用 p(n) = round(_S × n) 换算。fr955 上 _S ≈ 0.573。
- 时间带的数字要底部对齐,但
getFontHeight() 包含行间距,所以按字体加了实测的补偿值:HOT +p(36);MILD +p(17)+4px;LARGE +p(5)+9px(这些 px 是 fr955 上实测的绝对值)。
从上到下(454 基准 Y)
- y≈6 电量:电池图标 + 百分比,居中。
- y=55 Claude 区块(橙 #E8640A):logo 圆点 + 名称;左侧大字是 7D 剩余%;右侧两行:5H 行(标签 / % / 重置时间 分三段绘制)+ 进度条,7D 行(重置星期)+ 进度条。区块内容整体下移 dy=4。
- 分隔线(灰 #999999)之间是时间带:时(橙 HOT)、分(蓝 MILD)、秒(灰 LARGE),右侧是星期 + 日期 badge。
- y=308 GPT 区块(蓝 #4A9EFF),结构同上,logo 左移 lx=-4。
- y≈428 心率,居中。背景 #0D0D0D。
⑦ Monkey C 踩坑(编译不报错、运行时崩溃的最危险)
| 坑 | 正确做法 |
substring 的第二个参数 | 是 终点下标(不含),不是长度。起点 > 终点时返回 null,再调 .toNumber() 就崩溃。旧文档写成「长度」,已在 09-30 更正(这就是当天 IQ! 的根因)。 |
String.find() / indexOf() | 不存在,编译能过,运行时崩溃。用 toCharArray() 遍历,见 strFind();字符字面量用单引号 '/'。 |
KEY_BACK | 不存在(编译报错),BACK 键是 WatchUi.KEY_ESC。 |
类型注解 as String | 在这个项目里会报错,去掉。 |
getFontHeight() | 包含行间距,直接拿来算坐标会偏上,要加补偿值。 |
| 没有异常兜底 | 任何对 null 的方法调用都会导致整个 widget 崩溃(IQ!)。处理 VPS 下发的字符串前要先判空、校验格式。 |
⑧ 故障史与排障
| 右上角小红 ! | 网络请求失败,屏幕上仍显示旧缓存。根因几乎都在手表 ↔ 手机蓝牙链路:把 Garmin Connect 切到前台、确认蓝牙已连接,按键刷新;还不行就重启手表。长期办法:手机上允许 Garmin Connect 在后台常驻。 |
| 整屏 IQ! 大图标 | 程序崩溃。先 curl 接口看数据格式有没有变化(最可能是 VPS 输出变了,旧 widget 处理不了);再把手表接到 Mac,取 GARMIN/APPS/LOGS/CIQ_LOG.YML,里面有崩溃的具体行号。 |
| 数字旧 / 不准 | 先看 updated_at,比当前时间晚了超过一个 cron 间隔,就是缓存没刷新,不是算错了;再看 logs/garmin-usage.log,查 CLI 登录状态和输出格式有没有变化。 |
2026-09-30 · 打开 widget 显示 IQ!
- nginx 里手表的请求全是 200,所以不是网络问题。
reset7dDisplay 误把 substring(start, end) 当成 (start, length)。"10/6 11:00" 算出 substring(3, 1) → null → .toNumber() 崩溃。
- 修复(
5c792a5):VPS 直接下发 "TUE 11:00",手表免重装即恢复;源码里的 substring 同步改正;GUIDE 更正。
- 同一个 commit 顺带提交了 6/9 Codex 加的东京 → 曼谷时区换算(这部分一直在线上跑,只是没进 git)。
更早的故障
- 2026-07 前后,两次「手表读不到」:两次 VPS 都正常,都是蓝牙链路的问题,重启手表后恢复(排查清单见 QUICKSTART.md)。
- 2026-06-09 同步数据错误(Codex 会话):手表、API、CLI 三方的重置时间对不上 → 加了东京 → 曼谷时区换算。排查顺序:parse_usage.py → usage.json → API → DataModel.mc,API 和缓存一致的话就不是手表同步的问题。
- 2026-06-06 数据新鲜度:cron 从每小时切到每 5 分钟的交接时段,缓存停在整点的旧值近 1 小时,显示和真实值差了十几个百分点。
- 2026-06-06 首次崩溃:用了不存在的
String.find() → 改为 strFind()。
⑨ Mac 编译 / 模拟器 / 侧载
| Mac 环境 | Connect IQ SDK 9.1.0(~/Library/Application Support/Garmin/ConnectIQ/Sdks/connectiq-sdk-mac-9.1.0-2026-03-09-6a872a80b/);fr955 设备文件通过 SDK Manager 安装;Java 用 Eclipse Temurin(brew 安装);开发者 key 是自签的 ~/garmin-keys/developer_key.der(丢了就重新生成,但之后要用新 key 重新编译、侧载)。 |
| 仓库位置 | Mac 上是 ~/works/the-void(先 git pull 拿到 VPS 上的改动)。 |
| 侧载 | fr955 新固件没有 U 盘(Mass Storage)模式,要用 MTP 模式 + OpenMTP(Mac 应用),把 .prg 拷进 GARMIN/APPS/。拷进去后文件会被手表挪到内部隐藏存储,看不到了是正常的。widget 在 START 键 → 应用列表里找,不在「添加小工具」列表里。 |
SDKBIN="$HOME/Library/Application Support/Garmin/ConnectIQ/Sdks/connectiq-sdk-mac-9.1.0-2026-03-09-6a872a80b/bin"
WIDGET="$HOME/works/the-void/workspace/garmin-watchface/garmin-widget"
# 编译(fr955)
"$SDKBIN/monkeyc" -f "$WIDGET/monkey.jungle" -o "$WIDGET/AIUsage.prg" -d fr955 -y "$HOME/garmin-keys/developer_key.der"
# 模拟器:先开模拟器,窗口出来后再推送(模拟器走 Mac 网络,能拉到 VPS 真实数据)
open "$SDKBIN/ConnectIQ.app"
"$SDKBIN/monkeydo" "$WIDGET/AIUsage.prg" fr955
# 真机:手表 USB 连 Mac(MTP)→ 用 OpenMTP 把 AIUsage.prg 拷进 GARMIN/APPS/ → 断开
⑩ 已做 / 待办
✅ 已完成
- VPS:tmux 抓屏解析 + 接口 + 分时段 cron + 解析失败沿用旧值
- Claude 输出改成不带日期的新格式后仍能解析;Codex 自动更新规避
- 东京 → 曼谷时区换算;7D 重置时间由 VPS 输出成星期
- Widget 全屏布局、454 基准自适应缩放、fr945 → fr955 迁移
- 真机 MTP 侧载、BACK 键退出、按键手动刷新、网络失败标记
- IQ! 崩溃修复(2026-09-30,VPS 侧已生效)
⬜ 待办
- 机主确认:打开 widget,不再崩溃,7D 行显示
TUE 11:00 这类格式
- Mac 上重新编译(带 substring 修正)→ 模拟器验证 → 侧载;到时可以直接删掉
reset7dDisplay(已成死代码)
- 时区待定:显示时区写死为曼谷(
TARGET_TZ)。机主人在上海时,重置时间会差 1 小时。可以改成上海,或者让手表按本地时区换算
- 可选:手机上允许 Garmin Connect 在后台常驻,减少蓝牙掉线
- 可选:DataModel 把最后一次成功的数据存进 Storage,打开 widget 时先显示缓存
⑪ 时间线(commit)
| 2026-06-05 | 832d84d 方案 · ce9880f SPEC/IMPL · 627d8dc VPS 抓取 + 路由 · 3869786 Widget 初版 · db03769 构建/侧载指南 · 88b8bc4 fr955 布局(时间带重新设计)· 5bac063 7D 显示改星期 · b01cc09 5H 标签调整 |
| 2026-06-06 | d413c94 自适应缩放 · 1a8bfa5 精调 + strFind 修崩溃 · 736d245 BACK 键 + epix2pro · 68eadbc 进度条对齐 · 4b5899d GUIDE · 609502d cron 分时段 · 58d1fa9 QUICKSTART |
| 2026-06-09 | Codex 会话:同步数据错误 → 东京 → 曼谷时区换算(当时没提交,09-30 随 5c792a5 一起入库) |
| 2026-07-02 | bdeede3 「手表读不到」排查清单 |
| 2026-09-30 | 5c792a5 IQ! 崩溃修复(VPS 输出星期 + substring 修正) |
⑫ 文件与文档索引
| 项目根 | /home/ubuntu/the-void/workspace/garmin-watchface/ |
| VPS 端 | vps/parse_usage.py、vps/usage.json(运行时生成)、core/webui_v2/routes.py(接口)、logs/garmin-usage.log |
| 手表端 | garmin-widget/:manifest.xml、monkey.jungle、source/{UsageApp,UsageView,UsageDelegate,DataModel}.mc、resources/(strings、launcher 图标) |
| 文档 | GUIDE.md(最全:设计、各 session 记录、踩坑)· garmin-widget/QUICKSTART.md(快速上手 + 「读不到」排查)· SPEC.md / IMPL.md / PLAN.md(最初的设计,部分已过时,以代码为准)· watchface-demo.html(视觉稿) |
| 历史会话 | CC 2026-06-05 三次(表盘搭建、widget、fr945 → fr955):原始文件已被 CC 自动清理,完整对话存在 data/raw_sessions.db(session_id e06f34d0…、1f3f3741…、87f80f21…)。Codex 2026-06-09:codex resume 019ea9e2-66d7-7e61-bb01-47e69a92ced7。 |