Files
eink-push/CLAUDE.md
YANG JIANKUAN ea26f41298 feat: E1002 今日实时团购两列替换为赏金单量/赏金流水(含环比/同比)
- REALTIME_COLS 改取接口 bountyOrderCount/bountyPayAmount,旧后端缺字段时占位 --
- 毛利口径注释同步为含当天审核通过的赏金;小屏仍只显示三列不受影响

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 15:43:51 +08:00

110 lines
50 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## 项目定位
`eink-push` 是 JM monorepo 中一个**独立的 Python 工具**,与其余 Java/Vue 子项目技术栈无关,不受父级 CLAUDE.md 的 jm-cloud/uniapp/admin 开发约束。
作用:把日期天气、Claude Code 用量、橘喵今日经营合成为一张 **400×300 三色(黑/白/红,BWR)** PNG,推送到局域网墨水屏设备(喵喵固件,开源:https://gitee.com/gxp666111/miaomiao)。纯 Python + Pillow,**无浏览器依赖**,适合 cron 定时运行。
> **分支 `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`/`DEVICE_TTL`,默认 900/60/300/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 大时钟 **F84**(墨迹 16~79)+ 其右一列三行、左缘对齐(F12,行距 22):① 电池图标+电量(充电中绿、≤20% 红)② 温度计+温度 | 蓝色水滴+湿度(①② 数字 F12 伪粗体、字号不变,占位 -- 不加粗,2026-09-14)③ 星期/日期(与时钟基线对齐);**右侧天气(2026-09-15 定稿)上下两层**:上层 天气图标 **×1.25** | **实时温度 F48 粗 3**(墨迹顶沿 16 与时钟齐平、底沿 52,右贴页边 790;「°」用 F24 粗 1 作上标、墨迹顶沿对齐数字顶沿、补回字形右侧空白使墨迹右缘贴页边;图标按类别**真实墨迹范围**(`E1002Renderer._ICON_EXTENT`,多云太阳光芒探出名义框顶部 4 格、雷阵雨闪电垂到云下至 46)竖直居中于温度墨迹中心);下层 **一行「红定位水滴+城市 天气 温区」F12 右对齐贴 790**,与日期行同基线(底沿 79,与时钟基线对齐)。历史(均已否):同日上午「三行信息(天气/温区/城市)左缘对齐|图标 ×1.75|温度 F84 与时钟同高」;2026-09-14「两行(城市/天气·温区)|图标 ×1.5|温度 F72 小一号」;更早「城市 / F24 温度 / 天气·温区」三行右对齐+图标在左)——设备数据来自 **SenseCraft 设备遥测** `sensecraft_api.py`(2026-09-14 新增;先做过「状态栏横排在时钟上方+时钟缩 F60」被否)/天气;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 用户要求,原为留空、后短暂用过「-」;已下沉到基类 `_pace`,小屏同样)——各行进度条右端齐平、等宽(2026-09-14;Codex Plus 接口 `secondary_window` 就是 null,原始 `wham/usage` 已核对,5h 行为空是数据如此)。**真 0 的窗口也必须有 pace 列**:Claude 5h 空闲时接口回 `utilization: 0.0, resets_at: null`,算不出 time_pct,早先渲染器遇「有 pct 无 time_pct」整列跳过,该行条就比别行长——现 `data.usage_from_raw` 对 pct=0 且无重置时刻的窗口记 time_pct=0(窗口未开始),基类 `_usage_row` 只要有 pct 就画 pace(time_pct 缺失按 0 差值画 "00"),紧凑单行样式(曾试过撑满高度的大卡片,被否);C **橘喵·今日实时**五列全宽(探店单量/探店流水/赏金单量/赏金流水/毛利,2026-09-11 接口新增团购字段后由三列改五列,2026-10-26 团购两列替换为赏金两列——赏金流水=任务奖励 ×2、毛利已含当天审核通过的赏金奖励;列内四行逐行居中:名 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`,三色小屏仍涨红);红另用于定位/太阳闪电,蓝=雨雪。
## 命令
```bash
pip install -r requirements.txt # 仅 Pillow>=10.0;urllib 用标准库
cp .env.example .env # 首次:复制模板并填入真实凭证 + 设备 host
python3 main.py # 取数据 → 渲染 → 推送(设备 host 取自 .env 的 EPD_HOST)
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(钥匙串登录态→/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
# 临时覆盖:真实环境变量优先级高于 .env
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`、设备遥测 `SENSECRAFT_API_KEY`/`SENSECRAFT_DEVICE_EUI`/`DEVICE_TTL`、服务端 `FETCH_BUDGET`/`IMAGE_TOKEN`(页面访问令牌)/`PUSH_TOKEN`、`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`/`BTC_SOURCES`(行情源顺序,境内服务器须排 gateio/huobi_aws/binance_vision 在前)/`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 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=, 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`/`device`(`device` = `{battery, charging, temp, humidity}`,由 `_device(now)` 经 `sensecraft_api` 拉取、文件缓存 `.device_cache.json` TTL=`DEVICE_TTL`,服务端用纯拉取 `device_fetch()`/占位 `device_empty()`)。日期时间用真实系统时间;橘喵今日经营由 `_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, vp, hb, tb, wide}`(`vp` 为数值三段 `(前缀, 主数字, 后缀)`,金额为 `("¥", "2.14", "万")`/不足万 `("¥", "2590", "")`、单量前后缀为空,`v` 是三段拼接的整串供网页版用;2026-09-14 用户要求 ¥ 与 万 字号小一点),五列由 `REALTIME_COLS`(标签、接口字段前缀、格式化、是否仅宽版)驱动,`_col_from()` 用 `.get()` 取 `today{Base}`/`{base}WowRate`/`{base}YoyRate`,字段缺失(旧后端未上赏金字段)该列占位 `--`;`wide=True`(赏金两列)小屏 `render()` 跳过、宽版全显;`hb/tb` 为环比/同比 `(direction, text)`,direction ∈ `up`/`down`/`flat`/`None`:`flat` 为增长率恰等于 0,三角位画等号「=」(2026-09-14 用户要求,与用量行 pace 持平一致,此前 0 也画上三角)、`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`:距上次成功 <TTL(`.env` 的 `CLAUDE_USAGE_TTL`/`CODEX_USAGE_TTL`)直接用缓存;失败沿用旧快照并退避 BACKOFF 秒;只存快照与时间戳、**不存凭证**)。以上缓存文件均已在 `.gitignore`。
- **用量的 pace(时间维度对比)**:`usage_from_raw()` 每次按当前时间**现算** `time_pct=(窗口已流逝/窗口长)`、`pace=pct−time_pct`(>0 超前↑ / <0 节余↓);窗口空闲(pct=0 且 `resets_at` 为 null)记 time_pct=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 <token>` + `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` 未配置时直接抛错→天气区显示 `"--"`。
- **`sensecraft_api.py`** — SenseCraft HMI 设备遥测客户端:`GET {SENSECRAFT_API_BASE}/api/v1/user/device/iot_data/{SENSECRAFT_DEVICE_EUI}`,头 `api-key`(用户在 SenseCraft 后台生成,放 `.env`,**2026-09-14 该 key 曾出现在对话里,建议轮换**),取 `result.battery.level/charging`、`result.sensor.temp/humidity`;设备每 `dataaccess.interval`=300s 上报一次,故 `DEVICE_TTL` 默认 300。直连不走代理、超时 10s、`code!=200` 抛错 → 状态栏 `--`。
- **`btc_api.py`** — 比特币 K 线客户端,免费公开接口、无需密钥,`get_candles(bar, limit)`,周期代号 `BARS = {"1H", "1D"}` 映射到各源写法,逐源兜底 `SOURCES = (okx, coinbase, huobi, gateio, huobi_aws, binance_vision)`:OKX `/api/v5/market/candles?instId=BTC-USDT&bar=`(主源,本地直连可达且最新)、Coinbase `/products/BTC-USD/candles?granularity=`、Huobi `/market/history/kline?period=`(`huobi_aws` 同接口换 `api-aws.huobi.pro`)、Gate.io `/api/v4/spot/candlesticks`(升序 [ts, 成交额, close, high, low, open, …])、Binance 公开镜像 `data-api.binance.vision/api/v3/klines`(升序 [openTime ms, o, h, l, c, …]);各源字段顺序不同(OKX/Huobi 最新在前,Coinbase 为 [time, low, high, open, close]),统一归一为升序 `{ts, o, h, l, c}`。尝试顺序由 `config.BTC_SOURCES`(`_ordered_sources()`,未列出的按默认顺序追加)决定:本机默认 okx 在前;**境内 NGCQ 服务器 okx/coinbase/huobi 主域名不通**,`.env` 设 `gateio,huobi_aws,binance_vision`。`api.binance.com` 在本地区返回 restricted location,不列入。
- **`claude_usage.py`** — Claude **账户级**用量采集(形态 A,跑在本机):**复用 Claude Code 自身的 OAuth 登录态**,直接 GET `https://api.anthropic.com/api/oauth/usage`(头 `Authorization: Bearer <accessToken>` + `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)`:距上次成功 <ttl 直接返回缓存(ttl 来自 `.env`,与 `WEATHER_TTL`/`MAO_TTL`/`BTC_TTL` 同语义,0=每次实拉)(launchd 每 5 分钟先后起「小屏渲染」「上报」两个进程,只打一次接口);失败沿用旧快照并退避 backoff 秒(期内不再尝试);从未成功且失败则抛出。缓存文件 `.claude_usage_cache.json`/`.codex_usage_cache.json` 只存快照与时间戳,**不存凭证**。
- **`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` 居中版基于它)、`_cmp_color(direction, fg)`(三角与数值颜色:三色版涨红,宽版覆写涨绿跌红,`flat`/`None` 两版都黑;数值 F12 伪粗体、字号不变,占位 `--` 不加粗——加粗会粘成一条长横,2026-09-14)、`_eq(cx, cy, w)`(等号「=」,pace 持平与环比/同比 `flat` 共用)、`_value(cx, y, col)`(今日实时数值:主数字 F24 粗 + 前缀 ¥/后缀 万 F12 小号伪粗体,按数字墨迹底沿基线对齐、整体居中,两版共用);字体另有 `F36`(3 倍整数缩放)。`render_to_file()` 落盘。布局硬编码为**上中下三段**,两道 `hdash` 横规分隔(y=90 / 200):
- **A 顶部**(0–90):大时钟(F48)+日期(左)/ 天气(右:红定位水滴 + 温度 + 天气·温区 + 全矢量图标)。
- **B 中部主角 Claude Usage**(`_label` 标题 + 两条 `_usage_row`):每条单行 = 左标签 `5h`/`7d`(F13) + 粗进度条 + 百分比(F16) + 时间维度对比 pace(F13,`_pace`);`pct≥warn`→该条进度条(含边框)与百分比转红预警;pace 用上/下三角替代 +/−(超前↑ / 节余↓)。整行「标签·进度条·百分比·三角」用 `_mid()` 按墨迹竖直中心对齐到同一中线。
- **C 底部 橘喵今日实时**(`_label` + 三列——过滤掉 `wide` 列,只显示探店单量/探店流水/毛利,版式对齐主分支):`■ 橘喵·今日实时` + `上次更新`,列内 名(F12)/值(`_value`:F24 粗主数字 + 小号 ¥/万)/环比·同比(`_cmp_line`),相对标题偏移 +18/+34/+64/+82,底部留 ~11px 下边距。
- **红色点缀系统**(改配色务必克制):模块顶部 `BLACK/WHITE/RED`,`RED=(255,0,0)` 是给设备红通道的明确信号。红只用在:① 定位水滴(`_loc_pin(fg=RED)`)② 天气太阳/闪电(`_weather_icon(accent=RED)`,云/雨/雪/雾/月仍黑)③ 用量进度条与百分比(`pct≥warn`) + pace 超前(↑) ④ 环比/同比「涨」的三角与数值(`_cmp_line`,跌/标签仍黑)。**配色铁律**:红与黑同属深色、红叠黑对比极低,故红只压白底或作大块实心(白字压红),**绝不红字压黑底**。加红先问「是否承载语义」。
- **`_trend_chart`(当前隐藏)**:近30天三指标折线,用 `_styled_polyline()` 按 solid/dashed/dotted 区分、主指标(流水)红线,各指标按自身极值独立归一化。已保留但 `render()` 未调用,想恢复即在 C 前挂回。
- **天气图标全矢量手绘**(无图片资源):`_weather_category()` 把和风 icon 代码归并为 8 类(sunny/clear_night/cloudy/overcast/thunder/rain/snow/fog,识别不了回退 cloudy),再由 `_weather_icon()` 用 `_sun`/`_cloud` 等基元按 1bit 描边画出;改天气展示从这里动。
- **`pusher.py`** — 直连局域网墨水屏设备(喵喵固件,开源:https://gitee.com/gxp666111/miaomiao,`web/` 即设备自带管理页,可浏览器打开 `http://<EPD_HOST>/` 调试同一套接口)。`push_image()` 先调 `GET /images` 拿画廊已存图片名单,逐个 `POST /delete_image?name=`清空(单张删除失败不阻塞后续),再 `POST /upload`(`Content-Type: image/png`,body 为原始 PNG 字节,非 multipart)触发设备转换+刷屏。**清空画廊是刻意设计**:设备把每次 `/upload` 都追加存一份到画廊(`GET /images` 可见),不清理会越推越多、且设备当前 `current_mode` 为 slideshow 会轮播到旧图;先清后传使画廊恒为 1 张,等价于始终静态展示最新一张。**清理非核心**:拉取画廊列表本身失败也不阻塞后续 `/upload`(跳过清理,直接推)。`_request()` 对连接类异常(`urllib.error.URLError`,含 mDNS `.local` 域名解析瞬时失败)内置重试(`RETRY_TIMES=2`,间隔 `RETRY_DELAY=2s`)——mDNS 基于组播 UDP,偶发丢包很正常,单次失败不该让整轮推送直接跳过。注:历史上曾走 Zectrix 公网 API(`POST /devices/{id}/display/image` + 设备 MAC + `ZECTRIX_API_KEY`),现统一为局域网直连。
- **`renderer_e1002.py`** — reTerminal E1002 目标:`E1002Renderer(DashboardRenderer)` 以 **k=1** 复用组件(字号 12/24/48 与三色版一致),只重写版式与配色钩子(基类抽出类属性 `PCT_TRACK`(小屏 F16 百分比的手动字距),宽版置 0——其 F16 映射到 12px、加粗后由 `_track()` 自动加 1px 字距;基类另抽 `FPACE` 让 pace 字体与标签 F13 解耦)。四段、三道横规,各段 y 由上一段实际底部推算:A 顶部 0–90 **大时钟 F84 粗 3**(按 `getbbox` 把墨迹顶沿放到 16、底沿 79,占满整段且保住原上/左/下边距),其右 14px 起一列三行左缘对齐:电池行、温度湿度行(`_status_battery`/`_status_env`)、星期/日期 F12(按日期数字底沿与时钟**基线对齐**;三行中心约 30.5/52.5/74.5,行距 22);天气右(上层 图标 ×1.25 | 温度 F48 顶沿齐时钟+「°」F24 上标;下层 一行 定位+城市·天气·温区 右对齐,均贴 x=790,见分支说明);B 用量 `_usage_block`×2(左 `usage` 10–386|竖规 x=400|右 `usage_gpt` 414–790):`_label` 标题 + 右侧「上次更新 HH:MM」(与今日实时同款文案)或 mock 的「示例数据」,行用基类紧凑 `_usage_row`(行距 31,宽版把 `bar["label"]` 长标签代入 `k`,`label_w` 按本块最长标签自适应);C `_realtime` 今日实时五列全宽(`mao.cols` 全部,含 `wide` 列):列内四行 `ctext`/`_cmp_line` 逐行水平居中——名 F12 y0+21 / 值 `_value` F24 粗+小号 ¥/万 y0+38 / 环比 y0+72 / 同比 y0+88(按墨迹框:标题↔名 ~9px——2026-09-14 整列下移 4px,原 ~5px 用户觉得挤;名↔值 ~9px、值↔环比 ~6px),竖规 y0+26~102,区块底 y0+104;D `_btc_chart` BTC K 线(`data.btc`,标题按 `bar` 显示「时K/日K」:右侧最新价 F12 粗 + 口径 `chg_label`(24h/1d)+ 涨跌三角 + 涨跌幅(F12 粗,2026-09-14 加粗),涨绿跌红;每根蜡烛 1px 影线 + 实心实体(宽≈槽位 60% 取奇数)、收≥开 绿 否则 红;右侧 66px 标签列放最高/最低/最新价,最新价横贯一道点规、标签避让顶底;底部基线 + 首/中/末时间标签 `t`;无数据居中 `--`)。用量块 `_usage_block` 返回末条进度条底部,B/C 分割线取其 +16px(用户反馈太挤)。配色钩子覆写:`_usage_colors` 三档(≥`USAGE_WARN` 红|≤`USAGE_OK` 绿|其间黑;填充、边框、百分比文字三者同色)、`_pace_color` 三档(≥+`PACE_TOL` 红|≤−`PACE_TOL` 绿|其间黑),阈值由 `usage.ok`/`usage.pace_tol`(来自 config)在 `_usage_block` 内注入。天气图标 `wet=BLUE` 让雨滴/雪花着蓝。可选六色测试色条 `_color_strip`(`E1002_COLOR_STRIP=1` 打开,默认关)。`write_page()` 写出固定 800×480 的 `index.html`(零边距、`image-rendering: pixelated`、PNG 带时间戳参数防缓存)。**为何固定尺寸而非自适应**:云端截图视口/DPR 不可控,自适应会重排+抗锯齿产生中间色再被平台抖动;固定页里的 1:1 PNG 每个像素本就是六色之一,截图零损失——已用无头 Chrome `--window-size=800,480` 截图验证与 PNG 逐像素一致。设备端 HTML 控件须铺满整个画布。历史(均已弃用):k=2 整体放大版;「左用量/右今日实时/下 30 天折线」版;「用量大卡片撑满」版。
- **`server.py`** — E1002 动态服务(stdlib `ThreadingHTTPServer`,无第三方依赖)。`build_data()` → `(data, 各路状态)`:日期按 `config.TZ_NAME` 时区现算;四路上游(天气/橘喵/BTC/设备遥测)经 `_cached(key, ttl, fetch)`——`STATE.last_good[key]` 未过期(`time - STATE.fetched_at[key] < ttl`)直接用;过期的**并行**提交到线程池 `_POOL`(`_refresh` 跑 `data.weather_fetch()`/`data.mao_fetch(now, with_trend=False)`/`data.btc_fetch()`,成功更新数据与时间戳),`build_data` 用 `concurrent.futures.wait` 整体最多等 **`FETCH_BUDGET`** 秒(默认 5,`.env` 可调),再由 `_settle` 结算:完成→`fetched(0.27s)`(带耗时)/失败沿用旧值 `stale`/从未成功占位 `none`;**未完成→ `slow→stale`(用上一次成功值)或 `slow→none`(占位)**,拉取线程继续跑完写入缓存供下次请求(stale-while-revalidate),`STATE.inflight[key]` 保证同一路进行中不重复实拉。响应上限 ≈ 预算 + 渲染,任一路上游卡死(socket 超时和风/橘喵 30s、BTC 8s)不再拖垮整图。**2026-09-14 前为串行实拉**(三路全拉约 1.1~2.6s,单路卡住即整体超时);改并行后三路全拉约 0.75s(取最慢一路)。日志每行末尾 `[weather:cache(12s) mao:fetched(0.06s) btc:slow→stale]` 可直接看命中/耗时/超预算情况,`/health` 有 `cache_age_s`/`ttl_s`/`fetch_budget_s`/`inflight`;用量取 `STATE.raw`(推送暂存)→ `data._usage(now, raw=…)`/`data._usage_gpt(now, raw=…)`,未推送时 Claude 全 `--`、ChatGPT 用 mock。端点:`GET IMAGE_PATH` → `render_png_bytes()`(`_RENDER_LOCK` 串行化,Pillow 渲染器非线程安全);**三个渲染端点在 `IMAGE_TOKEN` 非空时必须带 `?token=<IMAGE_TOKEN>`(`_page_authed`,`hmac.compare_digest`),否则返回与未知路径完全相同的 404、日志标 `bad page token`(2026-09-18 用户要求,防爬虫扫到固定路径);`/health` 不需要令牌**;`GET HTML_PATH` → `html_renderer.render_html()`;`GET HTML_PNG_PATH` → `html_shot.screenshot_html()`+`quantize6()`(`?raw=1` 跳过量化);`GET /health` JSON;`POST /push/usage|/push/usage_gpt`(body=原始快照 JSON,≤64KB,头 `X-Push-Token`==`PUSH_TOKEN`,为空拒绝);`GET /push/*` 查看暂存(同令牌)。所有响应 `Cache-Control: no-store`;每请求打一行日志(含 `CF-Connecting-IP`)。`PUSH_STATE_PATH` 非空时推送数据落盘(原子替换),重启恢复;图片永不落盘。实测:三路全实拉约 1.1~1.3s(天气 0.28s、橘喵 0.06s、OKX 0.7s 串行),全部命中缓存约 0.02s(渲染 20ms + 编码 14ms)。
- **`html_shot.py`** — 方案三:`screenshot_html(html)` 用无头 Chrome(`find_chrome()` 自动探测或 `CHROME_BIN`;Linux root 需 `CHROME_EXTRA_ARGS=--no-sandbox`)把 HTML 截成 800×480,临时目录用后即删,`_SHOT_LOCK` 串行化;**切勿传 `--user-data-dir` 指向全新目录**——新版 `--headless=new` 对空 profile 首启会卡死到超时(实测 A/B/D/E 全超时、只有不带它的 C 2 秒完成)。`quantize6(img)`:每通道二值化(阈值 `HTML_PNG_THRESHOLD` 默认 160,偏向保墨迹,细笔画浅灰边归黑而非丢白)→ Pillow 调色板 `quantize(dither=NONE)` 映射到 `SPECTRA6`,输出保证只含六色。服务端 `?raw=1` 返回未量化截图供对照。实测单次约 3.3s(Chrome 启动为主)。
- **`html_renderer.py`** — 方案二网页版:纯 HTML+CSS+内联 SVG、无 JS,数据服务端写死;字体栈 PingFang/微软雅黑/Noto Sans SC + Google Fonts 兜底(平台截图机若无中文字体会掉字,能否加载取决于其出网能力);颜色只用六色纯值;配色规则与 PNG 版一致(用量/pace 三档、涨红跌黑、K 线涨绿跌红),版式同构。K 线标题右侧价格用 `<strong>`(`h3 b::before` 的方块装饰只给标题)。
- **`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`/环境变量。
## 墨水屏渲染约束(改 renderer.py 必读)
设备是 **三色(黑/白/红)、无真灰阶**——灰阶只能抖动成网点(难看),所以底色只用黑白 + 红作稀缺强调,靠构图/字号/留白建立层级,**不要引入灰色填充或抖动**:
- **红只作语义强调**:方向(涨)、警示(闪电)、强调(定位/主折线);红占墨保持很小(<10% 墨迹),滥用会稀释注意力、破坏层级。整图**只允许三种纯色** `(0,0,0)/(255,255,255)/(255,0,0)`——渲染后可用 `img.getcolors()` 自查,出现第四种色(多为抗锯齿灰边)即为 bug。矢量图元(line/ellipse/polygon/arc)默认不抗锯齿故为纯色;文字靠 `fontmode="1"` 保持纯色。
- **`DashboardRenderer(k=…)` 缩放系数**:所有组件(`_label`/`_cmp_line`/`_usage_row`/`_pace`/`tri` 等)的像素常量与伪粗体偏移都乘 `k`,字体按 `12*k/24*k/48*k` 创建;`k=1` 为 400×300 原版(改组件后务必用固定夹具比对 `k=1` 输出逐像素不变),E1002 版当前用 `k=1`(k=2 整体放大在 800×480 上字过大,已否决),k 参数保留供更高分辩率设备使用。字体属性名(`F12/F24/F48`)表达层级而非绝对像素。
- 字体固定用 **Fusion Pixel 12px 点阵字体**(`fonts/`,OFL 协议),首选整数倍尺寸 **12/24/48**(`F12/F24/F48`)最锐利;用量区为拿到合适的字号层级另用了 **F13/F16**(1.08x/1.33x 非整数倍,笔画略不均,属有意取舍),靠 `d.fontmode = "1"` 关抗锯齿仍保持纯色不糊。跨字号竖直居中统一用 `_mid()`(按 `getbbox` 墨迹中心对齐)。
- 加粗用「伪粗体」:`_draw()` 按 1~bold px 水平偏移叠绘笔画(点阵字体只有单一字重)。**12px 加粗自动加 1px 字距**(`_track()`,2026-09-14):12px 字形之间只有 1 列空隙,伪粗体右偏 1px 正好填满、相邻字粘连(13/24px 空隙 2 列不受影响);`text/rtext/ctext/_mid` 内部都走它,宽版映射到 12px 的 F13/F16/FPACE 同样命中。手动推进 x 或居中时用 `_tw_b(s, ft, bold)` 取含粗体偏移与字距的实际占宽,别再手写 `_tw + bold`。
- 推送时 `DITHER = False`(硬阈值),纯黑白图最锐利,勿改成 `true`。
- 坐标、字号均为像素网格上的硬编码常量;调布局时注意各区分隔线 `hdash` 的 y 值与下方组件位置联动。
## 注意
- 所有凭证均在 `.env`(不提交);`config.py` 不再内置明文 fallback。分享代码时给出 `.env.example` 即可,真实 `.env` 切勿入库。
- `.gitignore` 已忽略:`output/`、`__pycache__/`、`*.pyc`、缓存文件 `.weather_cache.json`/`.trend_cache.json`/`.btc_cache.json`/`.device_cache.json`/`.claude_usage_cache.json`/`.codex_usage_cache.json`、本机专用的 `run.sh`、含凭证的 `.env`(模板 `.env.example` 入库)。改缓存逻辑后想强制重拉,删掉对应缓存文件即可。
## 验证与运维手法(会话沉淀)
- **像素回归**:改 `renderer.py` 组件后,用固定夹具比对 HEAD 版与工作区版 `DashboardRenderer` 的 k=1 输出逐像素相同
(`git show HEAD:renderer.py > /tmp/orig/renderer.py`,切换 `sys.path` 分别 import 渲染,`ImageChops.difference(a,b).getbbox() is None`);
六色版渲染后 `set(img.getcolors())` 必须 ⊆ `SPECTRA6`。夹具需覆盖:pct=None、≥warn 红档、≤ok 绿档、direction=None、空 candles。
- **HTML→截图逐像素核验**:`"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" --headless=new --disable-gpu --hide-scrollbars --force-device-scale-factor=1 --window-size=800,480 --screenshot=/tmp/s.png file://…`;**勿加指向空目录的 `--user-data-dir`**(卡死到超时)。
- **server.py 生产部署:NGCQ 服务器(2026-09-18 起)**,SSH 别名 `njcq`(Ubuntu 22.04,腾讯云境内,用户 `ubuntu` 免密 sudo)。公网地址 **`https://eink.njcqit.com`**(SenseCraft HTML 控件填 `https://eink.njcqit.com/e1002.png?token=<IMAGE_TOKEN>`,令牌见两端 `.env` 的 `IMAGE_TOKEN`(本机与服务器一致);本机 `.env` 的 `PUSH_URL=https://eink.njcqit.com`)。组成:
- 代码在 `/home/ubuntu/eink-push`(rsync `git ls-files` 的 24 个文件 + `.env` 0600,不含 output/缓存;`.env` 由本机 `.env` 派生:`SERVER_HOST=127.0.0.1`、`PUSH_STATE_PATH=/home/ubuntu/eink-push/output/push_state.json`、`PUSH_URL` 留空、`EPD_HOST`/`CODEX_*` 留空、另加 `BTC_SOURCES=gateio,huobi_aws,binance_vision`)。系统 `/usr/bin/python3` 3.10 + 用户级 Pillow 12.2(`~/.local`,无 python3-venv 包故不建 venv)。
- 进程:systemd **`eink-server.service`**(`/etc/systemd/system/`,`User=ubuntu`、`Restart=always`、日志追加 `output/server.log`)。改代码后:本机 rsync 同上 → `ssh njcq sudo systemctl restart eink-server`;查看 `ssh njcq 'systemctl status eink-server; tail output/server.log; curl -s 127.0.0.1:8790/health'`。
- 反代与证书走 **1Panel(v2.2.4,OpenResty 容器 host 网络)**:站点 `eink.njcqit.com`(id 10,类型 proxy → `http://127.0.0.1:8790`,默认分组)+ Let's Encrypt 证书(ssl id 11,EC256,`dnsAccount` 腾讯云 DNS 账号 id 1 + acme 账号 id 1,自动续期,与其它 njcqit.com 站点同一套);HTTP 301 到 HTTPS、HSTS。生成的配置:`/opt/1panel/www/conf.d/eink.njcqit.com.conf` + `/opt/1panel/www/sites/eink.njcqit.com/proxy/root.conf`。建站时用的是 **1Panel API**(面板已开 API、白名单 127.0.0.1,浏览器扩展当时未连接):服务器上留有辅助脚本 `~/.1p_api.sh METHOD /api/v2/path [json]`(运行时从 `/opt/1panel/db/core.db` 取 ApiKey 算 `1Panel-Token=md5("1panel"+key+ts)`,不落盘不回显)。**坑**:v2.2.4 的建站请求体与 dev 分支 swagger 不同——用 `domains:[{domain,port,ssl}]` 而非 `primaryDomain`(发 `primaryDomain` 直接 500 空响应),可 `enableSSL:true, websiteSSLID` 一步挂证书;结构体以 `raw.githubusercontent.com/1Panel-dev/1Panel/v2.2.4/agent/app/dto/request/website.go` 为准。建站后配置未即时生效(拿到面板默认证书、HTTP 404),`docker exec 1Panel-openresty-wYew nginx -t` 通过后几秒内自行生效。
- **境内上游可达性**:JM 后端、和风、SenseCraft 都通;**OKX/Coinbase/Huobi 主域名全部不通且各超时 4~8s**(合计超 `FETCH_BUDGET`,K 线永远拿不到),故 `btc_api.py` 新增 `gateio`/`huobi_aws`(api-aws.huobi.pro)/`binance_vision`(data-api.binance.vision)三源并加 `BTC_SOURCES` 顺序配置(config.py);本机默认顺序不变。
- 同一服务器上另有 **旧 main 分支 Zectrix 版仍在用**(2026-06 部署,每分钟 cron 推 Zectrix 云设备 44:1B:F6:89:C7:78,那块屏还在):目录 `~/eink-push-zectrix`(2026-09-18 由原 `~/eink-push` 改名,`run.sh` 内 `cd` 路径同步改)、cron `* * * * * /home/ubuntu/eink-push-zectrix/run.sh`、日志 `~/eink-push-zectrix/output/cron.log`。与 `~/eink-push`(本分支 E1002 服务)**并存互不干扰**:各自目录/`.env`/缓存,Zectrix 版不占端口、无 systemd;只共用 JM/和风上游接口。本分支代码不含 Zectrix 推送,**部署本分支时切勿覆盖或删掉 `~/eink-push-zectrix`**。
- **本机不再跑 server/cloudflared**:`com.jm.eink-server`/`com.jm.eink-cloudflared` 两个 LaunchAgent 已 bootout 并删除 plist;只保留 `com.jm.eink-push`(小屏推送 + 用量上报)。历史坑(cloudflared 快速隧道会被 Cloudflare 静默回收、SIGTERM 退出慢、需 `--protocol http2`)已不再相关。
- **shell 坑**:本机是 fish——`env -u …` 不能存进变量再当命令执行;含中文的 Python heredoc 用 `python3 -X utf8 - <<'EOF'`,否则 stdin 解码报错;`gh` 未登录且 GitHub API 匿名限流,取源码走 `curl raw.githubusercontent.com`。
- **用量采集验证**:`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`,不动代码。本机 `.env` 当前 `MAO_TTL=0`/`BTC_TTL=0`(每次请求实拉)。
- **「平台抓取失败」排查顺序**:① `ssh njcq 'systemctl is-active eink-server; curl -s -o /dev/null -w "%{http_code}" https://eink.njcqit.com/health'` 服务与反代是否活着(1Panel 站点/证书状态在面板「网站」里看);② 服务器 `output/server.log` 该时刻有无该请求(平台 IP 13.91.x.x,每 ~5m45s 一次)——没有记录说明请求根本没到我们这里,问题在平台/Nginx;③ 有记录看末尾状态串与耗时:`slow→` 表示某路超 `FETCH_BUDGET`、本次用了旧值(境内 BTC 源见上)。2026-09-14 一次怀疑「多路 TTL 同时到期导致超时」,核查日志:所有平台请求均 200、最慢 4.23s 且该次天气还是缓存,三路同时实拉的两次仅 1.2~1.5s——猜想不成立(MAO/BTC TTL=0 本就每次同时实拉);真正的隐患是当时串行拉取 + 30s socket 超时,已改并行 + 预算。
- **SenseCraft 未公开后端**(从网页前端 bundle 提取,`https://sensecraft-hmi-api.seeed.cc`,头 `Authorization: <token>`,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"}]}`)
→ `POST /api/v2/user/playlist/upsert_pages` → `POST /api/v2/user/device/down_link`(`{mac_address,playlist_id,type,refresh_interval?,deep_sleep_enabled?}`);
另有 `POST /api/v2/user/device/config`、`POST /render/preview`(`{url,img_format,resolution,dither}`)。**未采用**(未公开、可随时变更),仅作备选记录。