From 2121b1eff4d2e3e19c5154580e410d9908b14160 Mon Sep 17 00:00:00 2001 From: YANG JIANKUAN Date: Tue, 7 Jul 2026 10:19:10 +0800 Subject: [PATCH] =?UTF-8?q?refactor:=20=E6=8E=A8=E9=80=81=E6=94=B9?= =?UTF-8?q?=E4=B8=BA=E5=B1=80=E5=9F=9F=E7=BD=91=E7=9B=B4=E8=BF=9E=E8=AE=BE?= =?UTF-8?q?=E5=A4=87=EF=BC=8C=E6=8E=A8=E9=80=81=E5=89=8D=E6=B8=85=E7=A9=BA?= =?UTF-8?q?=E7=94=BB=E5=BB=8A=E6=97=A7=E5=9B=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 原走 Zectrix 公网 API + 设备 MAC,现改为直连局域网墨水屏设备(喵喵固件) 的 HTTP 接口:POST /upload 推送 PNG 触发刷屏。设备画廊按追加保存,故推送 前先 GET /images 清空旧图,避免图片越推越多、且避免 slideshow 模式轮播 到过期图。 Co-Authored-By: Claude Opus 4.6 --- .env.example | 7 +++--- CLAUDE.md | 16 ++++++------- config.py | 13 +++-------- main.py | 6 ++--- pusher.py | 63 ++++++++++++++++++---------------------------------- 5 files changed, 39 insertions(+), 66 deletions(-) diff --git a/.env.example b/.env.example index ceb8b10..40e61f1 100644 --- a/.env.example +++ b/.env.example @@ -1,10 +1,9 @@ # eink-push 配置模板:复制为 .env 并填入真实值(cp .env.example .env) # 真实环境变量(cron/命令行注入)优先级高于 .env 文件。 -# ---- Zectrix 极趣云平台 ---- -ZECTRIX_API_KEY=zt_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx -# 目标设备硬件 MAC,必填——直接据此推送,不再获取设备列表 -ZECTRIX_DEVICE_ID=AA:BB:CC:DD:EE:FF +# ---- 局域网墨水屏设备(喵喵固件)---- +# mDNS host,同局域网内可直接在浏览器打开 http:/// 调试 +EPD_HOST=epd400a44.local # ---- 橘喵 jm-devops 后端(设备密钥授权)---- JM_BASE_API=https://admin.jumiaotandian.com/prod-api diff --git a/CLAUDE.md b/CLAUDE.md index b701fe9..1412b1e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,7 +6,7 @@ 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 开发约束。 -作用:把日期天气、Claude Code 用量、橘喵今日经营合成为一张 **400×300 三色(黑/白/红,BWR)** PNG,推送到 Zectrix 极趣云墨水屏设备。纯 Python + Pillow,**无浏览器依赖**,适合 cron 定时运行。 +作用:把日期天气、Claude Code 用量、橘喵今日经营合成为一张 **400×300 三色(黑/白/红,BWR)** PNG,推送到局域网墨水屏设备(喵喵固件,开源:https://gitee.com/gxp666111/miaomiao)。纯 Python + Pillow,**无浏览器依赖**,适合 cron 定时运行。 > **分支 `feature/colorful`**:新设备在黑白外支持红色,本分支把渲染升级为黑/白/红三色。当前版式为**上中下三段**:A 大时钟+日期 / 天气(定位南京·江宁);B **Claude Usage** 主角(5h/7d 用量进度条 + 时间维度对比 pace,用量吃紧转红预警);C 橘喵今日实时三列。红作**语义强调**(用量预警、涨、定位、天气太阳/闪电),仍**无真灰阶、不抖动**,全图只三种纯色。近30天趋势图当前**隐藏**(`renderer._trend_chart` 及依赖保留、未调用,随时可挂回)。 @@ -14,28 +14,28 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ```bash pip install -r requirements.txt # 仅 Pillow>=10.0;urllib 用标准库 -cp .env.example .env # 首次:复制模板并填入真实凭证 + 设备 MAC -python3 main.py # 取数据 → 渲染 → 推送(设备 ID 取自 .env) +cp .env.example .env # 首次:复制模板并填入真实凭证 + 设备 host +python3 main.py # 取数据 → 渲染 → 推送(设备 host 取自 .env 的 EPD_HOST) python3 main.py --render-only # 只渲染到 output/dashboard.png,不推送(本地调试渲染时用这个) sh run.sh # cron 包装脚本:cd 项目目录 + vfox python 绝对路径 + 追加日志到 output/cron.log # 临时覆盖:真实环境变量优先级高于 .env -ZECTRIX_DEVICE_ID=AA:BB:CC:DD:EE:FF python3 main.py +EPD_HOST=epd400a44.local python3 main.py ``` 无测试、无 lint。调试渲染效果就跑 `--render-only` 看 `output/dashboard.png`。 -**配置统一走 `.env`**:所有凭证/参数放同目录 `.env`(已在 `.gitignore`,本机专用;模板见已提交的 `.env.example`)。`config.py` 顶部用零依赖加载器 `_load_dotenv()` 把 `.env` 写入 `os.environ`,再由各 `os.environ.get(...)` 读取;采用 `setdefault`,故**真实环境变量(cron/命令行注入)优先级高于 `.env`**。`config.py` 内不再保留任何明文凭证(fallback 为空串)。可配置项:`ZECTRIX_API_KEY`/`ZECTRIX_DEVICE_ID`、`JM_BASE_API`/`JM_CLIENT_ID`/`JM_CLIENT_SECRET`/`JM_GRANT_TYPE`/`JM_TENANT_ID`、`QWEATHER_HOST`/`QWEATHER_KEY`/`WEATHER_LOCATION`(默认南京·江宁)/`WEATHER_TTL`、`USAGE_LOCAL_PATH`/`USAGE_WARN`。 +**配置统一走 `.env`**:所有凭证/参数放同目录 `.env`(已在 `.gitignore`,本机专用;模板见已提交的 `.env.example`)。`config.py` 顶部用零依赖加载器 `_load_dotenv()` 把 `.env` 写入 `os.environ`,再由各 `os.environ.get(...)` 读取;采用 `setdefault`,故**真实环境变量(cron/命令行注入)优先级高于 `.env`**。`config.py` 内不再保留任何明文凭证(fallback 为空串)。可配置项:`EPD_HOST`(局域网设备 mDNS host,默认 `epd400a44.local`)、`JM_BASE_API`/`JM_CLIENT_ID`/`JM_CLIENT_SECRET`/`JM_GRANT_TYPE`/`JM_TENANT_ID`、`QWEATHER_HOST`/`QWEATHER_KEY`/`WEATHER_LOCATION`(默认南京·江宁)/`WEATHER_TTL`、`USAGE_LOCAL_PATH`/`USAGE_WARN`。 -**定时部署**:`run.sh` 是 cron 入口(已在 `.gitignore`,本机专用)。cron 不继承登录环境,故脚本内 python 写死 vfox 绝对路径、并 `cd` 到项目目录后追加日志到 `output/cron.log`(`cd` 后 `config.py` 才能就近找到 `.env`)。设计为**每分钟**触发:业务数据每次都刷新,天气靠缓存、用量读本地文件,避免高频打接口(见 `data.py`)。 +**定时部署**:由 macOS **launchd LaunchAgent** 触发 `run.sh`(不再用 cron——cron 在现代 macOS 上易被 TCC/权限静默拦截,launchd 是系统原生调度器)。LaunchAgent plist 装在 `~/Library/LaunchAgents/com.jm.eink-push.plist`(机器专用,未入库,等价于 `run.sh`/`.env` 的本机产物),`StartInterval=300` + `RunAtLoad=true`:登录/重启后立即跑一次,此后每 5 分钟一次。用 LaunchAgent(非 LaunchDaemon)是因为脚本依赖当前用户目录下的文件(`~/.claude/usage-snapshot.json`、`.env`),必须在用户会话下运行——机器需已登录(或开了自动登录)才能保证开机即触发。`run.sh` 内部逻辑不变:cron 不继承登录环境,故脚本内 python 写死 vfox 绝对路径、并 `cd` 到项目目录后追加日志到 `output/cron.log`(`cd` 后 `config.py` 才能就近找到 `.env`);launchd 自身的 stdout/stderr 另落 `output/launchd.log`(正常应为空,非空说明进程本身起不来,而非业务报错)。常用排查:`launchctl list | grep com.jm.eink-push` 看是否常驻及最近退出码,`launchctl kickstart -k gui/$(id -u)/com.jm.eink-push` 手动立即触发一次;改 plist 后需 `launchctl bootout` + `bootstrap` 重新加载才生效(热改文件不会自动生效)。每 5 分钟触发:业务数据每次都刷新,天气靠缓存、用量读本地文件,避免高频打接口(见 `data.py`)。 ## 架构(数据 → 渲染 → 推送 三层) `main.py` 串联三层,各层职责单一、低耦合: - **`data.py`** — 唯一数据入口 `get_dashboard_data()`,返回固定结构的 dict:`date`/`weather`/`mao`/`usage`。日期时间用真实系统时间;橘喵今日经营由 `_mao()` 经 `jm_api`、天气由 `_weather()` 经 `weather_api`(和风,定位南京·江宁)、Claude 用量由 `_usage()` 经 `usage_local`,**任何异常都回退占位 `"--"`,不回退 mock**。改数据保持返回结构不变即可(renderer 依赖其 key:`weather.icon` 为和风图标代码;`mao.cols[].hb/tb` 为环比/同比 `(direction, text)`,direction 为 `None` 时不画三角;`usage.bars[]` 为 `{k, pct(或 None), time_pct?}`、`usage.warn` 为转红阈值)。注:`_mao` 仍会拉 `mao.trend`,但趋势图当前隐藏、renderer 不使用它。 - - **缓存**(为 cron 每分钟独立进程而设,必须落盘):天气 `.weather_cache.json`(TTL=`WEATHER_TTL`,默认 15min)、趋势 `.trend_cache.json`(按日期 key,每天只拉一次);两者拉取失败沿用旧缓存。**用量不自建缓存**——直接读 statusline 生产的本地快照 `~/.claude/usage-snapshot.json`(见 `usage_local`),失败即 `"--"`。两个缓存文件已在 `.gitignore`。 + - **缓存**(为 launchd 每 5 分钟独立进程而设,必须落盘):天气 `.weather_cache.json`(TTL=`WEATHER_TTL`,默认 15min)、趋势 `.trend_cache.json`(按日期 key,每天只拉一次);两者拉取失败沿用旧缓存。**用量不自建缓存**——直接读 statusline 生产的本地快照 `~/.claude/usage-snapshot.json`(见 `usage_local`),失败即 `"--"`。两个缓存文件已在 `.gitignore`。 - **用量的 pace(时间维度对比)**:`_usage()` 每次按当前时间**现算** `time_pct=(窗口已流逝/窗口长)`、`pace=pct−time_pct`(>0 超前↑ / <0 节余↓);故即使 `utilization` 来自可能滞后的本地缓存,pace 仍随时钟准确推进。5h 窗口 18000s、7d 窗口 604800s。 - **`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` 未配置时直接抛错→天气区显示 `"--"`。 @@ -47,7 +47,7 @@ ZECTRIX_DEVICE_ID=AA:BB:CC:DD:EE:FF python3 main.py - **红色点缀系统**(改配色务必克制):模块顶部 `BLACK/WHITE/RED`,`RED=(255,0,0)` 是给设备红通道的明确信号。红只用在:① 定位水滴(`_loc_pin(fg=RED)`)② 天气太阳/闪电(`_weather_icon(accent=RED)`,云/雨/雪/雾/月仍黑)③ 用量进度条与百分比(`pct≥warn`) + pace 超前(↑) ④ 环比/同比「涨」的三角与数值(`_cmp_line`,跌/标签仍黑)。**配色铁律**:红与黑同属深色、红叠黑对比极低,故红只压白底或作大块实心(白字压红),**绝不红字压黑底**。加红先问「是否承载语义」。 - **`_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 描边画出;改天气展示从这里动。 -- **`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`)。 +- **`pusher.py`** — 直连局域网墨水屏设备(喵喵固件,开源:https://gitee.com/gxp666111/miaomiao,`web/` 即设备自带管理页,可浏览器打开 `http:///` 调试同一套接口)。`push_image()` 先调 `GET /images` 拿画廊已存图片名单,逐个 `POST /delete_image?name=`清空(单张删除失败不阻塞后续),再 `POST /upload`(`Content-Type: image/png`,body 为原始 PNG 字节,非 multipart)触发设备转换+刷屏。**清空画廊是刻意设计**:设备把每次 `/upload` 都追加存一份到画廊(`GET /images` 可见),不清理会越推越多、且设备当前 `current_mode` 为 slideshow 会轮播到旧图;先清后传使画廊恒为 1 张,等价于始终静态展示最新一张。注:历史上曾走 Zectrix 公网 API(`POST /devices/{id}/display/image` + 设备 MAC + `ZECTRIX_API_KEY`),现统一为局域网直连。 - **`config.py`** — 顶部 `_load_dotenv()` 先加载同目录 `.env`,再定义 API、设备、画布尺寸、字体路径、推送参数;凭证 fallback 为空串,真实值来自 `.env`/环境变量。 ## 墨水屏渲染约束(改 renderer.py 必读) diff --git a/config.py b/config.py index 93b4441..61bac31 100644 --- a/config.py +++ b/config.py @@ -28,16 +28,9 @@ def _load_dotenv(path): _load_dotenv(os.path.join(BASE_DIR, ".env")) -# ---- Zectrix 极趣云平台 API ---- -API_BASE = "https://cloud.zectrix.com/open/v1" -API_KEY = os.environ.get("ZECTRIX_API_KEY", "") - -# 目标设备 ID(硬件 MAC)。必填——直接据此调推送接口,不再获取设备列表。 -DEVICE_ID = os.environ.get("ZECTRIX_DEVICE_ID", "") - -# 推送参数 -PAGE_ID = "1" # 持久化页面编号 1-5 -DITHER = False # 纯黑白点阵图用硬阈值最锐利;设备无真灰阶,勿开抖动 +# ---- 局域网墨水屏设备(喵喵固件,开源:https://gitee.com/gxp666111/miaomiao)---- +# 不再走 Zectrix 公网 API + 设备 MAC,改为直连局域网设备 mDNS host 的 HTTP 接口。 +EPD_HOST = os.environ.get("EPD_HOST", "epd400a44.local") # ---- 画布 ---- WIDTH, HEIGHT = 400, 300 diff --git a/main.py b/main.py index 0f127ea..f162bf5 100644 --- a/main.py +++ b/main.py @@ -4,7 +4,7 @@ 入口 —— 取数据 → 渲染 → 推送到设备。 用法: - python3 main.py # 渲染并推送(设备 ID 取自 .env 的 ZECTRIX_DEVICE_ID) + python3 main.py # 渲染并推送(设备 host 取自 .env 的 EPD_HOST) python3 main.py --render-only # 只渲染生成图片,不推送(output/dashboard.png) 配置统一从同目录 .env 读取(见 .env.example),真实环境变量优先。 @@ -29,8 +29,8 @@ def main(): return from pusher import push_image - device_id, resp = push_image(path) - print(f"[推送] 设备 {device_id} 成功:{resp.get('data')}") + host, resp = push_image(path) + print(f"[推送] 设备 {host} 成功:{resp}") if __name__ == "__main__": diff --git a/pusher.py b/pusher.py index 267d273..3c5b5f7 100644 --- a/pusher.py +++ b/pusher.py @@ -1,11 +1,12 @@ #!/usr/bin/env python3 # -*- coding: utf-8 -*- """ -推送层 —— 调用 Zectrix 极趣云平台 API(仅用标准库 urllib,无第三方依赖)。 -文档:显示推送 / 设备管理。 +推送层 —— 直连局域网墨水屏设备(喵喵固件,仅用标准库 urllib,无第三方依赖)。 +固件开源:https://gitee.com/gxp666111/miaomiao(web/ 为设备自带管理页,可在浏览器打开 +http:/// 调试同一套接口)。 """ import json -import uuid +import urllib.parse import urllib.request import urllib.error @@ -13,53 +14,33 @@ import config def _request(method, path, headers=None, body=None): - url = f"{config.API_BASE}{path}" - h = {"X-API-Key": config.API_KEY} - if headers: - h.update(headers) - req = urllib.request.Request(url, data=body, headers=h, method=method) + url = f"http://{config.EPD_HOST}{path}" + req = urllib.request.Request(url, data=body, headers=headers or {}, method=method) try: with urllib.request.urlopen(req, timeout=30) as resp: - return json.loads(resp.read().decode("utf-8")) + return resp.read().decode("utf-8", "ignore") except urllib.error.HTTPError as e: raise RuntimeError(f"HTTP {e.code}: {e.read().decode('utf-8', 'ignore')}") from e -def resolve_device_id(): - """返回目标设备 ID:直接取 config.DEVICE_ID(硬件 MAC),未配置则报错。""" - if not config.DEVICE_ID: - raise RuntimeError("未配置设备 ID:请在 .env 设置 ZECTRIX_DEVICE_ID=<硬件 MAC>") - return config.DEVICE_ID +def _list_gallery_names(): + text = _request("GET", "/images") + return [item["name"] for item in json.loads(text).get("items", [])] -def _multipart(fields, files): - """构造 multipart/form-data 请求体。fields: dict[str,str]; files: list[(name, filename, bytes, mime)]""" - boundary = uuid.uuid4().hex - crlf = b"\r\n" - buf = bytearray() - for k, v in fields.items(): - buf += b"--" + boundary.encode() + crlf - buf += f'Content-Disposition: form-data; name="{k}"'.encode() + crlf + crlf - buf += str(v).encode() + crlf - for name, filename, content, mime in files: - buf += b"--" + boundary.encode() + crlf - buf += f'Content-Disposition: form-data; name="{name}"; filename="{filename}"'.encode() + crlf - buf += f"Content-Type: {mime}".encode() + crlf + crlf - buf += content + crlf - buf += b"--" + boundary.encode() + b"--" + crlf - return bytes(buf), f"multipart/form-data; boundary={boundary}" +def _clear_gallery(): + """设备画廊按追加保存,用完即删,使其始终只留最新一张。单张删除失败不影响后续上传。""" + for name in _list_gallery_names(): + try: + _request("POST", f"/delete_image?name={urllib.parse.quote(name)}") + except Exception as e: + print(f"[推送] 清理画廊旧图 {name} 失败:{e}") -def push_image(image_path, device_id=None, page_id=config.PAGE_ID, dither=config.DITHER): - """推送图片到设备显示。""" - device_id = device_id or resolve_device_id() +def push_image(image_path): + """推送图片到局域网墨水屏设备:先清空画廊旧图,再上传新图触发显示刷新。""" + _clear_gallery() with open(image_path, "rb") as fp: content = fp.read() - fields = {"dither": "true" if dither else "false", "pageId": str(page_id)} - files = [("images", "dashboard.png", content, "image/png")] - body, content_type = _multipart(fields, files) - data = _request("POST", f"/devices/{device_id}/display/image", - headers={"Content-Type": content_type}, body=body) - if data.get("code") != 0: - raise RuntimeError(f"推送失败: {data}") - return device_id, data + resp = _request("POST", "/upload", headers={"Content-Type": "image/png"}, body=content) + return config.EPD_HOST, resp