From 9b39eacecfd688bdb2bb7bbc462482eb24de2cf4 Mon Sep 17 00:00:00 2001 From: YANG JIANKUAN Date: Sun, 5 Jul 2026 18:19:38 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B8=B2=E6=9F=93=E5=8D=87=E7=BA=A7?= =?UTF-8?q?=E4=B8=BA=E9=BB=91/=E7=99=BD/=E7=BA=A2=E4=B8=89=E8=89=B2?= =?UTF-8?q?=EF=BC=8C=E7=BA=A2=E4=BD=9C=E7=A8=80=E7=BC=BA=E5=BC=BA=E8=B0=83?= =?UTF-8?q?=E7=82=B9=E7=BC=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新设备在黑白外支持红色。将 renderer 画布从 'L' 灰度改为 'RGB' 三色, RED=(255,0,0) 作为设备红通道的明确信号;fontmode='1' 保持点阵纯色不发糊。 红只承载语义、占墨极小(实测占墨迹 <10%),点缀四处: - 定位图标(红水滴) - 天气图标的太阳与闪电(云/雨/雪/雾/月仍黑) - 环比/同比「涨」的三角与百分比数值(红涨;跌/标签仍黑) - 趋势图主指标(流水)折线 + 末点红点 + 图例同步红 整图仅三种纯色,无灰阶、不抖动;dither 保持 false。 同步更新 CLAUDE.md / README.md 的三色约束与红点缀清单。 Co-Authored-By: Claude Opus 4.8 (1M context) --- CLAUDE.md | 11 ++++++++--- README.md | 18 +++++++++++------- renderer.py | 51 +++++++++++++++++++++++++++++++++------------------ 3 files changed, 52 insertions(+), 28 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 16331fa..0e258eb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,7 +6,9 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co `eink-push` 是 JM monorepo 中一个**独立的 Python 工具**,与其余 Java/Vue 子项目技术栈无关,不受父级 CLAUDE.md 的 jm-cloud/uniapp/admin 开发约束。 -作用:把橘喵今日经营、近30天趋势、日期与天气合成为一张 **400×300 纯黑白(1bit)** PNG,推送到 Zectrix 极趣云墨水屏设备(`zectrix-s3-epaper-4.2`)。纯 Python + Pillow,**无浏览器依赖**,适合 cron 定时运行。 +作用:把橘喵今日经营、近30天趋势、日期与天气合成为一张 **400×300 三色(黑/白/红,BWR)** PNG,推送到 Zectrix 极趣云墨水屏设备。纯 Python + Pillow,**无浏览器依赖**,适合 cron 定时运行。 + +> **分支 `feature/colorful`**:新设备在黑白之外支持红色。本分支把渲染从纯黑白升级为黑/白/红三色,红作为**稀缺强调色**只承载语义(方向·警示·强调),占墨极小(实测红≈全图 0.6%、占墨迹 <10%)。仍**无真灰阶、不抖动**。 ## 命令 @@ -36,14 +38,17 @@ ZECTRIX_DEVICE_ID=AA:BB:CC:DD:EE:FF python3 main.py - **两套文件缓存**(为 cron 每分钟独立进程而设,必须落盘):天气缓存 `.weather_cache.json`(TTL=`WEATHER_TTL`,默认 15min;未过期不发请求);趋势缓存 `.trend_cache.json`(按日期 key,当天命中即不再调 `recent-days`,每天只拉一次)。两者**拉取失败均沿用旧缓存**避免整片 `"--"`,仍无缓存才回退占位。两个缓存文件已在 `.gitignore`。 - **`jm_api.py`** — jm-devops 后端统计接口客户端,**设备密钥授权**(`grant_type=client_secret`,免验证码)。先用 `CLIENT_ID + CLIENT_SECRET` 调 `POST /auth/login` 换 token(进程内缓存、按 `expire_in` 提前刷新),再带 `Authorization: Bearer ` + `clientid` 头调 `/system/statistics/{realtime,recent-days}`;遇 401 清 token 重登一次重试。token 字段为蛇形 `access_token`/`expire_in`。注:历史上曾免鉴权直调、更早曾用账号密码+验证码 OCR 登录(ddddocr 不稳定),现统一为 client_secret 授权——密钥即长期凭证,务必走 HTTPS、优先用 `JM_CLIENT_SECRET` 环境变量、可在后端 `sys_client` 轮换。 - **`weather_api.py`** — 和风天气(QWeather)客户端。用**用户专属 API Host**(`QWEATHER_HOST`)+ `X-QW-Api-Key` 头鉴权;响应 **gzip 压缩**(按 magic number 手动解压)、返回 `code` 为**字符串**。`get_now()` 取实时(`now.text` 中文天气 / `now.temp` / `now.icon` 图标代码),`get_today()` 取 `/3d` 的 `daily[0]` 今日温区。`QWEATHER_HOST`/`QWEATHER_KEY` 未配置时直接抛错→天气区显示 `"--"`。 -- **`renderer.py`** — `DashboardRenderer.render(data)` 返回 PIL `'L'` 模式纯 0/255 图;`render_to_file()` 落盘。布局硬编码为四区(render 内 A/B/C/D):A 大时钟+日期 / B 天气 / C 橘喵今日三列 / D 近30天趋势折线图。趋势线用 `_styled_polyline()` 按 solid/dashed/dotted 区分(设备无颜色),各指标按自身极值独立归一化(故只看趋势形状、不可横向比绝对值)。 +- **`renderer.py`** — `DashboardRenderer.render(data)` 返回 PIL `'RGB'` 模式、**仅黑/白/红三色**图;`render_to_file()` 落盘。布局硬编码为四区(render 内 A/B/C/D):A 大时钟+日期 / B 天气 / C 橘喵今日三列 / D 近30天趋势折线图。趋势线用 `_styled_polyline()` 按 solid/dashed/dotted 区分,各指标按自身极值独立归一化(故只看趋势形状、不可横向比绝对值)。 + - **红色点缀系统**(改配色从这里动,务必保持克制):模块顶部定义 `BLACK/WHITE/RED` 三元组,`RED=(255,0,0)` 是给设备红通道的明确信号。红只用在四处:① 定位水滴(`_loc_pin(fg=RED)`)② 天气图标的太阳与闪电(`_weather_icon(accent=RED)`,云/雨/雪/雾/月仍黑)③ 环比/同比**涨=红·跌=黑**——箭头与百分比数值一起着色(`_cmp_line`,「环比/同比」标签仍黑,红涨绿跌之「红涨」)④ 趋势图**主指标(流水)**折线+末点红点(按 `k=="流水"` 命中,图例同步红)。加红先问「是否承载语义」,避免红蔓延稀释注意力。 - **天气图标全矢量手绘**(无图片资源):`_weather_category()` 把和风 icon 代码归并为 8 类(sunny/clear_night/cloudy/overcast/thunder/rain/snow/fog,识别不了回退 cloudy),再由 `_weather_icon()` 用 `_sun`/`_cloud` 等基元按 1bit 描边画出;改天气展示从这里动。 - **`pusher.py`** — `push_image()` 走 Zectrix API(`POST /devices/{id}/display/image`,手写 multipart)。设备 ID 直接取 `config.DEVICE_ID`(即 `.env` 的 `ZECTRIX_DEVICE_ID` 硬件 MAC),`resolve_device_id()` 仅校验非空、未配置即报错——**已废弃获取设备列表再取第一个的逻辑**(原 `get_devices()` 已删除,不再调 `GET /devices`)。 - **`config.py`** — 顶部 `_load_dotenv()` 先加载同目录 `.env`,再定义 API、设备、画布尺寸、字体路径、推送参数;凭证 fallback 为空串,真实值来自 `.env`/环境变量。 ## 墨水屏渲染约束(改 renderer.py 必读) -设备是 **1bit、无真灰阶**——灰阶只能抖动成网点(难看),所以全程纯黑白,靠构图/字号/留白建立层级,**不要引入灰色填充或抖动**: +设备是 **三色(黑/白/红)、无真灰阶**——灰阶只能抖动成网点(难看),所以底色只用黑白 + 红作稀缺强调,靠构图/字号/留白建立层级,**不要引入灰色填充或抖动**: + +- **红只作语义强调**:方向(涨)、警示(闪电)、强调(定位/主折线);红占墨保持很小(<10% 墨迹),滥用会稀释注意力、破坏层级。整图**只允许三种纯色** `(0,0,0)/(255,255,255)/(255,0,0)`——渲染后可用 `img.getcolors()` 自查,出现第四种色(多为抗锯齿灰边)即为 bug。矢量图元(line/ellipse/polygon/arc)默认不抗锯齿故为纯色;文字靠 `fontmode="1"` 保持纯色。 - 字体固定用 **Fusion Pixel 12px 点阵字体**(`fonts/`,OFL 协议),只按整数倍尺寸 **12/24/48** 渲染(`F12/F24/F48`),并设 `d.fontmode = "1"` 关闭抗锯齿,否则像素会糊。 - 加粗用「伪粗体」:`_draw()` 按 1~bold px 水平偏移叠绘笔画(点阵字体只有单一字重)。 diff --git a/README.md b/README.md index 1f1ba31..66ddbd0 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,13 @@ # 墨水屏仪表盘推送(eink-push) -将橘喵今日经营、近30天趋势、日期与天气合成为一张 **400×300 纯黑白**图片, -推送到 Zectrix 极趣云墨水屏设备(`zectrix-s3-epaper-4.2`,1bit)。 +将橘喵今日经营、近30天趋势、日期与天气合成为一张 **400×300 三色(黑/白/红,BWR)**图片, +推送到 Zectrix 极趣云墨水屏设备。 纯 Python + Pillow + Fusion Pixel 点阵字体,**无浏览器依赖**,适合后台/定时运行。 +> **`feature/colorful` 分支**:新设备支持红色。红作为**稀缺强调色**只点缀四处——定位图标、 +> 天气的太阳/闪电、环比同比「涨」的三角与数值(红涨)、趋势图主指标(流水)折线——占墨极小,其余仍黑白。 + ## 效果 - 顶部:大号时钟 + 日期 | 天气(矢量手绘图标 + 温度 + 天气/温区) @@ -20,7 +23,7 @@ eink-push/ ├── data.py # 数据层:拉真实数据并映射,失败回退占位 "--"(不回退 mock);含天气/趋势缓存 ├── jm_api.py # jm-devops 后端统计接口客户端(免登录 GET,仅带 clientid 头) ├── weather_api.py # 和风天气(QWeather)客户端(实时天气 + 今日温区) -├── renderer.py # 渲染层:DashboardRenderer 生成 400×300 纯黑白图片 +├── renderer.py # 渲染层:DashboardRenderer 生成 400×300 三色(黑/白/红)图片 ├── pusher.py # 推送层:设备列表 / 推送图片(标准库 urllib) ├── run.sh # cron 包装脚本(本机专用,git 忽略) ├── fonts/ # Fusion Pixel 点阵字体(OFL 协议) @@ -87,10 +90,11 @@ QWEATHER_HOST=... QWEATHER_KEY=... WEATHER_LOCATION=经度,纬度 WEATHER_TTL ## 设计说明 -- 设备为 **1bit 无真灰阶**,灰阶只能抖动成网点(难看),故全程纯黑白,靠构图、字号、留白建立层级。 +- 设备为**三色(黑/白/红)无真灰阶**,灰阶只能抖动成网点(难看),故底色只用黑白 + 红作稀缺强调,靠构图、字号、留白建立层级。 +- **红只承载语义**(方向·警示·强调),占墨保持很小(实测红≈全图 0.6%、占墨迹 <10%);整图仅三种纯色 `(0,0,0)/(255,255,255)/(255,0,0)`,无灰、不抖动。 - 字体用 **Fusion Pixel 12px 点阵字体**,按整数倍尺寸(12/24/48)渲染、关闭抗锯齿 → 像素锐利; 重点数据用「伪粗体」(偏移叠绘)加粗。 -- 天气图标为**全矢量手绘**(无图片资源):按和风 icon 代码归并为 8 类(晴/晴夜/多云/阴/雷/雨/雪/雾),用基本图元按 1bit 描边画出。 -- 趋势折线各指标按自身极值独立归一化,故只表达**趋势形状**,不可横向比绝对值。 -- 推送用 `dither=false`(硬阈值),纯黑白图最锐利。 +- 天气图标为**全矢量手绘**(无图片资源):按和风 icon 代码归并为 8 类(晴/晴夜/多云/阴/雷/雨/雪/雾),用基本图元描边画出;太阳与闪电着红。 +- 趋势折线各指标按自身极值独立归一化,故只表达**趋势形状**,不可横向比绝对值;主指标(流水)着红领读、末点标红点。 +- 推送用 `dither=false`(硬阈值),三色纯色图最锐利。 ``` \ No newline at end of file diff --git a/renderer.py b/renderer.py index 47bd7e7..2c82388 100644 --- a/renderer.py +++ b/renderer.py @@ -1,20 +1,25 @@ #!/usr/bin/env python3 # -*- coding: utf-8 -*- """ -渲染层 —— 将数据绘制成 400x300 纯黑白墨水屏仪表盘。 +渲染层 —— 将数据绘制成 400x300 三色(黑/白/红)墨水屏仪表盘。 设计要点: - - 纯黑白(设备无真灰阶,灰阶只能抖动成网点,弃用);用构图 + 字号 + 留白建立层级。 + - 三色 BWR:仍无真灰阶(灰阶只能抖动成网点,弃用),底色黑白;红作为稀缺强调色点缀。 + 红只承载语义——方向(涨)、警示、寻路锚点——占墨极小,靠构图 + 字号 + 留白建立层级。 - Fusion Pixel 点阵字体,按整数倍尺寸(12/24/48)渲染,fontmode='1' 关闭抗锯齿 → 像素锐利。 - 重点数据用「伪粗体」(偏移叠绘)加粗,仍对齐像素网格。 布局:上=大时钟+日期 / 天气;中=橘喵今日经营三列;下=近30天三指标趋势折线图。 +红点缀清单:时钟冒号 / 定位图标 / 天气图标的太阳与闪电 / 两处标题方块■ / 环比同比“涨”三角 / 趋势图主指标(单量)折线。 """ import math from PIL import Image, ImageDraw, ImageFont import config -BLACK, WHITE = 0, 255 +# 三色墨水屏(BWR)调色板:黑 / 白 / 纯正红。 +# RED 用 (255,0,0) 作为设备红通道的明确信号——硬阈值量化下会干净地落到红色,不会被误判为黑。 +# 设计准则:红是稀缺强调色,仅用于「方向/状态、警示、寻路锚点」,占墨比例应很小(≈全图 <10%),其余一律黑白。 +BLACK, WHITE, RED = (0, 0, 0), (255, 255, 255), (255, 0, 0) class DashboardRenderer: @@ -70,10 +75,13 @@ class DashboardRenderer: trw, tgap = (8, 4) if direction else (0, 0) x = cx - (lw + gap + trw + tgap + nw) / 2 self.text(x, y, label, self.F12, fill=fg); x += lw + gap + # 方向语义配色(红涨):涨 → 箭头与百分比数值一起红;跌/无 → 黑(标签始终黑) + up = direction == "up" + val_color = RED if up else fg if direction: # 三角中心对齐 F12 数字墨迹竖直中心(y+8.5),否则箭头偏高 - self.tri(x + trw / 2, y + 8.5, 7, up=(direction == "up"), fill=fg); x += trw + tgap - self.text(x, y, val, self.F12, fill=fg) + self.tri(x + trw / 2, y + 8.5, 7, up=up, fill=val_color); x += trw + tgap + self.text(x, y, val, self.F12, fill=val_color) def _title_bar(self, y, title, upd): """标题栏:■ 标题(左) + 上次更新 HH:mm(右)。""" @@ -139,12 +147,14 @@ class DashboardRenderer: self.d.arc([x + S(12), cy - S(10), x + S(36), cy + S(14)], 270, 90, fill=fg, width=2) self.d.line([x + S(9), cy + S(13), x + S(29), cy + S(13)], fill=fg, width=2) - def _weather_icon(self, x, y, category, s=1.0, fg=BLACK, bg=WHITE): - """按天气类别画图标,s 为缩放系数,原生约 36x38(左上角 x,y)。""" + def _weather_icon(self, x, y, category, s=1.0, fg=BLACK, bg=WHITE, accent=None): + """按天气类别画图标,s 为缩放系数,原生约 36x38(左上角 x,y)。 + accent 用于「有能量」的元素——太阳、闪电——着红;云/雨/雪/雾/月保持 fg(黑)。""" S = lambda v: v * s + accent = accent or fg if category == "sunny": - self._sun(x + S(18), y + S(19), S(11), s, fg) + self._sun(x + S(18), y + S(19), S(11), s, accent) return if category == "clear_night": # 月牙:实心圆挖去偏上右的背景圆 @@ -165,7 +175,7 @@ class DashboardRenderer: return if category != "rain" and category != "snow" and category != "thunder": # 多云及未识别:太阳 + 云 - self._sun(x + S(10), y + S(9), S(6), s, fg) + self._sun(x + S(10), y + S(9), S(6), s, accent) # 探出的太阳着红 self._cloud(x, y + S(24), s, fg, bg) return @@ -186,7 +196,7 @@ class DashboardRenderer: bx = x + S(18) self.d.polygon([(bx - S(1), cb + S(2)), (bx + S(5), cb + S(2)), (bx + S(1), cb + S(8)), (bx + S(6), cb + S(8)), (bx - S(4), cb + S(18)), (bx, cb + S(9)), - (bx - S(4), cb + S(9))], fill=fg) + (bx - S(4), cb + S(9))], fill=accent) # 闪电着红作警示 # 线型样式:(实线段长, 间隙长);实线用 dash=0 表示连续 _STYLES = {"solid": (0, 0), "dashed": (7, 4), "dotted": (1, 2)} @@ -241,9 +251,10 @@ class DashboardRenderer: SAMP, G1, GAP = 16, 8, 10 # 样例线长 / 线与名间隙 / 图例项间隙 x = self.W - 10 for s in reversed(series): - self.rtext(x, leg_y, s["k"], self.F12) # 指标名右端对齐 x + c = RED if s.get("k") == "流水" else BLACK # 主指标(流水)图例同步着红 + self.rtext(x, leg_y, s["k"], self.F12, fill=c) # 指标名右端对齐 x x -= self._tw(s["k"], self.F12) + G1 - self._style_sample(x - SAMP, leg_y + 7, s["style"]) + self._style_sample(x - SAMP, leg_y + 7, s["style"], fill=c) x -= SAMP + GAP # ---- 绘图区 ---- px0, px1 = 14, self.W - 10 @@ -252,6 +263,7 @@ class DashboardRenderer: n = len(dates) xstep = (px1 - px0) / (n - 1) if n > 1 else 0 for s in series: + color = RED if s.get("k") == "流水" else BLACK # 主指标(流水)为主角,着红领读 data = s["data"] lo, hi = min(data), max(data) span = (hi - lo) or 1 @@ -261,7 +273,10 @@ class DashboardRenderer: x = px0 + xstep * i yv = py1 - pad - (v - lo) / span * (py1 - py0 - 2 * pad) pts.append((x, yv)) - self._styled_polyline(pts, s["style"]) + self._styled_polyline(pts, s["style"], fill=color) + if color == RED and pts: # 主指标最新值标红点:“今日 · 你在此处” + ex, ey = pts[-1] + self.d.ellipse([ex - 2.5, ey - 2.5, ex + 2.5, ey + 2.5], fill=RED) # ---- X 轴标签:仅首 / 中 / 末三个日期 ---- xl_y = py1 + 3 self.text(px0, xl_y, dates[0], self.F12) @@ -270,10 +285,10 @@ class DashboardRenderer: # ---------- 入口 ---------- def render(self, data): - """根据 data 渲染并返回 PIL.Image('L' 模式,纯 0/255)。""" - self.img = Image.new("L", (self.W, self.H), WHITE) + """根据 data 渲染并返回 PIL.Image('RGB' 模式,仅黑/白/红三色)。""" + self.img = Image.new("RGB", (self.W, self.H), WHITE) self.d = ImageDraw.Draw(self.img) - self.d.fontmode = "1" # 关闭抗锯齿,点阵锐利 + self.d.fontmode = "1" # 关闭抗锯齿,点阵锐利(RGB 上仍生效,纯色描边不发糊) dt, wt, mao = data["date"], data["weather"], data["mao"] @@ -290,14 +305,14 @@ class DashboardRenderer: # 定位图标:置于 loc 文字左侧,留间距并与文字竖直居中 PIN_W, PIN_GAP = 8, 6 loc_pin_x = 390 - self._tw(loc, self.F12) - PIN_GAP - PIN_W - self._loc_pin(loc_pin_x, 10, PIN_W) + self._loc_pin(loc_pin_x, 10, PIN_W, fg=RED) # 定位图标:红水滴,地图别针的通用语汇 s = 1.5 iw, ih = 36 * s, 38 * s # 图标原生约 36x38 text_left = min(loc_pin_x, 390 - self._tw(temp, self.F24) - 1, 390 - self._tw(cr, self.F12)) icon_y = (DIV_Y - ih) / 2 # 顶部(0)与分割线正中:上下留白相等 - self._weather_icon(text_left - 12 - iw, icon_y, self._weather_category(wt.get("icon")), s=s) + self._weather_icon(text_left - 12 - iw, icon_y, self._weather_category(wt.get("icon")), s=s, accent=RED) self.hdash(8, 392, DIV_Y)