init: init proj
This commit is contained in:
6
.gitignore
vendored
Normal file
6
.gitignore
vendored
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
output/
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
.weather_cache.json
|
||||||
|
.trend_cache.json
|
||||||
|
run.sh
|
||||||
47
CLAUDE.md
Normal file
47
CLAUDE.md
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
# CLAUDE.md
|
||||||
|
|
||||||
|
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||||
|
|
||||||
|
## 项目定位
|
||||||
|
|
||||||
|
`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 定时运行。
|
||||||
|
|
||||||
|
## 命令
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install -r requirements.txt # 仅 Pillow>=10.0;urllib 用标准库
|
||||||
|
python3 main.py # 取数据 → 渲染 → 推送(设备取列表第一个)
|
||||||
|
python3 main.py --render-only # 只渲染到 output/dashboard.png,不推送(本地调试渲染时用这个)
|
||||||
|
|
||||||
|
# 环境变量覆盖配置(见 config.py)
|
||||||
|
ZECTRIX_API_KEY=zt_xxx ZECTRIX_DEVICE_ID=AA:BB:CC:DD:EE:FF python3 main.py
|
||||||
|
```
|
||||||
|
|
||||||
|
无测试、无 lint。调试渲染效果就跑 `--render-only` 看 `output/dashboard.png`。
|
||||||
|
|
||||||
|
## 架构(数据 → 渲染 → 推送 三层)
|
||||||
|
|
||||||
|
`main.py` 串联三层,各层职责单一、低耦合:
|
||||||
|
|
||||||
|
- **`data.py`** — 唯一数据入口 `get_dashboard_data()`,返回固定结构的 dict。日期/时间用真实系统时间;橘喵今日经营+趋势由 `_mao()` 经 `jm_api`、天气由 `_weather()` 经 `weather_api`(和风,定位南京·秦淮)拉真实数据,**任何接口异常都回退占位 `"--"`,不回退 mock**。改数据保持返回结构不变即可(renderer 依赖其 key,尤其 `mao.trend.series` 每条须带 `style`;环比/同比 `(direction, text)`,direction 为 `None` 时不画涨跌三角)。
|
||||||
|
- **`jm_api.py`** — jm-devops 后端统计接口客户端,**免鉴权 GET**(`/system/statistics/realtime`、`/recent-days?days=`)。注:早期曾实现登录+RSA/AES 加密+验证码 OCR,后改为后端直接放开这两个接口,故已全部删除——若后端再次收紧鉴权,参考 jm-devops 的 `src/utils/{crypto,jsencrypt,request}.ts` 复刻。
|
||||||
|
- **`weather_api.py`** — 和风天气(QWeather)客户端。用**用户专属 API Host**(`QWEATHER_HOST`)+ `X-QW-Api-Key` 头鉴权;响应 **gzip 压缩**(按 magic number 手动解压)、返回 `code` 为**字符串**。`get_now()` 取实时(`now.text` 中文天气 / `now.temp`),`get_today()` 取 `/3d` 的 `daily[0]` 今日温区。`QWEATHER_HOST`/`QWEATHER_KEY` 未配置时直接抛错→天气区显示 `"--"`。
|
||||||
|
- **`renderer.py`** — `DashboardRenderer.render(data)` 返回 PIL `'L'` 模式纯 0/255 图;`render_to_file()` 落盘。布局硬编码为三区:大时钟+日期 / 天气 / 橘喵三列 / 近30天趋势折线图。趋势线用 `_styled_polyline()` 按 solid/dashed/dotted 区分(设备无颜色),各指标按自身极值独立归一化(故只看趋势形状、不可横向比绝对值)。
|
||||||
|
- **`pusher.py`** — `push_image()` 走 Zectrix API(`POST /devices/{id}/display/image`,手写 multipart)。`resolve_device_id()` 在 `config.DEVICE_ID` 为空时自动取设备列表第一个。
|
||||||
|
- **`config.py`** — API、设备、画布尺寸、字体路径、推送参数;敏感项可被环境变量覆盖。
|
||||||
|
|
||||||
|
## 墨水屏渲染约束(改 renderer.py 必读)
|
||||||
|
|
||||||
|
设备是 **1bit、无真灰阶**——灰阶只能抖动成网点(难看),所以全程纯黑白,靠构图/字号/留白建立层级,**不要引入灰色填充或抖动**:
|
||||||
|
|
||||||
|
- 字体固定用 **Fusion Pixel 12px 点阵字体**(`fonts/`,OFL 协议),只按整数倍尺寸 **12/24/48** 渲染(`F12/F24/F48`),并设 `d.fontmode = "1"` 关闭抗锯齿,否则像素会糊。
|
||||||
|
- 加粗用「伪粗体」:`_draw()` 按 1~bold px 水平偏移叠绘笔画(点阵字体只有单一字重)。
|
||||||
|
- 推送时 `DITHER = False`(硬阈值),纯黑白图最锐利,勿改成 `true`。
|
||||||
|
- 坐标、字号均为像素网格上的硬编码常量;调布局时注意各区分隔线 `hdash` 的 y 值与下方组件位置联动。
|
||||||
|
|
||||||
|
## 注意
|
||||||
|
|
||||||
|
- `config.py` 内置了一个默认 `API_KEY`(硬编码 fallback)。修改/分享代码时留意,优先用 `ZECTRIX_API_KEY` 环境变量。
|
||||||
|
- `output/` 与 `__pycache__/` 已在 `.gitignore` 中忽略。
|
||||||
72
README.md
Normal file
72
README.md
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
# 墨水屏仪表盘推送(eink-push)
|
||||||
|
|
||||||
|
将橘喵今日经营、近30天趋势、日期与天气合成为一张 **400×300 纯黑白**图片,
|
||||||
|
推送到 Zectrix 极趣云墨水屏设备(`zectrix-s3-epaper-4.2`,1bit)。
|
||||||
|
|
||||||
|
纯 Python + Pillow + Fusion Pixel 点阵字体,**无浏览器依赖**,适合后台/定时运行。
|
||||||
|
|
||||||
|
## 效果
|
||||||
|
|
||||||
|
- 顶部:大号时钟 + 日期 | 天气(图标 + 温度 + 区间)
|
||||||
|
- 中部:橘喵今日经营(单量 / 流水 / 毛利,含环比、同比涨跌)
|
||||||
|
- 底部:近30天三指标趋势折线图(同一图表,实线 / 虚线 / 点线区分,X 轴仅月/日)
|
||||||
|
|
||||||
|
## 目录结构
|
||||||
|
|
||||||
|
```
|
||||||
|
eink-push/
|
||||||
|
├── main.py # 入口:取数据 → 渲染 → 推送
|
||||||
|
├── config.py # 配置:API Key、设备、字体、尺寸、推送参数
|
||||||
|
├── data.py # 数据层:拉真实数据并映射,失败回退 mock
|
||||||
|
├── jm_api.py # jm-devops 后端统计接口客户端(免鉴权 GET)
|
||||||
|
├── renderer.py # 渲染层:DashboardRenderer 生成 400×300 图片
|
||||||
|
├── pusher.py # 推送层:设备列表 / 推送图片(标准库 urllib)
|
||||||
|
├── fonts/ # Fusion Pixel 点阵字体(OFL 协议)
|
||||||
|
├── output/ # 生成的图片(git 忽略)
|
||||||
|
├── requirements.txt
|
||||||
|
└── README.md
|
||||||
|
```
|
||||||
|
|
||||||
|
## 安装
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install -r requirements.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
## 使用
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 main.py # 渲染并推送(默认取设备列表第一个)
|
||||||
|
python3 main.py --render-only # 只渲染到 output/dashboard.png,不推送
|
||||||
|
```
|
||||||
|
|
||||||
|
环境变量可覆盖配置:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ZECTRIX_API_KEY=zt_xxx ZECTRIX_DEVICE_ID=AA:BB:CC:DD:EE:FF python3 main.py
|
||||||
|
```
|
||||||
|
|
||||||
|
## 定时刷新(cron 示例,每 15 分钟)
|
||||||
|
|
||||||
|
```cron
|
||||||
|
*/15 * * * * cd /path/to/eink-push && /usr/bin/python3 main.py >> /tmp/eink.log 2>&1
|
||||||
|
```
|
||||||
|
|
||||||
|
## 数据来源
|
||||||
|
|
||||||
|
- **橘喵今日经营 + 近30天趋势**:已接入 jm-devops 后端统计接口(`jm_api.py`),
|
||||||
|
免鉴权直接 GET:
|
||||||
|
- `GET /system/statistics/realtime` —— 今日单量/流水/毛利 + 环比/同比
|
||||||
|
- `GET /system/statistics/recent-days?days=30` —— 近30天每日数据
|
||||||
|
`data.py` 负责把响应映射成渲染结构;**拉取失败会自动回退 mock**,
|
||||||
|
设 `JM_USE_MOCK=1` 可强制用 mock 离线调试。后端地址等见 `config.py`。
|
||||||
|
- **天气**:`data.py` 的 `_weather()` 仍为 mock 占位,接入真实天气 API 时替换它即可。
|
||||||
|
- **日期 / 时间 / 上次更新**:真实系统时间。
|
||||||
|
|
||||||
|
## 设计说明
|
||||||
|
|
||||||
|
- 设备为 **1bit 无真灰阶**,灰阶只能抖动成网点(难看),故全程纯黑白,靠构图、字号、留白建立层级。
|
||||||
|
- 字体用 **Fusion Pixel 12px 点阵字体**,按整数倍尺寸(12/24/48)渲染、关闭抗锯齿 → 像素锐利;
|
||||||
|
重点数据用「伪粗体」(偏移叠绘)加粗。
|
||||||
|
- 推送用 `dither=false`(硬阈值),纯黑白图最锐利。
|
||||||
|
```
|
||||||
43
config.py
Normal file
43
config.py
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""全局配置。敏感项支持用环境变量覆盖。"""
|
||||||
|
import os
|
||||||
|
|
||||||
|
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
|
||||||
|
# ---- Zectrix 极趣云平台 API ----
|
||||||
|
API_BASE = "https://cloud.zectrix.com/open/v1"
|
||||||
|
API_KEY = os.environ.get("ZECTRIX_API_KEY", "zt_3266e9b843768257893ff95e4bf4d740")
|
||||||
|
|
||||||
|
# 目标设备 ID(MAC)。留空则自动取设备列表的第一个。
|
||||||
|
DEVICE_ID = os.environ.get("ZECTRIX_DEVICE_ID", "")
|
||||||
|
|
||||||
|
# 推送参数
|
||||||
|
PAGE_ID = "1" # 持久化页面编号 1-5
|
||||||
|
DITHER = False # 纯黑白点阵图用硬阈值最锐利;设备无真灰阶,勿开抖动
|
||||||
|
|
||||||
|
# ---- 画布 ----
|
||||||
|
WIDTH, HEIGHT = 400, 300
|
||||||
|
|
||||||
|
# ---- 资源路径 ----
|
||||||
|
FONT_PATH = os.path.join(BASE_DIR, "fonts", "fusion-pixel-12px-proportional-zh_hans.otf")
|
||||||
|
OUTPUT_DIR = os.path.join(BASE_DIR, "output")
|
||||||
|
OUTPUT_PATH = os.path.join(OUTPUT_DIR, "dashboard.png")
|
||||||
|
|
||||||
|
# ---- 橘喵 jm-devops 后端(今日经营 / 近30天趋势 数据源,免鉴权 GET 调用)----
|
||||||
|
BASE_API = os.environ.get("JM_BASE_API", "https://admin.jumiaotandian.com/prod-api")
|
||||||
|
CLIENT_ID = os.environ.get("JM_CLIENT_ID", "e5cd7e4891bf95d1d19206ce24a7b32e")
|
||||||
|
|
||||||
|
# ---- 和风天气(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")
|
||||||
|
# 定位固定为南京·秦淮区。和风经纬度格式为「经度,纬度」
|
||||||
|
WEATHER_LOCATION = os.environ.get("WEATHER_LOCATION", "118.788,32.014")
|
||||||
|
WEATHER_LOC_NAME = "南京·秦淮"
|
||||||
|
# 天气缓存:业务数据每分钟刷新,但天气 15 分钟才重新拉一次(cron 每次独立进程,缓存须落盘)。
|
||||||
|
WEATHER_TTL = int(os.environ.get("WEATHER_TTL", "900")) # 秒,默认 900=15 分钟
|
||||||
|
WEATHER_CACHE_PATH = os.path.join(BASE_DIR, ".weather_cache.json")
|
||||||
|
# 近30天趋势缓存:recent-days 每天只更新一次,按日期为 key 缓存,当天命中即不再请求。
|
||||||
|
TREND_CACHE_PATH = os.path.join(BASE_DIR, ".trend_cache.json")
|
||||||
180
data.py
Normal file
180
data.py
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
数据层 —— 汇总仪表盘所需的全部数据。
|
||||||
|
|
||||||
|
橘喵今日经营 + 近30天趋势来自 jm_api(jm-devops 后端统计接口,免鉴权 GET)。
|
||||||
|
天气来自 weather_api(和风天气,定位南京·秦淮)。日期/时间用真实系统时间。
|
||||||
|
任何接口失败时相关数据以占位符 "--" 呈现,不回退 mock。
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
import config
|
||||||
|
import jm_api
|
||||||
|
import weather_api
|
||||||
|
|
||||||
|
WEEK_CN = ["周一", "周二", "周三", "周四", "周五", "周六", "周日"]
|
||||||
|
|
||||||
|
TREND_DAYS = 30 # 近 N 天趋势
|
||||||
|
|
||||||
|
|
||||||
|
def _read_weather_cache():
|
||||||
|
try:
|
||||||
|
with open(config.WEATHER_CACHE_PATH, encoding="utf-8") as f:
|
||||||
|
return json.load(f) # {"ts": float, "data": {...}}
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _write_weather_cache(ts, data):
|
||||||
|
try:
|
||||||
|
with open(config.WEATHER_CACHE_PATH, "w", encoding="utf-8") as f:
|
||||||
|
json.dump({"ts": ts, "data": data}, f, ensure_ascii=False)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[警告] 天气缓存写入失败:{e}")
|
||||||
|
|
||||||
|
|
||||||
|
def _read_trend_cache():
|
||||||
|
try:
|
||||||
|
with open(config.TREND_CACHE_PATH, encoding="utf-8") as f:
|
||||||
|
return json.load(f) # {"date": "YYYY-MM-DD", "data": {...}}
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _write_trend_cache(date_key, data):
|
||||||
|
try:
|
||||||
|
with open(config.TREND_CACHE_PATH, "w", encoding="utf-8") as f:
|
||||||
|
json.dump({"date": date_key, "data": data}, f, ensure_ascii=False)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[警告] 趋势缓存写入失败:{e}")
|
||||||
|
|
||||||
|
|
||||||
|
def _trend(now):
|
||||||
|
"""近30天趋势,按日期缓存:recent-days 每天只更新一次。
|
||||||
|
|
||||||
|
当天已有缓存(且有数据)→ 直接复用,不发请求;当天还没有 → 拉取并写缓存。
|
||||||
|
拉取失败时沿用任意旧缓存(哪怕非当天),仍无缓存才回退空。
|
||||||
|
API 降序 → 升序:左旧右新。
|
||||||
|
"""
|
||||||
|
date_key = now.strftime("%Y-%m-%d")
|
||||||
|
cached = _read_trend_cache()
|
||||||
|
if cached and cached.get("date") == date_key and cached.get("data", {}).get("series"):
|
||||||
|
return cached["data"]
|
||||||
|
try:
|
||||||
|
asc = list(reversed(jm_api.get_recent_days(TREND_DAYS)))
|
||||||
|
trend = {
|
||||||
|
"dates": [d["date"][5:].replace("-", "/") for d in asc], # yyyy-MM-dd → MM/DD
|
||||||
|
"series": [
|
||||||
|
{"k": "单量", "style": "solid", "data": [int(d["orderCount"]) for d in asc]},
|
||||||
|
{"k": "流水", "style": "dashed", "data": [float(d["payAmount"]) for d in asc]},
|
||||||
|
{"k": "毛利", "style": "dotted", "data": [float(d["grossProfit"]) for d in asc]},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
_write_trend_cache(date_key, trend)
|
||||||
|
return trend
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[警告] 近30天趋势拉取失败:{e}")
|
||||||
|
if cached and cached.get("data", {}).get("series"): # 失败沿用旧缓存,避免趋势图整片 "--"
|
||||||
|
print("[警告] 沿用上次趋势缓存")
|
||||||
|
return cached["data"]
|
||||||
|
return {"dates": [], "series": []}
|
||||||
|
|
||||||
|
|
||||||
|
def _weather(now):
|
||||||
|
"""和风天气(南京·秦淮),带 WEATHER_TTL 文件缓存。
|
||||||
|
|
||||||
|
缓存未过期 → 直接复用,不发请求(每分钟刷新时省掉 14/15 次天气调用)。
|
||||||
|
过期 → 重新拉取并写缓存;全部接口失败时沿用上次缓存,仍无缓存才回退占位 "--"。
|
||||||
|
"""
|
||||||
|
ts = now.timestamp()
|
||||||
|
cached = _read_weather_cache()
|
||||||
|
if cached and ts - cached.get("ts", 0) < config.WEATHER_TTL:
|
||||||
|
return cached["data"]
|
||||||
|
out = {"loc": config.WEATHER_LOC_NAME, "cond": "--", "temp": "--", "range": "--", "icon": ""}
|
||||||
|
ok = False
|
||||||
|
try:
|
||||||
|
w = weather_api.get_now()
|
||||||
|
out["cond"] = w.get("text") or "--"
|
||||||
|
out["temp"] = f"{w['temp']}°"
|
||||||
|
out["icon"] = w.get("icon") or "" # 和风图标代码,供 renderer 映射图标
|
||||||
|
ok = True
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[警告] 实时天气拉取失败:{e}")
|
||||||
|
try:
|
||||||
|
today = weather_api.get_today()
|
||||||
|
out["range"] = f"{today['tempMin']}°~{today['tempMax']}°"
|
||||||
|
ok = True
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[警告] 今日温区拉取失败:{e}")
|
||||||
|
if ok:
|
||||||
|
_write_weather_cache(ts, out)
|
||||||
|
return out
|
||||||
|
if cached: # 全部失败:沿用上次缓存,避免天气区整片 "--"
|
||||||
|
print("[警告] 天气全部拉取失败,沿用上次缓存")
|
||||||
|
return cached["data"]
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- 格式化 ----------
|
||||||
|
def _fmt_count(n):
|
||||||
|
return f"{int(n):,}" # 千分位,如 1,284
|
||||||
|
|
||||||
|
|
||||||
|
def _fmt_money(yuan):
|
||||||
|
"""元金额 → 紧凑展示:≥1万显示「X.XX万」(两位小数),否则取整元。"""
|
||||||
|
v = float(yuan)
|
||||||
|
return f"{v / 10000:.2f}万" if abs(v) >= 10000 else f"{v:.0f}"
|
||||||
|
|
||||||
|
|
||||||
|
def _fmt_rate(rate):
|
||||||
|
"""增长率字符串 → (方向, 文本)。null/空 → (None, '--'),方向 None 表示不画三角。
|
||||||
|
数值统一两位小数;整数部分不足两位用前导 0 补齐(6.5→06.50),便于环比/同比上下对齐;
|
||||||
|
三/四位数(≥100%)原样完整输出不截断。"""
|
||||||
|
if rate is None or rate == "":
|
||||||
|
return (None, "--")
|
||||||
|
v = float(rate)
|
||||||
|
return ("down" if v < 0 else "up", f"{abs(v):05.2f}%")
|
||||||
|
|
||||||
|
|
||||||
|
def _col(k, v, wow, yoy):
|
||||||
|
return {"k": k, "v": v, "hb": _fmt_rate(wow), "tb": _fmt_rate(yoy)}
|
||||||
|
|
||||||
|
|
||||||
|
def _placeholder_col(k):
|
||||||
|
return {"k": k, "v": "--", "hb": (None, "--"), "tb": (None, "--")}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- 橘喵业务数据 ----------
|
||||||
|
def _mao(now):
|
||||||
|
upd = now.strftime("%H:%M")
|
||||||
|
# 今日经营(realtime)
|
||||||
|
try:
|
||||||
|
rt = jm_api.get_realtime()
|
||||||
|
upd = (rt.get("dataTime") or "")[11:16] or upd
|
||||||
|
cols = [
|
||||||
|
_col("单量", _fmt_count(rt["todayOrderCount"]), rt["orderCountWowRate"], rt["orderCountYoyRate"]),
|
||||||
|
_col("流水", _fmt_money(rt["todayPayAmount"]), rt["payAmountWowRate"], rt["payAmountYoyRate"]),
|
||||||
|
_col("毛利", _fmt_money(rt["todayGrossProfit"]), rt["grossProfitWowRate"], rt["grossProfitYoyRate"]),
|
||||||
|
]
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[警告] 今日经营拉取失败:{e}")
|
||||||
|
cols = [_placeholder_col(k) for k in ("单量", "流水", "毛利")]
|
||||||
|
# 近30天趋势(按日期缓存,每天只拉一次)
|
||||||
|
trend = _trend(now)
|
||||||
|
return {"upd": upd, "cols": cols, "trend": trend}
|
||||||
|
|
||||||
|
|
||||||
|
def get_dashboard_data(now=None):
|
||||||
|
"""返回渲染所需的完整数据字典。"""
|
||||||
|
now = now or datetime.now()
|
||||||
|
return {
|
||||||
|
"date": {
|
||||||
|
"greg": now.strftime("%Y/%m/%d"),
|
||||||
|
"week": WEEK_CN[now.weekday()],
|
||||||
|
"time": now.strftime("%H:%M"),
|
||||||
|
},
|
||||||
|
"weather": _weather(now),
|
||||||
|
"mao": _mao(now),
|
||||||
|
}
|
||||||
BIN
fonts/fusion-pixel-12px-proportional-zh_hans.otf
Normal file
BIN
fonts/fusion-pixel-12px-proportional-zh_hans.otf
Normal file
Binary file not shown.
43
jm_api.py
Normal file
43
jm_api.py
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
橘喵 jm-devops 后端客户端 —— 直接调用免鉴权的统计接口(无需登录/token)。
|
||||||
|
|
||||||
|
接口(参考 jm-devops 首页 src/api/dashboard.ts):
|
||||||
|
- GET /system/statistics/realtime 今日经营实时数据
|
||||||
|
- GET /system/statistics/recent-days?days 近 N 天每日数据(按日期降序、不含今天)
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import urllib.request
|
||||||
|
import urllib.error
|
||||||
|
|
||||||
|
import config
|
||||||
|
|
||||||
|
|
||||||
|
def _get(path):
|
||||||
|
url = config.BASE_API + path
|
||||||
|
headers = {"Content-Type": "application/json;charset=utf-8", "clientid": config.CLIENT_ID}
|
||||||
|
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:
|
||||||
|
raise RuntimeError(f"HTTP {e.code}: {e.read().decode('utf-8', 'ignore')[:200]}") from e
|
||||||
|
if data.get("code") != 200:
|
||||||
|
raise RuntimeError(f"{path} 返回 code={data.get('code')}: {data.get('msg')}")
|
||||||
|
return data["data"]
|
||||||
|
|
||||||
|
|
||||||
|
def get_realtime() -> dict:
|
||||||
|
"""今日经营实时数据(单量/流水/毛利 + 环比/同比)。"""
|
||||||
|
return _get("/system/statistics/realtime")
|
||||||
|
|
||||||
|
|
||||||
|
def get_recent_days(days=30) -> list:
|
||||||
|
"""近 N 天每日经营数据(API 按日期降序、不含今天)。"""
|
||||||
|
return _get(f"/system/statistics/recent-days?days={days}")["days"]
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
print("realtime:", json.dumps(get_realtime(), ensure_ascii=False)[:300])
|
||||||
|
print("recent[0]:", json.dumps(get_recent_days(30)[0], ensure_ascii=False))
|
||||||
37
main.py
Normal file
37
main.py
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
入口 —— 取数据 → 渲染 → 推送到设备。
|
||||||
|
|
||||||
|
用法:
|
||||||
|
python3 main.py # 渲染并推送(默认取设备列表第一个)
|
||||||
|
python3 main.py --render-only # 只渲染生成图片,不推送(output/dashboard.png)
|
||||||
|
|
||||||
|
可用环境变量覆盖配置:ZECTRIX_API_KEY、ZECTRIX_DEVICE_ID
|
||||||
|
"""
|
||||||
|
import argparse
|
||||||
|
|
||||||
|
import config
|
||||||
|
from data import get_dashboard_data
|
||||||
|
from renderer import DashboardRenderer
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(description="墨水屏仪表盘:渲染并推送")
|
||||||
|
parser.add_argument("--render-only", action="store_true", help="只渲染,不推送")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
data = get_dashboard_data()
|
||||||
|
path = DashboardRenderer().render_to_file(data, config.OUTPUT_PATH)
|
||||||
|
print(f"[渲染] 已生成 {path}")
|
||||||
|
|
||||||
|
if args.render_only:
|
||||||
|
return
|
||||||
|
|
||||||
|
from pusher import push_image
|
||||||
|
device_id, resp = push_image(path)
|
||||||
|
print(f"[推送] 设备 {device_id} 成功:{resp.get('data')}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
76
pusher.py
Normal file
76
pusher.py
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
推送层 —— 调用 Zectrix 极趣云平台 API(仅用标准库 urllib,无第三方依赖)。
|
||||||
|
文档:显示推送 / 设备管理。
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import uuid
|
||||||
|
import urllib.request
|
||||||
|
import urllib.error
|
||||||
|
|
||||||
|
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)
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=30) as resp:
|
||||||
|
return 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')}") 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:
|
||||||
|
return config.DEVICE_ID
|
||||||
|
devices = get_devices()
|
||||||
|
if not devices:
|
||||||
|
raise RuntimeError("账号下没有任何设备")
|
||||||
|
return devices[0]["deviceId"]
|
||||||
|
|
||||||
|
|
||||||
|
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 push_image(image_path, device_id=None, page_id=config.PAGE_ID, dither=config.DITHER):
|
||||||
|
"""推送图片到设备显示。"""
|
||||||
|
device_id = device_id or resolve_device_id()
|
||||||
|
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
|
||||||
330
renderer.py
Normal file
330
renderer.py
Normal file
@@ -0,0 +1,330 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
渲染层 —— 将数据绘制成 400x300 纯黑白墨水屏仪表盘。
|
||||||
|
|
||||||
|
设计要点:
|
||||||
|
- 纯黑白(设备无真灰阶,灰阶只能抖动成网点,弃用);用构图 + 字号 + 留白建立层级。
|
||||||
|
- Fusion Pixel 点阵字体,按整数倍尺寸(12/24/48)渲染,fontmode='1' 关闭抗锯齿 → 像素锐利。
|
||||||
|
- 重点数据用「伪粗体」(偏移叠绘)加粗,仍对齐像素网格。
|
||||||
|
布局:上=大时钟+日期 / 天气;中=橘喵今日经营三列;下=近30天三指标趋势折线图。
|
||||||
|
"""
|
||||||
|
import math
|
||||||
|
from PIL import Image, ImageDraw, ImageFont
|
||||||
|
|
||||||
|
import config
|
||||||
|
|
||||||
|
BLACK, WHITE = 0, 255
|
||||||
|
|
||||||
|
|
||||||
|
class DashboardRenderer:
|
||||||
|
def __init__(self, font_path=config.FONT_PATH, size=(config.WIDTH, config.HEIGHT)):
|
||||||
|
self.W, self.H = size
|
||||||
|
self.F12 = ImageFont.truetype(font_path, 12)
|
||||||
|
self.F24 = ImageFont.truetype(font_path, 24)
|
||||||
|
self.F48 = ImageFont.truetype(font_path, 48)
|
||||||
|
|
||||||
|
# ---------- 基础绘制工具 ----------
|
||||||
|
def _tw(self, s, ft):
|
||||||
|
return self.d.textlength(s, font=ft)
|
||||||
|
|
||||||
|
def _draw(self, x, y, s, ft, fill, bold):
|
||||||
|
# 伪粗体:点阵字体单一字重,按 1~bold px 偏移叠绘加粗笔画
|
||||||
|
self.d.text((x, y), s, font=ft, fill=fill)
|
||||||
|
for dx in range(1, bold + 1):
|
||||||
|
self.d.text((x + dx, y), s, font=ft, fill=fill)
|
||||||
|
|
||||||
|
def text(self, x, y, s, ft, fill=BLACK, bold=0):
|
||||||
|
self._draw(x, y, s, ft, fill, bold)
|
||||||
|
|
||||||
|
def rtext(self, xr, y, s, ft, fill=BLACK, bold=0):
|
||||||
|
self._draw(xr - self._tw(s, ft) - bold, y, s, ft, fill, bold)
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
def hdash(self, x0, x1, y, dash=4, gap=4, fill=BLACK):
|
||||||
|
x = x0
|
||||||
|
while x < x1:
|
||||||
|
self.d.line([x, y, min(x + dash, x1), y], fill=fill, width=1)
|
||||||
|
x += dash + gap
|
||||||
|
|
||||||
|
def vdash(self, x, y0, y1, dash=4, gap=4, fill=BLACK):
|
||||||
|
y = y0
|
||||||
|
while y < y1:
|
||||||
|
self.d.line([x, y, x, min(y + dash, y1)], fill=fill, width=1)
|
||||||
|
y += dash + gap
|
||||||
|
|
||||||
|
def tri(self, cx, cy, sz, up=True, fill=BLACK):
|
||||||
|
if up:
|
||||||
|
pts = [(cx, cy - sz / 2), (cx - sz / 2, cy + sz / 2), (cx + sz / 2, cy + sz / 2)]
|
||||||
|
else:
|
||||||
|
pts = [(cx, cy + sz / 2), (cx - sz / 2, cy - sz / 2), (cx + sz / 2, cy - sz / 2)]
|
||||||
|
self.d.polygon(pts, fill=fill)
|
||||||
|
|
||||||
|
# ---------- 组件 ----------
|
||||||
|
def _cmp_line(self, cx, y, label, pair, fg=BLACK):
|
||||||
|
"""环比/同比一行:标签 + 涨跌三角 + 数值,整体居中。direction 为 None 时不画三角。"""
|
||||||
|
direction, val = pair
|
||||||
|
lw = self._tw(label, self.F12); gap = 5; nw = self._tw(val, self.F12)
|
||||||
|
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
|
||||||
|
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)
|
||||||
|
|
||||||
|
def _title_bar(self, y, title, upd):
|
||||||
|
"""标题栏:■ 标题(左) + 上次更新 HH:mm(右)。"""
|
||||||
|
sq, sq_y = 6, y + 4
|
||||||
|
self.d.rectangle([10, sq_y, 10 + sq, sq_y + sq], fill=BLACK)
|
||||||
|
self.text(10 + sq + 6, y, title, self.F12)
|
||||||
|
self.rtext(self.W - 10, y, f"上次更新 {upd}", self.F12)
|
||||||
|
|
||||||
|
def _loc_pin(self, x, y, w=8, fg=BLACK, bg=WHITE):
|
||||||
|
"""定位图标:实心水滴(圆头+尖尾) + 白色挖孔,1-bit 下轮廓清晰。
|
||||||
|
(x,y) 为头部圆左上角,w 为头径,总高约 w*1.5。"""
|
||||||
|
cx, cy = x + w / 2, y + w / 2 # 头部圆心
|
||||||
|
self.d.ellipse([x, y, x + w, y + w], fill=fg) # 头部实心圆
|
||||||
|
self.d.polygon([(x, cy), (x + w, cy), (cx, y + w + w * 0.5)], fill=fg) # 尾部尖
|
||||||
|
hr = w * 0.2
|
||||||
|
self.d.ellipse([cx - hr, cy - hr, cx + hr, cy + hr], fill=bg) # 头部白孔
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _weather_category(icon):
|
||||||
|
"""和风天气 icon 代码 → 可枚举类别。无法识别时回退 'cloudy'。
|
||||||
|
参考 https://dev.qweather.com/docs/resource/icons/ :
|
||||||
|
100=晴日 150=晴夜 / 101~103,151~153=多云 / 104,154=阴 /
|
||||||
|
302~304=雷阵雨 / 300~399=雨 / 400~499=雪 / 500~599=雾霾沙尘。"""
|
||||||
|
try:
|
||||||
|
c = int(icon)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return "cloudy"
|
||||||
|
if c == 100:
|
||||||
|
return "sunny"
|
||||||
|
if c == 150:
|
||||||
|
return "clear_night"
|
||||||
|
if c in (101, 102, 103, 151, 152, 153):
|
||||||
|
return "cloudy"
|
||||||
|
if c in (104, 154):
|
||||||
|
return "overcast"
|
||||||
|
if c in (302, 303, 304):
|
||||||
|
return "thunder"
|
||||||
|
if 300 <= c <= 399:
|
||||||
|
return "rain"
|
||||||
|
if 400 <= c <= 499:
|
||||||
|
return "snow"
|
||||||
|
if 500 <= c <= 599:
|
||||||
|
return "fog"
|
||||||
|
return "cloudy"
|
||||||
|
|
||||||
|
def _sun(self, cx, cy, r, s, fg, rays=True):
|
||||||
|
"""太阳:圆 + 8 道光芒(光芒可关)。"""
|
||||||
|
S = lambda v: v * s
|
||||||
|
self.d.ellipse([cx - r, cy - r, cx + r, cy + r], outline=fg, width=2)
|
||||||
|
if rays:
|
||||||
|
for a in range(0, 360, 45):
|
||||||
|
rad = math.radians(a)
|
||||||
|
self.d.line([cx + math.cos(rad) * (r + S(3)), cy + math.sin(rad) * (r + S(3)),
|
||||||
|
cx + math.cos(rad) * (r + S(7)), cy + math.sin(rad) * (r + S(7))], fill=fg, width=2)
|
||||||
|
|
||||||
|
def _cloud(self, x, cy, s, fg, bg):
|
||||||
|
"""一朵云,cy 为云体中线高度;bg 填充内部以盖住其后的太阳光芒。"""
|
||||||
|
S = lambda v: v * s
|
||||||
|
self.d.ellipse([x + S(4), cy - S(2), x + S(20), cy + S(12)], fill=bg)
|
||||||
|
self.d.ellipse([x + S(14), cy - S(8), x + S(34), cy + S(12)], fill=bg)
|
||||||
|
self.d.rectangle([x + S(6), cy + S(4), x + S(34), cy + S(12)], fill=bg)
|
||||||
|
self.d.arc([x + S(2), cy - S(4), x + S(22), cy + S(14)], 90, 270, fill=fg, width=2)
|
||||||
|
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)。"""
|
||||||
|
S = lambda v: v * s
|
||||||
|
|
||||||
|
if category == "sunny":
|
||||||
|
self._sun(x + S(18), y + S(19), S(11), s, fg)
|
||||||
|
return
|
||||||
|
|
||||||
|
if category == "clear_night": # 月牙:实心圆挖去偏上右的背景圆
|
||||||
|
mx, my, r = x + S(18), y + S(19), S(13)
|
||||||
|
self.d.ellipse([mx - r, my - r, mx + r, my + r], fill=fg)
|
||||||
|
ox, oy, r2 = mx + S(6), my - S(5), S(12)
|
||||||
|
self.d.ellipse([ox - r2, oy - r2, ox + r2, oy + r2], fill=bg)
|
||||||
|
return
|
||||||
|
|
||||||
|
if category == "overcast": # 阴:单独一朵云,居中偏下
|
||||||
|
self._cloud(x, y + S(20), s, fg, bg)
|
||||||
|
return
|
||||||
|
|
||||||
|
if category == "fog": # 雾/霾/沙尘:横向雾线,长短交错
|
||||||
|
for i, dy in enumerate((9, 17, 25, 33)):
|
||||||
|
inset = S(5) if i % 2 else 0
|
||||||
|
self.d.line([x + inset, y + S(dy), x + S(34) - inset, y + S(dy)], fill=fg, width=2)
|
||||||
|
return
|
||||||
|
|
||||||
|
if category != "rain" and category != "snow" and category != "thunder": # 多云及未识别:太阳 + 云
|
||||||
|
self._sun(x + S(10), y + S(9), S(6), s, fg)
|
||||||
|
self._cloud(x, y + S(24), s, fg, bg)
|
||||||
|
return
|
||||||
|
|
||||||
|
# 以下三类:云在上,降水/天气符号在云下方
|
||||||
|
cb = y + S(15) + S(13) # 云底 y
|
||||||
|
self._cloud(x, y + S(15), s, fg, bg)
|
||||||
|
if category == "rain":
|
||||||
|
for dx in (9, 18, 27):
|
||||||
|
self.d.line([x + S(dx) + S(3), cb + S(3), x + S(dx) - S(1), cb + S(10)], fill=fg, width=2)
|
||||||
|
elif category == "snow":
|
||||||
|
for dx in (10, 19, 28):
|
||||||
|
fx, fy, rr = x + S(dx), cb + S(7), S(4)
|
||||||
|
for a in (0, 60, 120):
|
||||||
|
rad = math.radians(a)
|
||||||
|
self.d.line([fx - math.cos(rad) * rr, fy - math.sin(rad) * rr,
|
||||||
|
fx + math.cos(rad) * rr, fy + math.sin(rad) * rr], fill=fg, width=1)
|
||||||
|
elif category == "thunder":
|
||||||
|
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)
|
||||||
|
|
||||||
|
# 线型样式:(实线段长, 间隙长);实线用 dash=0 表示连续
|
||||||
|
_STYLES = {"solid": (0, 0), "dashed": (7, 4), "dotted": (1, 2)}
|
||||||
|
|
||||||
|
def _styled_polyline(self, pts, style, fill=BLACK):
|
||||||
|
"""按线型(solid/dashed/dotted)沿折线绘制。点线/虚线靠等距步进采样。"""
|
||||||
|
dash, gap = self._STYLES.get(style, (0, 0))
|
||||||
|
if dash == 0: # 实线:直接连线
|
||||||
|
for (x0, y0), (x1, y1) in zip(pts, pts[1:]):
|
||||||
|
self.d.line([x0, y0, x1, y1], fill=fill, width=1)
|
||||||
|
return
|
||||||
|
period, drawn = dash + gap, 0.0 # drawn: 当前所在周期内已走过的距离
|
||||||
|
for (x0, y0), (x1, y1) in zip(pts, pts[1:]):
|
||||||
|
seg = math.hypot(x1 - x0, y1 - y0)
|
||||||
|
if seg == 0:
|
||||||
|
continue
|
||||||
|
ux, uy = (x1 - x0) / seg, (y1 - y0) / seg
|
||||||
|
t = 0.0
|
||||||
|
while t < seg:
|
||||||
|
phase = drawn % period
|
||||||
|
if phase < dash: # 处于“实”阶段 → 画一小段
|
||||||
|
step = min(dash - phase, seg - t)
|
||||||
|
sx, sy = x0 + ux * t, y0 + uy * t
|
||||||
|
ex, ey = x0 + ux * (t + step), y0 + uy * (t + step)
|
||||||
|
if dash <= 1: # 点线:画方点更醒目
|
||||||
|
self.d.point((round(sx), round(sy)), fill=fill)
|
||||||
|
else:
|
||||||
|
self.d.line([sx, sy, ex, ey], fill=fill, width=1)
|
||||||
|
t += step; drawn += step
|
||||||
|
else: # 处于“虚”阶段 → 跳过
|
||||||
|
step = min(period - phase, seg - t)
|
||||||
|
t += step; drawn += step
|
||||||
|
|
||||||
|
def _style_sample(self, x, y, style, w=22, fill=BLACK):
|
||||||
|
"""图例用的线型样例(一小段水平线)。"""
|
||||||
|
self._styled_polyline([(x, y), (x + w, y)], style, fill=fill)
|
||||||
|
|
||||||
|
def _trend_chart(self, y0, trend):
|
||||||
|
"""近30天三指标折线图:标题(左) + 紧凑图例(右) + 折线 + X轴(仅月/日)。
|
||||||
|
各指标按自身极值独立归一化,故只表达趋势形状,不可横向比绝对值。"""
|
||||||
|
dates, series = trend["dates"], trend["series"]
|
||||||
|
leg_y = y0
|
||||||
|
# ---- 标题(左):■ 近30天趋势 ----
|
||||||
|
sq, sq_y = 6, leg_y + 4
|
||||||
|
self.d.rectangle([10, sq_y, 10 + sq, sq_y + sq], fill=BLACK)
|
||||||
|
self.text(10 + sq + 6, leg_y, "橘喵·30天趋势", self.F12)
|
||||||
|
# 无数据:画占位符 "--",跳过图例/折线/X轴
|
||||||
|
if not series or not dates or not any(s.get("data") for s in series):
|
||||||
|
self.ctext(self.W / 2, leg_y + 36, "--", self.F24)
|
||||||
|
return
|
||||||
|
# ---- 图例(右):紧凑、整体右对齐,从右往左逐项排布 ----
|
||||||
|
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
|
||||||
|
x -= self._tw(s["k"], self.F12) + G1
|
||||||
|
self._style_sample(x - SAMP, leg_y + 7, s["style"])
|
||||||
|
x -= SAMP + GAP
|
||||||
|
# ---- 绘图区 ----
|
||||||
|
px0, px1 = 14, self.W - 10
|
||||||
|
py0, py1 = leg_y + 22, leg_y + 22 + 56 # 顶/底
|
||||||
|
self.hdash(px0, px1, py1, dash=2, gap=3) # 基线
|
||||||
|
n = len(dates)
|
||||||
|
xstep = (px1 - px0) / (n - 1) if n > 1 else 0
|
||||||
|
for s in series:
|
||||||
|
data = s["data"]
|
||||||
|
lo, hi = min(data), max(data)
|
||||||
|
span = (hi - lo) or 1
|
||||||
|
pad = 4 # 上下留白,避免贴边
|
||||||
|
pts = []
|
||||||
|
for i, v in enumerate(data):
|
||||||
|
x = px0 + xstep * i
|
||||||
|
yv = py1 - pad - (v - lo) / span * (py1 - py0 - 2 * pad)
|
||||||
|
pts.append((x, yv))
|
||||||
|
self._styled_polyline(pts, s["style"])
|
||||||
|
# ---- X 轴标签:仅首 / 中 / 末三个日期 ----
|
||||||
|
xl_y = py1 + 3
|
||||||
|
self.text(px0, xl_y, dates[0], self.F12)
|
||||||
|
self.ctext((px0 + px1) / 2, xl_y, dates[n // 2], self.F12)
|
||||||
|
self.rtext(px1, xl_y, dates[-1], self.F12)
|
||||||
|
|
||||||
|
# ---------- 入口 ----------
|
||||||
|
def render(self, data):
|
||||||
|
"""根据 data 渲染并返回 PIL.Image('L' 模式,纯 0/255)。"""
|
||||||
|
self.img = Image.new("L", (self.W, self.H), WHITE)
|
||||||
|
self.d = ImageDraw.Draw(self.img)
|
||||||
|
self.d.fontmode = "1" # 关闭抗锯齿,点阵锐利
|
||||||
|
|
||||||
|
dt, wt, mao = data["date"], data["weather"], data["mao"]
|
||||||
|
|
||||||
|
# A. Hero:大时钟 + 日期(时钟上移使「顶部留白 ≈ 与日期间距」对称)
|
||||||
|
self.text(10, 0, dt["time"], self.F48, bold=2)
|
||||||
|
self.text(12, 64, f'{dt["week"]} {dt["greg"]}', self.F12)
|
||||||
|
|
||||||
|
# B. 天气:右对齐文字 + 居中的 28° + 放大右靠的图标
|
||||||
|
DIV_Y = 90 # 天气区底部分割线
|
||||||
|
loc, temp, cr = wt["loc"], wt["temp"], f'{wt["cond"]} {wt["range"]}'
|
||||||
|
self.rtext(390, 8, loc, self.F12)
|
||||||
|
self.rtext(390, 30, temp, self.F24, bold=1)
|
||||||
|
self.rtext(390, 64, cr, self.F12)
|
||||||
|
# 定位图标:置于 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)
|
||||||
|
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.hdash(8, 392, DIV_Y)
|
||||||
|
|
||||||
|
# C. 橘喵今日经营:三列(数值 + 环比/同比)
|
||||||
|
self._title_bar(96, "橘喵·今日实时", mao["upd"])
|
||||||
|
cols = mao["cols"]; n = len(cols)
|
||||||
|
ax0, ax1 = 8, 392
|
||||||
|
cw = (ax1 - ax0) / n
|
||||||
|
for i in range(1, n):
|
||||||
|
self.vdash(ax0 + cw * i, 128, 192)
|
||||||
|
for i, c in enumerate(cols):
|
||||||
|
cx = ax0 + cw * (i + 0.5)
|
||||||
|
self.ctext(cx, 114, c["k"], self.F12)
|
||||||
|
self.ctext(cx, 130, c["v"], self.F24, bold=1)
|
||||||
|
self._cmp_line(cx, 160, "环比", c["hb"])
|
||||||
|
self._cmp_line(cx, 178, "同比", c["tb"])
|
||||||
|
|
||||||
|
self.hdash(8, 392, 200)
|
||||||
|
|
||||||
|
# D. 近30天三指标趋势折线图
|
||||||
|
self._trend_chart(204, mao["trend"])
|
||||||
|
|
||||||
|
return self.img
|
||||||
|
|
||||||
|
def render_to_file(self, data, path):
|
||||||
|
img = self.render(data)
|
||||||
|
import os
|
||||||
|
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||||||
|
img.save(path)
|
||||||
|
return path
|
||||||
1
requirements.txt
Normal file
1
requirements.txt
Normal file
@@ -0,0 +1 @@
|
|||||||
|
Pillow>=10.0
|
||||||
55
weather_api.py
Normal file
55
weather_api.py
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
和风天气(QWeather)客户端 —— 实时天气 + 今日温区。
|
||||||
|
|
||||||
|
和风要求使用「用户专属 API Host」(控制台-设置查看 https://console.qweather.com/setting),
|
||||||
|
用 API Key 鉴权(请求头 X-QW-Api-Key);响应体 Gzip 压缩需手动解压,且返回 code 为字符串。
|
||||||
|
免费订阅额度足够墨水屏每日推送。HOST/KEY 通过环境变量配置,未配置时调用方回退占位。
|
||||||
|
|
||||||
|
接口(参考 https://dev.qweather.com/docs/api/):
|
||||||
|
- GET /v7/weather/now?location=经度,纬度 实时天气(now.text 中文天气 / now.temp 温度)
|
||||||
|
- GET /v7/weather/3d?location=经度,纬度 逐日预报,取 daily[0] 的今日 tempMin/tempMax
|
||||||
|
"""
|
||||||
|
import gzip
|
||||||
|
import json
|
||||||
|
import urllib.request
|
||||||
|
import urllib.error
|
||||||
|
|
||||||
|
import config
|
||||||
|
|
||||||
|
|
||||||
|
def _get(path):
|
||||||
|
if not config.QWEATHER_HOST or not config.QWEATHER_KEY:
|
||||||
|
raise RuntimeError("未配置 QWEATHER_HOST / QWEATHER_KEY")
|
||||||
|
host = config.QWEATHER_HOST.rstrip("/")
|
||||||
|
if not host.startswith("http"):
|
||||||
|
host = "https://" + host
|
||||||
|
headers = {"X-QW-Api-Key": config.QWEATHER_KEY, "Accept-Encoding": "gzip"}
|
||||||
|
req = urllib.request.Request(host + path, headers=headers, method="GET")
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=30) as resp:
|
||||||
|
raw = resp.read()
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
raise RuntimeError(f"HTTP {e.code}: {e.read().decode('utf-8', 'ignore')[:200]}") from e
|
||||||
|
if raw[:2] == b"\x1f\x8b": # gzip magic number,和风默认压缩
|
||||||
|
raw = gzip.decompress(raw)
|
||||||
|
data = json.loads(raw.decode("utf-8"))
|
||||||
|
if str(data.get("code")) != "200": # 和风 code 为字符串
|
||||||
|
raise RuntimeError(f"{path} 返回 code={data.get('code')}")
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def get_now() -> dict:
|
||||||
|
"""实时天气:now.text(中文天气)/ now.temp(摄氏温度)。"""
|
||||||
|
return _get(f"/v7/weather/now?location={config.WEATHER_LOCATION}")["now"]
|
||||||
|
|
||||||
|
|
||||||
|
def get_today() -> dict:
|
||||||
|
"""今日逐日预报:daily[0] 含 tempMin / tempMax。"""
|
||||||
|
return _get(f"/v7/weather/3d?location={config.WEATHER_LOCATION}")["daily"][0]
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
print("now: ", json.dumps(get_now(), ensure_ascii=False)[:300])
|
||||||
|
print("today:", json.dumps(get_today(), ensure_ascii=False)[:300])
|
||||||
Reference in New Issue
Block a user