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>
This commit is contained in:
2026-09-10 14:11:35 +08:00
parent eab90bf3b5
commit d7ae193f56
5 changed files with 184 additions and 86 deletions

View File

@@ -10,7 +10,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
> **分支 `feature/colorful`**:新设备在黑白外支持红色,本分支把渲染升级为黑/白/红三色。当前版式为**上中下三段**A 大时钟+日期 / 天气定位南京·江宁B **Claude Usage** 主角5h/7d 用量进度条 + 时间维度对比 pace用量吃紧转红预警C 橘喵今日实时三列。红作**语义强调**(用量预警、涨、定位、天气太阳/闪电),仍**无真灰阶、不抖动**全图只三种纯色。近30天趋势图当前**隐藏**`renderer._trend_chart` 及依赖保留、未调用,随时可挂回)。 > **分支 `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 秒)。因此本分支产出的是**一张固定 800×480 的静态页 `output/e1002/index.html` + 同目录 `dashboard.png`**,需部署到公网 HTTPS局域网地址/明文 HTTP 云端抓不到)。渲染由 `renderer_e1002.py` 完成:继承 `DashboardRenderer` 全部组件、**`k=2` 整数倍缩放**(字体 24/48/96px像素常量乘 k只重写 `render()` 版式;元素与三色版完全相同(时钟日期/天气、Claude Usage、橘喵今日实时),黄/绿/蓝目前**仅用于底部六色测试色条**(真机校准平台量化用),语义配色仍只用黑/白/红 > **分支 `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 秒)。因此本分支产出的是**一张固定 800×480 的静态页 `output/e1002/index.html` + 同目录 `dashboard.png`**,需部署到公网 HTTPS局域网地址/明文 HTTP 云端抓不到)。渲染由 `renderer_e1002.py` 完成:继承 `DashboardRenderer` 全部组件、**k=1 字号与三色版完全相同(不放大,放大后字太大很傻)**,靠四倍面积重新排版:顶部时钟/天气不变;中段左 Claude Usage、橘喵今日实时并排;下段全宽恢复三色版隐藏的 **30 天趋势图**(三指标各一色 + 合计)。六色各有唯一语义:红=预警/涨/定位/太阳闪电/流水线,黄=用量「注意」档填充,绿=毛利线,蓝=单量线/雨雪
## 命令 ## 命令
@@ -29,7 +29,7 @@ EPD_HOST=epd400a44.local python3 main.py
无测试、无 lint。调试渲染效果就跑 `--render-only``output/dashboard.png` 无测试、无 lint。调试渲染效果就跑 `--render-only``output/dashboard.png`
**配置统一走 `.env`**:所有凭证/参数放同目录 `.env`(已在 `.gitignore`,本机专用;模板见已提交的 `.env.example`)。`config.py` 顶部用零依赖加载器 `_load_dotenv()``.env` 写入 `os.environ`,再由各 `os.environ.get(...)` 读取;采用 `setdefault`,故**真实环境变量cron/命令行注入)优先级高于 `.env`**。`config.py` 内不再保留任何明文凭证fallback 为空串)。可配置项:`EPD_HOST`(局域网设备 mDNS host默认 `epd400a44.local`)、`JM_BASE_API`/`JM_CLIENT_ID`/`JM_CLIENT_SECRET`/`JM_GRANT_TYPE`/`JM_TENANT_ID``QWEATHER_HOST`/`QWEATHER_KEY`/`WEATHER_LOCATION`(默认南京·江宁)/`WEATHER_TTL``USAGE_LOCAL_PATH`/`USAGE_WARN` **配置统一走 `.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_CAUTION`(仅 E1002 黄档)、`E1002_COLOR_STRIP`
**定时部署**:由 macOS **launchd LaunchAgent** 触发 `run.sh`(不再用 cron——cron 在现代 macOS 上易被 TCC/权限静默拦截launchd 是系统原生调度器。LaunchAgent plist 装在 `~/Library/LaunchAgents/com.jm.eink-push.plist`(机器专用,未入库,等价于 `run.sh`/`.env` 的本机产物),`StartInterval=300` + `RunAtLoad=true`:登录/重启后立即跑一次,此后每 5 分钟一次。用 LaunchAgent非 LaunchDaemon是因为脚本依赖当前用户目录下的文件`~/.claude/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`)。 **定时部署**:由 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`)。
@@ -37,13 +37,13 @@ EPD_HOST=epd400a44.local python3 main.py
`main.py` 串联三层,各层职责单一、低耦合: `main.py` 串联三层,各层职责单一、低耦合:
- **`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 不使用它 - **`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.trend` 三色版当前不用趋势图隐藏E1002 版用它画 30 天趋势;`_mao` 还会现算 `trend.totals`(三指标 30 天合计,`_trend_totals`,不进缓存)
- **缓存**(为 launchd 每 5 分钟独立进程而设,必须落盘):天气 `.weather_cache.json`TTL=`WEATHER_TTL`,默认 15min、趋势 `.trend_cache.json`(按日期 key每天只拉一次两者拉取失败沿用旧缓存。**用量不自建缓存**——直接读 statusline 生产的本地快照 `~/.claude/usage-snapshot.json`(见 `usage_local`),失败即 `"--"`。两个缓存文件已在 `.gitignore` - **缓存**(为 launchd 每 5 分钟独立进程而设,必须落盘):天气 `.weather_cache.json`TTL=`WEATHER_TTL`,默认 15min、趋势 `.trend_cache.json`(按日期 key每天只拉一次两者拉取失败沿用旧缓存。**用量不自建缓存**——直接读 statusline 生产的本地快照 `~/.claude/usage-snapshot.json`(见 `usage_local`),失败即 `"--"`。两个缓存文件已在 `.gitignore`
- **用量的 pace时间维度对比**`_usage()` 每次按当前时间**现算** `time_pct=(窗口已流逝/窗口长)``pace=pcttime_pct`>0 超前↑ / <0 节余↓);故即使 `utilization` 来自可能滞后的本地缓存pace 仍随时钟准确推进5h 窗口 18000s7d 窗口 604800s - **用量的 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` 轮换 - **`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` 未配置时直接抛错天气区显示 `"--"` - **`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` 未配置时直接抛错天气区显示 `"--"`
- **`usage_local.py`** Claude Code 用量本地读取**不发任何网络请求****生产者/消费者**模式只读 `config.USAGE_LOCAL_PATH`默认 `~/.claude/usage-snapshot.json`)。**生产者 = 打过补丁的 claude-statusline** `bin/statusline.sh` 把每次 Claude Code stdin 喂来的**权威实时**额度落盘为该快照schema `/api/oauth/usage``five_hour`/`seven_day` `utilization` + `resets_at`本模块作消费者读取活跃使用时快照几乎持续刷新准确空闲时停在最后一次文件缺失/损坏用量区 `"--"``resets_at` 可能是 **epoch 数字**Claude Code stdin 给的就是 epoch ISO `data._parse_reset_ts` 两者兼容。(曾评估直调 `/api/oauth/usage`改为读本地快照以免网络请求。) - **`usage_local.py`** Claude Code 用量本地读取**不发任何网络请求****生产者/消费者**模式只读 `config.USAGE_LOCAL_PATH`默认 `~/.claude/usage-snapshot.json`)。**生产者 = 打过补丁的 claude-statusline** `bin/statusline.sh` 把每次 Claude Code stdin 喂来的**权威实时**额度落盘为该快照schema `/api/oauth/usage``five_hour`/`seven_day` `utilization` + `resets_at`本模块作消费者读取活跃使用时快照几乎持续刷新准确空闲时停在最后一次文件缺失/损坏用量区 `"--"``resets_at` 可能是 **epoch 数字**Claude Code stdin 给的就是 epoch ISO `data._parse_reset_ts` 两者兼容。(曾评估直调 `/api/oauth/usage`改为读本地快照以免网络请求。)
- **`renderer.py`** `DashboardRenderer.render(data)` 返回 PIL `'RGB'` 模式、**仅黑//红三色**`render_to_file()` 落盘布局硬编码为**上中下三段**两道 `hdash` 横规分隔y=90 / 200 - **`renderer.py`** `DashboardRenderer.render(data)` 返回 PIL `'RGB'` 模式、**仅黑//红三色**组件层提供两个可覆写钩子/参数供六色版复用`_usage_colors()`用量行 填充/边框/文字 三色元组)、`_weather_icon(wet=)`降水元素颜色`_label(x0,x1)` 可指定左右边界`render_to_file()` 落盘布局硬编码为**上中下三段**两道 `hdash` 横规分隔y=90 / 200
- **A 顶部**090大时钟(F48)+日期/ 天气红定位水滴 + 温度 + 天气·温区 + 全矢量图标)。 - **A 顶部**090大时钟(F48)+日期/ 天气红定位水滴 + 温度 + 天气·温区 + 全矢量图标)。
- **B 中部主角 Claude Usage**`_label` 标题 + 两条 `_usage_row`每条单行 = 左标签 `5h`/`7d`(F13) + 粗进度条 + 百分比(F16) + 时间维度对比 pace(F13`_pace`)`pct≥warn`该条进度条(含边框)与百分比转红预警pace 用上/下三角替代 +/超前 / 节余↓)。整行标签·进度条·百分比·三角 `_mid()` 按墨迹竖直中心对齐到同一中线 - **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 下边距 - **C 底部 橘喵今日实时**`_label` + 三列版式对齐主分支`■ 橘喵·今日实时` + `上次更新`列内 (F12)/(F24 )/环比·同比(`_cmp_line`)相对标题偏移 +18/+34/+64/+82底部留 ~11px 下边距
@@ -51,7 +51,7 @@ EPD_HOST=epd400a44.local python3 main.py
- **`_trend_chart`当前隐藏**近30天三指标折线 `_styled_polyline()` solid/dashed/dotted 区分主指标(流水)红线各指标按自身极值独立归一化已保留但 `render()` 未调用想恢复即在 C 前挂回 - **`_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 描边画出改天气展示从这里动 - **天气图标全矢量手绘**无图片资源`_weather_category()` 把和风 icon 代码归并为 8 sunny/clear_night/cloudy/overcast/thunder/rain/snow/fog识别不了回退 cloudy再由 `_weather_icon()` `_sun`/`_cloud` 等基元按 1bit 描边画出改天气展示从这里动
- **`pusher.py`** 直连局域网墨水屏设备喵喵固件开源https://gitee.com/gxp666111/miaomiao`web/` 即设备自带管理页可浏览器打开 `http://<EPD_HOST>/` 调试同一套接口)。`push_image()` 先调 `GET /images` 拿画廊已存图片名单逐个 `POST /delete_image?name=`清空单张删除失败不阻塞后续 `POST /upload``Content-Type: image/png`body 为原始 PNG 字节 multipart触发设备转换+刷屏。**清空画廊是刻意设计**设备把每次 `/upload` 都追加存一份到画廊`GET /images` 可见不清理会越推越多且设备当前 `current_mode` slideshow 会轮播到旧图先清后传使画廊恒为 1 等价于始终静态展示最新一张。**清理非核心**拉取画廊列表本身失败也不阻塞后续 `/upload`跳过清理直接推)。`_request()` 对连接类异常`urllib.error.URLError` mDNS `.local` 域名解析瞬时失败内置重试`RETRY_TIMES=2`间隔 `RETRY_DELAY=2s`)——mDNS 基于组播 UDP偶发丢包很正常单次失败不该让整轮推送直接跳过历史上曾走 Zectrix 公网 API`POST /devices/{id}/display/image` + 设备 MAC + `ZECTRIX_API_KEY`现统一为局域网直连 - **`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=2` 复用全部组件`render()` 重排为 800×480 四段A 顶部 0112时钟 F48 + 同基线日期 F12右侧定位/温度 F24/天气·温区 F12 + 图标 s=2.0B 124258 Claude UsageC 272440 今日实时三列D 452480 六色测试色条 `_color_strip``SPECTRA6` 为六色调色板`write_page()` 写出固定 800×480 `index.html`零边距`image-rendering: pixelated`PNG 带时间戳参数防缓存)。**为何固定尺寸而非自适应**云端截图视口/DPR 不可控自适应会重排+抗锯齿产生中间色再被平台抖动固定页里的 1:1 PNG 每个像素本就是六色之一截图零损失——已用无头 Chrome `--window-size=800,480` 截图验证与 PNG 逐像素一致设备端 HTML 控件须铺满整个画布 - **`renderer_e1002.py`** reTerminal E1002 目标`E1002Renderer(DashboardRenderer)` **k=1** 复用全部组件字号 12/24/48 与三色版一致只重写版式与配色钩子版式A 顶部 090 时钟/日期左天气右 x=790B 96192 左半 Claude Usage10390竖规 x=400右半 今日实时三列410790`_label(x0,x1)` 指定边界C 206472 全宽 `_trend_chart6` 30 天趋势用颜色而非线型区分流水红 2px 主线 + 末点单量蓝毛利绿标题行右接合计三指标 30 天求和来自 `data._trend_totals`每次现算不进缓存 7 天一道竖点规D 可选六色测试色条 `_color_strip``E1002_COLOR_STRIP=1` 打开默认关)。配色钩子 `_usage_colors` 三档:≥`USAGE_WARN` |≥`USAGE_CAUTION`默认 60黄填充+黑框黑字其余黑——黄字压白底不可读黄只作填充天气图标 `wet=BLUE` 让雨滴/雪花着蓝`write_page()` 写出固定 800×480 `index.html`零边距`image-rendering: pixelated`PNG 带时间戳参数防缓存)。**为何固定尺寸而非自适应**云端截图视口/DPR 不可控自适应会重排+抗锯齿产生中间色再被平台抖动固定页里的 1:1 PNG 每个像素本就是六色之一截图零损失——已用无头 Chrome `--window-size=800,480` 截图验证与 PNG 逐像素一致设备端 HTML 控件须铺满整个画布
- **`config.py`** 顶部 `_load_dotenv()` 先加载同目录 `.env`再定义 API设备画布尺寸字体路径推送参数凭证 fallback 为空串真实值来自 `.env`/环境变量 - **`config.py`** 顶部 `_load_dotenv()` 先加载同目录 `.env`再定义 API设备画布尺寸字体路径推送参数凭证 fallback 为空串真实值来自 `.env`/环境变量
## 墨水屏渲染约束(改 renderer.py 必读) ## 墨水屏渲染约束(改 renderer.py 必读)
@@ -60,7 +60,7 @@ EPD_HOST=epd400a44.local python3 main.py
- **红只作语义强调**方向)、警示闪电)、强调定位/主折线红占墨保持很小<10% 墨迹滥用会稀释注意力破坏层级整图**只允许三种纯色** `(0,0,0)/(255,255,255)/(255,0,0)`——渲染后可用 `img.getcolors()` 自查出现第四种色多为抗锯齿灰边即为 bug矢量图元(line/ellipse/polygon/arc)默认不抗锯齿故为纯色文字靠 `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` 输出逐像素不变`k=2` 800×480字体属性名`F12/F24/F48`表达层级而非绝对像素写新版式时沿用原层级名不要再手动放大一档 - **`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` 墨迹中心对齐)。 - 字体固定用 **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 水平偏移叠绘笔画点阵字体只有单一字重)。 - 加粗用伪粗体」:`_draw()` 1~bold px 水平偏移叠绘笔画点阵字体只有单一字重)。
- 推送时 `DITHER = False`硬阈值纯黑白图最锐利勿改成 `true` - 推送时 `DITHER = False`硬阈值纯黑白图最锐利勿改成 `true`

