feat: E1002 六色版改版为 AI 用量看板+Claude 用量新增 Fable 7d 行与重置时刻

- 版面重排:主体左 Claude Usage(5h/7d/Fable 7d 三行),右 ChatGPT Usage(目前 mock,标「示例数据」);每行为一张用量卡:标签+F24 百分比+pace/全宽进度条/「重置 HH:MM」+「已过 NN%」
- 移除上一版的橘喵今日实时与 30 天趋势图及 trend totals 合计(业务数据留给 400×300 小屏),绿色暂不再使用
- data 用量结构新增 scoped(按模型限定额度)、reset(重置时刻文本)、updated(快照落盘时刻),新增 _usage_gpt mock 数据源,接入真实数据时保持结构替换即可
- usage_local 读取 statusline 新增的 seven_day_fable 与 updated_at;400×300 小屏 render 跳过 scoped 行
- 同步更新 CLAUDE.md 分支说明

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
2026-09-10 14:43:31 +08:00
parent d7ae193f56
commit 110f710ff0
5 changed files with 176 additions and 144 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/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 天趋势图**(三指标各一色 + 合计)。六色各有唯一语义:红=预警//定位/太阳闪电/流水线,黄=用量「注意」档填充,绿=毛利线,蓝=单量线/雨雪
> **分支 `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 超前/定位/太阳闪电,黄=用量「注意」档填充,蓝=雨雪,绿暂未用
## 命令
@@ -37,12 +37,12 @@ EPD_HOST=epd400a44.local python3 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.trend` 三色版当前不用趋势图隐藏E1002 版用它画 30 天趋势;`_mao` 还会现算 `trend.totals`(三指标 30 天合计,`_trend_totals`,不进缓存)。
- **`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 不使用它)。
- **缓存**(为 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
- **`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` 未配置时直接抛错天气区显示 `"--"`
- **`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**本机 `~/.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
- **A 顶部**090大时钟(F48)+日期/ 天气红定位水滴 + 温度 + 天气·温区 + 全矢量图标)。
- **B 中部主角 Claude Usage**`_label` 标题 + 两条 `_usage_row`每条单行 = 左标签 `5h`/`7d`(F13) + 粗进度条 + 百分比(F16) + 时间维度对比 pace(F13`_pace`)`pct≥warn`该条进度条(含边框)与百分比转红预警pace 用上/下三角替代 +/超前 / 节余↓)。整行标签·进度条·百分比·三角 `_mid()` 按墨迹竖直中心对齐到同一中线
@@ -51,7 +51,7 @@ EPD_HOST=epd400a44.local python3 main.py
- **`_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`** 直连局域网墨水屏设备喵喵固件开源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 顶部 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 控件须铺满整个画布
- **`renderer_e1002.py`** reTerminal E1002 目标`E1002Renderer(DashboardRenderer)` **k=1** 复用组件字号 12/24/48 与三色版一致只重写版式与配色钩子版式A 顶部 090 时钟/日期左天气右 x=790B 主体 y=100 左半 `usage`10386竖规 x=400右半 `usage_gpt`414790各自一个 `_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 天趋势均已按用户反馈弃用
- **`config.py`** 顶部 `_load_dotenv()` 先加载同目录 `.env`再定义 API设备画布尺寸字体路径推送参数凭证 fallback 为空串真实值来自 `.env`/环境变量
## 墨水屏渲染约束(改 renderer.py 必读)

106
data.py
View File

