24 Commits

Author SHA1 Message Date
06785f030b docs: 记录 NGCQ 上 Zectrix 版(~/eink-push-zectrix)与 E1002 服务并存、部署时不可覆盖
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-18 11:48:39 +08:00
04fa31cb94 feat: server.py 迁移部署到 NGCQ(1Panel Nginx 反代 eink.njcqit.com)+境内可达 BTC 行情源+页面访问令牌
- btc_api:新增 gateio/huobi_aws/binance_vision 三源,config 增 BTC_SOURCES 控制尝试顺序(境内 okx/coinbase/huobi 主域名不通)
- server:IMAGE_TOKEN 非空时三个渲染端点须带 ?token=…,否则与未知路径同样 404(防爬虫扫描);推送令牌改常量时间比较
- 文档:CLAUDE.md/README/.env.example 记录 NGCQ 部署(systemd eink-server、1Panel API 建站坑)、本机 server/cloudflared 已卸载

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-18 11:42:23 +08:00
7c60d3680c feat: E1002 右上角天气改「图标|F48 温度」在上、一行城市·天气·温区右对齐在下
温度 F48 粗 3 顶沿与时钟齐平、「°」F24 上标、右贴页边;底行「定位水滴+城市  天气  温区」
与日期同基线右对齐。图标 1.25 倍并按各类别真实墨迹范围(_ICON_EXTENT,多云太阳光芒探出顶部、
雷阵雨闪电垂到云下)竖直居中于温度中心,避免顶到页边或越过横规。移除不再使用的 F72。
同步 CLAUDE.md 版式说明并记录同日被否的「三行信息|图标 ×1.75|F84 温度」中间版。

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-15 16:53:05 +08:00
c836b0d6bc docs: CLAUDE.md 记录 server.py/cloudflared 改由 launchd 常驻及运维命令
- 说明 nohup 启动会随 Claude Code 会话回收被 SIGTERM、重启后不自起的事故原因
- 记录两个 LaunchAgent 的 label、kickstart/bootout 操作,原手工命令降级为应急参考

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-15 10:59:12 +08:00
cf9aa3b1bb feat: E1002 右上角天气改「城市天气|图标|F72 大号温度」
- 实时温度改 F72 粗 3(先做过 F84 与时钟同高被嫌大),与时钟同基线、右贴页边
- 「°」不用大字号(F84 下 35px 宽且比数字高 7px),改 F36 粗 2 作上标,顶沿对齐数字顶沿、补回字形右侧空白使墨迹贴页边
- 定位+城市 / 天气·温区两行左缘对齐、底行与时钟同基线,天气图标居中放在两行与温度之间
- 此前为「城市 / F24 温度 / 天气·温区」三行右对齐+图标在左

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-14 17:56:27 +08:00
3656d8d933 fix: 12px 伪粗体文字自动加 1px 字距,消除相邻字粘连
- 12px 点阵字形间只有 1 列空隙,伪粗体右偏 1px 正好填满导致左右重叠;13/24px 空隙 2 列不受影响
- 基类新增 _track():12px 且加粗时自动加 k 字距,text/rtext/ctext/_mid 统一走它,宽版映射到 12px 的 F13/F16/FPACE 同样命中
- 新增 _tw_b() 取含粗体偏移与字距的实际占宽,替换环比/同比居中、金额前后缀、状态栏推进、BTC 涨跌幅、用量行进度条右端的手写宽度
- 夹具回归:小屏 F13/F16 部分逐像素不变,各行进度条右端仍齐平

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-14 17:34:41 +08:00
3fa6bb5de6 feat: 今日实时 0% 画等号+金额小号 ¥/万,数值与状态栏数字加粗
- 环比/同比增长率恰为 0 时方向记 flat,三角位画等号(与用量行 pace 持平一致)
- 金额拆为「¥ / 主数字 / 万」三段,¥ 与 万 用 F12 小号伪粗体、与 F24 主数字基线对齐;单量列不变
- 环比/同比数值、BTC 涨跌幅、顶部电量/温度/湿度数字改 F12 伪粗体(字号不变),占位 -- 不加粗
- 等号绘制抽为基类 _eq 供 pace 与环比/同比共用;网页版 flat 同样输出等号

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-14 17:10:21 +08:00
d35f233ef3 fix: 用量行 pace 列真 0 窗口不再缺席,diff=0 三角位画等号
Claude 5h 空闲时接口回 utilization 0、resets_at null,算不出 time_pct,
渲染器整列跳过 pace 导致该行进度条比别行长。数据层对 pct=0 且无重置时刻的
窗口记 time_pct=0;基类 _usage_row 只要有百分比就画 pace(缺 time_pct 按 0)。
diff=0 时三角位由留空改为画等宽等号「=」,预留逻辑从宽版下沉到基类,小屏同样齐平。

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-14 15:33:21 +08:00
f96ae29590 feat: E1002 顶部加设备电量/温湿度列+server 四路上游并行拉取与响应预算
- 新增 SenseCraft 设备遥测客户端(api-key 走 .env),读电量/充电/温度/湿度,DEVICE_TTL 与设备 300s 上报周期同步
- 大时钟 F84 右侧一列三行左缘对齐:电池(充电绿/≤20% 红)、温度计+蓝色水滴、星期日期(与时钟基线对齐)
- server 过期上游由串行改并行,整体最多等 FETCH_BUDGET 秒(默认 5);超预算的一路本次用旧值、后台拉完回填缓存,同一路不重复实拉
- 此前串行且上游 socket 超时 30s,任一路卡住即整图拖到平台抓取超时;「多路 TTL 同时到期」猜想经日志核查不成立,排查顺序写入 CLAUDE.md

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-14 15:02:34 +08:00
7378d6bb88 feat: AI 用量改账户级本机采集(Claude 钥匙串/Codex RPC)+宽版 7d 合并行
- Claude:复用 Claude Code 钥匙串登录态直调 /api/oauth/usage,零依赖 statusline 与 Claude Code 进程;只读不刷新 token
- Codex:经 codex app-server JSON-RPC 读 ChatGPT 订阅的 Codex 额度池(普通聊天额度无账户级途径,右块改名 Codex Usage)
- 两路采集共用 TTL 缓存+失败退避(CLAUDE_USAGE_TTL/CODEX_USAGE_TTL,语义同 WEATHER_TTL),推送端两路独立上报
- E1002:Claude 7d 与 fable 7d 合并为一行(单边框内上下对半实心,数字取较大者)、标签去 all 前缀、空值按 00% 00 占位等宽
- E1002/网页版:今日实时环比同比改涨绿跌红,与 K 线一致

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-14 14:08:21 +08:00
2015947812 feat: E1002 顶部时钟改 F84+用量行 12px 三档配色、标签右对齐
- 顶部时钟 F48→F84 占满 A 段,星期/日期移到时钟右侧并与基线对齐
- 用量行:标签常规字重右对齐贴进度条,百分比与 pace 数字 12px 粗体
- 配色:进度条/边框/百分比同色,用量与 pace 各自三档绿/黑/红、独立取色
  (曾按要求加过黄档,同日被否并移除)
- 用量块标题右侧「快照」改为「上次更新」,与今日实时统一
- 今日实时标题与指标名间距加大 4px
- 基类抽出 PCT_TRACK / LABEL_BOLD / LABEL_ALIGN / FPACE 供宽版覆写,
  400×300 小屏输出逐像素不变

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-14 11:39:55 +08:00
b58e1decf0 feat: BTC 时K 默认范围由 72 小时改为 48 小时
- BTC_LIMIT 默认 72→48(.env 未覆盖时即生效),文档同步
- BTC 文件缓存判定加入根数校验:改周期或根数后 TTL 内不再沿用旧范围
- CLAUDE.md 补充 cloudflared 快速隧道运维经验:隧道会被静默回收,
  重启用 --protocol http2 并剥掉代理环境变量

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-14 10:37:18 +08:00
b1a99a0e6b feat: 今日实时新增团购单量/流水字段,E1002 改五列竖排版式
今日实时由三列(单量/流水/毛利)扩展为五列,新增团购单量与团购流水;小屏
仍只显三列(跳过 wide 列),宽版 E1002 从「值 F36+右侧叠放环比同比」改为
四行竖排(名/值/环比/同比),避免五列每列 157px 放不下的问题。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-11 16:12:01 +08:00
85b5113ba4 feat: E1002 新增公网图片服务(恒定 URL 现渲染)+底部改为 BTC 时K
- server.py:三条恒定 URL 并行——/e1002.png 内存现渲染 PNG(主力)、
  /e1002.html 现代网页、/e1002-web.png 网页经无头 Chrome 截图后固化六色;
  天气/橘喵/BTC 按 WEATHER_TTL/MAO_TTL/BTC_TTL 走内存缓存,过期才实拉、
  失败沿用旧值;用量不读本机文件,改由 POST /push/usage(_gpt) 推送暂存
- push_client.py:本机把 Claude 用量快照推送到服务(X-Push-Token 鉴权)
- btc_api.py:OKX/Coinbase/Huobi 免费接口逐源兜底,时K 近 72 小时,涨绿跌红
- data.py 拆出 weather_fetch/mao_fetch/btc_fetch 与 usage_from_raw,
  统一按 TZ_NAME 时区;宽版只用点阵字体整数倍字号 12/24/36/48
- probe_server.py:验证平台对同一 URL 是否周期重抓(结论:Image 控件
  不重抓,HTML 控件重抓)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-10 18:55:47 +08:00
6dc89b504e feat: E1002 宽版用量配色改三档绿/黑/红+今日实时与近7天版式重做
- 用量与 pace 配色由「红/黑」二档、黄「注意」档,改为绿(健康)/黑(正常)/红(危险)三档,新增 USAGE_OK/PACE_TOL 阈值,去掉黄色进度条
- Claude/ChatGPT 用量行标签改用 statusline 同款长标签(all 5h/fable 7d 等)
- 今日实时区块改为指标名角标+数值与环比同比整组居中;近7天区块改为真实数据表(取30天趋势缓存最后7天),不画折线
- renderer.py 抽出 _cmp_at/_pace_color 钩子+新增 F36 字体供宽版复用;CLAUDE.md 同步版式与配置项说明

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-10 15:42:17 +08:00
110f710ff0 feat: E1002 六色版改版为 AI 用量看板+Claude 用量新增 Fable 7d 行与重置时刻
- 版面重排:主体左 Claude Usage(5h/7d/Fable 7d 三行),右 ChatGPT Usage(目前 mock,标「示例数据」);每行为一张用量卡:标签+F24 百分比+pace/全宽进度条/「重置 HH:MM」+「已过 NN%」
- 移除上一版的橘喵今日实时与 30 天趋势图及 trend totals 合计(业务数据留给 400×300 小屏),绿色暂不再使用
- data 用量结构新增 scoped(按模型限定额度)、reset(重置时刻文本)、updated(快照落盘时刻),新增 _usage_gpt mock 数据源,接入真实数据时保持结构替换即可
- usage_local 读取 statusline 新增的 seven_day_fable 与 updated_at;400×300 小屏 render 跳过 scoped 行
- 同步更新 CLAUDE.md 分支说明

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-10 14:43:31 +08:00
d7ae193f56 feat: E1002 六色版改为 k=1 原字号重排版+启用黄绿蓝语义色并恢复 30 天趋势图
- 放弃 k=2 整数倍放大(字太大),字号与 400×300 三色版一致,靠四倍面积重排:顶部时钟/天气不变,中段 Claude Usage 与橘喵今日实时左右并排,下段全宽恢复三色版隐藏的 30 天趋势图
- 六色各有唯一语义:红=预警/涨/定位/太阳闪电/流水线,黄=用量「注意」档填充(USAGE_CAUTION,默认 60,仅六色版),绿=毛利线,蓝=单量线与雨滴雪花
- renderer 基类新增 _usage_colors 配色钩子与 _weather_icon 的 wet 参数、_section 支持自定义横向范围,三色版行为不变
- data 为趋势追加 totals 合计(现算不进缓存),usage 增加 caution 阈值
- 底部六色测试色条改由 E1002_COLOR_STRIP 控制,默认关闭;同步更新 CLAUDE.md

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-10 14:11:35 +08:00
eab90bf3b5 feat: 新增 reTerminal E1002(800×480 六色 E Ink)目标设备支持
- 新增 renderer_e1002.py:E1002Renderer 继承 DashboardRenderer,以 k=2 整数倍缩放复用全部组件,重排为 800×480 四段版式(时钟/天气、Claude Usage、今日实时、六色测试色条)
- DashboardRenderer 改造为支持 k 缩放系数(字体按 k 倍创建,组件像素常量随 k 缩放),k=1 保持 400×300 原版不变
- main.py 新增 --target e1002 参数:渲染 800×480 六色图 + 固定尺寸 index.html 到 output/e1002/,供 SenseCraft HMI 的 HTML 控件云端周期性抓取(不推送)
- config.py 新增 E1002 画布尺寸与输出路径常量
- README.md / CLAUDE.md 补充新设备的架构说明与用法
2026-09-10 10:49:26 +08:00
097b9d1d64 fix: 用量百分比数字补零并加字符间距,避免两位数贴连
F16(16/12x 非整数倍缩放)下数字字形墨迹宽度等于步进宽度、无侧边距,
两位数(如 12%/36%)几乎贴在一起;加统一补零格式 + 手动字符间距(track)修复。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-07-08 15:16:54 +08:00
b32007483a fix: 推送网络请求加重试,避免瞬时失败漏推
mDNS(.local)域名解析基于组播UDP,偶发丢包会导致整轮推送直接抛异常跳过。
_request() 对连接类异常加重试(2次/间隔2s);画廊清理列表拉取失败也不再
阻塞后续 /upload,清理是锦上添花而非推送必要条件。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-07-07 10:55:15 +08:00
2121b1eff4 refactor: 推送改为局域网直连设备,推送前清空画廊旧图
原走 Zectrix 公网 API + 设备 MAC,现改为直连局域网墨水屏设备(喵喵固件)
的 HTTP 接口:POST /upload 推送 PNG 触发刷屏。设备画廊按追加保存,故推送
前先 GET /images 清空旧图,避免图片越推越多、且避免 slideshow 模式轮播
到过期图。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-07-07 10:19:10 +08:00
21426e13bc feat: 用量改读 statusline 生产的本地快照(生产者/消费者),实时准确
原先读 statusline 的 /tmp API 兜底缓存;新版 Claude Code 走 stdin、该缓存不再刷新
→ 数字过期(14% vs 实际 60%+)。改为消费「打过补丁的 claude-statusline 从 stdin
落盘的权威实时快照」:
- USAGE_LOCAL_PATH 默认改指 ~/.claude/usage-snapshot.json
- 修复 resets_at 解析:Claude Code stdin 给的是 epoch 数字(非 ISO),_parse_reset_ts
  现兼容 epoch/ISO,否则算不出 time_pct、pace 会消失
- 仍不发任何网络请求;快照缺失/损坏 → "--"
- 同步更新 CLAUDE.md / README.md 的数据源说明(生产者/消费者)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 19:22:03 +08:00
14359c8726 feat: 版式重构为三段 + 接入 Claude Usage(只读本地) + 定位江宁
将仪表盘从「四区(时钟/天气/今日实时/30天趋势)」重构为上中下三段:
- A 顶部:大时钟+日期 / 天气(定位改南京·江宁)
- B 中部主角 Claude Usage:5h/7d 用量进度条 + 大号百分比 + 时间维度对比 pace
  (pace=用量%−时间%,超前↑红/节余↓黑,参考 claude-statusline);用量≥阈值该条转红预警
- C 底部 橘喵今日实时:三列版式对齐主分支(名F12/值F24粗/环比同比偏移 +18/+34/+64/+82)
- 近30天趋势图隐藏(_trend_chart 及依赖保留、未调用,可挂回)

用量只读本地、不发网络请求:新增 usage_local.py 读取 claude-statusline 落盘的
/tmp/claude/statusline-usage-cache.json(utilization + resets_at)。utilization 可能滞后,
但 resets_at 为绝对时间,故 pace 每次按当前时间现算、始终准确;文件缺失回退 "--"。

其他:
- 天气定位 秦淮→江宁(config 默认 118.840,31.953)
- 进度条边框随条色(红条即红框);行内元素按墨迹竖直中心对齐(_mid)
- 新增 F13/F16 字号(非整数倍,fontmode=1 保持纯色)
- 全图仍严格三色(黑/白/红)、无灰阶不抖动;底部留 ~11px 下边距
- 同步更新 CLAUDE.md / README.md

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 18:55:46 +08:00
9b39eacecf feat: 渲染升级为黑/白/红三色,红作稀缺强调点缀
新设备在黑白外支持红色。将 renderer 画布从 'L' 灰度改为 'RGB' 三色,
RED=(255,0,0) 作为设备红通道的明确信号;fontmode='1' 保持点阵纯色不发糊。

红只承载语义、占墨极小(实测占墨迹 <10%),点缀四处:
- 定位图标(红水滴)
- 天气图标的太阳与闪电(云/雨/雪/雾/月仍黑)
- 环比/同比「涨」的三角与百分比数值(红涨;跌/标签仍黑)
- 趋势图主指标(流水)折线 + 末点红点 + 图例同步红

整图仅三种纯色,无灰阶、不抖动;dither 保持 false。
同步更新 CLAUDE.md / README.md 的三色约束与红点缀清单。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 18:19:38 +08:00
20 changed files with 2706 additions and 210 deletions

View File

@@ -1,10 +1,9 @@
# eink-push 配置模板:复制为 .env 并填入真实值cp .env.example .env
# 真实环境变量cron/命令行注入)优先级高于 .env 文件。
# ---- Zectrix 极趣云平台 ----
ZECTRIX_API_KEY=zt_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
# 目标设备硬件 MAC必填——直接据此推送不再获取设备列表
ZECTRIX_DEVICE_ID=AA:BB:CC:DD:EE:FF
# ---- 局域网墨水屏设备(喵喵固件)----
# mDNS host同局域网内可直接在浏览器打开 http://<EPD_HOST>/ 调试
EPD_HOST=epd400a44.local
# ---- 橘喵 jm-devops 后端(设备密钥授权)----
JM_BASE_API=https://admin.jumiaotandian.com/prod-api
@@ -18,3 +17,54 @@ QWEATHER_HOST=your-host.re.qweatherapi.com
QWEATHER_KEY=
WEATHER_LOCATION=118.788,32.014
WEATHER_TTL=900
# ---- reTerminal E1002 设备遥测SenseCraft HMI 后台生成的 API key宽版顶部状态栏显示电量/温度/湿度)----
SENSECRAFT_API_KEY=
SENSECRAFT_DEVICE_EUI=
DEVICE_TTL=300
# ---- reTerminal E1002 图片/网页服务server.py部署在公网本机推送端 main.py --target push----
# 服务端:监听地址/端口、两条恒定 URL 路径(可改成难猜的路径)、推送令牌(为空则拒绝一切推送)、推送数据落盘路径(空=仅内存)
SERVER_HOST=0.0.0.0
SERVER_PORT=8790
IMAGE_PATH=/e1002.png
HTML_PATH=/e1002.html
HTML_PNG_PATH=/e1002-web.png
# 方案三需要服务器装 Chrome/ChromiumCHROME_BIN 为空则自动探测Linux root 运行需 CHROME_EXTRA_ARGS=--no-sandbox
CHROME_BIN=
CHROME_EXTRA_ARGS=
HTML_PNG_THRESHOLD=160
PUSH_TOKEN=
# 页面访问令牌:非空时 /e1002.png、/e1002.html、/e1002-web.png 必须带 ?token=<IMAGE_TOKEN>,否则 404防爬虫扫描
# SenseCraft HTML 控件填 https://<域名>/e1002.png?token=<IMAGE_TOKEN>。生成python3 -c "import secrets; print(secrets.token_urlsafe(24))"
IMAGE_TOKEN=
PUSH_STATE_PATH=
# 推送端(本机):服务地址(协议+域名/端口,不含路径),令牌与服务端一致
PUSH_URL=
# 时区(服务端可能在境外 VPS所有时间按此时区
TZ_NAME=Asia/Shanghai
# 用量/pace 三档阈值(宽版,颜色顺序 绿→黑→红,两者独立取色)与 BTC K 线
USAGE_WARN=80
USAGE_OK=30
PACE_TOL=10
BTC_BAR=1H
BTC_LIMIT=48
BTC_TTL=300
# 行情源尝试顺序可选okx,coinbase,huobi,gateio,huobi_aws,binance_vision未列出的按默认顺序追加
# 境内服务器 okx/coinbase/huobi 主域名不通且各超时数秒,部署 NGCQ 时设为 gateio,huobi_aws,binance_vision
BTC_SOURCES=okx,coinbase,huobi,gateio,huobi_aws,binance_vision
# ---- 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 不含 HomebrewCODEX_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
# 服务端一次请求等待上游并行实拉的总预算(秒),超预算的一路本次用旧值、后台拉完更新缓存
FETCH_BUDGET=5

4
.gitignore vendored
View File

@@ -3,5 +3,9 @@ __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

File diff suppressed because one or more lines are too long

View File