View File

@@ -38,6 +38,7 @@ WIDTH, HEIGHT = 400, 300
# ---- reTerminal E1002800x480E Ink Spectra 6 六色;经 SenseCraft HMI 的 HTML 控件云端抓取)---- # ---- reTerminal E1002800x480E Ink Spectra 6 六色;经 SenseCraft HMI 的 HTML 控件云端抓取)----
# 产物:固定 800x480 的 index.html + 同目录 dashboard.png需部署到公网 HTTPS 地址供平台每周期重新拉取。 # 产物:固定 800x480 的 index.html + 同目录 dashboard.png需部署到公网 HTTPS 地址供平台每周期重新拉取。
E1002_WIDTH, E1002_HEIGHT = 800, 480 E1002_WIDTH, E1002_HEIGHT = 800, 480
E1002_COLOR_STRIP = os.environ.get("E1002_COLOR_STRIP", "0") == "1" # 底部六色测试色条(真机校准用,默认关)
# ---- 资源路径 ---- # ---- 资源路径 ----
FONT_PATH = os.path.join(BASE_DIR, "fonts", "fusion-pixel-12px-proportional-zh_hans.otf") FONT_PATH = os.path.join(BASE_DIR, "fonts", "fusion-pixel-12px-proportional-zh_hans.otf")
@@ -78,3 +79,4 @@ TREND_CACHE_PATH = os.path.join(BASE_DIR, ".trend_cache.json")
# 活跃使用时该快照几乎持续刷新、准确;空闲时停在最后一次。文件缺失时用量区显示 "--"。 # 活跃使用时该快照几乎持续刷新、准确;空闲时停在最后一次。文件缺失时用量区显示 "--"。
USAGE_LOCAL_PATH = os.environ.get("USAGE_LOCAL_PATH", os.path.expanduser("~/.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")) # 用量 ≥ 此百分比 → 进度条与百分比转红预警 USAGE_WARN = int(os.environ.get("USAGE_WARN", "80")) # 用量 ≥ 此百分比 → 进度条与百分比转红预警
USAGE_CAUTION = int(os.environ.get("USAGE_CAUTION", "60")) # 仅六色版E1002≥ 此百分比且 < WARN → 进度条黄色填充「注意」

