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>
This commit is contained in:
2026-07-06 18:55:46 +08:00
parent 9b39eacecf
commit 14359c8726
6 changed files with 242 additions and 67 deletions

View File

@@ -6,9 +6,9 @@ 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 三色(黑/白/红BWR** PNG推送到 Zectrix 极趣云墨水屏设备。纯 Python + Pillow**无浏览器依赖**,适合 cron 定时运行。
作用:把日期天气、Claude Code 用量、橘喵今日经营合成为一张 **400×300 三色(黑/白/红BWR** PNG推送到 Zectrix 极趣云墨水屏设备。纯 Python + Pillow**无浏览器依赖**,适合 cron 定时运行。
> **分支 `feature/colorful`**:新设备在黑白外支持红色本分支把渲染从纯黑白升级为黑/白/红三色,红作为**稀缺强调色**只承载语义(方向·警示·强调),占墨极小(实测红≈全图 0.6%、占墨迹 <10%)。仍**无真灰阶、不抖动**
> **分支 `feature/colorful`**:新设备在黑白外支持红色本分支把渲染升级为黑/白/红三色。当前版式为**上中下三段**A 大时钟+日期 / 天气定位南京·江宁B **Claude Usage** 主角5h/7d 用量进度条 + 时间维度对比 pace用量吃紧转红预警C 橘喵今日实时三列。红作**语义强调**(用量预警、涨、定位、天气太阳/闪电),仍**无真灰阶、不抖动**全图只三种纯色。近30天趋势图当前**隐藏**`renderer._trend_chart` 及依赖保留、未调用,随时可挂回)
## 命令
@@ -26,20 +26,26 @@ ZECTRIX_DEVICE_ID=AA:BB:CC:DD:EE:FF 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 为空串)。可配置项:`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``USAGE_LOCAL_PATH`/`USAGE_WARN`
**定时部署**`run.sh` 是 cron 入口(已在 `.gitignore`本机专用。cron 不继承登录环境,故脚本内 python 写死 vfox 绝对路径、并 `cd` 到项目目录后追加日志到 `output/cron.log``cd``config.py` 才能就近找到 `.env`)。设计为**每分钟**触发:业务数据每次都刷新,天气/趋势靠缓存避免高频打接口(见 `data.py`)。
**定时部署**`run.sh` 是 cron 入口(已在 `.gitignore`本机专用。cron 不继承登录环境,故脚本内 python 写死 vfox 绝对路径、并 `cd` 到项目目录后追加日志到 `output/cron.log``cd``config.py` 才能就近找到 `.env`)。设计为**每分钟**触发:业务数据每次都刷新,天气靠缓存、用量读本地文件,避免高频打接口(见 `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()`,返回固定结构的 dict`date`/`weather`/`mao`/`usage`。日期时间用真实系统时间;橘喵今日经营由 `_mao()``jm_api`、天气由 `_weather()``weather_api`(和风,定位南京·江宁、Claude 用量由 `_usage()``usage_local`**任何异常都回退占位 `"--"`,不回退 mock**。改数据保持返回结构不变即可renderer 依赖其 key`weather.icon` 为和风图标代码;`mao.cols[].hb/tb`环比/同比 `(direction, text)`direction 为 `None` 时不画三角;`usage.bars[]``{k, pct(或 None), time_pct?}``usage.warn` 为转红阈值)。注:`_mao` 仍会拉 `mao.trend`但趋势图当前隐藏、renderer 不使用它
- **缓存**(为 cron 每分钟独立进程而设,必须落盘):天气 `.weather_cache.json`TTL=`WEATHER_TTL`,默认 15min)、趋势 `.trend_cache.json`(按日期 key每天只拉一次两者拉取失败沿用旧缓存**用量不自建缓存**——直接读 statusline 落盘的本地文件(见 `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 `'RGB'` 模式、**仅黑/白/红三色**图;`render_to_file()` 落盘。布局硬编码为四区render 内 A/B/C/DA 大时钟+日期 / B 天气 / C 橘喵今日三列 / D 近30天趋势折线图。趋势线用 `_styled_polyline()` 按 solid/dashed/dotted 区分,各指标按自身极值独立归一化(故只看趋势形状、不可横向比绝对值)。
- **红色点缀系统**(改配色从这里动,务必保持克制):模块顶部定义 `BLACK/WHITE/RED` 三元组,`RED=(255,0,0)` 是给设备红通道的明确信号。红只用在四处:① 定位水滴(`_loc_pin(fg=RED)`)② 天气图标的太阳与闪电(`_weather_icon(accent=RED)`,云/雨/雪/雾/月仍黑)③ 环比/同比**涨=红·跌=黑**——箭头与百分比数值一起着色(`_cmp_line`,「环比/同比」标签仍黑,红涨绿跌之「红涨」)④ 趋势图**主指标(流水)**折线+末点红点(按 `k=="流水"` 命中,图例同步红)。加红先问「是否承载语义」,避免红蔓延稀释注意力。
- **`usage_local.py`** Claude Code 用量本地读取**不发任何网络请求**只读 `config.USAGE_LOCAL_PATH`默认 `/tmp/claude/statusline-usage-cache.json`由已安装的 claude-statusline 落盘 `five_hour`/`seven_day` `utilization`(已用%) `resets_at`(重置时间)。新版 Claude Code stdin statusline该磁盘缓存刷新不频繁、**utilization 可能滞后** `resets_at` 是绝对时间 `data._usage` 现算的 pace 始终准文件缺失/损坏用量区 `"--"`。(曾评估直调 Anthropic OAuth 用量接口 `/api/oauth/usage`按需求改为只读本地。)
- **`renderer.py`** `DashboardRenderer.render(data)` 返回 PIL `'RGB'` 模式、**仅黑//红三色**`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` + 三列版式对齐主分支`■ 橘喵·今日实时` + `上次更新`列内 (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`)。
- **`config.py`** 顶部 `_load_dotenv()` 先加载同目录 `.env`再定义 API设备画布尺寸字体路径推送参数凭证 fallback 为空串真实值来自 `.env`/环境变量
@@ -50,7 +56,7 @@ ZECTRIX_DEVICE_ID=AA:BB:CC:DD:EE:FF python3 main.py
- **红只作语义强调**方向)、警示闪电)、强调定位/主折线红占墨保持很小<10% 墨迹滥用会稀释注意力破坏层级整图**只允许三种纯色** `(0,0,0)/(255,255,255)/(255,0,0)`——渲染后可用 `img.getcolors()` 自查出现第四种色多为抗锯齿灰边即为 bug矢量图元(line/ellipse/polygon/arc)默认不抗锯齿故为纯色文字靠 `fontmode="1"` 保持纯色
- 字体固定用 **Fusion Pixel 12px 点阵字体**`fonts/`OFL 协议只按整数倍尺寸 **12/24/48** 渲染`F12/F24/F48`并设 `d.fontmode = "1"` 抗锯齿否则像素会糊
- 字体固定用 **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 值与下方组件位置联动

