feat: 新增 reTerminal E1002(800×480 六色 E Ink)目标设备支持

- 新增 renderer_e1002.py:E1002Renderer 继承 DashboardRenderer,以 k=2 整数倍缩放复用全部组件,重排为 800×480 四段版式(时钟/天气、Claude Usage、今日实时、六色测试色条)
- DashboardRenderer 改造为支持 k 缩放系数(字体按 k 倍创建,组件像素常量随 k 缩放),k=1 保持 400×300 原版不变
- main.py 新增 --target e1002 参数:渲染 800×480 六色图 + 固定尺寸 index.html 到 output/e1002/,供 SenseCraft HMI 的 HTML 控件云端周期性抓取(不推送)
- config.py 新增 E1002 画布尺寸与输出路径常量
- README.md / CLAUDE.md 补充新设备的架构说明与用法
This commit is contained in:
2026-09-10 10:49:26 +08:00
parent 097b9d1d64
commit eab90bf3b5
6 changed files with 214 additions and 27 deletions

152
renderer_e1002.py Normal file
View File

@@ -0,0 +1,152 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
reTerminal E1002 渲染层 —— 复用 renderer.DashboardRenderer 的全部组件k=2 整数倍缩放),
重新排版为 800x480输出 **E Ink Spectra 6 原生六色**(黑/白/黄/红/绿/蓝)纯色图,
并生成一张固定 800x480 的 HTML 页面供 SenseCraft HMI 的 HTML 控件云端截图拉取。
为什么是「固定 800x480 + 一张 PNG」而不是自适应 HTML
- SenseCraft 的 HTML 控件是云端无头浏览器截图后再量化到六色下发设备。自适应布局在未知视口/DPR 下
会被重排、缩放、抗锯齿,产生大量中间色,最终被平台抖动成网点——恰是我们最不想要的。
- 页面锁死为 800x480、内容就是一张 1:1 的 800x480 PNG`image-rendering: pixelated`
只要控件铺满画布、视口 ≥ 800x480截图即与 PNG 逐像素一致;每个像素本来就是六色之一,量化零损失。
- 所有版式/字体/图标仍由 Pillow 在像素网格上控制(点阵字体 + fontmode='1'),与 400x300 版一致锐利。
六色使用原则(比三色版更克制,不因为「有」就用):
- 黑/白 承担全部结构与正文;红 延续三色版语义(预警、涨、定位、太阳/闪电);
- 黄/绿/蓝 目前**只出现在底部测试色条**,用于在真机上校准平台量化是否落到原生色。
后续若要启用(如 绿=节余/跌、蓝=雨雪),先在真机看过色条效果再决定。
- 整图只允许六种纯色;渲染后可用 `img.getcolors()` 自查。
"""
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=2只重写 `render()` 的版式。"""
def __init__(self, font_path=config.FONT_PATH):
super().__init__(font_path, size=(config.E1002_WIDTH, config.E1002_HEIGHT), k=2)
def _color_strip(self, y0, y1, x0=None, x1=None):
"""底部六色测试色条:六块等宽实心色块,白块加 1px 黑框以便可见。真机校准用。"""
k = self.k
x0 = 10 * k if x0 is None else x0
x1 = self.W - 10 * k 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 render(self, data):
"""800x480 版式(自上而下):
字体属性沿用 k=1 的层级名F12/F24/F48k=2 下实际为 24/48/96px。
A 顶部0112大时钟F48+ 同行右侧日期F12定位·温度F24·天气/温区F12+ 矢量图标s=2.0
B 中部124258Claude Usage 标题 + 两条用量行k=2 组件原样放大)
C 底部272440橘喵今日实时三列名 F12 / 值 F24 粗 / 环比·同比 F12
D 色条452480Spectra 6 六色测试块
"""
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"
k = self.k
M = 10 * k # 页边距
L, R = M, self.W - M # 内容左右边界 20 / 780
dt, wt, mao, usage = data["date"], data["weather"], data["mao"], data["usage"]
# ---- A. 顶部:时钟 + 日期(左,同一基线)/ 天气(右)----
DIV_Y = 112
clock = dt["time"]
self.text(L, 4, clock, self.F48, bold=2 * k)
cw = self._tw(clock, self.F48) + 2 * k
# 日期紧随时钟右侧按墨迹底沿对齐时钟数字底沿F48 字身框内数字底沿≈ y+4+ 字号*0.83
_, _, _, cb = self.F48.getbbox(clock)
_, _, _, db = self.F12.getbbox("2026/09/09")
self.text(L + cw + 12 * k, 4 + cb - db, 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, 34, temp, self.F24, bold=k)
self.rtext(R, 82, cr, self.F12)
PIN_W, PIN_GAP = 8 * k, 6 * k
loc_pin_x = R - self._tw(loc, self.F12) - PIN_GAP - PIN_W
self._loc_pin(loc_pin_x, 12, PIN_W, fg=RED)
s = 2.0 # 图标标称 36x38雷阵雨含闪电实高 46s=2 时 92px 仍落在 DIV_Y 之上,再大会压线
iw, ih = 36 * s, 38 * s
text_left = min(loc_pin_x, R - self._tw(temp, self.F24) - k, R - self._tw(cr, self.F12))
self._weather_icon(text_left - 14 * k - iw, (DIV_Y - ih) / 2,
self._weather_category(wt.get("icon")), s=s, accent=RED)
self.hdash(8 * k, self.W - 8 * k, DIV_Y, dash=4 * k, gap=4 * k, width=k)
# ---- B. 中部主角Claude Usage ----
self._label(DIV_Y + 12, usage.get("title", "Claude Usage"))
warn = usage.get("warn", 80)
ry = DIV_Y + 48
for b in usage.get("bars", []):
self._usage_row(L, R, ry, b, warn)
ry += 54
DIV2_Y = 266
self.hdash(8 * k, self.W - 8 * k, DIV2_Y, dash=4 * k, gap=4 * k, width=k)
# ---- C. 底部:橘喵·今日实时 三列 ----
ly = DIV2_Y + 10
self._label(ly, "橘喵·今日实时", right=f'上次更新 {mao["upd"]}')
cols = mao["cols"]; n = len(cols)
ax0, ax1 = 8 * k, self.W - 8 * k
cw = (ax1 - ax0) / n
for i in range(1, n):
self.vdash(ax0 + cw * i, ly + 28, ly + 166, dash=4 * k, gap=4 * k, width=k)
for i, c in enumerate(cols):
cx = ax0 + cw * (i + 0.5)
self.ctext(cx, ly + 32, c["k"], self.F12) # 指标名
self.ctext(cx, ly + 60, c["v"], self.F24, bold=k) # 数值 伪粗体
self._cmp_line(cx, ly + 110, "环比", c["hb"])
self._cmp_line(cx, ly + 138, "同比", c["tb"])
# ---- D. 六色测试色条 ----
self._color_strip(self.H - 28, 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