.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 AI 用量表盘 · Project Handoff
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 编译 显示时区固定为曼谷,是否改待定

目录

① 新会话从这里开始

  1. 先判断是哪一边的问题:手表屏幕上是右上角小红 !(网络请求失败)还是整屏 IQ! 大图标(程序崩溃)?处理路径完全不同,见 ⑧。
  2. 在 VPS 上看数据:curl https://www.edmundhuang.xyz/void/api/v2/garmin/usage,看是不是 200、updated_at 新不新、字段格式有没有变化;再 tail -4 logs/garmin-usage.log 看 cron 有没有按时写。
  3. 改 VPS 端(解析、格式、频率):直接在 VPS 上改 workspace/garmin-watchface/vps/parse_usage.py 或 crontab,立即生效,手表不用动。
  4. 改手表端(布局、逻辑):只能在 Mac 上编译(VPS 没有 SDK),流程见 ⑨。改完先在模拟器验证,再侧载到真机。
  5. 能在 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_atVPS 本地时间(东京)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 进程里。
cronubuntu 用户的 crontab,小时按东京时间:*/5 10-22 * * *(白天每 5 分钟)+ 0 0-9,23 * * *(夜间每小时)。日志追加到 logs/garmin-usage.log。改频率只动 crontab,不用重新编译手表。
依赖/usr/bin/python3(系统 Python,只用标准库)、tmux,以及 VPS 上已登录的 claude 和 codex CLI(用的是机主本人账号的额度)。

⑤ 手表端:Connect IQ Widget(Monkey C)

类型 / 设备type="widget",minApiLevel 3.4.0;manifest 里登记了 fr955(260×260,真机)和 epix2pro51mm(454×454,只编译过)。应用名 AI Usage,产物 AIUsage.prg。
权限Communications(发网络请求)、SensorHistory(读心率历史)。
UsageApp.mc入口,返回 [UsageView, UsageDelegate]。
DataModel.mcmakeWebRequest GET 接口(JSON 响应),回调 onReceive 按 ③ 的规则判定成功与否,把字段存成成员变量。数据只在内存里,不持久化:每次打开 widget 都从空开始,重新拉取。
UsageView.mconShow 拉一次数据,并启动每秒一次的 Timer 触发重绘;onHide 停掉 Timer。onUpdate 依次画电量、Claude 区块、时间带、GPT 区块、心率;出错时画右上角红「!」。
UsageDelegate.mcUP / 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-05832d84d 方案 · ce9880f SPEC/IMPL · 627d8dc VPS 抓取 + 路由 · 3869786 Widget 初版 · db03769 构建/侧载指南 · 88b8bc4 fr955 布局(时间带重新设计)· 5bac063 7D 显示改星期 · b01cc09 5H 标签调整
2026-06-06d413c94 自适应缩放 · 1a8bfa5 精调 + strFind 修崩溃 · 736d245 BACK 键 + epix2pro · 68eadbc 进度条对齐 · 4b5899d GUIDE · 609502d cron 分时段 · 58d1fa9 QUICKSTART
2026-06-09Codex 会话:同步数据错误 → 东京 → 曼谷时区换算(当时没提交,09-30 随 5c792a5 一起入库)
2026-07-02bdeede3 「手表读不到」排查清单
2026-09-305c792a5 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。
本页由 The Void 的「Garmin 表盘排障」工作会话生成,反映截至 2026-09-30 的状态。本页与仓库文档冲突时以代码为准;更新项目后请同步更新本页(/var/www/edmund-home/handoff/garmin.html)。