#!/usr/bin/env python3 # -*- coding: utf-8 -*- """ reTerminal E1002 渲染层 —— 复用 renderer.DashboardRenderer 的组件(**k=1,字号与 400x300 版完全相同**), 利用 800x480 的四倍面积重新排版为「AI 用量看板」,输出 **E Ink Spectra 6 原生六色**(黑/白/黄/红/绿/蓝)纯色图, 并生成一张固定 800x480 的 HTML 页面供 SenseCraft HMI 的 HTML 控件云端截图拉取。 版式(k=1,点阵字体且**只用整数倍字号 12/24/36/48**,不放大;四段,三道横规): A 顶部(0~90) :大时钟+日期(左)|天气(右,贴 790)——与三色版同一套组件与尺寸。 B 用量(96~206) :左半 **Claude Usage**(all 5h / all 7d / fable 7d)|竖规 x=400|右半 **ChatGPT Usage**(mock:all 5h / all 7d / codex 7d)。 行组件沿用三色版的紧凑单行 `_usage_row`(行距 31),标签用 bar["label"](statusline 同款写法),列宽按最长标签自适应。 C 今日(218~318):**橘喵·今日实时** 五列全宽(探店单量/探店流水/团购单量/团购流水/毛利):列内四行逐行居中—— 名 F12 / 值 F24 粗 / 环比 / 同比(五列每列 ~157px,原「值 F36 + 右侧叠放环比/同比」并排组放不下)。 D 行情(~336~470):**BTC/USDT 时K(近 72 小时)** 蜡烛图(`_btc_chart`,数据来自 btc_api:OKX 主源、Coinbase/Huobi 备源,5 分钟缓存; 周期/根数由 BTC_BAR/BTC_LIMIT 配置)——标题右侧最新价 + 24h 涨跌幅;**涨(收≥开)绿、跌红**,实心蜡烛 + 1px 影线; 右侧最高/最低/最新价标签,最新价一道点规;底部首/中/末时间。 可选(470~479):六色测试色条(config.E1002_COLOR_STRIP,默认关;真机校准色彩量化时打开)。 六色的语义分工(每种颜色只有一个含义,不为「有」而用): - 黑/白:结构与正文。红:危险(用量 ≥ warn 的进度条+边框+百分比、pace ≥ +tol)、涨、定位、太阳/闪电。 - 绿:健康(用量 ≤ ok 的进度条+边框、pace ≤ −tol)、K 线上涨;红另用于 K 线下跌。用量与 pace 各自三档:绿 / 黑 / 红(阈值 USAGE_OK / USAGE_WARN / PACE_TOL)。 - 蓝:天气雨滴/雪花。黄:不用(用户明确不要黄色进度条;曾试过 60%~80% 黄「注意」档,已移除)。 - 整图只允许六种纯色;渲染后可用 `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 import config as _cfg # 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=1,字号不变),只重写版式与配色钩子。""" def __init__(self, font_path=config.FONT_PATH): super().__init__(font_path, size=(config.E1002_WIDTH, config.E1002_HEIGHT), k=1) # 宽版只用点阵字体的**整数倍字号**(12 / 24 / 36 / 48):基类给用量行用的 F13(标签、pace)与 F16(百分比) # 是非整数倍缩放(笔画不均),这里分别替换成 12px 与 24px;行几何靠 _mid 按墨迹中心对齐,不受字号变化影响。 from PIL import ImageFont self.F13 = ImageFont.truetype(font_path, 12) self.F16 = ImageFont.truetype(font_path, 24) # ---------- 配色钩子(三档:绿 健康 / 黑 正常 / 红 危险)---------- _ok, _pace_tol = _cfg.USAGE_OK, _cfg.PACE_TOL # 由 _usage_block 按数据覆盖 def _usage_colors(self, ok, pct, warn, bar): """进度条填充与边框同色:pct ≥ warn 红|pct ≤ ok 绿|其间黑。 百分比文字只在红档转红,绿档仍黑——绿在白底上偏淡,做大块填充清楚、做小字发虚。""" if not ok: return BLACK, BLACK, BLACK if pct >= warn: return RED, RED, RED if pct <= self._ok: return GREEN, GREEN, BLACK return BLACK, BLACK, BLACK def _pace_color(self, diff): """pace = 用量% − 时间%:≥ +tol 红(烧得比时间快,超前过多)|≤ −tol 绿(节余充足)|其间黑。""" if diff >= self._pace_tol: return RED if diff <= -self._pace_tol: return GREEN return 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 _usage_block(self, x0, x1, y0, usage): """一个用量区块:标题(_label,右侧「快照 HH:MM」或 mock 的「示例数据」)+ 逐行紧凑 `_usage_row`(行距 31)。 标签列宽按本块最长标签墨迹 + 8px 算,保证 "Fable 7d" 这类长标签不压进度条。返回末行底部 y。""" right = "示例数据" if usage.get("mock") else (f'快照 {usage["updated"]}' if usage.get("updated") else None) self._label(y0, usage.get("title", "Usage"), right=right, x0=x0, x1=x1) warn = usage.get("warn", 80) self._ok, self._pace_tol = usage.get("ok", _cfg.USAGE_OK), usage.get("pace_tol", _cfg.PACE_TOL) bars = [dict(b, k=b.get("label") or b["k"]) for b in usage.get("bars", [])] # 宽版用长标签(all 5h / fable 7d) label_w = max([self._tw(b["k"], self.F13) + 1 for b in bars] + [26]) + 8 y = y0 + 27 for b in bars: self._usage_row(x0, x1, y, b, warn, label_w=label_w) y += 31 return y - 31 + 18 # 末行进度条底部(cy=y+10,条高 15 → 底 y+17) def _realtime(self, y0, mao, x0=8, x1=None): """橘喵·今日实时五列(宽版):探店单量 / 探店流水 / 团购单量 / 团购流水 / 毛利,列内四行逐行水平居中: 指标名 F12(y0+17)→ 值 F24 粗(y0+34)→ 环比(y0+68)→ 同比(y0+84,行距 16);按墨迹框算,名↔值 ~9px、值↔环比 ~6px、环比↔同比 5px。 为什么不再是「值 F36 + 右侧叠放环比/同比」的并排组:五列每列仅 ~157px,F36 并排组(如「2.12万」102+间隙+环比 77) 近 194px 放不下;降到 F24 后并排组仍有 156px,贴着两侧分隔线太挤。竖排最宽元素(「1234.56%」一行 89px)余量充足, 与 400×300 小屏同构。(更早曾试过名与值同行、名贴左上角角标,均已否。)返回区块底部 y。""" x1 = self.W - 8 if x1 is None else x1 self._label(y0, "橘喵·今日实时", right=f'上次更新 {mao["upd"]}', x0=x0 + 2, x1=x1 - 2) cols = mao["cols"]; n = len(cols) cw = (x1 - x0) / n TAG_Y, VAL_Y, HB_Y, TB_Y = y0 + 17, y0 + 34, y0 + 68, y0 + 84 for i in range(1, n): self.vdash(x0 + cw * i, y0 + 22, y0 + 98) for i, c in enumerate(cols): cx = x0 + cw * (i + 0.5) self.ctext(cx, TAG_Y, c["k"], self.F12) self.ctext(cx, VAL_Y, c["v"], self.F24, bold=1) self._cmp_line(cx, HB_Y, "环比", c["hb"]) self._cmp_line(cx, TB_Y, "同比", c["tb"]) return y0 + 100 @staticmethod def _fmt_price(v): """价格文本:≥1000 千分位取整,否则两位小数。""" return f"{v:,.0f}" if abs(v) >= 1000 else f"{v:.2f}" def _btc_chart(self, y0, y1, btc, x0=10, x1=None): """BTC K 线蜡烛图(y0 标题行顶 ~ y1 底;周期由 btc["bar"] 决定标题「时K/日K」): 标题右侧 最新价(F12 粗)+ 涨跌口径(24h/1d)+ 涨跌三角 + 涨跌幅(F12),涨绿 / 跌红; 绘图区:每根蜡烛 = 1px 影线(最高↔最低)+ 实心实体(开↔收,宽≈槽位 60%、奇数像素居中),收≥开 绿、否则 红; 右侧价格标签列:最高(顶)、最低(底)、最新(粗,随最新价位置浮动、避让顶底标签),最新价横贯一道点规; 底部基线点规 + 首/中/末三个日期。无数据时居中 "--"。""" x1 = self.W - 10 if x1 is None else x1 btc = btc or {} candles = btc.get("candles") or [] kind = "时K" if btc.get("bar", "1H") == "1H" else "日K" self._label(y0, f'{btc.get("symbol") or "BTC/USDT"}·{kind}', x0=x0, x1=x1) if candles and btc.get("last") is not None: chg = btc.get("chg") or 0.0 col = GREEN if chg >= 0 else RED xr = x1 pct_txt = f"{abs(chg):.2f}%" self.rtext(xr, y0, pct_txt, self.F12, fill=col); xr -= self._tw(pct_txt, self.F12) + 4 self.tri(xr - 4, y0 + 8.5, 7, up=chg >= 0, fill=col); xr -= 8 + 6 if btc.get("chg_label"): # 涨跌幅口径(24h / 1d) self.rtext(xr, y0, btc["chg_label"], self.F12); xr -= self._tw(btc["chg_label"], self.F12) + 10 self.rtext(xr, y0, self._fmt_price(btc["last"]), self.F12, bold=1) if not candles: self.ctext((x0 + x1) / 2, (y0 + y1) / 2 - 12, "--", self.F24) return LBL_W = 66 # 右侧价格标签列 px0, px1 = x0, x1 - LBL_W py0, py1 = y0 + 26, y1 - 16 # 底部留 16px 给日期 hi_raw, lo_raw = max(c["h"] for c in candles), min(c["l"] for c in candles) pad = (hi_raw - lo_raw or 1) * 0.04 lo, hi = lo_raw - pad, hi_raw + pad span = hi - lo Y = lambda v: py1 - (v - lo) / span * (py1 - py0) n = len(candles) slot = (px1 - px0) / n bw = max(3, int(slot * 0.6)) | 1 # 实体宽度取奇数,影线居中 self.hdash(px0, px1, py1, dash=2, gap=3) # 基线 for i, c in enumerate(candles): cx = round(px0 + slot * (i + 0.5)) col = GREEN if c["c"] >= c["o"] else RED self.d.line([cx, round(Y(c["h"])), cx, round(Y(c["l"]))], fill=col, width=1) top, bot = sorted((round(Y(c["o"])), round(Y(c["c"])))) self.d.rectangle([cx - bw // 2, top, cx + bw // 2, bot], fill=col) # 十字星时 top==bot 退化为 1px 横线 # ---- 右侧价格标签:最高 / 最低 / 最新 ---- hi_y, lo_y = py0 - 6, py1 - 12 self.rtext(x1, hi_y, self._fmt_price(hi_raw), self.F12) self.rtext(x1, lo_y, self._fmt_price(lo_raw), self.F12) last = candles[-1]["c"] ly = round(Y(last)) self.hdash(px0, px1, ly, dash=1, gap=3) # 最新价水平点规 ty = min(max(ly - 8, hi_y + 15), lo_y - 15) # 避让顶/底标签 self.rtext(x1, ty, self._fmt_price(last), self.F12, bold=1) # ---- X 轴日期:首 / 中 / 末 ---- xl_y = py1 + 3 lab = lambda c: c.get("t") or c.get("d") or "" self.text(px0, xl_y, lab(candles[0]), self.F12) self.ctext((px0 + px1) / 2, xl_y, lab(candles[n // 2]), self.F12) self.rtext(px1, xl_y, lab(candles[-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 = data["date"], data["weather"], data["mao"] usage, gpt = data["usage"], data.get("usage_gpt") or {} # ---- 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 | 右 ChatGPT(紧凑单行,与三色版同款)---- MID = self.W // 2 # 400 y_l = self._usage_block(10, MID - 14, DIV_Y + 6, usage) y_r = self._usage_block(MID + 14, R, DIV_Y + 6, gpt) if gpt else y_l self.vdash(MID, DIV_Y + 6, max(y_l, y_r) + 6) DIV2_Y = max(y_l, y_r) + 16 # 分割线离末条进度条 16px,避免拥挤 self.hdash(8, self.W - 8, DIV2_Y) # ---- C. 橘喵·今日实时(全宽五列)---- end_c = self._realtime(DIV2_Y + 6, mao) DIV3_Y = end_c + 8 self.hdash(8, self.W - 8, DIV3_Y) # ---- D. BTC/USDT 日 K ---- strip = config.E1002_COLOR_STRIP self._btc_chart(DIV3_Y + 6, self.H - (16 if strip else 10), data.get("btc")) # ---- C. 可选:六色测试色条 ---- if config.E1002_COLOR_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