Files
eink-push/renderer_e1002.py
YANG JIANKUAN 110f710ff0 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>
2026-09-10 14:43:31 +08:00

208 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
reTerminal E1002 渲染层 —— 复用 renderer.DashboardRenderer 的组件(**k=1字号与 400x300 版完全相同**
利用 800x480 的四倍面积重新排版为「AI 用量看板」,输出 **E Ink Spectra 6 原生六色**(黑/白/黄/红/绿/蓝)纯色图,
并生成一张固定 800x480 的 HTML 页面供 SenseCraft HMI 的 HTML 控件云端截图拉取。
版式k=112/24/48px 点阵字体,不放大;四段,三道横规):
A 顶部090 :大时钟+日期(左)|天气(右,贴 790——与三色版同一套组件与尺寸。
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
- SenseCraft 的 HTML 控件是云端无头浏览器截图后再量化到六色下发。自适应布局在未知视口/DPR 下会被重排、
缩放、抗锯齿,产生大量中间色,最终被平台抖动成网点。页面锁死 800x480、内容就是 1:1 的 PNG
`image-rendering: pixelated`),控件铺满画布即逐像素一致,每个像素本就是六色之一,量化零损失。
"""
import os
import time
import config
from renderer import DashboardRenderer, BLACK, WHITE, RED
# E Ink Spectra 6 原生六色。取纯饱和值作为给平台量化器的明确信号(与 ESPHome/GxEPD2 驱动的 BWYRGB 映射一致)。
YELLOW, GREEN, BLUE = (255, 255, 0), (0, 255, 0), (0, 0, 255)
SPECTRA6 = [BLACK, WHITE, YELLOW, RED, GREEN, BLUE]
class E1002Renderer(DashboardRenderer):
"""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 黄填充+黑框黑字|其余黑。返回(填充,边框,文字)。"""
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):
"""六色测试色条:六块等宽实心色块,白块加 1px 黑框以便可见。真机校准用。"""
x0 = 10 if x0 is None else x0
x1 = self.W - 10 if x1 is None else x1
n = len(SPECTRA6)
w = (x1 - x0) / n
for i, c in enumerate(SPECTRA6):
bx0, bx1 = round(x0 + w * i), round(x0 + w * (i + 1)) - 1
self.d.rectangle([bx0, y0, bx1, y1], fill=c)
if c == WHITE:
self.d.rectangle([bx0, y0, bx1, y1], outline=BLACK, width=1)
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):
from PIL import Image, ImageDraw
self.img = Image.new("RGB", (self.W, self.H), WHITE)
self.d = ImageDraw.Draw(self.img)
self.d.fontmode = "1"
R = self.W - 10 # 右页边 790
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. 顶部:大时钟+日期(左)/ 天气(右)——与三色版同尺寸 ----
DIV_Y = 90
self.text(10, 0, dt["time"], self.F48, bold=2)
self.text(12, 64, f'{dt["week"]} {dt["greg"]}', self.F12)
loc, temp, cr = wt["loc"], wt["temp"], f'{wt["cond"]} {wt["range"]}'
self.rtext(R, 8, loc, self.F12)
self.rtext(R, 30, temp, self.F24, bold=1)
self.rtext(R, 64, cr, self.F12)
PIN_W, PIN_GAP = 8, 6
loc_pin_x = R - self._tw(loc, self.F12) - PIN_GAP - PIN_W
self._loc_pin(loc_pin_x, 10, PIN_W, fg=RED)
s = 1.5
iw, ih = 36 * s, 38 * s
text_left = min(loc_pin_x, R - self._tw(temp, self.F24) - 1, R - self._tw(cr, self.F12))
self._weather_icon(text_left - 12 - iw, (DIV_Y - ih) / 2,
self._weather_category(wt.get("icon")), s=s, accent=RED, wet=BLUE)
self.hdash(8, self.W - 8, DIV_Y)
# ---- B. 用量:左 Claude 右 ChatGPT紧凑单行与三色版同款----
MID = self.W // 2 # 400
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. 橘喵·今日实时(全宽三列)----
end_c = self._realtime(DIV2_Y + 6, mao)
DIV3_Y = end_c + 8
self.hdash(8, self.W - 8, DIV3_Y)
# ---- 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
# ---------- HTML 页面(给 SenseCraft HMI HTML 控件抓取)----------
_HTML = """<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=800, initial-scale=1">
<meta http-equiv="Cache-Control" content="no-store, no-cache, must-revalidate">
<meta http-equiv="Pragma" content="no-cache">
<meta http-equiv="Expires" content="0">
<title>eink dashboard 800x480</title>
<style>
/* 固定 800x480、零边距让云端截图与 PNG 逐像素对应;即便被缩放也用最近邻,避免产生中间色 */
html, body {{ margin: 0; padding: 0; background: #fff; width: 800px; height: 480px; overflow: hidden; }}
img {{ display: block; width: 800px; height: 480px;
image-rendering: pixelated; image-rendering: crisp-edges; -ms-interpolation-mode: nearest-neighbor; }}
</style>
</head>
<body><img src="{png}?v={stamp}" width="800" height="480" alt=""></body>
</html>
"""
def write_page(html_path, png_path, stamp=None):
"""写出固定 800x480 的静态页。PNG 以相对路径引用并带时间戳参数,防止云端抓取器命中旧图缓存。"""
stamp = stamp or str(int(time.time()))
rel = os.path.relpath(png_path, os.path.dirname(html_path))
os.makedirs(os.path.dirname(html_path), exist_ok=True)
with open(html_path, "w", encoding="utf-8") as f:
f.write(_HTML.format(png=rel.replace(os.sep, "/"), stamp=stamp))
return html_path