diff --git a/.env.example b/.env.example index e452afa..bc603c6 100644 --- a/.env.example +++ b/.env.example @@ -42,5 +42,16 @@ PACE_TOL=10 BTC_BAR=1H BTC_LIMIT=48 BTC_TTL=300 +# ---- AI 用量(本机采集 → main.py --target push 上报;server.py 不持凭证)---- +# Claude:复用 Claude Code 钥匙串登录态调 /api/oauth/usage(只读不刷新)。快照有效期(秒,同 WEATHER_TTL 语义,0=每次实拉)建议 ≥300,失败退避 1800 秒 +CLAUDE_USAGE_TTL=300 +CLAUDE_USAGE_BACKOFF=1800 +CLAUDE_USAGE_SCOPE_MODEL=Fable +# Codex:经 `codex app-server` RPC 读 ChatGPT 订阅的 Codex 额度;需 Codex 已用 ChatGPT 账号 `codex login` +# CODEX_BIN 建议绝对路径(launchd 的 PATH 不含 Homebrew);CODEX_USAGE_HOME 留空=默认 ~/.codex,或指向独立 CODEX_HOME +CODEX_BIN=/opt/homebrew/bin/codex +CODEX_USAGE_HOME= +CODEX_USAGE_TTL=300 +CODEX_USAGE_BACKOFF=900 # 服务端(server.py)三路上游的内存缓存有效期(秒,0=每次实拉):天气/BTC 复用 WEATHER_TTL/BTC_TTL,橘喵实时用 MAO_TTL MAO_TTL=60 diff --git a/.gitignore b/.gitignore index 4974e88..8227f6f 100644 --- a/.gitignore +++ b/.gitignore @@ -4,5 +4,7 @@ __pycache__/ .weather_cache.json .trend_cache.json .btc_cache.json +.claude_usage_cache.json +.codex_usage_cache.json run.sh .env diff --git a/CLAUDE.md b/CLAUDE.md index d2a7431..f99ef41 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -10,7 +10,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co > **分支 `feature/colorful`**:新设备在黑白外支持红色,本分支把渲染升级为黑/白/红三色。当前版式为**上中下三段**:A 大时钟+日期 / 天气(定位南京·江宁);B **Claude Usage** 主角(5h/7d 用量进度条 + 时间维度对比 pace,用量吃紧转红预警);C 橘喵今日实时三列。红作**语义强调**(用量预警、涨、定位、天气太阳/闪电),仍**无真灰阶、不抖动**,全图只三种纯色。近30天趋势图当前**隐藏**(`renderer._trend_chart` 及依赖保留、未调用,随时可挂回)。 -> **分支 `feature/reterminal-e1002`**(基于 `feature/colorful`):新增第二个目标设备 **Seeed reTerminal E1002**(7.3 寸 **800×480**,E Ink **Spectra 6 原生六色**:黑/白/黄/红/绿/蓝,真机已验证六色量化干净)。该设备不接受局域网直推,而是由 **SenseCraft HMI** 云端的 **HTML 控件**按设备刷新间隔(最小 5 分钟)**每周期重新抓取**一个公网 HTTPS URL、无头浏览器截图后量化下发;不支持 ETag/304,内容不变也会整屏重绘(全刷约 17 秒)。**真机验证(2026-09)**:Image 控件只在填写 URL 时抓一次快照、此后不再更新;HTML 控件填同一 URL 则每周期重抓——所以对外只用 HTML 控件。当前主形态是 **`server.py` 动态服务**(部署公网 HTTPS;局域网地址/明文 HTTP 云端抓不到):三条恒定 URL 并行供对比——方案一 `IMAGE_PATH`(默认 `/e1002.png`)每次请求**内存中现渲染 PNG**(Pillow 点阵版);方案二 `HTML_PATH`(默认 `/e1002.html`)每次请求生成 **800×480 现代网页**(`html_renderer.py`,系统/网络字体 + 内联 SVG K 线,无 JS)由平台截图——**真机实拍:文字抗锯齿灰边被平台抖动成毛边、小字与复杂汉字发糊**;方案三 `HTML_PNG_PATH`(默认 `/e1002-web.png`)同一网页由**服务端无头 Chrome 截图后按最近色(不抖动)固化为六色 PNG**(`html_shot.py`),平台拿到纯色图无从抖动,需服务器装 Chrome。**结论(2026-09-10):方案一为主力**——方案二/三的矢量字体在 125 PPI 下小字必然毛糙(抗锯齿灰边无论由平台还是我们二值化都只能变成锯齿/断笔,见 `output/e1002/font_compare.png`:矢量字体单色渲染 ≈ 抗锯齿后二值化,都不如点阵),Canvas 逐像素画也改变不了这点(Pillow 本身就是逐像素画)。方案二/三端点保留供对比,不再投入。宽版**只用点阵字体的整数倍字号 12/24/36/48/84**(`E1002Renderer.__init__` 把基类的 F13/F16/FPACE 都映射到 12px——用量行 标签常规字重、右对齐贴进度条(基类类属性 `LABEL_BOLD`/`LABEL_ALIGN`/`LABEL_GAP`),百分比与 pace 数字粗体;24px 试过被嫌大;另加 F84 给顶部大时钟),可用 `all(v % 12 == 0 for v in 字体尺寸)` 自查。天气/橘喵/BTC 走**服务端内存缓存 + 有效期**(`WEATHER_TTL`/`MAO_TTL`/`BTC_TTL`,默认 900/60/300 秒,0=每次实拉;请求时过期才实拉并更新时间戳,失败沿用旧值、不更新时间戳),服务**不读本机任何文件**;Claude/ChatGPT 用量**由本机 `main.py --target push` 推送原始快照**(`X-Push-Token` 鉴权),服务端内存暂存、渲染时现算 pace。静态形态 `main.py --target e1002`(`output/e1002/index.html` + `dashboard.png`)仍保留。渲染由 `renderer_e1002.py` 完成:继承 `DashboardRenderer` 组件、**k=1 字号与三色版完全相同(不放大,放大后字太大很傻)**,四段版式:A 时钟/天气;B 左 **Claude Usage 三行(all 5h / all 7d / fable 7d,标签沿用 statusline 写法、小写开头)**、右 **ChatGPT Usage(mock,标「示例数据」)**,紧凑单行样式(曾试过撑满高度的大卡片,被否);C **橘喵·今日实时**五列全宽(探店单量/探店流水/团购单量/团购流水/毛利,2026-09-11 接口新增团购字段后由三列改五列;列内四行逐行居中:名 F12 / 值 F24 粗 / 环比 / 同比,与小屏同构——五列每列仅 ~157px,此前「值 F36 粗 + 右侧叠放的环比/同比」并排组约 194px 放不下、降 F24 后 156px 仍贴分隔线,故改竖排;更早试过名与值同行、左上角角标,均已否);D **BTC/USDT 时 K 蜡烛图(近 48 小时 = 2 天,2026-09-14 由 72 根改 48 根)**(免费公开行情:OKX 主源、Coinbase/Huobi 备源,`btc_api.py`,5 分钟缓存;周期/根数 `BTC_BAR`/`BTC_LIMIT` 可配,日 K 也支持;**涨绿跌红**)。此前放过 30 天折线图(用户明确不要)和「近 7 天」数据表(已被 K 线替换)。六色语义:用量进度条(含边框、百分比数字三者同色——2026-09-14 用户要求百分比随条色)与 pace 各自**三档、颜色顺序固定 绿→黑→红、两者独立取色**:用量 ≤ `USAGE_OK` 30 绿「健康」|30~80 黑「正常」|≥ `USAGE_WARN` 80 红「危险」;pace ≤ −`PACE_TOL` 10 绿「节余」|±10 内黑「持平」|≥ +10 红「偏快」。**黄不用**:2026-09-14 曾按要求加过 60%~80% 黄档(含 `USAGE_NOTICE`/`PACE_WARN` 两个阈值),同日被否并整体移除,别再加回。红另用于涨/定位/太阳闪电,蓝=雨雪。 +> **分支 `feature/reterminal-e1002`**(基于 `feature/colorful`):新增第二个目标设备 **Seeed reTerminal E1002**(7.3 寸 **800×480**,E Ink **Spectra 6 原生六色**:黑/白/黄/红/绿/蓝,真机已验证六色量化干净)。该设备不接受局域网直推,而是由 **SenseCraft HMI** 云端的 **HTML 控件**按设备刷新间隔(最小 5 分钟)**每周期重新抓取**一个公网 HTTPS URL、无头浏览器截图后量化下发;不支持 ETag/304,内容不变也会整屏重绘(全刷约 17 秒)。**真机验证(2026-09)**:Image 控件只在填写 URL 时抓一次快照、此后不再更新;HTML 控件填同一 URL 则每周期重抓——所以对外只用 HTML 控件。当前主形态是 **`server.py` 动态服务**(部署公网 HTTPS;局域网地址/明文 HTTP 云端抓不到):三条恒定 URL 并行供对比——方案一 `IMAGE_PATH`(默认 `/e1002.png`)每次请求**内存中现渲染 PNG**(Pillow 点阵版);方案二 `HTML_PATH`(默认 `/e1002.html`)每次请求生成 **800×480 现代网页**(`html_renderer.py`,系统/网络字体 + 内联 SVG K 线,无 JS)由平台截图——**真机实拍:文字抗锯齿灰边被平台抖动成毛边、小字与复杂汉字发糊**;方案三 `HTML_PNG_PATH`(默认 `/e1002-web.png`)同一网页由**服务端无头 Chrome 截图后按最近色(不抖动)固化为六色 PNG**(`html_shot.py`),平台拿到纯色图无从抖动,需服务器装 Chrome。**结论(2026-09-10):方案一为主力**——方案二/三的矢量字体在 125 PPI 下小字必然毛糙(抗锯齿灰边无论由平台还是我们二值化都只能变成锯齿/断笔,见 `output/e1002/font_compare.png`:矢量字体单色渲染 ≈ 抗锯齿后二值化,都不如点阵),Canvas 逐像素画也改变不了这点(Pillow 本身就是逐像素画)。方案二/三端点保留供对比,不再投入。宽版**只用点阵字体的整数倍字号 12/24/36/48/84**(`E1002Renderer.__init__` 把基类的 F13/F16/FPACE 都映射到 12px——用量行 标签常规字重、右对齐贴进度条(基类类属性 `LABEL_BOLD`/`LABEL_ALIGN`/`LABEL_GAP`),百分比与 pace 数字粗体;24px 试过被嫌大;另加 F84 给顶部大时钟),可用 `all(v % 12 == 0 for v in 字体尺寸)` 自查。天气/橘喵/BTC 走**服务端内存缓存 + 有效期**(`WEATHER_TTL`/`MAO_TTL`/`BTC_TTL`,默认 900/60/300 秒,0=每次实拉;请求时过期才实拉并更新时间戳,失败沿用旧值、不更新时间戳),服务**不读本机任何文件**;Claude/Codex 用量为**账户级、形态 A**:本机 `main.py --target push` 采集(Claude 复用 Claude Code 钥匙串登录态调 `/api/oauth/usage`;Codex 经 `codex app-server` RPC)后推送原始快照(`X-Push-Token` 鉴权),服务端内存暂存、**不持任何凭证**、渲染时现算 pace。静态形态 `main.py --target e1002`(`output/e1002/index.html` + `dashboard.png`)仍保留。渲染由 `renderer_e1002.py` 完成:继承 `DashboardRenderer` 组件、**k=1 字号与三色版完全相同(不放大,放大后字太大很傻)**,四段版式:A 时钟/天气;B 左 **Claude Usage 两行(5h / 合并行 7d——整行高度不变、**一个大边框**,边框内上下精确对半两条 7px **实心**填充(紧挨、无空隙,2026-09-14 用户要求去掉白隔):上=总 7d、下=fable 7d,各按自身档位取色;右侧百分比与 pace **只显示两者中较大者**,边框与文字随其档位取色;`E1002Renderer._usage_row_split` 以较大者合成一条交给基类 `_usage_row`(已返回条几何)画边框/文字后在内部重绘两条半高实心。2026-09-14 为与右块对称而合并,先做过「两条各带边框的细条+两行数字」版被否;标签去掉 all 前缀)**、右 **Codex Usage 两行(5h / 7d——ChatGPT 订阅的 Codex 额度池;服务端未收到推送时 mock 标「示例数据」)**。宽版用量行的**空值不显示 "--"**:pct 为 None 按 0 画("00%"、pace "00"、空条、全黑中性色),且 pace 列在 diff=0 时仍预留三角位——各行进度条右端齐平、等宽(2026-09-14;Codex Plus 接口 `secondary_window` 就是 null,原始 `wham/usage` 已核对,5h 行为空是数据如此),紧凑单行样式(曾试过撑满高度的大卡片,被否);C **橘喵·今日实时**五列全宽(探店单量/探店流水/团购单量/团购流水/毛利,2026-09-11 接口新增团购字段后由三列改五列;列内四行逐行居中:名 F12 / 值 F24 粗 / 环比 / 同比,与小屏同构——五列每列仅 ~157px,此前「值 F36 粗 + 右侧叠放的环比/同比」并排组约 194px 放不下、降 F24 后 156px 仍贴分隔线,故改竖排;更早试过名与值同行、左上角角标,均已否);D **BTC/USDT 时 K 蜡烛图(近 48 小时 = 2 天,2026-09-14 由 72 根改 48 根)**(免费公开行情:OKX 主源、Coinbase/Huobi 备源,`btc_api.py`,5 分钟缓存;周期/根数 `BTC_BAR`/`BTC_LIMIT` 可配,日 K 也支持;**涨绿跌红**)。此前放过 30 天折线图(用户明确不要)和「近 7 天」数据表(已被 K 线替换)。六色语义:用量进度条(含边框、百分比数字三者同色——2026-09-14 用户要求百分比随条色)与 pace 各自**三档、颜色顺序固定 绿→黑→红、两者独立取色**:用量 ≤ `USAGE_OK` 30 绿「健康」|30~80 黑「正常」|≥ `USAGE_WARN` 80 红「危险」;pace ≤ −`PACE_TOL` 10 绿「节余」|±10 内黑「持平」|≥ +10 红「偏快」。**黄不用**:2026-09-14 曾按要求加过 60%~80% 黄档(含 `USAGE_NOTICE`/`PACE_WARN` 两个阈值),同日被否并整体移除,别再加回。**今日实时的环比/同比「三角 + 数值」涨绿跌红**(2026-09-14 改,与 K 线一致;基类钩子 `_cmp_color`,三色小屏仍涨红);红另用于定位/太阳闪电,蓝=雨雪。 ## 命令 @@ -21,7 +21,9 @@ python3 main.py # 取数据 → 渲染 → 推送(设 python3 main.py --render-only # 只渲染到 output/dashboard.png,不推送(本地调试渲染时用这个) python3 main.py --target e1002 # reTerminal E1002 静态形态:渲染 800x480 六色图 + 固定尺寸 index.html 到 output/e1002/,不推送 python3 server.py # reTerminal E1002 动态服务(部署公网):GET /e1002.png(内存现渲染 PNG)/ GET /e1002.html(现代网页)/ GET /e1002-web.png(网页截图固化六色)/ /health -python3 main.py --target push # 本机:把 ~/.claude/usage-snapshot.json 推送到 server.py(需 .env 的 PUSH_URL + PUSH_TOKEN) +python3 main.py --target push # 本机:采集 Claude(钥匙串登录态→/api/oauth/usage)与 Codex(codex app-server RPC)账户级用量并推送到 server.py(需 .env 的 PUSH_URL + PUSH_TOKEN) +python3 claude_usage.py [--force] # 单独调试 Claude 采集(--force 跳过 TTL 缓存) +python3 codex_usage.py [--force] # 单独调试 Codex 采集;需 Codex 已用 ChatGPT 账号 `codex login`(默认 ~/.codex) python3 probe_server.py # 验证工具:同一 URL 是否被平台周期重抓(带访问日志) sh run.sh # cron 包装脚本:cd 项目目录 + vfox python 绝对路径 + 追加日志到 output/cron.log @@ -32,22 +34,24 @@ EPD_HOST=epd400a44.local python3 main.py 无测试、无 lint。调试渲染效果就跑 `--render-only` 看 `output/dashboard.png`。 -**配置统一走 `.env`**:所有凭证/参数放同目录 `.env`(已在 `.gitignore`,本机专用;模板见已提交的 `.env.example`)。`config.py` 顶部用零依赖加载器 `_load_dotenv()` 把 `.env` 写入 `os.environ`,再由各 `os.environ.get(...)` 读取;采用 `setdefault`,故**真实环境变量(cron/命令行注入)优先级高于 `.env`**。`config.py` 内不再保留任何明文凭证(fallback 为空串)。可配置项:`EPD_HOST`(局域网设备 mDNS host,默认 `epd400a44.local`)、`JM_BASE_API`/`JM_CLIENT_ID`/`JM_CLIENT_SECRET`/`JM_GRANT_TYPE`/`JM_TENANT_ID`、`QWEATHER_HOST`/`QWEATHER_KEY`/`WEATHER_LOCATION`(默认南京·江宁)/`WEATHER_TTL`、`USAGE_LOCAL_PATH`/`USAGE_WARN`、仅宽版的 `USAGE_OK`/`PACE_TOL`/`BTC_BAR`/`BTC_LIMIT`/`BTC_TTL`/`MAO_TTL`、`E1002_COLOR_STRIP`。 +**配置统一走 `.env`**:所有凭证/参数放同目录 `.env`(已在 `.gitignore`,本机专用;模板见已提交的 `.env.example`)。`config.py` 顶部用零依赖加载器 `_load_dotenv()` 把 `.env` 写入 `os.environ`,再由各 `os.environ.get(...)` 读取;采用 `setdefault`,故**真实环境变量(cron/命令行注入)优先级高于 `.env`**。`config.py` 内不再保留任何明文凭证(fallback 为空串)。可配置项:`EPD_HOST`(局域网设备 mDNS host,默认 `epd400a44.local`)、`JM_BASE_API`/`JM_CLIENT_ID`/`JM_CLIENT_SECRET`/`JM_GRANT_TYPE`/`JM_TENANT_ID`、`QWEATHER_HOST`/`QWEATHER_KEY`/`WEATHER_LOCATION`(默认南京·江宁)/`WEATHER_TTL`、`USAGE_WARN`、Claude 采集 `CLAUDE_USAGE_TTL`(快照有效期,语义同 `WEATHER_TTL`,0=每次实拉)/`CLAUDE_USAGE_BACKOFF`/`CLAUDE_USAGE_SCOPE_MODEL`、Codex 采集 `CODEX_BIN`/`CODEX_USAGE_HOME`/`CODEX_USAGE_TTL`/`CODEX_USAGE_BACKOFF`、仅宽版的 `USAGE_OK`/`PACE_TOL`/`BTC_BAR`/`BTC_LIMIT`/`BTC_TTL`/`MAO_TTL`、`E1002_COLOR_STRIP`。 -**定时部署**:由 macOS **launchd LaunchAgent** 触发 `run.sh`(不再用 cron——cron 在现代 macOS 上易被 TCC/权限静默拦截,launchd 是系统原生调度器)。LaunchAgent plist 装在 `~/Library/LaunchAgents/com.jm.eink-push.plist`(机器专用,未入库,等价于 `run.sh`/`.env` 的本机产物),`StartInterval=300` + `RunAtLoad=true`:登录/重启后立即跑一次,此后每 5 分钟一次。用 LaunchAgent(非 LaunchDaemon)是因为脚本依赖当前用户目录下的文件(`~/.claude/usage-snapshot.json`、`.env`),必须在用户会话下运行——机器需已登录(或开了自动登录)才能保证开机即触发。`run.sh` 内部逻辑不变:cron 不继承登录环境,故脚本内 python 写死 vfox 绝对路径、并 `cd` 到项目目录后追加日志到 `output/cron.log`(`cd` 后 `config.py` 才能就近找到 `.env`);launchd 自身的 stdout/stderr 另落 `output/launchd.log`(正常应为空,非空说明进程本身起不来,而非业务报错)。常用排查:`launchctl list | grep com.jm.eink-push` 看是否常驻及最近退出码,`launchctl kickstart -k gui/$(id -u)/com.jm.eink-push` 手动立即触发一次;改 plist 后需 `launchctl bootout` + `bootstrap` 重新加载才生效(热改文件不会自动生效)。每 5 分钟触发:业务数据每次都刷新,天气靠缓存、用量读本地文件,避免高频打接口(见 `data.py`)。 +**定时部署**:由 macOS **launchd LaunchAgent** 触发 `run.sh`(不再用 cron——cron 在现代 macOS 上易被 TCC/权限静默拦截,launchd 是系统原生调度器)。LaunchAgent plist 装在 `~/Library/LaunchAgents/com.jm.eink-push.plist`(机器专用,未入库,等价于 `run.sh`/`.env` 的本机产物),`StartInterval=300` + `RunAtLoad=true`:登录/重启后立即跑一次,此后每 5 分钟一次。用 LaunchAgent(非 LaunchDaemon)是因为脚本依赖当前用户的登录钥匙串(Claude Code 凭证)、`~/.codex`(Codex 登录态)、`.env`,必须在用户会话下运行——机器需已登录(或开了自动登录)才能保证开机即触发。`run.sh` 内部逻辑不变:cron 不继承登录环境,故脚本内 python 写死 vfox 绝对路径、并 `cd` 到项目目录后追加日志到 `output/cron.log`(`cd` 后 `config.py` 才能就近找到 `.env`);launchd 自身的 stdout/stderr 另落 `output/launchd.log`(正常应为空,非空说明进程本身起不来,而非业务报错)。常用排查:`launchctl list | grep com.jm.eink-push` 看是否常驻及最近退出码,`launchctl kickstart -k gui/$(id -u)/com.jm.eink-push` 手动立即触发一次;改 plist 后需 `launchctl bootout` + `bootstrap` 重新加载才生效(热改文件不会自动生效)。每 5 分钟触发:业务数据每次都刷新,天气靠缓存、两路用量各有 `CLAUDE_USAGE_TTL`/`CODEX_USAGE_TTL` 有效期缓存(默认 300s,同一分钟内小屏渲染与上报两个进程只打一次接口),避免高频打接口(见 `data.py`)。 ## 架构(数据 → 渲染 → 推送 三层) `main.py` 串联三层,各层职责单一、低耦合: -- **`data.py`** — 两种形态:本机 `get_dashboard_data()`(带文件缓存,用量读本地快照);服务端用纯拉取函数 `weather_fetch()`/`mao_fetch()`/`btc_fetch()` + `usage_from_raw(now, raw, windows, title)`(`_usage(now, raw=)`/`_usage_gpt(now, raw=)` 都基于它,raw=None 时前者读本地快照、后者用 `gpt_mock_raw()`)。所有「现在」用 `now_tz()`、时间戳格式化用 `_local()`(`TZ=ZoneInfo(config.TZ_NAME)`),本机与境外服务端结果一致。返回固定结构的 dict:`date`/`weather`/`mao`/`usage`/`usage_gpt`/`btc`。日期时间用真实系统时间;橘喵今日经营由 `_mao()` 经 `jm_api`、天气由 `_weather()` 经 `weather_api`(和风,定位南京·江宁)、Claude 用量由 `_usage()` 经 `usage_local`,**任何异常都回退占位 `"--"`,不回退 mock**。改数据保持返回结构不变即可(renderer 依赖其 key:`weather.icon` 为和风图标代码;`mao.cols[]` 为 `{k, v, hb, tb, wide}`,五列由 `REALTIME_COLS`(标签、接口字段前缀、格式化、是否仅宽版)驱动,`_col_from()` 用 `.get()` 取 `today{Base}`/`{base}WowRate`/`{base}YoyRate`,字段缺失(旧后端未上团购)该列占位 `--`;`wide=True`(团购两列)小屏 `render()` 跳过、宽版全显;`hb/tb` 为环比/同比 `(direction, text)`,direction 为 `None` 时不画三角;接口文档见 `../docs/首页看板接口文档-20260911.md`(毛利口径已含团购、金额与增长率为字符串、增长率可 `null`);`usage.bars[]` 为 `{k(短标签,小屏用), label(长标签 all 5h/fable 7d,宽版用), pct(或 None), time_pct?, reset?, scoped}`、`usage.warn`/`usage.ok`/`usage.pace_tol` 为用量与 pace 的三档阈值(小屏只用 warn)、`usage.updated` 为快照落盘时刻;`usage_gpt` 与 `usage` 同构,目前是 **mock**(`mock: True`;三行故意取 88/71/22 以常驻红/黑/绿三档),接真实数据源时替换 `_usage_gpt()` 即可)。注:`_mao` 仍会拉 `mao.trend`,但两个版面当前都不用它。宽版另有 `btc`:`_btc()` 经 `btc_api.get_candles(BTC_BAR, BTC_LIMIT)` 取 K 线(默认 1H×48),结构 `{symbol, bar, src, candles:[{t, o, h, l, c}], last, chg, chg_label}`,chg 时 K 取相对 24 根前收盘(24h)、日 K 取相对上一根;缓存 `.btc_cache.json`(TTL=`BTC_TTL` 默认 5 分钟,周期变更即失效,失败沿用旧缓存,仍无则 candles 为空 → 面板 `--`)。 - - **缓存**(为 launchd 每 5 分钟独立进程而设,必须落盘):天气 `.weather_cache.json`(TTL=`WEATHER_TTL`,默认 15min)、趋势 `.trend_cache.json`(按日期 key,每天只拉一次)、BTC K 线 `.btc_cache.json`(TTL=`BTC_TTL`,默认 5min);三者拉取失败沿用旧缓存。**用量不自建缓存**——直接读 statusline 生产的本地快照 `~/.claude/usage-snapshot.json`(见 `usage_local`),失败即 `"--"`。两个缓存文件已在 `.gitignore`。 - - **用量的 pace(时间维度对比)**:`_usage()` 每次按当前时间**现算** `time_pct=(窗口已流逝/窗口长)`、`pace=pct−time_pct`(>0 超前↑ / <0 节余↓);故即使 `utilization` 来自可能滞后的本地缓存,pace 仍随时钟准确推进。5h 窗口 18000s、7d 窗口 604800s。 +- **`data.py`** — 两种形态:本机 `get_dashboard_data()`(带文件缓存,两路用量经本机采集器);服务端用纯拉取函数 `weather_fetch()`/`mao_fetch()`/`btc_fetch()` + `usage_from_raw(now, raw, windows, title)`(`_usage(now, raw=)`/`_usage_gpt(now, raw=, allow_local=)` 都基于它,raw=None 时前者经 `claude_usage` 本机采集、后者经 `codex_usage` 本机采集,Codex 采集失败或服务端 `allow_local=False` 时用 `gpt_mock_raw()` 并标 mock)。所有「现在」用 `now_tz()`、时间戳格式化用 `_local()`(`TZ=ZoneInfo(config.TZ_NAME)`),本机与境外服务端结果一致。返回固定结构的 dict:`date`/`weather`/`mao`/`usage`/`usage_gpt`/`btc`。日期时间用真实系统时间;橘喵今日经营由 `_mao()` 经 `jm_api`、天气由 `_weather()` 经 `weather_api`(和风,定位南京·江宁)、Claude 用量由 `_usage()` 经 `claude_usage`、Codex 用量由 `_usage_gpt()` 经 `codex_usage`,**Claude 任何异常都回退占位 `"--"`,不回退 mock**(Codex 无数据时回退 mock 并显式标「示例数据」)。改数据保持返回结构不变即可(renderer 依赖其 key:`weather.icon` 为和风图标代码;`mao.cols[]` 为 `{k, v, hb, tb, wide}`,五列由 `REALTIME_COLS`(标签、接口字段前缀、格式化、是否仅宽版)驱动,`_col_from()` 用 `.get()` 取 `today{Base}`/`{base}WowRate`/`{base}YoyRate`,字段缺失(旧后端未上团购)该列占位 `--`;`wide=True`(团购两列)小屏 `render()` 跳过、宽版全显;`hb/tb` 为环比/同比 `(direction, text)`,direction 为 `None` 时不画三角;接口文档见 `../docs/首页看板接口文档-20260911.md`(毛利口径已含团购、金额与增长率为字符串、增长率可 `null`);`usage.bars[]` 为 `{k(短标签,小屏用), label(长标签 5h/7d/fable 7d,宽版用), pct(或 None), time_pct?, reset?, scoped}`(宽版把 scoped 行并入紧邻其前的非 scoped 行作同一边框内的下半实心)、`usage.warn`/`usage.ok`/`usage.pace_tol` 为用量与 pace 的三档阈值(小屏只用 warn)、`usage.updated` 为快照采集时刻;`usage_gpt` 与 `usage` 同构、标题「Codex Usage」、只有 5h/7d 两行(快照各窗口可带 `window` 真实秒数,pace 优先按它算),服务端未收到推送时为 mock(`mock: True`,88/22 覆盖红/绿两端)。注:`_mao` 仍会拉 `mao.trend`,但两个版面当前都不用它。宽版另有 `btc`:`_btc()` 经 `btc_api.get_candles(BTC_BAR, BTC_LIMIT)` 取 K 线(默认 1H×48),结构 `{symbol, bar, src, candles:[{t, o, h, l, c}], last, chg, chg_label}`,chg 时 K 取相对 24 根前收盘(24h)、日 K 取相对上一根;缓存 `.btc_cache.json`(TTL=`BTC_TTL` 默认 5 分钟,周期变更即失效,失败沿用旧缓存,仍无则 candles 为空 → 面板 `--`)。 + - **缓存**(为 launchd 每 5 分钟独立进程而设,必须落盘):天气 `.weather_cache.json`(TTL=`WEATHER_TTL`,默认 15min)、趋势 `.trend_cache.json`(按日期 key,每天只拉一次)、BTC K 线 `.btc_cache.json`(TTL=`BTC_TTL`,默认 5min);三者拉取失败沿用旧缓存。两路用量各有采集缓存 `.claude_usage_cache.json`/`.codex_usage_cache.json`(`usage_common.cached_snapshot`:距上次成功 0 超前↑ / <0 节余↓);故即使 `utilization` 来自缓存/推送暂存,pace 仍随时钟准确推进。5h 窗口 18000s、7d 窗口 604800s(Codex 快照带 `window` 时用真实值)。 - **`jm_api.py`** — jm-devops 后端统计接口客户端,**设备密钥授权**(`grant_type=client_secret`,免验证码)。先用 `CLIENT_ID + CLIENT_SECRET` 调 `POST /auth/login` 换 token(进程内缓存、按 `expire_in` 提前刷新),再带 `Authorization: Bearer ` + `clientid` 头调 `/system/statistics/{realtime,recent-days}`;遇 401 清 token 重登一次重试。token 字段为蛇形 `access_token`/`expire_in`。注:历史上曾免鉴权直调、更早曾用账号密码+验证码 OCR 登录(ddddocr 不稳定),现统一为 client_secret 授权——密钥即长期凭证,务必走 HTTPS、优先用 `JM_CLIENT_SECRET` 环境变量、可在后端 `sys_client` 轮换。 - **`weather_api.py`** — 和风天气(QWeather)客户端。用**用户专属 API Host**(`QWEATHER_HOST`)+ `X-QW-Api-Key` 头鉴权;响应 **gzip 压缩**(按 magic number 手动解压)、返回 `code` 为**字符串**。`get_now()` 取实时(`now.text` 中文天气 / `now.temp` / `now.icon` 图标代码),`get_today()` 取 `/3d` 的 `daily[0]` 今日温区。`QWEATHER_HOST`/`QWEATHER_KEY` 未配置时直接抛错→天气区显示 `"--"`。 - **`btc_api.py`** — 比特币 K 线客户端,免费公开接口、无需密钥,`get_candles(bar, limit)`,周期代号 `BARS = {"1H", "1D"}` 映射到各源写法,逐源兜底 `SOURCES = (okx, coinbase, huobi)`:OKX `/api/v5/market/candles?instId=BTC-USDT&bar=`(主源,本地直连可达且最新)、Coinbase `/products/BTC-USD/candles?granularity=`、Huobi `/market/history/kline?period=`;各源字段顺序不同(OKX/Huobi 最新在前,Coinbase 为 [time, low, high, open, close]),统一归一为升序 `{ts, o, h, l, c}`。Binance 在本地区返回 restricted location,不列入。 -- **`usage_local.py`** — Claude Code 用量本地读取,**不发任何网络请求**(**生产者/消费者**模式):只读 `config.USAGE_LOCAL_PATH`(默认 `~/.claude/usage-snapshot.json`)。**生产者 = 打过补丁的 claude-statusline**(本机 `~/.claude/statusline.sh`):把每次 Claude Code 经 stdin 喂来的**权威实时**额度落盘为该快照(schema 同 `/api/oauth/usage`:`five_hour`/`seven_day` 的 `utilization` + `resets_at`),并额外写入 **`seven_day_fable`**(Fable 模型限定的 7 天额度——stdin 里没有,只在 `/api/oauth/usage` 返回的 `limits[]` 中 `scope.model.display_name == "Fable"` 那项;statusline 在 stdin 分支也会每 5 分钟刷新一次 API 缓存 `/tmp/claude/statusline-usage-cache.json` 以拿到它,无 token/断网时该键为 `null`);`data.USAGE_WINDOWS` 用 `scoped=True` 标记这一行,400×300 小屏 `render()` 跳过 scoped 行、800×480 宽版显示。本模块作消费者读取。活跃使用时快照几乎持续刷新、准确;空闲时停在最后一次。文件缺失/损坏→用量区 `"--"`。注:`resets_at` 可能是 **epoch 数字**(Claude Code stdin 给的就是 epoch)或 ISO 串,`data._parse_reset_ts` 两者兼容。(曾评估直调 `/api/oauth/usage`,改为读本地快照以免网络请求。) -- **`renderer.py`** — `DashboardRenderer.render(data)` 返回 PIL `'RGB'` 模式、**仅黑/白/红三色**图;组件层提供可覆写钩子/参数供宽版复用:`_usage_colors()`(用量行 填充/边框/文字 三色元组)、`_pace_color()`(pace 数值与三角颜色)、`_usage_row(label_w=)`、`_weather_icon(wet=)`(降水元素颜色)、`_label(x0,x1)`、`_cmp_at()`(环比/同比左对齐版,`_cmp_line` 居中版基于它);字体另有 `F36`(3 倍整数缩放)。`render_to_file()` 落盘。布局硬编码为**上中下三段**,两道 `hdash` 横规分隔(y=90 / 200): +- **`claude_usage.py`** — Claude **账户级**用量采集(形态 A,跑在本机):**复用 Claude Code 自身的 OAuth 登录态**,直接 GET `https://api.anthropic.com/api/oauth/usage`(头 `Authorization: Bearer ` + `anthropic-beta: oauth-2025-04-20`)——与 Claude Code `/usage`、claude.ai 用量页同源,CLI/桌面 App/网页消耗都算,**不依赖 Claude Code 进程在跑、不依赖 statusline 或任何插件**(2026-09-14 起替代旧的「statusline 补丁落盘 `~/.claude/usage-snapshot.json`、本项目读文件」方案,`usage_local.py` 已删)。token 来源按序:环境变量 `CLAUDE_CODE_OAUTH_TOKEN` → macOS 钥匙串 generic password(service `Claude Code-credentials`,JSON `claudeAiOauth.accessToken/expiresAt(ms)`)→ `~/.claude/.credentials.json`(尊重 `CLAUDE_CONFIG_DIR`)。**铁律**:① **只读、绝不刷新 token**(refresh token 每次使用即轮换,第三方刷新不写回会让 Claude Code 强制重登,多个官方 issue 证实);access token 约 8 小时有效,过期抛 `TokenUnavailable` → 沿用旧快照,等 Claude Code 下次启动自动刷新;② **有效期 `CLAUDE_USAGE_TTL` 内不再调、建议 ≥300 秒**(突发调用 429 会封约 24 小时并连带 Claude Code `/usage`、claude.ai 一起看不到);③ 直连不走代理。`normalize()` 优先解析 `limits[]`(`kind`=session/weekly_all/weekly_scoped,模型限定周额度按 `scope.model.display_name == CLAUDE_USAGE_SCOPE_MODEL`「Fable」识别——2026 年中起顶层 `seven_day_opus/sonnet` 已置 null,只能这样取),顶层 `five_hour/seven_day` 兜底;输出与旧快照同构 `{five_hour, seven_day, seven_day_fable|None, updated_at, source}`。`get_usage(force=)` 经 `usage_common.cached_snapshot` 带缓存/最小间隔/退避,`status` ∈ fetched/cache/stale/backoff。官方合规页禁止第三方「收集、存储、中介」claude.ai 凭证,未专门点名此端点,个人自用只读监控属灰色地带、无封号报告——所以凭证只留本机、服务端不碰。 +- **`codex_usage.py`** — Codex **账户级**用量采集(形态 A,跑在本机):经官方 **`codex app-server` stdio JSON-RPC** 读 ChatGPT 订阅的 **Codex 额度池**(CLI/IDE 扩展/Codex Web/ChatGPT 桌面 App 内 Codex 共用)。**调研结论(2026-09-14)**:ChatGPT 订阅的**普通聊天**额度没有任何可编程的账户级读取途径(私有端点在 Sentinel/Cloudflare 门后、且只在被限流时才有值;官方明确聊天与 Codex 分开计量),故右侧块如实命名「Codex Usage」。协议(本机 codex-cli 0.154 验证):`initialize` → 通知 `initialized` → `account/rateLimits/read`(params `{excludeResetCreditDetails:true}`),响应 `rateLimits.primary/secondary{usedPercent, windowDurationMins, resetsAt(epoch 秒)}` + `planType` + 多桶 `rateLimitsByLimitId`;未登录 ChatGPT 账号时 error「codex account authentication required to read rate limits」。**窗口归类按 `windowDurationMins`**(≤12h → five_hour,≥5 天 → seven_day),Pro 计划常只回一个窗口、另一个 null,不能按 primary/secondary 位置判断;快照各窗口带 `window` 真实秒数供 pace 用。为什么不直调 `chatgpt.com/backend-api/wham/usage`:token 刷新由 Codex CLI 代管(refresh token 轮换,外部自刷会踢掉 CLI 登录,官方 CI 文档也明示「不要自己调刷新接口」)。**登录**:2026-09-14 用户直接在默认 `~/.codex` 用 ChatGPT 账号 `codex login`(`auth_mode=chatgpt`,原 API Key 仍保留在 auth.json;`config.toml` 的第三方中转 base_url 只影响推理、不影响额度读取),故 `CODEX_USAGE_HOME` 默认留空跟随默认目录;若要隔离可设独立 `CODEX_HOME`(目录不存在会自动建)并在其下 `codex login`。**`CODEX_BIN` 由 `config._which_codex()` 解析为绝对路径**(launchd 的 PATH 不含 `/opt/homebrew/bin`,曾报「找不到 codex 可执行文件」进入退避;`.env` 已写死 `/opt/homebrew/bin/codex`)。子进程剥掉代理环境变量直连(chatgpt.com 本地直连可达)。**真实数据(Plus 计划)**:`rateLimits.primary` 只有周窗口(`windowDurationMins=10080`)、`secondary=null`,`rateLimitsByLimitId` 仅一个桶 `codex`——故 5h 行为 `--`、7d 行有值;`buckets` 原样附带,若将来出现第二个桶再决定是否加第三行。 +- **`usage_common.py`** — 两个采集器共用的骨架 `cached_snapshot(path, ttl, backoff, fetch, force)`:距上次成功 `(`h3 b::before` 的方块装饰只给标题)。 -- **`push_client.py`** — 本机推送端:读 `~/.claude/usage-snapshot.json` 原文 `POST {PUSH_URL}/push/usage`,直连、连接类异常重试 2 次、4xx/5xx 直接报错;ChatGPT 暂无本机源不推(将来按 `data.GPT_WINDOWS` 键组 dict 推 `/push/usage_gpt`)。 +- **`push_client.py`** — 本机推送端:`SOURCES` 两路——`claude_usage.get_usage()` → `POST {PUSH_URL}/push/usage`、`codex_usage.get_usage()` → `POST {PUSH_URL}/push/usage_gpt`(推前去掉 `status` 键);两路彼此独立,一路失败只打日志、两路都失败才非零退出;直连不走代理、连接类异常重试 2 次、4xx/5xx 直接报错。 - **`probe_server.py`** — 验证工具(不参与正常流程):极简 HTTP 服务,`/probe.png` 每 `--every` 秒重绘一张带大号时刻+序号的 800×480 探针图,响应 `Cache-Control: no-store`,每次请求打印时间/UA/来源 IP(含 `CF-Connecting-IP`)。用途:配合 `cloudflared tunnel --url http://localhost:8787` 暴露成公网 HTTPS,填入 SenseCraft 的 Image 控件,凭**访问日志**判断平台对同一 URL 是否每个刷新周期重抓(成立则可直接覆盖同一 URL 的图片,省掉 HTML 页那一层)。**结论(已验证)**:Image 控件不重抓、HTML 控件重抓,见分支说明。 - **`config.py`** — 顶部 `_load_dotenv()` 先加载同目录 `.env`,再定义 API、设备、画布尺寸、字体路径、推送参数;凭证 fallback 为空串,真实值来自 `.env`/环境变量。 @@ -78,7 +82,7 @@ EPD_HOST=epd400a44.local python3 main.py ## 注意 - 所有凭证均在 `.env`(不提交);`config.py` 不再内置明文 fallback。分享代码时给出 `.env.example` 即可,真实 `.env` 切勿入库。 -- `.gitignore` 已忽略:`output/`、`__pycache__/`、`*.pyc`、两个缓存文件 `.weather_cache.json`/`.trend_cache.json`/`.btc_cache.json`、本机专用的 `run.sh`、含凭证的 `.env`(模板 `.env.example` 入库)。改缓存逻辑后想强制重拉,删掉对应缓存文件即可。 +- `.gitignore` 已忽略:`output/`、`__pycache__/`、`*.pyc`、缓存文件 `.weather_cache.json`/`.trend_cache.json`/`.btc_cache.json`/`.claude_usage_cache.json`/`.codex_usage_cache.json`、本机专用的 `run.sh`、含凭证的 `.env`(模板 `.env.example` 入库)。改缓存逻辑后想强制重拉,删掉对应缓存文件即可。 ## 验证与运维手法(会话沉淀) @@ -95,9 +99,8 @@ EPD_HOST=epd400a44.local python3 main.py 「设备收不到数据」先看 `output/cloudflared.log` 而不是 server;修复只能重启 cloudflared 拿新地址并更新 SenseCraft。 重启要点:旧进程对 SIGTERM 退出很慢(数秒),确认 `ps aux | grep "cloudflared tunnel"` 已清空再起新的;默认 QUIC 注册曾 `context deadline exceeded` 后自行退出,加 `--protocol http2` 一次成功。 - **shell 坑**:本机是 fish——`env -u …` 不能存进变量再当命令执行;含中文的 Python heredoc 用 `python3 -X utf8 - <<'EOF'`,否则 stdin 解码报错;`gh` 未登录且 GitHub API 匿名限流,取源码走 `curl raw.githubusercontent.com`。 -- **statusline 补丁验证**(`~/.claude/statusline.sh`,备份 `statusline.sh.bak-*`):用假 HOME 喂 stdin 不污染真实快照—— - `echo '' | HOME=/tmp/fakehome bash ~/.claude/statusline.sh`,查 `/tmp/fakehome/.claude/usage-snapshot.json` 是否含 `seven_day_fable`。 -- **定时任务**:`run.sh` 末尾已追加 `main.py --target push`(vfox python 绝对路径,`|| true`),用量随喵喵推送每 5 分钟同步到 server.py;推送暂存落盘 `output/push_state.json`。 +- **用量采集验证**:`python3 claude_usage.py --force` 应打出 `status: fetched` 且 `seven_day_fable` 非空;紧接着不带 `--force` 再跑应为 `cache`;缓存文件里不得出现 token。`python3 codex_usage.py --force` 未登录时报「authentication required…请先执行 CODEX_HOME=… codex login」,登录后应有 `five_hour`/`seven_day`。历史:statusline 补丁(`~/.claude/statusline.sh`)仍在本机产出 `~/.claude/usage-snapshot.json`,但本项目已不读它。 +- **定时任务**:`run.sh` 末尾已追加 `main.py --target push`(vfox python 绝对路径,`|| true`),Claude/Codex 两路账户级用量随喵喵推送每 5 分钟采集并同步到 server.py;推送暂存落盘 `output/push_state.json`。 - **上游配额**:server 三路上游已有 TTL 内存缓存(天气默认 15 分钟 → 和风约 192 次/天);改有效期只动 `.env`,不动代码。 - **SenseCraft 未公开后端**(从网页前端 bundle 提取,`https://sensecraft-hmi-api.seeed.cc`,头 `Authorization: `,token 在 localStorage `user-storage`): `POST /api/v1/oss/file/upload`(multipart `file`+`type=image`→`file_url`)→ `POST /api/v2/user/page`(`{pages:[{name,type:img|url|layout,data,dither,thumbnail,resolution:"800x480"}]}`) diff --git a/claude_usage.py b/claude_usage.py new file mode 100644 index 0000000..df772f1 --- /dev/null +++ b/claude_usage.py @@ -0,0 +1,155 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +Claude 账户级用量 —— 复用 Claude Code 自身的 OAuth 登录态,直接调 Anthropic 的 /api/oauth/usage。 + +这是 Claude Code `/usage` 命令、claude.ai 用量页共用的同一数据源,反映**整个订阅账户**的 5 小时 / 7 天 / +模型限定 7 天额度:不论额度是在 Claude Code CLI、桌面 App 还是网页消耗的,这里都能看到。 +与旧方案(statusline 插件把 Claude Code stdin 里的 rate_limits 落盘、本项目读文件)相比: +不依赖 Claude Code 进程在跑、不依赖任何第三方插件,空闲时也持续刷新。 + +鉴权复用方式与 statusline 一致——**只读** Claude Code 存下来的 access token: + 1. 环境变量 CLAUDE_CODE_OAUTH_TOKEN; + 2. macOS 登录钥匙串 generic password,service「Claude Code-credentials」,JSON 的 claudeAiOauth.accessToken; + 3. 文件 ~/.claude/.credentials.json(Linux / SSH 场景;尊重 CLAUDE_CONFIG_DIR)。 +铁律: + - **绝不刷新 token**。refresh token 每次使用即轮换,第三方刷新而不写回会让 Claude Code 下次 401 并强制重新登录。 + access token 约 8 小时有效,过期就沿用旧快照(标 stale),等 Claude Code 自己下次启动刷新。 + - **快照有效期 CLAUDE_USAGE_TTL 秒内不再调**(.env 可调,默认 300;0=每次实拉),429 后退避(见 usage_common)。 + - 必须与 Claude Code 在同一台机器、同一用户会话下运行(读钥匙串)——所以在形态 A 里它跑在本机,由 + main.py --target push 上报给 server.py,服务端不持任何凭证。 +输出快照结构(与旧 statusline 快照兼容,data.usage_from_raw 直接消费): + {five_hour:{utilization, resets_at}, seven_day:{...}, seven_day_fable:{...}|None, updated_at, source} +""" +import json +import os +import subprocess +import time +import urllib.error +import urllib.request + +import config +import usage_common + +API_URL = "https://api.anthropic.com/api/oauth/usage" +KEYCHAIN_SERVICE = "Claude Code-credentials" +TIMEOUT = 15 + + +class TokenUnavailable(RuntimeError): + """找不到 token 或已过期——本机状态而非接口限流:退避只需一个 TTL(Claude Code 随时可能刷新钥匙串),不按 BACKOFF 长等。""" + backoff = config.CLAUDE_USAGE_TTL + + +# ---------------------------------------------------------------- 凭证(只读) +def _credentials_blob(): + """按优先级取 claudeAiOauth JSON 串;找不到返回 None。""" + env_tok = os.environ.get("CLAUDE_CODE_OAUTH_TOKEN") + if env_tok: + return json.dumps({"claudeAiOauth": {"accessToken": env_tok}}) + try: + out = subprocess.run(["security", "find-generic-password", "-s", KEYCHAIN_SERVICE, "-w"], + capture_output=True, text=True, timeout=5) + if out.returncode == 0 and out.stdout.strip(): + return out.stdout.strip() + except (OSError, subprocess.TimeoutExpired): + pass + cfg_dir = os.environ.get("CLAUDE_CONFIG_DIR") or os.path.expanduser("~/.claude") + path = os.path.join(cfg_dir, ".credentials.json") + if os.path.exists(path): + with open(path, encoding="utf-8") as f: + return f.read() + return None + + +def read_token(): + """返回未过期的 access token;拿不到/已过期抛 TokenUnavailable。""" + blob = _credentials_blob() + if not blob: + raise TokenUnavailable("未找到 Claude Code 登录凭证(钥匙串「Claude Code-credentials」/ ~/.claude/.credentials.json)") + try: + oauth = json.loads(blob).get("claudeAiOauth") or {} + except json.JSONDecodeError as e: + raise TokenUnavailable(f"凭证 JSON 解析失败:{e}") from e + tok = oauth.get("accessToken") + if not tok: + raise TokenUnavailable("凭证里没有 accessToken") + exp = oauth.get("expiresAt") # 毫秒 epoch;缺失则不判过期 + if exp and float(exp) / 1000 <= time.time() + 30: + raise TokenUnavailable("Claude Code 的 access token 已过期(约 8 小时有效),等 Claude Code 下次启动自动刷新;本模块不做刷新") + return tok + + +# ---------------------------------------------------------------- 拉取与归一 +def fetch_raw(): + """GET /api/oauth/usage,返回原始 JSON。直连、不走代理(与本机其他脚本一致)。""" + req = urllib.request.Request(API_URL, headers={ + "Authorization": f"Bearer {read_token()}", + "anthropic-beta": "oauth-2025-04-20", + "Accept": "application/json", + "Content-Type": "application/json", + "User-Agent": config.CLAUDE_USAGE_UA, + }) + opener = urllib.request.build_opener(urllib.request.ProxyHandler({})) + try: + with opener.open(req, timeout=TIMEOUT) as resp: + return json.loads(resp.read().decode("utf-8")) + except urllib.error.HTTPError as e: + body = e.read().decode("utf-8", "replace")[:200] + if e.code == 429: + raise RuntimeError(f"HTTP 429 被限流(将退避 {config.CLAUDE_USAGE_BACKOFF}s;勿高频调用,否则约 24 小时内连 Claude Code /usage 也看不到):{body}") from e + if e.code == 401: + raise TokenUnavailable(f"HTTP 401 token 无效:{body}") from e + raise RuntimeError(f"HTTP {e.code}:{body}") from e + + +def _seg(percent, resets_at): + if percent is None: + return None + return {"utilization": float(percent), "resets_at": resets_at} + + +def normalize(raw, now=None): + """原始响应 → 快照。优先用 limits[](2026 年中起模型限定额度只在这里:kind=weekly_scoped + scope.model.display_name), + 顶层 five_hour / seven_day 作兜底;两处都没有的窗口为 None。""" + now = now or time.time() + five = seven = scoped = None + for lim in raw.get("limits") or []: + kind = lim.get("kind") + seg = _seg(lim.get("percent"), lim.get("resets_at")) + if kind == "session": + five = five or seg + elif kind == "weekly_all": + seven = seven or seg + elif kind == "weekly_scoped": + name = ((lim.get("scope") or {}).get("model") or {}).get("display_name") or "" + if name.lower() == config.CLAUDE_USAGE_SCOPE_MODEL.lower(): + scoped = scoped or seg + for key, cur in (("five_hour", five), ("seven_day", seven)): + if cur is None and isinstance(raw.get(key), dict): + cur = _seg(raw[key].get("utilization"), raw[key].get("resets_at")) + if key == "five_hour": + five = cur + else: + seven = cur + if scoped is None and isinstance(raw.get(config.CLAUDE_USAGE_SCOPE_FALLBACK_KEY), dict): + s = raw[config.CLAUDE_USAGE_SCOPE_FALLBACK_KEY] + scoped = _seg(s.get("utilization"), s.get("resets_at")) + return {"five_hour": five, "seven_day": seven, "seven_day_fable": scoped, + "updated_at": int(now), "source": "oauth-usage"} + + +def get_usage(force=False): + """对外入口:带缓存/最小间隔/退避的账户级用量快照(结构见模块说明)。失败且无旧快照时抛出。""" + snap, status = usage_common.cached_snapshot( + config.CLAUDE_USAGE_CACHE_PATH, config.CLAUDE_USAGE_TTL, config.CLAUDE_USAGE_BACKOFF, + lambda: normalize(fetch_raw()), force=force) + snap = dict(snap) + snap["status"] = status + return snap + + +if __name__ == "__main__": + import sys + d = get_usage(force="--force" in sys.argv) + print(json.dumps(d, ensure_ascii=False, indent=1)) diff --git a/codex_usage.py b/codex_usage.py new file mode 100644 index 0000000..e07c83e --- /dev/null +++ b/codex_usage.py @@ -0,0 +1,147 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +Codex 账户级用量 —— 通过官方 `codex app-server` JSON-RPC 读 ChatGPT 订阅的 Codex 额度(5 小时 / 7 天窗口)。 + +背景(2026-09 调研结论):ChatGPT 订阅的**普通聊天**额度没有任何可编程的账户级读取途径(私有端点在 +Sentinel/Cloudflare 门后、且只在被限流时才返回数值);可监控的账户级实时余额只有 **Codex 额度池**—— +Codex CLI / IDE 扩展 / Codex Web / ChatGPT 桌面 App 内的 Codex 共用同一池。本模块读的就是它。 + +为什么走 app-server RPC 而不是直调 chatgpt.com/backend-api/wham/usage:token 刷新由 Codex CLI 代管 +(refresh token 轮换、外部进程自刷会踢掉 CLI 登录),官方 CI 文档也明示「不要自己调刷新接口」。 +协议(已在本机 codex-cli 0.154 验证):stdio、按行 JSON-RPC—— + → {"id":1,"method":"initialize","params":{"clientInfo":{...}}} ← {"id":1,"result":{...}} + → {"method":"initialized"} + → {"id":2,"method":"account/rateLimits/read","params":{"excludeResetCreditDetails":true}} + ← {"id":2,"result":{"rateLimits":{"primary":{usedPercent,windowDurationMins,resetsAt},"secondary":...,"planType":...}, + "rateLimitsByLimitId":{...}}} + API Key 登录时返回 error「chatgpt authentication required to read rate limits」。 +登录:需 Codex 以 ChatGPT 账号 `codex login`(2026-09-14 用户在默认 ~/.codex 完成,故 CODEX_USAGE_HOME 默认留空跟随默认目录; +若日常 Codex 要保持 API Key/第三方中转登录,可设独立 CODEX_HOME 并在其下 `CODEX_HOME=… codex login`)。token 由 CLI 自管。 +CODEX_BIN 由 config 解析为绝对路径(launchd 的 PATH 不含 /opt/homebrew/bin,曾因此报「找不到 codex 可执行文件」)。窗口归类按 windowDurationMins(Pro 计划常只回一个窗口、另一个为 null, +不能按 primary/secondary 位置判断)。输出快照与 Claude 同构,data.usage_from_raw 直接消费: + {five_hour:{utilization, resets_at, window}, seven_day:{...}, plan, updated_at, source} +""" +import json +import os +import select +import subprocess +import time + +import config +import usage_common + +RPC_TIMEOUT = 25 # 秒:含 app-server 启动 + 一次远端查询 + + +def _env(): + env = {k: v for k, v in os.environ.items() + if k.upper() not in ("HTTPS_PROXY", "HTTP_PROXY", "ALL_PROXY", "NO_PROXY")} # 直连,不走代理 + if config.CODEX_USAGE_HOME: + home = os.path.expanduser(config.CODEX_USAGE_HOME) + os.makedirs(home, exist_ok=True) # codex 对不存在的 CODEX_HOME 直接退出;建空目录后它会给出清晰的「未登录」错误 + env["CODEX_HOME"] = home + return env + + +def _rpc_read_rate_limits(): + """起一个 app-server 子进程,完成握手并读一次额度;返回 account/rateLimits/read 的 result。""" + try: + p = subprocess.Popen([config.CODEX_BIN, "app-server"], stdin=subprocess.PIPE, stdout=subprocess.PIPE, + stderr=subprocess.PIPE, text=True, env=_env()) + except FileNotFoundError as e: + raise RuntimeError(f"找不到 codex 可执行文件(CODEX_BIN={config.CODEX_BIN})") from e + deadline = time.time() + RPC_TIMEOUT + + def send(obj): + p.stdin.write(json.dumps(obj) + "\n") + p.stdin.flush() + + def wait_for(rid): + while time.time() < deadline: + r, _, _ = select.select([p.stdout], [], [], 0.2) + if not r: + if p.poll() is not None: + raise RuntimeError(f"app-server 提前退出:{p.stderr.read()[:300]}") + continue + line = p.stdout.readline() + if not line: + raise RuntimeError(f"app-server 关闭了输出:{p.stderr.read()[:300]}") + try: + msg = json.loads(line) + except json.JSONDecodeError: + continue + if msg.get("id") != rid: + continue # 通知(remoteControl/status/changed 等)一律跳过 + if "error" in msg: + err = msg["error"].get("message", str(msg["error"])) + hint = "" + if "authentication required" in err.lower(): + hint = (f";请先执行 {'CODEX_HOME=' + config.CODEX_USAGE_HOME + ' ' if config.CODEX_USAGE_HOME else ''}" + f"codex login 用 ChatGPT 账号登录") + raise RuntimeError(f"RPC 错误:{err}{hint}") + return msg.get("result") or {} + raise RuntimeError(f"app-server 在 {RPC_TIMEOUT}s 内未响应 id={rid}") + + try: + send({"id": 1, "method": "initialize", + "params": {"clientInfo": {"name": "eink-push", "title": "eink-push usage", "version": "0.1"}}}) + wait_for(1) + send({"method": "initialized"}) + send({"id": 2, "method": "account/rateLimits/read", "params": {"excludeResetCreditDetails": True}}) + return wait_for(2) + finally: + try: + p.kill() + except OSError: + pass + + +def _classify(win, fallback_key): + """按窗口长度归类:≤12 小时 → five_hour,≥5 天 → seven_day;缺 windowDurationMins 时按位置兜底。""" + mins = win.get("windowDurationMins") + if mins is None: + return fallback_key + if mins <= 12 * 60: + return "five_hour" + if mins >= 5 * 24 * 60: + return "seven_day" + return None + + +def normalize(result, now=None): + now = now or time.time() + rl = result.get("rateLimits") or {} + out = {"five_hour": None, "seven_day": None, "plan": rl.get("planType"), + "updated_at": int(now), "source": "codex-app-server"} + for pos, fallback in (("primary", "five_hour"), ("secondary", "seven_day")): + win = rl.get(pos) + if not isinstance(win, dict) or win.get("usedPercent") is None: + continue + key = _classify(win, fallback) + if key and out.get(key) is None: + seg = {"utilization": float(win["usedPercent"]), "resets_at": win.get("resetsAt")} + if win.get("windowDurationMins"): + seg["window"] = int(win["windowDurationMins"]) * 60 # 秒,供 pace 用真实窗口长度 + out[key] = seg + # 多桶视图原样附带(key 为 limit_id,如 codex),目前不渲染,留作观察真实数据后再决定是否加第三行 + by_id = result.get("rateLimitsByLimitId") + if isinstance(by_id, dict) and by_id: + out["buckets"] = {k: {"primary": (v or {}).get("primary"), "secondary": (v or {}).get("secondary"), + "limitName": (v or {}).get("limitName")} for k, v in by_id.items()} + return out + + +def get_usage(force=False): + """对外入口:带缓存/最小间隔/退避的 Codex 额度快照。未登录 ChatGPT 账号且无旧快照时抛出。""" + snap, status = usage_common.cached_snapshot( + config.CODEX_USAGE_CACHE_PATH, config.CODEX_USAGE_TTL, config.CODEX_USAGE_BACKOFF, + lambda: normalize(_rpc_read_rate_limits()), force=force) + snap = dict(snap) + snap["status"] = status + return snap + + +if __name__ == "__main__": + import sys + print(json.dumps(get_usage(force="--force" in sys.argv), ensure_ascii=False, indent=1)) diff --git a/config.py b/config.py index 7013815..439bf4d 100644 --- a/config.py +++ b/config.py @@ -104,12 +104,37 @@ BTC_LIMIT = int(os.environ.get("BTC_LIMIT", "48")) # 展示最近 N 根 BTC_TTL = int(os.environ.get("BTC_TTL", "300")) # 秒,默认 5 分钟(本机文件缓存与 server.py 内存缓存共用) BTC_CACHE_PATH = os.path.join(BASE_DIR, ".btc_cache.json") -# ---- Claude Code 用量(Claude Usage 模块)数据源 ---- -# 只读本地、**不发任何网络请求**(详见 usage_local.py)——生产者/消费者模式: -# 生产者 = 打过补丁的 claude-statusline,把每次从 Claude Code stdin 拿到的**权威实时**额度 -# (five_hour/seven_day 的 utilization + resets_at)落盘到 ~/.claude/usage-snapshot.json;本项目读它。 -# 活跃使用时该快照几乎持续刷新、准确;空闲时停在最后一次。文件缺失时用量区显示 "--"。 -USAGE_LOCAL_PATH = os.environ.get("USAGE_LOCAL_PATH", os.path.expanduser("~/.claude/usage-snapshot.json")) +# ---- AI 用量数据源(Claude Usage / Codex Usage 两个模块)---- +# 形态 A:两路都在**本机**采集(需本机的登录态),由 main.py --target push 上报给 server.py;服务端不持任何凭证、不读本机文件。 +# Claude(claude_usage.py):复用 Claude Code 自身的 OAuth 登录态(macOS 钥匙串「Claude Code-credentials」,只读、绝不刷新), +# 直接调 https://api.anthropic.com/api/oauth/usage——与 Claude Code `/usage` 同源、**账户级**(CLI/桌面/网页消耗都算), +# 不依赖 Claude Code 进程在跑、不依赖 statusline 插件。 +CLAUDE_USAGE_TTL = int(os.environ.get("CLAUDE_USAGE_TTL", "300")) # 秒,快照有效期(同 WEATHER_TTL 语义,0=每次实拉);社区一致建议 ≥300(突发调用 429 会封约 24 小时) +CLAUDE_USAGE_BACKOFF = int(os.environ.get("CLAUDE_USAGE_BACKOFF", "1800")) # 秒;拉取失败后的退避期,期内沿用旧快照 +CLAUDE_USAGE_SCOPE_MODEL = os.environ.get("CLAUDE_USAGE_SCOPE_MODEL", "Fable") # limits[] 里模型限定周额度按 display_name 识别 +CLAUDE_USAGE_SCOPE_FALLBACK_KEY = os.environ.get("CLAUDE_USAGE_SCOPE_FALLBACK_KEY", "seven_day_opus") # 旧版顶层键兜底 +CLAUDE_USAGE_UA = os.environ.get("CLAUDE_USAGE_UA", "claude-code/2.1.34") +CLAUDE_USAGE_CACHE_PATH = os.path.join(BASE_DIR, ".claude_usage_cache.json") # 只存快照与时间戳,不存凭证 +# Codex(codex_usage.py):经官方 `codex app-server` JSON-RPC 读 ChatGPT 订阅的 Codex 额度池(CLI/IDE/Web/桌面 App 内 Codex 共用); +# token 刷新由 Codex CLI 代管。需 Codex 以 ChatGPT 账号登录(`codex login`);CODEX_USAGE_HOME 留空用默认 ~/.codex, +# 也可指向独立 CODEX_HOME 与日常配置隔离。 +def _which_codex(): + """launchd 不继承登录 shell 的 PATH(无 /opt/homebrew/bin),故按 PATH → Homebrew/常见位置解析出绝对路径。""" + import shutil + env = os.environ.get("CODEX_BIN", "") + if env: + return os.path.expanduser(env) + for cand in (shutil.which("codex"), "/opt/homebrew/bin/codex", "/usr/local/bin/codex", os.path.expanduser("~/.npm-global/bin/codex")): + if cand and os.path.exists(cand): + return cand + return "codex" +CODEX_BIN = _which_codex() +# 留空 = 用 Codex 默认目录 ~/.codex(2026-09-14 用户直接在默认目录用 ChatGPT 账号登录,故默认跟随之); +# 若日常 Codex 需保持 API Key/中转登录而另开一份 ChatGPT 登录,可设为独立目录(如 ~/.codex-usage)并在其下 `CODEX_HOME=… codex login`。 +CODEX_USAGE_HOME = os.environ.get("CODEX_USAGE_HOME", "") +CODEX_USAGE_TTL = int(os.environ.get("CODEX_USAGE_TTL", "300")) # 秒,快照有效期(0=每次实拉) +CODEX_USAGE_BACKOFF = int(os.environ.get("CODEX_USAGE_BACKOFF", "900")) +CODEX_USAGE_CACHE_PATH = os.path.join(BASE_DIR, ".codex_usage_cache.json") USAGE_WARN = int(os.environ.get("USAGE_WARN", "80")) # 用量 ≥ 此百分比 → 进度条与百分比转红预警(两个版面统一) # 以下两项仅六色宽版(E1002)使用,三色小屏只有 黑/红 两档。用量与 pace 各自三档、独立取色,颜色顺序固定为 绿 → 黑 → 红 # (黄不用:2026-09-14 曾按要求加过 60%~80% 黄档,随即被否——黄小字压白底发虚、且四档在三行里过于花): diff --git a/data.py b/data.py index cb0d135..68ab69c 100644 --- a/data.py +++ b/data.py @@ -18,7 +18,8 @@ from zoneinfo import ZoneInfo import config import jm_api import weather_api -import usage_local +import claude_usage +import codex_usage import btc_api WEEK_CN = ["周一", "周二", "周三", "周四", "周五", "周六", "周日"] @@ -311,26 +312,28 @@ def _fmt_reset(resets_at, window): # Claude 用量窗口:(快照键,短标签,长标签,窗口长度秒,scoped)。 -# 短标签 k 给 400x300 小屏(标签列只有 26px);长标签 label 给 800x480 宽版,沿用 statusline 的写法 -# (all 5h / all 7d / fable 7d,小写开头)。scoped=True 为「按模型限定」的额度(Fable 7d,来自 -# /api/oauth/usage 的 limits[],由 statusline 写入快照 seven_day_fable),小屏放不下第三行,只在宽版展示。 +# 短标签 k 给 400x300 小屏(标签列只有 26px);长标签 label 给 800x480 宽版(2026-09-14 去掉 all 前缀,就叫 5h / 7d)。 +# scoped=True 为「按模型限定」的额度(Fable 7d,来自 /api/oauth/usage 的 limits[] weekly_scoped,由 claude_usage.normalize +# 写入快照 seven_day_fable):小屏 render() 跳过;宽版**并入紧邻其前的非 scoped 行**(7d 行拆成上下两条细条:上 = 总 7d, +# 下 = fable 7d),使左右两块都是两行、视觉对称(此前三行 vs 两行不对称)。 USAGE_WINDOWS = ( - ("five_hour", "5h", "all 5h", FIVE_HOUR, False), - ("seven_day", "7d", "all 7d", SEVEN_DAY, False), + ("five_hour", "5h", "5h", FIVE_HOUR, False), + ("seven_day", "7d", "7d", SEVEN_DAY, False), ("seven_day_fable", "Fable 7d", "fable 7d", SEVEN_DAY, True), ) -# ChatGPT 用量窗口:键名沿用同一套约定(five_hour / seven_day / seven_day_codex),推送端照此结构提供原始数据即可。 +# Codex 用量窗口(ChatGPT 订阅的 Codex 额度池,由 codex_usage.py 经 app-server RPC 取得):只有 5h / 7d 两个窗口。 +# ChatGPT 普通聊天额度没有账户级读取途径(2026-09 调研结论),故右侧块监控的就是 Codex 池并如实命名「Codex Usage」。 +# 快照里各窗口可带 window(真实窗口秒数),pace 优先按它算;rateLimitsByLimitId 多桶原样存于 buckets,看到真实数据后再决定是否加第三行。 GPT_WINDOWS = ( - ("five_hour", "5h", "all 5h", FIVE_HOUR, False), - ("seven_day", "7d", "all 7d", SEVEN_DAY, False), - ("seven_day_codex", "Codex 7d", "codex 7d", SEVEN_DAY, True), + ("five_hour", "5h", "5h", FIVE_HOUR, False), + ("seven_day", "7d", "7d", SEVEN_DAY, False), ) def usage_from_raw(now, raw, windows, title): """原始快照 → 渲染结构(Claude / ChatGPT 通用)。 - raw 形如 {five_hour:{utilization, resets_at}, seven_day:{...}, :{...}|null, updated_at?}; - utilization 为已用百分比,resets_at 为 epoch 秒或 ISO;缺哪个窗口哪个显示 "--"。 + raw 形如 {five_hour:{utilization, resets_at, window?}, seven_day:{...}, :{...}|null, updated_at?}; + utilization 为已用百分比,resets_at 为 epoch 秒或 ISO,window 为该窗口真实长度秒(缺省用 windows 表的常量);缺哪个窗口哪个显示 "--"。 time_pct(时间已流逝%)与 pace 按传入的 now **现算**——所以推送端只需推原始快照,服务端渲染时 pace 仍随时钟准确推进。 结构契约(renderer 依赖):{title, warn, ok, pace_tol, updated?, bars:[{k, label, pct(0~100 或 None), time_pct?, reset?, scoped}]}; pct ≥ warn 时该条进度条与百分比转红(宽版另按 ok/warn 分 绿/黑/红 三档,百分比随条色); @@ -343,6 +346,7 @@ def usage_from_raw(now, raw, windows, title): util = seg.get("utilization") pct = int(round(float(util))) if util is not None else None bar = {"k": k, "label": label, "pct": pct, "scoped": scoped} + win = int(seg.get("window") or win) tp = _window_time_pct(seg.get("resets_at"), win, now) if pct is not None and tp is not None: bar["time_pct"] = tp @@ -358,31 +362,34 @@ def usage_from_raw(now, raw, windows, title): def _usage(now, raw=None): - """Claude Code 用量(5h / 7d / Fable 7d)。raw 为 None 时读本地 statusline 快照(见 usage_local.py,**不发请求**); - 服务端传入推送来的原始快照。utilization 来自 statusline 从 Claude Code stdin 落盘的权威实时值(Fable 行来自其转存的 API limits)。""" + """Claude 账户级用量(5h / 7d / Fable 7d)。raw 为 None 时本机经 claude_usage 采集(复用 Claude Code 钥匙串登录态调 + /api/oauth/usage,带 ≥300s 最小间隔缓存与退避);服务端传入推送来的原始快照。""" if raw is None: raw = {} try: - raw = usage_local.get_usage() + raw = claude_usage.get_usage() except Exception as e: print(f"[警告] Claude 用量读取失败:{e}") return usage_from_raw(now, raw, USAGE_WINDOWS, "Claude Usage") def gpt_mock_raw(now): - """ChatGPT 原始快照的 mock(尚无真实数据源时用):刻意覆盖宽版三档配色—— - 5h 88%(≥warn 红)、7d 71%(黑)、Codex 7d 22%(≤ok 绿,scoped,与左侧 fable 行对称)。""" + """Codex 原始快照的 mock(服务端尚未收到推送时用):5h 88%(≥warn 红)、7d 22%(≤ok 绿)覆盖两端配色。""" t = now.timestamp() return {"five_hour": {"utilization": 88, "resets_at": t + 2 * 3600}, - "seven_day": {"utilization": 71, "resets_at": t + 3 * 86400}, - "seven_day_codex": {"utilization": 22, "resets_at": t + 3 * 86400}} + "seven_day": {"utilization": 22, "resets_at": t + 3 * 86400}} -def _usage_gpt(now, raw=None): - """ChatGPT 用量。raw 为推送来的原始快照(结构同 gpt_mock_raw);为 None 时用 mock 并标 mock=True - (renderer 在标题右侧标「示例数据」)。接入真实数据源时由推送端提供 raw,本函数无需改动。""" +def _usage_gpt(now, raw=None, allow_local=True): + """Codex 用量(ChatGPT 订阅的 Codex 额度池)。raw 为推送来的原始快照(codex_usage 产出,结构同 gpt_mock_raw); + 服务端未收到推送时(raw=None)用 mock 并标 mock=True(renderer 在标题右侧标「示例数据」)。本机形态直接经 codex_usage 采集。""" mock = raw is None - out = usage_from_raw(now, gpt_mock_raw(now) if mock else raw, GPT_WINDOWS, "ChatGPT Usage") + if mock and allow_local: + try: + raw, mock = codex_usage.get_usage(), False + except Exception as e: + print(f"[警告] Codex 用量读取失败:{e}") + out = usage_from_raw(now, gpt_mock_raw(now) if mock else raw, GPT_WINDOWS, "Codex Usage") if mock: out["mock"] = True return out diff --git a/html_renderer.py b/html_renderer.py index 3a4da78..39b5d2d 100644 --- a/html_renderer.py +++ b/html_renderer.py @@ -8,8 +8,8 @@ (平台截图机若无中文字体会掉字,网络字体是保险;能否加载取决于其出网能力)。 - 纯 HTML+CSS+内联 SVG,**不依赖 JavaScript**(平台是否执行 JS 未证实),数据全部服务端写死进 HTML。 - 颜色只用六色纯值 #000/#fff/#f00/#0f0(黄/蓝未用),抗锯齿边缘交给平台抖动——这正是要和 PNG 版对比的点。 - - 版式与 PNG 版同构:顶部时钟/天气;左 Claude Usage、右 ChatGPT Usage;橘喵今日实时三列;BTC 时 K(SVG)。 - - 配色规则与 PNG 版一致:用量条 ≥warn 红 / ≤ok 绿 / 其间黑;pace ≥+tol 红 / ≤−tol 绿 / 其间黑;环比同比 涨红 跌黑;K 线 涨绿 跌红。 + - 版式与 PNG 版同构:顶部时钟/天气;左 Claude Usage、右 Codex Usage;橘喵今日实时三列;BTC 时 K(SVG)。 + - 配色规则与 PNG 版一致:用量条 ≥warn 红 / ≤ok 绿 / 其间黑;pace ≥+tol 红 / ≤−tol 绿 / 其间黑;环比同比 涨绿 跌红(与 K 线一致);K 线 涨绿 跌红。 """ from html import escape as _e @@ -95,7 +95,7 @@ def _usage_card(usage): def _cmp(label, pair): direction, val = pair - col = RED if direction == "up" else BLACK + col = GREEN if direction == "up" else RED if direction == "down" else BLACK tri = '' if direction == "up" else ('' if direction == "down" else "") return f'
{_e(label)} {tri}{_e(val)}
' @@ -173,7 +173,7 @@ def render_html(d): f'
{_e(wt["loc"])}
' f'{_e(wt["cond"])}
{_e(wt["range"])}
{_e(wt["temp"])}
') body = (head - + f'
{_usage_card(d["usage"])}{_usage_card(d.get("usage_gpt") or {"title": "ChatGPT Usage"})}
' + + f'
{_usage_card(d["usage"])}{_usage_card(d.get("usage_gpt") or {"title": "Codex Usage"})}
' + _realtime(d["mao"]) + _btc(d.get("btc"))) return ("" diff --git a/main.py b/main.py index 9746e76..0ffdd19 100644 --- a/main.py +++ b/main.py @@ -8,8 +8,8 @@ python3 main.py --render-only # 只渲染生成图片,不推送(output/dashboard.png) python3 main.py --target e1002 # reTerminal E1002:渲染 800x480 六色图 + 固定尺寸 index.html 到 output/e1002/, # 不推送(静态部署形态;动态形态见 server.py) - python3 main.py --target push # 把本机 Claude 用量快照(~/.claude/usage-snapshot.json)推送到 server.py - # (PUSH_URL + PUSH_TOKEN),服务端渲染时按当前时刻现算 pace + python3 main.py --target push # 本机采集 Claude(钥匙串登录态 → /api/oauth/usage)与 Codex(codex app-server RPC) + # 两路账户级用量并推送到 server.py(PUSH_URL + PUSH_TOKEN),服务端渲染时现算 pace 配置统一从同目录 .env 读取(见 .env.example),真实环境变量优先。 """ @@ -25,7 +25,7 @@ def main(): parser.add_argument("--render-only", action="store_true", help="只渲染,不推送") parser.add_argument("--target", choices=("miao", "e1002", "push"), default="miao", help="miao=喵喵固件 400x300 三色屏(默认,局域网推送);e1002=reTerminal E1002 800x480 六色(产出静态 HTML 页);" - "push=把本机用量快照推送到 server.py") + "push=本机采集 Claude/Codex 账户级用量并推送到 server.py") args = parser.parse_args() if args.target == "push": diff --git a/push_client.py b/push_client.py index d897ea8..01cad9b 100644 --- a/push_client.py +++ b/push_client.py @@ -1,22 +1,24 @@ #!/usr/bin/env python3 # -*- coding: utf-8 -*- """ -推送端(本机运行)—— 把本机的用量原始快照 POST 到 server.py,服务端暂存、渲染时现算 pace。 +推送端(本机运行)—— 在本机采集两路账户级 AI 用量,POST 到 server.py;服务端暂存、渲染时现算 pace。 -推什么: - - Claude:~/.claude/usage-snapshot.json 原文(statusline 生产,含 five_hour / seven_day / seven_day_fable / updated_at)→ POST /push/usage - - ChatGPT:暂无本机数据源;将来有了按 data.GPT_WINDOWS 的键(five_hour / seven_day / seven_day_codex,各含 utilization + resets_at) - 组一个 dict → POST /push/usage_gpt。目前不推,服务端用 mock 并标「示例数据」。 +推什么(形态 A:凭证与采集都留在本机,服务端不持任何凭证、不读本机文件): + - Claude:claude_usage.get_usage()(复用 Claude Code 钥匙串登录态调 /api/oauth/usage)→ POST /push/usage + - Codex:codex_usage.get_usage()(`codex app-server` RPC,独立 CODEX_HOME 的 ChatGPT 登录)→ POST /push/usage_gpt +两路彼此独立:一路失败不影响另一路;两路都失败才以非零码退出。采集器自带 ≥300s 最小间隔缓存, +所以与 main.py(小屏渲染)在同一分钟内先后运行也只打一次接口。 鉴权:请求头 X-Push-Token = PUSH_TOKEN。网络:直连(不走代理),连接类异常重试 2 次。 -调度:与喵喵设备推送同节律,由 launchd 每 5 分钟跑一次 `main.py --target push`(或加进 run.sh)。 +调度:与喵喵设备推送同节律,由 launchd 每 5 分钟跑一次 `main.py --target push`(run.sh 末尾)。 """ import json import time import urllib.error import urllib.request +import claude_usage +import codex_usage import config -import usage_local RETRY_TIMES, RETRY_DELAY, TIMEOUT = 2, 2, 10 @@ -28,10 +30,11 @@ def _post_json(url, payload): "X-Push-Token": config.PUSH_TOKEN, "User-Agent": "eink-push/push_client", }) + opener = urllib.request.build_opener(urllib.request.ProxyHandler({})) last = None for i in range(RETRY_TIMES + 1): try: - with urllib.request.urlopen(req, timeout=TIMEOUT) as resp: + with opener.open(req, timeout=TIMEOUT) as resp: return json.loads(resp.read().decode("utf-8")) except urllib.error.HTTPError as e: # 4xx/5xx 不重试,直接报出服务端的说明 raise RuntimeError(f"HTTP {e.code}: {e.read().decode('utf-8', 'replace')[:200]}") from e @@ -42,13 +45,29 @@ def _post_json(url, payload): raise RuntimeError(f"连接失败:{last}") +SOURCES = ( + ("Claude", claude_usage.get_usage, "/push/usage"), + ("Codex", codex_usage.get_usage, "/push/usage_gpt"), +) + + def push_usage(): if not config.PUSH_URL or not config.PUSH_TOKEN: raise SystemExit("[推送] 需在 .env 配置 PUSH_URL 与 PUSH_TOKEN") - raw = usage_local.get_usage() # 缺文件直接抛错——没有快照就没什么可推 - resp = _post_json(config.PUSH_URL.rstrip("/") + "/push/usage", raw) - print(f"[推送] Claude 用量快照已推送到 {config.PUSH_URL}:{resp}") - return resp + base = config.PUSH_URL.rstrip("/") + ok = 0 + for name, getter, path in SOURCES: + try: + raw = getter() + status = raw.pop("status", "") + resp = _post_json(base + path, raw) + print(f"[推送] {name} 用量({status})已推送到 {base}{path}:{resp}") + ok += 1 + except Exception as e: + print(f"[推送] {name} 用量跳过:{e}") + if ok == 0: + raise SystemExit(1) + return ok if __name__ == "__main__": diff --git a/renderer.py b/renderer.py index 09d9fe5..f2886d4 100644 --- a/renderer.py +++ b/renderer.py @@ -115,6 +115,10 @@ class DashboardRenderer: """环比/同比一行:标签 + 涨跌三角 + 数值,整体居中于 cx。direction 为 None 时不画三角。""" self._cmp_at(cx - self._cmp_w(label, pair) / 2, y, label, pair, fg) + def _cmp_color(self, direction, fg=BLACK): + """环比/同比「三角 + 数值」的颜色钩子:三色版 涨红、其余沿用 fg。宽版覆写。""" + return RED if direction == "up" else fg + def _cmp_at(self, x, y, label, pair, fg=BLACK): """环比/同比一行,左对齐于 x(宽版把它放在数值右侧时用)。""" direction, val = pair @@ -122,9 +126,9 @@ class DashboardRenderer: lw = self._tw(label, self.F12); gap = 5 * k trw, tgap = (8 * k, 4 * k) if direction else (0, 0) self.text(x, y, label, self.F12, fill=fg); x += lw + gap - # 方向语义配色(红涨):涨 → 箭头与百分比数值一起红;跌/无 → 黑(标签始终黑) + # 方向语义配色由 _cmp_color 决定(三色版:红涨、跌/无 → 黑;宽版覆写为 涨绿跌红 与 K 线一致);标签始终黑 up = direction == "up" - val_color = RED if up else fg + val_color = self._cmp_color(direction, fg) if direction: # 三角中心对齐 F12 数字墨迹竖直中心(y+8.5),否则箭头偏高 self.tri(x + trw / 2, y + 8.5 * k, 7 * k, up=up, fill=val_color); x += trw + tgap @@ -203,6 +207,7 @@ class DashboardRenderer: fw = (bx1 - bx0) * pct / 100.0 if fw >= 2: self.d.rectangle([bx0, by, bx0 + fw, by + H], fill=fill) + return bx0, bx1, by, H # 进度条几何(宽版合并行据此在边框内重绘上下两条实心填充) def _usage_colors(self, ok, pct, warn, bar): """用量行配色钩子 → (进度条填充,轨道边框,百分比文字)。三色版:≥warn 整体红,否则黑。""" diff --git a/renderer_e1002.py b/renderer_e1002.py index e867bc3..4a39109 100644 --- a/renderer_e1002.py +++ b/renderer_e1002.py @@ -8,7 +8,7 @@ reTerminal E1002 渲染层 —— 复用 renderer.DashboardRenderer 的组件( 版式(k=1,点阵字体且**只用整数倍字号 12/24/36/48/84**,不放大;四段,三道横规): A 顶部(0~90) :**大时钟 F84**(墨迹 16~79,占满上下边距内的整段高度)+ 星期/日期 F12 紧随其右、与时钟数字**基线对齐**(左) |天气(右,贴 790)——天气组件与三色版同尺寸。 - B 用量(96~206) :左半 **Claude Usage**(all 5h / all 7d / fable 7d)|竖规 x=400|右半 **ChatGPT Usage**(mock:all 5h / all 7d / codex 7d)。 + B 用量(96~206) :左半 **Claude Usage**(两行:5h + 合并行 7d——一个大边框内上下两条实心 = 总 7d / fable 7d,右侧数字取较大者,`_usage_row_split`)|竖规 x=400|右半 **Codex Usage**(5h / 7d,ChatGPT 订阅的 Codex 额度池;未推送时 mock 标「示例数据」)。 行组件沿用三色版的紧凑单行 `_usage_row`(行距 31);**三处文字统一 12px**(基类 F13/F16/FPACE 都映射到 12px): 标签**常规字重、在标签列内右对齐**(贴进度条),百分比与 pace 数字**粗体**(曾试过 24px,用户嫌大), 标签用 bar["label"](statusline 同款写法),列宽按最长标签自适应;标题右侧「上次更新 HH:MM」与今日实时同款。 @@ -51,7 +51,7 @@ class E1002Renderer(DashboardRenderer): PCT_TRACK = 0 # 百分比是整数倍字号(自带侧边距),不再手动加字符间距 LABEL_BOLD = False # 标签常规字重(数值才加粗,层级:数值 > 标签) - LABEL_ALIGN = "r" # 标签右对齐贴进度条,长短标签(all 5h / fable 7d)右缘齐平 + LABEL_ALIGN = "r" # 标签右对齐贴进度条,长短标签(5h / 7d)右缘齐平 def __init__(self, font_path=config.FONT_PATH): super().__init__(font_path, size=(config.E1002_WIDTH, config.E1002_HEIGHT), k=1) @@ -66,13 +66,29 @@ class E1002Renderer(DashboardRenderer): # ---------- 配色钩子(三档,顺序固定 绿 → 黑 → 红;用量与 pace 各自独立计算)---------- _ok, _pace_tol = _cfg.USAGE_OK, _cfg.PACE_TOL # 由 _usage_block 按数据覆盖 + def _usage_row(self, x0, x1, y, bar, warn, label_w=None): + """宽版:pct 为 None(接口未返回该窗口,如 Codex Plus 的 5h)时不画 "--",而是按 0 画——百分比 "00%"、pace "00"、 + 空条、全黑(无数据的中性色,不落绿档)——使各行进度条右端对齐、宽度一致(2026-09-14 用户要求;"--" 会让该行条变短)。""" + if not isinstance(bar.get("pct"), (int, float)): + bar = dict(bar, pct=0, time_pct=0, _null=True) + return super()._usage_row(x0, x1, y, bar, warn, label_w=label_w) + + def _pace(self, xr, cy, diff): + """宽版:diff=0(持平/无数据)不画三角,但**仍预留三角位**(6 + 间隙 4),使每行 pace 列等宽、进度条右端齐平。""" + w = super()._pace(xr, cy, diff) + return w if diff != 0 else w + 10 * self.k + def _usage_colors(self, ok, pct, warn, bar): - """进度条填充、边框、百分比数字三者同色:pct ≤ ok 绿「健康」|其间黑「正常」|pct ≥ warn 红「危险」。""" - if not ok: + """进度条填充、边框、百分比数字三者同色:pct ≤ ok 绿「健康」|其间黑「正常」|pct ≥ warn 红「危险」;无数据(_null)黑。""" + if not ok or bar.get("_null"): return BLACK, BLACK, BLACK c = RED if pct >= warn else GREEN if pct <= self._ok else BLACK return c, c, c + def _cmp_color(self, direction, fg=BLACK): + """环比/同比 涨绿 / 跌红(与底部 K 线「涨绿跌红」一致,2026-09-14 用户要求);无方向沿用 fg。""" + return GREEN if direction == "up" else RED if direction == "down" else fg + def _pace_color(self, diff): """pace = 用量% − 时间%(与用量档位独立):≤ −tol 绿「节余」|(−tol, +tol) 黑「持平」|≥ +tol 红「偏快」。""" if diff >= self._pace_tol: @@ -101,14 +117,48 @@ class E1002Renderer(DashboardRenderer): self._label(y0, usage.get("title", "Usage"), right=right, x0=x0, x1=x1) warn = usage.get("warn", 80) self._ok, self._pace_tol = usage.get("ok", _cfg.USAGE_OK), usage.get("pace_tol", _cfg.PACE_TOL) - bars = [dict(b, k=b.get("label") or b["k"]) for b in usage.get("bars", [])] # 宽版用长标签(all 5h / fable 7d) - label_w = max([self._tw(b["k"], self.F13) for b in bars] + [26]) + self.LABEL_GAP - y = y0 + 27 + bars = [dict(b, k=b.get("label") or b["k"]) for b in usage.get("bars", [])] # 宽版用长标签(5h / 7d) + # scoped 行(fable 7d)并入紧邻其前的非 scoped 行,成为该行的下半细条 → 左右两块都是两行,对称 + rows = [] for b in bars: - self._usage_row(x0, x1, y, b, warn, label_w=label_w) + if b.get("scoped") and rows and rows[-1][1] is None: + rows[-1] = (rows[-1][0], b) + else: + rows.append((b, None)) + label_w = max([self._tw(m["k"], self.F13) for m, _ in rows] + [26]) + self.LABEL_GAP + y = y0 + 27 + for m, sub in rows: + if sub is None: + self._usage_row(x0, x1, y, m, warn, label_w=label_w) + else: + self._usage_row_split(x0, x1, y, m, sub, warn, label_w) y += 31 return y - 31 + 18 # 末行进度条底部(cy=y+10,条高 15 → 底 y+17) + def _usage_row_split(self, x0, x1, y, main, sub, warn, label_w): + """合并行「7d」(2026-09-14 定稿):**一个大边框**、整行高度与单行相同(H=15);边框内部上下**精确对半**两条**实心**填充 + (各 7px、紧挨无空隙)——上 = main(总 7d),下 = sub(模型限定 fable 7d)。右侧百分比与 pace **只显示两者中较大者** + (同一窗口 time_pct 相同,pct 大者 pace 也大);边框与文字颜色随该较大者的档位,两条填充各按自身档位取色。 + 实现:以「较大者」合成一条 bar 交给基类 _usage_row 画边框/标签/文字,再把边框内部刷白、重绘两条半高填充。""" + k = self.k + def _p(b): + v = (b or {}).get("pct") + return int(v) if isinstance(v, (int, float)) else None + pm, ps = _p(main), _p(sub) + dom = sub if (ps is not None and (pm is None or ps > pm)) else main + bx0, bx1, by, H = self._usage_row(x0, x1, y, dict(dom, k=main["k"]), warn, label_w=label_w) + # 边框内部刷白(保留 1px 边框),再画上下两条实心 + self.d.rectangle([bx0 + k, by + k, bx1 - k, by + H - k], fill=WHITE) + halves = ((pm, main, by + k, by + 7 * k), (ps, sub, by + 8 * k, by + 14 * k)) # 边框内 14px 精确对半:上 by+1..by+7,下 by+8..by+14,无空隙 + for pct, b, top, bot in halves: + if pct is None: + continue + pct = max(0, min(100, pct)) + fill = self._usage_colors(True, pct, warn, b or {})[0] + fw = (bx1 - bx0) * pct / 100.0 + if fw >= 2: + self.d.rectangle([bx0 + k, top, min(bx0 + fw, bx1 - k), bot], fill=fill) + def _realtime(self, y0, mao, x0=8, x1=None): """橘喵·今日实时五列(宽版):探店单量 / 探店流水 / 团购单量 / 团购流水 / 毛利,列内四行逐行水平居中: 指标名 F12(y0+21)→ 值 F24 粗(y0+38)→ 环比(y0+72)→ 同比(y0+88,行距 16);按墨迹框算,标题↔名 ~9px(原 ~5px 太挤,整列下移 4px)、 @@ -229,7 +279,7 @@ class E1002Renderer(DashboardRenderer): self._weather_category(wt.get("icon")), s=s, accent=RED, wet=BLUE) self.hdash(8, self.W - 8, DIV_Y) - # ---- B. 用量:左 Claude | 右 ChatGPT(紧凑单行,与三色版同款)---- + # ---- B. 用量:左 Claude | 右 Codex(紧凑单行,与三色版同款)---- MID = self.W // 2 # 400 y_l = self._usage_block(10, MID - 14, DIV_Y + 6, usage) y_r = self._usage_block(MID + 14, R, DIV_Y + 6, gpt) if gpt else y_l diff --git a/server.py b/server.py index a1a9802..718fa47 100644 --- a/server.py +++ b/server.py @@ -15,7 +15,7 @@ HTML 控件填同一个 URL 则**每个刷新周期都重抓**。所以对外只 - 天气 / 橘喵今日实时 / BTC K 线:**内存缓存 + 有效期**(WEATHER_TTL / MAO_TTL / BTC_TTL,.env 可配,0=每次实拉): 收到请求先查该路缓存是否过期,过期才实拉并更新时间戳,否则直接用缓存渲染;拉取失败沿用旧值、不更新时间戳(下次再试), 从未成功则占位 "--"。整个服务**不读本机任何文件**(部署在公网服务器)。 - - Claude Usage / ChatGPT Usage:**不主动拉取**,由本机 `main.py --target push` 推送原始快照到 POST /push/usage、/push/usage_gpt, + - Claude Usage / Codex Usage:**不主动拉取、不持凭证**,由本机 `main.py --target push` 采集后推送原始快照到 POST /push/usage、/push/usage_gpt, 服务端暂存最新一份(内存;PUSH_STATE_PATH 非空时同时落盘,重启恢复);渲染时按当前时刻现算 pace, 所以推送时机与设备抓取时机不必对齐。未推送过:Claude 显示 "--",ChatGPT 用 mock 并标「示例数据」。 - 推送鉴权:请求头 X-Push-Token 必须等于 PUSH_TOKEN;PUSH_TOKEN 为空时拒绝一切推送(服务在公网,务必配置)。 @@ -128,7 +128,7 @@ def build_data(now=None): "weather": weather, "mao": mao, "usage": data._usage(now, raw=raw_usage if raw_usage is not None else {}), # 未推送 → 全部 "--"(不读本机文件) - "usage_gpt": data._usage_gpt(now, raw=raw_gpt), # 未推送 → mock(标「示例数据」) + "usage_gpt": data._usage_gpt(now, raw=raw_gpt, allow_local=False), # 未推送 → mock(标「示例数据」),服务端不本地采集 "btc": btc, } return d, [s1, s2, s3] diff --git a/usage_common.py b/usage_common.py new file mode 100644 index 0000000..92ad0cd --- /dev/null +++ b/usage_common.py @@ -0,0 +1,62 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +用量采集的公共骨架 —— 本地缓存 + 有效期(TTL)+ 失败退避(Claude / Codex 两个采集器共用)。 + +为什么需要:launchd 每 5 分钟起两个独立进程(小屏渲染 + 上报),且用户可能手动连跑几次; +账户级用量接口对突发调用敏感(Anthropic 的 /api/oauth/usage 一分钟内几十次即 429 并封约 24 小时, +连带 Claude Code `/usage` 与 claude.ai 一起看不到用量)。故: + - 距上次成功拉取不足 TTL 秒(CLAUDE_USAGE_TTL / CODEX_USAGE_TTL,语义同 WEATHER_TTL/MAO_TTL/BTC_TTL,0=每次实拉)→ 直接用缓存,不发请求; + - 拉取失败 → 沿用旧缓存(标 stale),并按 BACKOFF 秒退避,退避期内不再尝试;异常对象带 `backoff` 属性时按它退避 + (claude_usage.TokenUnavailable = 一个 TTL:token 过期是本机状态、Claude Code 随时可能刷新,不必等满 BACKOFF); + - 从未成功且本次失败 → 抛出,由上层回退占位 "--"。 +缓存文件只存快照与时间戳,**不存任何凭证**。 +""" +import json +import os +import time + + +def cached_snapshot(path, ttl, backoff, fetch, force=False): + """返回 (snapshot, status)。status ∈ {"fetched", "cache", "stale", "backoff"}。 + fetch() 需返回可 JSON 序列化的快照 dict(含 updated_at)。""" + state = _load(path) + now = time.time() + snap = state.get("snapshot") + if not force and snap and now - state.get("fetched_at", 0) < ttl: + return snap, "cache" + if not force and state.get("backoff_until", 0) > now: + if snap: + return snap, "backoff" + raise RuntimeError(f"退避期内(至 {time.strftime('%H:%M:%S', time.localtime(state['backoff_until']))}),且无旧快照") + try: + snap_new = fetch() + except Exception as e: + state["backoff_until"] = now + getattr(e, "backoff", backoff) # 异常可自带更短退避(如本机 token 过期,只需等一个 TTL) + state["last_error"] = f"{type(e).__name__}: {e}"[:300] + _save(path, state) + if snap: + print(f"[用量] 拉取失败,沿用旧快照:{e}") + return snap, "stale" + raise + state = {"fetched_at": now, "snapshot": snap_new} + _save(path, state) + return snap_new, "fetched" + + +def _load(path): + try: + with open(path, encoding="utf-8") as f: + return json.load(f) + except Exception: + return {} + + +def _save(path, state): + tmp = path + ".tmp" + try: + with open(tmp, "w", encoding="utf-8") as f: + json.dump(state, f, ensure_ascii=False) + os.replace(tmp, path) + except Exception as e: # 缓存写不进去不算致命 + print(f"[用量] 缓存写入失败:{e}") diff --git a/usage_local.py b/usage_local.py deleted file mode 100644 index fc28a9d..0000000 --- a/usage_local.py +++ /dev/null @@ -1,33 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- -""" -Claude Code 用量本地读取 —— 只读 claude-statusline 生产的用量快照,**不发任何网络请求**。 - -生产者/消费者模式:生产者 = 打过补丁的 claude-statusline(见其仓库 bin/statusline.sh 的 -「Usage snapshot for external consumers」段),它把每次从 Claude Code stdin 拿到的**权威实时**额度 -落盘到 config.USAGE_LOCAL_PATH(默认 ~/.claude/usage-snapshot.json);本模块作为消费者读取。 - -结构含 five_hour / seven_day / seven_day_fable(后者为 Fable 模型限定的 7 天额度,statusline 从 -/api/oauth/usage 的 limits[] 转存,旧版 statusline 或 API 无该项时为 null),各有 utilization(已用百分比) -与 resets_at(重置时间,epoch 秒或 ISO,供上层算「时间已流逝%」做 pace 对比),外加 updated_at(落盘 epoch)。 -活跃使用时快照几乎持续刷新、准确;空闲时停在最后一次。文件缺失/损坏时抛错 → 上层回退占位 "--"。 -""" -import json - -import config - - -def get_usage() -> dict: - """读取本地 statusline 用量缓存并返回(含 five_hour / seven_day)。缺失/损坏时抛出。""" - with open(config.USAGE_LOCAL_PATH, encoding="utf-8") as f: - data = json.load(f) - if "five_hour" not in data: - raise RuntimeError(f"用量缓存缺 five_hour 字段:{config.USAGE_LOCAL_PATH}") - return data - - -if __name__ == "__main__": - d = get_usage() - for k in ("five_hour", "seven_day", "seven_day_fable"): - s = d.get(k) or {} - print(k, "util=", s.get("utilization"), "resets_at=", s.get("resets_at"))