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:
152
renderer_e1002.py
Normal file
152
renderer_e1002.py
Normal 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/F48),k=2 下实际为 24/48/96px。
|
||||
A 顶部(0~112):大时钟(F48)+ 同行右侧日期(F12)|右:定位·温度(F24)·天气/温区(F12)+ 矢量图标(s=2.0)
|
||||
B 中部(124~258):Claude Usage 标题 + 两条用量行(k=2 组件原样放大)
|
||||
C 底部(272~440):橘喵今日实时三列(名 F12 / 值 F24 粗 / 环比·同比 F12)
|
||||
D 色条(452~480):Spectra 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,雷阵雨含闪电实高 46:s=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
|
||||
Reference in New Issue
Block a user