refactor: 配置迁移至 .env,推送直连设备 MAC
- config.py 顶部新增零依赖 _load_dotenv(),凭证/参数统一从同目录 .env 读取, 真实环境变量(cron/命令行注入)优先;移除所有明文 fallback - 新增 .env.example 模板,.env 加入 .gitignore - pusher.py 删除 get_devices(),resolve_device_id() 直接取 ZECTRIX_DEVICE_ID, 不再调 GET /devices 列表 - jm_api.py 改为设备密钥授权(client_secret,免验证码),token 进程内缓存 + 401 重试 - 同步 CLAUDE.md / main.py 文档 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
20
.env.example
Normal file
20
.env.example
Normal file
@@ -0,0 +1,20 @@
|
||||
# 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
|
||||
|
||||
# ---- 橘喵 jm-devops 后端(设备密钥授权)----
|
||||
JM_BASE_API=https://admin.jumiaotandian.com/prod-api
|
||||
JM_CLIENT_ID=
|
||||
JM_CLIENT_SECRET=
|
||||
JM_GRANT_TYPE=client_secret
|
||||
JM_TENANT_ID=000000
|
||||
|
||||
# ---- 和风天气(QWeather)----
|
||||
QWEATHER_HOST=your-host.re.qweatherapi.com
|
||||
QWEATHER_KEY=
|
||||
WEATHER_LOCATION=118.788,32.014
|
||||
WEATHER_TTL=900
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -4,3 +4,4 @@ __pycache__/
|
||||
.weather_cache.json
|
||||
.trend_cache.json
|
||||
run.sh
|
||||
.env
|
||||
|
||||
21
CLAUDE.md
21
CLAUDE.md
@@ -12,18 +12,21 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
||||
|
||||
```bash
|
||||
pip install -r requirements.txt # 仅 Pillow>=10.0;urllib 用标准库
|
||||
python3 main.py # 取数据 → 渲染 → 推送(设备取列表第一个)
|
||||
cp .env.example .env # 首次:复制模板并填入真实凭证 + 设备 MAC
|
||||
python3 main.py # 取数据 → 渲染 → 推送(设备 ID 取自 .env)
|
||||
python3 main.py --render-only # 只渲染到 output/dashboard.png,不推送(本地调试渲染时用这个)
|
||||
|
||||
sh run.sh # cron 包装脚本:cd 项目目录 + vfox python 绝对路径 + 追加日志到 output/cron.log
|
||||
|
||||
# 环境变量覆盖配置(见 config.py)
|
||||
ZECTRIX_API_KEY=zt_xxx ZECTRIX_DEVICE_ID=AA:BB:CC:DD:EE:FF python3 main.py
|
||||
# 临时覆盖:真实环境变量优先级高于 .env
|
||||
ZECTRIX_DEVICE_ID=AA:BB:CC:DD:EE:FF python3 main.py
|
||||
```
|
||||
|
||||
无测试、无 lint。调试渲染效果就跑 `--render-only` 看 `output/dashboard.png`。
|
||||
|
||||
**定时部署**:`run.sh` 是 cron 入口(已在 `.gitignore`,本机专用)。cron 不继承登录环境,故脚本内 python 写死 vfox 绝对路径、并 `cd` 到项目目录后追加日志到 `output/cron.log`。设计为**每分钟**触发:业务数据每次都刷新,天气/趋势靠缓存避免高频打接口(见 `data.py`)。可覆盖的环境变量不止 ZECTRIX 两项,还有 `JM_BASE_API`/`JM_CLIENT_ID`/`QWEATHER_HOST`/`QWEATHER_KEY`/`WEATHER_LOCATION`/`WEATHER_TTL`(详见 `config.py`)。
|
||||
**配置统一走 `.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`。
|
||||
|
||||
**定时部署**:`run.sh` 是 cron 入口(已在 `.gitignore`,本机专用)。cron 不继承登录环境,故脚本内 python 写死 vfox 绝对路径、并 `cd` 到项目目录后追加日志到 `output/cron.log`(`cd` 后 `config.py` 才能就近找到 `.env`)。设计为**每分钟**触发:业务数据每次都刷新,天气/趋势靠缓存避免高频打接口(见 `data.py`)。
|
||||
|
||||
## 架构(数据 → 渲染 → 推送 三层)
|
||||
|
||||
@@ -31,12 +34,12 @@ ZECTRIX_API_KEY=zt_xxx ZECTRIX_DEVICE_ID=AA:BB:CC:DD:EE:FF python3 main.py
|
||||
|
||||
- **`data.py`** — 唯一数据入口 `get_dashboard_data()`,返回固定结构的 dict。日期/时间用真实系统时间;橘喵今日经营+趋势由 `_mao()` 经 `jm_api`、天气由 `_weather()` 经 `weather_api`(和风,定位南京·秦淮)拉真实数据,**任何接口异常都回退占位 `"--"`,不回退 mock**。改数据保持返回结构不变即可(renderer 依赖其 key,尤其 `mao.trend.series` 每条须带 `style`、`weather.icon` 为和风图标代码;环比/同比 `(direction, text)`,direction 为 `None` 时不画涨跌三角)。
|
||||
- **两套文件缓存**(为 cron 每分钟独立进程而设,必须落盘):天气缓存 `.weather_cache.json`(TTL=`WEATHER_TTL`,默认 15min;未过期不发请求);趋势缓存 `.trend_cache.json`(按日期 key,当天命中即不再调 `recent-days`,每天只拉一次)。两者**拉取失败均沿用旧缓存**避免整片 `"--"`,仍无缓存才回退占位。两个缓存文件已在 `.gitignore`。
|
||||
- **`jm_api.py`** — jm-devops 后端统计接口客户端,**免登录 GET**(`/system/statistics/realtime`、`/recent-days?days=`),仅带 `clientid` 头(`config.CLIENT_ID`)、无 token。注:早期曾实现登录+RSA/AES 加密+验证码 OCR,后改为后端直接放开这两个接口,故已全部删除——若后端再次收紧鉴权,参考 jm-devops 的 `src/utils/{crypto,jsencrypt,request}.ts` 复刻。
|
||||
- **`jm_api.py`** — jm-devops 后端统计接口客户端,**设备密钥授权**(`grant_type=client_secret`,免验证码)。先用 `CLIENT_ID + CLIENT_SECRET` 调 `POST /auth/login` 换 token(进程内缓存、按 `expire_in` 提前刷新),再带 `Authorization: Bearer <token>` + `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 区分(设备无颜色),各指标按自身极值独立归一化(故只看趋势形状、不可横向比绝对值)。
|
||||
- **天气图标全矢量手绘**(无图片资源):`_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)。`resolve_device_id()` 在 `config.DEVICE_ID` 为空时自动取设备列表第一个。
|
||||
- **`config.py`** — API、设备、画布尺寸、字体路径、推送参数;敏感项可被环境变量覆盖。
|
||||
- **`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 必读)
|
||||
|
||||
@@ -49,5 +52,5 @@ ZECTRIX_API_KEY=zt_xxx ZECTRIX_DEVICE_ID=AA:BB:CC:DD:EE:FF python3 main.py
|
||||
|
||||
## 注意
|
||||
|
||||
- `config.py` 内置了一个默认 `API_KEY`(硬编码 fallback)。修改/分享代码时留意,优先用 `ZECTRIX_API_KEY` 环境变量。
|
||||
- `.gitignore` 已忽略:`output/`、`__pycache__/`、`*.pyc`、两个缓存文件 `.weather_cache.json`/`.trend_cache.json`、本机专用的 `run.sh`。改缓存逻辑后想强制重拉,删掉对应缓存文件即可。
|
||||
- 所有凭证均在 `.env`(不提交);`config.py` 不再内置明文 fallback。分享代码时给出 `.env.example` 即可,真实 `.env` 切勿入库。
|
||||
- `.gitignore` 已忽略:`output/`、`__pycache__/`、`*.pyc`、两个缓存文件 `.weather_cache.json`/`.trend_cache.json`、本机专用的 `run.sh`、含凭证的 `.env`(模板 `.env.example` 入库)。改缓存逻辑后想强制重拉,删掉对应缓存文件即可。
|
||||
|
||||
43
config.py
43
config.py
@@ -1,15 +1,38 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""全局配置。敏感项支持用环境变量覆盖。"""
|
||||
"""全局配置。敏感项从同目录 .env 读取,真实环境变量(如 cron 注入)优先。"""
|
||||
import os
|
||||
|
||||
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
|
||||
|
||||
def _load_dotenv(path):
|
||||
"""极简零依赖 .env 加载器:把 KEY=VALUE 写入 os.environ。
|
||||
已存在的真实环境变量优先(不覆盖),故 cron/命令行注入仍可压过 .env。
|
||||
支持 # 注释、空行、行内 KEY=VALUE,值两侧成对的单/双引号会被去掉。"""
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8") as fp:
|
||||
for raw in fp:
|
||||
line = raw.strip()
|
||||
if not line or line.startswith("#") or "=" not in line:
|
||||
continue
|
||||
key, _, val = line.partition("=")
|
||||
key, val = key.strip(), val.strip()
|
||||
if len(val) >= 2 and val[0] == val[-1] and val[0] in "\"'":
|
||||
val = val[1:-1]
|
||||
if key:
|
||||
os.environ.setdefault(key, val)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
|
||||
|
||||
_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", "zt_3266e9b843768257893ff95e4bf4d740")
|
||||
API_KEY = os.environ.get("ZECTRIX_API_KEY", "")
|
||||
|
||||
# 目标设备 ID(MAC)。留空则自动取设备列表的第一个。
|
||||
# 目标设备 ID(硬件 MAC)。必填——直接据此调推送接口,不再获取设备列表。
|
||||
DEVICE_ID = os.environ.get("ZECTRIX_DEVICE_ID", "")
|
||||
|
||||
# 推送参数
|
||||
@@ -24,15 +47,21 @@ FONT_PATH = os.path.join(BASE_DIR, "fonts", "fusion-pixel-12px-proportional-zh_h
|
||||
OUTPUT_DIR = os.path.join(BASE_DIR, "output")
|
||||
OUTPUT_PATH = os.path.join(OUTPUT_DIR, "dashboard.png")
|
||||
|
||||
# ---- 橘喵 jm-devops 后端(今日经营 / 近30天趋势 数据源,免鉴权 GET 调用)----
|
||||
# ---- 橘喵 jm-devops 后端(今日经营 / 近30天趋势 数据源)----
|
||||
# 鉴权方式:设备密钥授权(grant_type=client_secret,免验证码)。
|
||||
# 用 CLIENT_ID + CLIENT_SECRET 调 /auth/login 换 token,再带 Bearer 调统计接口(详见 jm_api.py)。
|
||||
# CLIENT_SECRET 等价于长期凭证,务必走 HTTPS、优先用环境变量覆盖、可在后端 sys_client 轮换。
|
||||
BASE_API = os.environ.get("JM_BASE_API", "https://admin.jumiaotandian.com/prod-api")
|
||||
CLIENT_ID = os.environ.get("JM_CLIENT_ID", "e5cd7e4891bf95d1d19206ce24a7b32e")
|
||||
CLIENT_ID = os.environ.get("JM_CLIENT_ID", "")
|
||||
CLIENT_SECRET = os.environ.get("JM_CLIENT_SECRET", "")
|
||||
GRANT_TYPE = os.environ.get("JM_GRANT_TYPE", "client_secret")
|
||||
TENANT_ID = os.environ.get("JM_TENANT_ID", "000000")
|
||||
|
||||
# ---- 和风天气(QWeather)数据源 ----
|
||||
# 专属 API Host 与 Key 在 https://console.qweather.com/setting 查看,须用环境变量配置;
|
||||
# 未配置时天气区显示占位 "--",不影响其余渲染与推送。
|
||||
QWEATHER_HOST = os.environ.get("QWEATHER_HOST", "n57fc3yv6r.re.qweatherapi.com") # 用户专属 API Host
|
||||
QWEATHER_KEY = os.environ.get("QWEATHER_KEY", "b27f545219a34d42adb54bbf2c5b81dd")
|
||||
QWEATHER_HOST = os.environ.get("QWEATHER_HOST", "") # 用户专属 API Host
|
||||
QWEATHER_KEY = os.environ.get("QWEATHER_KEY", "")
|
||||
# 定位固定为南京·秦淮区。和风经纬度格式为「经度,纬度」
|
||||
WEATHER_LOCATION = os.environ.get("WEATHER_LOCATION", "118.788,32.014")
|
||||
WEATHER_LOC_NAME = "南京·秦淮"
|
||||
|
||||
67
jm_api.py
67
jm_api.py
@@ -1,28 +1,87 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
橘喵 jm-devops 后端客户端 —— 直接调用免鉴权的统计接口(无需登录/token)。
|
||||
橘喵 jm-devops 后端客户端。
|
||||
|
||||
接口(参考 jm-devops 首页 src/api/dashboard.ts):
|
||||
鉴权:设备密钥授权(grant_type=client_secret,免验证码)。
|
||||
先用 CLIENT_ID + CLIENT_SECRET 调 POST /auth/login 换 token(缓存于进程内,按有效期提前刷新),
|
||||
再带 Authorization: Bearer <token> + clientid 头调统计接口;遇 401 清 token 重登一次重试。
|
||||
|
||||
接口:
|
||||
- GET /system/statistics/realtime 今日经营实时数据
|
||||
- GET /system/statistics/recent-days?days 近 N 天每日数据(按日期降序、不含今天)
|
||||
"""
|
||||
import json
|
||||
import time
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
|
||||
import config
|
||||
|
||||
# 进程内 token 缓存(cron 每次独立进程,不落盘;单次运行内复用)
|
||||
_token = None
|
||||
_token_expire_at = 0.0
|
||||
# token 到期前提前刷新的余量(秒)
|
||||
_TOKEN_REFRESH_SKEW = 60
|
||||
|
||||
def _get(path):
|
||||
url = config.BASE_API + path
|
||||
|
||||
def _login():
|
||||
"""用 client_secret 授权换取 token,写入模块级缓存。"""
|
||||
global _token, _token_expire_at
|
||||
# 设备专用明文登录端点(不走 @ApiEncrypt,仅放行 client_secret 授权)
|
||||
url = config.BASE_API + "/auth/deviceLogin"
|
||||
payload = json.dumps({
|
||||
"clientId": config.CLIENT_ID,
|
||||
"grantType": config.GRANT_TYPE,
|
||||
"clientSecret": config.CLIENT_SECRET,
|
||||
"tenantId": config.TENANT_ID,
|
||||
}).encode("utf-8")
|
||||
headers = {"Content-Type": "application/json;charset=utf-8", "clientid": config.CLIENT_ID}
|
||||
req = urllib.request.Request(url, data=payload, headers=headers, method="POST")
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=30) as resp:
|
||||
data = json.loads(resp.read().decode("utf-8"))
|
||||
except urllib.error.HTTPError as e:
|
||||
raise RuntimeError(f"登录 HTTP {e.code}: {e.read().decode('utf-8', 'ignore')[:200]}") from e
|
||||
if data.get("code") != 200:
|
||||
raise RuntimeError(f"/auth/login 返回 code={data.get('code')}: {data.get('msg')}")
|
||||
body = data["data"]
|
||||
_token = body["access_token"]
|
||||
# expire_in 为秒;提前 skew 刷新,缺省给个保守值
|
||||
expire_in = body.get("expire_in") or 1800
|
||||
_token_expire_at = time.time() + max(0, expire_in - _TOKEN_REFRESH_SKEW)
|
||||
return _token
|
||||
|
||||
|
||||
def _ensure_token():
|
||||
if _token and time.time() < _token_expire_at:
|
||||
return _token
|
||||
return _login()
|
||||
|
||||
|
||||
def _get(path, _retry=True):
|
||||
global _token, _token_expire_at
|
||||
token = _ensure_token()
|
||||
url = config.BASE_API + path
|
||||
headers = {
|
||||
"Content-Type": "application/json;charset=utf-8",
|
||||
"clientid": config.CLIENT_ID,
|
||||
"Authorization": f"Bearer {token}",
|
||||
}
|
||||
req = urllib.request.Request(url, headers=headers, method="GET")
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=30) as resp:
|
||||
data = json.loads(resp.read().decode("utf-8"))
|
||||
except urllib.error.HTTPError as e:
|
||||
# token 失效(401)则清缓存重登一次重试
|
||||
if e.code == 401 and _retry:
|
||||
_token, _token_expire_at = None, 0.0
|
||||
return _get(path, _retry=False)
|
||||
raise RuntimeError(f"HTTP {e.code}: {e.read().decode('utf-8', 'ignore')[:200]}") from e
|
||||
# 业务层也可能用 401 表示未登录
|
||||
if data.get("code") == 401 and _retry:
|
||||
_token, _token_expire_at = None, 0.0
|
||||
return _get(path, _retry=False)
|
||||
if data.get("code") != 200:
|
||||
raise RuntimeError(f"{path} 返回 code={data.get('code')}: {data.get('msg')}")
|
||||
return data["data"]
|
||||
|
||||
4
main.py
4
main.py
@@ -4,10 +4,10 @@
|
||||
入口 —— 取数据 → 渲染 → 推送到设备。
|
||||
|
||||
用法:
|
||||
python3 main.py # 渲染并推送(默认取设备列表第一个)
|
||||
python3 main.py # 渲染并推送(设备 ID 取自 .env 的 ZECTRIX_DEVICE_ID)
|
||||
python3 main.py --render-only # 只渲染生成图片,不推送(output/dashboard.png)
|
||||
|
||||
可用环境变量覆盖配置:ZECTRIX_API_KEY、ZECTRIX_DEVICE_ID
|
||||
配置统一从同目录 .env 读取(见 .env.example),真实环境变量优先。
|
||||
"""
|
||||
import argparse
|
||||
|
||||
|
||||
17
pusher.py
17
pusher.py
@@ -25,22 +25,11 @@ def _request(method, path, headers=None, body=None):
|
||||
raise RuntimeError(f"HTTP {e.code}: {e.read().decode('utf-8', 'ignore')}") from e
|
||||
|
||||
|
||||
def get_devices():
|
||||
"""获取设备列表。"""
|
||||
data = _request("GET", "/devices")
|
||||
if data.get("code") != 0:
|
||||
raise RuntimeError(f"获取设备失败: {data}")
|
||||
return data.get("data", [])
|
||||
|
||||
|
||||
def resolve_device_id():
|
||||
"""返回目标设备 ID:优先用 config.DEVICE_ID,否则取列表第一个。"""
|
||||
if config.DEVICE_ID:
|
||||
"""返回目标设备 ID:直接取 config.DEVICE_ID(硬件 MAC),未配置则报错。"""
|
||||
if not config.DEVICE_ID:
|
||||
raise RuntimeError("未配置设备 ID:请在 .env 设置 ZECTRIX_DEVICE_ID=<硬件 MAC>")
|
||||
return config.DEVICE_ID
|
||||
devices = get_devices()
|
||||
if not devices:
|
||||
raise RuntimeError("账号下没有任何设备")
|
||||
return devices[0]["deviceId"]
|
||||
|
||||
|
||||
def _multipart(fields, files):
|
||||
|
||||
Reference in New Issue
Block a user