@@ -1,27 +1,39 @@
# 墨水屏仪表盘推送eink-push
将橘喵今日经营、近30天趋势、日期与天气合成为一张 **400×300 纯黑白**图片,
推送到 Zectrix 极趣云墨水屏设备`zectrix-s3-epaper-4.2`1bit
日期天气、Claude Code 用量、橘喵今日经营合成为一张 **400×300 三色(黑/白/红BWR**图片,
推送到 Zectrix 极趣云墨水屏设备。
纯 Python + Pillow + Fusion Pixel 点阵字体,**无浏览器依赖**,适合后台/定时运行。
> **`feature/colorful` 分支**:新设备支持红色,渲染升级为黑/白/红三色,版式为上中下三段
> (时钟天气 / Claude Usage / 今日实时)。红作**语义强调**——用量吃紧预警、涨、定位、天气太阳/闪电——
> 全图只三种纯色、无灰阶不抖动。近30天趋势图当前隐藏代码保留可挂回
## 效果
- 顶部:大号时钟 + 日期 天气(矢量手绘图标 + 温度 + 天气/温区)
- 中部:橘喵今日实时经营(单量 / 流水 / 毛利,含环比、同比涨跌)
- 底部:近30天三指标趋势折线图同一图表实线 / 虚线 / 点线区分X 轴仅月/日
- 顶部:大号时钟 + 日期 天气(矢量手绘图标 + 温度 + 天气/温区,定位南京·江宁
- 中部:**Claude Usage**——5h / 7d 用量进度条 + 大号百分比 + 时间维度对比 pace用量%−时间%,超前↑ / 节余↓);用量 ≥ 阈值时该条转红预警
- 底部:橘喵今日实时经营(单量 / 流水 / 毛利,含环比、同比涨跌,涨=红
## 目录结构
```
eink-push/
├── main.py # 入口:取数据 → 渲染 → 推送
├── config.py # 配置API Key、设备、字体、尺寸、推送参数、缓存路径
├── data.py # 数据层:拉真实数据并映射,失败回退占位 "--"(不回退 mock含天气/趋势缓存
├── jm_api.py # jm-devops 后端统计接口客户端(免登录 GET仅带 clientid 头
├── main.py # 入口:取数据 → 渲染 → 推送--target e1002 时改为产出 HTML 页)
├── renderer_e1002.py# reTerminal E1002800x480 六色版式 + 固定尺寸 index.html 生成
├── server.py # E1002 动态服务:恒定 URL每次请求现取数据、内存现渲染PNG / HTML 两方案)+ 用量推送接口
├── html_renderer.py # 方案二800x480 现代网页版(无 JS内联 SVG K 线
├── html_shot.py # 方案三:网页 → 无头 Chrome 截图 → 最近色固化六色 PNG
├── push_client.py # 本机推送端:用量快照 → server.py
├── btc_api.py # BTC K 线OKX/Coinbase/Huobi 免费接口,逐源兜底)
├── probe_server.py # 验证工具:平台对同一 URL 是否周期重抓
├── config.py # 配置:设备、天气、用量、字体、尺寸、推送参数、缓存路径
├── data.py # 数据层:拉真实数据并映射,失败回退占位 "--"(不回退 mock天气/趋势缓存
├── jm_api.py # jm-devops 后端统计接口客户端(设备密钥 client_secret 授权 + token 缓存)
├── weather_api.py # 和风天气QWeather客户端实时天气 + 今日温区)
├── renderer.py # 渲染层DashboardRenderer 生成 400×300 纯黑白图片
├── pusher.py # 推送层:设备列表 / 推送图片(标准库 urllib
├── usage_local.py # Claude Code 用量:只读 statusline 生产的本地快照(不发网络请求)
├── renderer.py # 渲染层DashboardRenderer 生成 400×300 三色(黑/白/红)图片
├── pusher.py # 推送层:按 .env 设备 MAC 直接推送图片(标准库 urllib
├── run.sh # cron 包装脚本本机专用git 忽略)
├── fonts/ # Fusion Pixel 点阵字体OFL 协议)
├── output/ # 生成的图片与 cron.loggit 忽略)
@@ -38,8 +50,15 @@ pip install -r requirements.txt # 仅 Pillow>=10.0;网络请求用标准库
## 使用
```bash
python3 main.py # 渲染并推送(默认取设备列表第一个
python3 main.py # 渲染并推送(设备取 .env 的 ZECTRIX_DEVICE_ID
python3 main.py --render-only # 只渲染到 output/dashboard.png不推送本地调试渲染用
python3 main.py --target e1002 # reTerminal E1002800x480 六色):产出 output/e1002/index.html + dashboard.png
# 部署到公网 HTTPS 后填入 SenseCraft HMI 的 HTML 控件(云端每周期重新抓取)
python3 server.py # reTerminal E1002 动态服务(部署公网 HTTPS/e1002.png 每次请求内存渲染 PNG/e1002.html 现代网页,
# /e1002-web.png 网页经服务端无头 Chrome 截图后固化六色(需装 Chrome
# 天气/橘喵/BTC 实时拉取Claude/ChatGPT 用量由本机推送
python3 main.py --target push # 本机:采集 Claude/Codex 账户级用量并推送到 server.py.env: PUSH_URL + PUSH_TOKEN
# server.py 生产部署在 NGCQ 服务器systemd eink-server + 1Panel Nginx 反代 https://eink.njcqit.com见 CLAUDE.md
```
环境变量可覆盖配置(详见 `config.py`
@@ -50,11 +69,13 @@ ZECTRIX_API_KEY=zt_xxx ZECTRIX_DEVICE_ID=AA:BB:CC:DD:EE:FF python3 main.py
# jm-devops 后端 / 和风天气
JM_BASE_API=... JM_CLIENT_ID=...
QWEATHER_HOST=... QWEATHER_KEY=... WEATHER_LOCATION=经度,纬度 WEATHER_TTL=900
# Claude Code 用量(只读 statusline 生产的本地快照,不发请求)
USAGE_LOCAL_PATH=~/.claude/usage-snapshot.json USAGE_WARN=80
```
## 定时刷新cron
设计为**每分钟**触发:业务数据每次都刷新,天气/趋势靠缓存避免高频打接口(见下「设计说明」)。
设计为**每分钟**触发:业务数据每次都刷新,天气靠缓存、用量读本地文件,避免高频打接口(见下「设计说明」)。
`run.sh` 是 cron 入口——cron 不继承登录环境,故脚本内 python 写死绝对路径、`cd` 到项目目录后追加日志到 `output/cron.log`
```cron
@@ -65,14 +86,18 @@ QWEATHER_HOST=... QWEATHER_KEY=... WEATHER_LOCATION=经度,纬度 WEATHER_TTL
## 数据来源
- **橘喵今日经营 + 近30天趋势**:接入 jm-devops 后端统计接口(`jm_api.py`
免登录 GET仅带 `clientid` 头、无 token
- **橘喵今日经营**:接入 jm-devops 后端统计接口(`jm_api.py`设备密钥 client_secret 授权):
- `GET /system/statistics/realtime` —— 今日单量/流水/毛利 + 环比/同比
- `GET /system/statistics/recent-days?days=30` —— 近30天每日数据按日期降序、不含今天
- **天气**接入真实和风天气QWeather`weather_api.py`),定位固定南京·秦淮
实时天气取 `now.text`/`now.temp`/`now.icon`,今日温区取 `/3d``daily[0]`
需配置 `QWEATHER_HOST` + `QWEATHER_KEY`(控制台-设置查看)。
- `GET /system/statistics/recent-days?days=30` —— 近30天每日数据趋势图当前隐藏,仍会拉取并缓存
- **天气**接入真实和风天气QWeather`weather_api.py`),定位固定南京·江宁
实时天气取 `now.text`/`now.temp`/`now.icon`,今日温区取 `/3d``daily[0]`需配置 `QWEATHER_HOST` + `QWEATHER_KEY`
- **Claude Code 用量****只读本地**`usage_local.py`,不发请求),**生产者/消费者**模式——
生产者是**打过补丁的 claude-statusline**:它把每次 Claude Code 经 stdin 喂来的**权威实时**额度
落盘为快照 `~/.claude/usage-snapshot.json``five_hour`/`seven_day``utilization` + `resets_at`
本项目作消费者读取。活跃使用时快照持续刷新、准确;空闲时停在最后一次。**需先安装补丁版 statusline**
(见其仓库 `bin/statusline.sh` 的 usage snapshot 段 + `node bin/install.js`)。
- **日期 / 时间 / 上次更新**:真实系统时间。
- **比特币 K 线**(仅 800×480 宽版底部面板,默认时 K 近 48 小时,`BTC_BAR`/`BTC_LIMIT` 可配):免费公开接口,无需密钥,逐源兜底 OKX → Coinbase → Huobi`btc_api.py`5 分钟文件缓存 `.btc_cache.json`,失败沿用旧缓存。涨(收≥开)绿、跌红。
> `data.py` 负责把响应映射成渲染结构;**任何接口失败都回退占位 `"--"`,不回退 mock**。
@@ -83,14 +108,16 @@ QWEATHER_HOST=... QWEATHER_KEY=... WEATHER_LOCATION=经度,纬度 WEATHER_TTL
- **天气缓存** `.weather_cache.json`TTL = `WEATHER_TTL`(默认 15 分钟),未过期不发请求。
- **趋势缓存** `.trend_cache.json`:按日期为 key当天命中即不再调 `recent-days`,每天只拉一次。
两者拉取失败均**沿用旧缓存**避免趋势/天气区整片 `"--"`,仍无缓存才回退占位。想强制重拉删掉对应缓存文件即可(均已 git 忽略)。
两者拉取失败均**沿用旧缓存**避免天气区整片 `"--"`,仍无缓存才回退占位。想强制重拉删掉对应缓存文件即可(均已 git 忽略)。
Claude 用量**不自建缓存**——直接读 statusline 生产的本地快照 `~/.claude/usage-snapshot.json`,文件缺失/损坏即显示 `"--"`
## 设计说明
- 设备为 **1bit 无真灰阶**,灰阶只能抖动成网点(难看),故全程纯黑白,靠构图、字号、留白建立层级。
- 字体用 **Fusion Pixel 12px 点阵字体**,按整数倍尺寸(12/24/48)渲染、关闭抗锯齿 → 像素锐利;
重点数据用「伪粗体」(偏移叠绘)加粗。
- 天气图标为**全矢量手绘**(无图片资源):按和风 icon 代码归并为 8 类(晴/晴夜/多云/阴/雷/雨/雪/雾),用基本图元按 1bit 描边画出。
- 趋势折线各指标按自身极值独立归一化,故只表达**趋势形状**,不可横向比绝对值。
- 推送用 `dither=false`(硬阈值),纯黑白图最锐利。
- 设备为**三色(黑/白/红)无真灰阶**,灰阶只能抖动成网点(难看),故底色只用黑白 + 红作稀缺强调,靠构图、字号、留白建立层级。
- **红只承载语义**(方向·警示·强调),占墨保持很小(实测红≈全图 0.6%、占墨迹 <10%整图仅三种纯色 `(0,0,0)/(255,255,255)/(255,0,0)`无灰不抖动
- 字体用 **Fusion Pixel 12px 点阵字体**首选整数倍(12/24/48)最锐利用量区另用 13/16px(非整数倍笔画略不均)取字号层级关抗锯齿仍保持纯色不糊重点数据用伪粗体」(偏移叠绘)加粗
- **Claude Usage**每条单行 = 左标签(5h/7d) + 粗进度条 + 百分比 + pace(/下三角)用量 `USAGE_WARN`(默认 80) 时该条进度条与百分比转红预警行内所有元素按墨迹竖直中心对齐到同一中线
- 天气图标为**全矢量手绘**无图片资源按和风 icon 代码归并为 8 /晴夜/多云/////用基本图元描边画出太阳与闪电着红
- 近30天趋势折线图当前**隐藏**`renderer._trend_chart` 及依赖保留未调用随时可挂回)。
- 推送用 `dither=false`硬阈值三色纯色图最锐利
```

118
btc_api.py Normal file
View File

@@ -0,0 +1,118 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
比特币 K 线行情客户端 —— 免费、无需密钥的公开接口,按顺序尝试多个源,首个成功即返回。
源(均为公开 REST无鉴权
1. OKX GET /api/v5/market/candles?instId=BTC-USDT&bar=<1H|1D> —— 主源,本地直连可达、数据最新
2. Coinbase GET /products/BTC-USD/candles?granularity=<3600|86400> —— 备源USD 计价,与 USDT 数值几乎一致)
3. Huobi GET /market/history/kline?period=<60min|1day>&symbol=btcusdt —— 备源
4. Gate.io GET /api/v4/spot/candlesticks?currency_pair=BTC_USDT&interval=<1h|1d>
5. Huobi AWS 同 3域名 api-aws.huobi.pro
6. Binance GET data-api.binance.vision/api/v3/klines?symbol=BTCUSDT&interval=<1h|1d>(公开行情镜像域名)
api.binance.com 在本地区返回 restricted location不列入。
顺序可用 BTC_SOURCES逗号分隔源名覆盖境内服务器上 okx/coinbase/huobi 主域名均不通且逐个超时(合计 >15s
超出 FETCH_BUDGET须把 gateio/huobi_aws/binance_vision 排在前面2026-09-18 部署 NGCQ 实测)。
周期用统一代号 bar"1H"(时 K/ "1D"(日 K由 BARS 映射到各源的写法。
统一输出:按时间**升序**的 list[dict],每根 {"ts": epoch 秒, "o", "h", "l", "c"}float最后一根为当前未收盘的那根。
任何源失败抛异常并尝试下一个;全部失败抛 RuntimeError由 data 层决定沿用缓存或占位。
"""
import json
import urllib.request
TIMEOUT = 8
UA = "eink-push/1.0 (+https://github.com/)"
# 周期代号 → 各源参数:(OKX bar, Coinbase granularity 秒, Huobi period, Gate.io/Binance interval)
BARS = {"1H": ("1H", 3600, "60min", "1h"), "1D": ("1D", 86400, "1day", "1d")}
def _get_json(url):
req = urllib.request.Request(url, headers={"User-Agent": UA, "Accept": "application/json"})
with urllib.request.urlopen(req, timeout=TIMEOUT) as resp:
return json.loads(resp.read().decode("utf-8"))
def _okx(bar, limit):
j = _get_json(f"https://www.okx.com/api/v5/market/candles?instId=BTC-USDT&bar={BARS[bar][0]}&limit={limit}")
if str(j.get("code")) != "0":
raise RuntimeError(f"okx code={j.get('code')} msg={j.get('msg')}")
rows = j["data"] # 最新在前:[ts_ms, o, h, l, c, vol, ...]
out = [{"ts": int(r[0]) // 1000, "o": float(r[1]), "h": float(r[2]), "l": float(r[3]), "c": float(r[4])} for r in rows]
return sorted(out, key=lambda x: x["ts"])
def _coinbase(bar, limit):
rows = _get_json(f"https://api.exchange.coinbase.com/products/BTC-USD/candles?granularity={BARS[bar][1]}")
# 最新在前:[time, low, high, open, close, volume]
out = [{"ts": int(r[0]), "o": float(r[3]), "h": float(r[2]), "l": float(r[1]), "c": float(r[4])} for r in rows]
return sorted(out, key=lambda x: x["ts"])[-limit:]
def _huobi(bar, limit, host="api.huobi.pro"):
j = _get_json(f"https://{host}/market/history/kline?period={BARS[bar][2]}&size={limit}&symbol=btcusdt")
if j.get("status") != "ok":
raise RuntimeError(f"huobi status={j.get('status')} err={j.get('err-msg')}")
out = [{"ts": int(r["id"]), "o": float(r["open"]), "h": float(r["high"]), "l": float(r["low"]), "c": float(r["close"])}
for r in j["data"]]
return sorted(out, key=lambda x: x["ts"])
def _huobi_aws(bar, limit):
return _huobi(bar, limit, host="api-aws.huobi.pro")
def _gateio(bar, limit):
rows = _get_json(f"https://api.gateio.ws/api/v4/spot/candlesticks?currency_pair=BTC_USDT&interval={BARS[bar][3]}&limit={limit}")
# 升序:[ts 秒, 计价币成交额, close, high, low, open, 基础币成交量, 是否收盘]
out = [{"ts": int(r[0]), "o": float(r[5]), "h": float(r[3]), "l": float(r[4]), "c": float(r[2])} for r in rows]
return sorted(out, key=lambda x: x["ts"])
def _binance_vision(bar, limit):
rows = _get_json(f"https://data-api.binance.vision/api/v3/klines?symbol=BTCUSDT&interval={BARS[bar][3]}&limit={limit}")
# 升序:[openTime ms, o, h, l, c, vol, closeTime, ...]
out = [{"ts": int(r[0]) // 1000, "o": float(r[1]), "h": float(r[2]), "l": float(r[3]), "c": float(r[4])} for r in rows]
return sorted(out, key=lambda x: x["ts"])
SOURCES = (("okx", _okx), ("coinbase", _coinbase), ("huobi", _huobi),
("gateio", _gateio), ("huobi_aws", _huobi_aws), ("binance_vision", _binance_vision))
_BY_NAME = dict(SOURCES)
def _ordered_sources():
"""按 config.BTC_SOURCES逗号分隔源名排序未列出的源按默认顺序追加到末尾未知名字忽略。"""
try:
import config
wanted = [s.strip() for s in config.BTC_SOURCES.split(",") if s.strip()]
except Exception: # noqa: BLE001 —— 单独运行本模块或 config 缺失时用默认顺序
wanted = []
ordered = [(n, _BY_NAME[n]) for n in wanted if n in _BY_NAME]
ordered += [(n, f) for n, f in SOURCES if n not in dict(ordered)]
return ordered
def get_candles(bar="1H", limit=36):
"""返回 (源名, 升序 K 线列表)。bar 为 "1H"/"1D"。逐源尝试,全部失败抛 RuntimeError。"""
if bar not in BARS:
raise ValueError(f"不支持的周期 {bar!r},可选:{list(BARS)}")
errors = []
for name, fn in _ordered_sources():
try:
rows = fn(bar, limit)
if len(rows) >= 2:
return name, rows[-limit:]
errors.append(f"{name}: 数据不足({len(rows)})")
except Exception as e: # noqa: BLE001 —— 逐源兜底,任何异常都换下一个源
errors.append(f"{name}: {e}")
raise RuntimeError(f"BTC {bar} K 线全部源失败:" + "".join(errors))
if __name__ == "__main__":
src, rows = get_candles("1H", 5)
print(src)
for r in rows:
print(r)

155
claude_usage.py Normal file
View File

@@ -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 passwordservice「Claude Code-credentials」JSON 的 claudeAiOauth.accessToken
3. 文件 ~/.claude/.credentials.jsonLinux / SSH 场景;尊重 CLAUDE_CONFIG_DIR
铁律:
- **绝不刷新 token**。refresh token 每次使用即轮换,第三方刷新而不写回会让 Claude Code 下次 401 并强制重新登录。
access token 约 8 小时有效,过期就沿用旧快照(标 stale等 Claude Code 自己下次启动刷新。
- **快照有效期 CLAUDE_USAGE_TTL 秒内不再调**.env 可调,默认 3000=每次实拉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 或已过期——本机状态而非接口限流:退避只需一个 TTLClaude 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))

147
codex_usage.py Normal file
View File

@@ -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/usagetoken 刷新由 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 可执行文件」)。窗口归类按 windowDurationMinsPro 计划常只回一个窗口、另一个为 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))

115
config.py
View File

@@ -28,24 +28,55 @@ def _load_dotenv(path):
_load_dotenv(os.path.join(BASE_DIR, ".env"))
# ---- Zectrix 极趣云平台 API ----
API_BASE = "https://cloud.zectrix.com/open/v1"
API_KEY = os.environ.get("ZECTRIX_API_KEY", "")
# ---- 局域网墨水屏设备喵喵固件开源https://gitee.com/gxp666111/miaomiao----
# 不再走 Zectrix 公网 API + 设备 MAC改为直连局域网设备 mDNS host 的 HTTP 接口。
EPD_HOST = os.environ.get("EPD_HOST", "epd400a44.local")
# 目标设备 ID硬件 MAC。必填——直接据此调推送接口不再获取设备列表。
DEVICE_ID = os.environ.get("ZECTRIX_DEVICE_ID", "")
# ---- 时区 ----
# 服务端可能部署在境外 VPS所有「现在时刻」与时间戳格式化统一按此时区data.TZ本机与服务端结果一致。
TZ_NAME = os.environ.get("TZ_NAME", "Asia/Shanghai")
# 推送参数
PAGE_ID = "1" # 持久化页面编号 1-5
DITHER = False # 纯黑白点阵图用硬阈值最锐利;设备无真灰阶,勿开抖动
# ---- 画布 ----
# ---- 画布(喵喵固件 400x300 三色屏)----
WIDTH, HEIGHT = 400, 300
# ---- reTerminal E1002800x480E Ink Spectra 6 六色;经 SenseCraft HMI 的 HTML 控件云端抓取)----
# 产物:固定 800x480 的 index.html + 同目录 dashboard.png需部署到公网 HTTPS 地址供平台每周期重新拉取。
E1002_WIDTH, E1002_HEIGHT = 800, 480
E1002_COLOR_STRIP = os.environ.get("E1002_COLOR_STRIP", "0") == "1" # 底部六色测试色条(真机校准用,默认关)
# ---- 图片服务server.py恒定 URL每次请求内存中现渲染 800x480 PNG供 SenseCraft HTML 控件周期抓取)----
# 服务端三路上游的内存缓存有效期(秒):收到请求先查缓存是否过期,过期才实拉并更新时间戳,否则直接用缓存渲染;
# 拉取失败沿用旧值且不更新时间戳(下次请求再试)。设 0 = 每次请求都实拉。天气/BTC 复用上面的 WEATHER_TTL/BTC_TTL。
MAO_TTL = int(os.environ.get("MAO_TTL", "60")) # 橘喵今日实时,默认 60 秒
# server.py 一次请求里等待过期上游并行实拉的总预算(秒):超预算的那路本次用上一次成功值,后台继续拉完更新缓存。
# 上游 socket 超时各 30s和风/橘喵、8sBTC若不设预算、任一路卡住会拖到平台抓取超时预算 ≥ 正常三路并行耗时(~1s即可。
FETCH_BUDGET = float(os.environ.get("FETCH_BUDGET", "5"))
# 真机验证结论Image 控件只在填写 URL 时抓一次快照、此后不再更新HTML 控件填同一图片 URL 则每周期重抓。
SERVER_HOST = os.environ.get("SERVER_HOST", "0.0.0.0")
SERVER_PORT = int(os.environ.get("SERVER_PORT", "8790"))
IMAGE_PATH = os.environ.get("IMAGE_PATH", "/e1002.png") # 方案一:恒定图片路径(每次请求内存渲染 PNG可改成难猜的路径防扫描
HTML_PATH = os.environ.get("HTML_PATH", "/e1002.html") # 方案二:恒定网页路径(每次请求生成 800x480 现代网页,平台截图)
# 方案三:服务端用无头 Chrome 把方案二的网页截成 800x480再按「最近色、不抖动」固化为六色 PNG需服务器装 Chrome/Chromium
HTML_PNG_PATH = os.environ.get("HTML_PNG_PATH", "/e1002-web.png")
CHROME_BIN = os.environ.get("CHROME_BIN", "") # 为空则按常见路径自动探测macOS Google Chrome / google-chrome / chromium
CHROME_EXTRA_ARGS = os.environ.get("CHROME_EXTRA_ARGS", "") # 例如 Linux 以 root 运行需 "--no-sandbox"
# 每通道二值化阈值0~255低于阈值归 0、否则归 255取 160而非 128偏向保留墨迹细笔画的浅灰边不至于被丢成白
HTML_PNG_THRESHOLD = int(os.environ.get("HTML_PNG_THRESHOLD", "160"))
# 页面访问令牌:非空时 IMAGE_PATH/HTML_PATH/HTML_PNG_PATH 必须带 ?token=<IMAGE_TOKEN>,否则一律 404对扫描器等同不存在
# 服务在公网且路径固定SenseCraft 里填 https://<域名>/e1002.png?token=<IMAGE_TOKEN>2026-09-18 用户要求,防爬虫扫到)
IMAGE_TOKEN = os.environ.get("IMAGE_TOKEN", "")
PUSH_TOKEN = os.environ.get("PUSH_TOKEN", "") # 推送鉴权令牌X-Push-Token 头);为空则拒绝一切推送
PUSH_STATE_PATH = os.environ.get("PUSH_STATE_PATH", "") # 推送数据落盘路径(重启后恢复);为空=仅内存
# 本机推送端main.py --target push服务地址含协议与端口不含路径令牌同 PUSH_TOKEN
PUSH_URL = os.environ.get("PUSH_URL", "")
# ---- 资源路径 ----
FONT_PATH = os.path.join(BASE_DIR, "fonts", "fusion-pixel-12px-proportional-zh_hans.otf")
OUTPUT_DIR = os.path.join(BASE_DIR, "output")
OUTPUT_PATH = os.path.join(OUTPUT_DIR, "dashboard.png")
E1002_OUTPUT_DIR = os.path.join(OUTPUT_DIR, "e1002")
E1002_PNG_PATH = os.path.join(E1002_OUTPUT_DIR, "dashboard.png")
E1002_HTML_PATH = os.path.join(E1002_OUTPUT_DIR, "index.html")
# ---- 橘喵 jm-devops 后端(今日经营 / 近30天趋势 数据源)----
# 鉴权方式设备密钥授权grant_type=client_secret免验证码
@@ -62,11 +93,67 @@ TENANT_ID = os.environ.get("JM_TENANT_ID", "000000")
# 未配置时天气区显示占位 "--",不影响其余渲染与推送。
QWEATHER_HOST = os.environ.get("QWEATHER_HOST", "") # 用户专属 API Host
QWEATHER_KEY = os.environ.get("QWEATHER_KEY", "")
# 定位固定为南京·秦淮区。和风经纬度格式为「经度,纬度」
WEATHER_LOCATION = os.environ.get("WEATHER_LOCATION", "118.788,32.014")
WEATHER_LOC_NAME = "南京·秦淮"
# 定位固定为南京·江宁区。和风经纬度格式为「经度,纬度」
WEATHER_LOCATION = os.environ.get("WEATHER_LOCATION", "118.840,31.953")
WEATHER_LOC_NAME = "南京·江宁"
# 天气缓存:业务数据每分钟刷新,但天气 15 分钟才重新拉一次cron 每次独立进程,缓存须落盘)。
WEATHER_TTL = int(os.environ.get("WEATHER_TTL", "900")) # 秒,默认 900=15 分钟
WEATHER_TTL = int(os.environ.get("WEATHER_TTL", "900")) # 秒,默认 900=15 分钟(本机文件缓存与 server.py 内存缓存共用)
WEATHER_CACHE_PATH = os.path.join(BASE_DIR, ".weather_cache.json")
# 近30天趋势缓存recent-days 每天只更新一次,按日期为 key 缓存,当天命中即不再请求。
TREND_CACHE_PATH = os.path.join(BASE_DIR, ".trend_cache.json")
# ---- 比特币 K 线(仅 800x480 宽版底部面板)----
# 免费公开接口无需密钥逐源兜底OKX → Coinbase → Huobi见 btc_api.py。当前为时 K、近 48 小时2 天);
# 每 5 分钟刷新(与 launchd 节律一致,最新一根随实时价推进)。拉取失败沿用旧缓存。缓存文件已在 .gitignore。
BTC_BAR = os.environ.get("BTC_BAR", "1H") # 周期:"1H" 时K / "1D" 日K
BTC_LIMIT = int(os.environ.get("BTC_LIMIT", "48")) # 展示最近 N 根(时 K 默认 48 = 近 2 天2026-09-14 由 72 改 48
BTC_TTL = int(os.environ.get("BTC_TTL", "300")) # 秒,默认 5 分钟(本机文件缓存与 server.py 内存缓存共用)
# 行情源尝试顺序(逗号分隔,见 btc_api.SOURCES未列出的按默认顺序追加。境内服务器 okx/coinbase/huobi 主域名不通且各超时数秒,
# 部署 NGCQ 时设 BTC_SOURCES=gateio,huobi_aws,binance_vision否则每次请求都超 FETCH_BUDGET 拿不到 K 线。
BTC_SOURCES = os.environ.get("BTC_SOURCES", "okx,coinbase,huobi,gateio,huobi_aws,binance_vision")
BTC_CACHE_PATH = os.path.join(BASE_DIR, ".btc_cache.json")
# ---- reTerminal E1002 设备遥测SenseCraft HMI电量/温度/湿度,宽版顶部状态栏)----
# api-key 由用户在 SenseCraft 后台生成,放 .env设备每 300s 上报一次DEVICE_TTL 与之同步0=每次实拉)。
SENSECRAFT_API_BASE = os.environ.get("SENSECRAFT_API_BASE", "https://sensecraft-hmi-api.seeed.cc")
SENSECRAFT_API_KEY = os.environ.get("SENSECRAFT_API_KEY", "")
SENSECRAFT_DEVICE_EUI = os.environ.get("SENSECRAFT_DEVICE_EUI", "")
DEVICE_TTL = int(os.environ.get("DEVICE_TTL", "300"))
DEVICE_CACHE_PATH = os.path.join(BASE_DIR, ".device_cache.json")
# ---- AI 用量数据源Claude Usage / Codex Usage 两个模块)----
# 形态 A两路都在**本机**采集(需本机的登录态),由 main.py --target push 上报给 server.py服务端不持任何凭证、不读本机文件。
# Claudeclaude_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") # 只存快照与时间戳,不存凭证
# Codexcodex_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 默认目录 ~/.codex2026-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% 黄档,随即被否——黄小字压白底发虚、且四档在三行里过于花):
USAGE_OK = int(os.environ.get("USAGE_OK", "30")) # 用量 ≤ OK 绿「健康」OK~WARN 黑「正常」|≥ WARN 红「危险」;进度条、边框与百分比数字同色
PACE_TOL = int(os.environ.get("PACE_TOL", "10")) # pace=用量%−时间%:≤ TOL 绿「节余」±TOL 内黑「持平」|≥ +TOL 红「偏快」

341
data.py
View File

@@ -4,19 +4,40 @@
数据层 —— 汇总仪表盘所需的全部数据。
橘喵今日经营 + 近30天趋势来自 jm_apijm-devops 后端统计接口,免鉴权 GET
天气来自 weather_api和风天气定位南京·秦淮)。日期/时间用真实系统时间。
天气来自 weather_api和风天气定位南京·江宁)。日期/时间用真实系统时间(统一按 config.TZ_NAME 时区)
任何接口失败时相关数据以占位符 "--" 呈现,不回退 mock。
两种调用形态:
- 本机脚本main.pyget_dashboard_data() —— 带文件缓存(天气/趋势/BTC用量读本地快照
- 图片服务server.py直接用各 `*_fetch()` 纯拉取函数 + `_usage(now, raw=<推送数据>)`,不落盘。
"""
import json
from datetime import datetime
from zoneinfo import ZoneInfo
import config
import jm_api
import weather_api
import claude_usage
import codex_usage
import btc_api
import sensecraft_api
WEEK_CN = ["周一", "周二", "周三", "周四", "周五", "周六", "周日"]
TZ = ZoneInfo(config.TZ_NAME)
def now_tz():
"""当前时刻(带 config.TZ_NAME 时区)。"""
return datetime.now(TZ)
def _local(ts):
"""epoch 秒 → 该时区的 datetime。"""
return datetime.fromtimestamp(ts, TZ)
TREND_DAYS = 30 # 近 N 天趋势
FIVE_HOUR, SEVEN_DAY = 5 * 3600, 7 * 86400 # 两个滚动窗口长度(秒),用于算「时间已流逝%」
def _read_weather_cache():
@@ -83,7 +104,7 @@ def _trend(now):
def _weather(now):
"""和风天气(南京·秦淮),带 WEATHER_TTL 文件缓存。
"""和风天气(南京·江宁),带 WEATHER_TTL 文件缓存(本机脚本用)
缓存未过期 → 直接复用,不发请求(每分钟刷新时省掉 14/15 次天气调用)。
过期 → 重新拉取并写缓存;全部接口失败时沿用上次缓存,仍无缓存才回退占位 "--"
@@ -92,6 +113,18 @@ def _weather(now):
cached = _read_weather_cache()
if cached and ts - cached.get("ts", 0) < config.WEATHER_TTL:
return cached["data"]
out, ok = weather_fetch()
if ok:
_write_weather_cache(ts, out)
return out
if cached: # 全部失败:沿用上次缓存,避免天气区整片 "--"
print("[警告] 天气全部拉取失败,沿用上次缓存")
return cached["data"]
return out
def weather_fetch():
"""实时拉取天气(不读不写缓存)→ weather dict是否至少一个接口成功。服务端每次请求直接调用。"""
out = {"loc": config.WEATHER_LOC_NAME, "cond": "--", "temp": "--", "range": "--", "icon": ""}
ok = False
try:
@@ -108,67 +141,313 @@ def _weather(now):
ok = True
except Exception as e:
print(f"[警告] 今日温区拉取失败:{e}")
if ok:
_write_weather_cache(ts, out)
return out
if cached: # 全部失败:沿用上次缓存,避免天气区整片 "--"
print("[警告] 天气全部拉取失败,沿用上次缓存")
return cached["data"]
return out
return out, ok
# ---------- 格式化 ----------
# 数值统一拆成三段 (前缀, 主数字, 后缀)渲染器把主数字画大、前后缀画小如「¥」「万」2026-09-14 用户要求),
# 拼接后的整串仍放在 col["v"],供网页版等只认字符串的消费方使用。
def _fmt_count(n):
return f"{int(n):,}" # 千分位,如 1,284
return ("", f"{int(n):,}", "") # 千分位,如 1,284
def _fmt_money(yuan):
"""元金额 → 紧凑展示≥1万显示「X.XX万」(两位小数),否则取整元"""
"""元金额 → 紧凑展示≥1万显示「¥X.XX万」(两位小数),否则「¥整数元」;人民币符号作小号前缀、万作小号后缀"""
v = float(yuan)
return f"{v / 10000:.2f}" if abs(v) >= 10000 else f"{v:.0f}"
return ("¥", f"{v / 10000:.2f}", "") if abs(v) >= 10000 else ("¥", f"{v:.0f}", "")
def _fmt_rate(rate):
"""增长率字符串 → (方向, 文本)。null/空 → (None, '--'),方向 None 表示不画三角
"""增长率字符串 → (方向, 文本)。null/空 → (None, '--'),方向 None 表示不画三角
恰为 0 → 'flat',渲染器在三角位画等号「=」(与用量行 pace 持平一致2026-09-14 用户要求,原来 0 也画上三角)。
数值统一两位小数;整数部分不足两位用前导 0 补齐(6.5→06.50),便于环比/同比上下对齐;
三/四位数(≥100%)原样完整输出不截断。"""
if rate is None or rate == "":
return (None, "--")
v = float(rate)
return ("down" if v < 0 else "up", f"{abs(v):05.2f}%")
direction = "flat" if v == 0 else "down" if v < 0 else "up"
return (direction, f"{abs(v):05.2f}%")
def _col(k, v, wow, yoy):
return {"k": k, "v": v, "hb": _fmt_rate(wow), "tb": _fmt_rate(yoy)}
def _col(k, parts, wow, yoy, wide=False):
"""一列指标。parts 为 (前缀, 主数字, 后缀)wide=True 表示仅宽版800×480展示400×300 小屏三列放不下、渲染时跳过。"""
return {"k": k, "v": "".join(parts), "vp": tuple(parts), "hb": _fmt_rate(wow), "tb": _fmt_rate(yoy), "wide": wide}
def _placeholder_col(k):
return {"k": k, "v": "--", "hb": (None, "--"), "tb": (None, "--")}
def _placeholder_col(k, wide=False):
return {"k": k, "v": "--", "vp": ("", "--", ""), "hb": (None, "--"), "tb": (None, "--"), "wide": wide}
# 今日实时五列:(标签、接口字段前缀、格式化、是否仅宽版)。字段前缀 base 对应 today{Base} / {base}WowRate / {base}YoyRate
# 见 docs/首页看板接口文档-20260911.md。团购两列为新增字段旧后端无此键 → 占位 "--"(不报错、不影响其余列)。
REALTIME_COLS = (
("探店单量", "orderCount", _fmt_count, False),
("探店流水", "payAmount", _fmt_money, False),
("团购单量", "groupbuyOrderCount", _fmt_count, True),
("团购流水", "groupbuyPayAmount", _fmt_money, True),
("毛利", "grossProfit", _fmt_money, False), # 口径已含团购毛利(接口合计,未拆分)
)
def _col_from(rt, k, base, fmt, wide):
cap = base[0].upper() + base[1:]
v = rt.get(f"today{cap}")
if v is None or v == "":
return _placeholder_col(k, wide)
return _col(k, fmt(v), rt.get(f"{base}WowRate"), rt.get(f"{base}YoyRate"), wide)
# ---------- 橘喵业务数据 ----------
def _mao(now):
def mao_fetch(now, with_trend=True):
"""橘喵今日经营实时接口with_trend=False 时不拉近 30 天趋势(服务端与宽版都不用它,且趋势有文件缓存)。
返回mao dict实时接口是否成功"""
upd = now.strftime("%H:%M")
ok = True
# 今日经营realtime
try:
rt = jm_api.get_realtime()
upd = (rt.get("dataTime") or "")[11:16] or upd
cols = [
_col("单量", _fmt_count(rt["todayOrderCount"]), rt["orderCountWowRate"], rt["orderCountYoyRate"]),
_col("流水", _fmt_money(rt["todayPayAmount"]), rt["payAmountWowRate"], rt["payAmountYoyRate"]),
_col("毛利", _fmt_money(rt["todayGrossProfit"]), rt["grossProfitWowRate"], rt["grossProfitYoyRate"]),
]
cols = [_col_from(rt, k, base, fmt, wide) for k, base, fmt, wide in REALTIME_COLS]
except Exception as e:
print(f"[警告] 今日经营拉取失败:{e}")
cols = [_placeholder_col(k) for k in ("单量", "流水", "毛利")]
cols = [_placeholder_col(k, wide) for k, _, _, wide in REALTIME_COLS]
ok = False
# 近30天趋势按日期缓存每天只拉一次
trend = _trend(now)
return {"upd": upd, "cols": cols, "trend": trend}
trend = _trend(now) if with_trend else {"dates": [], "series": []}
return {"upd": upd, "cols": cols, "trend": trend}, ok
def _mao(now):
return mao_fetch(now)[0]
# ---------- 比特币 K 线 ----------
def _read_btc_cache():
try:
with open(config.BTC_CACHE_PATH, encoding="utf-8") as f:
return json.load(f) # {"ts": float, "data": {...}}
except Exception:
return None
def _write_btc_cache(ts, data):
try:
with open(config.BTC_CACHE_PATH, "w", encoding="utf-8") as f:
json.dump({"ts": ts, "data": data}, f, ensure_ascii=False)
except Exception as e:
print(f"[警告] BTC 缓存写入失败:{e}")
def _btc(now):
"""比特币 K 线(宽版底部面板;周期 BTC_BAR、根数 BTC_LIMIT带 BTC_TTL 文件缓存;
拉取失败沿用旧缓存,仍无缓存回退空 candles。
结构契约renderer 依赖):{symbol, bar, src, candles:[{t, o, h, l, c}], last, chg, chg_label},其中 t 为轴标签文本;
last 为最新一根当前未收盘的收盘价chg 为涨跌幅(%)——时 K 取相对 24 根前24h收盘、不足则相对首根
日 K 取相对上一根收盘chg_label 说明口径("24h"/"1d");涨=绿 / 跌=红 由 renderer 按 c≥o 判定。"""
ts = now.timestamp()
bar = config.BTC_BAR
cached = _read_btc_cache()
cd = (cached or {}).get("data", {})
# 周期或根数BTC_BAR/BTC_LIMIT变更即视为失效避免改配置后 TTL 内仍显示旧范围
if cached and ts - cached.get("ts", 0) < config.BTC_TTL and cd.get("bar") == bar and len(cd.get("candles", [])) == config.BTC_LIMIT:
return cached["data"]
try:
out = btc_fetch()
_write_btc_cache(ts, out)
return out
except Exception as e:
print(f"[警告] BTC K 线拉取失败:{e}")
if cached:
print("[警告] 沿用上次 BTC 缓存")
return cached["data"]
return btc_empty()
def btc_empty():
return {"symbol": "BTC/USDT", "bar": config.BTC_BAR, "src": "", "candles": [], "last": None, "chg": None, "chg_label": ""}
def btc_fetch():
"""实时拉取 BTC K 线并整理成渲染结构(不读不写缓存);失败抛异常。服务端每次请求直接调用。"""
bar = config.BTC_BAR
src, rows = btc_api.get_candles(bar, config.BTC_LIMIT)
fmt = "%m/%d %H:%M" if bar == "1H" else "%m/%d"
candles = [{"t": _local(r["ts"]).strftime(fmt), "o": r["o"], "h": r["h"], "l": r["l"], "c": r["c"]} for r in rows]
last = rows[-1]["c"]
if bar == "1H":
base = rows[-25]["c"] if len(rows) >= 25 else rows[0]["c"]
chg_label = "24h" if len(rows) >= 25 else f"{len(rows) - 1}h"
else:
base, chg_label = rows[-2]["c"], "1d"
return {"symbol": "BTC/USDT", "bar": bar, "src": src, "candles": candles,
"last": last, "chg": (last - base) / base * 100 if base else 0.0, "chg_label": chg_label}
# ---------- Claude Code 用量 ----------
def _parse_reset_ts(s):
"""resets_at → epoch 秒。兼容三种epoch 数字Claude Code stdin 给的就是 epoch
ISO 时间串(含结尾 'Z')、空。失败返回 None。"""
if s is None or s == "":
return None
s = str(s)
if s.replace(".", "", 1).isdigit(): # 纯数字 → 当作 epoch 秒
return float(s)
try:
return datetime.fromisoformat(s.replace("Z", "+00:00")).timestamp()
except Exception:
return None
def _window_time_pct(resets_at, window, now):
"""该滚动窗口「已流逝时间百分比」= (window 距重置剩余) / window ×100。失败返回 None。"""
reset = _parse_reset_ts(resets_at)
if reset is None:
return None
remaining = max(0, min(window, reset - now.timestamp()))
return int(round((window - remaining) / window * 100))
def _fmt_reset(resets_at, window):
"""重置时刻 → 文本≤1 天的窗口只给 HH:MM更长的窗口带 MM/DD。解析失败返回 None。"""
ts = _parse_reset_ts(resets_at)
if ts is None:
return None
dt = _local(ts)
return dt.strftime("%H:%M") if window <= 86400 else dt.strftime("%m/%d %H:%M")
# Claude 用量窗口快照键短标签长标签窗口长度秒scoped
# 短标签 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", "5h", FIVE_HOUR, False),
("seven_day", "7d", "7d", SEVEN_DAY, False),
("seven_day_fable", "Fable 7d", "fable 7d", SEVEN_DAY, True),
)
# 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", "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, window?}, seven_day:{...}, <scoped 键>:{...}|null, updated_at?}
utilization 为已用百分比resets_at 为 epoch 秒或 ISOwindow 为该窗口真实长度秒(缺省用 windows 表的常量);缺哪个窗口哪个显示 "--"(宽版按 0 画utilization 为 0 且 resets_at 为 null 的空闲窗口记 time_pct=0保证 pace 列不缺席。
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 分 绿/黑/红 三档,百分比随条色);
pace = pct time_pct>0 超前↑ / <0 节余↓,宽版按 ±pace_tol 分 绿/黑/红 三档);
reset 为重置时刻文本updated 为快照落盘时刻 HH:MM活跃使用时持续刷新空闲时停在最后一次"""
raw = raw or {}
bars = []
for key, k, label, win, scoped in windows:
seg = raw.get(key) or {}
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 None and pct == 0:
tp = 0 # 窗口空闲utilization 0 且 resets_at null5h 窗口重置后常见):窗口尚未开始,已流逝 0%、pace 持平
if pct is not None and tp is not None:
bar["time_pct"] = tp
rs = _fmt_reset(seg.get("resets_at"), win)
if rs:
bar["reset"] = rs
bars.append(bar)
out = {"title": title, "warn": config.USAGE_WARN, "ok": config.USAGE_OK, "pace_tol": config.PACE_TOL, "bars": bars}
upd = _parse_reset_ts(raw.get("updated_at"))
if upd:
out["updated"] = _local(upd).strftime("%H:%M")
return out
def _usage(now, raw=None):
"""Claude 账户级用量5h / 7d / Fable 7d。raw 为 None 时本机经 claude_usage 采集(复用 Claude Code 钥匙串登录态调
/api/oauth/usage带 ≥300s 最小间隔缓存与退避);服务端传入推送来的原始快照。"""
if raw is None:
raw = {}
try:
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):
"""Codex 原始快照的 mock服务端尚未收到推送时用5h 88%≥warn 红、7d 22%≤ok 绿)覆盖两端配色。"""
t = now.timestamp()
return {"five_hour": {"utilization": 88, "resets_at": t + 2 * 3600},
"seven_day": {"utilization": 22, "resets_at": t + 3 * 86400}}
def _usage_gpt(now, raw=None, allow_local=True):
"""Codex 用量ChatGPT 订阅的 Codex 额度池。raw 为推送来的原始快照codex_usage 产出,结构同 gpt_mock_raw
服务端未收到推送时raw=None用 mock 并标 mock=Truerenderer 在标题右侧标「示例数据」)。本机形态直接经 codex_usage 采集。"""
mock = raw is None
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
# ---------------------------------------------------------------- 设备遥测E1002 电量/温度/湿度)
def device_empty():
return {"battery": None, "charging": False, "temp": None, "humidity": None}
def device_fetch():
"""纯拉取(服务端用):返回 (device dict, True);失败抛出。结构 {battery, charging, temp, humidity}。"""
return sensecraft_api.get_iot_data(), True
def _device(now):
"""本机形态:带 DEVICE_TTL 文件缓存;失败沿用旧缓存,仍无则占位(渲染为 "--")。"""
ts = now.timestamp()
cached = _read_json_cache(config.DEVICE_CACHE_PATH)
if cached and ts - cached.get("ts", 0) < config.DEVICE_TTL:
return cached["data"]
try:
out, _ = device_fetch()
_write_json_cache(config.DEVICE_CACHE_PATH, {"ts": ts, "data": out})
return out
except Exception as e:
print(f"[警告] 设备遥测拉取失败:{e}")
if cached:
return cached["data"]
return device_empty()
def _read_json_cache(path):
try:
with open(path, encoding="utf-8") as f:
return json.load(f)
except Exception:
return None
def _write_json_cache(path, obj):
try:
with open(path, "w", encoding="utf-8") as f:
json.dump(obj, f, ensure_ascii=False)
except Exception as e:
print(f"[警告] 缓存写入失败 {path}{e}")
def get_dashboard_data(now=None):
"""返回渲染所需的完整数据字典。"""
now = now or datetime.now()
"""返回渲染所需的完整数据字典(本机脚本形态:带文件缓存,用量读本地快照)"""
now = now or now_tz()
return {
"date": {
"greg": now.strftime("%Y/%m/%d"),
@@ -177,4 +456,8 @@ def get_dashboard_data(now=None):
},
"weather": _weather(now),
"mao": _mao(now),
"usage": _usage(now),
"usage_gpt": _usage_gpt(now),
"btc": _btc(now),
"device": _device(now),
}

185
html_renderer.py Normal file
View File

@@ -0,0 +1,185 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
方案二800x480 的**现代网页**版仪表盘server.py 的 HTML_PATH 每次请求现生成),供 SenseCraft HTML 控件用无头浏览器截图。
与 PNG 版renderer_e1002的差别
- 不限点阵字体系统中文字体栈PingFang / Microsoft YaHei / Noto Sans SC+ Google Fonts 的 Noto Sans SC 兜底
(平台截图机若无中文字体会掉字,网络字体是保险;能否加载取决于其出网能力)。
- 纯 HTML+CSS+内联 SVG**不依赖 JavaScript**(平台是否执行 JS 未证实),数据全部服务端写死进 HTML。
- 颜色只用六色纯值 #000/#fff/#f00/#0f0黄/蓝未用),抗锯齿边缘交给平台抖动——这正是要和 PNG 版对比的点。
- 版式与 PNG 版同构:顶部时钟/天气;左 Claude Usage、右 Codex Usage橘喵今日实时三列BTC 时 KSVG
- 配色规则与 PNG 版一致:用量条 ≥warn 红 / ≤ok 绿 / 其间黑pace ≥+tol 红 / ≤tol 绿 / 其间黑;环比同比 涨绿 跌红(与 K 线一致K 线 涨绿 跌红。
"""
from html import escape as _e
RED, GREEN, BLACK = "#f00", "#0f0", "#000"
_CSS = """
*{box-sizing:border-box;margin:0;padding:0}
html,body{width:800px;height:480px;overflow:hidden;background:#fff;color:#000;
font-family:"PingFang SC","Microsoft YaHei","Noto Sans SC","Helvetica Neue",Arial,sans-serif;
-webkit-font-smoothing:antialiased}
.page{width:800px;height:480px;padding:12px 16px 10px;display:flex;flex-direction:column;gap:6px}
.hd{display:flex;justify-content:space-between;align-items:flex-end;height:92px;border-bottom:2px solid #000;padding-bottom:6px}
.clock{font-size:72px;font-weight:800;line-height:1;letter-spacing:-2px;font-variant-numeric:tabular-nums}
.date{font-size:15px;margin-top:4px;color:#000}
.wx{text-align:right;display:flex;align-items:flex-end;gap:14px}
.wx .t{font-size:44px;font-weight:800;line-height:1}
.wx .c{font-size:15px;line-height:1.45}
.wx .loc{font-size:13px;color:#000}
.pin{display:inline-block;width:9px;height:9px;border-radius:50% 50% 50% 0;background:#f00;transform:rotate(-45deg);margin-right:4px;vertical-align:-1px}
.row{display:flex;gap:16px}
.card{flex:1;min-width:0}
.card h3{font-size:14px;font-weight:700;display:flex;justify-content:space-between;align-items:baseline;margin-bottom:6px}
.card h3 em{font-style:normal;font-weight:400;font-size:12px}
.card h3 b::before{content:"";display:inline-block;width:9px;height:9px;background:#000;margin-right:6px;vertical-align:-1px}
.u{display:grid;grid-template-columns:64px 1fr 58px 44px;align-items:center;column-gap:8px;height:26px}
.u .k{font-size:13px;font-weight:700;font-family:"SF Mono",Menlo,Consolas,monospace}
.bar{height:11px;border:1.5px solid #000;position:relative;background:#fff}
.bar i{position:absolute;left:0;top:0;bottom:0;background:#000}
.u .p{font-size:19px;font-weight:800;text-align:right;font-variant-numeric:tabular-nums;line-height:1}
.u .pace{font-size:13px;font-weight:700;font-variant-numeric:tabular-nums;white-space:nowrap}
.tri{display:inline-block;width:0;height:0;border-left:5px solid transparent;border-right:5px solid transparent;vertical-align:1px;margin-right:2px}
.tri.up{border-bottom:8px solid currentColor}.tri.dn{border-top:8px solid currentColor}
.rt{border-top:2px solid #000;border-bottom:2px solid #000;padding:6px 0 4px}
.rt .cols{display:flex}
.rt .col{flex:1;text-align:center;border-left:1px dashed #000;padding:2px 0}
.rt .col:first-child{border-left:0}
.rt .name{font-size:13px;margin-bottom:2px}
.rt .grp{display:inline-flex;align-items:center;gap:12px}
.rt .v{font-size:36px;font-weight:800;line-height:1;font-variant-numeric:tabular-nums}
.rt .cmp{text-align:left;font-size:12px;line-height:1.5;font-variant-numeric:tabular-nums}
.btc h3{margin-bottom:2px}
.btc svg{display:block}
"""
def _tier_bar(bar, usage):
"""用量条颜色三档 绿/黑/红(与 renderer_e1002 一致)。返回 (填充/边框色, 百分比文字色),两者同色。"""
pct = bar.get("pct")
if not isinstance(pct, (int, float)):
return BLACK, BLACK
c = RED if pct >= usage.get("warn", 80) else GREEN if pct <= usage.get("ok", 30) else BLACK
return c, c
def _tier_pace(diff, usage):
tol = usage.get("pace_tol", 10)
return RED if diff >= tol else GREEN if diff <= -tol else BLACK
def _usage_card(usage):
note = "示例数据" if usage.get("mock") else (f'上次更新 {usage["updated"]}' if usage.get("updated") else "")
rows = []
for b in usage.get("bars", []):
pct = b.get("pct")
ok = isinstance(pct, (int, float))
fill, ptxt = _tier_bar(b, usage)
width = max(0, min(100, int(pct))) if ok else 0
pace = ""
if ok and "time_pct" in b:
diff = int(pct) - int(b["time_pct"])
col = _tier_pace(diff, usage)
tri = '<i class="tri up"></i>' if diff > 0 else ('<i class="tri dn"></i>' if diff < 0 else "")
pace = f'<span class="pace" style="color:{col}">{tri}{abs(diff):02d}</span>'
else:
pace = '<span class="pace"></span>'
rows.append(
f'<div class="u"><span class="k">{_e(b.get("label") or b["k"])}</span>'
f'<span class="bar" style="border-color:{fill}"><i style="width:{width}%;background:{fill}"></i></span>'
f'<span class="p" style="color:{ptxt}">{f"{int(pct):02d}%" if ok else "--"}</span>{pace}</div>')
return (f'<section class="card"><h3><b>{_e(usage.get("title", "Usage"))}</b><em>{_e(note)}</em></h3>'
+ "".join(rows) + "</section>")
def _cmp(label, pair):
direction, val = pair
col = GREEN if direction == "up" else RED if direction == "down" else BLACK
tri = ('<i class="tri up"></i>' if direction == "up" else '<i class="tri dn"></i>' if direction == "down"
else "= " if direction == "flat" else "") # flat增长率恰为 0等号表示持平与 PNG 版一致)
return f'<div>{_e(label)} <span style="color:{col}">{tri}{_e(val)}</span></div>'
def _realtime(mao):
cols = "".join(
f'<div class="col"><div class="name">{_e(c["k"])}</div><div class="grp"><span class="v">{_e(c["v"])}</span>'
f'<span class="cmp">{_cmp("环比", c["hb"])}{_cmp("同比", c["tb"])}</span></div></div>'
for c in mao["cols"])
return (f'<section class="rt card"><h3><b>橘喵·今日实时</b><em>上次更新 {_e(mao["upd"])}</em></h3>'
f'<div class="cols">{cols}</div></section>')
def _fmt_price(v):
return f"{v:,.0f}" if abs(v) >= 1000 else f"{v:.2f}"
def _btc(btc, W=768, H=118):
"""BTC K 线 → 内联 SVG涨绿跌红坐标逻辑同 renderer_e1002._btc_chart。"""
btc = btc or {}
candles = btc.get("candles") or []
kind = "时K" if btc.get("bar", "1H") == "1H" else "日K"
head = f'<b>{_e(btc.get("symbol") or "BTC/USDT")}·{kind}</b>'
right = ""
if candles and btc.get("last") is not None:
chg = btc.get("chg") or 0.0
col = GREEN if chg >= 0 else RED
tri = '<i class="tri up"></i>' if chg >= 0 else '<i class="tri dn"></i>'
right = (f'<em><strong style="font-size:14px">{_fmt_price(btc["last"])}</strong>&nbsp;&nbsp;{_e(btc.get("chg_label") or "")}'
f'&nbsp;<span style="color:{col};font-weight:700">{tri}{abs(chg):.2f}%</span></em>')
if not candles:
return f'<section class="btc card"><h3>{head}</h3><div style="text-align:center;font-size:24px;padding:30px 0">--</div></section>'
LBL, TOP, BOT = 66, 6, 16
px0, px1 = 0, W - LBL
py0, py1 = TOP, H - BOT
hi_raw, lo_raw = max(c["h"] for c in candles), min(c["l"] for c in candles)
pad = (hi_raw - lo_raw or 1) * 0.04
lo, hi = lo_raw - pad, hi_raw + pad
Y = lambda v: py1 - (v - lo) / (hi - lo) * (py1 - py0)
n = len(candles)
slot = (px1 - px0) / n
bw = max(3, int(slot * 0.6)) | 1
parts = [f'<svg width="{W}" height="{H}" viewBox="0 0 {W} {H}" shape-rendering="crispEdges">',
f'<line x1="{px0}" y1="{py1}" x2="{px1}" y2="{py1}" stroke="#000" stroke-dasharray="2 3"/>']
for i, c in enumerate(candles):
cx = round(px0 + slot * (i + 0.5))
col = GREEN if c["c"] >= c["o"] else RED
top, bot = sorted((round(Y(c["o"])), round(Y(c["c"]))))
parts.append(f'<line x1="{cx}" y1="{round(Y(c["h"]))}" x2="{cx}" y2="{round(Y(c["l"]))}" stroke="{col}"/>')
parts.append(f'<rect x="{cx - bw // 2}" y="{top}" width="{bw}" height="{max(1, bot - top)}" fill="{col}"/>')
last = candles[-1]["c"]
ly = round(Y(last))
parts.append(f'<line x1="{px0}" y1="{ly}" x2="{px1}" y2="{ly}" stroke="#000" stroke-dasharray="1 3"/>')
hi_y, lo_y = py0 + 5, py1 - 1
ty = min(max(ly + 4, hi_y + 13), lo_y - 13)
txt = 'font-size="12" font-family="SF Mono,Menlo,Consolas,monospace" text-anchor="end"'
parts.append(f'<text x="{W}" y="{hi_y}" {txt}>{_fmt_price(hi_raw)}</text>')
parts.append(f'<text x="{W}" y="{lo_y}" {txt}>{_fmt_price(lo_raw)}</text>')
parts.append(f'<text x="{W}" y="{ty}" {txt} font-weight="700">{_fmt_price(last)}</text>')
lab = lambda c: _e(c.get("t") or c.get("d") or "")
ax = 'font-size="11" font-family="SF Mono,Menlo,Consolas,monospace"'
parts.append(f'<text x="{px0}" y="{H - 3}" {ax}>{lab(candles[0])}</text>')
parts.append(f'<text x="{(px0 + px1) / 2}" y="{H - 3}" {ax} text-anchor="middle">{lab(candles[n // 2])}</text>')
parts.append(f'<text x="{px1}" y="{H - 3}" {ax} text-anchor="end">{lab(candles[-1])}</text>')
parts.append("</svg>")
return f'<section class="btc card"><h3>{head}{right}</h3>{"".join(parts)}</section>'
def render_html(d):
"""data dict同 renderer_e1002.render 的输入)→ 完整 HTML 字符串。"""
dt, wt = d["date"], d["weather"]
head = (
f'<header class="hd"><div><div class="clock">{_e(dt["time"])}</div>'
f'<div class="date">{_e(dt["week"])}&nbsp;&nbsp;{_e(dt["greg"])}</div></div>'
f'<div class="wx"><div class="c"><div class="loc"><span class="pin"></span>{_e(wt["loc"])}</div>'
f'{_e(wt["cond"])}<br>{_e(wt["range"])}</div><div class="t">{_e(wt["temp"])}</div></div></header>')
body = (head
+ f'<div class="row">{_usage_card(d["usage"])}{_usage_card(d.get("usage_gpt") or {"title": "Codex Usage"})}</div>'
+ _realtime(d["mao"])
+ _btc(d.get("btc")))
return ("<!doctype html><html lang=\"zh-CN\"><head><meta charset=\"utf-8\">"
"<meta name=\"viewport\" content=\"width=800, initial-scale=1\">"
"<meta http-equiv=\"Cache-Control\" content=\"no-store\">"
"<link rel=\"preconnect\" href=\"https://fonts.gstatic.com\" crossorigin>"
"<link href=\"https://fonts.googleapis.com/css2?family=Noto+Sans+SC:wght@400;700;800&display=swap\" rel=\"stylesheet\">"
f"<title>eink dashboard</title><style>{_CSS}</style></head><body><div class=\"page\">{body}</div></body></html>")

97
html_shot.py Normal file
View File

@@ -0,0 +1,97 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
方案三:把 html_renderer 生成的网页用**服务端无头 Chrome** 截成 800x480再**固化为六色 PNG**(最近色、不抖动)。
为什么需要它SenseCraft 抓 HTML 时是平台自己截图再量化,文字抗锯齿的灰边会被它抖动成噪点、细笔画被打断(真机实拍已确认)。
在我们这一侧先截图、先量化,交给平台的就是纯六色图,平台无从抖动——效果对齐 PNG 方案,同时保留现代字体与网页排版。
量化策略(不抖动):
1. 每通道二值化v < HTML_PNG_THRESHOLD → 0否则 → 255。阈值默认 160偏向保墨迹抗锯齿的中灰边缘归黑
细笔画不会因为「不够黑」而消失;纯红/绿/黄/蓝/黑/白本身各通道就是 0/255不受影响。
2. 二值化后像素落在 {0,255}^3 的 8 个角,其中青/品/其它非六色角极少见(只可能出现在彩色抗锯齿边缘),
再用 Pillow 的调色板量化dither=NONE映射到最近的六色。
→ 输出保证只含 SPECTRA6 六色。
无头 Chrome 只能把截图写到文件这里用临时文件并立刻删除Linux 下 /tmp 通常是 tmpfs不落磁盘
若想彻底零文件需改用 Playwright/CDP本项目不引第三方依赖先不做。
"""
import os
import shutil
import subprocess
import tempfile
import threading
from PIL import Image
import config
from renderer_e1002 import SPECTRA6
_CANDIDATES = (
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
"/Applications/Chromium.app/Contents/MacOS/Chromium",
"google-chrome", "google-chrome-stable", "chromium", "chromium-browser", "chrome",
)
def find_chrome():
if config.CHROME_BIN:
return config.CHROME_BIN
for c in _CANDIDATES:
if os.path.isabs(c):
if os.path.exists(c):
return c
elif shutil.which(c):
return shutil.which(c)
raise RuntimeError("未找到 Chrome/Chromium请配置 CHROME_BIN")
_SHOT_LOCK = threading.Lock() # 无头 Chrome 共用默认 profile截图串行化避免互相干扰
def screenshot_html(html: str, size=(800, 480), timeout=40) -> Image.Image:
"""HTML 字符串 → 无头 Chrome 截图RGB Image。临时文件用后即删。
注意不要传 --user-data-dir 指向全新目录:新版 --headless=new 对空 profile 的首启流程会卡死到超时(实测)。"""
chrome = find_chrome()
tmpdir = tempfile.mkdtemp(prefix="eink-shot-")
html_path = os.path.join(tmpdir, "page.html")
png_path = os.path.join(tmpdir, "shot.png")
try:
with open(html_path, "w", encoding="utf-8") as f:
f.write(html)
cmd = [chrome, "--headless=new", "--disable-gpu", "--hide-scrollbars", "--force-device-scale-factor=1",
f"--window-size={size[0]},{size[1]}", "--virtual-time-budget=6000", f"--screenshot={png_path}"]
if config.CHROME_EXTRA_ARGS:
cmd += config.CHROME_EXTRA_ARGS.split()
cmd.append("file://" + html_path)
with _SHOT_LOCK:
subprocess.run(cmd, check=True, timeout=timeout, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
with Image.open(png_path) as im:
return im.convert("RGB").copy()
finally:
shutil.rmtree(tmpdir, ignore_errors=True)
def quantize6(img: Image.Image, threshold=None) -> Image.Image:
"""RGB → 六色纯色(最近色、不抖动)。见模块文档。"""
t = config.HTML_PNG_THRESHOLD if threshold is None else threshold
lut = [0 if v < t else 255 for v in range(256)] * 3
bin_img = img.convert("RGB").point(lut)
pal = Image.new("P", (1, 1))
flat = [c for rgb in SPECTRA6 for c in rgb]
pal.putpalette(flat + [0] * (768 - len(flat)))
q = bin_img.quantize(palette=pal, dither=Image.Dither.NONE)
return q.convert("RGB")
def html_to_png6(html: str) -> Image.Image:
return quantize6(screenshot_html(html))
if __name__ == "__main__":
import sys, data, html_renderer
d = data.get_dashboard_data()
im = html_to_png6(html_renderer.render_html(d))
out = sys.argv[1] if len(sys.argv) > 1 else "output/e1002/web6.png"
os.makedirs(os.path.dirname(out), exist_ok=True)
im.save(out)
print(out, im.size, sorted(c for _, c in im.getcolors(64)))

27
main.py
View File

@@ -4,8 +4,12 @@
入口 —— 取数据 → 渲染 → 推送到设备。
用法:
python3 main.py # 渲染并推送(设备 ID 取自 .env 的 ZECTRIX_DEVICE_ID
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/
# 不推送(静态部署形态;动态形态见 server.py
python3 main.py --target push # 本机采集 Claude钥匙串登录态 → /api/oauth/usage与 Codexcodex app-server RPC
# 两路账户级用量并推送到 server.pyPUSH_URL + PUSH_TOKEN服务端渲染时现算 pace
配置统一从同目录 .env 读取(见 .env.example真实环境变量优先。
"""
@@ -19,9 +23,26 @@ from renderer import DashboardRenderer
def main():
parser = argparse.ArgumentParser(description="墨水屏仪表盘:渲染并推送")
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=本机采集 Claude/Codex 账户级用量并推送到 server.py")
args = parser.parse_args()
if args.target == "push":
from push_client import push_usage
push_usage()
return
data = get_dashboard_data()
if args.target == "e1002":
from renderer_e1002 import E1002Renderer, write_page
png = E1002Renderer().render_to_file(data, config.E1002_PNG_PATH)
html = write_page(config.E1002_HTML_PATH, png)
print(f"[渲染] 已生成 {png}")
print(f"[页面] 已生成 {html}(部署到公网 HTTPS 后填入 SenseCraft HMI 的 HTML 控件)")
return
path = DashboardRenderer().render_to_file(data, config.OUTPUT_PATH)
print(f"[渲染] 已生成 {path}")
@@ -29,8 +50,8 @@ def main():
return
from pusher import push_image
device_id, resp = push_image(path)
print(f"[推送] 设备 {device_id} 成功:{resp.get('data')}")
host, resp = push_image(path)
print(f"[推送] 设备 {host} 成功:{resp}")
if __name__ == "__main__":

142
probe_server.py Normal file
View File

@@ -0,0 +1,142 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
验证工具 —— SenseCraft HMI 的 Image/HTML 控件对**同一 URL** 是否每个刷新周期都重新抓取。
原理:本地起一个极简 HTTP 服务,固定路径 /probe.png 每 --every 秒重绘一张 800x480 探针图
(大号生成时刻 HH:MM:SS + 递增序号 + 六色校准块),响应头 Cache-Control: no-store
每次有人请求就在终端打一行日志时间、路径、User-Agent、来源 IP含经隧道透传的 CF-Connecting-IP
判定:
- 终端日志每个设备刷新周期都出现一次 /probe.png 请求,且设备画面上的时刻/序号随之推进 → **每周期重抓,方案二成立**
此后只要不断覆盖同一 URL 指向的文件即可,无需再经 HTML 页。
- 日志只在「部署」那一刻出现一次、之后再无请求 → 平台只在部署时抓一次并缓存,方案二不成立。
- 也可同时把 /index.htmlHTML 控件方案)放到另一台画布对照。
用法:
python3 probe_server.py [--port 8787] [--every 60]
# 另开终端暴露成公网 HTTPS云端抓不到局域网/明文 HTTP。cloudflared 快速隧道无需账号:
brew install cloudflared
cloudflared tunnel --url http://localhost:8787
# 把打印出的 https://xxxx.trycloudflare.com/probe.png 填入 SenseCraft 画布的 Image 控件(铺满 800x480→ 部署。
"""
import argparse
import io
import threading
import time
from datetime import datetime
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from PIL import Image, ImageDraw, ImageFont
import config
BLACK, WHITE, RED = (0, 0, 0), (255, 255, 255), (255, 0, 0)
SIX = [BLACK, WHITE, (255, 255, 0), RED, (0, 255, 0), (0, 0, 255)]
_state = {"png": b"", "seq": 0, "stamp": ""}
_lock = threading.Lock()
def render_probe(seq, now):
"""800x480 探针图:序号(左上)/ 大号时刻居中F96/ 日期(下)/ 底部六色块。全部纯色。"""
img = Image.new("RGB", (800, 480), WHITE)
d = ImageDraw.Draw(img)
d.fontmode = "1"
f24 = ImageFont.truetype(config.FONT_PATH, 24)
f96 = ImageFont.truetype(config.FONT_PATH, 96)
d.text((20, 16), f"probe #{seq:04d}", font=f24, fill=BLACK)
d.text((20, 48), "SenseCraft re-fetch test / 同一 URL 每周期是否重抓", font=f24, fill=BLACK)
t = now.strftime("%H:%M:%S")
w = d.textlength(t, font=f96)
for dx in range(3): # 伪粗体
d.text(((800 - w) / 2 + dx, 150), t, font=f96, fill=RED)
ds = now.strftime("%Y/%m/%d %A")
d.text(((800 - d.textlength(ds, font=f24)) / 2, 290), ds, font=f24, fill=BLACK)
d.text((20, 400), "画面上的时刻若随设备刷新周期推进 → 平台每周期重新抓取了同一 URL", font=f24, fill=BLACK)
n = len(SIX)
for i, c in enumerate(SIX):
d.rectangle([round(800 / n * i), 456, round(800 / n * (i + 1)) - 1, 479], fill=c)
buf = io.BytesIO()
img.save(buf, "PNG")
return buf.getvalue()
def regenerate(every):
while True:
now = datetime.now()
with _lock:
_state["seq"] += 1
_state["png"] = render_probe(_state["seq"], now)
_state["stamp"] = now.strftime("%H:%M:%S")
print(f"[{now:%H:%M:%S}] 重绘 probe.png seq={_state['seq']}", flush=True)
time.sleep(every)
_INDEX = """<!doctype html><html><head><meta charset="utf-8">
<meta http-equiv="Cache-Control" content="no-store"><title>probe</title>
<style>html,body{margin:0;width:800px;height:480px;overflow:hidden;background:#fff}
img{display:block;width:800px;height:480px;image-rendering:pixelated}</style></head>
<body><img src="/probe.png" width="800" height="480"></body></html>"""
class Handler(BaseHTTPRequestHandler):
server_version = "probe/1.0"
def log_message(self, fmt, *args): # 关闭默认日志,用自定义格式
pass
def _log(self, code):
ua = self.headers.get("User-Agent", "-")
ip = self.headers.get("CF-Connecting-IP") or self.headers.get("X-Forwarded-For") or self.client_address[0]
print(f"[{datetime.now():%H:%M:%S}] {code} GET {self.path} ip={ip} ua={ua}", flush=True)
def _send(self, code, ctype, body):
self.send_response(code)
self.send_header("Content-Type", ctype)
self.send_header("Content-Length", str(len(body)))
self.send_header("Cache-Control", "no-store, no-cache, must-revalidate, max-age=0")
self.send_header("Pragma", "no-cache")
self.send_header("Expires", "0")
self.send_header("Access-Control-Allow-Origin", "*")
self.end_headers()
self.wfile.write(body)
def do_GET(self):
path = self.path.split("?", 1)[0]
if path == "/probe.png":
with _lock:
body = _state["png"]
self._log(200)
return self._send(200, "image/png", body)
if path in ("/", "/index.html"):
self._log(200)
return self._send(200, "text/html; charset=utf-8", _INDEX.encode("utf-8"))
if path == "/favicon.ico":
return self._send(204, "image/x-icon", b"")
self._log(404)
self._send(404, "text/plain; charset=utf-8", b"not found")
do_HEAD = do_GET
def main():
ap = argparse.ArgumentParser(description="SenseCraft 同 URL 重抓验证服务")
ap.add_argument("--port", type=int, default=8787)
ap.add_argument("--every", type=int, default=60, help="探针图重绘间隔(秒)")
args = ap.parse_args()
threading.Thread(target=regenerate, args=(args.every,), daemon=True).start()
while not _state["png"]:
time.sleep(0.05)
srv = ThreadingHTTPServer(("0.0.0.0", args.port), Handler)
print(f"探针服务已启动http://localhost:{args.port}/probe.png HTML 对照页:/index.html")
print("下一步:另开终端执行 cloudflared tunnel --url http://localhost:%d ,把得到的 https 地址 + /probe.png 填入 Image 控件。" % args.port)
print("观察本终端:每个设备刷新周期是否出现一条 GET /probe.png。Ctrl+C 退出。", flush=True)
try:
srv.serve_forever()
except KeyboardInterrupt:
pass
if __name__ == "__main__":
main()

74
push_client.py Normal file
View File

@@ -0,0 +1,74 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
推送端(本机运行)—— 在本机采集两路账户级 AI 用量POST 到 server.py服务端暂存、渲染时现算 pace。
推什么(形态 A凭证与采集都留在本机服务端不持任何凭证、不读本机文件
- Claudeclaude_usage.get_usage()(复用 Claude Code 钥匙串登录态调 /api/oauth/usage→ POST /push/usage
- Codexcodex_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 末尾)。
"""
import json
import time
import urllib.error
import urllib.request
import claude_usage
import codex_usage
import config
RETRY_TIMES, RETRY_DELAY, TIMEOUT = 2, 2, 10
def _post_json(url, payload):
body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
req = urllib.request.Request(url, data=body, method="POST", headers={
"Content-Type": "application/json; charset=utf-8",
"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 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
except urllib.error.URLError as e: # 连接类:重试
last = e
if i < RETRY_TIMES:
time.sleep(RETRY_DELAY)
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")
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__":
push_usage()

View File

@@ -1,65 +1,62 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
推送层 —— 调用 Zectrix 极趣云平台 API仅用标准库 urllib无第三方依赖
文档:显示推送 / 设备管理。
推送层 —— 直连局域网墨水屏设备(喵喵固件,仅用标准库 urllib无第三方依赖
固件开源https://gitee.com/gxp666111/miaomiaoweb/ 设备自带管理页,可在浏览器打开
http://<EPD_HOST>/ 调试同一套接口)。
"""
import json
import uuid
import time
import urllib.parse
import urllib.request
import urllib.error
import config
# 局域网 mDNS.local域名解析基于组播 UDP偶发瞬时失败正常重试即可恢复。
RETRY_TIMES = 2
RETRY_DELAY = 2
def _request(method, path, headers=None, body=None):
url = f"{config.API_BASE}{path}"
h = {"X-API-Key": config.API_KEY}
if headers:
h.update(headers)
req = urllib.request.Request(url, data=body, headers=h, method=method)
url = f"http://{config.EPD_HOST}{path}"
req = urllib.request.Request(url, data=body, headers=headers or {}, method=method)
for attempt in range(RETRY_TIMES + 1):
try:
with urllib.request.urlopen(req, timeout=30) as resp:
return json.loads(resp.read().decode("utf-8"))
return resp.read().decode("utf-8", "ignore")
except urllib.error.HTTPError as e:
raise RuntimeError(f"HTTP {e.code}: {e.read().decode('utf-8', 'ignore')}") from e
except urllib.error.URLError:
if attempt == RETRY_TIMES:
raise
time.sleep(RETRY_DELAY)
def resolve_device_id():
"""返回目标设备 ID直接取 config.DEVICE_ID硬件 MAC未配置则报错。"""
if not config.DEVICE_ID:
raise RuntimeError("未配置设备 ID请在 .env 设置 ZECTRIX_DEVICE_ID=<硬件 MAC>")
return config.DEVICE_ID
def _list_gallery_names():
text = _request("GET", "/images")
return [item["name"] for item in json.loads(text).get("items", [])]
def _multipart(fields, files):
"""构造 multipart/form-data 请求体。fields: dict[str,str]; files: list[(name, filename, bytes, mime)]"""
boundary = uuid.uuid4().hex
crlf = b"\r\n"
buf = bytearray()
for k, v in fields.items():
buf += b"--" + boundary.encode() + crlf
buf += f'Content-Disposition: form-data; name="{k}"'.encode() + crlf + crlf
buf += str(v).encode() + crlf
for name, filename, content, mime in files:
buf += b"--" + boundary.encode() + crlf
buf += f'Content-Disposition: form-data; name="{name}"; filename="{filename}"'.encode() + crlf
buf += f"Content-Type: {mime}".encode() + crlf + crlf
buf += content + crlf
buf += b"--" + boundary.encode() + b"--" + crlf
return bytes(buf), f"multipart/form-data; boundary={boundary}"
def _clear_gallery():
"""设备画廊按追加保存,用完即删,使其始终只留最新一张。
清理是锦上添花,不是推送的必要条件——拉列表或单张删除失败都不能阻塞后续上传。"""
try:
names = _list_gallery_names()
except Exception as e:
print(f"[推送] 拉取画廊列表失败,跳过本次清理:{e}")
return
for name in names:
try:
_request("POST", f"/delete_image?name={urllib.parse.quote(name)}")
except Exception as e:
print(f"[推送] 清理画廊旧图 {name} 失败:{e}")
def push_image(image_path, device_id=None, page_id=config.PAGE_ID, dither=config.DITHER):
"""推送图片到设备显示"""
device_id = device_id or resolve_device_id()
def push_image(image_path):
"""推送图片到局域网墨水屏设备:先清空画廊旧图,再上传新图触发显示刷新"""
_clear_gallery()
with open(image_path, "rb") as fp:
content = fp.read()
fields = {"dither": "true" if dither else "false", "pageId": str(page_id)}
files = [("images", "dashboard.png", content, "image/png")]
body, content_type = _multipart(fields, files)
data = _request("POST", f"/devices/{device_id}/display/image",
headers={"Content-Type": content_type}, body=body)
if data.get("code") != 0:
raise RuntimeError(f"推送失败: {data}")
return device_id, data
resp = _request("POST", "/upload", headers={"Content-Type": "image/png"}, body=content)
return config.EPD_HOST, resp

View File

@@ -1,58 +1,110 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
渲染层 —— 将数据绘制成 400x300 纯黑白墨水屏仪表盘。
渲染层 —— 将数据绘制成 400x300 三色(黑/白/红)墨水屏仪表盘。
设计要点:
- 纯黑白(设备无真灰阶灰阶只能抖动成网点,弃用);用构图 + 字号 + 留白建立层级。
- 三色 BWR无真灰阶灰阶只能抖动成网点,弃用);底色黑白,靠留白 + 字号 + 一处大红块建层级。
克制而有分量:全图只一个「大红」焦点(用量超阈值的整条红进度条),其余红为小面积语义点缀。
- 关键墨水屏配色铁律:红与黑同属「深色」,红叠黑对比极低 → 红只出现在**白底**(红字/红三角/红块),
**绝不红字压黑底**;白底红块 + 白字最稳。
- Fusion Pixel 点阵字体,按整数倍尺寸(12/24/48)渲染fontmode='1' 关闭抗锯齿 → 像素锐利。
- 重点数据用「伪粗体」(偏移叠绘)加粗,仍对齐像素网格。
布局:上=大时钟+日期 / 天气;中=橘喵今日经营三列;下=近30天三指标趋势折线图。
布局(自上而下三段,两道横规分隔):
A 顶部:大时钟+日期(左)/ 天气(右),白底黑字 + 红点缀(定位红水滴、太阳/闪电红);
B 中部主角Claude Usage——每条单行 = 左标签(5h/7d,F13) + 进度条 + 百分比(F16,主数字) + 时间维度对比 pace(F13)
pct ≥ 阈值(warn) 时该条进度条(含边框)与百分比整体转红;
pace = 用量% 时间% ,用上/下三角(替代 +/)表示超前/节余,超前(↑)红、节余(↓)黑;
C 底部:橘喵今日实时三列,环比/同比「涨=红·跌=黑」。
近30天趋势图暂隐藏`_trend_chart` 及其依赖 `_styled_polyline`/`_style_sample` 保留、未调用,随时可再挂回)。
红出现处:① 定位水滴 ② 天气太阳/闪电 ③ 用量进度条与百分比(≥warn) ④ pace 超前(↑) ⑤ 环比/同比「涨」的三角与数值。
"""
import math
from PIL import Image, ImageDraw, ImageFont
import config
BLACK, WHITE = 0, 255
# 三色墨水屏(BWR)调色板:黑 / 白 / 纯正红。
# RED 用 (255,0,0) 作为设备红通道的明确信号——硬阈值量化下会干净地落到红色,不会被误判为黑。
# 设计准则:红是稀缺强调色,仅用于「方向/状态、警示、寻路锚点」,占墨比例应很小(≈全图 <10%),其余一律黑白。
BLACK, WHITE, RED = (0, 0, 0), (255, 255, 255), (255, 0, 0)
class DashboardRenderer:
def __init__(self, font_path=config.FONT_PATH, size=(config.WIDTH, config.HEIGHT)):
# 百分比数字的字符间距(px乘 k)F16 为 16/12x 非整数倍缩放,数字墨迹无侧边距、相邻紧贴,需手动加距;
# 子类若把百分比换成原生 12px 字号(宽版),字形自带侧边距,应覆写为 0。
PCT_TRACK = 2
# 用量行左标签的字重与对齐:三色版 粗体、左对齐(贴 x0宽版覆写为 常规字重、右对齐(贴进度条左侧留 LABEL_GAP
LABEL_BOLD = True
LABEL_ALIGN = "l"
LABEL_GAP = 8
def __init__(self, font_path=config.FONT_PATH, size=(config.WIDTH, config.HEIGHT), k=1):
"""k整体缩放系数整数。k=1 为 400x300 原版k=2 供 800x480 设备reTerminal E1002复用组件
字体按 k 倍创建(点阵字体整数倍最锐利),组件内的像素常量一律乘 k故各组件在两种尺寸下形态一致。
字体属性名仍按 k=1 的字号命名F12/F24/…),表达「层级」而非绝对像素。"""
self.W, self.H = size
self.F12 = ImageFont.truetype(font_path, 12)
self.F24 = ImageFont.truetype(font_path, 24)
self.F48 = ImageFont.truetype(font_path, 48)
self.k = k
self.F12 = ImageFont.truetype(font_path, 12 * k)
self.F13 = ImageFont.truetype(font_path, 13 * k) # 5h/7d 与 pace 用;接近原生 12px畸变很小
self.F16 = ImageFont.truetype(font_path, 16 * k) # 非整数倍(1.33x):靠 fontmode='1' 保持纯色,笔画略不均可接受
self.FPACE = self.F13 # pace 数值字体;与标签解耦,宽版把它换成 24px 而标签仍 12px
self.F24 = ImageFont.truetype(font_path, 24 * k)
self.F36 = ImageFont.truetype(font_path, 36 * k) # 3 倍整数缩放,仍锐利;宽版今日实时数值用
self.F48 = ImageFont.truetype(font_path, 48 * k)
# ---------- 基础绘制工具 ----------
def _tw(self, s, ft):
def _tw(self, s, ft, track=0):
# track额外字符间距(px)。非整数倍缩放字号(如 F16=16/12x)下,数字字形墨迹宽度
# 等于步进宽度(无侧边距),相邻字符几乎零间隙,需手动加距才不会紧贴。
if track:
return sum(self.d.textlength(c, font=ft) for c in s) + track * (len(s) - 1)
return self.d.textlength(s, font=ft)
def _draw(self, x, y, s, ft, fill, bold):
def _draw(self, x, y, s, ft, fill, bold, track=0):
# 伪粗体:点阵字体单一字重,按 1~bold px 偏移叠绘加粗笔画
if not track:
self.d.text((x, y), s, font=ft, fill=fill)
for dx in range(1, bold + 1):
self.d.text((x + dx, y), s, font=ft, fill=fill)
return
cx = x
for ch in s:
self.d.text((cx, y), ch, font=ft, fill=fill)
for dx in range(1, bold + 1):
self.d.text((cx + dx, y), ch, font=ft, fill=fill)
cx += self.d.textlength(ch, font=ft) + track
def _track(self, ft, bold, track=0):
"""实际字距:显式 track 优先否则「12px 且加粗」自动加 k——12px 点阵字形之间只有 1 列空隙,伪粗体右偏 1px 正好填满、
相邻字左右粘连13/24px 空隙有 2 列所以正常2026-09-14 用户要求拉开一点点。宽版 F13/F16/FPACE 都映射到 12px同样命中。"""
if track or not bold:
return track
return self.k if getattr(ft, "size", 0) == 12 * self.k else 0
def _tw_b(self, s, ft, bold=0, track=0):
"""含伪粗体偏移与自动字距的实际占宽(手动推进 x / 居中时用它,别再手写 _tw + bold"""
return self._tw(s, ft, self._track(ft, bold, track)) + bold
def text(self, x, y, s, ft, fill=BLACK, bold=0):
self._draw(x, y, s, ft, fill, bold)
self._draw(x, y, s, ft, fill, bold, self._track(ft, bold))
def rtext(self, xr, y, s, ft, fill=BLACK, bold=0):
self._draw(xr - self._tw(s, ft) - bold, y, s, ft, fill, bold)
self._draw(xr - self._tw_b(s, ft, bold), y, s, ft, fill, bold, self._track(ft, bold))
def ctext(self, cx, y, s, ft, fill=BLACK, bold=0):
self._draw(cx - (self._tw(s, ft) + bold) / 2, y, s, ft, fill, bold)
self._draw(cx - self._tw_b(s, ft, bold) / 2, y, s, ft, fill, bold, self._track(ft, bold))
def hdash(self, x0, x1, y, dash=4, gap=4, fill=BLACK):
def hdash(self, x0, x1, y, dash=4, gap=4, fill=BLACK, width=1):
x = x0
while x < x1:
self.d.line([x, y, min(x + dash, x1), y], fill=fill, width=1)
self.d.line([x, y, min(x + dash, x1), y], fill=fill, width=width)
x += dash + gap
def vdash(self, x, y0, y1, dash=4, gap=4, fill=BLACK):
def vdash(self, x, y0, y1, dash=4, gap=4, fill=BLACK, width=1):
y = y0
while y < y1:
self.d.line([x, y, x, min(y + dash, y1)], fill=fill, width=1)
self.d.line([x, y, x, min(y + dash, y1)], fill=fill, width=width)
y += dash + gap
def tri(self, cx, cy, sz, up=True, fill=BLACK):
@@ -63,24 +115,153 @@ class DashboardRenderer:
self.d.polygon(pts, fill=fill)
# ---------- 组件 ----------
def _cmp_line(self, cx, y, label, pair, fg=BLACK):
"""环比/同比一行:标签 + 涨跌三角 + 数值整体居中。direction 为 None 时不画三角"""
def _cmp_w(self, label, pair):
"""环比/同比一行的总宽度(标签 + 间隙 + 三角 + 数值),供居中/右对齐排布"""
direction, val = pair
lw = self._tw(label, self.F12); gap = 5; nw = self._tw(val, self.F12)
trw, tgap = (8, 4) if direction else (0, 0)
x = cx - (lw + gap + trw + tgap + nw) / 2
self.text(x, y, label, self.F12, fill=fg); x += lw + gap
if direction:
# 三角中心对齐 F12 数字墨迹竖直中心(y+8.5),否则箭头偏高
self.tri(x + trw / 2, y + 8.5, 7, up=(direction == "up"), fill=fg); x += trw + tgap
self.text(x, y, val, self.F12, fill=fg)
k = self.k
trw, tgap, vb = (8 * k, 4 * k, k) if direction else (0, 0, 0) # vb数值伪粗体占位 -- 不加粗)
return self._tw(label, self.F12) + 5 * k + trw + tgap + self._tw_b(val, self.F12, vb)
def _title_bar(self, y, title, upd):
"""标题栏:■ 标题(左) + 上次更新 HH:mm"""
sq, sq_y = 6, y + 4
self.d.rectangle([10, sq_y, 10 + sq, sq_y + sq], fill=BLACK)
self.text(10 + sq + 6, y, title, self.F12)
self.rtext(self.W - 10, y, f"上次更新 {upd}", self.F12)
def _cmp_line(self, cx, y, label, pair, fg=BLACK):
"""环比/同比一行:标签 + 涨跌三角 + 数值,整体居中于 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 _eq(self, cx, cy, w, fill=BLACK):
"""等号「=」:两道横杠各宽 w、厚 k中间隔 2k整体以 (cx, cy) 为中心——占三角的位子表示「持平」。
用量行 pacediff=0与今日实时环比/同比(增长率恰为 0共用。"""
k = self.k
for top in (cy - 2 * k, cy + k):
self.d.rectangle([cx - w / 2, top, cx + w / 2 - 1, top + k - 1], fill=fill)
def _cmp_at(self, x, y, label, pair, fg=BLACK):
"""环比/同比一行,左对齐于 x宽版把它放在数值右侧时用
direction: up 上三角 / down 下三角 / flat 等号(增长率恰为 02026-09-14 用户要求,原来 0 也画上三角)/ None 不画。"""
direction, val = pair
k = self.k
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 线一致);标签始终黑
val_color = self._cmp_color(direction, fg)
if direction == "flat":
self._eq(x + trw / 2, y + 8 * k, 7 * k, fill=val_color); x += trw + tgap # 横杠落在第 6/9 行,中心 8 ≈ 数字墨迹中心 8.5
elif direction:
# 三角中心对齐 F12 数字墨迹竖直中心(y+8.5),否则箭头偏高
self.tri(x + trw / 2, y + 8.5 * k, 7 * k, up=direction == "up", fill=val_color); x += trw + tgap
# 数值伪粗体、字号不变2026-09-14 用户要求);标签常规字重;占位「--」不加粗(加粗会粘成一条长横)
self.text(x, y, val, self.F12, fill=val_color, bold=k if direction else 0)
def _value(self, cx, y, col, bold=1):
"""今日实时的数值:主数字 F24 粗,前缀(¥)/后缀F12 小号、与主数字基线对齐2026-09-14 用户要求「¥ 与 万 字号小一点」),
整体水平居中于 cx。col["vp"] 为 (前缀, 主数字, 后缀),缺失时退回整串 col["v"] 按 F24 画。"""
pre, num, suf = col.get("vp") or ("", col["v"], "")
k = self.k
GAP = 1 * k # 小号段与主数字的间距
sb = bold # 小号段同样伪粗体,否则挨着粗体主数字显得单薄
wp = self._tw_b(pre, self.F12, sb) + GAP if pre else 0
ws = GAP + self._tw_b(suf, self.F12, sb) if suf else 0
wn = self._tw_b(num, self.F24, bold)
x = cx - (wp + wn + ws) / 2.0
# 基线对齐F12/F24 数字墨迹底沿分别为 13k/26kgetbbox小号段下移其差值
ys = y + (self.F24.getbbox("0")[3] - self.F12.getbbox("0")[3])
if pre:
self.text(x, ys, pre, self.F12, bold=sb); x += wp
self.text(x, y, num, self.F24, bold=bold); x += wn
if suf:
self.text(x + GAP, ys, suf, self.F12, bold=sb)
def _label(self, y, title, right=None, x0=None, x1=None):
"""区块标题(两区块统一,对齐主分支 _title_bar 风格):
■ 方块装饰(8px与 F12 标题墨迹竖直居中) + 标题(F12,常规字重) + 可选右侧信息。
x0/x1标题起点 / 右侧信息右端,默认为页边距 10宽版并排两块时可各自指定"""
k = self.k
SQ, M = 8 * k, 10 * k # 方块边长 / 左右页边距
x0 = M if x0 is None else x0
x1 = self.W - M if x1 is None else x1
self.d.rectangle([x0, y + 4 * k, x0 + SQ, y + 4 * k + SQ], fill=BLACK) # 中心≈y+8对齐 F12 墨迹中心(≈y+8.5)
self.text(x0 + SQ + 6 * k, y, title, self.F12)
if right:
self.rtext(x1, y, right, self.F12)
def _mid(self, x, cy, s, ft, fill=BLACK, bold=0, align="l", track=0):
"""按字符串「墨迹竖直中心」对齐到 cy 绘制——跨字号(F12/F24)也能真正居中,
避免不同字号按顶部对齐时的视觉错位。align: l 左对齐 / r 右对齐 / c 居中于 x。
track见 _tw用于给紧贴的字符手动加间距。"""
_, t, _, b = ft.getbbox(s) # 该串墨迹在字身框内的上/下沿
y = cy - (t + b) / 2.0
track = self._track(ft, bold, track)
w = self._tw(s, ft, track)
if align == "r":
x -= w + bold
elif align == "c":
x -= (w + bold) / 2.0
self._draw(x, y, s, ft, fill, bold, track)
def _pace(self, xr, cy, diff):
"""时间维度对比pace = 用量% 时间%>0 超前↑红;<0 节余↓黑;=0 持平。
数值(FPACE默认 F13)两位补零、右端对齐 xr小三角(6)紧贴其左;均竖直居中 cy。返回整体宽度供左侧定位。
diff=0持平/无数据)三角位画一个与三角等宽的等号「=」2026-09-14 用户要求,原为留空,后曾用「-」——pace 列每行等宽,各行进度条右端齐平。"""
up = diff > 0
color = self._pace_color(diff)
txt = f"{abs(int(diff)):02d}"
tw = self._tw(txt, self.FPACE, self._track(self.FPACE, self.k)) # 宽版 FPACE=12px 加粗 → 含自动字距
self._mid(xr, cy, txt, self.FPACE, fill=color, bold=self.k, align="r")
TRI_GAP, TRI = 4 * self.k, 6 * self.k
cx = xr - tw - TRI_GAP - TRI / 2
if diff != 0:
self.tri(cx, cy, TRI, up=up, fill=color)
else:
self._eq(cx, cy, TRI, fill=color) # 等号与三角等宽
return tw + TRI_GAP + TRI
def _usage_row(self, x0, x1, y, bar, warn, label_w=None):
"""一条用量(单行):左标签(5h/7d, F13) + 进度条 + 百分比(F16, 主数字) + 时间维度对比 pace(F13)。
右侧从右往左排布显式控制两处横向间距G1 进度条↔百分比、G2 百分比↔pace全部竖直居中 cy。
pct ≥ warn → 进度条(含整条边框)与百分比转红;边框随条色,红条即红框,无黑白错位。
label_w标签列宽默认 26*k够放 5h/7d宽版有 "Fable 7d" 这类长标签时由调用方按最长标签算好传入。"""
raw = bar.get("pct")
ok = isinstance(raw, (int, float)) # None/缺失 → 显示 "--"
pct = max(0, min(100, int(raw))) if ok else 0
fill, outline, color = self._usage_colors(ok, pct, warn, bar)
k = self.k
LABEL_W = 26 * k if label_w is None else label_w
G1, G2, H = 13 * k, 8 * k, 15 * k # G1 进度条↔百分比、G2 百分比↔pace视觉约 12/11
PCT_TRACK = self.PCT_TRACK * k # 见类属性 PCT_TRACK
cy = y + 10 * k # 行竖直中心
lb = k if self.LABEL_BOLD else 0 # 左标签 F13
if self.LABEL_ALIGN == "r":
self._mid(x0 + LABEL_W - self.LABEL_GAP * k, cy, bar["k"], self.F13, bold=lb, align="r")
else:
self._mid(x0, cy, bar["k"], self.F13, bold=lb)
# 右侧从右往左pace(最右) → 百分比 → 进度条
pct_r = x1
if ok:
# 有百分比就必画 pace 列time_pct 缺失(窗口无重置时刻)按 0 差值画 "00",不能整列省掉——否则该行进度条会变长、右端错位
pace_w = self._pace(x1, cy, pct - int(bar["time_pct"]) if "time_pct" in bar else 0)
pct_r = x1 - pace_w - G2
pct_txt = f"{pct:02d}%" if ok else "--"
self._mid(pct_r, cy, pct_txt, self.F16, fill=color, bold=k, align="r", track=PCT_TRACK) # 百分比 F16
bx0 = x0 + LABEL_W
bx1 = pct_r - self._tw(pct_txt, self.F16, self._track(self.F16, k, PCT_TRACK)) - G1
by = cy - H // 2
self.d.rectangle([bx0, by, bx1, by + H], outline=outline, width=k) # 轨道边框随条色
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 整体红,否则黑。"""
c = RED if (ok and pct >= warn) else BLACK
return c, c, c
def _pace_color(self, diff):
"""pace用量% 时间%)配色钩子。三色版:>0 超前红,否则黑。"""
return RED if diff > 0 else BLACK
def _loc_pin(self, x, y, w=8, fg=BLACK, bg=WHITE):
"""定位图标:实心水滴(圆头+尖尾) + 白色挖孔1-bit 下轮廓清晰。
@@ -139,12 +320,16 @@ class DashboardRenderer:
self.d.arc([x + S(12), cy - S(10), x + S(36), cy + S(14)], 270, 90, fill=fg, width=2)
self.d.line([x + S(9), cy + S(13), x + S(29), cy + S(13)], fill=fg, width=2)
def _weather_icon(self, x, y, category, s=1.0, fg=BLACK, bg=WHITE):
"""按天气类别画图标s 为缩放系数,原生约 36x38左上角 x,y"""
def _weather_icon(self, x, y, category, s=1.0, fg=BLACK, bg=WHITE, accent=None, wet=None):
"""按天气类别画图标s 为缩放系数,原生约 36x38左上角 x,y
accent 用于「有能量」的元素——太阳、闪电——着红wet 用于雨滴/雪花(六色版给蓝),默认随 fg
云/雾/月保持 fg"""
S = lambda v: v * s
accent = accent or fg
wet = wet or fg
if category == "sunny":
self._sun(x + S(18), y + S(19), S(11), s, fg)
self._sun(x + S(18), y + S(19), S(11), s, accent)
return
if category == "clear_night": # 月牙:实心圆挖去偏上右的背景圆
@@ -165,7 +350,7 @@ class DashboardRenderer:
return
if category != "rain" and category != "snow" and category != "thunder": # 多云及未识别:太阳 + 云
self._sun(x + S(10), y + S(9), S(6), s, fg)
self._sun(x + S(10), y + S(9), S(6), s, accent) # 探出的太阳着红
self._cloud(x, y + S(24), s, fg, bg)
return
@@ -174,19 +359,19 @@ class DashboardRenderer:
self._cloud(x, y + S(15), s, fg, bg)
if category == "rain":
for dx in (9, 18, 27):
self.d.line([x + S(dx) + S(3), cb + S(3), x + S(dx) - S(1), cb + S(10)], fill=fg, width=2)
self.d.line([x + S(dx) + S(3), cb + S(3), x + S(dx) - S(1), cb + S(10)], fill=wet, width=2)
elif category == "snow":
for dx in (10, 19, 28):
fx, fy, rr = x + S(dx), cb + S(7), S(4)
for a in (0, 60, 120):
rad = math.radians(a)
self.d.line([fx - math.cos(rad) * rr, fy - math.sin(rad) * rr,
fx + math.cos(rad) * rr, fy + math.sin(rad) * rr], fill=fg, width=1)
fx + math.cos(rad) * rr, fy + math.sin(rad) * rr], fill=wet, width=1)
elif category == "thunder":
bx = x + S(18)
self.d.polygon([(bx - S(1), cb + S(2)), (bx + S(5), cb + S(2)), (bx + S(1), cb + S(8)),
(bx + S(6), cb + S(8)), (bx - S(4), cb + S(18)), (bx, cb + S(9)),
(bx - S(4), cb + S(9))], fill=fg)
(bx - S(4), cb + S(9))], fill=accent) # 闪电着红作警示
# 线型样式:(实线段长, 间隙长);实线用 dash=0 表示连续
_STYLES = {"solid": (0, 0), "dashed": (7, 4), "dotted": (1, 2)}
@@ -241,9 +426,10 @@ class DashboardRenderer:
SAMP, G1, GAP = 16, 8, 10 # 样例线长 / 线与名间隙 / 图例项间隙
x = self.W - 10
for s in reversed(series):
self.rtext(x, leg_y, s["k"], self.F12) # 指标名右端对齐 x
c = RED if s.get("k") == "流水" else BLACK # 指标(流水)图例同步着红
self.rtext(x, leg_y, s["k"], self.F12, fill=c) # 指标名右端对齐 x
x -= self._tw(s["k"], self.F12) + G1
self._style_sample(x - SAMP, leg_y + 7, s["style"])
self._style_sample(x - SAMP, leg_y + 7, s["style"], fill=c)
x -= SAMP + GAP
# ---- 绘图区 ----
px0, px1 = 14, self.W - 10
@@ -252,6 +438,7 @@ class DashboardRenderer:
n = len(dates)
xstep = (px1 - px0) / (n - 1) if n > 1 else 0
for s in series:
color = RED if s.get("k") == "流水" else BLACK # 主指标(流水)为主角,着红领读
data = s["data"]
lo, hi = min(data), max(data)
span = (hi - lo) or 1
@@ -261,7 +448,10 @@ class DashboardRenderer:
x = px0 + xstep * i
yv = py1 - pad - (v - lo) / span * (py1 - py0 - 2 * pad)
pts.append((x, yv))
self._styled_polyline(pts, s["style"])
self._styled_polyline(pts, s["style"], fill=color)
if color == RED and pts: # 主指标最新值标红点:“今日 · 你在此处”
ex, ey = pts[-1]
self.d.ellipse([ex - 2.5, ey - 2.5, ex + 2.5, ey + 2.5], fill=RED)
# ---- X 轴标签:仅首 / 中 / 末三个日期 ----
xl_y = py1 + 3
self.text(px0, xl_y, dates[0], self.F12)
@@ -270,55 +460,65 @@ class DashboardRenderer:
# ---------- 入口 ----------
def render(self, data):
"""根据 data 渲染并返回 PIL.Image'L' 模式,纯 0/255"""
self.img = Image.new("L", (self.W, self.H), WHITE)
"""根据 data 渲染并返回 PIL.Image'RGB' 模式,仅黑/白/红三色)。
版式(自上而下三段,两道横规分隔):
A 顶部:大时钟+日期(左)/ 天气(右)——白底黑字,红点缀(定位/太阳闪电)
B 中部主角Claude Code 用量——每条=标题行 + 一条粗进度条pct≥阈值转红预警
C 底部:橘喵今日实时三列(涨=红·跌=黑。近30天趋势图暂隐藏(_trend_chart 保留)。
"""
self.img = Image.new("RGB", (self.W, self.H), WHITE)
self.d = ImageDraw.Draw(self.img)
self.d.fontmode = "1" # 关闭抗锯齿,点阵锐利
self.d.fontmode = "1" # 关闭抗锯齿,点阵锐利RGB 上仍生效,纯色描边不发糊)
dt, wt, mao = data["date"], data["weather"], data["mao"]
dt, wt, mao, usage = data["date"], data["weather"], data["mao"], data["usage"]
# A. Hero大时钟 + 日期(时钟上移使「顶部留白 ≈ 与日期间距」对称)
# ---- A. 顶部:大时钟+日期(左)/ 天气(右)。白底黑字,红点缀 ----
self.text(10, 0, dt["time"], self.F48, bold=2)
self.text(12, 64, f'{dt["week"]} {dt["greg"]}', self.F12)
# B. 天气:右对齐文字 + 居中的 28° + 放大右靠的图标
DIV_Y = 90 # 天气区底部分割线
DIV_Y = 90
loc, temp, cr = wt["loc"], wt["temp"], f'{wt["cond"]} {wt["range"]}'
self.rtext(390, 8, loc, self.F12)
self.rtext(390, 30, temp, self.F24, bold=1)
self.rtext(390, 64, cr, self.F12)
# 定位图标:置于 loc 文字左侧,留间距并与文字竖直居中
PIN_W, PIN_GAP = 8, 6
loc_pin_x = 390 - self._tw(loc, self.F12) - PIN_GAP - PIN_W
self._loc_pin(loc_pin_x, 10, PIN_W)
self._loc_pin(loc_pin_x, 10, PIN_W, fg=RED) # 定位红水滴
s = 1.5
iw, ih = 36 * s, 38 * s # 图标原生约 36x38
text_left = min(loc_pin_x,
390 - self._tw(temp, self.F24) - 1,
390 - self._tw(cr, self.F12))
icon_y = (DIV_Y - ih) / 2 # 顶部(0)与分割线正中:上下留白相等
self._weather_icon(text_left - 12 - iw, icon_y, self._weather_category(wt.get("icon")), s=s)
self._weather_icon(text_left - 12 - iw, (DIV_Y - ih) / 2,
self._weather_category(wt.get("icon")), s=s, accent=RED)
self.hdash(8, 392, DIV_Y)
# C. 橘喵今日经营:三列(数值 + 环比/同比)
self._title_bar(96, "橘喵·今日实时", mao["upd"])
cols = mao["cols"]; n = len(cols)
# ---- B. 中部主角Claude Usage单行标签 + 进度条 + 百分比 + pace≥阈值转红----
# 标题贴近上分割线(与今日实时一致的 6px 间距);两条进度条在余下空间内等距(≈19px)
self._label(96, usage.get("title", "Claude Usage"))
warn = usage.get("warn", 80)
ry = 123
for b in usage.get("bars", []):
if b.get("scoped"): # 按模型限定的额度Fable 7d只在 800x480 宽版展示,小屏放不下第三行
continue
self._usage_row(10, 390, ry, b, warn)
ry += 31 # 略压缩两条间距,给底部让出下边距
self.hdash(8, 392, 187) # B/C 分割线上移,最终给底部今日实时留出与整图下边的间距
# ---- C. 底部:橘喵·今日实时(三列,版式对齐主分支:标题+18=名/+34=值/+64=环比/+82=同比)----
self._label(193, "橘喵·今日实时", right=f'上次更新 {mao["upd"]}')
cols = [c for c in mao["cols"] if not c.get("wide")]; n = len(cols) # 仅宽版的列(团购)小屏放不下,跳过
ax0, ax1 = 8, 392
cw = (ax1 - ax0) / n
for i in range(1, n):
self.vdash(ax0 + cw * i, 128, 192)
self.vdash(ax0 + cw * i, 225, 289)
for i, c in enumerate(cols):
cx = ax0 + cw * (i + 0.5)
self.ctext(cx, 114, c["k"], self.F12)
self.ctext(cx, 130, c["v"], self.F24, bold=1)
self._cmp_line(cx, 160, "环比", c["hb"])
self._cmp_line(cx, 178, "同比", c["tb"])
self.hdash(8, 392, 200)
# D. 近30天三指标趋势折线图
self._trend_chart(204, mao["trend"])
self.ctext(cx, 211, c["k"], self.F12) # 指标名(标题+18
self._value(cx, 227, c, bold=1) # 数值 F24 伪粗体 + 小号 ¥/万(+34
self._cmp_line(cx, 257, "环比", c["hb"]) # +64
self._cmp_line(cx, 275, "同比", c["tb"]) # +82墨迹止于≈289底部留≈10px
return self.img

400
renderer_e1002.py Normal file
View File

@@ -0,0 +1,400 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
reTerminal E1002 渲染层 —— 复用 renderer.DashboardRenderer 的组件(**k=1字号与 400x300 版完全相同**
利用 800x480 的四倍面积重新排版为「AI 用量看板」,输出 **E Ink Spectra 6 原生六色**(黑/白/黄/红/绿/蓝)纯色图,
并生成一张固定 800x480 的 HTML 页面供 SenseCraft HMI 的 HTML 控件云端截图拉取。
版式k=1点阵字体且**只用整数倍字号 12/24/36/48/84**,不放大;四段,三道横规):
A 顶部090 **大时钟 F84**(墨迹 1679 其右一列三行、左缘对齐:① 电池图标+电量 ② 温度计+温度 水滴+湿度 ③ 星期/日期
右侧上下两层:上 天气图标 ×1.25 **实时温度 F48 粗 3**(墨迹顶沿 16 与时钟齐平、「°」F24 上标、右贴页边 790下 一行「定位水滴+城市 天气 温区」F12 右对齐贴 790、与日期同基线
F12行距 22日期与时钟数字**基线对齐**;设备数据来自 SenseCraft 遥测 `data.device`2026-09-14 定稿,
此前「状态栏横排在时钟上方+时钟缩 F60」版被否
|天气(右,贴 790——天气组件与三色版同尺寸。
B 用量96206 :左半 **Claude Usage**两行5h 合并行 7d——一个大边框内上下两条实心 = 总 7d / fable 7d右侧数字取较大者`_usage_row_split`)|竖规 x=400右半 **Codex Usage**5h / 7dChatGPT 订阅的 Codex 额度池;未推送时 mock 标「示例数据」)。
行组件沿用三色版的紧凑单行 `_usage_row`(行距 31**三处文字统一 12px**(基类 F13/F16/FPACE 都映射到 12px
标签**常规字重、在标签列内右对齐**(贴进度条),百分比与 pace 数字**粗体**(曾试过 24px用户嫌大
标签用 bar["label"]statusline 同款写法),列宽按最长标签自适应;标题右侧「上次更新 HH:MM」与今日实时同款。
C 今日218322**橘喵·今日实时** 五列全宽(探店单量/探店流水/团购单量/团购流水/毛利):列内四行逐行居中——
名 F12 / 值 F24 粗 / 环比 / 同比(五列每列 ~157px原「值 F36 右侧叠放环比/同比」并排组放不下)。
D 行情(~336470**BTC/USDT 时K近 48 小时)** 蜡烛图(`_btc_chart`,数据来自 btc_apiOKX 主源、Coinbase/Huobi 备源5 分钟缓存;
周期/根数由 BTC_BAR/BTC_LIMIT 配置)——标题右侧最新价 + 24h 涨跌幅;**涨(收≥开)绿、跌红**,实心蜡烛 + 1px 影线;
右侧最高/最低/最新价标签,最新价一道点规;底部首/中/末时间。
可选470479六色测试色条config.E1002_COLOR_STRIP默认关真机校准色彩量化时打开
六色的语义分工(每种颜色只有一个含义,不为「有」而用):
- 黑/白:结构与正文。红:危险、涨、定位、太阳/闪电绿健康、K 线上涨(红另用于 K 线下跌);蓝:天气雨滴/雪花。
黄:**不用**2026-09-14 曾按要求加过四档含黄,随即被否)。
- 用量与 pace **各自三档、颜色顺序固定 绿 → 黑 → 红**,两者独立计算(`_usage_colors` / `_pace_color`
用量 pct≤ USAGE_OK(30) 绿「健康」|(30, 80) 黑「正常」|≥ USAGE_WARN(80) 红「危险」——进度条填充、边框与**百分比数字三者同色**
pace = 用量% 时间%:≤ PACE_TOL(10) 绿「节余」|(10, +10) 黑「持平」|≥ +10 红「偏快」。
绿色数字在白底上比黑字淡一些24px 字号下可读;这是「百分比随条色」的已知取舍。
- 整图只允许六种纯色;渲染后可用 `img.getcolors()` 自查。
为什么是「固定 800x480 + 一张 PNG」而不是自适应 HTML
- SenseCraft 的 HTML 控件是云端无头浏览器截图后再量化到六色下发。自适应布局在未知视口/DPR 下会被重排、
缩放、抗锯齿,产生大量中间色,最终被平台抖动成网点。页面锁死 800x480、内容就是 1:1 的 PNG
`image-rendering: pixelated`),控件铺满画布即逐像素一致,每个像素本就是六色之一,量化零损失。
"""
import os
import time
import config
from renderer import DashboardRenderer, BLACK, WHITE, RED
import config as _cfg
from PIL import ImageFont
# E Ink Spectra 6 原生六色。取纯饱和值作为给平台量化器的明确信号(与 ESPHome/GxEPD2 驱动的 BWYRGB 映射一致)。
YELLOW, GREEN, BLUE = (255, 255, 0), (0, 255, 0), (0, 0, 255)
SPECTRA6 = [BLACK, WHITE, YELLOW, RED, GREEN, BLUE]
class E1002Renderer(DashboardRenderer):
"""800x480 六色版。组件继承k=1字号不变只重写版式与配色钩子。"""
PCT_TRACK = 0 # 百分比是整数倍字号(自带侧边距),不再手动加字符间距
LABEL_BOLD = False # 标签常规字重(数值才加粗,层级:数值 > 标签)
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)
# 宽版只用点阵字体的**整数倍字号**12 / 24 / 36 / 48 / 84基类给用量行用的 F13标签/ FPACEpace/ F16百分比
# 是非整数倍缩放(笔画不均),这里统一映射到 12px24px 试过、用户嫌大);标签常规字重、百分比与 pace 数字粗体;
# 行几何靠 _mid 按墨迹中心对齐不受字号变化影响。F84 给顶部大时钟7 倍整数缩放,墨迹 63px 恰好占满 A 段)。
self.F13 = ImageFont.truetype(font_path, 12)
self.F16 = self.F13
self.FPACE = self.F13
self.F84 = ImageFont.truetype(font_path, 84) # 顶部大时钟
# ---------- 配色钩子(三档,顺序固定 绿 → 黑 → 红;用量与 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 _usage_colors(self, ok, pct, warn, bar):
"""进度条填充、边框、百分比数字三者同色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:
return RED
if diff <= -self._pace_tol:
return GREEN
return BLACK
# 天气图标各类别的真实墨迹上下沿(基类 _weather_icon 名义框 36×38 的坐标系、未乘 s用于按墨迹而非名义框竖直居中。
_ICON_EXTENT = {"sunny": (1, 37), "clear_night": (6, 32), "overcast": (10, 34), "fog": (9, 34),
"cloudy": (-4, 38), "rain": (5, 39), "snow": (5, 40), "thunder": (5, 46)}
# ---------- 组件 ----------
def _status_battery(self, x, cy, dev):
"""状态项·电池F12数字伪粗体墨迹中心 cy外框 16×9 + 正极 2×4内部按电量实心填充——充电中 GREEN、≤20% RED、其余 BLACK
右侧电量数字,缺失 "--"。返回末端 x。"""
F, GAP_I = self.F12, 5
lvl, chg = (dev or {}).get("battery"), (dev or {}).get("charging")
self.d.rectangle([x, cy - 4, x + 15, cy + 4], outline=BLACK, width=1)
self.d.rectangle([x + 16, cy - 2, x + 17, cy + 2], fill=BLACK)
if lvl is not None:
fill = GREEN if chg else RED if lvl <= 20 else BLACK
fw = round(12 * max(0, min(100, lvl)) / 100)
if fw > 0:
self.d.rectangle([x + 2, cy - 2, x + 2 + fw, cy + 2], fill=fill)
x += 18 + GAP_I
txt = f"{lvl}%" if lvl is not None else "--"
b = 1 if lvl is not None else 0 # 数字伪粗体、字号不变2026-09-14 用户要求);占位不加粗
self._mid(x, cy, txt, F, bold=b)
return x + self._tw_b(txt, F, b)
def _status_env(self, x, cy, dev):
"""状态项·环境F12数字伪粗体墨迹中心 cy温度计细管 3×8 + 底部球 7×7管内水银线温度 蓝色水滴(上尖下圆)+湿度,
项间距 16、图标↔数字 5缺失 "--"。返回末端 x。"""
F, GAP_I, GAP = self.F12, 5, 16
dev = dev or {}
self.d.rectangle([x + 2, cy - 6, x + 4, cy + 1], outline=BLACK, width=1)
self.d.ellipse([x, cy + 1, x + 6, cy + 7], fill=BLACK)
self.d.line([x + 3, cy - 2, x + 3, cy + 1], fill=BLACK)
x += 7 + GAP_I
t = dev.get("temp")
txt = f"{t:.1f}" if t is not None else "--"
b = 1 if t is not None else 0 # 数字伪粗体、字号不变2026-09-14 用户要求);占位不加粗
self._mid(x, cy, txt, F, bold=b); x += self._tw_b(txt, F, b) + GAP
self.d.polygon([(x + 4, cy - 6), (x, cy + 2), (x + 8, cy + 2)], fill=BLUE)
self.d.ellipse([x, cy - 1, x + 8, cy + 6], fill=BLUE)
x += 9 + GAP_I
h = dev.get("humidity")
txt = f"{h:.0f}%" if h is not None else "--"
b = 1 if h is not None else 0
self._mid(x, cy, txt, F, bold=b)
return x + self._tw_b(txt, F, b)
def _color_strip(self, y0, y1, x0=None, x1=None):
"""六色测试色条:六块等宽实心色块,白块加 1px 黑框以便可见。真机校准用。"""
x0 = 10 if x0 is None else x0
x1 = self.W - 10 if x1 is None else x1
n = len(SPECTRA6)
w = (x1 - x0) / n
for i, c in enumerate(SPECTRA6):
bx0, bx1 = round(x0 + w * i), round(x0 + w * (i + 1)) - 1
self.d.rectangle([bx0, y0, bx1, y1], fill=c)
if c == WHITE:
self.d.rectangle([bx0, y0, bx1, y1], outline=BLACK, width=1)
def _usage_block(self, x0, x1, y0, usage):
"""一个用量区块标题_label右侧「上次更新 HH:MM」——与今日实时同款文案mock 数据则标「示例数据」)
+ 逐行紧凑 `_usage_row`(行距 31。标签列宽按本块最长标签墨迹 + LABEL_GAP 算(标签右对齐贴进度条,短标签左侧留白)。返回末行底部 y。"""
right = "示例数据" if usage.get("mock") else (f'上次更新 {usage["updated"]}' if usage.get("updated") else None)
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", [])] # 宽版用长标签5h / 7d
# scoped 行fable 7d并入紧邻其前的非 scoped 行,成为该行的下半细条 → 左右两块都是两行,对称
rows = []
for b in bars:
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):
"""橘喵·今日实时五列(宽版):探店单量 / 探店流水 / 团购单量 / 团购流水 / 毛利,列内四行逐行水平居中:
指标名 F12y0+21→ 值 F24 粗y0+38→ 环比y0+72→ 同比y0+88行距 16按墨迹框算标题↔名 ~9px原 ~5px 太挤,整列下移 4px
名↔值 ~9px、值↔环比 ~6px、环比↔同比 5px。
为什么不再是「值 F36 右侧叠放环比/同比」的并排组:五列每列仅 ~157pxF36 并排组如「2.12万」102间隙环比 77
近 194px 放不下;降到 F24 后并排组仍有 156px贴着两侧分隔线太挤。竖排最宽元素「1234.56%」一行 89px余量充足
与 400×300 小屏同构。(更早曾试过名与值同行、名贴左上角角标,均已否。)返回区块底部 y。"""
x1 = self.W - 8 if x1 is None else x1
self._label(y0, "橘喵·今日实时", right=f'上次更新 {mao["upd"]}', x0=x0 + 2, x1=x1 - 2)
cols = mao["cols"]; n = len(cols)
cw = (x1 - x0) / n
TAG_Y, VAL_Y, HB_Y, TB_Y = y0 + 21, y0 + 38, y0 + 72, y0 + 88
for i in range(1, n):
self.vdash(x0 + cw * i, y0 + 26, y0 + 102)
for i, c in enumerate(cols):
cx = x0 + cw * (i + 0.5)
self.ctext(cx, TAG_Y, c["k"], self.F12)
self._value(cx, VAL_Y, c, bold=1) # F24 粗主数字 + 小号 ¥ / 万
self._cmp_line(cx, HB_Y, "环比", c["hb"])
self._cmp_line(cx, TB_Y, "同比", c["tb"])
return y0 + 104
@staticmethod
def _fmt_price(v):
"""价格文本≥1000 千分位取整,否则两位小数。"""
return f"{v:,.0f}" if abs(v) >= 1000 else f"{v:.2f}"
def _btc_chart(self, y0, y1, btc, x0=10, x1=None):
"""BTC K 线蜡烛图y0 标题行顶 y1 底;周期由 btc["bar"] 决定标题「时K/日K」
标题右侧 最新价F12 粗)+ 涨跌口径24h/1d+ 涨跌三角 + 涨跌幅F12涨绿 / 跌红;
绘图区:每根蜡烛 = 1px 影线(最高↔最低)+ 实心实体(开↔收,宽≈槽位 60%、奇数像素居中),收≥开 绿、否则 红;
右侧价格标签列:最高(顶)、最低(底)、最新(粗,随最新价位置浮动、避让顶底标签),最新价横贯一道点规;
底部基线点规 + 首/中/末三个日期。无数据时居中 "--""""
x1 = self.W - 10 if x1 is None else x1
btc = btc or {}
candles = btc.get("candles") or []
kind = "时K" if btc.get("bar", "1H") == "1H" else "日K"
self._label(y0, f'{btc.get("symbol") or "BTC/USDT"}·{kind}', x0=x0, x1=x1)
if candles and btc.get("last") is not None:
chg = btc.get("chg") or 0.0
col = GREEN if chg >= 0 else RED
xr = x1
pct_txt = f"{abs(chg):.2f}%"
self.rtext(xr, y0, pct_txt, self.F12, fill=col, bold=1); xr -= self._tw_b(pct_txt, self.F12, 1) + 4 # 涨跌幅伪粗体
self.tri(xr - 4, y0 + 8.5, 7, up=chg >= 0, fill=col); xr -= 8 + 6
if btc.get("chg_label"): # 涨跌幅口径24h / 1d
self.rtext(xr, y0, btc["chg_label"], self.F12); xr -= self._tw(btc["chg_label"], self.F12) + 10
self.rtext(xr, y0, self._fmt_price(btc["last"]), self.F12, bold=1)
if not candles:
self.ctext((x0 + x1) / 2, (y0 + y1) / 2 - 12, "--", self.F24)
return
LBL_W = 66 # 右侧价格标签列
px0, px1 = x0, x1 - LBL_W
py0, py1 = y0 + 26, y1 - 16 # 底部留 16px 给日期
hi_raw, lo_raw = max(c["h"] for c in candles), min(c["l"] for c in candles)
pad = (hi_raw - lo_raw or 1) * 0.04
lo, hi = lo_raw - pad, hi_raw + pad
span = hi - lo
Y = lambda v: py1 - (v - lo) / span * (py1 - py0)
n = len(candles)
slot = (px1 - px0) / n
bw = max(3, int(slot * 0.6)) | 1 # 实体宽度取奇数,影线居中
self.hdash(px0, px1, py1, dash=2, gap=3) # 基线
for i, c in enumerate(candles):
cx = round(px0 + slot * (i + 0.5))
col = GREEN if c["c"] >= c["o"] else RED
self.d.line([cx, round(Y(c["h"])), cx, round(Y(c["l"]))], fill=col, width=1)
top, bot = sorted((round(Y(c["o"])), round(Y(c["c"]))))
self.d.rectangle([cx - bw // 2, top, cx + bw // 2, bot], fill=col) # 十字星时 top==bot 退化为 1px 横线
# ---- 右侧价格标签:最高 / 最低 / 最新 ----
hi_y, lo_y = py0 - 6, py1 - 12
self.rtext(x1, hi_y, self._fmt_price(hi_raw), self.F12)
self.rtext(x1, lo_y, self._fmt_price(lo_raw), self.F12)
last = candles[-1]["c"]
ly = round(Y(last))
self.hdash(px0, px1, ly, dash=1, gap=3) # 最新价水平点规
ty = min(max(ly - 8, hi_y + 15), lo_y - 15) # 避让顶/底标签
self.rtext(x1, ty, self._fmt_price(last), self.F12, bold=1)
# ---- X 轴日期:首 / 中 / 末 ----
xl_y = py1 + 3
lab = lambda c: c.get("t") or c.get("d") or ""
self.text(px0, xl_y, lab(candles[0]), self.F12)
self.ctext((px0 + px1) / 2, xl_y, lab(candles[n // 2]), self.F12)
self.rtext(px1, xl_y, lab(candles[-1]), self.F12)
# ---------- 入口 ----------
def render(self, data):
from PIL import Image, ImageDraw
self.img = Image.new("RGB", (self.W, self.H), WHITE)
self.d = ImageDraw.Draw(self.img)
self.d.fontmode = "1"
R = self.W - 10 # 右页边 790
dt, wt, mao = data["date"], data["weather"], data["mao"]
usage, gpt = data["usage"], data.get("usage_gpt") or {}
# ---- A. 顶部:大时钟 F84 占满整段(左);其右一列三行、左缘与日期对齐:① 电池 ② 温度+湿度 ③ 星期/日期(与时钟基线对齐)
# 天气(右):上层 图标 ×1.25 温度 F48顶沿与时钟齐平、右贴页边下层 一行「定位+城市 天气 温区」右对齐、与日期同基线 ----
DIV_Y = 90
TIME_BOLD, INK_TOP = 3, 16 # 伪粗体 3px墨迹顶沿 16底沿 79F84 数字墨迹高 63
_, tt, _, tb = self.F84.getbbox(dt["time"])
time_y = INK_TOP - tt
base_y = time_y + tb # 时钟数字基线(墨迹底沿)
self.text(10, time_y, dt["time"], self.F84, bold=TIME_BOLD)
col_x = 10 + self._tw(dt["time"], self.F84) + TIME_BOLD + 14 # 右侧信息列左缘
date_txt = f'{dt["week"]} {dt["greg"]}'
_, _, _, db = self.F12.getbbox(dt["greg"]) # 用日期数字的底沿对齐基线(汉字底沿比数字低 1px忽略
date_y = base_y - db
self.text(col_x, date_y, date_txt, self.F12)
# 状态两行叠在日期上方:行距 22F12 墨迹中心 ≈ 文字 y+8.5),三行中心约 30.5 / 52.5 / 74.5,落在时钟墨迹 1679 之内
PITCH = 22
cy_date = date_y + 8.5
dev = data.get("device")
self._status_battery(col_x, cy_date - 2 * PITCH, dev)
self._status_env(col_x, cy_date - PITCH, dev)
# 天气2026-09-15 用户要求)上下两层:上层 天气图标 实时温度 F48右贴页边 R墨迹顶沿 16 与时钟齐平);
# 下层 一行「定位水滴+城市 天气 温区」F12 右对齐贴 R与日期行同基线底沿 79与时钟基线对齐
# 此前(同日上午)为「三行信息|图标 ×1.75|温度 F84 与时钟同高」,用户随即改为本版;更早为 F72「小一号」版2026-09-14
loc, temp, cond, rng = wt["loc"], wt["temp"], wt["cond"], wt["range"]
FT, DEG_F = self.F48, self.F24 # 「°」小一号作上标(大字号度号又宽又高),墨迹顶沿对齐数字顶沿
has_deg = temp.endswith("°")
digits = temp[:-1] if has_deg else temp
DEG_BOLD, DEG_GAP = 1, 2
deg_w = self._tw_b("°", DEG_F, DEG_BOLD) + DEG_GAP if has_deg else 0
_, tdt, _, tdb = FT.getbbox(digits)
temp_x = R - deg_w - self._tw_b(digits, FT, TIME_BOLD)
temp_y = INK_TOP - tdt # 数字墨迹顶沿与时钟顶沿齐平16底沿 52
self.text(temp_x, temp_y, digits, FT, bold=TIME_BOLD)
if has_deg:
rb = DEG_F.getlength("°") - DEG_F.getmask("°").getbbox()[2] # 字形右侧空白,补回使墨迹右缘贴 R
self.rtext(R + rb, temp_y + tdt - DEG_F.getbbox("°")[1], "°", DEG_F, bold=DEG_BOLD)
temp_cy = INK_TOP + (tdb - tdt) / 2 # 温度墨迹竖直中心,图标以此居中
s = 1.25
cat = self._weather_category(wt.get("icon"))
et, eb = self._ICON_EXTENT.get(cat, (0, 38)) # 该类图标真实墨迹的上下沿(名义框 36×38 之外:多云太阳光芒探出顶部、雷阵雨闪电垂到云下)
icon_x = temp_x - 14 - 36 * s
self._weather_icon(icon_x, temp_cy - (et + eb) * s / 2, cat, s=s, accent=RED, wet=BLUE)
PIN_W, PIN_GAP, SEP = 8, 6, " "
info = f"{loc}{SEP}{cond}{SEP}{rng}"
info_x = R - self._tw(info, self.F12)
self.text(info_x, date_y, info, self.F12) # 底行与日期同基线
self._loc_pin(info_x - PIN_GAP - PIN_W, date_y + 2, PIN_W, fg=RED)
self.hdash(8, self.W - 8, DIV_Y)
# ---- 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
self.vdash(MID, DIV_Y + 6, max(y_l, y_r) + 6)
DIV2_Y = max(y_l, y_r) + 16 # 分割线离末条进度条 16px避免拥挤
self.hdash(8, self.W - 8, DIV2_Y)
# ---- C. 橘喵·今日实时(全宽五列)----
end_c = self._realtime(DIV2_Y + 6, mao)
DIV3_Y = end_c + 8
self.hdash(8, self.W - 8, DIV3_Y)
# ---- D. BTC/USDT 日 K ----
strip = config.E1002_COLOR_STRIP
self._btc_chart(DIV3_Y + 6, self.H - (16 if strip else 10), data.get("btc"))
# ---- C. 可选:六色测试色条 ----
if config.E1002_COLOR_STRIP:
self._color_strip(self.H - 10, self.H - 1)
return self.img
# ---------- HTML 页面(给 SenseCraft HMI HTML 控件抓取)----------
_HTML = """<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=800, initial-scale=1">
<meta http-equiv="Cache-Control" content="no-store, no-cache, must-revalidate">
<meta http-equiv="Pragma" content="no-cache">
<meta http-equiv="Expires" content="0">
<title>eink dashboard 800x480</title>
<style>
/* 固定 800x480、零边距让云端截图与 PNG 逐像素对应;即便被缩放也用最近邻,避免产生中间色 */
html, body {{ margin: 0; padding: 0; background: #fff; width: 800px; height: 480px; overflow: hidden; }}
img {{ display: block; width: 800px; height: 480px;
image-rendering: pixelated; image-rendering: crisp-edges; -ms-interpolation-mode: nearest-neighbor; }}
</style>
</head>
<body><img src="{png}?v={stamp}" width="800" height="480" alt=""></body>
</html>
"""
def write_page(html_path, png_path, stamp=None):
"""写出固定 800x480 的静态页。PNG 以相对路径引用并带时间戳参数,防止云端抓取器命中旧图缓存。"""
stamp = stamp or str(int(time.time()))
rel = os.path.relpath(png_path, os.path.dirname(html_path))
os.makedirs(os.path.dirname(html_path), exist_ok=True)
with open(html_path, "w", encoding="utf-8") as f:
f.write(_HTML.format(png=rel.replace(os.sep, "/"), stamp=stamp))
return html_path

52
sensecraft_api.py Normal file
View File

@@ -0,0 +1,52 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
SenseCraft HMI 设备遥测客户端 —— 读 reTerminal E1002 自身上报的电量 / 温度 / 湿度。
接口GET {SENSECRAFT_API_BASE}/api/v1/user/device/iot_data/{SENSECRAFT_DEVICE_EUI},头 `api-key: <SENSECRAFT_API_KEY>`
(用户在 SenseCraft 后台生成的 API key放 .env不入库。响应2026-09-14 实测):
{"code":200,"result":{"battery":{"charging":true,"level":100},"sensor":{"humidity":42.9,"temp":30.3},
"dataaccess":{"interval":300},"devicestatus":{"status":1},...},"message":"success"}
取值result.battery.level%/ result.battery.charging / result.sensor.temp/ result.sensor.humidity%)。
设备本身每 dataaccess.interval300s上报一次故 DEVICE_TTL 取 300 与之同步即可。直连、不走代理,超时 10s。
密钥未配置直接抛错 → 上层回退占位 "--"
"""
import json
import urllib.error
import urllib.request
import config
TIMEOUT = 10
def get_iot_data():
"""返回 {battery(int|None), charging(bool), temp(float|None), humidity(float|None)};网络/鉴权失败抛出。"""
if not config.SENSECRAFT_API_KEY or not config.SENSECRAFT_DEVICE_EUI:
raise RuntimeError("未配置 SENSECRAFT_API_KEY / SENSECRAFT_DEVICE_EUI")
url = f"{config.SENSECRAFT_API_BASE.rstrip('/')}/api/v1/user/device/iot_data/{config.SENSECRAFT_DEVICE_EUI}"
req = urllib.request.Request(url, headers={"api-key": config.SENSECRAFT_API_KEY, "Accept": "application/json",
"User-Agent": "eink-push/sensecraft_api"})
opener = urllib.request.build_opener(urllib.request.ProxyHandler({}))
try:
with opener.open(req, timeout=TIMEOUT) as resp:
body = json.loads(resp.read().decode("utf-8"))
except urllib.error.HTTPError as e:
raise RuntimeError(f"HTTP {e.code}{e.read().decode('utf-8', 'replace')[:200]}") from e
if body.get("code") != 200:
raise RuntimeError(f"接口返回 code={body.get('code')} message={body.get('message')}")
r = body.get("result") or {}
bat, sen = r.get("battery") or {}, r.get("sensor") or {}
return {"battery": _num(bat.get("level"), int), "charging": bool(bat.get("charging")),
"temp": _num(sen.get("temp"), float), "humidity": _num(sen.get("humidity"), float)}
def _num(v, cast):
try:
return cast(v) if v is not None else None
except (TypeError, ValueError):
return None
if __name__ == "__main__":
print(get_iot_data())

342
server.py Normal file
View File

@@ -0,0 +1,342 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
图片/网页服务 —— 给 SenseCraft HMI 的 HTML 控件周期抓取的**恒定 URL**,每次请求都**现取数据、现渲染**,全程内存、不落盘。
真机验证结论2026-09SenseCraft 的 Image 控件只在填写 URL 时抓一次快照、此后不再更新;
HTML 控件填同一个 URL 则**每个刷新周期都重抓**。所以对外只暴露 HTML 控件用的 URL两种形态并行供对比
方案一 GET <IMAGE_PATH>(默认 /e1002.png→ image/png与 main.py --target e1002 同一套 Pillow 点阵渲染renderer_e1002
方案二 GET <HTML_PATH> (默认 /e1002.html→ text/html800x480 的现代网页html_renderer系统/网络字体 + 内联 SVG K 线),
由平台无头浏览器截图后量化(真机实拍:文字抗锯齿灰边被平台抖动成毛边、小字糊)
方案三 GET <HTML_PNG_PATH>(默认 /e1002-web.png→ image/png同一网页由**服务端**无头 Chrome 截图,再按最近色(不抖动)
固化为六色 PNGhtml_shot平台拿到的已是纯色图加 ?raw=1 返回未量化的原始截图供对照。需服务器装 Chrome。
两者共用同一份 build_data()
- 日期时间:服务器按 config.TZ_NAME 时区现算
- 天气 / 橘喵今日实时 / BTC K 线:**内存缓存 + 有效期**WEATHER_TTL / MAO_TTL / BTC_TTL.env 可配0=每次实拉):
收到请求先查该路缓存是否过期,过期才实拉并更新时间戳,否则直接用缓存渲染;拉取失败沿用旧值、不更新时间戳(下次再试),
从未成功则占位 "--"。整个服务**不读本机任何文件**(部署在公网服务器)。
- Claude Usage / Codex Usage**不主动拉取、不持凭证**,由本机 `main.py --target push` 采集后推送原始快照到 POST /push/usage、/push/usage_gpt
服务端暂存最新一份内存PUSH_STATE_PATH 非空时同时落盘,重启恢复);渲染时按当前时刻现算 pace
- 三路上游过期时**并行**实拉,整体最多等 FETCH_BUDGET 秒(默认 5超预算的一路本次用旧值、后台拉完更新缓存见 build_data
所以推送时机与设备抓取时机不必对齐。未推送过Claude 显示 "--"ChatGPT 用 mock 并标「示例数据」。
- 推送鉴权:请求头 X-Push-Token 必须等于 PUSH_TOKENPUSH_TOKEN 为空时拒绝一切推送(服务在公网,务必配置)。
- 页面鉴权IMAGE_TOKEN 非空时,三个渲染端点必须带 ?token=<IMAGE_TOKEN>,否则返回与未知路径一样的 404防爬虫扫描
其他端点GET /health → JSON 运行状态GET /push/usage、/push/usage_gpt → 查看暂存的原始快照(需同样令牌)。
部署python3 server.py监听 SERVER_HOST:SERVER_PORT前置 Caddy/Nginx 做 HTTPS或 cloudflared tunnel。
"""
import hmac
import io
import json
import os
import threading
import time
import urllib.parse
from concurrent.futures import ThreadPoolExecutor, wait
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
import config
import data
from renderer_e1002 import E1002Renderer
import html_renderer
import html_shot
class State:
"""服务端内存状态:推送来的原始用量快照 + 上游数据的「上一次成功值」+ 计数。"""
def __init__(self):
self.lock = threading.Lock()
self.raw = {"usage": None, "usage_gpt": None}
self.pushed_at = {"usage": None, "usage_gpt": None}
self.last_good = {"weather": None, "mao": None, "btc": None, "device": None} # 各路最近一次成功的数据
self.fetched_at = {"weather": 0.0, "mao": 0.0, "btc": 0.0, "device": 0.0} # 各路最近一次成功拉取的时间戳(缓存有效期起点)
self.inflight = {} # 进行中的拉取 futurekey → Future防同一路重复实拉
self.renders = {"png": 0, "html": 0}
self.last_render = None
self.started = time.time()
self._load()
# ---- 推送数据持久化(可选)----
def _load(self):
p = config.PUSH_STATE_PATH
if not p or not os.path.exists(p):
return
try:
with open(p, encoding="utf-8") as f:
st = json.load(f)
self.raw.update(st.get("raw") or {})
self.pushed_at.update(st.get("pushed_at") or {})
print(f"[状态] 已从 {p} 恢复推送数据")
except Exception as e:
print(f"[警告] 推送状态恢复失败:{e}")
def _save(self):
p = config.PUSH_STATE_PATH
if not p:
return
try:
tmp = p + ".tmp"
with open(tmp, "w", encoding="utf-8") as f:
json.dump({"raw": self.raw, "pushed_at": self.pushed_at}, f, ensure_ascii=False)
os.replace(tmp, p)
except Exception as e:
print(f"[警告] 推送状态落盘失败:{e}")
def push(self, kind, raw):
with self.lock:
self.raw[kind] = raw
self.pushed_at[kind] = time.time()
self._save()
STATE = State()
_RENDER_LOCK = threading.Lock() # Pillow 渲染器持有 self.img/self.d非线程安全
_PNG_RENDERER = E1002Renderer()
_POOL = ThreadPoolExecutor(max_workers=6, thread_name_prefix="fetch")
def _refresh(key, fetch):
"""在线程池里实拉一路上游:成功则更新 last_good/fetched_at无论成败都清掉 inflight。返回 (data, ok, 耗时秒)。"""
t0 = time.time()
try:
d, ok = fetch()
except Exception as e: # noqa: BLE001 —— 上游任何异常都按失败处理
print(f"[警告] {key} 拉取异常:{e}")
d, ok = None, False
with STATE.lock:
if ok:
STATE.last_good[key], STATE.fetched_at[key] = d, time.time()
STATE.inflight.pop(key, None)
return d, ok, time.time() - t0
def _cached(key, ttl, fetch):
"""带有效期的内存缓存(提交阶段):未过期 → (缓存, 状态串, None);过期 → 提交到线程池实拉,返回 (None, None, future)。
同一路已有进行中的拉取(上一请求超预算后仍在跑)则复用其 future不重复打上游。"""
now = time.time()
with STATE.lock:
cached, ts = STATE.last_good[key], STATE.fetched_at[key]
if cached is not None and ttl > 0 and now - ts < ttl:
return cached, f"{key}:cache({int(now - ts)}s)", None
fut = STATE.inflight.get(key)
if fut is None:
fut = _POOL.submit(_refresh, key, fetch)
STATE.inflight[key] = fut
return None, None, fut
def _settle(key, fut, fallback):
"""预算等待结束后结算一路:完成 → fetched(耗时)/stale/none未完成 → 用上一次成功值slow→stale或占位slow→none
拉取线程继续跑完并写入缓存供下次请求使用stale-while-revalidate"""
with STATE.lock:
cached = STATE.last_good[key]
if fut.done():
d, ok, dt = fut.result()
if ok:
return d, f"{key}:fetched({dt:.2f}s)"
return (cached, f"{key}:stale") if cached is not None else (fallback, f"{key}:none")
return (cached, f"{key}:slow→stale") if cached is not None else (fallback, f"{key}:slow→none")
def build_data(now=None):
"""组装渲染数据:三路上游按各自 TTL 走内存缓存;过期的**并行**实拉,整体最多等 FETCH_BUDGET 秒,
超预算的那路本次用旧值/占位、后台继续拉完更新缓存——响应时间上限 ≈ 预算 + 渲染,任何一路上游卡死都不再拖垮整图。
2026-09-14 前为串行实拉、各上游 socket 超时 30s单路卡住即整体超时。用量取推送暂存。返回 (data, 各路状态列表)。"""
now = now or data.now_tz()
with STATE.lock:
raw_usage, raw_gpt = STATE.raw["usage"], STATE.raw["usage_gpt"]
fallbacks = {
"weather": {"loc": config.WEATHER_LOC_NAME, "cond": "--", "temp": "--", "range": "--", "icon": ""},
"mao": {"upd": now.strftime("%H:%M"), "cols": [data._placeholder_col(k) for k in ("单量", "流水", "毛利")],
"trend": {"dates": [], "series": []}},
"btc": data.btc_empty(),
"device": data.device_empty(),
}
fetchers = {
"weather": (config.WEATHER_TTL, data.weather_fetch),
"mao": (config.MAO_TTL, lambda: data.mao_fetch(now, with_trend=False)),
"btc": (config.BTC_TTL, lambda: (data.btc_fetch(), True)),
"device": (config.DEVICE_TTL, data.device_fetch),
}
result, status, pending = {}, {}, {}
for key, (ttl, fetch) in fetchers.items():
d, st, fut = _cached(key, ttl, fetch)
if fut is None:
result[key], status[key] = d, st
else:
pending[key] = fut
if pending:
wait(pending.values(), timeout=config.FETCH_BUDGET)
for key, fut in pending.items():
result[key], status[key] = _settle(key, fut, fallbacks[key])
d = {
"date": {"greg": now.strftime("%Y/%m/%d"), "week": data.WEEK_CN[now.weekday()], "time": now.strftime("%H:%M")},
"weather": result["weather"],
"mao": result["mao"],
"usage": data._usage(now, raw=raw_usage if raw_usage is not None else {}), # 未推送 → 全部 "--"(不读本机文件)
"usage_gpt": data._usage_gpt(now, raw=raw_gpt, allow_local=False), # 未推送 → mock标「示例数据」服务端不本地采集
"btc": result["btc"],
"device": result["device"],
}
return d, [status[k] for k in ("weather", "mao", "btc", "device")]
def render_png_bytes(d):
"""渲染 PNG 到内存字节。"""
with _RENDER_LOCK:
img = _PNG_RENDERER.render(d)
buf = io.BytesIO()
img.save(buf, "PNG", optimize=True)
return buf.getvalue()
class Handler(BaseHTTPRequestHandler):
server_version = "eink-push/1.0"
protocol_version = "HTTP/1.1"
def log_message(self, fmt, *args):
pass
def _log(self, code, note=""):
ua = self.headers.get("User-Agent", "-")
ip = self.headers.get("CF-Connecting-IP") or self.headers.get("X-Forwarded-For") or self.client_address[0]
print(f"[{time.strftime('%H:%M:%S')}] {code} {self.command} {self.path} ip={ip} ua={ua[:60]} {note}", flush=True)
def _send(self, code, ctype, body, extra=None):
self.send_response(code)
self.send_header("Content-Type", ctype)
self.send_header("Content-Length", str(len(body)))
self.send_header("Cache-Control", "no-store, no-cache, must-revalidate, max-age=0")
self.send_header("Pragma", "no-cache")
self.send_header("Expires", "0")
for k, v in (extra or {}).items():
self.send_header(k, v)
self.end_headers()
if self.command != "HEAD":
self.wfile.write(body)
def _json(self, code, obj):
self._send(code, "application/json; charset=utf-8", json.dumps(obj, ensure_ascii=False).encode("utf-8"))
def _authed(self):
tok = config.PUSH_TOKEN
return bool(tok) and hmac.compare_digest(self.headers.get("X-Push-Token", ""), tok)
def _page_authed(self):
"""页面令牌IMAGE_TOKEN 为空则放行;否则查询串里 token 必须相等(常量时间比较)。"""
tok = config.IMAGE_TOKEN
if not tok:
return True
qs = urllib.parse.parse_qs(urllib.parse.urlsplit(self.path).query)
return any(hmac.compare_digest(v, tok) for v in qs.get("token", []))
# ---------- GET ----------
def do_GET(self):
path = self.path.split("?", 1)[0]
try:
if path in (config.IMAGE_PATH, config.HTML_PATH, config.HTML_PNG_PATH) and not self._page_authed():
# 令牌不对与路径不存在返回完全一样的 404不暴露端点存在
self._log(404, "bad page token")
return self._json(404, {"ok": False, "error": "not found"})
if path == config.IMAGE_PATH:
t0 = time.time()
d, st = build_data()
body = render_png_bytes(d)
with STATE.lock:
STATE.renders["png"] += 1
STATE.last_render = time.time()
self._log(200, f"png {len(body)}B {time.time() - t0:.2f}s [{' '.join(st)}]")
return self._send(200, "image/png", body)
if path == config.HTML_PATH:
t0 = time.time()
d, st = build_data()
body = html_renderer.render_html(d).encode("utf-8")
with STATE.lock:
STATE.renders["html"] += 1
STATE.last_render = time.time()
self._log(200, f"html {len(body)}B {time.time() - t0:.2f}s [{' '.join(st)}]")
return self._send(200, "text/html; charset=utf-8", body)
if path == config.HTML_PNG_PATH:
t0 = time.time()
raw = "raw=1" in (self.path.split("?", 1) + [""])[1]
d, st = build_data()
html = html_renderer.render_html(d)
img = html_shot.screenshot_html(html)
if not raw:
img = html_shot.quantize6(img)
buf = io.BytesIO(); img.save(buf, "PNG", optimize=True); body = buf.getvalue()
with STATE.lock:
STATE.renders["web_png"] = STATE.renders.get("web_png", 0) + 1
STATE.last_render = time.time()
self._log(200, f"web-png{'(raw)' if raw else ''} {len(body)}B {time.time() - t0:.2f}s [{' '.join(st)}]")
return self._send(200, "image/png", body)
if path == "/health":
with STATE.lock:
st = {"ok": True, "uptime_s": int(time.time() - STATE.started), "renders": dict(STATE.renders),
"last_render": STATE.last_render, "pushed_at": dict(STATE.pushed_at),
"has_last_good": {k: v is not None for k, v in STATE.last_good.items()},
"cache_age_s": {k: (int(time.time() - v) if v else None) for k, v in STATE.fetched_at.items()},
"ttl_s": {"weather": config.WEATHER_TTL, "mao": config.MAO_TTL, "btc": config.BTC_TTL, "device": config.DEVICE_TTL},
"fetch_budget_s": config.FETCH_BUDGET, "inflight": sorted(STATE.inflight.keys()),
"image_path": config.IMAGE_PATH, "html_path": config.HTML_PATH,
"html_png_path": config.HTML_PNG_PATH, "tz": config.TZ_NAME}
self._log(200)
return self._json(200, st)
if path in ("/push/usage", "/push/usage_gpt"):
if not self._authed():
self._log(401); return self._json(401, {"ok": False, "error": "bad token"})
kind = path.rsplit("/", 1)[1]
with STATE.lock:
raw, at = STATE.raw[kind], STATE.pushed_at[kind]
self._log(200)
return self._json(200, {"ok": True, "kind": kind, "pushed_at": at, "raw": raw})
if path == "/favicon.ico":
return self._send(204, "image/x-icon", b"")
self._log(404)
self._json(404, {"ok": False, "error": "not found"})
except Exception as e: # noqa: BLE001 —— 任何渲染异常都返回 500 而不是断开连接
self._log(500, repr(e))
self._json(500, {"ok": False, "error": repr(e)})
do_HEAD = do_GET
# ---------- POST推送 ----------
def do_POST(self):
path = self.path.split("?", 1)[0]
if path not in ("/push/usage", "/push/usage_gpt"):
self._log(404); return self._json(404, {"ok": False, "error": "not found"})
if not self._authed():
self._log(401); return self._json(401, {"ok": False, "error": "bad token"})
try:
n = int(self.headers.get("Content-Length") or 0)
if n <= 0 or n > 64 * 1024:
raise ValueError(f"bad content-length {n}")
raw = json.loads(self.rfile.read(n).decode("utf-8"))
if not isinstance(raw, dict):
raise ValueError("body must be a JSON object")
except Exception as e: # noqa: BLE001
self._log(400, repr(e)); return self._json(400, {"ok": False, "error": f"bad json: {e}"})
kind = path.rsplit("/", 1)[1]
STATE.push(kind, raw)
self._log(200, f"push {kind} keys={sorted(raw)[:6]}")
self._json(200, {"ok": True, "kind": kind, "received_at": time.time()})
def main():
if not config.PUSH_TOKEN:
print("[警告] PUSH_TOKEN 未配置:将拒绝一切推送,用量区会一直显示 \"--\"")
srv = ThreadingHTTPServer((config.SERVER_HOST, config.SERVER_PORT), Handler)
print(f"服务已启动 http://{config.SERVER_HOST}:{config.SERVER_PORT} 方案一 {config.IMAGE_PATH} 方案二 {config.HTML_PATH} "
f"方案三 {config.HTML_PNG_PATH} /health", flush=True)
try:
srv.serve_forever()
except KeyboardInterrupt:
pass
if __name__ == "__main__":
main()

62
usage_common.py Normal file
View File

@@ -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_TTL0=每次实拉)→ 直接用缓存,不发请求;
- 拉取失败 → 沿用旧缓存(标 stale并按 BACKOFF 秒退避,退避期内不再尝试;异常对象带 `backoff` 属性时按它退避
claude_usage.TokenUnavailable = 一个 TTLtoken 过期是本机状态、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}")