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 补充新设备的架构说明与用法
This commit is contained in:
@@ -10,6 +10,8 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
|||||||
|
|
||||||
> **分支 `feature/colorful`**:新设备在黑白外支持红色,本分支把渲染升级为黑/白/红三色。当前版式为**上中下三段**:A 大时钟+日期 / 天气(定位南京·江宁);B **Claude Usage** 主角(5h/7d 用量进度条 + 时间维度对比 pace,用量吃紧转红预警);C 橘喵今日实时三列。红作**语义强调**(用量预警、涨、定位、天气太阳/闪电),仍**无真灰阶、不抖动**,全图只三种纯色。近30天趋势图当前**隐藏**(`renderer._trend_chart` 及依赖保留、未调用,随时可挂回)。
|
> **分支 `feature/colorful`**:新设备在黑白外支持红色,本分支把渲染升级为黑/白/红三色。当前版式为**上中下三段**:A 大时钟+日期 / 天气(定位南京·江宁);B **Claude Usage** 主角(5h/7d 用量进度条 + 时间维度对比 pace,用量吃紧转红预警);C 橘喵今日实时三列。红作**语义强调**(用量预警、涨、定位、天气太阳/闪电),仍**无真灰阶、不抖动**,全图只三种纯色。近30天趋势图当前**隐藏**(`renderer._trend_chart` 及依赖保留、未调用,随时可挂回)。
|
||||||
|
|
||||||
|
> **分支 `feature/reterminal-e1002`**(基于 `feature/colorful`):新增第二个目标设备 **Seeed reTerminal E1002**(7.3 寸 **800×480**,E Ink **Spectra 6 原生六色**:黑/白/黄/红/绿/蓝)。该设备不接受局域网直推,而是由 **SenseCraft HMI** 云端的 **HTML 控件**按设备刷新间隔(最小 5 分钟)**每周期重新抓取**一个公网 HTTPS URL、无头浏览器截图后量化下发;不支持 ETag/304,内容不变也会整屏重绘(全刷约 17 秒)。因此本分支产出的是**一张固定 800×480 的静态页 `output/e1002/index.html` + 同目录 `dashboard.png`**,需部署到公网 HTTPS(局域网地址/明文 HTTP 云端抓不到)。渲染由 `renderer_e1002.py` 完成:继承 `DashboardRenderer` 的全部组件、以 **`k=2` 整数倍缩放**(字体 24/48/96px,像素常量乘 k),只重写 `render()` 版式;元素与三色版完全相同(时钟日期/天气、Claude Usage、橘喵今日实时),黄/绿/蓝目前**仅用于底部六色测试色条**(真机校准平台量化用),语义配色仍只用黑/白/红。
|
||||||
|
|
||||||
## 命令
|
## 命令
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -17,6 +19,7 @@ pip install -r requirements.txt # 仅 Pillow>=10.0;urllib 用标准库
|
|||||||
cp .env.example .env # 首次:复制模板并填入真实凭证 + 设备 host
|
cp .env.example .env # 首次:复制模板并填入真实凭证 + 设备 host
|
||||||
python3 main.py # 取数据 → 渲染 → 推送(设备 host 取自 .env 的 EPD_HOST)
|
python3 main.py # 取数据 → 渲染 → 推送(设备 host 取自 .env 的 EPD_HOST)
|
||||||
python3 main.py --render-only # 只渲染到 output/dashboard.png,不推送(本地调试渲染时用这个)
|
python3 main.py --render-only # 只渲染到 output/dashboard.png,不推送(本地调试渲染时用这个)
|
||||||
|
python3 main.py --target e1002 # reTerminal E1002:渲染 800x480 六色图 + 固定尺寸 index.html 到 output/e1002/,不推送
|
||||||
|
|
||||||
sh run.sh # cron 包装脚本:cd 项目目录 + vfox python 绝对路径 + 追加日志到 output/cron.log
|
sh run.sh # cron 包装脚本:cd 项目目录 + vfox python 绝对路径 + 追加日志到 output/cron.log
|
||||||
|
|
||||||
@@ -48,6 +51,7 @@ EPD_HOST=epd400a44.local python3 main.py
|
|||||||
- **`_trend_chart`(当前隐藏)**:近30天三指标折线,用 `_styled_polyline()` 按 solid/dashed/dotted 区分、主指标(流水)红线,各指标按自身极值独立归一化。已保留但 `render()` 未调用,想恢复即在 C 前挂回。
|
- **`_trend_chart`(当前隐藏)**:近30天三指标折线,用 `_styled_polyline()` 按 solid/dashed/dotted 区分、主指标(流水)红线,各指标按自身极值独立归一化。已保留但 `render()` 未调用,想恢复即在 C 前挂回。
|
||||||
- **天气图标全矢量手绘**(无图片资源):`_weather_category()` 把和风 icon 代码归并为 8 类(sunny/clear_night/cloudy/overcast/thunder/rain/snow/fog,识别不了回退 cloudy),再由 `_weather_icon()` 用 `_sun`/`_cloud` 等基元按 1bit 描边画出;改天气展示从这里动。
|
- **天气图标全矢量手绘**(无图片资源):`_weather_category()` 把和风 icon 代码归并为 8 类(sunny/clear_night/cloudy/overcast/thunder/rain/snow/fog,识别不了回退 cloudy),再由 `_weather_icon()` 用 `_sun`/`_cloud` 等基元按 1bit 描边画出;改天气展示从这里动。
|
||||||
- **`pusher.py`** — 直连局域网墨水屏设备(喵喵固件,开源:https://gitee.com/gxp666111/miaomiao,`web/` 即设备自带管理页,可浏览器打开 `http://<EPD_HOST>/` 调试同一套接口)。`push_image()` 先调 `GET /images` 拿画廊已存图片名单,逐个 `POST /delete_image?name=`清空(单张删除失败不阻塞后续),再 `POST /upload`(`Content-Type: image/png`,body 为原始 PNG 字节,非 multipart)触发设备转换+刷屏。**清空画廊是刻意设计**:设备把每次 `/upload` 都追加存一份到画廊(`GET /images` 可见),不清理会越推越多、且设备当前 `current_mode` 为 slideshow 会轮播到旧图;先清后传使画廊恒为 1 张,等价于始终静态展示最新一张。**清理非核心**:拉取画廊列表本身失败也不阻塞后续 `/upload`(跳过清理,直接推)。`_request()` 对连接类异常(`urllib.error.URLError`,含 mDNS `.local` 域名解析瞬时失败)内置重试(`RETRY_TIMES=2`,间隔 `RETRY_DELAY=2s`)——mDNS 基于组播 UDP,偶发丢包很正常,单次失败不该让整轮推送直接跳过。注:历史上曾走 Zectrix 公网 API(`POST /devices/{id}/display/image` + 设备 MAC + `ZECTRIX_API_KEY`),现统一为局域网直连。
|
- **`pusher.py`** — 直连局域网墨水屏设备(喵喵固件,开源:https://gitee.com/gxp666111/miaomiao,`web/` 即设备自带管理页,可浏览器打开 `http://<EPD_HOST>/` 调试同一套接口)。`push_image()` 先调 `GET /images` 拿画廊已存图片名单,逐个 `POST /delete_image?name=`清空(单张删除失败不阻塞后续),再 `POST /upload`(`Content-Type: image/png`,body 为原始 PNG 字节,非 multipart)触发设备转换+刷屏。**清空画廊是刻意设计**:设备把每次 `/upload` 都追加存一份到画廊(`GET /images` 可见),不清理会越推越多、且设备当前 `current_mode` 为 slideshow 会轮播到旧图;先清后传使画廊恒为 1 张,等价于始终静态展示最新一张。**清理非核心**:拉取画廊列表本身失败也不阻塞后续 `/upload`(跳过清理,直接推)。`_request()` 对连接类异常(`urllib.error.URLError`,含 mDNS `.local` 域名解析瞬时失败)内置重试(`RETRY_TIMES=2`,间隔 `RETRY_DELAY=2s`)——mDNS 基于组播 UDP,偶发丢包很正常,单次失败不该让整轮推送直接跳过。注:历史上曾走 Zectrix 公网 API(`POST /devices/{id}/display/image` + 设备 MAC + `ZECTRIX_API_KEY`),现统一为局域网直连。
|
||||||
|
- **`renderer_e1002.py`** — reTerminal E1002 目标:`E1002Renderer(DashboardRenderer)` 以 `k=2` 复用全部组件,`render()` 重排为 800×480 四段(A 顶部 0–112:时钟 F48 + 同基线日期 F12|右侧定位/温度 F24/天气·温区 F12 + 图标 s=2.0;B 124–258 Claude Usage;C 272–440 今日实时三列;D 452–480 六色测试色条 `_color_strip`),`SPECTRA6` 为六色调色板。`write_page()` 写出固定 800×480 的 `index.html`(零边距、`image-rendering: pixelated`、PNG 带时间戳参数防缓存)。**为何固定尺寸而非自适应**:云端截图视口/DPR 不可控,自适应会重排+抗锯齿产生中间色再被平台抖动;固定页里的 1:1 PNG 每个像素本就是六色之一,截图零损失——已用无头 Chrome `--window-size=800,480` 截图验证与 PNG 逐像素一致。设备端 HTML 控件须铺满整个画布。
|
||||||
- **`config.py`** — 顶部 `_load_dotenv()` 先加载同目录 `.env`,再定义 API、设备、画布尺寸、字体路径、推送参数;凭证 fallback 为空串,真实值来自 `.env`/环境变量。
|
- **`config.py`** — 顶部 `_load_dotenv()` 先加载同目录 `.env`,再定义 API、设备、画布尺寸、字体路径、推送参数;凭证 fallback 为空串,真实值来自 `.env`/环境变量。
|
||||||
|
|
||||||
## 墨水屏渲染约束(改 renderer.py 必读)
|
## 墨水屏渲染约束(改 renderer.py 必读)
|
||||||
@@ -56,6 +60,7 @@ EPD_HOST=epd400a44.local python3 main.py
|
|||||||
|
|
||||||
- **红只作语义强调**:方向(涨)、警示(闪电)、强调(定位/主折线);红占墨保持很小(<10% 墨迹),滥用会稀释注意力、破坏层级。整图**只允许三种纯色** `(0,0,0)/(255,255,255)/(255,0,0)`——渲染后可用 `img.getcolors()` 自查,出现第四种色(多为抗锯齿灰边)即为 bug。矢量图元(line/ellipse/polygon/arc)默认不抗锯齿故为纯色;文字靠 `fontmode="1"` 保持纯色。
|
- **红只作语义强调**:方向(涨)、警示(闪电)、强调(定位/主折线);红占墨保持很小(<10% 墨迹),滥用会稀释注意力、破坏层级。整图**只允许三种纯色** `(0,0,0)/(255,255,255)/(255,0,0)`——渲染后可用 `img.getcolors()` 自查,出现第四种色(多为抗锯齿灰边)即为 bug。矢量图元(line/ellipse/polygon/arc)默认不抗锯齿故为纯色;文字靠 `fontmode="1"` 保持纯色。
|
||||||
|
|
||||||
|
- **`DashboardRenderer(k=…)` 缩放系数**:所有组件(`_label`/`_cmp_line`/`_usage_row`/`_pace`/`tri` 等)的像素常量与伪粗体偏移都乘 `k`,字体按 `12*k/24*k/48*k` 创建;`k=1` 为 400×300 原版(改组件后务必用固定夹具比对 `k=1` 输出逐像素不变),`k=2` 供 800×480。字体属性名(`F12/F24/F48`)表达层级而非绝对像素,写新版式时沿用原层级名,不要再手动放大一档。
|
||||||
- 字体固定用 **Fusion Pixel 12px 点阵字体**(`fonts/`,OFL 协议),首选整数倍尺寸 **12/24/48**(`F12/F24/F48`)最锐利;用量区为拿到合适的字号层级另用了 **F13/F16**(1.08x/1.33x 非整数倍,笔画略不均,属有意取舍),靠 `d.fontmode = "1"` 关抗锯齿仍保持纯色不糊。跨字号竖直居中统一用 `_mid()`(按 `getbbox` 墨迹中心对齐)。
|
- 字体固定用 **Fusion Pixel 12px 点阵字体**(`fonts/`,OFL 协议),首选整数倍尺寸 **12/24/48**(`F12/F24/F48`)最锐利;用量区为拿到合适的字号层级另用了 **F13/F16**(1.08x/1.33x 非整数倍,笔画略不均,属有意取舍),靠 `d.fontmode = "1"` 关抗锯齿仍保持纯色不糊。跨字号竖直居中统一用 `_mid()`(按 `getbbox` 墨迹中心对齐)。
|
||||||
- 加粗用「伪粗体」:`_draw()` 按 1~bold px 水平偏移叠绘笔画(点阵字体只有单一字重)。
|
- 加粗用「伪粗体」:`_draw()` 按 1~bold px 水平偏移叠绘笔画(点阵字体只有单一字重)。
|
||||||
- 推送时 `DITHER = False`(硬阈值),纯黑白图最锐利,勿改成 `true`。
|
- 推送时 `DITHER = False`(硬阈值),纯黑白图最锐利,勿改成 `true`。
|
||||||
|
|||||||
@@ -19,7 +19,8 @@
|
|||||||
|
|
||||||
```
|
```
|
||||||
eink-push/
|
eink-push/
|
||||||
├── main.py # 入口:取数据 → 渲染 → 推送
|
├── main.py # 入口:取数据 → 渲染 → 推送(--target e1002 时改为产出 HTML 页)
|
||||||
|
├── renderer_e1002.py# reTerminal E1002:800x480 六色版式 + 固定尺寸 index.html 生成
|
||||||
├── config.py # 配置:设备、天气、用量、字体、尺寸、推送参数、缓存路径
|
├── config.py # 配置:设备、天气、用量、字体、尺寸、推送参数、缓存路径
|
||||||
├── data.py # 数据层:拉真实数据并映射,失败回退占位 "--"(不回退 mock);天气/趋势缓存
|
├── data.py # 数据层:拉真实数据并映射,失败回退占位 "--"(不回退 mock);天气/趋势缓存
|
||||||
├── jm_api.py # jm-devops 后端统计接口客户端(设备密钥 client_secret 授权 + token 缓存)
|
├── jm_api.py # jm-devops 后端统计接口客户端(设备密钥 client_secret 授权 + token 缓存)
|
||||||
@@ -45,6 +46,8 @@ pip install -r requirements.txt # 仅 Pillow>=10.0;网络请求用标准库
|
|||||||
```bash
|
```bash
|
||||||
python3 main.py # 渲染并推送(设备取 .env 的 ZECTRIX_DEVICE_ID)
|
python3 main.py # 渲染并推送(设备取 .env 的 ZECTRIX_DEVICE_ID)
|
||||||
python3 main.py --render-only # 只渲染到 output/dashboard.png,不推送(本地调试渲染用)
|
python3 main.py --render-only # 只渲染到 output/dashboard.png,不推送(本地调试渲染用)
|
||||||
|
python3 main.py --target e1002 # reTerminal E1002(800x480 六色):产出 output/e1002/index.html + dashboard.png,
|
||||||
|
# 部署到公网 HTTPS 后填入 SenseCraft HMI 的 HTML 控件(云端每周期重新抓取)
|
||||||
```
|
```
|
||||||
|
|
||||||
环境变量可覆盖配置(详见 `config.py`):
|
环境变量可覆盖配置(详见 `config.py`):
|
||||||
|
|||||||
@@ -32,13 +32,20 @@ _load_dotenv(os.path.join(BASE_DIR, ".env"))
|
|||||||
# 不再走 Zectrix 公网 API + 设备 MAC,改为直连局域网设备 mDNS host 的 HTTP 接口。
|
# 不再走 Zectrix 公网 API + 设备 MAC,改为直连局域网设备 mDNS host 的 HTTP 接口。
|
||||||
EPD_HOST = os.environ.get("EPD_HOST", "epd400a44.local")
|
EPD_HOST = os.environ.get("EPD_HOST", "epd400a44.local")
|
||||||
|
|
||||||
# ---- 画布 ----
|
# ---- 画布(喵喵固件 400x300 三色屏)----
|
||||||
WIDTH, HEIGHT = 400, 300
|
WIDTH, HEIGHT = 400, 300
|
||||||
|
|
||||||
|
# ---- reTerminal E1002(800x480,E Ink Spectra 6 六色;经 SenseCraft HMI 的 HTML 控件云端抓取)----
|
||||||
|
# 产物:固定 800x480 的 index.html + 同目录 dashboard.png,需部署到公网 HTTPS 地址供平台每周期重新拉取。
|
||||||
|
E1002_WIDTH, E1002_HEIGHT = 800, 480
|
||||||
|
|
||||||
# ---- 资源路径 ----
|
# ---- 资源路径 ----
|
||||||
FONT_PATH = os.path.join(BASE_DIR, "fonts", "fusion-pixel-12px-proportional-zh_hans.otf")
|
FONT_PATH = os.path.join(BASE_DIR, "fonts", "fusion-pixel-12px-proportional-zh_hans.otf")
|
||||||
OUTPUT_DIR = os.path.join(BASE_DIR, "output")
|
OUTPUT_DIR = os.path.join(BASE_DIR, "output")
|
||||||
OUTPUT_PATH = os.path.join(OUTPUT_DIR, "dashboard.png")
|
OUTPUT_PATH = os.path.join(OUTPUT_DIR, "dashboard.png")
|
||||||
|
E1002_OUTPUT_DIR = os.path.join(OUTPUT_DIR, "e1002")
|
||||||
|
E1002_PNG_PATH = os.path.join(E1002_OUTPUT_DIR, "dashboard.png")
|
||||||
|
E1002_HTML_PATH = os.path.join(E1002_OUTPUT_DIR, "index.html")
|
||||||
|
|
||||||
# ---- 橘喵 jm-devops 后端(今日经营 / 近30天趋势 数据源)----
|
# ---- 橘喵 jm-devops 后端(今日经营 / 近30天趋势 数据源)----
|
||||||
# 鉴权方式:设备密钥授权(grant_type=client_secret,免验证码)。
|
# 鉴权方式:设备密钥授权(grant_type=client_secret,免验证码)。
|
||||||
|
|||||||
13
main.py
13
main.py
@@ -6,6 +6,8 @@
|
|||||||
用法:
|
用法:
|
||||||
python3 main.py # 渲染并推送(设备 host 取自 .env 的 EPD_HOST)
|
python3 main.py # 渲染并推送(设备 host 取自 .env 的 EPD_HOST)
|
||||||
python3 main.py --render-only # 只渲染生成图片,不推送(output/dashboard.png)
|
python3 main.py --render-only # 只渲染生成图片,不推送(output/dashboard.png)
|
||||||
|
python3 main.py --target e1002 # reTerminal E1002:渲染 800x480 六色图 + 固定尺寸 index.html 到 output/e1002/,
|
||||||
|
# 不推送(由 SenseCraft HMI 的 HTML 控件从公网地址周期性抓取)
|
||||||
|
|
||||||
配置统一从同目录 .env 读取(见 .env.example),真实环境变量优先。
|
配置统一从同目录 .env 读取(见 .env.example),真实环境变量优先。
|
||||||
"""
|
"""
|
||||||
@@ -19,9 +21,20 @@ from renderer import DashboardRenderer
|
|||||||
def main():
|
def main():
|
||||||
parser = argparse.ArgumentParser(description="墨水屏仪表盘:渲染并推送")
|
parser = argparse.ArgumentParser(description="墨水屏仪表盘:渲染并推送")
|
||||||
parser.add_argument("--render-only", action="store_true", help="只渲染,不推送")
|
parser.add_argument("--render-only", action="store_true", help="只渲染,不推送")
|
||||||
|
parser.add_argument("--target", choices=("miao", "e1002"), default="miao",
|
||||||
|
help="miao=喵喵固件 400x300 三色屏(默认,局域网推送);e1002=reTerminal E1002 800x480 六色(产出 HTML 页)")
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
data = get_dashboard_data()
|
data = get_dashboard_data()
|
||||||
|
|
||||||
|
if args.target == "e1002":
|
||||||
|
from renderer_e1002 import E1002Renderer, write_page
|
||||||
|
png = E1002Renderer().render_to_file(data, config.E1002_PNG_PATH)
|
||||||
|
html = write_page(config.E1002_HTML_PATH, png)
|
||||||
|
print(f"[渲染] 已生成 {png}")
|
||||||
|
print(f"[页面] 已生成 {html}(部署到公网 HTTPS 后填入 SenseCraft HMI 的 HTML 控件)")
|
||||||
|
return
|
||||||
|
|
||||||
path = DashboardRenderer().render_to_file(data, config.OUTPUT_PATH)
|
path = DashboardRenderer().render_to_file(data, config.OUTPUT_PATH)
|
||||||
print(f"[渲染] 已生成 {path}")
|
print(f"[渲染] 已生成 {path}")
|
||||||
|
|
||||||
|
|||||||
57
renderer.py
57
renderer.py
@@ -31,13 +31,17 @@ BLACK, WHITE, RED = (0, 0, 0), (255, 255, 255), (255, 0, 0)
|
|||||||
|
|
||||||
|
|
||||||
class DashboardRenderer:
|
class DashboardRenderer:
|
||||||
def __init__(self, font_path=config.FONT_PATH, size=(config.WIDTH, config.HEIGHT)):
|
def __init__(self, font_path=config.FONT_PATH, size=(config.WIDTH, config.HEIGHT), k=1):
|
||||||
|
"""k:整体缩放系数(整数)。k=1 为 400x300 原版;k=2 供 800x480 设备(reTerminal E1002)复用组件:
|
||||||
|
字体按 k 倍创建(点阵字体整数倍最锐利),组件内的像素常量一律乘 k,故各组件在两种尺寸下形态一致。
|
||||||
|
字体属性名仍按 k=1 的字号命名(F12/F24/…),表达「层级」而非绝对像素。"""
|
||||||
self.W, self.H = size
|
self.W, self.H = size
|
||||||
self.F12 = ImageFont.truetype(font_path, 12)
|
self.k = k
|
||||||
self.F13 = ImageFont.truetype(font_path, 13) # 5h/7d 与 pace 用;接近原生 12px,畸变很小
|
self.F12 = ImageFont.truetype(font_path, 12 * k)
|
||||||
self.F16 = ImageFont.truetype(font_path, 16) # 非整数倍(1.33x):靠 fontmode='1' 保持纯色,笔画略不均可接受
|
self.F13 = ImageFont.truetype(font_path, 13 * k) # 5h/7d 与 pace 用;接近原生 12px,畸变很小
|
||||||
self.F24 = ImageFont.truetype(font_path, 24)
|
self.F16 = ImageFont.truetype(font_path, 16 * k) # 非整数倍(1.33x):靠 fontmode='1' 保持纯色,笔画略不均可接受
|
||||||
self.F48 = ImageFont.truetype(font_path, 48)
|
self.F24 = ImageFont.truetype(font_path, 24 * k)
|
||||||
|
self.F48 = ImageFont.truetype(font_path, 48 * k)
|
||||||
|
|
||||||
# ---------- 基础绘制工具 ----------
|
# ---------- 基础绘制工具 ----------
|
||||||
def _tw(self, s, ft, track=0):
|
def _tw(self, s, ft, track=0):
|
||||||
@@ -70,16 +74,16 @@ class DashboardRenderer:
|
|||||||
def ctext(self, cx, y, s, ft, fill=BLACK, bold=0):
|
def ctext(self, cx, y, s, ft, fill=BLACK, bold=0):
|
||||||
self._draw(cx - (self._tw(s, ft) + bold) / 2, y, s, ft, fill, bold)
|
self._draw(cx - (self._tw(s, ft) + bold) / 2, y, s, ft, fill, bold)
|
||||||
|
|
||||||
def hdash(self, x0, x1, y, dash=4, gap=4, fill=BLACK):
|
def hdash(self, x0, x1, y, dash=4, gap=4, fill=BLACK, width=1):
|
||||||
x = x0
|
x = x0
|
||||||
while x < x1:
|
while x < x1:
|
||||||
self.d.line([x, y, min(x + dash, x1), y], fill=fill, width=1)
|
self.d.line([x, y, min(x + dash, x1), y], fill=fill, width=width)
|
||||||
x += dash + gap
|
x += dash + gap
|
||||||
|
|
||||||
def vdash(self, x, y0, y1, dash=4, gap=4, fill=BLACK):
|
def vdash(self, x, y0, y1, dash=4, gap=4, fill=BLACK, width=1):
|
||||||
y = y0
|
y = y0
|
||||||
while y < y1:
|
while y < y1:
|
||||||
self.d.line([x, y, x, min(y + dash, y1)], fill=fill, width=1)
|
self.d.line([x, y, x, min(y + dash, y1)], fill=fill, width=width)
|
||||||
y += dash + gap
|
y += dash + gap
|
||||||
|
|
||||||
def tri(self, cx, cy, sz, up=True, fill=BLACK):
|
def tri(self, cx, cy, sz, up=True, fill=BLACK):
|
||||||
@@ -93,8 +97,9 @@ class DashboardRenderer:
|
|||||||
def _cmp_line(self, cx, y, label, pair, fg=BLACK):
|
def _cmp_line(self, cx, y, label, pair, fg=BLACK):
|
||||||
"""环比/同比一行:标签 + 涨跌三角 + 数值,整体居中。direction 为 None 时不画三角。"""
|
"""环比/同比一行:标签 + 涨跌三角 + 数值,整体居中。direction 为 None 时不画三角。"""
|
||||||
direction, val = pair
|
direction, val = pair
|
||||||
lw = self._tw(label, self.F12); gap = 5; nw = self._tw(val, self.F12)
|
k = self.k
|
||||||
trw, tgap = (8, 4) if direction else (0, 0)
|
lw = self._tw(label, self.F12); gap = 5 * k; nw = self._tw(val, self.F12)
|
||||||
|
trw, tgap = (8 * k, 4 * k) if direction else (0, 0)
|
||||||
x = cx - (lw + gap + trw + tgap + nw) / 2
|
x = cx - (lw + gap + trw + tgap + nw) / 2
|
||||||
self.text(x, y, label, self.F12, fill=fg); x += lw + gap
|
self.text(x, y, label, self.F12, fill=fg); x += lw + gap
|
||||||
# 方向语义配色(红涨):涨 → 箭头与百分比数值一起红;跌/无 → 黑(标签始终黑)
|
# 方向语义配色(红涨):涨 → 箭头与百分比数值一起红;跌/无 → 黑(标签始终黑)
|
||||||
@@ -102,17 +107,18 @@ class DashboardRenderer:
|
|||||||
val_color = RED if up else fg
|
val_color = RED if up else fg
|
||||||
if direction:
|
if direction:
|
||||||
# 三角中心对齐 F12 数字墨迹竖直中心(y+8.5),否则箭头偏高
|
# 三角中心对齐 F12 数字墨迹竖直中心(y+8.5),否则箭头偏高
|
||||||
self.tri(x + trw / 2, y + 8.5, 7, up=up, fill=val_color); x += trw + tgap
|
self.tri(x + trw / 2, y + 8.5 * k, 7 * k, up=up, fill=val_color); x += trw + tgap
|
||||||
self.text(x, y, val, self.F12, fill=val_color)
|
self.text(x, y, val, self.F12, fill=val_color)
|
||||||
|
|
||||||
def _label(self, y, title, right=None):
|
def _label(self, y, title, right=None):
|
||||||
"""区块标题(两区块统一,对齐主分支 _title_bar 风格):
|
"""区块标题(两区块统一,对齐主分支 _title_bar 风格):
|
||||||
■ 方块装饰(8px,与 F12 标题墨迹竖直居中) + 标题(F12,常规字重) + 可选右侧信息。"""
|
■ 方块装饰(8px,与 F12 标题墨迹竖直居中) + 标题(F12,常规字重) + 可选右侧信息。"""
|
||||||
SQ = 8
|
k = self.k
|
||||||
self.d.rectangle([10, y + 4, 10 + SQ, y + 4 + SQ], fill=BLACK) # 中心≈y+8,对齐 F12 墨迹中心(≈y+8.5)
|
SQ, M = 8 * k, 10 * k # 方块边长 / 左右页边距
|
||||||
self.text(10 + SQ + 6, y, title, self.F12)
|
self.d.rectangle([M, y + 4 * k, M + SQ, y + 4 * k + SQ], fill=BLACK) # 中心≈y+8,对齐 F12 墨迹中心(≈y+8.5)
|
||||||
|
self.text(M + SQ + 6 * k, y, title, self.F12)
|
||||||
if right:
|
if right:
|
||||||
self.rtext(self.W - 10, y, right, self.F12)
|
self.rtext(self.W - M, y, right, self.F12)
|
||||||
|
|
||||||
def _mid(self, x, cy, s, ft, fill=BLACK, bold=0, align="l", track=0):
|
def _mid(self, x, cy, s, ft, fill=BLACK, bold=0, align="l", track=0):
|
||||||
"""按字符串「墨迹竖直中心」对齐到 cy 绘制——跨字号(F12/F24)也能真正居中,
|
"""按字符串「墨迹竖直中心」对齐到 cy 绘制——跨字号(F12/F24)也能真正居中,
|
||||||
@@ -134,10 +140,10 @@ class DashboardRenderer:
|
|||||||
color = RED if up else BLACK
|
color = RED if up else BLACK
|
||||||
txt = f"{abs(int(diff)):02d}"
|
txt = f"{abs(int(diff)):02d}"
|
||||||
tw = self._tw(txt, self.F13)
|
tw = self._tw(txt, self.F13)
|
||||||
self._mid(xr, cy, txt, self.F13, fill=color, bold=1, align="r")
|
self._mid(xr, cy, txt, self.F13, fill=color, bold=self.k, align="r")
|
||||||
if diff == 0:
|
if diff == 0:
|
||||||
return tw
|
return tw
|
||||||
TRI_GAP, TRI = 4, 6
|
TRI_GAP, TRI = 4 * self.k, 6 * self.k
|
||||||
self.tri(xr - tw - TRI_GAP - TRI / 2, cy, TRI, up=up, fill=color)
|
self.tri(xr - tw - TRI_GAP - TRI / 2, cy, TRI, up=up, fill=color)
|
||||||
return tw + TRI_GAP + TRI
|
return tw + TRI_GAP + TRI
|
||||||
|
|
||||||
@@ -149,21 +155,22 @@ class DashboardRenderer:
|
|||||||
ok = isinstance(raw, (int, float)) # None/缺失 → 显示 "--"
|
ok = isinstance(raw, (int, float)) # None/缺失 → 显示 "--"
|
||||||
pct = max(0, min(100, int(raw))) if ok else 0
|
pct = max(0, min(100, int(raw))) if ok else 0
|
||||||
color = RED if (ok and pct >= warn) else BLACK
|
color = RED if (ok and pct >= warn) else BLACK
|
||||||
LABEL_W, G1, G2, H = 26, 13, 8, 15 # G1 进度条↔百分比、G2 百分比↔pace(视觉约 12/11)
|
k = self.k
|
||||||
PCT_TRACK = 2 # F16(16/12x 非整数倍缩放)数字墨迹无侧边距、紧贴,手动加字符间距
|
LABEL_W, G1, G2, H = 26 * k, 13 * k, 8 * k, 15 * k # G1 进度条↔百分比、G2 百分比↔pace(视觉约 12/11)
|
||||||
cy = y + 10 # 行竖直中心
|
PCT_TRACK = 2 * k # F16(16/12x 非整数倍缩放)数字墨迹无侧边距、紧贴,手动加字符间距
|
||||||
self._mid(x0, cy, bar["k"], self.F13, bold=1) # 左标签 F13
|
cy = y + 10 * k # 行竖直中心
|
||||||
|
self._mid(x0, cy, bar["k"], self.F13, bold=k) # 左标签 F13
|
||||||
# 右侧从右往左:pace(最右) → 百分比 → 进度条
|
# 右侧从右往左:pace(最右) → 百分比 → 进度条
|
||||||
pct_r = x1
|
pct_r = x1
|
||||||
if ok and "time_pct" in bar:
|
if ok and "time_pct" in bar:
|
||||||
pace_w = self._pace(x1, cy, pct - int(bar["time_pct"]))
|
pace_w = self._pace(x1, cy, pct - int(bar["time_pct"]))
|
||||||
pct_r = x1 - pace_w - G2
|
pct_r = x1 - pace_w - G2
|
||||||
pct_txt = f"{pct:02d}%" if ok else "--"
|
pct_txt = f"{pct:02d}%" if ok else "--"
|
||||||
self._mid(pct_r, cy, pct_txt, self.F16, fill=color, bold=1, align="r", track=PCT_TRACK) # 百分比 F16(主)
|
self._mid(pct_r, cy, pct_txt, self.F16, fill=color, bold=k, align="r", track=PCT_TRACK) # 百分比 F16(主)
|
||||||
bx0 = x0 + LABEL_W
|
bx0 = x0 + LABEL_W
|
||||||
bx1 = pct_r - self._tw(pct_txt, self.F16, PCT_TRACK) - G1
|
bx1 = pct_r - self._tw(pct_txt, self.F16, PCT_TRACK) - G1
|
||||||
by = cy - H // 2
|
by = cy - H // 2
|
||||||
self.d.rectangle([bx0, by, bx1, by + H], outline=color, width=1) # 轨道边框随条色
|
self.d.rectangle([bx0, by, bx1, by + H], outline=color, width=k) # 轨道边框随条色
|
||||||
fw = (bx1 - bx0) * pct / 100.0
|
fw = (bx1 - bx0) * pct / 100.0
|
||||||
if fw >= 2:
|
if fw >= 2:
|
||||||
self.d.rectangle([bx0, by, bx0 + fw, by + H], fill=color)
|
self.d.rectangle([bx0, by, bx0 + fw, by + H], fill=color)
|
||||||
|
|||||||
152
renderer_e1002.py
Normal file
152
renderer_e1002.py
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
#!/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 = """<!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
|
||||||
Reference in New Issue
Block a user