#!/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 = """ eink dashboard 800x480 """ 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