Files
eink-push/renderer_e1002.py
YANG JIANKUAN d7ae193f56 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>
2026-09-10 14:11:35 +08:00

225 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 的四倍面积重新排版,输出 **E Ink Spectra 6 原生六色**(黑/白/黄/红/绿/蓝)纯色图,
并生成一张固定 800x480 的 HTML 页面供 SenseCraft HMI 的 HTML 控件云端截图拉取。
版式k=112/24/48px 点阵字体,不放大):
A 顶部090 :大时钟+日期(左)|天气(右,贴 790——与三色版同一套组件与尺寸。
B 中段96192 :左半 Claude Usage10390竖规 x=400右半 橘喵·今日实时三列410790
C 下段206462橘喵·30 天趋势(全宽,三指标各一色:流水红 / 单量蓝 / 毛利绿),三色版里隐藏的图在此恢复。
D 可选470479六色测试色条config.E1002_COLOR_STRIP默认关真机校准色彩量化时打开
六色的语义分工(每种颜色只有一个含义,不为「有」而用):
- 黑/白:结构与正文。红:预警、涨、定位、太阳/闪电、趋势主指标(流水)。
- 黄用量进度条「注意」档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]
# 趋势三指标配色:主指标流水红领读,单量蓝、毛利绿;未列出的指标回退黑
SERIES_COLOR = {"流水": RED, "单量": BLUE, "毛利": GREEN}
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 _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):
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, usage = data["date"], data["weather"], data["mao"], data["usage"]
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 Usage 右 橘喵·今日实时 ----
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
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 {})
# ---- D. 可选:六色测试色条 ----
if 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