View File

@@ -1,30 +1,32 @@
# 墨水屏仪表盘推送eink-push
将橘喵今日经营、近30天趋势、日期与天气合成为一张 **400×300 三色(黑/白/红BWR**图片,
日期天气、Claude Code 用量、橘喵今日经营合成为一张 **400×300 三色(黑/白/红BWR**图片,
推送到 Zectrix 极趣云墨水屏设备。
纯 Python + Pillow + Fusion Pixel 点阵字体,**无浏览器依赖**,适合后台/定时运行。
> **`feature/colorful` 分支**:新设备支持红色。红作为**稀缺强调色**只点缀四处——定位图标、
> 天气的太阳/闪电、环比同比「涨」的三角与数值(红涨)、趋势图主指标(流水)折线——占墨极小,其余仍黑白。
> **`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 头
├── config.py # 配置:设备、天气、用量、字体、尺寸、推送参数、缓存路径
├── data.py # 数据层:拉真实数据并映射,失败回退占位 "--"(不回退 mock天气/趋势缓存
├── jm_api.py # jm-devops 后端统计接口客户端(设备密钥 client_secret 授权 + token 缓存
├── weather_api.py # 和风天气QWeather客户端实时天气 + 今日温区)
├── usage_local.py # Claude Code 用量:只读本地 statusline 缓存文件(不发网络请求)
├── renderer.py # 渲染层DashboardRenderer 生成 400×300 三色(黑/白/红)图片
├── pusher.py # 推送层:设备列表 / 推送图片(标准库 urllib
├── pusher.py # 推送层:按 .env 设备 MAC 直接推送图片(标准库 urllib
├── run.sh # cron 包装脚本本机专用git 忽略)
├── fonts/ # Fusion Pixel 点阵字体OFL 协议)
├── output/ # 生成的图片与 cron.loggit 忽略)
@@ -41,7 +43,7 @@ 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不推送本地调试渲染用
```
@@ -53,11 +55,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=/tmp/claude/statusline-usage-cache.json USAGE_WARN=80
```
## 定时刷新cron
设计为**每分钟**触发:业务数据每次都刷新,天气/趋势靠缓存避免高频打接口(见下「设计说明」)。
设计为**每分钟**触发:业务数据每次都刷新,天气靠缓存、用量读本地文件,避免高频打接口(见下「设计说明」)。
`run.sh` 是 cron 入口——cron 不继承登录环境,故脚本内 python 写死绝对路径、`cd` 到项目目录后追加日志到 `output/cron.log`
```cron
@@ -68,13 +72,14 @@ 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 落盘的
`/tmp/claude/statusline-usage-cache.json`,取 5h/7d 的 `utilization``resets_at`
注:新版 Claude Code 经 stdin 喂 statusline该缓存 `utilization` 可能滞后;`resets_at` 为绝对时间,故 pace 时间对比始终准确。
- **日期 / 时间 / 上次更新**:真实系统时间。
> `data.py` 负责把响应映射成渲染结构;**任何接口失败都回退占位 `"--"`,不回退 mock**。
@@ -86,15 +91,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 落盘的本地文件,文件缺失/损坏即显示 `"--"`
## 设计说明
- 设备为**三色(黑/白/红)无真灰阶**,灰阶只能抖动成网点(难看),故底色只用黑白 + 红作稀缺强调,靠构图、字号、留白建立层级。
- **红只承载语义**(方向·警示·强调),占墨保持很小(实测红≈全图 0.6%、占墨迹 <10%整图仅三种纯色 `(0,0,0)/(255,255,255)/(255,0,0)`无灰不抖动
- 字体用 **Fusion Pixel 12px 点阵字体**整数倍尺寸(12/24/48)渲染关闭抗锯齿 像素锐利
重点数据用伪粗体」(偏移叠绘)加粗
- 字体用 **Fusion Pixel 12px 点阵字体**首选整数倍(12/24/48)最锐利用量区另用 13/16px(非整数倍笔画略不均)取字号层级关抗锯齿仍保持纯色不糊重点数据用伪粗体」(偏移叠绘)加粗
- **Claude Usage**每条单行 = 左标签(5h/7d) + 粗进度条 + 百分比 + pace(/下三角)用量 `USAGE_WARN`(默认 80) 时该条进度条与百分比转红预警行内所有元素按墨迹竖直中心对齐到同一中线
- 天气图标为**全矢量手绘**无图片资源按和风 icon 代码归并为 8 /晴夜/多云/////用基本图元描边画出太阳与闪电着红
- 趋势折线各指标按自身极值独立归一化故只表达**趋势形状**不可横向比绝对值主指标(流水)着红领读末点标红点
- 近30天趋势折线图当前**隐藏**`renderer._trend_chart` 及依赖保留未调用随时可挂回
- 推送用 `dither=false`硬阈值三色纯色图最锐利
```

View File

@@ -62,11 +62,18 @@ 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_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")
# ---- Claude Code 用量Claude Usage 模块)数据源 ----
# 只读本地:读取 claude-statusline 落盘的用量缓存文件,**不发任何网络请求**(详见 usage_local.py
# 注意:新版 Claude Code 经 stdin 喂 statusline该磁盘缓存刷新不频繁、utilization 可能滞后;
# resets_at 为绝对时间,故 pace(时间维度对比)始终准确。文件缺失时用量区显示 "--"。
USAGE_LOCAL_PATH = os.environ.get("USAGE_LOCAL_PATH", "/tmp/claude/statusline-usage-cache.json")
USAGE_WARN = int(os.environ.get("USAGE_WARN", "80")) # 用量 ≥ 此百分比 → 进度条与百分比转红预警

53
data.py
View File

@@ -13,10 +13,12 @@ from datetime import datetime
import config
import jm_api
import weather_api
import usage_local
WEEK_CN = ["周一", "周二", "周三", "周四", "周五", "周六", "周日"]
TREND_DAYS = 30 # 近 N 天趋势
FIVE_HOUR, SEVEN_DAY = 5 * 3600, 7 * 86400 # 两个滚动窗口长度(秒),用于算「时间已流逝%」
def _read_weather_cache():
@@ -166,6 +168,56 @@ def _mao(now):
return {"upd": upd, "cols": cols, "trend": trend}
# ---------- Claude Code 用量 ----------
def _parse_iso_ts(s):
"""ISO 时间串 → epoch 秒;兼容结尾 'Z'。失败返回 None。"""
if not s:
return None
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_iso_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 _usage(now):
"""Claude Code 用量5h / 7d——只读本地 statusline 缓存(见 usage_local.py**不发任何请求**。
time_pct(时间已流逝%) 与 pace 每次按当前时间**现算**resets_at 为绝对时间,始终准确);
utilization 取自本地缓存、可能滞后。文件缺失/损坏 → 回退占位 pct=Nonerenderer 显示 "--")。
结构契约renderer 依赖):{title, warn, bars:[{k, pct(0~100 或 None), time_pct(0~100)?}]}
pct ≥ warn 时该条进度条与百分比转红pace = pct time_pct>0 超前↑ / <0 节余↓)。
"""
wins = {}
try:
raw = usage_local.get_usage()
for key, label, win in (("five_hour", "5h", FIVE_HOUR), ("seven_day", "7d", SEVEN_DAY)):
seg = raw.get(key) or {}
wins[label] = {"pct": int(round(float(seg.get("utilization", 0)))),
"resets_at": seg.get("resets_at"), "win": win}
except Exception as e:
print(f"[警告] Claude 用量读取失败:{e}")
bars = []
for label in ("5h", "7d"):
w = wins.get(label) or {}
pct = w.get("pct")
bar = {"k": label, "pct": pct}
tp = _window_time_pct(w.get("resets_at"), w.get("win"), now)
if pct is not None and tp is not None:
bar["time_pct"] = tp
bars.append(bar)
return {"title": "Claude Usage", "warn": config.USAGE_WARN, "bars": bars}
def get_dashboard_data(now=None):
"""返回渲染所需的完整数据字典。"""
now = now or datetime.now()
@@ -177,4 +229,5 @@ def get_dashboard_data(now=None):
},
"weather": _weather(now),
"mao": _mao(now),
"usage": _usage(now),
}

View File

@@ -4,12 +4,20 @@
渲染层 —— 将数据绘制成 400x300 三色(黑/白/红)墨水屏仪表盘。
设计要点:
- 三色 BWR仍无真灰阶灰阶只能抖动成网点弃用底色黑白;红作为稀缺强调色点缀
红只承载语义——方向(涨)、警示、寻路锚点——占墨极小,靠构图 + 字号 + 留白建立层级
- 三色 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
@@ -26,6 +34,8 @@ class DashboardRenderer:
def __init__(self, font_path=config.FONT_PATH, size=(config.WIDTH, config.HEIGHT)):
self.W, self.H = size
self.F12 = ImageFont.truetype(font_path, 12)
self.F13 = ImageFont.truetype(font_path, 13) # 5h/7d 与 pace 用;接近原生 12px畸变很小
self.F16 = ImageFont.truetype(font_path, 16) # 非整数倍(1.33x):靠 fontmode='1' 保持纯色,笔画略不均可接受
self.F24 = ImageFont.truetype(font_path, 24)
self.F48 = ImageFont.truetype(font_path, 48)
@@ -83,12 +93,66 @@ class DashboardRenderer:
self.tri(x + trw / 2, y + 8.5, 7, 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):
"""区块标题(两区块统一,对齐主分支 _title_bar 风格):
■ 方块装饰(8px与 F12 标题墨迹竖直居中) + 标题(F12,常规字重) + 可选右侧信息。"""
SQ = 8
self.d.rectangle([10, y + 4, 10 + SQ, y + 4 + SQ], fill=BLACK) # 中心≈y+8对齐 F12 墨迹中心(≈y+8.5)
self.text(10 + SQ + 6, y, title, self.F12)
if right:
self.rtext(self.W - 10, y, right, self.F12)
def _mid(self, x, cy, s, ft, fill=BLACK, bold=0, align="l"):
"""按字符串「墨迹竖直中心」对齐到 cy 绘制——跨字号(F12/F24)也能真正居中,
避免不同字号按顶部对齐时的视觉错位。align: l 左对齐 / r 右对齐 / c 居中于 x。"""
_, t, _, b = ft.getbbox(s) # 该串墨迹在字身框内的上/下沿
y = cy - (t + b) / 2.0
w = self._tw(s, ft)
if align == "r":
x -= w + bold
elif align == "c":
x -= (w + bold) / 2.0
self._draw(x, y, s, ft, fill, bold)
def _pace(self, xr, cy, diff):
"""时间维度对比pace = 用量% 时间%>0 超前↑红;<0 节余↓黑;=0 持平。
数值(F13)两位补零、右端对齐 xr小三角(6)紧贴其左;均竖直居中 cy。返回整体宽度供左侧定位。"""
up = diff > 0
color = RED if up else BLACK
txt = f"{abs(int(diff)):02d}"
tw = self._tw(txt, self.F13)
self._mid(xr, cy, txt, self.F13, fill=color, bold=1, align="r")
if diff == 0:
return tw
TRI_GAP, TRI = 4, 6
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):
"""一条用量(单行):左标签(5h/7d, F13) + 进度条 + 百分比(F16, 主数字) + 时间维度对比 pace(F13)。
右侧从右往左排布显式控制两处横向间距G1 进度条↔百分比、G2 百分比↔pace全部竖直居中 cy。
pct ≥ warn → 进度条(含整条边框)与百分比转红;边框随条色,红条即红框,无黑白错位。"""
raw = bar.get("pct")
ok = isinstance(raw, (int, float)) # None/缺失 → 显示 "--"
pct = max(0, min(100, int(raw))) if ok else 0
color = RED if (ok and pct >= warn) else BLACK
LABEL_W, G1, G2, H = 26, 13, 8, 15 # G1 进度条↔百分比、G2 百分比↔pace视觉约 12/11
cy = y + 10 # 行竖直中心
self._mid(x0, cy, bar["k"], self.F13, bold=1) # 左标签 F13
# 右侧从右往左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}%" if ok else "--"
self._mid(pct_r, cy, pct_txt, self.F16, fill=color, bold=1, align="r") # 百分比 F16
bx0 = x0 + LABEL_W
bx1 = pct_r - self._tw(pct_txt, self.F16) - G1
by = cy - H // 2
self.d.rectangle([bx0, by, bx1, by + H], outline=color, width=1) # 轨道边框随条色
fw = (bx1 - bx0) * pct / 100.0
if fw >= 2:
self.d.rectangle([bx0, by, bx0 + fw, by + H], fill=color)
def _loc_pin(self, x, y, w=8, fg=BLACK, bg=WHITE):
"""定位图标:实心水滴(圆头+尖尾) + 白色挖孔1-bit 下轮廓清晰。
@@ -285,55 +349,63 @@ class DashboardRenderer:
# ---------- 入口 ----------
def render(self, data):
"""根据 data 渲染并返回 PIL.Image'RGB' 模式,仅黑/白/红三色)。"""
"""根据 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" # 关闭抗锯齿点阵锐利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, fg=RED) # 定位图标:红水滴,地图别针的通用语汇
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, accent=RED)
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"])
# ---- 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", []):
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 = mao["cols"]; 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

31
usage_local.py Normal file
View File

@@ -0,0 +1,31 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Claude Code 用量本地读取 —— 只读 claude-statusline 落盘的用量缓存,**不发任何网络请求**。
数据文件config.USAGE_LOCAL_PATH默认 /tmp/claude/statusline-usage-cache.json
由已安装的 claude-statusline 写入。结构含 five_hour / seven_day各有
utilization(已用百分比) 与 resets_at(ISO 重置时间,供上层算「时间已流逝%」做 pace 对比)。
注意:新版 Claude Code 经 stdin 把额度喂给 statusline该磁盘缓存刷新不频繁、utilization 可能滞后;
resets_at 是绝对时间,故上层的 pace(时间维度对比)始终准确。文件缺失/损坏时抛错 → 上层回退占位 "--"
"""
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"):
s = d.get(k) or {}
print(k, "util=", s.get("utilization"), "resets_at=", s.get("resets_at"))