Files
eink-push/renderer_e1002.py
YANG JIANKUAN b58e1decf0 feat: BTC 时K 默认范围由 72 小时改为 48 小时
- BTC_LIMIT 默认 72→48(.env 未覆盖时即生效),文档同步
- BTC 文件缓存判定加入根数校验:改周期或根数后 TTL 内不再沿用旧范围
- CLAUDE.md 补充 cloudflared 快速隧道运维经验:隧道会被静默回收,
  重启用 --protocol http2 并剥掉代理环境变量

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-14 10:37:18 +08:00

269 lines
16 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=1字号与 400x300 版完全相同**
利用 800x480 的四倍面积重新排版为「AI 用量看板」,输出 **E Ink Spectra 6 原生六色**(黑/白/黄/红/绿/蓝)纯色图,
并生成一张固定 800x480 的 HTML 页面供 SenseCraft HMI 的 HTML 控件云端截图拉取。
版式k=1点阵字体且**只用整数倍字号 12/24/36/48**,不放大;四段,三道横规):
A 顶部090 :大时钟+日期(左)|天气(右,贴 790——与三色版同一套组件与尺寸。
B 用量96206 :左半 **Claude Usage**all 5h / all 7d / fable 7d竖规 x=400右半 **ChatGPT Usage**mockall 5h / all 7d / codex 7d
行组件沿用三色版的紧凑单行 `_usage_row`(行距 31标签用 bar["label"]statusline 同款写法),列宽按最长标签自适应。
C 今日218318**橘喵·今日实时** 五列全宽(探店单量/探店流水/团购单量/团购流水/毛利):列内四行逐行居中——
名 F12 / 值 F24 粗 / 环比 / 同比(五列每列 ~157px原「值 F36 右侧叠放环比/同比」并排组放不下)。
D 行情(~336470**BTC/USDT 时K近 48 小时)** 蜡烛图(`_btc_chart`,数据来自 btc_apiOKX 主源、Coinbase/Huobi 备源5 分钟缓存;
周期/根数由 BTC_BAR/BTC_LIMIT 配置)——标题右侧最新价 + 24h 涨跌幅;**涨(收≥开)绿、跌红**,实心蜡烛 + 1px 影线;
右侧最高/最低/最新价标签,最新价一道点规;底部首/中/末时间。
可选470479六色测试色条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):
"""橘喵·今日实时五列(宽版):探店单量 / 探店流水 / 团购单量 / 团购流水 / 毛利,列内四行逐行水平居中:
指标名 F12y0+17→ 值 F24 粗y0+34→ 环比y0+68→ 同比y0+84行距 16按墨迹框算名↔值 ~9px、值↔环比 ~6px、环比↔同比 5px。
为什么不再是「值 F36 右侧叠放环比/同比」的并排组:五列每列仅 ~157pxF36 并排组如「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 = """<!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