#!/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=1,12/24/48px 点阵字体,不放大): A 顶部(0~90) :大时钟+日期(左)|天气(右,贴 790)——与三色版同一套组件与尺寸。 B 中段(96~192) :左半 Claude Usage(10~390)|竖规 x=400|右半 橘喵·今日实时三列(410~790)。 C 下段(206~462):橘喵·30 天趋势(全宽,三指标各一色:流水红 / 单量蓝 / 毛利绿),三色版里隐藏的图在此恢复。 D 可选(470~479):六色测试色条(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: 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), 绘图区高度由 y0~y1 决定以填满下半屏。各指标按自身极值独立归一化,只表达趋势形状。""" 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 = """ 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