From 14359c8726fc4fa228fd070ea8cf73b42a3ae4c9 Mon Sep 17 00:00:00 2001 From: YANG JIANKUAN Date: Mon, 6 Jul 2026 18:55:46 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E7=89=88=E5=BC=8F=E9=87=8D=E6=9E=84?= =?UTF-8?q?=E4=B8=BA=E4=B8=89=E6=AE=B5=20+=20=E6=8E=A5=E5=85=A5=20Claude?= =?UTF-8?q?=20Usage(=E5=8F=AA=E8=AF=BB=E6=9C=AC=E5=9C=B0)=20+=20=E5=AE=9A?= =?UTF-8?q?=E4=BD=8D=E6=B1=9F=E5=AE=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 将仪表盘从「四区(时钟/天气/今日实时/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 --- CLAUDE.md | 24 +++++---- README.md | 50 ++++++++++-------- config.py | 13 +++-- data.py | 53 +++++++++++++++++++ renderer.py | 138 +++++++++++++++++++++++++++++++++++++------------ usage_local.py | 31 +++++++++++ 6 files changed, 242 insertions(+), 67 deletions(-) create mode 100644 usage_local.py diff --git a/CLAUDE.md b/CLAUDE.md index 0e258eb..6385147 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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=pct−time_pct`(>0 超前↑ / <0 节余↓);故即使 `utilization` 来自可能滞后的本地缓存,pace 仍随时钟准确推进。5h 窗口 18000s、7d 窗口 604800s。 - **`jm_api.py`** — jm-devops 后端统计接口客户端,**设备密钥授权**(`grant_type=client_secret`,免验证码)。先用 `CLIENT_ID + CLIENT_SECRET` 调 `POST /auth/login` 换 token(进程内缓存、按 `expire_in` 提前刷新),再带 `Authorization: Bearer ` + `clientid` 头调 `/system/statistics/{realtime,recent-days}`;遇 401 清 token 重登一次重试。token 字段为蛇形 `access_token`/`expire_in`。注:历史上曾免鉴权直调、更早曾用账号密码+验证码 OCR 登录(ddddocr 不稳定),现统一为 client_secret 授权——密钥即长期凭证,务必走 HTTPS、优先用 `JM_CLIENT_SECRET` 环境变量、可在后端 `sys_client` 轮换。 - **`weather_api.py`** — 和风天气(QWeather)客户端。用**用户专属 API Host**(`QWEATHER_HOST`)+ `X-QW-Api-Key` 头鉴权;响应 **gzip 压缩**(按 magic number 手动解压)、返回 `code` 为**字符串**。`get_now()` 取实时(`now.text` 中文天气 / `now.temp` / `now.icon` 图标代码),`get_today()` 取 `/3d` 的 `daily[0]` 今日温区。`QWEATHER_HOST`/`QWEATHER_KEY` 未配置时直接抛错→天气区显示 `"--"`。 -- **`renderer.py`** — `DashboardRenderer.render(data)` 返回 PIL `'RGB'` 模式、**仅黑/白/红三色**图;`render_to_file()` 落盘。布局硬编码为四区(render 内 A/B/C/D):A 大时钟+日期 / 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 顶部**(0–90):大时钟(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 值与下方组件位置联动。 diff --git a/README.md b/README.md index 66ddbd0..79683a6 100644 --- a/README.md +++ b/README.md @@ -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.log(git 忽略) @@ -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`(硬阈值),三色纯色图最锐利。 ``` \ No newline at end of file diff --git a/config.py b/config.py index 18571b2..342a11f 100644 --- a/config.py +++ b/config.py @@ -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")) # 用量 ≥ 此百分比 → 进度条与百分比转红预警 diff --git a/data.py b/data.py index 6bd7ec1..11a3d7c 100644 --- a/data.py +++ b/data.py @@ -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=None(renderer 显示 "--")。 + 结构契约(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), } diff --git a/renderer.py b/renderer.py index 2c82388..a9c39fe 100644 --- a/renderer.py +++ b/renderer.py @@ -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 diff --git a/usage_local.py b/usage_local.py new file mode 100644 index 0000000..71647ad --- /dev/null +++ b/usage_local.py @@ -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"))