@@ -163,23 +163,26 @@ def _mao(now):
except Exception as e:
print(f"[警告] 今日经营拉取失败:{e}")
cols = [_placeholder_col(k) for k in ("单量", "流水", "毛利")]
# 近30天趋势按日期缓存每天只拉一次合计每次现算,不进缓存(缓存结构保持不变)
trend = dict(_trend(now))
trend["totals"] = _trend_totals(trend)
return {"upd": upd, "cols": cols, "trend": trend}
# 近30天趋势按日期缓存每天只拉一次宽版另用其最后 7 天做「近7天」数据表
trend = _trend(now)
return {"upd": upd, "cols": cols, "trend": trend, "week": _recent_days(trend, 7)}
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:
def _recent_days(trend, n):
"""趋势的最后 n 天 → 表格数据 {dates:[MM/DD…], rows:[(名, [文本…], 合计文本)]}供宽版「橘喵·近7天」表使用
recent-days 不含今日,故表头日期到昨天为止。单量按千分位流水/毛利按紧凑金额格式;无数据返回空 dict"""
dates, series = trend.get("dates") or [], trend.get("series") or []
if not dates or not series:
return {}
dates = dates[-n:]
rows = []
for s in series:
data = (s.get("data") or [])[-n:]
if len(data) != len(dates):
continue
total = sum(data)
out.append((s["k"], _fmt_count(total) if s["k"] == "单量" else _fmt_money(total)))
return out
fmt = _fmt_count if s["k"] == "单量" else _fmt_money
rows.append((s["k"], [fmt(v) for v in data], fmt(sum(data))))
return {"dates": dates, "rows": rows}
# ---------- Claude Code 用量 ----------
@@ -206,35 +209,75 @@ def _window_time_pct(resets_at, window, now):
return int(round((window - remaining) / window * 100))
def _usage(now):
"""Claude Code 用量5h / 7d——只读本地 statusline 生产的快照(见 usage_local.py**不发任何请求**。
def _fmt_reset(resets_at, window):
"""重置时刻 → 文本≤1 天的窗口只给 HH:MM更长的窗口带 MM/DD。解析失败返回 None。"""
ts = _parse_reset_ts(resets_at)
if ts is None:
return None
dt = datetime.fromtimestamp(ts)
return dt.strftime("%H:%M") if window <= 86400 else dt.strftime("%m/%d %H:%M")
utilization 来自 statusline 从 Claude Code stdin 落盘的权威实时值;
# Claude 用量窗口快照键行标签窗口长度秒scoped。scoped=True 为「按模型限定」的额度
# Fable 7d来自 /api/oauth/usage 的 limits[],由 statusline 写入快照 seven_day_fable
# 400x300 三色小屏版面放不下第三行,只在 800x480 宽版展示。
USAGE_WINDOWS = (
("five_hour", "5h", FIVE_HOUR, False),
("seven_day", "7d", SEVEN_DAY, False),
("seven_day_fable", "Fable 7d", SEVEN_DAY, True),
)
def _usage(now):
"""Claude Code 用量5h / 7d / Fable 7d——只读本地 statusline 生产的快照(见 usage_local.py**不发任何请求**。
utilization 来自 statusline 从 Claude Code stdin 落盘的权威实时值Fable 行来自其转存的 API limits
time_pct(时间已流逝%) 与 pace 每次按当前时间**现算**resets_at 为绝对时间)。
文件缺失/损坏 → 回退占位 pct=Nonerenderer 显示 "--")。
结构契约renderer 依赖):{title, warn, bars:[{k, pct(0~100 或 None), time_pct(0~100)?}]}
pct ≥ warn 时该条进度条与百分比转红pace = pct time_pct>0 超前↑ / <0 节余↓)
文件缺失/损坏 → 回退占位 pct=Nonerenderer 显示 "--";快照里没有某窗口(如旧版 statusline 无 Fable同样 "--"
结构契约renderer 依赖):{title, warn, caution, updated?, bars:[{k, pct(0~100 或 None), time_pct?, reset?, scoped}]}
pct ≥ warn 时该条进度条与百分比转红pace = pct time_pct>0 超前↑ / <0 节余↓)
reset 为重置时刻文本updated 为快照落盘时刻 HH:MM活跃使用时持续刷新空闲时停在最后一次
"""
wins = {}
raw = {}
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)
for key, label, win, scoped in USAGE_WINDOWS:
seg = raw.get(key) or {}
util = seg.get("utilization")
pct = int(round(float(util))) if util is not None else None
bar = {"k": label, "pct": pct, "scoped": scoped}
tp = _window_time_pct(seg.get("resets_at"), win, now)
if pct is not None and tp is not None:
bar["time_pct"] = tp
rs = _fmt_reset(seg.get("resets_at"), win)
if rs:
bar["reset"] = rs
bars.append(bar)
return {"title": "Claude Usage", "warn": config.USAGE_WARN, "caution": config.USAGE_CAUTION, "bars": bars}
out = {"title": "Claude Usage", "warn": config.USAGE_WARN, "caution": config.USAGE_CAUTION, "bars": bars}
upd = _parse_reset_ts(raw.get("updated_at"))
if upd:
out["updated"] = datetime.fromtimestamp(upd).strftime("%H:%M")
return out
def _usage_gpt(now):
"""ChatGPT 用量——**目前为 mock 占位**mock=Truerenderer 在标题右侧标「示例数据」)。
结构与 _usage 完全一致,后续接入真实数据源时只需替换本函数、保持返回结构不变。
mock 取值刻意覆盖多种配色档5h 42%、7d 71%黄「注意」、Codex 7d 55%scoped 与左侧 Fable 行对称)。"""
bars = []
for label, pct, win, remain, scoped in (("5h", 42, FIVE_HOUR, 2 * 3600, False),
("7d", 71, SEVEN_DAY, 3 * 86400, False),
("Codex 7d", 55, SEVEN_DAY, 3 * 86400, True)):
reset_ts = now.timestamp() + remain
bars.append({"k": label, "pct": pct, "scoped": scoped,
"time_pct": _window_time_pct(reset_ts, win, now),
"reset": _fmt_reset(reset_ts, win)})
return {"title": "ChatGPT Usage", "warn": config.USAGE_WARN, "caution": config.USAGE_CAUTION,
"bars": bars, "mock": True}
def get_dashboard_data(now=None):
@@ -249,4 +292,5 @@ def get_dashboard_data(now=None):
"weather": _weather(now),
"mao": _mao(now),
"usage": _usage(now),
"usage_gpt": _usage_gpt(now),
}

View File

@@ -150,16 +150,18 @@ class DashboardRenderer:
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):
def _usage_row(self, x0, x1, y, bar, warn, label_w=None):
"""一条用量(单行):左标签(5h/7d, F13) + 进度条 + 百分比(F16, 主数字) + 时间维度对比 pace(F13)。
右侧从右往左排布显式控制两处横向间距G1 进度条↔百分比、G2 百分比↔pace全部竖直居中 cy。
pct ≥ warn → 进度条(含整条边框)与百分比转红;边框随条色,红条即红框,无黑白错位。"""
pct ≥ warn → 进度条(含整条边框)与百分比转红;边框随条色,红条即红框,无黑白错位。
label_w标签列宽默认 26*k够放 5h/7d宽版有 "Fable 7d" 这类长标签时由调用方按最长标签算好传入。"""
raw = bar.get("pct")
ok = isinstance(raw, (int, float)) # None/缺失 → 显示 "--"
pct = max(0, min(100, int(raw))) if ok else 0
fill, outline, color = self._usage_colors(ok, pct, warn, bar)
k = self.k
LABEL_W, G1, G2, H = 26 * k, 13 * k, 8 * k, 15 * k # G1 进度条↔百分比、G2 百分比↔pace视觉约 12/11
LABEL_W = 26 * k if label_w is None else label_w
G1, G2, H = 13 * k, 8 * k, 15 * k # G1 进度条↔百分比、G2 百分比↔pace视觉约 12/11
PCT_TRACK = 2 * k # F16(16/12x 非整数倍缩放)数字墨迹无侧边距、紧贴,手动加字符间距
cy = y + 10 * k # 行竖直中心
self._mid(x0, cy, bar["k"], self.F13, bold=k) # 左标签 F13
@@ -419,6 +421,8 @@ class DashboardRenderer:
warn = usage.get("warn", 80)
ry = 123
for b in usage.get("bars", []):
if b.get("scoped"): # 按模型限定的额度Fable 7d只在 800x480 宽版展示,小屏放不下第三行
continue
self._usage_row(10, 390, ry, b, warn)
ry += 31 # 略压缩两条间距,给底部让出下边距

