Compare commits

14 Commits

Author SHA1 Message Date
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
17 changed files with 1891 additions and 198 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,30 @@ QWEATHER_HOST=your-host.re.qweatherapi.com
QWEATHER_KEY=
WEATHER_LOCATION=118.788,32.014
WEATHER_TTL=900
# ---- 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=
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
# 服务端server.py三路上游的内存缓存有效期0=每次实拉):天气/BTC 复用 WEATHER_TTL/BTC_TTL橘喵实时用 MAO_TTL
MAO_TTL=60

1
.gitignore vendored
View File

@@ -3,5 +3,6 @@ __pycache__/
*.pyc
.weather_cache.json
.trend_cache.json
.btc_cache.json
run.sh
.env

View File

@@ -6,46 +6,71 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
`eink-push` 是 JM monorepo 中一个**独立的 Python 工具**,与其余 Java/Vue 子项目技术栈无关,不受父级 CLAUDE.md 的 jm-cloud/uniapp/admin 开发约束。
作用:把橘喵今日经营、近30天趋势、日期与天气合成为一张 **400×300 纯黑白1bit** PNG推送到 Zectrix 极趣云墨水屏设备(`zectrix-s3-epaper-4.2`)。纯 Python + Pillow**无浏览器依赖**,适合 cron 定时运行。
作用:把日期天气、Claude Code 用量、橘喵今日经营合成为一张 **400×300 三色(黑/白/红BWR** PNG推送到局域网墨水屏设备喵喵固件开源https://gitee.com/gxp666111/miaomiao)。纯 Python + Pillow**无浏览器依赖**,适合 cron 定时运行。
> **分支 `feature/colorful`**:新设备在黑白外支持红色,本分支把渲染升级为黑/白/红三色。当前版式为**上中下三段**A 大时钟+日期 / 天气定位南京·江宁B **Claude Usage** 主角5h/7d 用量进度条 + 时间维度对比 pace用量吃紧转红预警C 橘喵今日实时三列。红作**语义强调**(用量预警、涨、定位、天气太阳/闪电),仍**无真灰阶、不抖动**全图只三种纯色。近30天趋势图当前**隐藏**`renderer._trend_chart` 及依赖保留、未调用,随时可挂回)。
> **分支 `feature/reterminal-e1002`**(基于 `feature/colorful`):新增第二个目标设备 **Seeed reTerminal E1002**7.3 寸 **800×480**E Ink **Spectra 6 原生六色**:黑/白/黄/红/绿/蓝,真机已验证六色量化干净)。该设备不接受局域网直推,而是由 **SenseCraft HMI** 云端的 **HTML 控件**按设备刷新间隔(最小 5 分钟)**每周期重新抓取**一个公网 HTTPS URL、无头浏览器截图后量化下发不支持 ETag/304内容不变也会整屏重绘全刷约 17 秒)。**真机验证2026-09**Image 控件只在填写 URL 时抓一次快照、此后不再更新HTML 控件填同一 URL 则每周期重抓——所以对外只用 HTML 控件。当前主形态是 **`server.py` 动态服务**(部署公网 HTTPS局域网地址/明文 HTTP 云端抓不到):三条恒定 URL 并行供对比——方案一 `IMAGE_PATH`(默认 `/e1002.png`)每次请求**内存中现渲染 PNG**Pillow 点阵版);方案二 `HTML_PATH`(默认 `/e1002.html`)每次请求生成 **800×480 现代网页**`html_renderer.py`,系统/网络字体 + 内联 SVG K 线,无 JS由平台截图——**真机实拍:文字抗锯齿灰边被平台抖动成毛边、小字与复杂汉字发糊**;方案三 `HTML_PNG_PATH`(默认 `/e1002-web.png`)同一网页由**服务端无头 Chrome 截图后按最近色(不抖动)固化为六色 PNG**`html_shot.py`),平台拿到纯色图无从抖动,需服务器装 Chrome。**结论2026-09-10方案一为主力**——方案二/三的矢量字体在 125 PPI 下小字必然毛糙(抗锯齿灰边无论由平台还是我们二值化都只能变成锯齿/断笔,见 `output/e1002/font_compare.png`:矢量字体单色渲染 ≈ 抗锯齿后二值化都不如点阵Canvas 逐像素画也改变不了这点Pillow 本身就是逐像素画)。方案二/三端点保留供对比,不再投入。宽版**只用点阵字体的整数倍字号 12/24/36/48/84**`E1002Renderer.__init__` 把基类的 F13/F16/FPACE 都映射到 12px——用量行 标签常规字重、右对齐贴进度条(基类类属性 `LABEL_BOLD`/`LABEL_ALIGN`/`LABEL_GAP`),百分比与 pace 数字粗体24px 试过被嫌大;另加 F84 给顶部大时钟),可用 `all(v % 12 == 0 for v in 字体尺寸)` 自查。天气/橘喵/BTC 走**服务端内存缓存 + 有效期**`WEATHER_TTL`/`MAO_TTL`/`BTC_TTL`,默认 900/60/300 秒0=每次实拉;请求时过期才实拉并更新时间戳,失败沿用旧值、不更新时间戳),服务**不读本机任何文件**Claude/ChatGPT 用量**由本机 `main.py --target push` 推送原始快照**`X-Push-Token` 鉴权),服务端内存暂存、渲染时现算 pace。静态形态 `main.py --target e1002``output/e1002/index.html` + `dashboard.png`)仍保留。渲染由 `renderer_e1002.py` 完成:继承 `DashboardRenderer` 组件、**k=1 字号与三色版完全相同(不放大,放大后字太大很傻)**四段版式A 时钟/天气B 左 **Claude Usage 三行all 5h / all 7d / fable 7d标签沿用 statusline 写法、小写开头)**、右 **ChatGPT Usagemock标「示例数据」**紧凑单行样式曾试过撑满高度的大卡片被否C **橘喵·今日实时**五列全宽探店单量探店流水团购单量团购流水毛利2026-09-11 接口新增团购字段后由三列改五列;列内四行逐行居中:名 F12 / 值 F24 粗 / 环比 / 同比,与小屏同构——五列每列仅 ~157px此前「值 F36 粗 右侧叠放的环比/同比」并排组约 194px 放不下、降 F24 后 156px 仍贴分隔线故改竖排更早试过名与值同行、左上角角标均已否D **BTC/USDT 时 K 蜡烛图(近 48 小时 = 2 天2026-09-14 由 72 根改 48 根)**免费公开行情OKX 主源、Coinbase/Huobi 备源,`btc_api.py`5 分钟缓存;周期/根数 `BTC_BAR`/`BTC_LIMIT` 可配,日 K 也支持;**涨绿跌红**)。此前放过 30 天折线图(用户明确不要)和「近 7 天」数据表(已被 K 线替换。六色语义用量进度条含边框、百分比数字三者同色——2026-09-14 用户要求百分比随条色)与 pace 各自**三档、颜色顺序固定 绿→黑→红、两者独立取色**:用量 ≤ `USAGE_OK` 30 绿「健康」3080 黑「正常」|≥ `USAGE_WARN` 80 红「危险」pace ≤ `PACE_TOL` 10 绿「节余」±10 内黑「持平」|≥ +10 红「偏快」。**黄不用**2026-09-14 曾按要求加过 60%80% 黄档(含 `USAGE_NOTICE`/`PACE_WARN` 两个阈值),同日被否并整体移除,别再加回。红另用于涨/定位/太阳闪电,蓝=雨雪。
## 命令
```bash
pip install -r requirements.txt # 仅 Pillow>=10.0urllib 用标准库
cp .env.example .env # 首次:复制模板并填入真实凭证 + 设备 MAC
python3 main.py # 取数据 → 渲染 → 推送(设备 ID 取自 .env
cp .env.example .env # 首次:复制模板并填入真实凭证 + 设备 host
python3 main.py # 取数据 → 渲染 → 推送(设备 host 取自 .env 的 EPD_HOST
python3 main.py --render-only # 只渲染到 output/dashboard.png不推送本地调试渲染时用这个
python3 main.py --target e1002 # reTerminal E1002 静态形态:渲染 800x480 六色图 + 固定尺寸 index.html 到 output/e1002/,不推送
python3 server.py # reTerminal E1002 动态服务部署公网GET /e1002.png内存现渲染 PNG/ GET /e1002.html现代网页/ GET /e1002-web.png网页截图固化六色/ /health
python3 main.py --target push # 本机:把 ~/.claude/usage-snapshot.json 推送到 server.py需 .env 的 PUSH_URL + PUSH_TOKEN
python3 probe_server.py # 验证工具:同一 URL 是否被平台周期重抓(带访问日志)
sh run.sh # cron 包装脚本cd 项目目录 + vfox python 绝对路径 + 追加日志到 output/cron.log
# 临时覆盖:真实环境变量优先级高于 .env
ZECTRIX_DEVICE_ID=AA:BB:CC:DD:EE:FF python3 main.py
EPD_HOST=epd400a44.local python3 main.py
```
无测试、无 lint。调试渲染效果就跑 `--render-only``output/dashboard.png`
**配置统一走 `.env`**:所有凭证/参数放同目录 `.env`(已在 `.gitignore`,本机专用;模板见已提交的 `.env.example`)。`config.py` 顶部用零依赖加载器 `_load_dotenv()``.env` 写入 `os.environ`,再由各 `os.environ.get(...)` 读取;采用 `setdefault`,故**真实环境变量cron/命令行注入)优先级高于 `.env`**。`config.py` 内不再保留任何明文凭证fallback 为空串)。可配置项:`ZECTRIX_API_KEY`/`ZECTRIX_DEVICE_ID``JM_BASE_API`/`JM_CLIENT_ID`/`JM_CLIENT_SECRET`/`JM_GRANT_TYPE`/`JM_TENANT_ID``QWEATHER_HOST`/`QWEATHER_KEY`/`WEATHER_LOCATION`/`WEATHER_TTL`
**配置统一走 `.env`**:所有凭证/参数放同目录 `.env`(已在 `.gitignore`,本机专用;模板见已提交的 `.env.example`)。`config.py` 顶部用零依赖加载器 `_load_dotenv()``.env` 写入 `os.environ`,再由各 `os.environ.get(...)` 读取;采用 `setdefault`,故**真实环境变量cron/命令行注入)优先级高于 `.env`**。`config.py` 内不再保留任何明文凭证fallback 为空串)。可配置项:`EPD_HOST`(局域网设备 mDNS host默认 `epd400a44.local``JM_BASE_API`/`JM_CLIENT_ID`/`JM_CLIENT_SECRET`/`JM_GRANT_TYPE`/`JM_TENANT_ID``QWEATHER_HOST`/`QWEATHER_KEY`/`WEATHER_LOCATION`(默认南京·江宁)/`WEATHER_TTL``USAGE_LOCAL_PATH`/`USAGE_WARN`、仅宽版的 `USAGE_OK`/`PACE_TOL`/`BTC_BAR`/`BTC_LIMIT`/`BTC_TTL`/`MAO_TTL``E1002_COLOR_STRIP`
**定时部署**`run.sh` cron 入口(已在 `.gitignore`本机专用。cron 不继承登录环境,故脚本内 python 写死 vfox 绝对路径、并 `cd` 到项目目录后追加日志到 `output/cron.log``cd``config.py` 才能就近找到 `.env`)。设计为**每分钟**触发:业务数据每次都刷新,天气/趋势靠缓存避免高频打接口(见 `data.py`)。
**定时部署**由 macOS **launchd LaunchAgent** 触发 `run.sh`(不再用 cron——cron 在现代 macOS 上易被 TCC/权限静默拦截launchd 是系统原生调度器。LaunchAgent plist 装在 `~/Library/LaunchAgents/com.jm.eink-push.plist`(机器专用,未入库,等价于 `run.sh`/`.env` 的本机产物),`StartInterval=300` + `RunAtLoad=true`:登录/重启后立即跑一次,此后每 5 分钟一次。用 LaunchAgent非 LaunchDaemon是因为脚本依赖当前用户目录下的文件`~/.claude/usage-snapshot.json``.env`),必须在用户会话下运行——机器需已登录(或开了自动登录)才能保证开机即触发。`run.sh` 内部逻辑不变cron 不继承登录环境,故脚本内 python 写死 vfox 绝对路径、并 `cd` 到项目目录后追加日志到 `output/cron.log``cd``config.py` 才能就近找到 `.env`launchd 自身的 stdout/stderr 另落 `output/launchd.log`(正常应为空,非空说明进程本身起不来,而非业务报错)。常用排查:`launchctl list | grep com.jm.eink-push` 看是否常驻及最近退出码,`launchctl kickstart -k gui/$(id -u)/com.jm.eink-push` 手动立即触发一次;改 plist 后需 `launchctl bootout` + `bootstrap` 重新加载才生效(热改文件不会自动生效)。每 5 分钟触发:业务数据每次都刷新,天气靠缓存、用量读本地文件,避免高频打接口(见 `data.py`)。
## 架构(数据 → 渲染 → 推送 三层)
`main.py` 串联三层,各层职责单一、低耦合:
- **`data.py`** — 唯一数据入口 `get_dashboard_data()`,返回固定结构的 dict。日期/时间用真实系统时间;橘喵今日经营+趋势`_mao()``jm_api`、天气由 `_weather()``weather_api`(和风,定位南京·秦淮)拉真实数据**任何接口异常都回退占位 `"--"`,不回退 mock**。改数据保持返回结构不变即可renderer 依赖其 key,尤其 `mao.trend.series` 每条须带 `style``weather.icon` 为和风图标代码;环比/同比 `(direction, text)`direction 为 `None` 时不画涨跌三角)。
- **两套文件缓存**(为 cron 每分钟独立进程而设,必须落盘):天气缓存 `.weather_cache.json`TTL=`WEATHER_TTL`,默认 15min;未过期不发请求);趋势缓存 `.trend_cache.json`(按日期 key当天命中即不再调 `recent-days`,每天只拉一次)。两者**拉取失败沿用旧缓存**避免整片 `"--"`,仍无缓存才回退占位。两个缓存文件已在 `.gitignore`
- **`data.py`** — 两种形态:本机 `get_dashboard_data()`(带文件缓存,用量读本地快照);服务端用纯拉取函数 `weather_fetch()`/`mao_fetch()`/`btc_fetch()` + `usage_from_raw(now, raw, windows, title)``_usage(now, raw=)`/`_usage_gpt(now, raw=)` 都基于它raw=None 时前者读本地快照、后者用 `gpt_mock_raw()`)。所有「现在」用 `now_tz()`、时间戳格式化用 `_local()``TZ=ZoneInfo(config.TZ_NAME)`),本机与境外服务端结果一致。返回固定结构的 dict`date`/`weather`/`mao`/`usage`/`usage_gpt`/`btc`。日期时间用真实系统时间;橘喵今日经营由 `_mao()``jm_api`、天气由 `_weather()``weather_api`(和风,定位南京·江宁、Claude 用量由 `_usage()``usage_local`**任何异常都回退占位 `"--"`,不回退 mock**。改数据保持返回结构不变即可renderer 依赖其 key`weather.icon` 为和风图标代码;`mao.cols[]``{k, v, hb, tb, wide}`,五列由 `REALTIME_COLS`(标签、接口字段前缀、格式化、是否仅宽版)驱动,`_col_from()``.get()``today{Base}`/`{base}WowRate`/`{base}YoyRate`,字段缺失(旧后端未上团购)该列占位 `--``wide=True`(团购两列)小屏 `render()` 跳过、宽版全显;`hb/tb` 为环比/同比 `(direction, text)`direction 为 `None` 时不画三角;接口文档见 `../docs/首页看板接口文档-20260911.md`(毛利口径已含团购、金额与增长率为字符串、增长率可 `null``usage.bars[]``{k(短标签,小屏用), label(长标签 all 5h/fable 7d宽版用), pct(或 None), time_pct?, reset?, scoped}``usage.warn`/`usage.ok`/`usage.pace_tol` 为用量与 pace 的三档阈值(小屏只用 warn`usage.updated` 为快照落盘时刻;`usage_gpt``usage` 同构,目前是 **mock**`mock: True`;三行故意取 88/71/22 以常驻红/黑/绿三档),接真实数据源时替换 `_usage_gpt()` 即可)。注:`_mao` 仍会拉 `mao.trend`,但两个版面当前都不用它。宽版另有 `btc``_btc()``btc_api.get_candles(BTC_BAR, BTC_LIMIT)` 取 K 线(默认 1H×48结构 `{symbol, bar, src, candles:[{t, o, h, l, c}], last, chg, chg_label}`chg 时 K 取相对 24 根前收盘24h、日 K 取相对上一根;缓存 `.btc_cache.json`TTL=`BTC_TTL` 默认 5 分钟,周期变更即失效,失败沿用旧缓存,仍无则 candles 为空 → 面板 `--`)。
- **缓存**(为 launchd 每 5 分钟独立进程而设,必须落盘):天气 `.weather_cache.json`TTL=`WEATHER_TTL`,默认 15min)、趋势 `.trend_cache.json`(按日期 key每天只拉一次、BTC K 线 `.btc_cache.json`TTL=`BTC_TTL`,默认 5min三者拉取失败沿用旧缓存**用量不自建缓存**——直接读 statusline 生产的本地快照 `~/.claude/usage-snapshot.json`(见 `usage_local`),失败即 `"--"`。两个缓存文件已在 `.gitignore`
- **用量的 pace时间维度对比**`_usage()` 每次按当前时间**现算** `time_pct=(窗口已流逝/窗口长)``pace=pcttime_pct`>0 超前↑ / <0 节余↓);故即使 `utilization` 来自可能滞后的本地缓存pace 仍随时钟准确推进5h 窗口 18000s7d 窗口 604800s
- **`jm_api.py`** jm-devops 后端统计接口客户端**设备密钥授权**`grant_type=client_secret`免验证码)。先用 `CLIENT_ID + CLIENT_SECRET` `POST /auth/login` token进程内缓存 `expire_in` 提前刷新再带 `Authorization: Bearer <token>` + `clientid` 头调 `/system/statistics/{realtime,recent-days}` 401 token 重登一次重试token 字段为蛇形 `access_token`/`expire_in`历史上曾免鉴权直调更早曾用账号密码+验证码 OCR 登录ddddocr 不稳定现统一为 client_secret 授权——密钥即长期凭证务必走 HTTPS优先用 `JM_CLIENT_SECRET` 环境变量可在后端 `sys_client` 轮换
- **`weather_api.py`** 和风天气QWeather客户端**用户专属 API Host**`QWEATHER_HOST`+ `X-QW-Api-Key` 头鉴权响应 **gzip 压缩** magic number 手动解压)、返回 `code` **字符串**。`get_now()` 取实时`now.text` 中文天气 / `now.temp` / `now.icon` 图标代码`get_today()` `/3d` `daily[0]` 今日温区`QWEATHER_HOST`/`QWEATHER_KEY` 未配置时直接抛错天气区显示 `"--"`
- **`renderer.py`**`DashboardRenderer.render(data)` 返回 PIL `'L'` 模式纯 0/255 图;`render_to_file()` 落盘。布局硬编码为四区render 内 A/B/C/DA 大时钟+日期 / B 天气 / C 橘喵今日三列 / D 近30天趋势折线图。趋势线用 `_styled_polyline()` 按 solid/dashed/dotted 区分(设备无颜色),各指标按自身极值独立归一化(故只看趋势形状、不可横向比绝对值)。
- **`btc_api.py`** 比特币 K 线客户端免费公开接口无需密钥`get_candles(bar, limit)`周期代号 `BARS = {"1H", "1D"}` 映射到各源写法逐源兜底 `SOURCES = (okx, coinbase, huobi)`OKX `/api/v5/market/candles?instId=BTC-USDT&bar=`主源本地直连可达且最新)、Coinbase `/products/BTC-USD/candles?granularity=`Huobi `/market/history/kline?period=`各源字段顺序不同OKX/Huobi 最新在前Coinbase [time, low, high, open, close]统一归一为升序 `{ts, o, h, l, c}`Binance 在本地区返回 restricted location不列入
- **`usage_local.py`** Claude Code 用量本地读取**不发任何网络请求****生产者/消费者**模式只读 `config.USAGE_LOCAL_PATH`默认 `~/.claude/usage-snapshot.json`)。**生产者 = 打过补丁的 claude-statusline**本机 `~/.claude/statusline.sh`把每次 Claude Code stdin 喂来的**权威实时**额度落盘为该快照schema `/api/oauth/usage``five_hour`/`seven_day` `utilization` + `resets_at`并额外写入 **`seven_day_fable`**Fable 模型限定的 7 天额度——stdin 里没有只在 `/api/oauth/usage` 返回的 `limits[]` `scope.model.display_name == "Fable"` 那项statusline stdin 分支也会每 5 分钟刷新一次 API 缓存 `/tmp/claude/statusline-usage-cache.json` 以拿到它 token/断网时该键为 `null``data.USAGE_WINDOWS` `scoped=True` 标记这一行400×300 小屏 `render()` 跳过 scoped 800×480 宽版显示本模块作消费者读取活跃使用时快照几乎持续刷新准确空闲时停在最后一次文件缺失/损坏用量区 `"--"``resets_at` 可能是 **epoch 数字**Claude Code stdin 给的就是 epoch ISO `data._parse_reset_ts` 两者兼容。(曾评估直调 `/api/oauth/usage`改为读本地快照以免网络请求。)
- **`renderer.py`** `DashboardRenderer.render(data)` 返回 PIL `'RGB'` 模式、**仅黑//红三色**组件层提供可覆写钩子/参数供宽版复用`_usage_colors()`用量行 填充/边框/文字 三色元组)、`_pace_color()`pace 数值与三角颜色)、`_usage_row(label_w=)``_weather_icon(wet=)`降水元素颜色)、`_label(x0,x1)``_cmp_at()`环比/同比左对齐版`_cmp_line` 居中版基于它字体另有 `F36`3 倍整数缩放)。`render_to_file()` 落盘布局硬编码为**上中下三段**两道 `hdash` 横规分隔y=90 / 200
- **A 顶部**090大时钟(F48)+日期/ 天气红定位水滴 + 温度 + 天气·温区 + 全矢量图标)。
- **B 中部主角 Claude Usage**`_label` 标题 + 两条 `_usage_row`每条单行 = 左标签 `5h`/`7d`(F13) + 粗进度条 + 百分比(F16) + 时间维度对比 pace(F13`_pace`)`pct≥warn`该条进度条(含边框)与百分比转红预警pace 用上/下三角替代 +/超前 / 节余↓)。整行标签·进度条·百分比·三角 `_mid()` 按墨迹竖直中心对齐到同一中线
- **C 底部 橘喵今日实时**`_label` + 三列——过滤掉 `wide` 只显示探店单量/探店流水/毛利版式对齐主分支`■ 橘喵·今日实时` + `上次更新`列内 (F12)/(F24 )/环比·同比(`_cmp_line`)相对标题偏移 +18/+34/+64/+82底部留 ~11px 下边距
- **红色点缀系统**改配色务必克制模块顶部 `BLACK/WHITE/RED``RED=(255,0,0)` 是给设备红通道的明确信号红只用在:① 定位水滴`_loc_pin(fg=RED)`)② 天气太阳/闪电`_weather_icon(accent=RED)`////月仍黑)③ 用量进度条与百分比(`pct≥warn`) + pace 超前(↑) 环比/同比的三角与数值`_cmp_line`/标签仍黑)。**配色铁律**红与黑同属深色红叠黑对比极低故红只压白底或作大块实心白字压红**绝不红字压黑底**。加红先问是否承载语义」。
- **`_trend_chart`当前隐藏**近30天三指标折线 `_styled_polyline()` solid/dashed/dotted 区分主指标(流水)红线各指标按自身极值独立归一化已保留但 `render()` 未调用想恢复即在 C 前挂回
- **天气图标全矢量手绘**无图片资源`_weather_category()` 把和风 icon 代码归并为 8 sunny/clear_night/cloudy/overcast/thunder/rain/snow/fog识别不了回退 cloudy再由 `_weather_icon()` `_sun`/`_cloud` 等基元按 1bit 描边画出改天气展示从这里动
- **`pusher.py`**`push_image()` 走 Zectrix API`POST /devices/{id}/display/image`,手写 multipart。设备 ID 直接取 `config.DEVICE_ID`(即 `.env``ZECTRIX_DEVICE_ID` 硬件 MAC`resolve_device_id()` 仅校验非空、未配置即报错——**已废弃获取设备列表再取第一个的逻辑**(原 `get_devices()` 已删除,不再调 `GET /devices`)。
- **`pusher.py`** 直连局域网墨水屏设备喵喵固件开源https://gitee.com/gxp666111/miaomiao`web/` 即设备自带管理页可浏览器打开 `http://<EPD_HOST>/` 调试同一套接口)。`push_image()` 先调 `GET /images` 拿画廊已存图片名单逐个 `POST /delete_image?name=`清空单张删除失败不阻塞后续 `POST /upload``Content-Type: image/png`body 为原始 PNG 字节 multipart触发设备转换+刷屏。**清空画廊是刻意设计**设备把每次 `/upload` 都追加存一份到画廊`GET /images` 可见不清理会越推越多且设备当前 `current_mode` slideshow 会轮播到旧图先清后传使画廊恒为 1 等价于始终静态展示最新一张。**清理非核心**拉取画廊列表本身失败也不阻塞后续 `/upload`跳过清理直接推)。`_request()` 对连接类异常`urllib.error.URLError` mDNS `.local` 域名解析瞬时失败内置重试`RETRY_TIMES=2`间隔 `RETRY_DELAY=2s`)——mDNS 基于组播 UDP偶发丢包很正常单次失败不该让整轮推送直接跳过历史上曾走 Zectrix 公网 API`POST /devices/{id}/display/image` + 设备 MAC + `ZECTRIX_API_KEY`现统一为局域网直连
- **`renderer_e1002.py`** reTerminal E1002 目标`E1002Renderer(DashboardRenderer)` **k=1** 复用组件字号 12/24/48 与三色版一致只重写版式与配色钩子基类抽出类属性 `PCT_TRACK`宽版置 0——百分比已是整数倍字号不需手动加字距基类另抽 `FPACE` pace 字体与标签 F13 解耦)。四段三道横规各段 y 由上一段实际底部推算A 顶部 090 **大时钟 F84 粗 3** `getbbox` 把墨迹顶沿放到 16底沿 79占满整段且保住原上//下边距星期/日期 F12 紧随其右 14px按日期数字底沿与时钟**基线对齐**2026-09-14 此前 F48 + 日期另起一行天气右 x=790B 用量 `_usage_block`×2 `usage` 10386竖规 x=400 `usage_gpt` 414790`_label` 标题 + 右侧上次更新 HH:MM」(与今日实时同款文案 mock 示例数据」,行用基类紧凑 `_usage_row`行距 31宽版把 `bar["label"]` 长标签代入 `k``label_w` 按本块最长标签自适应C `_realtime` 今日实时五列全宽`mao.cols` 全部 `wide` 列内四行 `ctext`/`_cmp_line` 逐行水平居中—— F12 y0+21 / F24 y0+38 / 环比 y0+72 / 同比 y0+88按墨迹框标题 ~9px——2026-09-14 整列下移 4px ~5px 用户觉得挤 ~9px环比 ~6px竖规 y0+26102区块底 y0+104D `_btc_chart` BTC K 线`data.btc`标题按 `bar` 显示时K/日K」:右侧最新价 F12 + 口径 `chg_label`24h/1d+ 涨跌三角 + 涨跌幅涨绿跌红每根蜡烛 1px 影线 + 实心实体槽位 60% 取奇数)、 绿 否则 右侧 66px 标签列放最高/最低/最新价最新价横贯一道点规标签避让顶底底部基线 + //末时间标签 `t`无数据居中 `--`)。用量块 `_usage_block` 返回末条进度条底部B/C 分割线取其 +16px用户反馈太挤)。配色钩子覆写`_usage_colors` 三档(≥`USAGE_WARN` |≤`USAGE_OK` 绿其间黑填充边框百分比文字三者同色)、`_pace_color` 三档(≥+`PACE_TOL` |≤−`PACE_TOL` 绿其间黑阈值由 `usage.ok`/`usage.pace_tol`来自 config `_usage_block` 内注入天气图标 `wet=BLUE` 让雨滴/雪花着蓝可选六色测试色条 `_color_strip``E1002_COLOR_STRIP=1` 打开默认关)。`write_page()` 写出固定 800×480 `index.html`零边距`image-rendering: pixelated`PNG 带时间戳参数防缓存)。**为何固定尺寸而非自适应**云端截图视口/DPR 不可控自适应会重排+抗锯齿产生中间色再被平台抖动固定页里的 1:1 PNG 每个像素本就是六色之一截图零损失——已用无头 Chrome `--window-size=800,480` 截图验证与 PNG 逐像素一致设备端 HTML 控件须铺满整个画布历史均已弃用k=2 整体放大版;「左用量/右今日实时/ 30 天折线;「用量大卡片撑满
- **`server.py`** E1002 动态服务stdlib `ThreadingHTTPServer`无第三方依赖)。`build_data()` `(data, 各路状态)`日期按 `config.TZ_NAME` 时区现算三路上游经 `_cached(key, ttl, fetch, fallback)`——`STATE.last_good[key]` 未过期`time - STATE.fetched_at[key] < ttl`直接用过期才调 `data.weather_fetch()`/`data.mao_fetch(now, with_trend=False)`/`data.btc_fetch()`成功更新数据与时间戳失败沿用旧值状态 `stale`)、从未成功用占位`none`日志每行末尾 `[weather:cache(12s) mao:fetched btc:cache(3s)]` 可直接看命中情况`/health` `cache_age_s`/`ttl_s`用量取 `STATE.raw`推送暂存)→ `data._usage(now, raw=…)`/`data._usage_gpt(now, raw=…)`未推送时 Claude `--`ChatGPT mock端点`GET IMAGE_PATH` `render_png_bytes()``_RENDER_LOCK` 串行化Pillow 渲染器非线程安全`GET HTML_PATH` `html_renderer.render_html()``GET HTML_PNG_PATH` `html_shot.screenshot_html()`+`quantize6()``?raw=1` 跳过量化`GET /health` JSON`POST /push/usage|/push/usage_gpt`body=原始快照 JSON,≤64KB`X-Push-Token`==`PUSH_TOKEN`,为空拒绝);`GET /push/*` 查看暂存同令牌)。所有响应 `Cache-Control: no-store`每请求打一行日志 `CF-Connecting-IP`)。`PUSH_STATE_PATH` 非空时推送数据落盘原子替换重启恢复图片永不落盘实测三路全实拉约 1.11.3s天气 0.28s橘喵 0.06sOKX 0.7s 串行全部命中缓存约 0.02s渲染 20ms + 编码 14ms)。
- **`html_shot.py`** 方案三`screenshot_html(html)` 用无头 Chrome`find_chrome()` 自动探测或 `CHROME_BIN`Linux root `CHROME_EXTRA_ARGS=--no-sandbox` HTML 截成 800×480临时目录用后即删`_SHOT_LOCK` 串行化**切勿传 `--user-data-dir` 指向全新目录**——新版 `--headless=new` 对空 profile 首启会卡死到超时实测 A/B/D/E 全超时只有不带它的 C 2 秒完成)。`quantize6(img)`每通道二值化阈值 `HTML_PNG_THRESHOLD` 默认 160偏向保墨迹细笔画浅灰边归黑而非丢白)→ Pillow 调色板 `quantize(dither=NONE)` 映射到 `SPECTRA6`输出保证只含六色服务端 `?raw=1` 返回未量化截图供对照实测单次约 3.3sChrome 启动为主)。
- **`html_renderer.py`** 方案二网页版 HTML+CSS+内联 SVG JS数据服务端写死字体栈 PingFang/微软雅黑/Noto Sans SC + Google Fonts 兜底平台截图机若无中文字体会掉字能否加载取决于其出网能力颜色只用六色纯值配色规则与 PNG 版一致用量/pace 三档涨红跌黑K 线涨绿跌红版式同构K 线标题右侧价格用 `<strong>``h3 b::before` 的方块装饰只给标题)。
- **`push_client.py`** 本机推送端 `~/.claude/usage-snapshot.json` 原文 `POST {PUSH_URL}/push/usage`直连连接类异常重试 2 4xx/5xx 直接报错ChatGPT 暂无本机源不推将来按 `data.GPT_WINDOWS` 键组 dict `/push/usage_gpt`)。
- **`probe_server.py`** 验证工具不参与正常流程极简 HTTP 服务`/probe.png` `--every` 秒重绘一张带大号时刻+序号的 800×480 探针图响应 `Cache-Control: no-store`每次请求打印时间/UA/来源 IP `CF-Connecting-IP`)。用途配合 `cloudflared tunnel --url http://localhost:8787` 暴露成公网 HTTPS填入 SenseCraft Image 控件**访问日志**判断平台对同一 URL 是否每个刷新周期重抓成立则可直接覆盖同一 URL 的图片省掉 HTML 页那一层)。**结论已验证**Image 控件不重抓HTML 控件重抓见分支说明
- **`config.py`** 顶部 `_load_dotenv()` 先加载同目录 `.env`再定义 API设备画布尺寸字体路径推送参数凭证 fallback 为空串真实值来自 `.env`/环境变量
## 墨水屏渲染约束(改 renderer.py 必读)
设备是 **1bit、无真灰阶**——灰阶只能抖动成网点(难看),所以全程纯黑白,靠构图/字号/留白建立层级,**不要引入灰色填充或抖动**
设备是 **三色(黑/白/红)、无真灰阶**——灰阶只能抖动成网点难看所以底色只用黑白 + 红作稀缺强调靠构图/字号/留白建立层级**不要引入灰色填充或抖动**
- 字体固定用 **Fusion Pixel 12px 点阵字体**`fonts/`OFL 协议),只按整数倍尺寸 **12/24/48** 渲染(`F12/F24/F48`),并设 `d.fontmode = "1"` 关闭抗锯齿,否则像素会糊。
- **红只作语义强调**方向)、警示闪电)、强调定位/主折线红占墨保持很小<10% 墨迹滥用会稀释注意力破坏层级整图**只允许三种纯色** `(0,0,0)/(255,255,255)/(255,0,0)`——渲染后可用 `img.getcolors()` 自查出现第四种色多为抗锯齿灰边即为 bug矢量图元(line/ellipse/polygon/arc)默认不抗锯齿故为纯色文字靠 `fontmode="1"` 保持纯色
- **`DashboardRenderer(k=…)` 缩放系数**所有组件`_label`/`_cmp_line`/`_usage_row`/`_pace`/`tri` 的像素常量与伪粗体偏移都乘 `k`字体按 `12*k/24*k/48*k` 创建`k=1` 400×300 原版改组件后务必用固定夹具比对 `k=1` 输出逐像素不变E1002 版当前用 `k=1`k=2 整体放大在 800×480 上字过大已否决k 参数保留供更高分辩率设备使用字体属性名`F12/F24/F48`表达层级而非绝对像素
- 字体固定用 **Fusion Pixel 12px 点阵字体**`fonts/`OFL 协议首选整数倍尺寸 **12/24/48**`F12/F24/F48`最锐利用量区为拿到合适的字号层级另用了 **F13/F16**1.08x/1.33x 非整数倍笔画略不均属有意取舍 `d.fontmode = "1"` 关抗锯齿仍保持纯色不糊跨字号竖直居中统一用 `_mid()` `getbbox` 墨迹中心对齐)。
- 加粗用伪粗体」:`_draw()` 1~bold px 水平偏移叠绘笔画点阵字体只有单一字重)。
- 推送时 `DITHER = False`硬阈值纯黑白图最锐利勿改成 `true`
- 坐标字号均为像素网格上的硬编码常量调布局时注意各区分隔线 `hdash` y 值与下方组件位置联动
@@ -53,4 +78,28 @@ ZECTRIX_DEVICE_ID=AA:BB:CC:DD:EE:FF python3 main.py
## 注意
- 所有凭证均在 `.env`不提交`config.py` 不再内置明文 fallback分享代码时给出 `.env.example` 即可真实 `.env` 切勿入库
- `.gitignore` 已忽略`output/``__pycache__/``*.pyc`两个缓存文件 `.weather_cache.json`/`.trend_cache.json`本机专用的 `run.sh`含凭证的 `.env`模板 `.env.example` 入库)。改缓存逻辑后想强制重拉删掉对应缓存文件即可
- `.gitignore` 已忽略`output/``__pycache__/``*.pyc`两个缓存文件 `.weather_cache.json`/`.trend_cache.json`/`.btc_cache.json`本机专用的 `run.sh`含凭证的 `.env`模板 `.env.example` 入库)。改缓存逻辑后想强制重拉删掉对应缓存文件即可
## 验证与运维手法(会话沉淀)
- **像素回归** `renderer.py` 组件后用固定夹具比对 HEAD 版与工作区版 `DashboardRenderer` k=1 输出逐像素相同
`git show HEAD:renderer.py > /tmp/orig/renderer.py`切换 `sys.path` 分别 import 渲染`ImageChops.difference(a,b).getbbox() is None`
六色版渲染后 `set(img.getcolors())` 必须 `SPECTRA6`夹具需覆盖pct=None、≥warn 红档、≤ok 绿档direction=None、空 candles
- **HTML截图逐像素核验**`"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" --headless=new --disable-gpu --hide-scrollbars --force-device-scale-factor=1 --window-size=800,480 --screenshot=/tmp/s.png file://…`**勿加指向空目录的 `--user-data-dir`**卡死到超时)。
- **server.py 本机运维**启动 `nohup env -u HTTPS_PROXY -u https_proxy -u HTTP_PROXY -u http_proxy -u ALL_PROXY python3 server.py >> output/server.log 2>&1 &`
重启前按 PID 杀干净`for p in $(lsof -nP -iTCP:8790 -sTCP:LISTEN -t); do kill $p; done`——`pkill -f` 曾漏杀 `*:8790` `127.0.0.1:8790` 两个监听可并存旧代码继续应答表现为新端点 404)。
- **cloudflared 快速隧道**免账号地址每次重启都变需到 SenseCraft 更新 URL
`nohup env -u HTTPS_PROXY -u https_proxy -u HTTP_PROXY -u http_proxy -u ALL_PROXY cloudflared tunnel --url http://127.0.0.1:8790 --no-autoupdate --protocol http2 > output/cloudflared.log 2>&1 &`
地址 `grep -oE 'https://[a-z0-9-]+\.trycloudflare\.com' output/cloudflared.log`已写入 `output/tunnel_url.txt`)。
**快速隧道会被 Cloudflare 侧静默回收**2026-09-13 一条跑了约 2 天后失效进程仍在server 仍正常但日志循环报 `Unauthorized: Tunnel not found`公网地址 530
设备收不到数据先看 `output/cloudflared.log` 而不是 server修复只能重启 cloudflared 拿新地址并更新 SenseCraft
重启要点旧进程对 SIGTERM 退出很慢数秒确认 `ps aux | grep "cloudflared tunnel"` 已清空再起新的默认 QUIC 注册曾 `context deadline exceeded` 后自行退出 `--protocol http2` 一次成功
- **shell **本机是 fish——`env -u …` 不能存进变量再当命令执行含中文的 Python heredoc `python3 -X utf8 - <<'EOF'`否则 stdin 解码报错`gh` 未登录且 GitHub API 匿名限流取源码走 `curl raw.githubusercontent.com`
- **statusline 补丁验证**`~/.claude/statusline.sh`备份 `statusline.sh.bak-*`用假 HOME stdin 不污染真实快照——
`echo '<stdin json>' | HOME=/tmp/fakehome bash ~/.claude/statusline.sh` `/tmp/fakehome/.claude/usage-snapshot.json` 是否含 `seven_day_fable`
- **定时任务**`run.sh` 末尾已追加 `main.py --target push`vfox python 绝对路径`|| true`用量随喵喵推送每 5 分钟同步到 server.py推送暂存落盘 `output/push_state.json`
- **上游配额**server 三路上游已有 TTL 内存缓存天气默认 15 分钟 和风约 192 /改有效期只动 `.env`不动代码
- **SenseCraft 未公开后端**从网页前端 bundle 提取`https://sensecraft-hmi-api.seeed.cc` `Authorization: <token>`token localStorage `user-storage`
`POST /api/v1/oss/file/upload`multipart `file`+`type=image``file_url`)→ `POST /api/v2/user/page``{pages:[{name,type:img|url|layout,data,dither,thumbnail,resolution:"800x480"}]}`
`POST /api/v2/user/playlist/upsert_pages` `POST /api/v2/user/device/down_link``{mac_address,playlist_id,type,refresh_interval?,deep_sleep_enabled?}`
另有 `POST /api/v2/user/device/config``POST /render/preview``{url,img_format,resolution,dither}`)。**未采用**未公开可随时变更仅作备选记录

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,14 @@ 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/usage-snapshot.json 到 server.py.env: PUSH_URL + PUSH_TOKEN
```
环境变量可覆盖配置(详见 `config.py`
@@ -50,11 +68,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 +85,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 +107,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`硬阈值三色纯色图最锐利
```

80
btc_api.py Normal file
View File

@@ -0,0 +1,80 @@
#!/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 —— 备源
Binance 在本地区返回 restricted location不列入。
周期用统一代号 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)
BARS = {"1H": ("1H", 3600, "60min"), "1D": ("1D", 86400, "1day")}
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):
j = _get_json(f"https://api.huobi.pro/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"])
SOURCES = (("okx", _okx), ("coinbase", _coinbase), ("huobi", _huobi))
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 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)

View File

@@ -28,24 +28,49 @@ 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 秒
# 真机验证结论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"))
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 +87,31 @@ 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_CACHE_PATH = os.path.join(BASE_DIR, ".btc_cache.json")
# ---- Claude Code 用量Claude Usage 模块)数据源 ----
# 只读本地、**不发任何网络请求**(详见 usage_local.py——生产者/消费者模式:
# 生产者 = 打过补丁的 claude-statusline把每次从 Claude Code stdin 拿到的**权威实时**额度
# five_hour/seven_day 的 utilization + resets_at落盘到 ~/.claude/usage-snapshot.json本项目读它。
# 活跃使用时该快照几乎持续刷新、准确;空闲时停在最后一次。文件缺失时用量区显示 "--"。
USAGE_LOCAL_PATH = os.environ.get("USAGE_LOCAL_PATH", os.path.expanduser("~/.claude/usage-snapshot.json"))
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 红「偏快」

273
data.py
View File

@@ -4,19 +4,38 @@
数据层 —— 汇总仪表盘所需的全部数据。
橘喵今日经营 + 近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 usage_local
import btc_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 +102,7 @@ def _trend(now):
def _weather(now):
"""和风天气(南京·秦淮),带 WEATHER_TTL 文件缓存。
"""和风天气(南京·江宁),带 WEATHER_TTL 文件缓存(本机脚本用)
缓存未过期 → 直接复用,不发请求(每分钟刷新时省掉 14/15 次天气调用)。
过期 → 重新拉取并写缓存;全部接口失败时沿用上次缓存,仍无缓存才回退占位 "--"
@@ -92,6 +111,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,13 +139,7 @@ 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
# ---------- 格式化 ----------
@@ -138,37 +163,234 @@ def _fmt_rate(rate):
return ("down" if v < 0 else "up", 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, v, wow, yoy, wide=False):
"""一列指标。wide=True 表示仅宽版800×480展示400×300 小屏三列放不下、渲染时跳过。"""
return {"k": k, "v": v, "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": "--", "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 宽版,沿用 statusline 的写法
# all 5h / all 7d / fable 7d小写开头。scoped=True 为「按模型限定」的额度Fable 7d来自
# /api/oauth/usage 的 limits[],由 statusline 写入快照 seven_day_fable小屏放不下第三行只在宽版展示。
USAGE_WINDOWS = (
("five_hour", "5h", "all 5h", FIVE_HOUR, False),
("seven_day", "7d", "all 7d", SEVEN_DAY, False),
("seven_day_fable", "Fable 7d", "fable 7d", SEVEN_DAY, True),
)
# ChatGPT 用量窗口键名沿用同一套约定five_hour / seven_day / seven_day_codex推送端照此结构提供原始数据即可。
GPT_WINDOWS = (
("five_hour", "5h", "all 5h", FIVE_HOUR, False),
("seven_day", "7d", "all 7d", SEVEN_DAY, False),
("seven_day_codex", "Codex 7d", "codex 7d", SEVEN_DAY, True),
)
def usage_from_raw(now, raw, windows, title):
"""原始快照 → 渲染结构Claude / ChatGPT 通用)。
raw 形如 {five_hour:{utilization, resets_at}, seven_day:{...}, <scoped 键>:{...}|null, updated_at?}
utilization 为已用百分比resets_at 为 epoch 秒或 ISO缺哪个窗口哪个显示 "--"
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}
tp = _window_time_pct(seg.get("resets_at"), win, now)
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 Code 用量5h / 7d / Fable 7d。raw 为 None 时读本地 statusline 快照(见 usage_local.py**不发请求**
服务端传入推送来的原始快照。utilization 来自 statusline 从 Claude Code stdin 落盘的权威实时值Fable 行来自其转存的 API limits"""
if raw is None:
raw = {}
try:
raw = usage_local.get_usage()
except Exception as e:
print(f"[警告] Claude 用量读取失败:{e}")
return usage_from_raw(now, raw, USAGE_WINDOWS, "Claude Usage")
def gpt_mock_raw(now):
"""ChatGPT 原始快照的 mock尚无真实数据源时用刻意覆盖宽版三档配色——
5h 88%≥warn 红、7d 71%、Codex 7d 22%≤ok 绿scoped与左侧 fable 行对称)。"""
t = now.timestamp()
return {"five_hour": {"utilization": 88, "resets_at": t + 2 * 3600},
"seven_day": {"utilization": 71, "resets_at": t + 3 * 86400},
"seven_day_codex": {"utilization": 22, "resets_at": t + 3 * 86400}}
def _usage_gpt(now, raw=None):
"""ChatGPT 用量。raw 为推送来的原始快照(结构同 gpt_mock_raw为 None 时用 mock 并标 mock=True
renderer 在标题右侧标「示例数据」)。接入真实数据源时由推送端提供 raw本函数无需改动。"""
mock = raw is None
out = usage_from_raw(now, gpt_mock_raw(now) if mock else raw, GPT_WINDOWS, "ChatGPT Usage")
if mock:
out["mock"] = True
return out
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 +399,7 @@ def get_dashboard_data(now=None):
},
"weather": _weather(now),
"mao": _mao(now),
"usage": _usage(now),
"usage_gpt": _usage_gpt(now),
"btc": _btc(now),
}

184
html_renderer.py Normal file
View File

@@ -0,0 +1,184 @@
#!/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、右 ChatGPT Usage橘喵今日实时三列BTC 时 KSVG
- 配色规则与 PNG 版一致:用量条 ≥warn 红 / ≤ok 绿 / 其间黑pace ≥+tol 红 / ≤tol 绿 / 其间黑;环比同比 涨红 跌黑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 = RED if direction == "up" else BLACK
tri = '<i class="tri up"></i>' if direction == "up" else ('<i class="tri dn"></i>' if direction == "down" else "")
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": "ChatGPT 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 用量快照(~/.claude/usage-snapshot.json推送到 server.py
# PUSH_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=把本机用量快照推送到 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()

55
push_client.py Normal file
View File

@@ -0,0 +1,55 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
推送端(本机运行)—— 把本机的用量原始快照 POST 到 server.py服务端暂存、渲染时现算 pace。
推什么:
- Claude~/.claude/usage-snapshot.json 原文statusline 生产,含 five_hour / seven_day / seven_day_fable / updated_at→ POST /push/usage
- ChatGPT暂无本机数据源将来有了按 data.GPT_WINDOWS 的键five_hour / seven_day / seven_day_codex各含 utilization + resets_at
组一个 dict → POST /push/usage_gpt。目前不推服务端用 mock 并标「示例数据」。
鉴权:请求头 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 config
import usage_local
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",
})
last = None
for i in range(RETRY_TIMES + 1):
try:
with urllib.request.urlopen(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}")
def push_usage():
if not config.PUSH_URL or not config.PUSH_TOKEN:
raise SystemExit("[推送] 需在 .env 配置 PUSH_URL 与 PUSH_TOKEN")
raw = usage_local.get_usage() # 缺文件直接抛错——没有快照就没什么可推
resp = _post_json(config.PUSH_URL.rstrip("/") + "/push/usage", raw)
print(f"[推送] Claude 用量快照已推送到 {config.PUSH_URL}{resp}")
return resp
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,38 +1,79 @@
#!/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 text(self, x, y, s, ft, fill=BLACK, bold=0):
self._draw(x, y, s, ft, fill, bold)
@@ -43,16 +84,16 @@ class DashboardRenderer:
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)
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 +104,114 @@ 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
k = self.k
trw, tgap = (8 * k, 4 * k) if direction else (0, 0)
return self._tw(label, self.F12) + 5 * k + trw + tgap + self._tw(val, 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_at(self, x, y, label, pair, fg=BLACK):
"""环比/同比一行,左对齐于 x宽版把它放在数值右侧时用"""
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
# 方向语义配色(红涨):涨 → 箭头与百分比数值一起红;跌/无 → 黑(标签始终黑)
up = direction == "up"
val_color = RED if up else fg
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)
self.tri(x + trw / 2, y + 8.5 * k, 7 * k, up=up, fill=val_color); x += trw + tgap
self.text(x, y, val, self.F12, fill=val_color)
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 _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
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。返回整体宽度供左侧定位。"""
up = diff > 0
color = self._pace_color(diff)
txt = f"{abs(int(diff)):02d}"
tw = self._tw(txt, self.FPACE)
self._mid(xr, cy, txt, self.FPACE, fill=color, bold=self.k, align="r")
if diff == 0:
return tw
TRI_GAP, TRI = 4 * self.k, 6 * self.k
self.tri(xr - tw - TRI_GAP - TRI / 2, cy, TRI, up=up, 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 and "time_pct" in bar:
pace_w = self._pace(x1, cy, pct - int(bar["time_pct"]))
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, 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)
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 +270,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 +300,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 +309,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 +376,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 +388,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 +398,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 +410,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.ctext(cx, 227, c["v"], self.F24, bold=1) # 数值 伪粗体(+34
self._cmp_line(cx, 257, "环比", c["hb"]) # +64
self._cmp_line(cx, 275, "同比", c["tb"]) # +82墨迹止于≈289底部留≈10px
return self.img

284
renderer_e1002.py Normal file
View File

@@ -0,0 +1,284 @@
#!/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占满上下边距内的整段高度 星期/日期 F12 紧随其右、与时钟数字**基线对齐**(左)
|天气(右,贴 790——天气组件与三色版同尺寸。
B 用量96206 :左半 **Claude Usage**all 5h / all 7d / fable 7d竖规 x=400右半 **ChatGPT Usage**mockall 5h / all 7d / codex 7d
行组件沿用三色版的紧凑单行 `_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" # 标签右对齐贴进度条长短标签all 5h / fable 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_colors(self, ok, pct, warn, bar):
"""进度条填充、边框、百分比数字三者同色pct ≤ ok 绿「健康」其间黑「正常」pct ≥ warn 红「危险」。"""
if not ok:
return BLACK, BLACK, BLACK
c = RED if pct >= warn else GREEN if pct <= self._ok else BLACK
return c, c, c
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
# ---------- 组件 ----------
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", [])] # 宽版用长标签all 5h / fable 7d
label_w = max([self._tw(b["k"], self.F13) for b in bars] + [26]) + self.LABEL_GAP
y = y0 + 27
for b in bars:
self._usage_row(x0, x1, y, b, warn, label_w=label_w)
y += 31
return y - 31 + 18 # 末行进度条底部cy=y+10条高 15 → 底 y+17
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.ctext(cx, VAL_Y, c["v"], self.F24, bold=1)
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); xr -= self._tw(pct_txt, self.F12) + 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 占满整段(左)+ 星期/日期紧随其右、与时钟数字基线对齐 / 天气(右)----
DIV_Y = 90
TIME_BOLD, INK_TOP = 3, 16 # 伪粗体 3px墨迹顶沿 16 与旧版 F48 相同,保住上边距
_, tt, _, tb = self.F84.getbbox(dt["time"]) # F84 数字墨迹 2891 → 高 63摆到 1679距横规 11px
time_y = INK_TOP - tt
base_y = time_y + tb # 时钟数字基线(墨迹底沿)
self.text(10, time_y, dt["time"], self.F84, bold=TIME_BOLD)
date_txt = f'{dt["week"]} {dt["greg"]}'
_, _, _, db = self.F12.getbbox(dt["greg"]) # 用日期数字的底沿对齐基线(汉字底沿比数字低 1px忽略
self.text(10 + self._tw(dt["time"], self.F84) + TIME_BOLD + 14, base_y - db, date_txt, self.F12)
loc, temp, cr = wt["loc"], wt["temp"], f'{wt["cond"]} {wt["range"]}'
self.rtext(R, 8, loc, self.F12)
self.rtext(R, 30, temp, self.F24, bold=1)
self.rtext(R, 64, cr, self.F12)
PIN_W, PIN_GAP = 8, 6
loc_pin_x = R - self._tw(loc, self.F12) - PIN_GAP - PIN_W
self._loc_pin(loc_pin_x, 10, PIN_W, fg=RED)
s = 1.5
iw, ih = 36 * s, 38 * s
text_left = min(loc_pin_x, R - self._tw(temp, self.F24) - 1, R - self._tw(cr, self.F12))
self._weather_icon(text_left - 12 - iw, (DIV_Y - ih) / 2,
self._weather_category(wt.get("icon")), s=s, accent=RED, wet=BLUE)
self.hdash(8, self.W - 8, DIV_Y)
# ---- B. 用量:左 Claude 右 ChatGPT紧凑单行与三色版同款----
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

