Files
eink-push/renderer_e1002.py
YANG JIANKUAN eab90bf3b5 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 补充新设备的架构说明与用法
2026-09-10 10:49:26 +08:00

153 lines
7.8 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=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