20
data.py
View File

@@ -163,11 +163,25 @@ def _mao(now):
except Exception as e: except Exception as e:
print(f"[警告] 今日经营拉取失败:{e}") print(f"[警告] 今日经营拉取失败:{e}")
cols = [_placeholder_col(k) for k in ("单量", "流水", "毛利")] cols = [_placeholder_col(k) for k in ("单量", "流水", "毛利")]
# 近30天趋势按日期缓存每天只拉一次 # 近30天趋势按日期缓存每天只拉一次;合计每次现算,不进缓存(缓存结构保持不变)
trend = _trend(now) trend = dict(_trend(now))
trend["totals"] = _trend_totals(trend)
return {"upd": upd, "cols": cols, "trend": trend} return {"upd": upd, "cols": cols, "trend": trend}
def _trend_totals(trend):
"""近30天三指标合计 → [(名,文本)]供宽版E1002趋势图标题行展示无数据返回空列表。
单量按千分位计数,流水/毛利按金额紧凑格式≥1万 显示 X.XX万"""
out = []
for s in trend.get("series") or []:
data = s.get("data") or []
if not data:
continue
total = sum(data)
out.append((s["k"], _fmt_count(total) if s["k"] == "单量" else _fmt_money(total)))
return out
# ---------- Claude Code 用量 ---------- # ---------- Claude Code 用量 ----------
def _parse_reset_ts(s): def _parse_reset_ts(s):
"""resets_at → epoch 秒。兼容三种epoch 数字Claude Code stdin 给的就是 epoch """resets_at → epoch 秒。兼容三种epoch 数字Claude Code stdin 给的就是 epoch
@@ -220,7 +234,7 @@ def _usage(now):
if pct is not None and tp is not None: if pct is not None and tp is not None:
bar["time_pct"] = tp bar["time_pct"] = tp
bars.append(bar) bars.append(bar)
return {"title": "Claude Usage", "warn": config.USAGE_WARN, "bars": bars} return {"title": "Claude Usage", "warn": config.USAGE_WARN, "caution": config.USAGE_CAUTION, "bars": bars}
def get_dashboard_data(now=None): def get_dashboard_data(now=None):