278
server.py Normal file
View File

@@ -0,0 +1,278 @@
#!/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 / ChatGPT Usage**不主动拉取**,由本机 `main.py --target push` 推送原始快照到 POST /push/usage、/push/usage_gpt
服务端暂存最新一份内存PUSH_STATE_PATH 非空时同时落盘,重启恢复);渲染时按当前时刻现算 pace
所以推送时机与设备抓取时机不必对齐。未推送过Claude 显示 "--"ChatGPT 用 mock 并标「示例数据」。
- 推送鉴权:请求头 X-Push-Token 必须等于 PUSH_TOKENPUSH_TOKEN 为空时拒绝一切推送(服务在公网,务必配置)。
其他端点GET /health → JSON 运行状态GET /push/usage、/push/usage_gpt → 查看暂存的原始快照(需同样令牌)。
部署python3 server.py监听 SERVER_HOST:SERVER_PORT前置 Caddy/Nginx 做 HTTPS或 cloudflared tunnel。
"""
import io
import json
import os
import threading
import time
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} # 各路最近一次成功的数据
self.fetched_at = {"weather": 0.0, "mao": 0.0, "btc": 0.0} # 各路最近一次成功拉取的时间戳(缓存有效期起点)
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()
def _cached(key, ttl, fetch, fallback):
"""带有效期的内存缓存:未过期 → 直接返回缓存;过期 → 实拉,成功则更新数据与时间戳,失败沿用旧值(无旧值用 fallback
fetch() 返回 (data, ok)。返回 (data, 状态字串) 供日志。"""
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)"
try:
d, ok = fetch()
except Exception as e: # noqa: BLE001 —— 上游任何异常都按失败处理
print(f"[警告] {key} 拉取异常:{e}")
d, ok = None, False
if ok:
with STATE.lock:
STATE.last_good[key], STATE.fetched_at[key] = d, now
return d, f"{key}:fetched"
if cached is not None:
return cached, f"{key}:stale"
return fallback, f"{key}:none"
def build_data(now=None):
"""组装渲染数据:三路上游按各自 TTL 走内存缓存,用量取推送暂存。返回 (data, 各路状态列表)。"""
now = now or data.now_tz()
with STATE.lock:
raw_usage, raw_gpt = STATE.raw["usage"], STATE.raw["usage_gpt"]
weather, s1 = _cached("weather", config.WEATHER_TTL, data.weather_fetch,
{"loc": config.WEATHER_LOC_NAME, "cond": "--", "temp": "--", "range": "--", "icon": ""})
mao, s2 = _cached("mao", config.MAO_TTL, lambda: data.mao_fetch(now, with_trend=False),
{"upd": now.strftime("%H:%M"), "cols": [data._placeholder_col(k) for k in ("单量", "流水", "毛利")],
"trend": {"dates": [], "series": []}})
btc, s3 = _cached("btc", config.BTC_TTL, lambda: (data.btc_fetch(), True), data.btc_empty())
d = {
"date": {"greg": now.strftime("%Y/%m/%d"), "week": data.WEEK_CN[now.weekday()], "time": now.strftime("%H:%M")},
"weather": weather,
"mao": mao,
"usage": data._usage(now, raw=raw_usage if raw_usage is not None else {}), # 未推送 → 全部 "--"(不读本机文件)
"usage_gpt": data._usage_gpt(now, raw=raw_gpt), # 未推送 → mock标「示例数据」
"btc": btc,
}
return d, [s1, s2, s3]
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 self.headers.get("X-Push-Token", "") == tok
# ---------- GET ----------
def do_GET(self):
path = self.path.split("?", 1)[0]
try:
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},
"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()

33
usage_local.py Normal file
View File

@@ -0,0 +1,33 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Claude Code 用量本地读取 —— 只读 claude-statusline 生产的用量快照,**不发任何网络请求**。
生产者/消费者模式:生产者 = 打过补丁的 claude-statusline见其仓库 bin/statusline.sh 的
「Usage snapshot for external consumers」段它把每次从 Claude Code stdin 拿到的**权威实时**额度
落盘到 config.USAGE_LOCAL_PATH默认 ~/.claude/usage-snapshot.json本模块作为消费者读取。
结构含 five_hour / seven_day / seven_day_fable后者为 Fable 模型限定的 7 天额度statusline 从
/api/oauth/usage 的 limits[] 转存,旧版 statusline 或 API 无该项时为 null各有 utilization已用百分比
与 resets_at重置时间epoch 秒或 ISO供上层算「时间已流逝%」做 pace 对比),外加 updated_at落盘 epoch
活跃使用时快照几乎持续刷新、准确;空闲时停在最后一次。文件缺失/损坏时抛错 → 上层回退占位 "--"
"""
import json
import config
def get_usage() -> dict:
"""读取本地 statusline 用量缓存并返回(含 five_hour / seven_day。缺失/损坏时抛出。"""
with open(config.USAGE_LOCAL_PATH, encoding="utf-8") as f:
data = json.load(f)
if "five_hour" not in data:
raise RuntimeError(f"用量缓存缺 five_hour 字段:{config.USAGE_LOCAL_PATH}")
return data
if __name__ == "__main__":
d = get_usage()
for k in ("five_hour", "seven_day", "seven_day_fable"):
s = d.get(k) or {}
print(k, "util=", s.get("utilization"), "resets_at=", s.get("resets_at"))