- 用量与 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>
230 lines
13 KiB
Python
230 lines
13 KiB
Python
#!/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=1,12/24/48px 点阵字体,不放大;四段,三道横规):
|
||
A 顶部(0~90) :大时钟+日期(左)|天气(右,贴 790)——与三色版同一套组件与尺寸。
|
||
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),标签用 bar["label"](statusline 同款写法),列宽按最长标签自适应。
|
||
C 今日(218~304):**橘喵·今日实时** 三列全宽:指标名 F12 作左上角角标;「值 F36 粗 + 右侧叠放的环比/同比」整组居中。
|
||
D 近7天(328~438):**橘喵·近7天** 数据表(真实数据:30 天趋势缓存的最后 7 天;不画折线)——
|
||
列 = 指标名 + 7 个日期 + 合计,行 = 单量 / 流水 / 毛利;合计列伪粗体。
|
||
可选(470~479):六色测试色条(config.E1002_COLOR_STRIP,默认关;真机校准色彩量化时打开)。
|
||
|
||
六色的语义分工(每种颜色只有一个含义,不为「有」而用):
|
||
- 黑/白:结构与正文。红:危险(用量 ≥ warn 的进度条+边框+百分比、pace ≥ +tol)、涨、定位、太阳/闪电。
|
||
- 绿:健康(用量 ≤ ok 的进度条+边框、pace ≤ −tol)。用量与 pace 各自三档:绿 / 黑 / 红(阈值 USAGE_OK / USAGE_WARN / PACE_TOL)。
|
||
- 蓝:天气雨滴/雪花。黄:不用(用户明确不要黄色进度条;曾试过 60%~80% 黄「注意」档,已移除)。
|
||
- 整图只允许六种纯色;渲染后可用 `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
|
||
import config as _cfg
|
||
|
||
# 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)
|
||
|
||
# ---------- 配色钩子(三档:绿 健康 / 黑 正常 / 红 危险)----------
|
||
_ok, _pace_tol = _cfg.USAGE_OK, _cfg.PACE_TOL # 由 _usage_block 按数据覆盖
|
||
|
||
def _usage_colors(self, ok, pct, warn, bar):
|
||
"""进度条填充与边框同色:pct ≥ warn 红|pct ≤ ok 绿|其间黑。
|
||
百分比文字只在红档转红,绿档仍黑——绿在白底上偏淡,做大块填充清楚、做小字发虚。"""
|
||
if not ok:
|
||
return BLACK, BLACK, BLACK
|
||
if pct >= warn:
|
||
return RED, RED, RED
|
||
if pct <= self._ok:
|
||
return GREEN, GREEN, 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):
|
||
"""六色测试色条:六块等宽实心色块,白块加 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)
|
||
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
|
||
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):
|
||
"""橘喵·今日实时三列(宽版):指标名 F12 贴在每列左上角作角标;
|
||
「值 F36 粗 + 右侧上下叠放的 环比/同比(F12)」整组在列内水平居中、竖直略偏下(给角标让位)。
|
||
(曾试过名在值上方居中——小字压大数字、上半显空;也试过名与值同行——名参与居中会把整组推偏。)返回区块底部 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
|
||
TAG_Y, CY, GAP = y0 + 22, y0 + 56, 14 # 角标顶部;组竖直中心;值↔环比块 间隙
|
||
for i in range(1, n):
|
||
self.vdash(x0 + cw * i, y0 + 22, y0 + 84)
|
||
for i, c in enumerate(cols):
|
||
cx0 = x0 + cw * i
|
||
cx = cx0 + cw / 2
|
||
self.text(cx0 + 12, TAG_Y, c["k"], self.F12) # 左上角角标
|
||
vw = self._tw(c["v"], self.F36) + 1
|
||
cmpw = max(self._cmp_w("环比", c["hb"]), self._cmp_w("同比", c["tb"]))
|
||
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):
|
||
"""橘喵·近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, 24
|
||
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
|
||
# 合计列前一道竖点规,把「逐日」与「合计」分开
|
||
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:
|
||
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 {}
|
||
|
||
# ---- 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
|