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>
This commit is contained in:
10
CLAUDE.md
10
CLAUDE.md
@@ -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=1 字号与三色版完全相同(不放大,放大后字太大很傻)**。版面定位为 **AI 用量看板**:顶部时钟/天气不变;主体左 **Claude Usage 三行(5h / 7d / Fable 7d)**、右 **ChatGPT Usage(目前 mock,标「示例数据」)**,每行是一张「用量卡」(标签 + F24 百分比 + pace / 全宽进度条 / 重置时刻 + 已过时间)。橘喵今日实时与 30 天趋势图**不在**本版面(业务数据留给 400×300 小屏)。六色语义:红=预警/pace 超前/定位/太阳闪电,黄=用量「注意」档填充,蓝=雨雪,绿暂未用。
|
> **分支 `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 字号与三色版完全相同(不放大,放大后字太大很傻)**,四段版式:A 时钟/天气;B 左 **Claude Usage 三行(all 5h / all 7d / fable 7d,标签沿用 statusline 写法、小写开头)**、右 **ChatGPT Usage(mock,标「示例数据」)**,紧凑单行样式(曾试过撑满高度的大卡片,被否);C **橘喵·今日实时**三列全宽(指标名 F12 作每列左上角角标;「值 F36 粗 + 右侧叠放的环比/同比」整组在列内居中——名不放在值上方,否则小字压大数字、区块上半显空;也不与值同行,否则名参与居中把整组推偏);D **橘喵·近7天**数据表(真实数据,取 30 天趋势缓存最后 7 天;**不画折线图**,用户明确不要)。六色语义:用量进度条(含边框)与 pace 各自**三档**——绿=健康(用量 ≤ `USAGE_OK` 30 / pace ≤ −`PACE_TOL` 10)、黑=正常、红=危险(用量 ≥ `USAGE_WARN` 80 / pace ≥ +10);百分比数字只在红档转红(绿小字在白底发虚)。红另用于涨/定位/太阳闪电,蓝=雨雪;**黄不用**——用户明确不要黄色进度条(曾有 60%~80% 黄「注意」档,已移除)。
|
||||||
|
|
||||||
## 命令
|
## 命令
|
||||||
|
|
||||||
@@ -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`/`USAGE_CAUTION`(仅 E1002 黄档)、`E1002_COLOR_STRIP`。
|
**配置统一走 `.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`、`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?, reset?, scoped}`、`usage.warn`/`usage.caution` 为转红/转黄阈值、`usage.updated` 为快照落盘时刻;`usage_gpt` 与 `usage` 同构,目前是 **mock**(`mock: True`),接真实数据源时替换 `_usage_gpt()` 即可)。注:`_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(短标签,小屏用), 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.trend` 两个版面都不画折线;宽版用 `_recent_days(trend, 7)` 得到 `mao.week`(结构 `{dates, rows:[(name, [text...], total)]}`)做「近7天」数据表。
|
||||||
- **缓存**(为 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=pct−time_pct`(>0 超前↑ / <0 节余↓);故即使 `utilization` 来自可能滞后的本地缓存,pace 仍随时钟准确推进。5h 窗口 18000s、7d 窗口 604800s。
|
- **用量的 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 <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**(本机 `~/.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`,改为读本地快照以免网络请求。)
|
- **`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()`(用量行 填充/边框/文字 三色元组)、`_weather_icon(wet=)`(降水元素颜色);`_label(x0,x1)` 可指定左右边界。`render_to_file()` 落盘。布局硬编码为**上中下三段**,两道 `hdash` 横规分隔(y=90 / 200):
|
- **`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 顶部**(0–90):大时钟(F48)+日期(左)/ 天气(右:红定位水滴 + 温度 + 天气·温区 + 全矢量图标)。
|
- **A 顶部**(0–90):大时钟(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=1** 复用组件(字号 12/24/48 与三色版一致),只重写版式与配色钩子。版式:A 顶部 0–90 时钟/日期左、天气右(贴 x=790);B 主体 y=100 起,左半 `usage`(10–386)|竖规 x=400|右半 `usage_gpt`(414–790),各自一个 `_usage_block`(`_label` 标题 + 右侧「快照 HH:MM」或 mock 的「示例数据」,然后逐行 `_usage_card`,行距 116)。`_usage_card` 三层:① 标签 F13 粗 + 百分比 F24 粗 + `_pace` 三角 ② 全宽进度条 H=14 ③ F12 说明行「重置 …」/「已过 NN%」;pct 为 None 显示 `--`。配色钩子 `_usage_colors` 三档:≥`USAGE_WARN` 红|≥`USAGE_CAUTION`(默认 60)黄填充+黑框黑字|其余黑——黄字压白底不可读,黄只作填充。天气图标 `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 天趋势」版,均已按用户反馈弃用。
|
- **`renderer_e1002.py`** — reTerminal E1002 目标:`E1002Renderer(DashboardRenderer)` 以 **k=1** 复用组件(字号 12/24/48 与三色版一致),只重写版式与配色钩子。四段、三道横规,各段 y 由上一段实际底部推算:A 顶部 0–90 时钟/日期左、天气右(贴 x=790);B 用量 `_usage_block`×2(左 `usage` 10–386|竖规 x=400|右 `usage_gpt` 414–790):`_label` 标题 + 右侧「快照 HH:MM」或 mock 的「示例数据」,行用基类紧凑 `_usage_row`(行距 31,宽版把 `bar["label"]` 长标签代入 `k`,`label_w` 按本块最长标签自适应);C `_realtime` 今日实时三列全宽:指标名 F12 贴列左上角(x=列左+12,y=y0+22)作角标;「值 F36 粗 + 右侧叠放的环比/同比(`_cmp_at` 左对齐版,行距 18)」按墨迹中心对齐到组中线 y0+56、整组在列内水平居中(不含角标);D `_week_table` 近 7 天表(`mao.week`:指标名列 + 7 个日期 + 合计列,行 单量/流水/毛利,全 F12 右对齐、合计伪粗体、合计列前一道竖点规、表头下一道点规;无数据居中 `--`)。配色钩子覆写:`_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 天折线」版;「用量大卡片撑满」版。
|
||||||
- **`config.py`** — 顶部 `_load_dotenv()` 先加载同目录 `.env`,再定义 API、设备、画布尺寸、字体路径、推送参数;凭证 fallback 为空串,真实值来自 `.env`/环境变量。
|
- **`config.py`** — 顶部 `_load_dotenv()` 先加载同目录 `.env`,再定义 API、设备、画布尺寸、字体路径、推送参数;凭证 fallback 为空串,真实值来自 `.env`/环境变量。
|
||||||
|
|
||||||
## 墨水屏渲染约束(改 renderer.py 必读)
|
## 墨水屏渲染约束(改 renderer.py 必读)
|
||||||
|
|||||||
@@ -78,5 +78,7 @@ TREND_CACHE_PATH = os.path.join(BASE_DIR, ".trend_cache.json")
|
|||||||
# (five_hour/seven_day 的 utilization + resets_at)落盘到 ~/.claude/usage-snapshot.json;本项目读它。
|
# (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_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 → 进度条黄色填充「注意」
|
# 以下两项仅六色宽版(E1002)使用,三色小屏只有 黑/红 两档:
|
||||||
|
USAGE_OK = int(os.environ.get("USAGE_OK", "30")) # 用量 ≤ 此百分比 → 进度条(含边框)绿色「健康」;介于 OK~WARN 之间为黑
|
||||||
|
PACE_TOL = int(os.environ.get("PACE_TOL", "10")) # pace=用量%−时间%:≥ +TOL 红(超前过多)/ ≤ −TOL 绿(节余充足)/ 其间黑
|
||||||
|
|||||||
35
data.py
35
data.py
@@ -218,13 +218,14 @@ def _fmt_reset(resets_at, window):
|
|||||||
return dt.strftime("%H:%M") if window <= 86400 else dt.strftime("%m/%d %H:%M")
|
return dt.strftime("%H:%M") if window <= 86400 else dt.strftime("%m/%d %H:%M")
|
||||||
|
|
||||||
|
|
||||||
# Claude 用量窗口:(快照键,行标签,窗口长度秒,scoped)。scoped=True 为「按模型限定」的额度
|
# Claude 用量窗口:(快照键,短标签,长标签,窗口长度秒,scoped)。
|
||||||
# (Fable 7d,来自 /api/oauth/usage 的 limits[],由 statusline 写入快照 seven_day_fable),
|
# 短标签 k 给 400x300 小屏(标签列只有 26px);长标签 label 给 800x480 宽版,沿用 statusline 的写法
|
||||||
# 400x300 三色小屏版面放不下第三行,只在 800x480 宽版展示。
|
# (all 5h / all 7d / fable 7d,小写开头)。scoped=True 为「按模型限定」的额度(Fable 7d,来自
|
||||||
|
# /api/oauth/usage 的 limits[],由 statusline 写入快照 seven_day_fable),小屏放不下第三行,只在宽版展示。
|
||||||
USAGE_WINDOWS = (
|
USAGE_WINDOWS = (
|
||||||
("five_hour", "5h", FIVE_HOUR, False),
|
("five_hour", "5h", "all 5h", FIVE_HOUR, False),
|
||||||
("seven_day", "7d", SEVEN_DAY, False),
|
("seven_day", "7d", "all 7d", SEVEN_DAY, False),
|
||||||
("seven_day_fable", "Fable 7d", SEVEN_DAY, True),
|
("seven_day_fable", "Fable 7d", "fable 7d", SEVEN_DAY, True),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -234,8 +235,8 @@ def _usage(now):
|
|||||||
utilization 来自 statusline 从 Claude Code stdin 落盘的权威实时值(Fable 行来自其转存的 API limits);
|
utilization 来自 statusline 从 Claude Code stdin 落盘的权威实时值(Fable 行来自其转存的 API limits);
|
||||||
time_pct(时间已流逝%) 与 pace 每次按当前时间**现算**(resets_at 为绝对时间)。
|
time_pct(时间已流逝%) 与 pace 每次按当前时间**现算**(resets_at 为绝对时间)。
|
||||||
文件缺失/损坏 → 回退占位 pct=None(renderer 显示 "--");快照里没有某窗口(如旧版 statusline 无 Fable)同样 "--"。
|
文件缺失/损坏 → 回退占位 pct=None(renderer 显示 "--");快照里没有某窗口(如旧版 statusline 无 Fable)同样 "--"。
|
||||||
结构契约(renderer 依赖):{title, warn, caution, updated?, bars:[{k, pct(0~100 或 None), time_pct?, reset?, scoped}]};
|
结构契约(renderer 依赖):{title, warn, ok, pace_tol, updated?, bars:[{k, label, pct(0~100 或 None), time_pct?, reset?, scoped}]};
|
||||||
pct ≥ warn 时该条进度条与百分比转红;pace = pct − time_pct(>0 超前↑ / <0 节余↓);
|
pct ≥ warn 时该条进度条与百分比转红(宽版另有 pct ≤ ok 绿档);pace = pct − time_pct(>0 超前↑ / <0 节余↓,宽版按 ±pace_tol 分三色);
|
||||||
reset 为重置时刻文本;updated 为快照落盘时刻 HH:MM(活跃使用时持续刷新,空闲时停在最后一次)。
|
reset 为重置时刻文本;updated 为快照落盘时刻 HH:MM(活跃使用时持续刷新,空闲时停在最后一次)。
|
||||||
"""
|
"""
|
||||||
raw = {}
|
raw = {}
|
||||||
@@ -245,11 +246,11 @@ def _usage(now):
|
|||||||
print(f"[警告] Claude 用量读取失败:{e}")
|
print(f"[警告] Claude 用量读取失败:{e}")
|
||||||
|
|
||||||
bars = []
|
bars = []
|
||||||
for key, label, win, scoped in USAGE_WINDOWS:
|
for key, k, label, win, scoped in USAGE_WINDOWS:
|
||||||
seg = raw.get(key) or {}
|
seg = raw.get(key) or {}
|
||||||
util = seg.get("utilization")
|
util = seg.get("utilization")
|
||||||
pct = int(round(float(util))) if util is not None else None
|
pct = int(round(float(util))) if util is not None else None
|
||||||
bar = {"k": label, "pct": pct, "scoped": scoped}
|
bar = {"k": k, "label": label, "pct": pct, "scoped": scoped}
|
||||||
tp = _window_time_pct(seg.get("resets_at"), win, now)
|
tp = _window_time_pct(seg.get("resets_at"), win, 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
|
||||||
@@ -257,7 +258,7 @@ def _usage(now):
|
|||||||
if rs:
|
if rs:
|
||||||
bar["reset"] = rs
|
bar["reset"] = rs
|
||||||
bars.append(bar)
|
bars.append(bar)
|
||||||
out = {"title": "Claude Usage", "warn": config.USAGE_WARN, "caution": config.USAGE_CAUTION, "bars": bars}
|
out = {"title": "Claude Usage", "warn": config.USAGE_WARN, "ok": config.USAGE_OK, "pace_tol": config.PACE_TOL, "bars": bars}
|
||||||
upd = _parse_reset_ts(raw.get("updated_at"))
|
upd = _parse_reset_ts(raw.get("updated_at"))
|
||||||
if upd:
|
if upd:
|
||||||
out["updated"] = datetime.fromtimestamp(upd).strftime("%H:%M")
|
out["updated"] = datetime.fromtimestamp(upd).strftime("%H:%M")
|
||||||
@@ -267,16 +268,16 @@ def _usage(now):
|
|||||||
def _usage_gpt(now):
|
def _usage_gpt(now):
|
||||||
"""ChatGPT 用量——**目前为 mock 占位**(mock=True,renderer 在标题右侧标「示例数据」)。
|
"""ChatGPT 用量——**目前为 mock 占位**(mock=True,renderer 在标题右侧标「示例数据」)。
|
||||||
结构与 _usage 完全一致,后续接入真实数据源时只需替换本函数、保持返回结构不变。
|
结构与 _usage 完全一致,后续接入真实数据源时只需替换本函数、保持返回结构不变。
|
||||||
mock 取值刻意覆盖多种配色档:5h 42%(黑)、7d 71%(黄「注意」)、Codex 7d 55%(黑,scoped 与左侧 Fable 行对称)。"""
|
mock 取值刻意覆盖宽版三档配色:5h 88%(≥warn 红)、7d 71%(黑)、Codex 7d 22%(≤ok 绿,scoped,与左侧 fable 行对称)。"""
|
||||||
bars = []
|
bars = []
|
||||||
for label, pct, win, remain, scoped in (("5h", 42, FIVE_HOUR, 2 * 3600, False),
|
for k, label, pct, win, remain, scoped in (("5h", "all 5h", 88, FIVE_HOUR, 2 * 3600, False),
|
||||||
("7d", 71, SEVEN_DAY, 3 * 86400, False),
|
("7d", "all 7d", 71, SEVEN_DAY, 3 * 86400, False),
|
||||||
("Codex 7d", 55, SEVEN_DAY, 3 * 86400, True)):
|
("Codex 7d", "codex 7d", 22, SEVEN_DAY, 3 * 86400, True)):
|
||||||
reset_ts = now.timestamp() + remain
|
reset_ts = now.timestamp() + remain
|
||||||
bars.append({"k": label, "pct": pct, "scoped": scoped,
|
bars.append({"k": k, "label": label, "pct": pct, "scoped": scoped,
|
||||||
"time_pct": _window_time_pct(reset_ts, win, now),
|
"time_pct": _window_time_pct(reset_ts, win, now),
|
||||||
"reset": _fmt_reset(reset_ts, win)})
|
"reset": _fmt_reset(reset_ts, win)})
|
||||||
return {"title": "ChatGPT Usage", "warn": config.USAGE_WARN, "caution": config.USAGE_CAUTION,
|
return {"title": "ChatGPT Usage", "warn": config.USAGE_WARN, "ok": config.USAGE_OK, "pace_tol": config.PACE_TOL,
|
||||||
"bars": bars, "mock": True}
|
"bars": bars, "mock": True}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
25
renderer.py
25
renderer.py
@@ -41,6 +41,7 @@ class DashboardRenderer:
|
|||||||
self.F13 = ImageFont.truetype(font_path, 13 * k) # 5h/7d 与 pace 用;接近原生 12px,畸变很小
|
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.F16 = ImageFont.truetype(font_path, 16 * k) # 非整数倍(1.33x):靠 fontmode='1' 保持纯色,笔画略不均可接受
|
||||||
self.F24 = ImageFont.truetype(font_path, 24 * k)
|
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)
|
self.F48 = ImageFont.truetype(font_path, 48 * k)
|
||||||
|
|
||||||
# ---------- 基础绘制工具 ----------
|
# ---------- 基础绘制工具 ----------
|
||||||
@@ -94,13 +95,23 @@ class DashboardRenderer:
|
|||||||
self.d.polygon(pts, fill=fill)
|
self.d.polygon(pts, fill=fill)
|
||||||
|
|
||||||
# ---------- 组件 ----------
|
# ---------- 组件 ----------
|
||||||
def _cmp_line(self, cx, y, label, pair, fg=BLACK):
|
def _cmp_w(self, label, pair):
|
||||||
"""环比/同比一行:标签 + 涨跌三角 + 数值,整体居中。direction 为 None 时不画三角。"""
|
"""环比/同比一行的总宽度(标签 + 间隙 + 三角 + 数值),供居中/右对齐排布。"""
|
||||||
direction, val = pair
|
direction, val = pair
|
||||||
k = self.k
|
k = self.k
|
||||||
lw = self._tw(label, self.F12); gap = 5 * k; nw = self._tw(val, self.F12)
|
|
||||||
trw, tgap = (8 * k, 4 * k) if direction else (0, 0)
|
trw, tgap = (8 * k, 4 * k) if direction else (0, 0)
|
||||||
x = cx - (lw + gap + trw + tgap + nw) / 2
|
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
|
self.text(x, y, label, self.F12, fill=fg); x += lw + gap
|
||||||
# 方向语义配色(红涨):涨 → 箭头与百分比数值一起红;跌/无 → 黑(标签始终黑)
|
# 方向语义配色(红涨):涨 → 箭头与百分比数值一起红;跌/无 → 黑(标签始终黑)
|
||||||
up = direction == "up"
|
up = direction == "up"
|
||||||
@@ -140,7 +151,7 @@ class DashboardRenderer:
|
|||||||
"""时间维度对比(pace = 用量% − 时间%):>0 超前↑红;<0 节余↓黑;=0 持平。
|
"""时间维度对比(pace = 用量% − 时间%):>0 超前↑红;<0 节余↓黑;=0 持平。
|
||||||
数值(F13)两位补零、右端对齐 xr,小三角(6)紧贴其左;均竖直居中 cy。返回整体宽度供左侧定位。"""
|
数值(F13)两位补零、右端对齐 xr,小三角(6)紧贴其左;均竖直居中 cy。返回整体宽度供左侧定位。"""
|
||||||
up = diff > 0
|
up = diff > 0
|
||||||
color = RED if up else BLACK
|
color = self._pace_color(diff)
|
||||||
txt = f"{abs(int(diff)):02d}"
|
txt = f"{abs(int(diff)):02d}"
|
||||||
tw = self._tw(txt, self.F13)
|
tw = self._tw(txt, self.F13)
|
||||||
self._mid(xr, cy, txt, self.F13, fill=color, bold=self.k, align="r")
|
self._mid(xr, cy, txt, self.F13, fill=color, bold=self.k, align="r")
|
||||||
@@ -185,6 +196,10 @@ class DashboardRenderer:
|
|||||||
c = RED if (ok and pct >= warn) else BLACK
|
c = RED if (ok and pct >= warn) else BLACK
|
||||||
return c, c, c
|
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):
|
def _loc_pin(self, x, y, w=8, fg=BLACK, bg=WHITE):
|
||||||
"""定位图标:实心水滴(圆头+尖尾) + 白色挖孔,1-bit 下轮廓清晰。
|
"""定位图标:实心水滴(圆头+尖尾) + 白色挖孔,1-bit 下轮廓清晰。
|
||||||
(x,y) 为头部圆左上角,w 为头径,总高约 w*1.5。"""
|
(x,y) 为头部圆左上角,w 为头径,总高约 w*1.5。"""
|
||||||
|
|||||||
@@ -7,17 +7,17 @@ reTerminal E1002 渲染层 —— 复用 renderer.DashboardRenderer 的组件(
|
|||||||
|
|
||||||
版式(k=1,12/24/48px 点阵字体,不放大;四段,三道横规):
|
版式(k=1,12/24/48px 点阵字体,不放大;四段,三道横规):
|
||||||
A 顶部(0~90) :大时钟+日期(左)|天气(右,贴 790)——与三色版同一套组件与尺寸。
|
A 顶部(0~90) :大时钟+日期(左)|天气(右,贴 790)——与三色版同一套组件与尺寸。
|
||||||
B 用量(96~206) :左半 **Claude Usage**(5h / 7d / Fable 7d)|竖规 x=400|右半 **ChatGPT Usage**(mock:5h / 7d / Codex 7d)。
|
B 用量(96~206) :左半 **Claude Usage**(all 5h / all 7d / fable 7d)|竖规 x=400|右半 **ChatGPT Usage**(mock:all 5h / all 7d / codex 7d)。
|
||||||
行组件沿用三色版的紧凑单行 `_usage_row`(行距 31),标签列宽按最长标签自适应。
|
行组件沿用三色版的紧凑单行 `_usage_row`(行距 31),标签用 bar["label"](statusline 同款写法),列宽按最长标签自适应。
|
||||||
C 今日(218~314):**橘喵·今日实时** 三列全宽(名 / 值 F24 粗 / 环比·同比),版式同三色版。
|
C 今日(218~304):**橘喵·今日实时** 三列全宽:指标名 F12 作左上角角标;「值 F36 粗 + 右侧叠放的环比/同比」整组居中。
|
||||||
D 近7天(328~438):**橘喵·近7天** 数据表(真实数据:30 天趋势缓存的最后 7 天;不画折线)——
|
D 近7天(328~438):**橘喵·近7天** 数据表(真实数据:30 天趋势缓存的最后 7 天;不画折线)——
|
||||||
列 = 指标名 + 7 个日期 + 合计,行 = 单量 / 流水 / 毛利;合计列伪粗体。
|
列 = 指标名 + 7 个日期 + 合计,行 = 单量 / 流水 / 毛利;合计列伪粗体。
|
||||||
可选(470~479):六色测试色条(config.E1002_COLOR_STRIP,默认关;真机校准色彩量化时打开)。
|
可选(470~479):六色测试色条(config.E1002_COLOR_STRIP,默认关;真机校准色彩量化时打开)。
|
||||||
|
|
||||||
六色的语义分工(每种颜色只有一个含义,不为「有」而用):
|
六色的语义分工(每种颜色只有一个含义,不为「有」而用):
|
||||||
- 黑/白:结构与正文。红:预警(pct≥warn)、pace 超前、定位、太阳/闪电。
|
- 黑/白:结构与正文。红:危险(用量 ≥ warn 的进度条+边框+百分比、pace ≥ +tol)、涨、定位、太阳/闪电。
|
||||||
- 黄:用量进度条「注意」档(caution ≤ pct < warn)填充,边框与百分比仍黑(黄字压白底不可读,故黄只作填充)。
|
- 绿:健康(用量 ≤ ok 的进度条+边框、pace ≤ −tol)。用量与 pace 各自三档:绿 / 黑 / 红(阈值 USAGE_OK / USAGE_WARN / PACE_TOL)。
|
||||||
- 蓝:天气雨滴/雪花。绿:暂未使用(保留给后续语义,例如「节余」)。
|
- 蓝:天气雨滴/雪花。黄:不用(用户明确不要黄色进度条;曾试过 60%~80% 黄「注意」档,已移除)。
|
||||||
- 整图只允许六种纯色;渲染后可用 `img.getcolors()` 自查。
|
- 整图只允许六种纯色;渲染后可用 `img.getcolors()` 自查。
|
||||||
|
|
||||||
为什么是「固定 800x480 + 一张 PNG」而不是自适应 HTML:
|
为什么是「固定 800x480 + 一张 PNG」而不是自适应 HTML:
|
||||||
@@ -30,6 +30,7 @@ import time
|
|||||||
|
|
||||||
import config
|
import config
|
||||||
from renderer import DashboardRenderer, BLACK, WHITE, RED
|
from renderer import DashboardRenderer, BLACK, WHITE, RED
|
||||||
|
import config as _cfg
|
||||||
|
|
||||||
# E Ink Spectra 6 原生六色。取纯饱和值作为给平台量化器的明确信号(与 ESPHome/GxEPD2 驱动的 BWYRGB 映射一致)。
|
# E Ink Spectra 6 原生六色。取纯饱和值作为给平台量化器的明确信号(与 ESPHome/GxEPD2 驱动的 BWYRGB 映射一致)。
|
||||||
YELLOW, GREEN, BLUE = (255, 255, 0), (0, 255, 0), (0, 0, 255)
|
YELLOW, GREEN, BLUE = (255, 255, 0), (0, 255, 0), (0, 0, 255)
|
||||||
@@ -42,17 +43,28 @@ class E1002Renderer(DashboardRenderer):
|
|||||||
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=1)
|
super().__init__(font_path, size=(config.E1002_WIDTH, config.E1002_HEIGHT), k=1)
|
||||||
|
|
||||||
# ---------- 配色钩子 ----------
|
# ---------- 配色钩子(三档:绿 健康 / 黑 正常 / 红 危险)----------
|
||||||
|
_ok, _pace_tol = _cfg.USAGE_OK, _cfg.PACE_TOL # 由 _usage_block 按数据覆盖
|
||||||
|
|
||||||
def _usage_colors(self, ok, pct, warn, bar):
|
def _usage_colors(self, ok, pct, warn, bar):
|
||||||
"""三档:≥warn 整体红|caution≤pct<warn 黄填充+黑框黑字|其余黑。返回(填充,边框,文字)。"""
|
"""进度条填充与边框同色:pct ≥ warn 红|pct ≤ ok 绿|其间黑。
|
||||||
|
百分比文字只在红档转红,绿档仍黑——绿在白底上偏淡,做大块填充清楚、做小字发虚。"""
|
||||||
if not ok:
|
if not ok:
|
||||||
return BLACK, BLACK, BLACK
|
return BLACK, BLACK, BLACK
|
||||||
if pct >= warn:
|
if pct >= warn:
|
||||||
return RED, RED, RED
|
return RED, RED, RED
|
||||||
if pct >= self._caution:
|
if pct <= self._ok:
|
||||||
return YELLOW, BLACK, BLACK
|
return GREEN, GREEN, BLACK
|
||||||
return BLACK, BLACK, BLACK
|
return BLACK, BLACK, BLACK
|
||||||
|
|
||||||
|
def _pace_color(self, diff):
|
||||||
|
"""pace = 用量% − 时间%:≥ +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):
|
def _color_strip(self, y0, y1, x0=None, x1=None):
|
||||||
"""六色测试色条:六块等宽实心色块,白块加 1px 黑框以便可见。真机校准用。"""
|
"""六色测试色条:六块等宽实心色块,白块加 1px 黑框以便可见。真机校准用。"""
|
||||||
@@ -72,7 +84,8 @@ class E1002Renderer(DashboardRenderer):
|
|||||||
right = "示例数据" if usage.get("mock") else (f'快照 {usage["updated"]}' if usage.get("updated") else None)
|
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)
|
self._label(y0, usage.get("title", "Usage"), right=right, x0=x0, x1=x1)
|
||||||
warn = usage.get("warn", 80)
|
warn = usage.get("warn", 80)
|
||||||
bars = usage.get("bars", [])
|
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) + 1 for b in bars] + [26]) + 8
|
label_w = max([self._tw(b["k"], self.F13) + 1 for b in bars] + [26]) + 8
|
||||||
y = y0 + 27
|
y = y0 + 27
|
||||||
for b in bars:
|
for b in bars:
|
||||||
@@ -81,20 +94,28 @@ class E1002Renderer(DashboardRenderer):
|
|||||||
return y - 31 + 20
|
return y - 31 + 20
|
||||||
|
|
||||||
def _realtime(self, y0, mao, x0=8, x1=None):
|
def _realtime(self, y0, mao, x0=8, x1=None):
|
||||||
"""橘喵·今日实时三列(版式同三色版:标题+18=名/+34=值/+64=环比/+82=同比)。返回墨迹底部 y。"""
|
"""橘喵·今日实时三列(宽版):指标名 F12 贴在每列左上角作角标;
|
||||||
|
「值 F36 粗 + 右侧上下叠放的 环比/同比(F12)」整组在列内水平居中、竖直略偏下(给角标让位)。
|
||||||
|
(曾试过名在值上方居中——小字压大数字、上半显空;也试过名与值同行——名参与居中会把整组推偏。)返回区块底部 y。"""
|
||||||
x1 = self.W - 8 if x1 is None else x1
|
x1 = self.W - 8 if x1 is None else x1
|
||||||
self._label(y0, "橘喵·今日实时", right=f'上次更新 {mao["upd"]}', x0=x0 + 2, x1=x1 - 2)
|
self._label(y0, "橘喵·今日实时", right=f'上次更新 {mao["upd"]}', x0=x0 + 2, x1=x1 - 2)
|
||||||
cols = mao["cols"]; n = len(cols)
|
cols = mao["cols"]; n = len(cols)
|
||||||
cw = (x1 - x0) / n
|
cw = (x1 - x0) / n
|
||||||
|
TAG_Y, CY, GAP = y0 + 22, y0 + 56, 14 # 角标顶部;组竖直中心;值↔环比块 间隙
|
||||||
for i in range(1, n):
|
for i in range(1, n):
|
||||||
self.vdash(x0 + cw * i, y0 + 32, y0 + 96)
|
self.vdash(x0 + cw * i, y0 + 22, y0 + 84)
|
||||||
for i, c in enumerate(cols):
|
for i, c in enumerate(cols):
|
||||||
cx = x0 + cw * (i + 0.5)
|
cx0 = x0 + cw * i
|
||||||
self.ctext(cx, y0 + 18, c["k"], self.F12)
|
cx = cx0 + cw / 2
|
||||||
self.ctext(cx, y0 + 34, c["v"], self.F24, bold=1)
|
self.text(cx0 + 12, TAG_Y, c["k"], self.F12) # 左上角角标
|
||||||
self._cmp_line(cx, y0 + 64, "环比", c["hb"])
|
vw = self._tw(c["v"], self.F36) + 1
|
||||||
self._cmp_line(cx, y0 + 82, "同比", c["tb"])
|
cmpw = max(self._cmp_w("环比", c["hb"]), self._cmp_w("同比", c["tb"]))
|
||||||
return y0 + 96
|
gx = cx - (vw + GAP + cmpw) / 2 # 整组左端(不含角标)
|
||||||
|
self._mid(gx, CY, c["v"], self.F36, bold=1)
|
||||||
|
_, t, _, b = self.F12.getbbox("环比")
|
||||||
|
self._cmp_at(gx + vw + GAP, CY - 9 - (t + b) / 2, "环比", c["hb"]) # 两行 F12 叠放(行距 18),中心对齐 CY
|
||||||
|
self._cmp_at(gx + vw + GAP, CY + 9 - (t + b) / 2, "同比", c["tb"])
|
||||||
|
return y0 + 86
|
||||||
|
|
||||||
def _week_table(self, y0, week, x0=10, x1=None):
|
def _week_table(self, y0, week, x0=10, x1=None):
|
||||||
"""橘喵·近7天数据表:表头 = 指标名列 + 7 个日期 + 合计;行 = 单量/流水/毛利。
|
"""橘喵·近7天数据表:表头 = 指标名列 + 7 个日期 + 合计;行 = 单量/流水/毛利。
|
||||||
@@ -105,7 +126,7 @@ class E1002Renderer(DashboardRenderer):
|
|||||||
if not dates or not rows:
|
if not dates or not rows:
|
||||||
self.ctext((x0 + x1) / 2, y0 + 40, "--", self.F24)
|
self.ctext((x0 + x1) / 2, y0 + 40, "--", self.F24)
|
||||||
return y0 + 80
|
return y0 + 80
|
||||||
NAME_W, ROW = 44, 22
|
NAME_W, ROW = 44, 24
|
||||||
ncol = len(dates) + 1 # 日期列 + 合计列
|
ncol = len(dates) + 1 # 日期列 + 合计列
|
||||||
cw = (x1 - x0 - NAME_W) / ncol
|
cw = (x1 - x0 - NAME_W) / ncol
|
||||||
col_r = lambda i: x0 + NAME_W + cw * (i + 1) - 4 # 第 i 列右端(含右内边距)
|
col_r = lambda i: x0 + NAME_W + cw * (i + 1) - 4 # 第 i 列右端(含右内边距)
|
||||||
@@ -115,6 +136,8 @@ class E1002Renderer(DashboardRenderer):
|
|||||||
self.rtext(col_r(len(dates)), hy, "合计", self.F12)
|
self.rtext(col_r(len(dates)), hy, "合计", self.F12)
|
||||||
self.hdash(x0, x1, hy + 17, dash=2, gap=3)
|
self.hdash(x0, x1, hy + 17, dash=2, gap=3)
|
||||||
y = hy + ROW
|
y = hy + ROW
|
||||||
|
# 合计列前一道竖点规,把「逐日」与「合计」分开
|
||||||
|
self.vdash(x0 + NAME_W + cw * len(dates), hy + 2, hy + ROW * (len(rows) + 1) - 6, dash=2, gap=3)
|
||||||
for name, vals, total in rows:
|
for name, vals, total in rows:
|
||||||
self.text(x0, y, name, self.F12)
|
self.text(x0, y, name, self.F12)
|
||||||
for i, v in enumerate(vals):
|
for i, v in enumerate(vals):
|
||||||
@@ -133,7 +156,6 @@ class E1002Renderer(DashboardRenderer):
|
|||||||
|
|
||||||
dt, wt, mao = data["date"], data["weather"], data["mao"]
|
dt, wt, mao = data["date"], data["weather"], data["mao"]
|
||||||
usage, gpt = data["usage"], data.get("usage_gpt") or {}
|
usage, gpt = data["usage"], data.get("usage_gpt") or {}
|
||||||
self._caution = usage.get("caution", config.USAGE_CAUTION)
|
|
||||||
|
|
||||||
# ---- A. 顶部:大时钟+日期(左)/ 天气(右)——与三色版同尺寸 ----
|
# ---- A. 顶部:大时钟+日期(左)/ 天气(右)——与三色版同尺寸 ----
|
||||||
DIV_Y = 90
|
DIV_Y = 90
|
||||||
|
|||||||
Reference in New Issue
Block a user