View File

@@ -1,20 +1,23 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
reTerminal E1002 渲染层 —— 复用 renderer.DashboardRenderer 的全部组件(**k=1字号与 400x300 版完全相同**
利用 800x480 的四倍面积重新排版,输出 **E Ink Spectra 6 原生六色**(黑/白/黄/红/绿/蓝)纯色图,
reTerminal E1002 渲染层 —— 复用 renderer.DashboardRenderer 的组件(**k=1字号与 400x300 版完全相同**
利用 800x480 的四倍面积重新排版为「AI 用量看板」,输出 **E Ink Spectra 6 原生六色**(黑/白/黄/红/绿/蓝)纯色图,
并生成一张固定 800x480 的 HTML 页面供 SenseCraft HMI 的 HTML 控件云端截图拉取。
版式k=112/24/48px 点阵字体,不放大):
版式k=112/24/48px 点阵字体,不放大;四段,三道横规
A 顶部090 :大时钟+日期(左)|天气(右,贴 790——与三色版同一套组件与尺寸。
B 中段96192 :左半 Claude Usage10390竖规 x=400右半 橘喵·今日实时三列410790)。
C 下段206462橘喵·30 天趋势(全宽,三指标各一色:流水红 / 单量蓝 / 毛利绿),三色版里隐藏的图在此恢复
D 可选470479六色测试色条config.E1002_COLOR_STRIP默认关真机校准色彩量化时打开
B 用量96206 :左半 **Claude Usage**5h / 7d / Fable 7d竖规 x=400右半 **ChatGPT Usage**mock5h / 7d / Codex 7d)。
行组件沿用三色版的紧凑单行 `_usage_row`(行距 31标签列宽按最长标签自适应
C 今日218314**橘喵·今日实时** 三列全宽(名 / 值 F24 粗 / 环比·同比),版式同三色版
D 近7天328438**橘喵·近7天** 数据表真实数据30 天趋势缓存的最后 7 天;不画折线)——
列 = 指标名 + 7 个日期 + 合计,行 = 单量 / 流水 / 毛利;合计列伪粗体。
可选470479六色测试色条config.E1002_COLOR_STRIP默认关真机校准色彩量化时打开
六色的语义分工(每种颜色只有一个含义,不为「有」而用):
- 黑/白:结构与正文。红:预警、涨、定位、太阳/闪电、趋势主指标(流水)
- 黑/白:结构与正文。红:预警pct≥warn、pace 超前、定位、太阳/闪电
- 黄用量进度条「注意」档caution ≤ pct < warn填充边框与百分比仍黑黄字压白底不可读故黄只作填充
- 绿:趋势「毛利」线。蓝:趋势「单量」线、天气雨滴/雪花
- 蓝:天气雨滴/雪花。绿:暂未使用(保留给后续语义,例如「节余」)
- 整图只允许六种纯色;渲染后可用 `img.getcolors()` 自查。
为什么是「固定 800x480 + 一张 PNG」而不是自适应 HTML
@@ -32,19 +35,16 @@ from renderer import DashboardRenderer, BLACK, WHITE, RED
YELLOW, GREEN, BLUE = (255, 255, 0), (0, 255, 0), (0, 0, 255)
SPECTRA6 = [BLACK, WHITE, YELLOW, RED, GREEN, BLUE]
# 趋势三指标配色:主指标流水红领读,单量蓝、毛利绿;未列出的指标回退黑
SERIES_COLOR = {"流水": RED, "单量": BLUE, "毛利": GREEN}
class E1002Renderer(DashboardRenderer):
"""800x480 六色版。组件全部继承k=1字号不变只重写版式与配色钩子。"""
"""800x480 六色版。组件继承k=1字号不变只重写版式与配色钩子。"""
def __init__(self, font_path=config.FONT_PATH):
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 黄填充+黑框黑字|其余黑。"""
"""三档≥warn 整体红caution≤pct<warn 黄填充+黑框黑字|其余黑。返回(填充,边框,文字)。"""
if not ok:
return BLACK, BLACK, BLACK
if pct >= warn:
@@ -66,66 +66,62 @@ class E1002Renderer(DashboardRenderer):
if c == WHITE:
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 _usage_block(self, x0, x1, y0, usage):
"""一个用量区块标题_label右侧「快照 HH:MM」或 mock 的「示例数据」)+ 逐行紧凑 `_usage_row`(行距 31
标签列宽按本块最长标签墨迹 + 8px 算,保证 "Fable 7d" 这类长标签不压进度条。返回末行底部 y。"""
right = "示例数据" if usage.get("mock") else (f'快照 {usage["updated"]}' if usage.get("updated") else None)
self._label(y0, usage.get("title", "Usage"), right=right, x0=x0, x1=x1)
warn = usage.get("warn", 80)
bars = usage.get("bars", [])
label_w = max([self._tw(b["k"], self.F13) + 1 for b in bars] + [26]) + 8
y = y0 + 27
for b in bars:
self._usage_row(x0, x1, y, b, warn, label_w=label_w)
y += 31
return y - 31 + 20
def _realtime(self, y0, mao, x0=8, x1=None):
"""橘喵·今日实时三列(版式同三色版:标题+18=名/+34=值/+64=环比/+82=同比)。返回墨迹底部 y。"""
x1 = self.W - 8 if x1 is None else x1
self._label(y0, "橘喵·今日实时", right=f'上次更新 {mao["upd"]}', x0=x0 + 2, x1=x1 - 2)
cols = mao["cols"]; n = len(cols)
cw = (x1 - x0) / n
for i in range(1, n):
self.vdash(x0 + cw * i, y0 + 32, y0 + 96)
for i, c in enumerate(cols):
cx = x0 + cw * (i + 0.5)
self.ctext(cx, y0 + 18, c["k"], self.F12)
self.ctext(cx, y0 + 34, c["v"], self.F24, bold=1)
self._cmp_line(cx, y0 + 64, "环比", c["hb"])
self._cmp_line(cx, y0 + 82, "同比", c["tb"])
return y0 + 96
def _week_table(self, y0, week, x0=10, x1=None):
"""橘喵·近7天数据表表头 = 指标名列 + 7 个日期 + 合计;行 = 单量/流水/毛利。
全部 F12、右对齐数字合计列伪粗体表头下一道点规。无数据居中 "--"。返回底部 y。"""
x1 = self.W - 10 if x1 is None else x1
self._label(y0, "橘喵·近7天", right="不含今日", x0=x0, x1=x1)
dates, rows = (week or {}).get("dates") or [], (week or {}).get("rows") or []
if not dates or not rows:
self.ctext((x0 + x1) / 2, y0 + 40, "--", self.F24)
return y0 + 80
NAME_W, ROW = 44, 22
ncol = len(dates) + 1 # 日期列 + 合计列
cw = (x1 - x0 - NAME_W) / ncol
col_r = lambda i: x0 + NAME_W + cw * (i + 1) - 4 # 第 i 列右端(含右内边距)
hy = y0 + 24
for i, d in enumerate(dates):
self.rtext(col_r(i), hy, d, self.F12)
self.rtext(col_r(len(dates)), hy, "合计", self.F12)
self.hdash(x0, x1, hy + 17, dash=2, gap=3)
y = hy + ROW
for name, vals, total in rows:
self.text(x0, y, name, self.F12)
for i, v in enumerate(vals):
self.rtext(col_r(i), y, v, self.F12)
self.rtext(col_r(len(dates)), y, total, self.F12, bold=1)
y += ROW
return y - ROW + 14
# ---------- 入口 ----------
def render(self, data):
@@ -135,7 +131,8 @@ class E1002Renderer(DashboardRenderer):
self.d.fontmode = "1"
R = self.W - 10 # 右页边 790
dt, wt, mao, usage = data["date"], data["weather"], data["mao"], data["usage"]
dt, wt, mao = data["date"], data["weather"], data["mao"]
usage, gpt = data["usage"], data.get("usage_gpt") or {}
self._caution = usage.get("caution", config.USAGE_CAUTION)
# ---- A. 顶部:大时钟+日期(左)/ 天气(右)——与三色版同尺寸 ----
@@ -156,38 +153,24 @@ class E1002Renderer(DashboardRenderer):
self._weather_category(wt.get("icon")), s=s, accent=RED, wet=BLUE)
self.hdash(8, self.W - 8, DIV_Y)
# ---- B. 中段:左 Claude Usage 右 橘喵·今日实时 ----
# ---- B. 用量:左 Claude 右 ChatGPT紧凑单行与三色版同款----
MID = self.W // 2 # 400
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, MID - 10, ry, b, warn)
ry += 31
self.vdash(MID, 96, 194)
lx0, lx1 = MID + 10, R
self._label(96, "橘喵·今日实时", right=f'上次更新 {mao["upd"]}', x0=lx0, x1=lx1)
cols = mao["cols"]; n = len(cols)
ax0, ax1 = MID + 8, self.W - 8
cw = (ax1 - ax0) / n
for i in range(1, n):
self.vdash(ax0 + cw * i, 128, 192)
for i, c in enumerate(cols):
cx = ax0 + cw * (i + 0.5)
self.ctext(cx, 114, c["k"], self.F12) # 指标名(标题+18
self.ctext(cx, 130, c["v"], self.F24, bold=1) # 数值 伪粗体(+34
self._cmp_line(cx, 160, "环比", c["hb"]) # +64
self._cmp_line(cx, 178, "同比", c["tb"]) # +82
DIV2_Y = 200
y_l = self._usage_block(10, MID - 14, DIV_Y + 6, usage)
y_r = self._usage_block(MID + 14, R, DIV_Y + 6, gpt) if gpt else y_l
self.vdash(MID, DIV_Y + 6, max(y_l, y_r))
DIV2_Y = max(y_l, y_r) + 6
self.hdash(8, self.W - 8, DIV2_Y)
# ---- C. 下段:近 30 天趋势(全宽)----
strip = config.E1002_COLOR_STRIP
self._trend_chart6(DIV2_Y + 6, self.H - (18 if strip else 8), mao.get("trend") or {})
# ---- C. 橘喵·今日实时(全宽三列----
end_c = self._realtime(DIV2_Y + 6, mao)
DIV3_Y = end_c + 8
self.hdash(8, self.W - 8, DIV3_Y)
# ---- D. 可选:六色测试色条 ----
if strip:
# ---- D. 橘喵·近7天 数据表 ----
self._week_table(DIV3_Y + 6, mao.get("week"))
# ---- C. 可选:六色测试色条 ----
if config.E1002_COLOR_STRIP:
self._color_strip(self.H - 10, self.H - 1)
return self.img

View File

@@ -7,9 +7,10 @@ Claude Code 用量本地读取 —— 只读 claude-statusline 生产的用量
「Usage snapshot for external consumers」段它把每次从 Claude Code stdin 拿到的**权威实时**额度
落盘到 config.USAGE_LOCAL_PATH默认 ~/.claude/usage-snapshot.json本模块作为消费者读取。
结构含 five_hour / seven_day,各有 utilization(已用百分比) 与 resets_at(ISO 重置时间,供上层算
「时间已流逝%」做 pace 对比)。活跃使用时快照几乎持续刷新、准确;空闲时停在最后一次。
文件缺失/损坏时抛错 → 上层回退占位 "--"
结构含 five_hour / seven_day / seven_day_fable后者为 Fable 模型限定的 7 天额度statusline 从
/api/oauth/usage 的 limits[] 转存,旧版 statusline 或 API 无该项时为 null各有 utilization已用百分比
与 resets_at重置时间epoch 秒或 ISO供上层算「时间已流逝%」做 pace 对比),外加 updated_at落盘 epoch
活跃使用时快照几乎持续刷新、准确;空闲时停在最后一次。文件缺失/损坏时抛错 → 上层回退占位 "--"
"""
import json
@@ -27,6 +28,6 @@ def get_usage() -> dict:
if __name__ == "__main__":
d = get_usage()
for k in ("five_hour", "seven_day"):
for k in ("five_hour", "seven_day", "seven_day_fable"):
s = d.get(k) or {}
print(k, "util=", s.get("utilization"), "resets_at=", s.get("resets_at"))