View File

@@ -110,15 +110,18 @@ class DashboardRenderer:
self.tri(x + trw / 2, y + 8.5 * k, 7 * k, up=up, fill=val_color); x += trw + tgap 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) self.text(x, y, val, self.F12, fill=val_color)
def _label(self, y, title, right=None): def _label(self, y, title, right=None, x0=None, x1=None):
"""区块标题(两区块统一,对齐主分支 _title_bar 风格): """区块标题(两区块统一,对齐主分支 _title_bar 风格):
■ 方块装饰(8px与 F12 标题墨迹竖直居中) + 标题(F12,常规字重) + 可选右侧信息。""" ■ 方块装饰(8px与 F12 标题墨迹竖直居中) + 标题(F12,常规字重) + 可选右侧信息。
x0/x1标题起点 / 右侧信息右端,默认为页边距 10宽版并排两块时可各自指定"""
k = self.k k = self.k
SQ, M = 8 * k, 10 * k # 方块边长 / 左右页边距 SQ, M = 8 * k, 10 * k # 方块边长 / 左右页边距
self.d.rectangle([M, y + 4 * k, M + SQ, y + 4 * k + SQ], fill=BLACK) # 中心≈y+8对齐 F12 墨迹中心(≈y+8.5) x0 = M if x0 is None else x0
self.text(M + SQ + 6 * k, y, title, self.F12) 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: if right:
self.rtext(self.W - M, y, right, self.F12) self.rtext(x1, y, right, self.F12)
def _mid(self, x, cy, s, ft, fill=BLACK, bold=0, align="l", track=0): def _mid(self, x, cy, s, ft, fill=BLACK, bold=0, align="l", track=0):
"""按字符串「墨迹竖直中心」对齐到 cy 绘制——跨字号(F12/F24)也能真正居中, """按字符串「墨迹竖直中心」对齐到 cy 绘制——跨字号(F12/F24)也能真正居中,
@@ -154,7 +157,7 @@ class DashboardRenderer:
raw = bar.get("pct") raw = bar.get("pct")
ok = isinstance(raw, (int, float)) # None/缺失 → 显示 "--" ok = isinstance(raw, (int, float)) # None/缺失 → 显示 "--"
pct = max(0, min(100, int(raw))) if ok else 0 pct = max(0, min(100, int(raw))) if ok else 0
color = RED if (ok and pct >= warn) else BLACK fill, outline, color = self._usage_colors(ok, pct, warn, bar)
k = self.k k = self.k
LABEL_W, G1, G2, H = 26 * k, 13 * k, 8 * k, 15 * k # G1 进度条↔百分比、G2 百分比↔pace视觉约 12/11 LABEL_W, G1, G2, H = 26 * k, 13 * k, 8 * k, 15 * k # G1 进度条↔百分比、G2 百分比↔pace视觉约 12/11
PCT_TRACK = 2 * k # F16(16/12x 非整数倍缩放)数字墨迹无侧边距、紧贴,手动加字符间距 PCT_TRACK = 2 * k # F16(16/12x 非整数倍缩放)数字墨迹无侧边距、紧贴,手动加字符间距
@@ -170,10 +173,15 @@ class DashboardRenderer:
bx0 = x0 + LABEL_W bx0 = x0 + LABEL_W
bx1 = pct_r - self._tw(pct_txt, self.F16, PCT_TRACK) - G1 bx1 = pct_r - self._tw(pct_txt, self.F16, PCT_TRACK) - G1
by = cy - H // 2 by = cy - H // 2
self.d.rectangle([bx0, by, bx1, by + H], outline=color, width=k) # 轨道边框随条色 self.d.rectangle([bx0, by, bx1, by + H], outline=outline, width=k) # 轨道边框随条色
fw = (bx1 - bx0) * pct / 100.0 fw = (bx1 - bx0) * pct / 100.0
if fw >= 2: if fw >= 2:
self.d.rectangle([bx0, by, bx0 + fw, by + H], fill=color) 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 _loc_pin(self, x, y, w=8, fg=BLACK, bg=WHITE): def _loc_pin(self, x, y, w=8, fg=BLACK, bg=WHITE):
"""定位图标:实心水滴(圆头+尖尾) + 白色挖孔1-bit 下轮廓清晰。 """定位图标:实心水滴(圆头+尖尾) + 白色挖孔1-bit 下轮廓清晰。
@@ -232,11 +240,13 @@ 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.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) 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, accent=None): def _weather_icon(self, x, y, category, s=1.0, fg=BLACK, bg=WHITE, accent=None, wet=None):
"""按天气类别画图标s 为缩放系数,原生约 36x38左上角 x,y """按天气类别画图标s 为缩放系数,原生约 36x38左上角 x,y
accent 用于「有能量」的元素——太阳、闪电——着红;云/雨/雪/雾/月保持 fg(黑)。""" accent 用于「有能量」的元素——太阳、闪电——着红;wet 用于雨滴/雪花(六色版给蓝),默认随 fg
云/雾/月保持 fg"""
S = lambda v: v * s S = lambda v: v * s
accent = accent or fg accent = accent or fg
wet = wet or fg
if category == "sunny": if category == "sunny":
self._sun(x + S(18), y + S(19), S(11), s, accent) self._sun(x + S(18), y + S(19), S(11), s, accent)
@@ -269,14 +279,14 @@ class DashboardRenderer:
self._cloud(x, y + S(15), s, fg, bg) self._cloud(x, y + S(15), s, fg, bg)
if category == "rain": if category == "rain":
for dx in (9, 18, 27): 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": elif category == "snow":
for dx in (10, 19, 28): for dx in (10, 19, 28):
fx, fy, rr = x + S(dx), cb + S(7), S(4) fx, fy, rr = x + S(dx), cb + S(7), S(4)
for a in (0, 60, 120): for a in (0, 60, 120):
rad = math.radians(a) rad = math.radians(a)
self.d.line([fx - math.cos(rad) * rr, fy - math.sin(rad) * rr, 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": elif category == "thunder":
bx = x + S(18) 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)), self.d.polygon([(bx - S(1), cb + S(2)), (bx + S(5), cb + S(2)), (bx + S(1), cb + S(8)),

View File

@@ -1,22 +1,26 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
""" """
reTerminal E1002 渲染层 —— 复用 renderer.DashboardRenderer 的全部组件(k=2 整数倍缩放 reTerminal E1002 渲染层 —— 复用 renderer.DashboardRenderer 的全部组件(**k=1字号与 400x300 版完全相同**
重新排版为 800x480输出 **E Ink Spectra 6 原生六色**(黑/白/黄/红/绿/蓝)纯色图, 利用 800x480 的四倍面积重新排版,输出 **E Ink Spectra 6 原生六色**(黑/白/黄/红/绿/蓝)纯色图,
并生成一张固定 800x480 的 HTML 页面供 SenseCraft HMI 的 HTML 控件云端截图拉取。 并生成一张固定 800x480 的 HTML 页面供 SenseCraft HMI 的 HTML 控件云端截图拉取。
为什么是「固定 800x480 + 一张 PNG」而不是自适应 HTML 版式k=112/24/48px 点阵字体,不放大)
- SenseCraft 的 HTML 控件是云端无头浏览器截图后再量化到六色下发设备。自适应布局在未知视口/DPR 下 A 顶部090 :大时钟+日期(左)|天气(右,贴 790——与三色版同一套组件与尺寸。
会被重排、缩放、抗锯齿,产生大量中间色,最终被平台抖动成网点——恰是我们最不想要的 B 中段96192 :左半 Claude Usage10390竖规 x=400右半 橘喵·今日实时三列410790
- 页面锁死为 800x480、内容就是一张 1:1 的 800x480 PNG`image-rendering: pixelated` C 下段206462橘喵·30 天趋势(全宽,三指标各一色:流水红 / 单量蓝 / 毛利绿),三色版里隐藏的图在此恢复。
只要控件铺满画布、视口 ≥ 800x480截图即与 PNG 逐像素一致;每个像素本来就是六色之一,量化零损失 D 可选470479六色测试色条config.E1002_COLOR_STRIP默认关真机校准色彩量化时打开
- 所有版式/字体/图标仍由 Pillow 在像素网格上控制(点阵字体 + fontmode='1'),与 400x300 版一致锐利。
六色使用原则(比三色版更克制,不为「有」用): 六色的语义分工(每种颜色只有一个含义,不为「有」用):
- 黑/白 承担全部结构与正文;红 延续三色版语义(预警、涨、定位、太阳/闪电 - 黑/白:结构与正文。红:预警、涨、定位、太阳/闪电、趋势主指标(流水)。
- 黄/绿/蓝 目前**只出现在底部测试色条**,用于在真机上校准平台量化是否落到原生色 - 黄用量进度条「注意」档caution ≤ pct < warn填充边框与百分比仍黑黄字压白底不可读故黄只作填充
后续若要启用(如 绿=节余/跌、蓝=雨雪),先在真机看过色条效果再决定 - 绿:趋势「毛利」线。蓝:趋势「单量」线、天气雨滴/雪花
- 整图只允许六种纯色;渲染后可用 `img.getcolors()` 自查。 - 整图只允许六种纯色;渲染后可用 `img.getcolors()` 自查。
为什么是「固定 800x480 + 一张 PNG」而不是自适应 HTML
- SenseCraft 的 HTML 控件是云端无头浏览器截图后再量化到六色下发。自适应布局在未知视口/DPR 下会被重排、
缩放、抗锯齿,产生大量中间色,最终被平台抖动成网点。页面锁死 800x480、内容就是 1:1 的 PNG
`image-rendering: pixelated`),控件铺满画布即逐像素一致,每个像素本就是六色之一,量化零损失。
""" """
import os import os
import time import time
@@ -28,18 +32,32 @@ from renderer import DashboardRenderer, BLACK, WHITE, RED
YELLOW, GREEN, BLUE = (255, 255, 0), (0, 255, 0), (0, 0, 255) YELLOW, GREEN, BLUE = (255, 255, 0), (0, 255, 0), (0, 0, 255)
SPECTRA6 = [BLACK, WHITE, YELLOW, RED, GREEN, BLUE] SPECTRA6 = [BLACK, WHITE, YELLOW, RED, GREEN, BLUE]
# 趋势三指标配色:主指标流水红领读,单量蓝、毛利绿;未列出的指标回退黑
SERIES_COLOR = {"流水": RED, "单量": BLUE, "毛利": GREEN}
class E1002Renderer(DashboardRenderer): class E1002Renderer(DashboardRenderer):
"""800x480 六色版。组件全部继承k=2只重写 `render()` 的版式""" """800x480 六色版。组件全部继承k=1字号不变只重写版式与配色钩子"""
def __init__(self, font_path=config.FONT_PATH): def __init__(self, font_path=config.FONT_PATH):
super().__init__(font_path, size=(config.E1002_WIDTH, config.E1002_HEIGHT), k=2) super().__init__(font_path, size=(config.E1002_WIDTH, config.E1002_HEIGHT), k=1)
# ---------- 配色钩子 ----------
def _usage_colors(self, ok, pct, warn, bar):
"""三档≥warn 整体红caution≤pct<warn 黄填充+黑框黑字|其余黑。"""
if not ok:
return BLACK, BLACK, BLACK
if pct >= warn:
return RED, RED, RED
if pct >= self._caution:
return YELLOW, BLACK, BLACK
return BLACK, BLACK, BLACK
# ---------- 组件 ----------
def _color_strip(self, y0, y1, x0=None, x1=None): def _color_strip(self, y0, y1, x0=None, x1=None):
"""底部六色测试色条:六块等宽实心色块,白块加 1px 黑框以便可见。真机校准用。""" """六色测试色条:六块等宽实心色块,白块加 1px 黑框以便可见。真机校准用。"""
k = self.k x0 = 10 if x0 is None else x0
x0 = 10 * k if x0 is None else x0 x1 = self.W - 10 if x1 is None else x1
x1 = self.W - 10 * k if x1 is None else x1
n = len(SPECTRA6) n = len(SPECTRA6)
w = (x1 - x0) / n w = (x1 - x0) / n
for i, c in enumerate(SPECTRA6): for i, c in enumerate(SPECTRA6):
@@ -48,75 +66,129 @@ class E1002Renderer(DashboardRenderer):
if c == WHITE: if c == WHITE:
self.d.rectangle([bx0, y0, bx1, y1], outline=BLACK, width=1) self.d.rectangle([bx0, y0, bx1, y1], outline=BLACK, width=1)
def _trend_chart6(self, y0, y1, trend):
"""近30天三指标折线六色版标题+ 彩色图例(右)+ 折线 + X 轴。
与三色版 `_trend_chart` 的差别:用**颜色**而非线型区分指标(全部实线,主指标 2px
绘图区高度由 y0y1 决定以填满下半屏。各指标按自身极值独立归一化,只表达趋势形状。"""
dates, series = trend.get("dates") or [], trend.get("series") or []
title = "橘喵·30天趋势"
self._label(y0, title)
if not series or not dates or not any(s.get("data") for s in series):
self.ctext(self.W / 2, (y0 + y1) / 2 - 12, "--", self.F24)
return
# ---- 合计(紧随标题):「合计 单量 12,345 流水 123.45万 毛利 12.34万」,数值伪粗体 ----
totals = trend.get("totals") or []
if totals:
x = 10 + 8 + 6 + self._tw(title, self.F12) + 24
self.text(x, y0, "合计", self.F12); x += self._tw("合计", self.F12) + 12
for name, val in totals:
self.text(x, y0, name, self.F12); x += self._tw(name, self.F12) + 4
self.text(x, y0, val, self.F12, bold=1); x += self._tw(val, self.F12) + 1 + 14
# ---- 图例(右):色块样线 + 指标名,从右往左排布 ----
SAMP, G1, GAP = 16, 8, 12
x = self.W - 10
for s in reversed(series):
c = SERIES_COLOR.get(s.get("k"), BLACK)
self.rtext(x, y0, s["k"], self.F12, fill=c)
x -= self._tw(s["k"], self.F12) + G1
self.d.line([x - SAMP, y0 + 7, x, y0 + 7], fill=c, width=2)
x -= SAMP + GAP
# ---- 绘图区 ----
px0, px1 = 14, self.W - 10
py0, py1 = y0 + 24, y1 - 16 # 底部留 16px 给 X 轴日期
self.hdash(px0, px1, py1, dash=2, gap=3) # 基线
n = len(dates)
xstep = (px1 - px0) / (n - 1) if n > 1 else 0
# 周界竖向点规:每 7 个点一道,帮助读「第几周」,不与折线争抢
for i in range(7, n, 7):
self.vdash(px0 + xstep * i, py0, py1, dash=1, gap=3)
# 先画次要指标,最后画主指标(流水)压顶
order = sorted(series, key=lambda s: s.get("k") == "流水")
for s in order:
color = SERIES_COLOR.get(s.get("k"), BLACK)
data = s["data"]
lo, hi = min(data), max(data)
span = (hi - lo) or 1
pad = 6
pts = []
for i, v in enumerate(data):
xv = px0 + xstep * i
yv = py1 - pad - (v - lo) / span * (py1 - py0 - 2 * pad)
pts.append((xv, yv))
w = 2 if color == RED else 1
for (ax, ay), (bx, by) in zip(pts, pts[1:]):
self.d.line([ax, ay, bx, by], fill=color, width=w)
if color == RED and pts: # 主指标最新值标点:“今日 · 你在此处”
ex, ey = pts[-1]
self.d.ellipse([ex - 3, ey - 3, ex + 3, ey + 3], fill=RED)
# ---- X 轴标签:首 / 中 / 末 ----
xl_y = py1 + 3
self.text(px0, xl_y, dates[0], self.F12)
self.ctext((px0 + px1) / 2, xl_y, dates[n // 2], self.F12)
self.rtext(px1, xl_y, dates[-1], self.F12)
# ---------- 入口 ----------
def render(self, data): def render(self, data):
"""800x480 版式(自上而下):
字体属性沿用 k=1 的层级名F12/F24/F48k=2 下实际为 24/48/96px。
A 顶部0112大时钟F48+ 同行右侧日期F12定位·温度F24·天气/温区F12+ 矢量图标s=2.0
B 中部124258Claude Usage 标题 + 两条用量行k=2 组件原样放大)
C 底部272440橘喵今日实时三列名 F12 / 值 F24 粗 / 环比·同比 F12
D 色条452480Spectra 6 六色测试块
"""
from PIL import Image, ImageDraw from PIL import Image, ImageDraw
self.img = Image.new("RGB", (self.W, self.H), WHITE) self.img = Image.new("RGB", (self.W, self.H), WHITE)
self.d = ImageDraw.Draw(self.img) self.d = ImageDraw.Draw(self.img)
self.d.fontmode = "1" self.d.fontmode = "1"
k = self.k R = self.W - 10 # 右页边 790
M = 10 * k # 页边距
L, R = M, self.W - M # 内容左右边界 20 / 780
dt, wt, mao, usage = data["date"], data["weather"], data["mao"], data["usage"] dt, wt, mao, usage = data["date"], data["weather"], data["mao"], data["usage"]
self._caution = usage.get("caution", config.USAGE_CAUTION)
# ---- A. 顶部:时钟 + 日期(左,同一基线/ 天气(右)---- # ---- A. 顶部:时钟+日期(左)/ 天气(右)——与三色版同尺寸 ----
DIV_Y = 112 DIV_Y = 90
clock = dt["time"] self.text(10, 0, dt["time"], self.F48, bold=2)
self.text(L, 4, clock, self.F48, bold=2 * k) self.text(12, 64, f'{dt["week"]} {dt["greg"]}', self.F12)
cw = self._tw(clock, self.F48) + 2 * k
# 日期紧随时钟右侧按墨迹底沿对齐时钟数字底沿F48 字身框内数字底沿≈ y+4+ 字号*0.83
_, _, _, cb = self.F48.getbbox(clock)
_, _, _, db = self.F12.getbbox("2026/09/09")
self.text(L + cw + 12 * k, 4 + cb - db, f'{dt["week"]} {dt["greg"]}', self.F12)
loc, temp, cr = wt["loc"], wt["temp"], f'{wt["cond"]} {wt["range"]}' loc, temp, cr = wt["loc"], wt["temp"], f'{wt["cond"]} {wt["range"]}'
self.rtext(R, 8, loc, self.F12) self.rtext(R, 8, loc, self.F12)
self.rtext(R, 34, temp, self.F24, bold=k) self.rtext(R, 30, temp, self.F24, bold=1)
self.rtext(R, 82, cr, self.F12) self.rtext(R, 64, cr, self.F12)
PIN_W, PIN_GAP = 8 * k, 6 * k PIN_W, PIN_GAP = 8, 6
loc_pin_x = R - self._tw(loc, self.F12) - PIN_GAP - PIN_W loc_pin_x = R - self._tw(loc, self.F12) - PIN_GAP - PIN_W
self._loc_pin(loc_pin_x, 12, PIN_W, fg=RED) self._loc_pin(loc_pin_x, 10, PIN_W, fg=RED)
s = 2.0 # 图标标称 36x38雷阵雨含闪电实高 46s=2 时 92px 仍落在 DIV_Y 之上,再大会压线 s = 1.5
iw, ih = 36 * s, 38 * s iw, ih = 36 * s, 38 * s
text_left = min(loc_pin_x, R - self._tw(temp, self.F24) - k, R - self._tw(cr, self.F12)) text_left = min(loc_pin_x, R - self._tw(temp, self.F24) - 1, R - self._tw(cr, self.F12))
self._weather_icon(text_left - 14 * k - iw, (DIV_Y - ih) / 2, self._weather_icon(text_left - 12 - iw, (DIV_Y - ih) / 2,
self._weather_category(wt.get("icon")), s=s, accent=RED) self._weather_category(wt.get("icon")), s=s, accent=RED, wet=BLUE)
self.hdash(8 * k, self.W - 8 * k, DIV_Y, dash=4 * k, gap=4 * k, width=k) self.hdash(8, self.W - 8, DIV_Y)
# ---- B. 中部主角:Claude Usage ---- # ---- B. 中段:左 Claude Usage 右 橘喵·今日实时 ----
self._label(DIV_Y + 12, usage.get("title", "Claude Usage")) MID = self.W // 2 # 400
self._label(96, usage.get("title", "Claude Usage"))
warn = usage.get("warn", 80) warn = usage.get("warn", 80)
ry = DIV_Y + 48 ry = 123
for b in usage.get("bars", []): for b in usage.get("bars", []):
self._usage_row(L, R, ry, b, warn) self._usage_row(10, MID - 10, ry, b, warn)
ry += 54 ry += 31
DIV2_Y = 266 self.vdash(MID, 96, 194)
self.hdash(8 * k, self.W - 8 * k, DIV2_Y, dash=4 * k, gap=4 * k, width=k)
# ---- C. 底部:橘喵·今日实时 三列 ---- lx0, lx1 = MID + 10, R
ly = DIV2_Y + 10 self._label(96, "橘喵·今日实时", right=f'上次更新 {mao["upd"]}', x0=lx0, x1=lx1)
self._label(ly, "橘喵·今日实时", right=f'上次更新 {mao["upd"]}')
cols = mao["cols"]; n = len(cols) cols = mao["cols"]; n = len(cols)
ax0, ax1 = 8 * k, self.W - 8 * k ax0, ax1 = MID + 8, self.W - 8
cw = (ax1 - ax0) / n cw = (ax1 - ax0) / n
for i in range(1, n): for i in range(1, n):
self.vdash(ax0 + cw * i, ly + 28, ly + 166, dash=4 * k, gap=4 * k, width=k) self.vdash(ax0 + cw * i, 128, 192)
for i, c in enumerate(cols): for i, c in enumerate(cols):
cx = ax0 + cw * (i + 0.5) cx = ax0 + cw * (i + 0.5)
self.ctext(cx, ly + 32, c["k"], self.F12) # 指标名 self.ctext(cx, 114, c["k"], self.F12) # 指标名(标题+18
self.ctext(cx, ly + 60, c["v"], self.F24, bold=k) # 数值 伪粗体 self.ctext(cx, 130, c["v"], self.F24, bold=1) # 数值 伪粗体+34
self._cmp_line(cx, ly + 110, "环比", c["hb"]) self._cmp_line(cx, 160, "环比", c["hb"]) # +64
self._cmp_line(cx, ly + 138, "同比", c["tb"]) self._cmp_line(cx, 178, "同比", c["tb"]) # +82
DIV2_Y = 200
self.hdash(8, self.W - 8, DIV2_Y)
# ---- D. 六色测试色条 ---- # ---- C. 下段:近 30 天趋势(全宽)----
self._color_strip(self.H - 28, self.H - 1) strip = config.E1002_COLOR_STRIP
self._trend_chart6(DIV2_Y + 6, self.H - (18 if strip else 8), mao.get("trend") or {})
# ---- D. 可选:六色测试色条 ----
if strip:
self._color_strip(self.H - 10, self.H - 1)
return self.img return self.img