Compare commits
24 Commits
main
...
feature/re
| Author | SHA1 | Date | |
|---|---|---|---|
| 06785f030b | |||
| 04fa31cb94 | |||
| 7c60d3680c | |||
| c836b0d6bc | |||
| cf9aa3b1bb | |||
| 3656d8d933 | |||
| 3fa6bb5de6 | |||
| d35f233ef3 | |||
| f96ae29590 | |||
| 7378d6bb88 | |||
| 2015947812 | |||
| b58e1decf0 | |||
| b1a99a0e6b | |||
| 85b5113ba4 | |||
| 6dc89b504e | |||
| 110f710ff0 | |||
| d7ae193f56 | |||
| eab90bf3b5 | |||
| 097b9d1d64 | |||
| b32007483a | |||
| 2121b1eff4 | |||
| 21426e13bc | |||
| 14359c8726 | |||
| 9b39eacecf |
58
.env.example
58
.env.example
@@ -1,10 +1,9 @@
|
|||||||
# eink-push 配置模板:复制为 .env 并填入真实值(cp .env.example .env)
|
# eink-push 配置模板:复制为 .env 并填入真实值(cp .env.example .env)
|
||||||
# 真实环境变量(cron/命令行注入)优先级高于 .env 文件。
|
# 真实环境变量(cron/命令行注入)优先级高于 .env 文件。
|
||||||
|
|
||||||
# ---- Zectrix 极趣云平台 ----
|
# ---- 局域网墨水屏设备(喵喵固件)----
|
||||||
ZECTRIX_API_KEY=zt_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
|
# mDNS host,同局域网内可直接在浏览器打开 http://<EPD_HOST>/ 调试
|
||||||
# 目标设备硬件 MAC,必填——直接据此推送,不再获取设备列表
|
EPD_HOST=epd400a44.local
|
||||||
ZECTRIX_DEVICE_ID=AA:BB:CC:DD:EE:FF
|
|
||||||
|
|
||||||
# ---- 橘喵 jm-devops 后端(设备密钥授权)----
|
# ---- 橘喵 jm-devops 后端(设备密钥授权)----
|
||||||
JM_BASE_API=https://admin.jumiaotandian.com/prod-api
|
JM_BASE_API=https://admin.jumiaotandian.com/prod-api
|
||||||
@@ -18,3 +17,54 @@ QWEATHER_HOST=your-host.re.qweatherapi.com
|
|||||||
QWEATHER_KEY=
|
QWEATHER_KEY=
|
||||||
WEATHER_LOCATION=118.788,32.014
|
WEATHER_LOCATION=118.788,32.014
|
||||||
WEATHER_TTL=900
|
WEATHER_TTL=900
|
||||||
|
|
||||||
|
# ---- reTerminal E1002 设备遥测(SenseCraft HMI 后台生成的 API key;宽版顶部状态栏显示电量/温度/湿度)----
|
||||||
|
SENSECRAFT_API_KEY=
|
||||||
|
SENSECRAFT_DEVICE_EUI=
|
||||||
|
DEVICE_TTL=300
|
||||||
|
|
||||||
|
# ---- reTerminal E1002 图片/网页服务(server.py,部署在公网;本机推送端 main.py --target push)----
|
||||||
|
# 服务端:监听地址/端口、两条恒定 URL 路径(可改成难猜的路径)、推送令牌(为空则拒绝一切推送)、推送数据落盘路径(空=仅内存)
|
||||||
|
SERVER_HOST=0.0.0.0
|
||||||
|
SERVER_PORT=8790
|
||||||
|
IMAGE_PATH=/e1002.png
|
||||||
|
HTML_PATH=/e1002.html
|
||||||
|
HTML_PNG_PATH=/e1002-web.png
|
||||||
|
# 方案三需要服务器装 Chrome/Chromium;CHROME_BIN 为空则自动探测,Linux root 运行需 CHROME_EXTRA_ARGS=--no-sandbox
|
||||||
|
CHROME_BIN=
|
||||||
|
CHROME_EXTRA_ARGS=
|
||||||
|
HTML_PNG_THRESHOLD=160
|
||||||
|
PUSH_TOKEN=
|
||||||
|
# 页面访问令牌:非空时 /e1002.png、/e1002.html、/e1002-web.png 必须带 ?token=<IMAGE_TOKEN>,否则 404(防爬虫扫描);
|
||||||
|
# SenseCraft HTML 控件填 https://<域名>/e1002.png?token=<IMAGE_TOKEN>。生成:python3 -c "import secrets; print(secrets.token_urlsafe(24))"
|
||||||
|
IMAGE_TOKEN=
|
||||||
|
PUSH_STATE_PATH=
|
||||||
|
# 推送端(本机):服务地址(协议+域名/端口,不含路径),令牌与服务端一致
|
||||||
|
PUSH_URL=
|
||||||
|
# 时区(服务端可能在境外 VPS,所有时间按此时区)
|
||||||
|
TZ_NAME=Asia/Shanghai
|
||||||
|
# 用量/pace 三档阈值(宽版,颜色顺序 绿→黑→红,两者独立取色)与 BTC K 线
|
||||||
|
USAGE_WARN=80
|
||||||
|
USAGE_OK=30
|
||||||
|
PACE_TOL=10
|
||||||
|
BTC_BAR=1H
|
||||||
|
BTC_LIMIT=48
|
||||||
|
BTC_TTL=300
|
||||||
|
# 行情源尝试顺序(可选:okx,coinbase,huobi,gateio,huobi_aws,binance_vision;未列出的按默认顺序追加)。
|
||||||
|
# 境内服务器 okx/coinbase/huobi 主域名不通且各超时数秒,部署 NGCQ 时设为 gateio,huobi_aws,binance_vision
|
||||||
|
BTC_SOURCES=okx,coinbase,huobi,gateio,huobi_aws,binance_vision
|
||||||
|
# ---- AI 用量(本机采集 → main.py --target push 上报;server.py 不持凭证)----
|
||||||
|
# Claude:复用 Claude Code 钥匙串登录态调 /api/oauth/usage(只读不刷新)。快照有效期(秒,同 WEATHER_TTL 语义,0=每次实拉)建议 ≥300,失败退避 1800 秒
|
||||||
|
CLAUDE_USAGE_TTL=300
|
||||||
|
CLAUDE_USAGE_BACKOFF=1800
|
||||||
|
CLAUDE_USAGE_SCOPE_MODEL=Fable
|
||||||
|
# Codex:经 `codex app-server` RPC 读 ChatGPT 订阅的 Codex 额度;需 Codex 已用 ChatGPT 账号 `codex login`
|
||||||
|
# CODEX_BIN 建议绝对路径(launchd 的 PATH 不含 Homebrew);CODEX_USAGE_HOME 留空=默认 ~/.codex,或指向独立 CODEX_HOME
|
||||||
|
CODEX_BIN=/opt/homebrew/bin/codex
|
||||||
|
CODEX_USAGE_HOME=
|
||||||
|
CODEX_USAGE_TTL=300
|
||||||
|
CODEX_USAGE_BACKOFF=900
|
||||||
|
# 服务端(server.py)三路上游的内存缓存有效期(秒,0=每次实拉):天气/BTC 复用 WEATHER_TTL/BTC_TTL,橘喵实时用 MAO_TTL
|
||||||
|
MAO_TTL=60
|
||||||
|
# 服务端一次请求等待上游并行实拉的总预算(秒),超预算的一路本次用旧值、后台拉完更新缓存
|
||||||
|
FETCH_BUDGET=5
|
||||||
|
|||||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -3,5 +3,9 @@ __pycache__/
|
|||||||
*.pyc
|
*.pyc
|
||||||
.weather_cache.json
|
.weather_cache.json
|
||||||
.trend_cache.json
|
.trend_cache.json
|
||||||
|
.btc_cache.json
|
||||||
|
.device_cache.json
|
||||||
|
.claude_usage_cache.json
|
||||||
|
.codex_usage_cache.json
|
||||||
run.sh
|
run.sh
|
||||||
.env
|
.env
|
||||||
|
|||||||
79
README.md
79
README.md
@@ -1,27 +1,39 @@
|
|||||||
# 墨水屏仪表盘推送(eink-push)
|
# 墨水屏仪表盘推送(eink-push)
|
||||||
|
|
||||||
将橘喵今日经营、近30天趋势、日期与天气合成为一张 **400×300 纯黑白**图片,
|
将日期天气、Claude Code 用量、橘喵今日经营合成为一张 **400×300 三色(黑/白/红,BWR)**图片,
|
||||||
推送到 Zectrix 极趣云墨水屏设备(`zectrix-s3-epaper-4.2`,1bit)。
|
推送到 Zectrix 极趣云墨水屏设备。
|
||||||
|
|
||||||
纯 Python + Pillow + Fusion Pixel 点阵字体,**无浏览器依赖**,适合后台/定时运行。
|
纯 Python + Pillow + Fusion Pixel 点阵字体,**无浏览器依赖**,适合后台/定时运行。
|
||||||
|
|
||||||
|
> **`feature/colorful` 分支**:新设备支持红色,渲染升级为黑/白/红三色,版式为上中下三段
|
||||||
|
> (时钟天气 / Claude Usage / 今日实时)。红作**语义强调**——用量吃紧预警、涨、定位、天气太阳/闪电——
|
||||||
|
> 全图只三种纯色、无灰阶不抖动。近30天趋势图当前隐藏(代码保留可挂回)。
|
||||||
|
|
||||||
## 效果
|
## 效果
|
||||||
|
|
||||||
- 顶部:大号时钟 + 日期 | 天气(矢量手绘图标 + 温度 + 天气/温区)
|
- 顶部:大号时钟 + 日期 | 天气(矢量手绘图标 + 温度 + 天气/温区,定位南京·江宁)
|
||||||
- 中部:橘喵今日实时经营(单量 / 流水 / 毛利,含环比、同比涨跌)
|
- 中部:**Claude Usage**——5h / 7d 用量进度条 + 大号百分比 + 时间维度对比 pace(用量%−时间%,超前↑ / 节余↓);用量 ≥ 阈值时该条转红预警
|
||||||
- 底部:近30天三指标趋势折线图(同一图表,实线 / 虚线 / 点线区分,X 轴仅月/日)
|
- 底部:橘喵今日实时经营(单量 / 流水 / 毛利,含环比、同比涨跌,涨=红)
|
||||||
|
|
||||||
## 目录结构
|
## 目录结构
|
||||||
|
|
||||||
```
|
```
|
||||||
eink-push/
|
eink-push/
|
||||||
├── main.py # 入口:取数据 → 渲染 → 推送
|
├── main.py # 入口:取数据 → 渲染 → 推送(--target e1002 时改为产出 HTML 页)
|
||||||
├── config.py # 配置:API Key、设备、字体、尺寸、推送参数、缓存路径
|
├── renderer_e1002.py# reTerminal E1002:800x480 六色版式 + 固定尺寸 index.html 生成
|
||||||
├── data.py # 数据层:拉真实数据并映射,失败回退占位 "--"(不回退 mock);含天气/趋势缓存
|
├── server.py # E1002 动态服务:恒定 URL,每次请求现取数据、内存现渲染(PNG / HTML 两方案)+ 用量推送接口
|
||||||
├── jm_api.py # jm-devops 后端统计接口客户端(免登录 GET,仅带 clientid 头)
|
├── html_renderer.py # 方案二:800x480 现代网页版(无 JS,内联 SVG K 线)
|
||||||
|
├── html_shot.py # 方案三:网页 → 无头 Chrome 截图 → 最近色固化六色 PNG
|
||||||
|
├── push_client.py # 本机推送端:用量快照 → server.py
|
||||||
|
├── btc_api.py # BTC K 线(OKX/Coinbase/Huobi 免费接口,逐源兜底)
|
||||||
|
├── probe_server.py # 验证工具:平台对同一 URL 是否周期重抓
|
||||||
|
├── config.py # 配置:设备、天气、用量、字体、尺寸、推送参数、缓存路径
|
||||||
|
├── data.py # 数据层:拉真实数据并映射,失败回退占位 "--"(不回退 mock);天气/趋势缓存
|
||||||
|
├── jm_api.py # jm-devops 后端统计接口客户端(设备密钥 client_secret 授权 + token 缓存)
|
||||||
├── weather_api.py # 和风天气(QWeather)客户端(实时天气 + 今日温区)
|
├── weather_api.py # 和风天气(QWeather)客户端(实时天气 + 今日温区)
|
||||||
├── renderer.py # 渲染层:DashboardRenderer 生成 400×300 纯黑白图片
|
├── usage_local.py # Claude Code 用量:只读 statusline 生产的本地快照(不发网络请求)
|
||||||
├── pusher.py # 推送层:设备列表 / 推送图片(标准库 urllib)
|
├── renderer.py # 渲染层:DashboardRenderer 生成 400×300 三色(黑/白/红)图片
|
||||||
|
├── pusher.py # 推送层:按 .env 设备 MAC 直接推送图片(标准库 urllib)
|
||||||
├── run.sh # cron 包装脚本(本机专用,git 忽略)
|
├── run.sh # cron 包装脚本(本机专用,git 忽略)
|
||||||
├── fonts/ # Fusion Pixel 点阵字体(OFL 协议)
|
├── fonts/ # Fusion Pixel 点阵字体(OFL 协议)
|
||||||
├── output/ # 生成的图片与 cron.log(git 忽略)
|
├── output/ # 生成的图片与 cron.log(git 忽略)
|
||||||
@@ -38,8 +50,15 @@ pip install -r requirements.txt # 仅 Pillow>=10.0;网络请求用标准库
|
|||||||
## 使用
|
## 使用
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python3 main.py # 渲染并推送(默认取设备列表第一个)
|
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 控件(云端每周期重新抓取)
|
||||||
|
python3 server.py # reTerminal E1002 动态服务(部署公网 HTTPS):/e1002.png 每次请求内存渲染 PNG,/e1002.html 现代网页,
|
||||||
|
# /e1002-web.png 网页经服务端无头 Chrome 截图后固化六色(需装 Chrome);
|
||||||
|
# 天气/橘喵/BTC 实时拉取,Claude/ChatGPT 用量由本机推送
|
||||||
|
python3 main.py --target push # 本机:采集 Claude/Codex 账户级用量并推送到 server.py(.env: PUSH_URL + PUSH_TOKEN)
|
||||||
|
# server.py 生产部署在 NGCQ 服务器(systemd eink-server + 1Panel Nginx 反代 https://eink.njcqit.com),见 CLAUDE.md
|
||||||
```
|
```
|
||||||
|
|
||||||
环境变量可覆盖配置(详见 `config.py`):
|
环境变量可覆盖配置(详见 `config.py`):
|
||||||
@@ -50,11 +69,13 @@ ZECTRIX_API_KEY=zt_xxx ZECTRIX_DEVICE_ID=AA:BB:CC:DD:EE:FF python3 main.py
|
|||||||
# jm-devops 后端 / 和风天气
|
# jm-devops 后端 / 和风天气
|
||||||
JM_BASE_API=... JM_CLIENT_ID=...
|
JM_BASE_API=... JM_CLIENT_ID=...
|
||||||
QWEATHER_HOST=... QWEATHER_KEY=... WEATHER_LOCATION=经度,纬度 WEATHER_TTL=900
|
QWEATHER_HOST=... QWEATHER_KEY=... WEATHER_LOCATION=经度,纬度 WEATHER_TTL=900
|
||||||
|
# Claude Code 用量(只读 statusline 生产的本地快照,不发请求)
|
||||||
|
USAGE_LOCAL_PATH=~/.claude/usage-snapshot.json USAGE_WARN=80
|
||||||
```
|
```
|
||||||
|
|
||||||
## 定时刷新(cron)
|
## 定时刷新(cron)
|
||||||
|
|
||||||
设计为**每分钟**触发:业务数据每次都刷新,天气/趋势靠缓存避免高频打接口(见下「设计说明」)。
|
设计为**每分钟**触发:业务数据每次都刷新,天气靠缓存、用量读本地文件,避免高频打接口(见下「设计说明」)。
|
||||||
`run.sh` 是 cron 入口——cron 不继承登录环境,故脚本内 python 写死绝对路径、`cd` 到项目目录后追加日志到 `output/cron.log`。
|
`run.sh` 是 cron 入口——cron 不继承登录环境,故脚本内 python 写死绝对路径、`cd` 到项目目录后追加日志到 `output/cron.log`。
|
||||||
|
|
||||||
```cron
|
```cron
|
||||||
@@ -65,14 +86,18 @@ QWEATHER_HOST=... QWEATHER_KEY=... WEATHER_LOCATION=经度,纬度 WEATHER_TTL
|
|||||||
|
|
||||||
## 数据来源
|
## 数据来源
|
||||||
|
|
||||||
- **橘喵今日经营 + 近30天趋势**:接入 jm-devops 后端统计接口(`jm_api.py`),
|
- **橘喵今日经营**:接入 jm-devops 后端统计接口(`jm_api.py`,设备密钥 client_secret 授权):
|
||||||
免登录 GET(仅带 `clientid` 头、无 token):
|
|
||||||
- `GET /system/statistics/realtime` —— 今日单量/流水/毛利 + 环比/同比
|
- `GET /system/statistics/realtime` —— 今日单量/流水/毛利 + 环比/同比
|
||||||
- `GET /system/statistics/recent-days?days=30` —— 近30天每日数据(按日期降序、不含今天)
|
- `GET /system/statistics/recent-days?days=30` —— 近30天每日数据(趋势图当前隐藏,仍会拉取并缓存)
|
||||||
- **天气**:接入真实和风天气(QWeather,`weather_api.py`),定位固定南京·秦淮。
|
- **天气**:接入真实和风天气(QWeather,`weather_api.py`),定位固定南京·江宁。
|
||||||
实时天气取 `now.text`/`now.temp`/`now.icon`,今日温区取 `/3d` 的 `daily[0]`。
|
实时天气取 `now.text`/`now.temp`/`now.icon`,今日温区取 `/3d` 的 `daily[0]`。需配置 `QWEATHER_HOST` + `QWEATHER_KEY`。
|
||||||
需配置 `QWEATHER_HOST` + `QWEATHER_KEY`(控制台-设置查看)。
|
- **Claude Code 用量**:**只读本地**(`usage_local.py`,不发请求),**生产者/消费者**模式——
|
||||||
|
生产者是**打过补丁的 claude-statusline**:它把每次 Claude Code 经 stdin 喂来的**权威实时**额度
|
||||||
|
落盘为快照 `~/.claude/usage-snapshot.json`(`five_hour`/`seven_day` 的 `utilization` + `resets_at`);
|
||||||
|
本项目作消费者读取。活跃使用时快照持续刷新、准确;空闲时停在最后一次。**需先安装补丁版 statusline**
|
||||||
|
(见其仓库 `bin/statusline.sh` 的 usage snapshot 段 + `node bin/install.js`)。
|
||||||
- **日期 / 时间 / 上次更新**:真实系统时间。
|
- **日期 / 时间 / 上次更新**:真实系统时间。
|
||||||
|
- **比特币 K 线**(仅 800×480 宽版底部面板,默认时 K 近 48 小时,`BTC_BAR`/`BTC_LIMIT` 可配):免费公开接口,无需密钥,逐源兜底 OKX → Coinbase → Huobi(`btc_api.py`),5 分钟文件缓存 `.btc_cache.json`,失败沿用旧缓存。涨(收≥开)绿、跌红。
|
||||||
|
|
||||||
> `data.py` 负责把响应映射成渲染结构;**任何接口失败都回退占位 `"--"`,不回退 mock**。
|
> `data.py` 负责把响应映射成渲染结构;**任何接口失败都回退占位 `"--"`,不回退 mock**。
|
||||||
|
|
||||||
@@ -83,14 +108,16 @@ QWEATHER_HOST=... QWEATHER_KEY=... WEATHER_LOCATION=经度,纬度 WEATHER_TTL
|
|||||||
- **天气缓存** `.weather_cache.json`:TTL = `WEATHER_TTL`(默认 15 分钟),未过期不发请求。
|
- **天气缓存** `.weather_cache.json`:TTL = `WEATHER_TTL`(默认 15 分钟),未过期不发请求。
|
||||||
- **趋势缓存** `.trend_cache.json`:按日期为 key,当天命中即不再调 `recent-days`,每天只拉一次。
|
- **趋势缓存** `.trend_cache.json`:按日期为 key,当天命中即不再调 `recent-days`,每天只拉一次。
|
||||||
|
|
||||||
两者拉取失败均**沿用旧缓存**避免趋势/天气区整片 `"--"`,仍无缓存才回退占位。想强制重拉删掉对应缓存文件即可(均已 git 忽略)。
|
两者拉取失败均**沿用旧缓存**避免天气区整片 `"--"`,仍无缓存才回退占位。想强制重拉删掉对应缓存文件即可(均已 git 忽略)。
|
||||||
|
Claude 用量**不自建缓存**——直接读 statusline 生产的本地快照 `~/.claude/usage-snapshot.json`,文件缺失/损坏即显示 `"--"`。
|
||||||
|
|
||||||
## 设计说明
|
## 设计说明
|
||||||
|
|
||||||
- 设备为 **1bit 无真灰阶**,灰阶只能抖动成网点(难看),故全程纯黑白,靠构图、字号、留白建立层级。
|
- 设备为**三色(黑/白/红)无真灰阶**,灰阶只能抖动成网点(难看),故底色只用黑白 + 红作稀缺强调,靠构图、字号、留白建立层级。
|
||||||
- 字体用 **Fusion Pixel 12px 点阵字体**,按整数倍尺寸(12/24/48)渲染、关闭抗锯齿 → 像素锐利;
|
- **红只承载语义**(方向·警示·强调),占墨保持很小(实测红≈全图 0.6%、占墨迹 <10%);整图仅三种纯色 `(0,0,0)/(255,255,255)/(255,0,0)`,无灰、不抖动。
|
||||||
重点数据用「伪粗体」(偏移叠绘)加粗。
|
- 字体用 **Fusion Pixel 12px 点阵字体**,首选整数倍(12/24/48)最锐利;用量区另用 13/16px(非整数倍、笔画略不均)取字号层级,关抗锯齿仍保持纯色不糊;重点数据用「伪粗体」(偏移叠绘)加粗。
|
||||||
- 天气图标为**全矢量手绘**(无图片资源):按和风 icon 代码归并为 8 类(晴/晴夜/多云/阴/雷/雨/雪/雾),用基本图元按 1bit 描边画出。
|
- **Claude Usage**:每条单行 = 左标签(5h/7d) + 粗进度条 + 百分比 + pace(上/下三角);用量 ≥ `USAGE_WARN`(默认 80) 时该条进度条与百分比转红预警。行内所有元素按墨迹竖直中心对齐到同一中线。
|
||||||
- 趋势折线各指标按自身极值独立归一化,故只表达**趋势形状**,不可横向比绝对值。
|
- 天气图标为**全矢量手绘**(无图片资源):按和风 icon 代码归并为 8 类(晴/晴夜/多云/阴/雷/雨/雪/雾),用基本图元描边画出;太阳与闪电着红。
|
||||||
- 推送用 `dither=false`(硬阈值),纯黑白图最锐利。
|
- 近30天趋势折线图当前**隐藏**(`renderer._trend_chart` 及依赖保留、未调用,随时可挂回)。
|
||||||
|
- 推送用 `dither=false`(硬阈值),三色纯色图最锐利。
|
||||||
```
|
```
|
||||||
118
btc_api.py
Normal file
118
btc_api.py
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
比特币 K 线行情客户端 —— 免费、无需密钥的公开接口,按顺序尝试多个源,首个成功即返回。
|
||||||
|
|
||||||
|
源(均为公开 REST,无鉴权):
|
||||||
|
1. OKX GET /api/v5/market/candles?instId=BTC-USDT&bar=<1H|1D> —— 主源,本地直连可达、数据最新
|
||||||
|
2. Coinbase GET /products/BTC-USD/candles?granularity=<3600|86400> —— 备源(USD 计价,与 USDT 数值几乎一致)
|
||||||
|
3. Huobi GET /market/history/kline?period=<60min|1day>&symbol=btcusdt —— 备源
|
||||||
|
4. Gate.io GET /api/v4/spot/candlesticks?currency_pair=BTC_USDT&interval=<1h|1d>
|
||||||
|
5. Huobi AWS 同 3,域名 api-aws.huobi.pro
|
||||||
|
6. Binance GET data-api.binance.vision/api/v3/klines?symbol=BTCUSDT&interval=<1h|1d>(公开行情镜像域名)
|
||||||
|
(api.binance.com 在本地区返回 restricted location,不列入。)
|
||||||
|
|
||||||
|
顺序可用 BTC_SOURCES(逗号分隔源名)覆盖:境内服务器上 okx/coinbase/huobi 主域名均不通且逐个超时(合计 >15s,
|
||||||
|
超出 FETCH_BUDGET),须把 gateio/huobi_aws/binance_vision 排在前面(2026-09-18 部署 NGCQ 实测)。
|
||||||
|
|
||||||
|
周期用统一代号 bar:"1H"(时 K)/ "1D"(日 K),由 BARS 映射到各源的写法。
|
||||||
|
统一输出:按时间**升序**的 list[dict],每根 {"ts": epoch 秒, "o", "h", "l", "c"}(float),最后一根为当前未收盘的那根。
|
||||||
|
任何源失败抛异常并尝试下一个;全部失败抛 RuntimeError,由 data 层决定沿用缓存或占位。
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
TIMEOUT = 8
|
||||||
|
UA = "eink-push/1.0 (+https://github.com/)"
|
||||||
|
|
||||||
|
# 周期代号 → 各源参数:(OKX bar, Coinbase granularity 秒, Huobi period, Gate.io/Binance interval)
|
||||||
|
BARS = {"1H": ("1H", 3600, "60min", "1h"), "1D": ("1D", 86400, "1day", "1d")}
|
||||||
|
|
||||||
|
|
||||||
|
def _get_json(url):
|
||||||
|
req = urllib.request.Request(url, headers={"User-Agent": UA, "Accept": "application/json"})
|
||||||
|
with urllib.request.urlopen(req, timeout=TIMEOUT) as resp:
|
||||||
|
return json.loads(resp.read().decode("utf-8"))
|
||||||
|
|
||||||
|
|
||||||
|
def _okx(bar, limit):
|
||||||
|
j = _get_json(f"https://www.okx.com/api/v5/market/candles?instId=BTC-USDT&bar={BARS[bar][0]}&limit={limit}")
|
||||||
|
if str(j.get("code")) != "0":
|
||||||
|
raise RuntimeError(f"okx code={j.get('code')} msg={j.get('msg')}")
|
||||||
|
rows = j["data"] # 最新在前:[ts_ms, o, h, l, c, vol, ...]
|
||||||
|
out = [{"ts": int(r[0]) // 1000, "o": float(r[1]), "h": float(r[2]), "l": float(r[3]), "c": float(r[4])} for r in rows]
|
||||||
|
return sorted(out, key=lambda x: x["ts"])
|
||||||
|
|
||||||
|
|
||||||
|
def _coinbase(bar, limit):
|
||||||
|
rows = _get_json(f"https://api.exchange.coinbase.com/products/BTC-USD/candles?granularity={BARS[bar][1]}")
|
||||||
|
# 最新在前:[time, low, high, open, close, volume]
|
||||||
|
out = [{"ts": int(r[0]), "o": float(r[3]), "h": float(r[2]), "l": float(r[1]), "c": float(r[4])} for r in rows]
|
||||||
|
return sorted(out, key=lambda x: x["ts"])[-limit:]
|
||||||
|
|
||||||
|
|
||||||
|
def _huobi(bar, limit, host="api.huobi.pro"):
|
||||||
|
j = _get_json(f"https://{host}/market/history/kline?period={BARS[bar][2]}&size={limit}&symbol=btcusdt")
|
||||||
|
if j.get("status") != "ok":
|
||||||
|
raise RuntimeError(f"huobi status={j.get('status')} err={j.get('err-msg')}")
|
||||||
|
out = [{"ts": int(r["id"]), "o": float(r["open"]), "h": float(r["high"]), "l": float(r["low"]), "c": float(r["close"])}
|
||||||
|
for r in j["data"]]
|
||||||
|
return sorted(out, key=lambda x: x["ts"])
|
||||||
|
|
||||||
|
|
||||||
|
def _huobi_aws(bar, limit):
|
||||||
|
return _huobi(bar, limit, host="api-aws.huobi.pro")
|
||||||
|
|
||||||
|
|
||||||
|
def _gateio(bar, limit):
|
||||||
|
rows = _get_json(f"https://api.gateio.ws/api/v4/spot/candlesticks?currency_pair=BTC_USDT&interval={BARS[bar][3]}&limit={limit}")
|
||||||
|
# 升序:[ts 秒, 计价币成交额, close, high, low, open, 基础币成交量, 是否收盘]
|
||||||
|
out = [{"ts": int(r[0]), "o": float(r[5]), "h": float(r[3]), "l": float(r[4]), "c": float(r[2])} for r in rows]
|
||||||
|
return sorted(out, key=lambda x: x["ts"])
|
||||||
|
|
||||||
|
|
||||||
|
def _binance_vision(bar, limit):
|
||||||
|
rows = _get_json(f"https://data-api.binance.vision/api/v3/klines?symbol=BTCUSDT&interval={BARS[bar][3]}&limit={limit}")
|
||||||
|
# 升序:[openTime ms, o, h, l, c, vol, closeTime, ...]
|
||||||
|
out = [{"ts": int(r[0]) // 1000, "o": float(r[1]), "h": float(r[2]), "l": float(r[3]), "c": float(r[4])} for r in rows]
|
||||||
|
return sorted(out, key=lambda x: x["ts"])
|
||||||
|
|
||||||
|
|
||||||
|
SOURCES = (("okx", _okx), ("coinbase", _coinbase), ("huobi", _huobi),
|
||||||
|
("gateio", _gateio), ("huobi_aws", _huobi_aws), ("binance_vision", _binance_vision))
|
||||||
|
_BY_NAME = dict(SOURCES)
|
||||||
|
|
||||||
|
|
||||||
|
def _ordered_sources():
|
||||||
|
"""按 config.BTC_SOURCES(逗号分隔源名)排序;未列出的源按默认顺序追加到末尾,未知名字忽略。"""
|
||||||
|
try:
|
||||||
|
import config
|
||||||
|
wanted = [s.strip() for s in config.BTC_SOURCES.split(",") if s.strip()]
|
||||||
|
except Exception: # noqa: BLE001 —— 单独运行本模块或 config 缺失时用默认顺序
|
||||||
|
wanted = []
|
||||||
|
ordered = [(n, _BY_NAME[n]) for n in wanted if n in _BY_NAME]
|
||||||
|
ordered += [(n, f) for n, f in SOURCES if n not in dict(ordered)]
|
||||||
|
return ordered
|
||||||
|
|
||||||
|
|
||||||
|
def get_candles(bar="1H", limit=36):
|
||||||
|
"""返回 (源名, 升序 K 线列表)。bar 为 "1H"/"1D"。逐源尝试,全部失败抛 RuntimeError。"""
|
||||||
|
if bar not in BARS:
|
||||||
|
raise ValueError(f"不支持的周期 {bar!r},可选:{list(BARS)}")
|
||||||
|
errors = []
|
||||||
|
for name, fn in _ordered_sources():
|
||||||
|
try:
|
||||||
|
rows = fn(bar, limit)
|
||||||
|
if len(rows) >= 2:
|
||||||
|
return name, rows[-limit:]
|
||||||
|
errors.append(f"{name}: 数据不足({len(rows)})")
|
||||||
|
except Exception as e: # noqa: BLE001 —— 逐源兜底,任何异常都换下一个源
|
||||||
|
errors.append(f"{name}: {e}")
|
||||||
|
raise RuntimeError(f"BTC {bar} K 线全部源失败:" + ";".join(errors))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
src, rows = get_candles("1H", 5)
|
||||||
|
print(src)
|
||||||
|
for r in rows:
|
||||||
|
print(r)
|
||||||
155
claude_usage.py
Normal file
155
claude_usage.py
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
Claude 账户级用量 —— 复用 Claude Code 自身的 OAuth 登录态,直接调 Anthropic 的 /api/oauth/usage。
|
||||||
|
|
||||||
|
这是 Claude Code `/usage` 命令、claude.ai 用量页共用的同一数据源,反映**整个订阅账户**的 5 小时 / 7 天 /
|
||||||
|
模型限定 7 天额度:不论额度是在 Claude Code CLI、桌面 App 还是网页消耗的,这里都能看到。
|
||||||
|
与旧方案(statusline 插件把 Claude Code stdin 里的 rate_limits 落盘、本项目读文件)相比:
|
||||||
|
不依赖 Claude Code 进程在跑、不依赖任何第三方插件,空闲时也持续刷新。
|
||||||
|
|
||||||
|
鉴权复用方式与 statusline 一致——**只读** Claude Code 存下来的 access token:
|
||||||
|
1. 环境变量 CLAUDE_CODE_OAUTH_TOKEN;
|
||||||
|
2. macOS 登录钥匙串 generic password,service「Claude Code-credentials」,JSON 的 claudeAiOauth.accessToken;
|
||||||
|
3. 文件 ~/.claude/.credentials.json(Linux / SSH 场景;尊重 CLAUDE_CONFIG_DIR)。
|
||||||
|
铁律:
|
||||||
|
- **绝不刷新 token**。refresh token 每次使用即轮换,第三方刷新而不写回会让 Claude Code 下次 401 并强制重新登录。
|
||||||
|
access token 约 8 小时有效,过期就沿用旧快照(标 stale),等 Claude Code 自己下次启动刷新。
|
||||||
|
- **快照有效期 CLAUDE_USAGE_TTL 秒内不再调**(.env 可调,默认 300;0=每次实拉),429 后退避(见 usage_common)。
|
||||||
|
- 必须与 Claude Code 在同一台机器、同一用户会话下运行(读钥匙串)——所以在形态 A 里它跑在本机,由
|
||||||
|
main.py --target push 上报给 server.py,服务端不持任何凭证。
|
||||||
|
输出快照结构(与旧 statusline 快照兼容,data.usage_from_raw 直接消费):
|
||||||
|
{five_hour:{utilization, resets_at}, seven_day:{...}, seven_day_fable:{...}|None, updated_at, source}
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import time
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
import config
|
||||||
|
import usage_common
|
||||||
|
|
||||||
|
API_URL = "https://api.anthropic.com/api/oauth/usage"
|
||||||
|
KEYCHAIN_SERVICE = "Claude Code-credentials"
|
||||||
|
TIMEOUT = 15
|
||||||
|
|
||||||
|
|
||||||
|
class TokenUnavailable(RuntimeError):
|
||||||
|
"""找不到 token 或已过期——本机状态而非接口限流:退避只需一个 TTL(Claude Code 随时可能刷新钥匙串),不按 BACKOFF 长等。"""
|
||||||
|
backoff = config.CLAUDE_USAGE_TTL
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- 凭证(只读)
|
||||||
|
def _credentials_blob():
|
||||||
|
"""按优先级取 claudeAiOauth JSON 串;找不到返回 None。"""
|
||||||
|
env_tok = os.environ.get("CLAUDE_CODE_OAUTH_TOKEN")
|
||||||
|
if env_tok:
|
||||||
|
return json.dumps({"claudeAiOauth": {"accessToken": env_tok}})
|
||||||
|
try:
|
||||||
|
out = subprocess.run(["security", "find-generic-password", "-s", KEYCHAIN_SERVICE, "-w"],
|
||||||
|
capture_output=True, text=True, timeout=5)
|
||||||
|
if out.returncode == 0 and out.stdout.strip():
|
||||||
|
return out.stdout.strip()
|
||||||
|
except (OSError, subprocess.TimeoutExpired):
|
||||||
|
pass
|
||||||
|
cfg_dir = os.environ.get("CLAUDE_CONFIG_DIR") or os.path.expanduser("~/.claude")
|
||||||
|
path = os.path.join(cfg_dir, ".credentials.json")
|
||||||
|
if os.path.exists(path):
|
||||||
|
with open(path, encoding="utf-8") as f:
|
||||||
|
return f.read()
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def read_token():
|
||||||
|
"""返回未过期的 access token;拿不到/已过期抛 TokenUnavailable。"""
|
||||||
|
blob = _credentials_blob()
|
||||||
|
if not blob:
|
||||||
|
raise TokenUnavailable("未找到 Claude Code 登录凭证(钥匙串「Claude Code-credentials」/ ~/.claude/.credentials.json)")
|
||||||
|
try:
|
||||||
|
oauth = json.loads(blob).get("claudeAiOauth") or {}
|
||||||
|
except json.JSONDecodeError as e:
|
||||||
|
raise TokenUnavailable(f"凭证 JSON 解析失败:{e}") from e
|
||||||
|
tok = oauth.get("accessToken")
|
||||||
|
if not tok:
|
||||||
|
raise TokenUnavailable("凭证里没有 accessToken")
|
||||||
|
exp = oauth.get("expiresAt") # 毫秒 epoch;缺失则不判过期
|
||||||
|
if exp and float(exp) / 1000 <= time.time() + 30:
|
||||||
|
raise TokenUnavailable("Claude Code 的 access token 已过期(约 8 小时有效),等 Claude Code 下次启动自动刷新;本模块不做刷新")
|
||||||
|
return tok
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- 拉取与归一
|
||||||
|
def fetch_raw():
|
||||||
|
"""GET /api/oauth/usage,返回原始 JSON。直连、不走代理(与本机其他脚本一致)。"""
|
||||||
|
req = urllib.request.Request(API_URL, headers={
|
||||||
|
"Authorization": f"Bearer {read_token()}",
|
||||||
|
"anthropic-beta": "oauth-2025-04-20",
|
||||||
|
"Accept": "application/json",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"User-Agent": config.CLAUDE_USAGE_UA,
|
||||||
|
})
|
||||||
|
opener = urllib.request.build_opener(urllib.request.ProxyHandler({}))
|
||||||
|
try:
|
||||||
|
with opener.open(req, timeout=TIMEOUT) as resp:
|
||||||
|
return json.loads(resp.read().decode("utf-8"))
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
body = e.read().decode("utf-8", "replace")[:200]
|
||||||
|
if e.code == 429:
|
||||||
|
raise RuntimeError(f"HTTP 429 被限流(将退避 {config.CLAUDE_USAGE_BACKOFF}s;勿高频调用,否则约 24 小时内连 Claude Code /usage 也看不到):{body}") from e
|
||||||
|
if e.code == 401:
|
||||||
|
raise TokenUnavailable(f"HTTP 401 token 无效:{body}") from e
|
||||||
|
raise RuntimeError(f"HTTP {e.code}:{body}") from e
|
||||||
|
|
||||||
|
|
||||||
|
def _seg(percent, resets_at):
|
||||||
|
if percent is None:
|
||||||
|
return None
|
||||||
|
return {"utilization": float(percent), "resets_at": resets_at}
|
||||||
|
|
||||||
|
|
||||||
|
def normalize(raw, now=None):
|
||||||
|
"""原始响应 → 快照。优先用 limits[](2026 年中起模型限定额度只在这里:kind=weekly_scoped + scope.model.display_name),
|
||||||
|
顶层 five_hour / seven_day 作兜底;两处都没有的窗口为 None。"""
|
||||||
|
now = now or time.time()
|
||||||
|
five = seven = scoped = None
|
||||||
|
for lim in raw.get("limits") or []:
|
||||||
|
kind = lim.get("kind")
|
||||||
|
seg = _seg(lim.get("percent"), lim.get("resets_at"))
|
||||||
|
if kind == "session":
|
||||||
|
five = five or seg
|
||||||
|
elif kind == "weekly_all":
|
||||||
|
seven = seven or seg
|
||||||
|
elif kind == "weekly_scoped":
|
||||||
|
name = ((lim.get("scope") or {}).get("model") or {}).get("display_name") or ""
|
||||||
|
if name.lower() == config.CLAUDE_USAGE_SCOPE_MODEL.lower():
|
||||||
|
scoped = scoped or seg
|
||||||
|
for key, cur in (("five_hour", five), ("seven_day", seven)):
|
||||||
|
if cur is None and isinstance(raw.get(key), dict):
|
||||||
|
cur = _seg(raw[key].get("utilization"), raw[key].get("resets_at"))
|
||||||
|
if key == "five_hour":
|
||||||
|
five = cur
|
||||||
|
else:
|
||||||
|
seven = cur
|
||||||
|
if scoped is None and isinstance(raw.get(config.CLAUDE_USAGE_SCOPE_FALLBACK_KEY), dict):
|
||||||
|
s = raw[config.CLAUDE_USAGE_SCOPE_FALLBACK_KEY]
|
||||||
|
scoped = _seg(s.get("utilization"), s.get("resets_at"))
|
||||||
|
return {"five_hour": five, "seven_day": seven, "seven_day_fable": scoped,
|
||||||
|
"updated_at": int(now), "source": "oauth-usage"}
|
||||||
|
|
||||||
|
|
||||||
|
def get_usage(force=False):
|
||||||
|
"""对外入口:带缓存/最小间隔/退避的账户级用量快照(结构见模块说明)。失败且无旧快照时抛出。"""
|
||||||
|
snap, status = usage_common.cached_snapshot(
|
||||||
|
config.CLAUDE_USAGE_CACHE_PATH, config.CLAUDE_USAGE_TTL, config.CLAUDE_USAGE_BACKOFF,
|
||||||
|
lambda: normalize(fetch_raw()), force=force)
|
||||||
|
snap = dict(snap)
|
||||||
|
snap["status"] = status
|
||||||
|
return snap
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
import sys
|
||||||
|
d = get_usage(force="--force" in sys.argv)
|
||||||
|
print(json.dumps(d, ensure_ascii=False, indent=1))
|
||||||
147
codex_usage.py
Normal file
147
codex_usage.py
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
Codex 账户级用量 —— 通过官方 `codex app-server` JSON-RPC 读 ChatGPT 订阅的 Codex 额度(5 小时 / 7 天窗口)。
|
||||||
|
|
||||||
|
背景(2026-09 调研结论):ChatGPT 订阅的**普通聊天**额度没有任何可编程的账户级读取途径(私有端点在
|
||||||
|
Sentinel/Cloudflare 门后、且只在被限流时才返回数值);可监控的账户级实时余额只有 **Codex 额度池**——
|
||||||
|
Codex CLI / IDE 扩展 / Codex Web / ChatGPT 桌面 App 内的 Codex 共用同一池。本模块读的就是它。
|
||||||
|
|
||||||
|
为什么走 app-server RPC 而不是直调 chatgpt.com/backend-api/wham/usage:token 刷新由 Codex CLI 代管
|
||||||
|
(refresh token 轮换、外部进程自刷会踢掉 CLI 登录),官方 CI 文档也明示「不要自己调刷新接口」。
|
||||||
|
协议(已在本机 codex-cli 0.154 验证):stdio、按行 JSON-RPC——
|
||||||
|
→ {"id":1,"method":"initialize","params":{"clientInfo":{...}}} ← {"id":1,"result":{...}}
|
||||||
|
→ {"method":"initialized"}
|
||||||
|
→ {"id":2,"method":"account/rateLimits/read","params":{"excludeResetCreditDetails":true}}
|
||||||
|
← {"id":2,"result":{"rateLimits":{"primary":{usedPercent,windowDurationMins,resetsAt},"secondary":...,"planType":...},
|
||||||
|
"rateLimitsByLimitId":{...}}}
|
||||||
|
API Key 登录时返回 error「chatgpt authentication required to read rate limits」。
|
||||||
|
登录:需 Codex 以 ChatGPT 账号 `codex login`(2026-09-14 用户在默认 ~/.codex 完成,故 CODEX_USAGE_HOME 默认留空跟随默认目录;
|
||||||
|
若日常 Codex 要保持 API Key/第三方中转登录,可设独立 CODEX_HOME 并在其下 `CODEX_HOME=… codex login`)。token 由 CLI 自管。
|
||||||
|
CODEX_BIN 由 config 解析为绝对路径(launchd 的 PATH 不含 /opt/homebrew/bin,曾因此报「找不到 codex 可执行文件」)。窗口归类按 windowDurationMins(Pro 计划常只回一个窗口、另一个为 null,
|
||||||
|
不能按 primary/secondary 位置判断)。输出快照与 Claude 同构,data.usage_from_raw 直接消费:
|
||||||
|
{five_hour:{utilization, resets_at, window}, seven_day:{...}, plan, updated_at, source}
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import select
|
||||||
|
import subprocess
|
||||||
|
import time
|
||||||
|
|
||||||
|
import config
|
||||||
|
import usage_common
|
||||||
|
|
||||||
|
RPC_TIMEOUT = 25 # 秒:含 app-server 启动 + 一次远端查询
|
||||||
|
|
||||||
|
|
||||||
|
def _env():
|
||||||
|
env = {k: v for k, v in os.environ.items()
|
||||||
|
if k.upper() not in ("HTTPS_PROXY", "HTTP_PROXY", "ALL_PROXY", "NO_PROXY")} # 直连,不走代理
|
||||||
|
if config.CODEX_USAGE_HOME:
|
||||||
|
home = os.path.expanduser(config.CODEX_USAGE_HOME)
|
||||||
|
os.makedirs(home, exist_ok=True) # codex 对不存在的 CODEX_HOME 直接退出;建空目录后它会给出清晰的「未登录」错误
|
||||||
|
env["CODEX_HOME"] = home
|
||||||
|
return env
|
||||||
|
|
||||||
|
|
||||||
|
def _rpc_read_rate_limits():
|
||||||
|
"""起一个 app-server 子进程,完成握手并读一次额度;返回 account/rateLimits/read 的 result。"""
|
||||||
|
try:
|
||||||
|
p = subprocess.Popen([config.CODEX_BIN, "app-server"], stdin=subprocess.PIPE, stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.PIPE, text=True, env=_env())
|
||||||
|
except FileNotFoundError as e:
|
||||||
|
raise RuntimeError(f"找不到 codex 可执行文件(CODEX_BIN={config.CODEX_BIN})") from e
|
||||||
|
deadline = time.time() + RPC_TIMEOUT
|
||||||
|
|
||||||
|
def send(obj):
|
||||||
|
p.stdin.write(json.dumps(obj) + "\n")
|
||||||
|
p.stdin.flush()
|
||||||
|
|
||||||
|
def wait_for(rid):
|
||||||
|
while time.time() < deadline:
|
||||||
|
r, _, _ = select.select([p.stdout], [], [], 0.2)
|
||||||
|
if not r:
|
||||||
|
if p.poll() is not None:
|
||||||
|
raise RuntimeError(f"app-server 提前退出:{p.stderr.read()[:300]}")
|
||||||
|
continue
|
||||||
|
line = p.stdout.readline()
|
||||||
|
if not line:
|
||||||
|
raise RuntimeError(f"app-server 关闭了输出:{p.stderr.read()[:300]}")
|
||||||
|
try:
|
||||||
|
msg = json.loads(line)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
continue
|
||||||
|
if msg.get("id") != rid:
|
||||||
|
continue # 通知(remoteControl/status/changed 等)一律跳过
|
||||||
|
if "error" in msg:
|
||||||
|
err = msg["error"].get("message", str(msg["error"]))
|
||||||
|
hint = ""
|
||||||
|
if "authentication required" in err.lower():
|
||||||
|
hint = (f";请先执行 {'CODEX_HOME=' + config.CODEX_USAGE_HOME + ' ' if config.CODEX_USAGE_HOME else ''}"
|
||||||
|
f"codex login 用 ChatGPT 账号登录")
|
||||||
|
raise RuntimeError(f"RPC 错误:{err}{hint}")
|
||||||
|
return msg.get("result") or {}
|
||||||
|
raise RuntimeError(f"app-server 在 {RPC_TIMEOUT}s 内未响应 id={rid}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
send({"id": 1, "method": "initialize",
|
||||||
|
"params": {"clientInfo": {"name": "eink-push", "title": "eink-push usage", "version": "0.1"}}})
|
||||||
|
wait_for(1)
|
||||||
|
send({"method": "initialized"})
|
||||||
|
send({"id": 2, "method": "account/rateLimits/read", "params": {"excludeResetCreditDetails": True}})
|
||||||
|
return wait_for(2)
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
p.kill()
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _classify(win, fallback_key):
|
||||||
|
"""按窗口长度归类:≤12 小时 → five_hour,≥5 天 → seven_day;缺 windowDurationMins 时按位置兜底。"""
|
||||||
|
mins = win.get("windowDurationMins")
|
||||||
|
if mins is None:
|
||||||
|
return fallback_key
|
||||||
|
if mins <= 12 * 60:
|
||||||
|
return "five_hour"
|
||||||
|
if mins >= 5 * 24 * 60:
|
||||||
|
return "seven_day"
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def normalize(result, now=None):
|
||||||
|
now = now or time.time()
|
||||||
|
rl = result.get("rateLimits") or {}
|
||||||
|
out = {"five_hour": None, "seven_day": None, "plan": rl.get("planType"),
|
||||||
|
"updated_at": int(now), "source": "codex-app-server"}
|
||||||
|
for pos, fallback in (("primary", "five_hour"), ("secondary", "seven_day")):
|
||||||
|
win = rl.get(pos)
|
||||||
|
if not isinstance(win, dict) or win.get("usedPercent") is None:
|
||||||
|
continue
|
||||||
|
key = _classify(win, fallback)
|
||||||
|
if key and out.get(key) is None:
|
||||||
|
seg = {"utilization": float(win["usedPercent"]), "resets_at": win.get("resetsAt")}
|
||||||
|
if win.get("windowDurationMins"):
|
||||||
|
seg["window"] = int(win["windowDurationMins"]) * 60 # 秒,供 pace 用真实窗口长度
|
||||||
|
out[key] = seg
|
||||||
|
# 多桶视图原样附带(key 为 limit_id,如 codex),目前不渲染,留作观察真实数据后再决定是否加第三行
|
||||||
|
by_id = result.get("rateLimitsByLimitId")
|
||||||
|
if isinstance(by_id, dict) and by_id:
|
||||||
|
out["buckets"] = {k: {"primary": (v or {}).get("primary"), "secondary": (v or {}).get("secondary"),
|
||||||
|
"limitName": (v or {}).get("limitName")} for k, v in by_id.items()}
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def get_usage(force=False):
|
||||||
|
"""对外入口:带缓存/最小间隔/退避的 Codex 额度快照。未登录 ChatGPT 账号且无旧快照时抛出。"""
|
||||||
|
snap, status = usage_common.cached_snapshot(
|
||||||
|
config.CODEX_USAGE_CACHE_PATH, config.CODEX_USAGE_TTL, config.CODEX_USAGE_BACKOFF,
|
||||||
|
lambda: normalize(_rpc_read_rate_limits()), force=force)
|
||||||
|
snap = dict(snap)
|
||||||
|
snap["status"] = status
|
||||||
|
return snap
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
import sys
|
||||||
|
print(json.dumps(get_usage(force="--force" in sys.argv), ensure_ascii=False, indent=1))
|
||||||
115
config.py
115
config.py
@@ -28,24 +28,55 @@ def _load_dotenv(path):
|
|||||||
|
|
||||||
_load_dotenv(os.path.join(BASE_DIR, ".env"))
|
_load_dotenv(os.path.join(BASE_DIR, ".env"))
|
||||||
|
|
||||||
# ---- Zectrix 极趣云平台 API ----
|
# ---- 局域网墨水屏设备(喵喵固件,开源:https://gitee.com/gxp666111/miaomiao)----
|
||||||
API_BASE = "https://cloud.zectrix.com/open/v1"
|
# 不再走 Zectrix 公网 API + 设备 MAC,改为直连局域网设备 mDNS host 的 HTTP 接口。
|
||||||
API_KEY = os.environ.get("ZECTRIX_API_KEY", "")
|
EPD_HOST = os.environ.get("EPD_HOST", "epd400a44.local")
|
||||||
|
|
||||||
# 目标设备 ID(硬件 MAC)。必填——直接据此调推送接口,不再获取设备列表。
|
# ---- 时区 ----
|
||||||
DEVICE_ID = os.environ.get("ZECTRIX_DEVICE_ID", "")
|
# 服务端可能部署在境外 VPS,所有「现在时刻」与时间戳格式化统一按此时区(data.TZ),本机与服务端结果一致。
|
||||||
|
TZ_NAME = os.environ.get("TZ_NAME", "Asia/Shanghai")
|
||||||
|
|
||||||
# 推送参数
|
# ---- 画布(喵喵固件 400x300 三色屏)----
|
||||||
PAGE_ID = "1" # 持久化页面编号 1-5
|
|
||||||
DITHER = False # 纯黑白点阵图用硬阈值最锐利;设备无真灰阶,勿开抖动
|
|
||||||
|
|
||||||
# ---- 画布 ----
|
|
||||||
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
|
||||||
|
E1002_COLOR_STRIP = os.environ.get("E1002_COLOR_STRIP", "0") == "1" # 底部六色测试色条(真机校准用,默认关)
|
||||||
|
|
||||||
|
# ---- 图片服务(server.py:恒定 URL,每次请求内存中现渲染 800x480 PNG,供 SenseCraft HTML 控件周期抓取)----
|
||||||
|
# 服务端三路上游的内存缓存有效期(秒):收到请求先查缓存是否过期,过期才实拉并更新时间戳,否则直接用缓存渲染;
|
||||||
|
# 拉取失败沿用旧值且不更新时间戳(下次请求再试)。设 0 = 每次请求都实拉。天气/BTC 复用上面的 WEATHER_TTL/BTC_TTL。
|
||||||
|
MAO_TTL = int(os.environ.get("MAO_TTL", "60")) # 橘喵今日实时,默认 60 秒
|
||||||
|
# server.py 一次请求里等待过期上游并行实拉的总预算(秒):超预算的那路本次用上一次成功值,后台继续拉完更新缓存。
|
||||||
|
# 上游 socket 超时各 30s(和风/橘喵)、8s(BTC),若不设预算、任一路卡住会拖到平台抓取超时;预算 ≥ 正常三路并行耗时(~1s)即可。
|
||||||
|
FETCH_BUDGET = float(os.environ.get("FETCH_BUDGET", "5"))
|
||||||
|
# 真机验证结论:Image 控件只在填写 URL 时抓一次快照、此后不再更新;HTML 控件填同一图片 URL 则每周期重抓。
|
||||||
|
SERVER_HOST = os.environ.get("SERVER_HOST", "0.0.0.0")
|
||||||
|
SERVER_PORT = int(os.environ.get("SERVER_PORT", "8790"))
|
||||||
|
IMAGE_PATH = os.environ.get("IMAGE_PATH", "/e1002.png") # 方案一:恒定图片路径(每次请求内存渲染 PNG);可改成难猜的路径防扫描
|
||||||
|
HTML_PATH = os.environ.get("HTML_PATH", "/e1002.html") # 方案二:恒定网页路径(每次请求生成 800x480 现代网页,平台截图)
|
||||||
|
# 方案三:服务端用无头 Chrome 把方案二的网页截成 800x480,再按「最近色、不抖动」固化为六色 PNG(需服务器装 Chrome/Chromium)
|
||||||
|
HTML_PNG_PATH = os.environ.get("HTML_PNG_PATH", "/e1002-web.png")
|
||||||
|
CHROME_BIN = os.environ.get("CHROME_BIN", "") # 为空则按常见路径自动探测(macOS Google Chrome / google-chrome / chromium)
|
||||||
|
CHROME_EXTRA_ARGS = os.environ.get("CHROME_EXTRA_ARGS", "") # 例如 Linux 以 root 运行需 "--no-sandbox"
|
||||||
|
# 每通道二值化阈值(0~255):低于阈值归 0、否则归 255;取 160(而非 128)偏向保留墨迹,细笔画的浅灰边不至于被丢成白
|
||||||
|
HTML_PNG_THRESHOLD = int(os.environ.get("HTML_PNG_THRESHOLD", "160"))
|
||||||
|
# 页面访问令牌:非空时 IMAGE_PATH/HTML_PATH/HTML_PNG_PATH 必须带 ?token=<IMAGE_TOKEN>,否则一律 404(对扫描器等同不存在)。
|
||||||
|
# 服务在公网且路径固定,SenseCraft 里填 https://<域名>/e1002.png?token=<IMAGE_TOKEN>(2026-09-18 用户要求,防爬虫扫到)
|
||||||
|
IMAGE_TOKEN = os.environ.get("IMAGE_TOKEN", "")
|
||||||
|
PUSH_TOKEN = os.environ.get("PUSH_TOKEN", "") # 推送鉴权令牌(X-Push-Token 头);为空则拒绝一切推送
|
||||||
|
PUSH_STATE_PATH = os.environ.get("PUSH_STATE_PATH", "") # 推送数据落盘路径(重启后恢复);为空=仅内存
|
||||||
|
# 本机推送端(main.py --target push):服务地址(含协议与端口,不含路径),令牌同 PUSH_TOKEN
|
||||||
|
PUSH_URL = os.environ.get("PUSH_URL", "")
|
||||||
|
|
||||||
# ---- 资源路径 ----
|
# ---- 资源路径 ----
|
||||||
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,免验证码)。
|
||||||
@@ -62,11 +93,67 @@ TENANT_ID = os.environ.get("JM_TENANT_ID", "000000")
|
|||||||
# 未配置时天气区显示占位 "--",不影响其余渲染与推送。
|
# 未配置时天气区显示占位 "--",不影响其余渲染与推送。
|
||||||
QWEATHER_HOST = os.environ.get("QWEATHER_HOST", "") # 用户专属 API Host
|
QWEATHER_HOST = os.environ.get("QWEATHER_HOST", "") # 用户专属 API Host
|
||||||
QWEATHER_KEY = os.environ.get("QWEATHER_KEY", "")
|
QWEATHER_KEY = os.environ.get("QWEATHER_KEY", "")
|
||||||
# 定位固定为南京·秦淮区。和风经纬度格式为「经度,纬度」
|
# 定位固定为南京·江宁区。和风经纬度格式为「经度,纬度」
|
||||||
WEATHER_LOCATION = os.environ.get("WEATHER_LOCATION", "118.788,32.014")
|
WEATHER_LOCATION = os.environ.get("WEATHER_LOCATION", "118.840,31.953")
|
||||||
WEATHER_LOC_NAME = "南京·秦淮"
|
WEATHER_LOC_NAME = "南京·江宁"
|
||||||
# 天气缓存:业务数据每分钟刷新,但天气 15 分钟才重新拉一次(cron 每次独立进程,缓存须落盘)。
|
# 天气缓存:业务数据每分钟刷新,但天气 15 分钟才重新拉一次(cron 每次独立进程,缓存须落盘)。
|
||||||
WEATHER_TTL = int(os.environ.get("WEATHER_TTL", "900")) # 秒,默认 900=15 分钟
|
WEATHER_TTL = int(os.environ.get("WEATHER_TTL", "900")) # 秒,默认 900=15 分钟(本机文件缓存与 server.py 内存缓存共用)
|
||||||
WEATHER_CACHE_PATH = os.path.join(BASE_DIR, ".weather_cache.json")
|
WEATHER_CACHE_PATH = os.path.join(BASE_DIR, ".weather_cache.json")
|
||||||
# 近30天趋势缓存:recent-days 每天只更新一次,按日期为 key 缓存,当天命中即不再请求。
|
# 近30天趋势缓存:recent-days 每天只更新一次,按日期为 key 缓存,当天命中即不再请求。
|
||||||
TREND_CACHE_PATH = os.path.join(BASE_DIR, ".trend_cache.json")
|
TREND_CACHE_PATH = os.path.join(BASE_DIR, ".trend_cache.json")
|
||||||
|
|
||||||
|
# ---- 比特币 K 线(仅 800x480 宽版底部面板)----
|
||||||
|
# 免费公开接口,无需密钥,逐源兜底(OKX → Coinbase → Huobi,见 btc_api.py)。当前为时 K、近 48 小时(2 天);
|
||||||
|
# 每 5 分钟刷新(与 launchd 节律一致,最新一根随实时价推进)。拉取失败沿用旧缓存。缓存文件已在 .gitignore。
|
||||||
|
BTC_BAR = os.environ.get("BTC_BAR", "1H") # 周期:"1H" 时K / "1D" 日K
|
||||||
|
BTC_LIMIT = int(os.environ.get("BTC_LIMIT", "48")) # 展示最近 N 根(时 K 默认 48 = 近 2 天;2026-09-14 由 72 改 48)
|
||||||
|
BTC_TTL = int(os.environ.get("BTC_TTL", "300")) # 秒,默认 5 分钟(本机文件缓存与 server.py 内存缓存共用)
|
||||||
|
# 行情源尝试顺序(逗号分隔,见 btc_api.SOURCES;未列出的按默认顺序追加)。境内服务器 okx/coinbase/huobi 主域名不通且各超时数秒,
|
||||||
|
# 部署 NGCQ 时设 BTC_SOURCES=gateio,huobi_aws,binance_vision,否则每次请求都超 FETCH_BUDGET 拿不到 K 线。
|
||||||
|
BTC_SOURCES = os.environ.get("BTC_SOURCES", "okx,coinbase,huobi,gateio,huobi_aws,binance_vision")
|
||||||
|
BTC_CACHE_PATH = os.path.join(BASE_DIR, ".btc_cache.json")
|
||||||
|
|
||||||
|
# ---- reTerminal E1002 设备遥测(SenseCraft HMI,电量/温度/湿度,宽版顶部状态栏)----
|
||||||
|
# api-key 由用户在 SenseCraft 后台生成,放 .env;设备每 300s 上报一次,DEVICE_TTL 与之同步(0=每次实拉)。
|
||||||
|
SENSECRAFT_API_BASE = os.environ.get("SENSECRAFT_API_BASE", "https://sensecraft-hmi-api.seeed.cc")
|
||||||
|
SENSECRAFT_API_KEY = os.environ.get("SENSECRAFT_API_KEY", "")
|
||||||
|
SENSECRAFT_DEVICE_EUI = os.environ.get("SENSECRAFT_DEVICE_EUI", "")
|
||||||
|
DEVICE_TTL = int(os.environ.get("DEVICE_TTL", "300"))
|
||||||
|
DEVICE_CACHE_PATH = os.path.join(BASE_DIR, ".device_cache.json")
|
||||||
|
|
||||||
|
# ---- AI 用量数据源(Claude Usage / Codex Usage 两个模块)----
|
||||||
|
# 形态 A:两路都在**本机**采集(需本机的登录态),由 main.py --target push 上报给 server.py;服务端不持任何凭证、不读本机文件。
|
||||||
|
# Claude(claude_usage.py):复用 Claude Code 自身的 OAuth 登录态(macOS 钥匙串「Claude Code-credentials」,只读、绝不刷新),
|
||||||
|
# 直接调 https://api.anthropic.com/api/oauth/usage——与 Claude Code `/usage` 同源、**账户级**(CLI/桌面/网页消耗都算),
|
||||||
|
# 不依赖 Claude Code 进程在跑、不依赖 statusline 插件。
|
||||||
|
CLAUDE_USAGE_TTL = int(os.environ.get("CLAUDE_USAGE_TTL", "300")) # 秒,快照有效期(同 WEATHER_TTL 语义,0=每次实拉);社区一致建议 ≥300(突发调用 429 会封约 24 小时)
|
||||||
|
CLAUDE_USAGE_BACKOFF = int(os.environ.get("CLAUDE_USAGE_BACKOFF", "1800")) # 秒;拉取失败后的退避期,期内沿用旧快照
|
||||||
|
CLAUDE_USAGE_SCOPE_MODEL = os.environ.get("CLAUDE_USAGE_SCOPE_MODEL", "Fable") # limits[] 里模型限定周额度按 display_name 识别
|
||||||
|
CLAUDE_USAGE_SCOPE_FALLBACK_KEY = os.environ.get("CLAUDE_USAGE_SCOPE_FALLBACK_KEY", "seven_day_opus") # 旧版顶层键兜底
|
||||||
|
CLAUDE_USAGE_UA = os.environ.get("CLAUDE_USAGE_UA", "claude-code/2.1.34")
|
||||||
|
CLAUDE_USAGE_CACHE_PATH = os.path.join(BASE_DIR, ".claude_usage_cache.json") # 只存快照与时间戳,不存凭证
|
||||||
|
# Codex(codex_usage.py):经官方 `codex app-server` JSON-RPC 读 ChatGPT 订阅的 Codex 额度池(CLI/IDE/Web/桌面 App 内 Codex 共用);
|
||||||
|
# token 刷新由 Codex CLI 代管。需 Codex 以 ChatGPT 账号登录(`codex login`);CODEX_USAGE_HOME 留空用默认 ~/.codex,
|
||||||
|
# 也可指向独立 CODEX_HOME 与日常配置隔离。
|
||||||
|
def _which_codex():
|
||||||
|
"""launchd 不继承登录 shell 的 PATH(无 /opt/homebrew/bin),故按 PATH → Homebrew/常见位置解析出绝对路径。"""
|
||||||
|
import shutil
|
||||||
|
env = os.environ.get("CODEX_BIN", "")
|
||||||
|
if env:
|
||||||
|
return os.path.expanduser(env)
|
||||||
|
for cand in (shutil.which("codex"), "/opt/homebrew/bin/codex", "/usr/local/bin/codex", os.path.expanduser("~/.npm-global/bin/codex")):
|
||||||
|
if cand and os.path.exists(cand):
|
||||||
|
return cand
|
||||||
|
return "codex"
|
||||||
|
CODEX_BIN = _which_codex()
|
||||||
|
# 留空 = 用 Codex 默认目录 ~/.codex(2026-09-14 用户直接在默认目录用 ChatGPT 账号登录,故默认跟随之);
|
||||||
|
# 若日常 Codex 需保持 API Key/中转登录而另开一份 ChatGPT 登录,可设为独立目录(如 ~/.codex-usage)并在其下 `CODEX_HOME=… codex login`。
|
||||||
|
CODEX_USAGE_HOME = os.environ.get("CODEX_USAGE_HOME", "")
|
||||||
|
CODEX_USAGE_TTL = int(os.environ.get("CODEX_USAGE_TTL", "300")) # 秒,快照有效期(0=每次实拉)
|
||||||
|
CODEX_USAGE_BACKOFF = int(os.environ.get("CODEX_USAGE_BACKOFF", "900"))
|
||||||
|
CODEX_USAGE_CACHE_PATH = os.path.join(BASE_DIR, ".codex_usage_cache.json")
|
||||||
|
USAGE_WARN = int(os.environ.get("USAGE_WARN", "80")) # 用量 ≥ 此百分比 → 进度条与百分比转红预警(两个版面统一)
|
||||||
|
# 以下两项仅六色宽版(E1002)使用,三色小屏只有 黑/红 两档。用量与 pace 各自三档、独立取色,颜色顺序固定为 绿 → 黑 → 红
|
||||||
|
# (黄不用:2026-09-14 曾按要求加过 60%~80% 黄档,随即被否——黄小字压白底发虚、且四档在三行里过于花):
|
||||||
|
USAGE_OK = int(os.environ.get("USAGE_OK", "30")) # 用量 ≤ OK 绿「健康」|OK~WARN 黑「正常」|≥ WARN 红「危险」;进度条、边框与百分比数字同色
|
||||||
|
PACE_TOL = int(os.environ.get("PACE_TOL", "10")) # pace=用量%−时间%:≤ −TOL 绿「节余」|±TOL 内黑「持平」|≥ +TOL 红「偏快」
|
||||||
|
|||||||
341
data.py
341
data.py
@@ -4,19 +4,40 @@
|
|||||||
数据层 —— 汇总仪表盘所需的全部数据。
|
数据层 —— 汇总仪表盘所需的全部数据。
|
||||||
|
|
||||||
橘喵今日经营 + 近30天趋势来自 jm_api(jm-devops 后端统计接口,免鉴权 GET)。
|
橘喵今日经营 + 近30天趋势来自 jm_api(jm-devops 后端统计接口,免鉴权 GET)。
|
||||||
天气来自 weather_api(和风天气,定位南京·秦淮)。日期/时间用真实系统时间。
|
天气来自 weather_api(和风天气,定位南京·江宁)。日期/时间用真实系统时间(统一按 config.TZ_NAME 时区)。
|
||||||
任何接口失败时相关数据以占位符 "--" 呈现,不回退 mock。
|
任何接口失败时相关数据以占位符 "--" 呈现,不回退 mock。
|
||||||
|
|
||||||
|
两种调用形态:
|
||||||
|
- 本机脚本(main.py):get_dashboard_data() —— 带文件缓存(天气/趋势/BTC),用量读本地快照;
|
||||||
|
- 图片服务(server.py):直接用各 `*_fetch()` 纯拉取函数 + `_usage(now, raw=<推送数据>)`,不落盘。
|
||||||
"""
|
"""
|
||||||
import json
|
import json
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
import config
|
import config
|
||||||
import jm_api
|
import jm_api
|
||||||
import weather_api
|
import weather_api
|
||||||
|
import claude_usage
|
||||||
|
import codex_usage
|
||||||
|
import btc_api
|
||||||
|
import sensecraft_api
|
||||||
|
|
||||||
WEEK_CN = ["周一", "周二", "周三", "周四", "周五", "周六", "周日"]
|
WEEK_CN = ["周一", "周二", "周三", "周四", "周五", "周六", "周日"]
|
||||||
|
TZ = ZoneInfo(config.TZ_NAME)
|
||||||
|
|
||||||
|
|
||||||
|
def now_tz():
|
||||||
|
"""当前时刻(带 config.TZ_NAME 时区)。"""
|
||||||
|
return datetime.now(TZ)
|
||||||
|
|
||||||
|
|
||||||
|
def _local(ts):
|
||||||
|
"""epoch 秒 → 该时区的 datetime。"""
|
||||||
|
return datetime.fromtimestamp(ts, TZ)
|
||||||
|
|
||||||
TREND_DAYS = 30 # 近 N 天趋势
|
TREND_DAYS = 30 # 近 N 天趋势
|
||||||
|
FIVE_HOUR, SEVEN_DAY = 5 * 3600, 7 * 86400 # 两个滚动窗口长度(秒),用于算「时间已流逝%」
|
||||||
|
|
||||||
|
|
||||||
def _read_weather_cache():
|
def _read_weather_cache():
|
||||||
@@ -83,7 +104,7 @@ def _trend(now):
|
|||||||
|
|
||||||
|
|
||||||
def _weather(now):
|
def _weather(now):
|
||||||
"""和风天气(南京·秦淮),带 WEATHER_TTL 文件缓存。
|
"""和风天气(南京·江宁),带 WEATHER_TTL 文件缓存(本机脚本用)。
|
||||||
|
|
||||||
缓存未过期 → 直接复用,不发请求(每分钟刷新时省掉 14/15 次天气调用)。
|
缓存未过期 → 直接复用,不发请求(每分钟刷新时省掉 14/15 次天气调用)。
|
||||||
过期 → 重新拉取并写缓存;全部接口失败时沿用上次缓存,仍无缓存才回退占位 "--"。
|
过期 → 重新拉取并写缓存;全部接口失败时沿用上次缓存,仍无缓存才回退占位 "--"。
|
||||||
@@ -92,6 +113,18 @@ def _weather(now):
|
|||||||
cached = _read_weather_cache()
|
cached = _read_weather_cache()
|
||||||
if cached and ts - cached.get("ts", 0) < config.WEATHER_TTL:
|
if cached and ts - cached.get("ts", 0) < config.WEATHER_TTL:
|
||||||
return cached["data"]
|
return cached["data"]
|
||||||
|
out, ok = weather_fetch()
|
||||||
|
if ok:
|
||||||
|
_write_weather_cache(ts, out)
|
||||||
|
return out
|
||||||
|
if cached: # 全部失败:沿用上次缓存,避免天气区整片 "--"
|
||||||
|
print("[警告] 天气全部拉取失败,沿用上次缓存")
|
||||||
|
return cached["data"]
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def weather_fetch():
|
||||||
|
"""实时拉取天气(不读不写缓存)→ (weather dict,是否至少一个接口成功)。服务端每次请求直接调用。"""
|
||||||
out = {"loc": config.WEATHER_LOC_NAME, "cond": "--", "temp": "--", "range": "--", "icon": ""}
|
out = {"loc": config.WEATHER_LOC_NAME, "cond": "--", "temp": "--", "range": "--", "icon": ""}
|
||||||
ok = False
|
ok = False
|
||||||
try:
|
try:
|
||||||
@@ -108,67 +141,313 @@ def _weather(now):
|
|||||||
ok = True
|
ok = True
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"[警告] 今日温区拉取失败:{e}")
|
print(f"[警告] 今日温区拉取失败:{e}")
|
||||||
if ok:
|
return out, ok
|
||||||
_write_weather_cache(ts, out)
|
|
||||||
return out
|
|
||||||
if cached: # 全部失败:沿用上次缓存,避免天气区整片 "--"
|
|
||||||
print("[警告] 天气全部拉取失败,沿用上次缓存")
|
|
||||||
return cached["data"]
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
# ---------- 格式化 ----------
|
# ---------- 格式化 ----------
|
||||||
|
# 数值统一拆成三段 (前缀, 主数字, 后缀):渲染器把主数字画大、前后缀画小(如「¥」「万」,2026-09-14 用户要求),
|
||||||
|
# 拼接后的整串仍放在 col["v"],供网页版等只认字符串的消费方使用。
|
||||||
def _fmt_count(n):
|
def _fmt_count(n):
|
||||||
return f"{int(n):,}" # 千分位,如 1,284
|
return ("", f"{int(n):,}", "") # 千分位,如 1,284
|
||||||
|
|
||||||
|
|
||||||
def _fmt_money(yuan):
|
def _fmt_money(yuan):
|
||||||
"""元金额 → 紧凑展示:≥1万显示「X.XX万」(两位小数),否则取整元。"""
|
"""元金额 → 紧凑展示:≥1万显示「¥X.XX万」(两位小数),否则「¥整数元」;人民币符号作小号前缀、万作小号后缀。"""
|
||||||
v = float(yuan)
|
v = float(yuan)
|
||||||
return f"{v / 10000:.2f}万" if abs(v) >= 10000 else f"{v:.0f}"
|
return ("¥", f"{v / 10000:.2f}", "万") if abs(v) >= 10000 else ("¥", f"{v:.0f}", "")
|
||||||
|
|
||||||
|
|
||||||
def _fmt_rate(rate):
|
def _fmt_rate(rate):
|
||||||
"""增长率字符串 → (方向, 文本)。null/空 → (None, '--'),方向 None 表示不画三角。
|
"""增长率字符串 → (方向, 文本)。null/空 → (None, '--'),方向 None 表示不画三角;
|
||||||
|
恰为 0 → 'flat',渲染器在三角位画等号「=」(与用量行 pace 持平一致,2026-09-14 用户要求,原来 0 也画上三角)。
|
||||||
数值统一两位小数;整数部分不足两位用前导 0 补齐(6.5→06.50),便于环比/同比上下对齐;
|
数值统一两位小数;整数部分不足两位用前导 0 补齐(6.5→06.50),便于环比/同比上下对齐;
|
||||||
三/四位数(≥100%)原样完整输出不截断。"""
|
三/四位数(≥100%)原样完整输出不截断。"""
|
||||||
if rate is None or rate == "":
|
if rate is None or rate == "":
|
||||||
return (None, "--")
|
return (None, "--")
|
||||||
v = float(rate)
|
v = float(rate)
|
||||||
return ("down" if v < 0 else "up", f"{abs(v):05.2f}%")
|
direction = "flat" if v == 0 else "down" if v < 0 else "up"
|
||||||
|
return (direction, f"{abs(v):05.2f}%")
|
||||||
|
|
||||||
|
|
||||||
def _col(k, v, wow, yoy):
|
def _col(k, parts, wow, yoy, wide=False):
|
||||||
return {"k": k, "v": v, "hb": _fmt_rate(wow), "tb": _fmt_rate(yoy)}
|
"""一列指标。parts 为 (前缀, 主数字, 后缀);wide=True 表示仅宽版(800×480)展示,400×300 小屏三列放不下、渲染时跳过。"""
|
||||||
|
return {"k": k, "v": "".join(parts), "vp": tuple(parts), "hb": _fmt_rate(wow), "tb": _fmt_rate(yoy), "wide": wide}
|
||||||
|
|
||||||
|
|
||||||
def _placeholder_col(k):
|
def _placeholder_col(k, wide=False):
|
||||||
return {"k": k, "v": "--", "hb": (None, "--"), "tb": (None, "--")}
|
return {"k": k, "v": "--", "vp": ("", "--", ""), "hb": (None, "--"), "tb": (None, "--"), "wide": wide}
|
||||||
|
|
||||||
|
|
||||||
|
# 今日实时五列:(标签、接口字段前缀、格式化、是否仅宽版)。字段前缀 base 对应 today{Base} / {base}WowRate / {base}YoyRate,
|
||||||
|
# 见 docs/首页看板接口文档-20260911.md。团购两列为新增字段,旧后端无此键 → 占位 "--"(不报错、不影响其余列)。
|
||||||
|
REALTIME_COLS = (
|
||||||
|
("探店单量", "orderCount", _fmt_count, False),
|
||||||
|
("探店流水", "payAmount", _fmt_money, False),
|
||||||
|
("团购单量", "groupbuyOrderCount", _fmt_count, True),
|
||||||
|
("团购流水", "groupbuyPayAmount", _fmt_money, True),
|
||||||
|
("毛利", "grossProfit", _fmt_money, False), # 口径已含团购毛利(接口合计,未拆分)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _col_from(rt, k, base, fmt, wide):
|
||||||
|
cap = base[0].upper() + base[1:]
|
||||||
|
v = rt.get(f"today{cap}")
|
||||||
|
if v is None or v == "":
|
||||||
|
return _placeholder_col(k, wide)
|
||||||
|
return _col(k, fmt(v), rt.get(f"{base}WowRate"), rt.get(f"{base}YoyRate"), wide)
|
||||||
|
|
||||||
|
|
||||||
# ---------- 橘喵业务数据 ----------
|
# ---------- 橘喵业务数据 ----------
|
||||||
def _mao(now):
|
def mao_fetch(now, with_trend=True):
|
||||||
|
"""橘喵今日经营(实时接口);with_trend=False 时不拉近 30 天趋势(服务端与宽版都不用它,且趋势有文件缓存)。
|
||||||
|
返回(mao dict,实时接口是否成功)。"""
|
||||||
upd = now.strftime("%H:%M")
|
upd = now.strftime("%H:%M")
|
||||||
|
ok = True
|
||||||
# 今日经营(realtime)
|
# 今日经营(realtime)
|
||||||
try:
|
try:
|
||||||
rt = jm_api.get_realtime()
|
rt = jm_api.get_realtime()
|
||||||
upd = (rt.get("dataTime") or "")[11:16] or upd
|
upd = (rt.get("dataTime") or "")[11:16] or upd
|
||||||
cols = [
|
cols = [_col_from(rt, k, base, fmt, wide) for k, base, fmt, wide in REALTIME_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:
|
except Exception as e:
|
||||||
print(f"[警告] 今日经营拉取失败:{e}")
|
print(f"[警告] 今日经营拉取失败:{e}")
|
||||||
cols = [_placeholder_col(k) for k in ("单量", "流水", "毛利")]
|
cols = [_placeholder_col(k, wide) for k, _, _, wide in REALTIME_COLS]
|
||||||
|
ok = False
|
||||||
# 近30天趋势(按日期缓存,每天只拉一次)
|
# 近30天趋势(按日期缓存,每天只拉一次)
|
||||||
trend = _trend(now)
|
trend = _trend(now) if with_trend else {"dates": [], "series": []}
|
||||||
return {"upd": upd, "cols": cols, "trend": trend}
|
return {"upd": upd, "cols": cols, "trend": trend}, ok
|
||||||
|
|
||||||
|
|
||||||
|
def _mao(now):
|
||||||
|
return mao_fetch(now)[0]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- 比特币 K 线 ----------
|
||||||
|
def _read_btc_cache():
|
||||||
|
try:
|
||||||
|
with open(config.BTC_CACHE_PATH, encoding="utf-8") as f:
|
||||||
|
return json.load(f) # {"ts": float, "data": {...}}
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _write_btc_cache(ts, data):
|
||||||
|
try:
|
||||||
|
with open(config.BTC_CACHE_PATH, "w", encoding="utf-8") as f:
|
||||||
|
json.dump({"ts": ts, "data": data}, f, ensure_ascii=False)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[警告] BTC 缓存写入失败:{e}")
|
||||||
|
|
||||||
|
|
||||||
|
def _btc(now):
|
||||||
|
"""比特币 K 线(宽版底部面板;周期 BTC_BAR、根数 BTC_LIMIT),带 BTC_TTL 文件缓存;
|
||||||
|
拉取失败沿用旧缓存,仍无缓存回退空 candles。
|
||||||
|
结构契约(renderer 依赖):{symbol, bar, src, candles:[{t, o, h, l, c}], last, chg, chg_label},其中 t 为轴标签文本;
|
||||||
|
last 为最新一根(当前未收盘)的收盘价;chg 为涨跌幅(%)——时 K 取相对 24 根前(24h)收盘、不足则相对首根,
|
||||||
|
日 K 取相对上一根收盘,chg_label 说明口径("24h"/"1d");涨=绿 / 跌=红 由 renderer 按 c≥o 判定。"""
|
||||||
|
ts = now.timestamp()
|
||||||
|
bar = config.BTC_BAR
|
||||||
|
cached = _read_btc_cache()
|
||||||
|
cd = (cached or {}).get("data", {})
|
||||||
|
# 周期或根数(BTC_BAR/BTC_LIMIT)变更即视为失效,避免改配置后 TTL 内仍显示旧范围
|
||||||
|
if cached and ts - cached.get("ts", 0) < config.BTC_TTL and cd.get("bar") == bar and len(cd.get("candles", [])) == config.BTC_LIMIT:
|
||||||
|
return cached["data"]
|
||||||
|
try:
|
||||||
|
out = btc_fetch()
|
||||||
|
_write_btc_cache(ts, out)
|
||||||
|
return out
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[警告] BTC K 线拉取失败:{e}")
|
||||||
|
if cached:
|
||||||
|
print("[警告] 沿用上次 BTC 缓存")
|
||||||
|
return cached["data"]
|
||||||
|
return btc_empty()
|
||||||
|
|
||||||
|
|
||||||
|
def btc_empty():
|
||||||
|
return {"symbol": "BTC/USDT", "bar": config.BTC_BAR, "src": "", "candles": [], "last": None, "chg": None, "chg_label": ""}
|
||||||
|
|
||||||
|
|
||||||
|
def btc_fetch():
|
||||||
|
"""实时拉取 BTC K 线并整理成渲染结构(不读不写缓存);失败抛异常。服务端每次请求直接调用。"""
|
||||||
|
bar = config.BTC_BAR
|
||||||
|
src, rows = btc_api.get_candles(bar, config.BTC_LIMIT)
|
||||||
|
fmt = "%m/%d %H:%M" if bar == "1H" else "%m/%d"
|
||||||
|
candles = [{"t": _local(r["ts"]).strftime(fmt), "o": r["o"], "h": r["h"], "l": r["l"], "c": r["c"]} for r in rows]
|
||||||
|
last = rows[-1]["c"]
|
||||||
|
if bar == "1H":
|
||||||
|
base = rows[-25]["c"] if len(rows) >= 25 else rows[0]["c"]
|
||||||
|
chg_label = "24h" if len(rows) >= 25 else f"{len(rows) - 1}h"
|
||||||
|
else:
|
||||||
|
base, chg_label = rows[-2]["c"], "1d"
|
||||||
|
return {"symbol": "BTC/USDT", "bar": bar, "src": src, "candles": candles,
|
||||||
|
"last": last, "chg": (last - base) / base * 100 if base else 0.0, "chg_label": chg_label}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- Claude Code 用量 ----------
|
||||||
|
def _parse_reset_ts(s):
|
||||||
|
"""resets_at → epoch 秒。兼容三种:epoch 数字(Claude Code stdin 给的就是 epoch)、
|
||||||
|
ISO 时间串(含结尾 'Z')、空。失败返回 None。"""
|
||||||
|
if s is None or s == "":
|
||||||
|
return None
|
||||||
|
s = str(s)
|
||||||
|
if s.replace(".", "", 1).isdigit(): # 纯数字 → 当作 epoch 秒
|
||||||
|
return float(s)
|
||||||
|
try:
|
||||||
|
return datetime.fromisoformat(s.replace("Z", "+00:00")).timestamp()
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _window_time_pct(resets_at, window, now):
|
||||||
|
"""该滚动窗口「已流逝时间百分比」= (window − 距重置剩余) / window ×100。失败返回 None。"""
|
||||||
|
reset = _parse_reset_ts(resets_at)
|
||||||
|
if reset is None:
|
||||||
|
return None
|
||||||
|
remaining = max(0, min(window, reset - now.timestamp()))
|
||||||
|
return int(round((window - remaining) / window * 100))
|
||||||
|
|
||||||
|
|
||||||
|
def _fmt_reset(resets_at, window):
|
||||||
|
"""重置时刻 → 文本:≤1 天的窗口只给 HH:MM,更长的窗口带 MM/DD。解析失败返回 None。"""
|
||||||
|
ts = _parse_reset_ts(resets_at)
|
||||||
|
if ts is None:
|
||||||
|
return None
|
||||||
|
dt = _local(ts)
|
||||||
|
return dt.strftime("%H:%M") if window <= 86400 else dt.strftime("%m/%d %H:%M")
|
||||||
|
|
||||||
|
|
||||||
|
# Claude 用量窗口:(快照键,短标签,长标签,窗口长度秒,scoped)。
|
||||||
|
# 短标签 k 给 400x300 小屏(标签列只有 26px);长标签 label 给 800x480 宽版(2026-09-14 去掉 all 前缀,就叫 5h / 7d)。
|
||||||
|
# scoped=True 为「按模型限定」的额度(Fable 7d,来自 /api/oauth/usage 的 limits[] weekly_scoped,由 claude_usage.normalize
|
||||||
|
# 写入快照 seven_day_fable):小屏 render() 跳过;宽版**并入紧邻其前的非 scoped 行**(7d 行拆成上下两条细条:上 = 总 7d,
|
||||||
|
# 下 = fable 7d),使左右两块都是两行、视觉对称(此前三行 vs 两行不对称)。
|
||||||
|
USAGE_WINDOWS = (
|
||||||
|
("five_hour", "5h", "5h", FIVE_HOUR, False),
|
||||||
|
("seven_day", "7d", "7d", SEVEN_DAY, False),
|
||||||
|
("seven_day_fable", "Fable 7d", "fable 7d", SEVEN_DAY, True),
|
||||||
|
)
|
||||||
|
# Codex 用量窗口(ChatGPT 订阅的 Codex 额度池,由 codex_usage.py 经 app-server RPC 取得):只有 5h / 7d 两个窗口。
|
||||||
|
# ChatGPT 普通聊天额度没有账户级读取途径(2026-09 调研结论),故右侧块监控的就是 Codex 池并如实命名「Codex Usage」。
|
||||||
|
# 快照里各窗口可带 window(真实窗口秒数),pace 优先按它算;rateLimitsByLimitId 多桶原样存于 buckets,看到真实数据后再决定是否加第三行。
|
||||||
|
GPT_WINDOWS = (
|
||||||
|
("five_hour", "5h", "5h", FIVE_HOUR, False),
|
||||||
|
("seven_day", "7d", "7d", SEVEN_DAY, False),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def usage_from_raw(now, raw, windows, title):
|
||||||
|
"""原始快照 → 渲染结构(Claude / ChatGPT 通用)。
|
||||||
|
raw 形如 {five_hour:{utilization, resets_at, window?}, seven_day:{...}, <scoped 键>:{...}|null, updated_at?};
|
||||||
|
utilization 为已用百分比,resets_at 为 epoch 秒或 ISO,window 为该窗口真实长度秒(缺省用 windows 表的常量);缺哪个窗口哪个显示 "--"(宽版按 0 画);utilization 为 0 且 resets_at 为 null 的空闲窗口记 time_pct=0,保证 pace 列不缺席。
|
||||||
|
time_pct(时间已流逝%)与 pace 按传入的 now **现算**——所以推送端只需推原始快照,服务端渲染时 pace 仍随时钟准确推进。
|
||||||
|
结构契约(renderer 依赖):{title, warn, ok, pace_tol, updated?, bars:[{k, label, pct(0~100 或 None), time_pct?, reset?, scoped}]};
|
||||||
|
pct ≥ warn 时该条进度条与百分比转红(宽版另按 ok/warn 分 绿/黑/红 三档,百分比随条色);
|
||||||
|
pace = pct − time_pct(>0 超前↑ / <0 节余↓,宽版按 ±pace_tol 分 绿/黑/红 三档);
|
||||||
|
reset 为重置时刻文本;updated 为快照落盘时刻 HH:MM(活跃使用时持续刷新,空闲时停在最后一次)。"""
|
||||||
|
raw = raw or {}
|
||||||
|
bars = []
|
||||||
|
for key, k, label, win, scoped in windows:
|
||||||
|
seg = raw.get(key) or {}
|
||||||
|
util = seg.get("utilization")
|
||||||
|
pct = int(round(float(util))) if util is not None else None
|
||||||
|
bar = {"k": k, "label": label, "pct": pct, "scoped": scoped}
|
||||||
|
win = int(seg.get("window") or win)
|
||||||
|
tp = _window_time_pct(seg.get("resets_at"), win, now)
|
||||||
|
if pct is not None and tp is None and pct == 0:
|
||||||
|
tp = 0 # 窗口空闲(utilization 0 且 resets_at null,5h 窗口重置后常见):窗口尚未开始,已流逝 0%、pace 持平
|
||||||
|
if pct is not None and tp is not None:
|
||||||
|
bar["time_pct"] = tp
|
||||||
|
rs = _fmt_reset(seg.get("resets_at"), win)
|
||||||
|
if rs:
|
||||||
|
bar["reset"] = rs
|
||||||
|
bars.append(bar)
|
||||||
|
out = {"title": title, "warn": config.USAGE_WARN, "ok": config.USAGE_OK, "pace_tol": config.PACE_TOL, "bars": bars}
|
||||||
|
upd = _parse_reset_ts(raw.get("updated_at"))
|
||||||
|
if upd:
|
||||||
|
out["updated"] = _local(upd).strftime("%H:%M")
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _usage(now, raw=None):
|
||||||
|
"""Claude 账户级用量(5h / 7d / Fable 7d)。raw 为 None 时本机经 claude_usage 采集(复用 Claude Code 钥匙串登录态调
|
||||||
|
/api/oauth/usage,带 ≥300s 最小间隔缓存与退避);服务端传入推送来的原始快照。"""
|
||||||
|
if raw is None:
|
||||||
|
raw = {}
|
||||||
|
try:
|
||||||
|
raw = claude_usage.get_usage()
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[警告] Claude 用量读取失败:{e}")
|
||||||
|
return usage_from_raw(now, raw, USAGE_WINDOWS, "Claude Usage")
|
||||||
|
|
||||||
|
|
||||||
|
def gpt_mock_raw(now):
|
||||||
|
"""Codex 原始快照的 mock(服务端尚未收到推送时用):5h 88%(≥warn 红)、7d 22%(≤ok 绿)覆盖两端配色。"""
|
||||||
|
t = now.timestamp()
|
||||||
|
return {"five_hour": {"utilization": 88, "resets_at": t + 2 * 3600},
|
||||||
|
"seven_day": {"utilization": 22, "resets_at": t + 3 * 86400}}
|
||||||
|
|
||||||
|
|
||||||
|
def _usage_gpt(now, raw=None, allow_local=True):
|
||||||
|
"""Codex 用量(ChatGPT 订阅的 Codex 额度池)。raw 为推送来的原始快照(codex_usage 产出,结构同 gpt_mock_raw);
|
||||||
|
服务端未收到推送时(raw=None)用 mock 并标 mock=True(renderer 在标题右侧标「示例数据」)。本机形态直接经 codex_usage 采集。"""
|
||||||
|
mock = raw is None
|
||||||
|
if mock and allow_local:
|
||||||
|
try:
|
||||||
|
raw, mock = codex_usage.get_usage(), False
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[警告] Codex 用量读取失败:{e}")
|
||||||
|
out = usage_from_raw(now, gpt_mock_raw(now) if mock else raw, GPT_WINDOWS, "Codex Usage")
|
||||||
|
if mock:
|
||||||
|
out["mock"] = True
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- 设备遥测(E1002 电量/温度/湿度)
|
||||||
|
def device_empty():
|
||||||
|
return {"battery": None, "charging": False, "temp": None, "humidity": None}
|
||||||
|
|
||||||
|
|
||||||
|
def device_fetch():
|
||||||
|
"""纯拉取(服务端用):返回 (device dict, True);失败抛出。结构 {battery, charging, temp, humidity}。"""
|
||||||
|
return sensecraft_api.get_iot_data(), True
|
||||||
|
|
||||||
|
|
||||||
|
def _device(now):
|
||||||
|
"""本机形态:带 DEVICE_TTL 文件缓存;失败沿用旧缓存,仍无则占位(渲染为 "--")。"""
|
||||||
|
ts = now.timestamp()
|
||||||
|
cached = _read_json_cache(config.DEVICE_CACHE_PATH)
|
||||||
|
if cached and ts - cached.get("ts", 0) < config.DEVICE_TTL:
|
||||||
|
return cached["data"]
|
||||||
|
try:
|
||||||
|
out, _ = device_fetch()
|
||||||
|
_write_json_cache(config.DEVICE_CACHE_PATH, {"ts": ts, "data": out})
|
||||||
|
return out
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[警告] 设备遥测拉取失败:{e}")
|
||||||
|
if cached:
|
||||||
|
return cached["data"]
|
||||||
|
return device_empty()
|
||||||
|
|
||||||
|
|
||||||
|
def _read_json_cache(path):
|
||||||
|
try:
|
||||||
|
with open(path, encoding="utf-8") as f:
|
||||||
|
return json.load(f)
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _write_json_cache(path, obj):
|
||||||
|
try:
|
||||||
|
with open(path, "w", encoding="utf-8") as f:
|
||||||
|
json.dump(obj, f, ensure_ascii=False)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[警告] 缓存写入失败 {path}:{e}")
|
||||||
|
|
||||||
|
|
||||||
def get_dashboard_data(now=None):
|
def get_dashboard_data(now=None):
|
||||||
"""返回渲染所需的完整数据字典。"""
|
"""返回渲染所需的完整数据字典(本机脚本形态:带文件缓存,用量读本地快照)。"""
|
||||||
now = now or datetime.now()
|
now = now or now_tz()
|
||||||
return {
|
return {
|
||||||
"date": {
|
"date": {
|
||||||
"greg": now.strftime("%Y/%m/%d"),
|
"greg": now.strftime("%Y/%m/%d"),
|
||||||
@@ -177,4 +456,8 @@ def get_dashboard_data(now=None):
|
|||||||
},
|
},
|
||||||
"weather": _weather(now),
|
"weather": _weather(now),
|
||||||
"mao": _mao(now),
|
"mao": _mao(now),
|
||||||
|
"usage": _usage(now),
|
||||||
|
"usage_gpt": _usage_gpt(now),
|
||||||
|
"btc": _btc(now),
|
||||||
|
"device": _device(now),
|
||||||
}
|
}
|
||||||
|
|||||||
185
html_renderer.py
Normal file
185
html_renderer.py
Normal file
@@ -0,0 +1,185 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
方案二:800x480 的**现代网页**版仪表盘(server.py 的 HTML_PATH 每次请求现生成),供 SenseCraft HTML 控件用无头浏览器截图。
|
||||||
|
|
||||||
|
与 PNG 版(renderer_e1002)的差别:
|
||||||
|
- 不限点阵字体:系统中文字体栈(PingFang / Microsoft YaHei / Noto Sans SC)+ Google Fonts 的 Noto Sans SC 兜底
|
||||||
|
(平台截图机若无中文字体会掉字,网络字体是保险;能否加载取决于其出网能力)。
|
||||||
|
- 纯 HTML+CSS+内联 SVG,**不依赖 JavaScript**(平台是否执行 JS 未证实),数据全部服务端写死进 HTML。
|
||||||
|
- 颜色只用六色纯值 #000/#fff/#f00/#0f0(黄/蓝未用),抗锯齿边缘交给平台抖动——这正是要和 PNG 版对比的点。
|
||||||
|
- 版式与 PNG 版同构:顶部时钟/天气;左 Claude Usage、右 Codex Usage;橘喵今日实时三列;BTC 时 K(SVG)。
|
||||||
|
- 配色规则与 PNG 版一致:用量条 ≥warn 红 / ≤ok 绿 / 其间黑;pace ≥+tol 红 / ≤−tol 绿 / 其间黑;环比同比 涨绿 跌红(与 K 线一致);K 线 涨绿 跌红。
|
||||||
|
"""
|
||||||
|
from html import escape as _e
|
||||||
|
|
||||||
|
RED, GREEN, BLACK = "#f00", "#0f0", "#000"
|
||||||
|
|
||||||
|
_CSS = """
|
||||||
|
*{box-sizing:border-box;margin:0;padding:0}
|
||||||
|
html,body{width:800px;height:480px;overflow:hidden;background:#fff;color:#000;
|
||||||
|
font-family:"PingFang SC","Microsoft YaHei","Noto Sans SC","Helvetica Neue",Arial,sans-serif;
|
||||||
|
-webkit-font-smoothing:antialiased}
|
||||||
|
.page{width:800px;height:480px;padding:12px 16px 10px;display:flex;flex-direction:column;gap:6px}
|
||||||
|
.hd{display:flex;justify-content:space-between;align-items:flex-end;height:92px;border-bottom:2px solid #000;padding-bottom:6px}
|
||||||
|
.clock{font-size:72px;font-weight:800;line-height:1;letter-spacing:-2px;font-variant-numeric:tabular-nums}
|
||||||
|
.date{font-size:15px;margin-top:4px;color:#000}
|
||||||
|
.wx{text-align:right;display:flex;align-items:flex-end;gap:14px}
|
||||||
|
.wx .t{font-size:44px;font-weight:800;line-height:1}
|
||||||
|
.wx .c{font-size:15px;line-height:1.45}
|
||||||
|
.wx .loc{font-size:13px;color:#000}
|
||||||
|
.pin{display:inline-block;width:9px;height:9px;border-radius:50% 50% 50% 0;background:#f00;transform:rotate(-45deg);margin-right:4px;vertical-align:-1px}
|
||||||
|
.row{display:flex;gap:16px}
|
||||||
|
.card{flex:1;min-width:0}
|
||||||
|
.card h3{font-size:14px;font-weight:700;display:flex;justify-content:space-between;align-items:baseline;margin-bottom:6px}
|
||||||
|
.card h3 em{font-style:normal;font-weight:400;font-size:12px}
|
||||||
|
.card h3 b::before{content:"";display:inline-block;width:9px;height:9px;background:#000;margin-right:6px;vertical-align:-1px}
|
||||||
|
.u{display:grid;grid-template-columns:64px 1fr 58px 44px;align-items:center;column-gap:8px;height:26px}
|
||||||
|
.u .k{font-size:13px;font-weight:700;font-family:"SF Mono",Menlo,Consolas,monospace}
|
||||||
|
.bar{height:11px;border:1.5px solid #000;position:relative;background:#fff}
|
||||||
|
.bar i{position:absolute;left:0;top:0;bottom:0;background:#000}
|
||||||
|
.u .p{font-size:19px;font-weight:800;text-align:right;font-variant-numeric:tabular-nums;line-height:1}
|
||||||
|
.u .pace{font-size:13px;font-weight:700;font-variant-numeric:tabular-nums;white-space:nowrap}
|
||||||
|
.tri{display:inline-block;width:0;height:0;border-left:5px solid transparent;border-right:5px solid transparent;vertical-align:1px;margin-right:2px}
|
||||||
|
.tri.up{border-bottom:8px solid currentColor}.tri.dn{border-top:8px solid currentColor}
|
||||||
|
.rt{border-top:2px solid #000;border-bottom:2px solid #000;padding:6px 0 4px}
|
||||||
|
.rt .cols{display:flex}
|
||||||
|
.rt .col{flex:1;text-align:center;border-left:1px dashed #000;padding:2px 0}
|
||||||
|
.rt .col:first-child{border-left:0}
|
||||||
|
.rt .name{font-size:13px;margin-bottom:2px}
|
||||||
|
.rt .grp{display:inline-flex;align-items:center;gap:12px}
|
||||||
|
.rt .v{font-size:36px;font-weight:800;line-height:1;font-variant-numeric:tabular-nums}
|
||||||
|
.rt .cmp{text-align:left;font-size:12px;line-height:1.5;font-variant-numeric:tabular-nums}
|
||||||
|
.btc h3{margin-bottom:2px}
|
||||||
|
.btc svg{display:block}
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def _tier_bar(bar, usage):
|
||||||
|
"""用量条颜色三档 绿/黑/红(与 renderer_e1002 一致)。返回 (填充/边框色, 百分比文字色),两者同色。"""
|
||||||
|
pct = bar.get("pct")
|
||||||
|
if not isinstance(pct, (int, float)):
|
||||||
|
return BLACK, BLACK
|
||||||
|
c = RED if pct >= usage.get("warn", 80) else GREEN if pct <= usage.get("ok", 30) else BLACK
|
||||||
|
return c, c
|
||||||
|
|
||||||
|
|
||||||
|
def _tier_pace(diff, usage):
|
||||||
|
tol = usage.get("pace_tol", 10)
|
||||||
|
return RED if diff >= tol else GREEN if diff <= -tol else BLACK
|
||||||
|
|
||||||
|
|
||||||
|
def _usage_card(usage):
|
||||||
|
note = "示例数据" if usage.get("mock") else (f'上次更新 {usage["updated"]}' if usage.get("updated") else "")
|
||||||
|
rows = []
|
||||||
|
for b in usage.get("bars", []):
|
||||||
|
pct = b.get("pct")
|
||||||
|
ok = isinstance(pct, (int, float))
|
||||||
|
fill, ptxt = _tier_bar(b, usage)
|
||||||
|
width = max(0, min(100, int(pct))) if ok else 0
|
||||||
|
pace = ""
|
||||||
|
if ok and "time_pct" in b:
|
||||||
|
diff = int(pct) - int(b["time_pct"])
|
||||||
|
col = _tier_pace(diff, usage)
|
||||||
|
tri = '<i class="tri up"></i>' if diff > 0 else ('<i class="tri dn"></i>' if diff < 0 else "")
|
||||||
|
pace = f'<span class="pace" style="color:{col}">{tri}{abs(diff):02d}</span>'
|
||||||
|
else:
|
||||||
|
pace = '<span class="pace"></span>'
|
||||||
|
rows.append(
|
||||||
|
f'<div class="u"><span class="k">{_e(b.get("label") or b["k"])}</span>'
|
||||||
|
f'<span class="bar" style="border-color:{fill}"><i style="width:{width}%;background:{fill}"></i></span>'
|
||||||
|
f'<span class="p" style="color:{ptxt}">{f"{int(pct):02d}%" if ok else "--"}</span>{pace}</div>')
|
||||||
|
return (f'<section class="card"><h3><b>{_e(usage.get("title", "Usage"))}</b><em>{_e(note)}</em></h3>'
|
||||||
|
+ "".join(rows) + "</section>")
|
||||||
|
|
||||||
|
|
||||||
|
def _cmp(label, pair):
|
||||||
|
direction, val = pair
|
||||||
|
col = GREEN if direction == "up" else RED if direction == "down" else BLACK
|
||||||
|
tri = ('<i class="tri up"></i>' if direction == "up" else '<i class="tri dn"></i>' if direction == "down"
|
||||||
|
else "= " if direction == "flat" else "") # flat:增长率恰为 0,等号表示持平(与 PNG 版一致)
|
||||||
|
return f'<div>{_e(label)} <span style="color:{col}">{tri}{_e(val)}</span></div>'
|
||||||
|
|
||||||
|
|
||||||
|
def _realtime(mao):
|
||||||
|
cols = "".join(
|
||||||
|
f'<div class="col"><div class="name">{_e(c["k"])}</div><div class="grp"><span class="v">{_e(c["v"])}</span>'
|
||||||
|
f'<span class="cmp">{_cmp("环比", c["hb"])}{_cmp("同比", c["tb"])}</span></div></div>'
|
||||||
|
for c in mao["cols"])
|
||||||
|
return (f'<section class="rt card"><h3><b>橘喵·今日实时</b><em>上次更新 {_e(mao["upd"])}</em></h3>'
|
||||||
|
f'<div class="cols">{cols}</div></section>')
|
||||||
|
|
||||||
|
|
||||||
|
def _fmt_price(v):
|
||||||
|
return f"{v:,.0f}" if abs(v) >= 1000 else f"{v:.2f}"
|
||||||
|
|
||||||
|
|
||||||
|
def _btc(btc, W=768, H=118):
|
||||||
|
"""BTC K 线 → 内联 SVG(涨绿跌红),坐标逻辑同 renderer_e1002._btc_chart。"""
|
||||||
|
btc = btc or {}
|
||||||
|
candles = btc.get("candles") or []
|
||||||
|
kind = "时K" if btc.get("bar", "1H") == "1H" else "日K"
|
||||||
|
head = f'<b>{_e(btc.get("symbol") or "BTC/USDT")}·{kind}</b>'
|
||||||
|
right = ""
|
||||||
|
if candles and btc.get("last") is not None:
|
||||||
|
chg = btc.get("chg") or 0.0
|
||||||
|
col = GREEN if chg >= 0 else RED
|
||||||
|
tri = '<i class="tri up"></i>' if chg >= 0 else '<i class="tri dn"></i>'
|
||||||
|
right = (f'<em><strong style="font-size:14px">{_fmt_price(btc["last"])}</strong> {_e(btc.get("chg_label") or "")}'
|
||||||
|
f' <span style="color:{col};font-weight:700">{tri}{abs(chg):.2f}%</span></em>')
|
||||||
|
if not candles:
|
||||||
|
return f'<section class="btc card"><h3>{head}</h3><div style="text-align:center;font-size:24px;padding:30px 0">--</div></section>'
|
||||||
|
LBL, TOP, BOT = 66, 6, 16
|
||||||
|
px0, px1 = 0, W - LBL
|
||||||
|
py0, py1 = TOP, H - BOT
|
||||||
|
hi_raw, lo_raw = max(c["h"] for c in candles), min(c["l"] for c in candles)
|
||||||
|
pad = (hi_raw - lo_raw or 1) * 0.04
|
||||||
|
lo, hi = lo_raw - pad, hi_raw + pad
|
||||||
|
Y = lambda v: py1 - (v - lo) / (hi - lo) * (py1 - py0)
|
||||||
|
n = len(candles)
|
||||||
|
slot = (px1 - px0) / n
|
||||||
|
bw = max(3, int(slot * 0.6)) | 1
|
||||||
|
parts = [f'<svg width="{W}" height="{H}" viewBox="0 0 {W} {H}" shape-rendering="crispEdges">',
|
||||||
|
f'<line x1="{px0}" y1="{py1}" x2="{px1}" y2="{py1}" stroke="#000" stroke-dasharray="2 3"/>']
|
||||||
|
for i, c in enumerate(candles):
|
||||||
|
cx = round(px0 + slot * (i + 0.5))
|
||||||
|
col = GREEN if c["c"] >= c["o"] else RED
|
||||||
|
top, bot = sorted((round(Y(c["o"])), round(Y(c["c"]))))
|
||||||
|
parts.append(f'<line x1="{cx}" y1="{round(Y(c["h"]))}" x2="{cx}" y2="{round(Y(c["l"]))}" stroke="{col}"/>')
|
||||||
|
parts.append(f'<rect x="{cx - bw // 2}" y="{top}" width="{bw}" height="{max(1, bot - top)}" fill="{col}"/>')
|
||||||
|
last = candles[-1]["c"]
|
||||||
|
ly = round(Y(last))
|
||||||
|
parts.append(f'<line x1="{px0}" y1="{ly}" x2="{px1}" y2="{ly}" stroke="#000" stroke-dasharray="1 3"/>')
|
||||||
|
hi_y, lo_y = py0 + 5, py1 - 1
|
||||||
|
ty = min(max(ly + 4, hi_y + 13), lo_y - 13)
|
||||||
|
txt = 'font-size="12" font-family="SF Mono,Menlo,Consolas,monospace" text-anchor="end"'
|
||||||
|
parts.append(f'<text x="{W}" y="{hi_y}" {txt}>{_fmt_price(hi_raw)}</text>')
|
||||||
|
parts.append(f'<text x="{W}" y="{lo_y}" {txt}>{_fmt_price(lo_raw)}</text>')
|
||||||
|
parts.append(f'<text x="{W}" y="{ty}" {txt} font-weight="700">{_fmt_price(last)}</text>')
|
||||||
|
lab = lambda c: _e(c.get("t") or c.get("d") or "")
|
||||||
|
ax = 'font-size="11" font-family="SF Mono,Menlo,Consolas,monospace"'
|
||||||
|
parts.append(f'<text x="{px0}" y="{H - 3}" {ax}>{lab(candles[0])}</text>')
|
||||||
|
parts.append(f'<text x="{(px0 + px1) / 2}" y="{H - 3}" {ax} text-anchor="middle">{lab(candles[n // 2])}</text>')
|
||||||
|
parts.append(f'<text x="{px1}" y="{H - 3}" {ax} text-anchor="end">{lab(candles[-1])}</text>')
|
||||||
|
parts.append("</svg>")
|
||||||
|
return f'<section class="btc card"><h3>{head}{right}</h3>{"".join(parts)}</section>'
|
||||||
|
|
||||||
|
|
||||||
|
def render_html(d):
|
||||||
|
"""data dict(同 renderer_e1002.render 的输入)→ 完整 HTML 字符串。"""
|
||||||
|
dt, wt = d["date"], d["weather"]
|
||||||
|
head = (
|
||||||
|
f'<header class="hd"><div><div class="clock">{_e(dt["time"])}</div>'
|
||||||
|
f'<div class="date">{_e(dt["week"])} {_e(dt["greg"])}</div></div>'
|
||||||
|
f'<div class="wx"><div class="c"><div class="loc"><span class="pin"></span>{_e(wt["loc"])}</div>'
|
||||||
|
f'{_e(wt["cond"])}<br>{_e(wt["range"])}</div><div class="t">{_e(wt["temp"])}</div></div></header>')
|
||||||
|
body = (head
|
||||||
|
+ f'<div class="row">{_usage_card(d["usage"])}{_usage_card(d.get("usage_gpt") or {"title": "Codex Usage"})}</div>'
|
||||||
|
+ _realtime(d["mao"])
|
||||||
|
+ _btc(d.get("btc")))
|
||||||
|
return ("<!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\">"
|
||||||
|
"<link rel=\"preconnect\" href=\"https://fonts.gstatic.com\" crossorigin>"
|
||||||
|
"<link href=\"https://fonts.googleapis.com/css2?family=Noto+Sans+SC:wght@400;700;800&display=swap\" rel=\"stylesheet\">"
|
||||||
|
f"<title>eink dashboard</title><style>{_CSS}</style></head><body><div class=\"page\">{body}</div></body></html>")
|
||||||
97
html_shot.py
Normal file
97
html_shot.py
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
方案三:把 html_renderer 生成的网页用**服务端无头 Chrome** 截成 800x480,再**固化为六色 PNG**(最近色、不抖动)。
|
||||||
|
|
||||||
|
为什么需要它:SenseCraft 抓 HTML 时是平台自己截图再量化,文字抗锯齿的灰边会被它抖动成噪点、细笔画被打断(真机实拍已确认)。
|
||||||
|
在我们这一侧先截图、先量化,交给平台的就是纯六色图,平台无从抖动——效果对齐 PNG 方案,同时保留现代字体与网页排版。
|
||||||
|
|
||||||
|
量化策略(不抖动):
|
||||||
|
1. 每通道二值化:v < HTML_PNG_THRESHOLD → 0,否则 → 255。阈值默认 160(偏向保墨迹):抗锯齿的中灰边缘归黑,
|
||||||
|
细笔画不会因为「不够黑」而消失;纯红/绿/黄/蓝/黑/白本身各通道就是 0/255,不受影响。
|
||||||
|
2. 二值化后像素落在 {0,255}^3 的 8 个角,其中青/品/其它非六色角极少见(只可能出现在彩色抗锯齿边缘),
|
||||||
|
再用 Pillow 的调色板量化(dither=NONE)映射到最近的六色。
|
||||||
|
→ 输出保证只含 SPECTRA6 六色。
|
||||||
|
|
||||||
|
无头 Chrome 只能把截图写到文件,这里用临时文件并立刻删除(Linux 下 /tmp 通常是 tmpfs,不落磁盘);
|
||||||
|
若想彻底零文件需改用 Playwright/CDP,本项目不引第三方依赖,先不做。
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
import threading
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
import config
|
||||||
|
from renderer_e1002 import SPECTRA6
|
||||||
|
|
||||||
|
_CANDIDATES = (
|
||||||
|
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
|
||||||
|
"/Applications/Chromium.app/Contents/MacOS/Chromium",
|
||||||
|
"google-chrome", "google-chrome-stable", "chromium", "chromium-browser", "chrome",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def find_chrome():
|
||||||
|
if config.CHROME_BIN:
|
||||||
|
return config.CHROME_BIN
|
||||||
|
for c in _CANDIDATES:
|
||||||
|
if os.path.isabs(c):
|
||||||
|
if os.path.exists(c):
|
||||||
|
return c
|
||||||
|
elif shutil.which(c):
|
||||||
|
return shutil.which(c)
|
||||||
|
raise RuntimeError("未找到 Chrome/Chromium,请配置 CHROME_BIN")
|
||||||
|
|
||||||
|
|
||||||
|
_SHOT_LOCK = threading.Lock() # 无头 Chrome 共用默认 profile,截图串行化避免互相干扰
|
||||||
|
|
||||||
|
|
||||||
|
def screenshot_html(html: str, size=(800, 480), timeout=40) -> Image.Image:
|
||||||
|
"""HTML 字符串 → 无头 Chrome 截图(RGB Image)。临时文件用后即删。
|
||||||
|
注意不要传 --user-data-dir 指向全新目录:新版 --headless=new 对空 profile 的首启流程会卡死到超时(实测)。"""
|
||||||
|
chrome = find_chrome()
|
||||||
|
tmpdir = tempfile.mkdtemp(prefix="eink-shot-")
|
||||||
|
html_path = os.path.join(tmpdir, "page.html")
|
||||||
|
png_path = os.path.join(tmpdir, "shot.png")
|
||||||
|
try:
|
||||||
|
with open(html_path, "w", encoding="utf-8") as f:
|
||||||
|
f.write(html)
|
||||||
|
cmd = [chrome, "--headless=new", "--disable-gpu", "--hide-scrollbars", "--force-device-scale-factor=1",
|
||||||
|
f"--window-size={size[0]},{size[1]}", "--virtual-time-budget=6000", f"--screenshot={png_path}"]
|
||||||
|
if config.CHROME_EXTRA_ARGS:
|
||||||
|
cmd += config.CHROME_EXTRA_ARGS.split()
|
||||||
|
cmd.append("file://" + html_path)
|
||||||
|
with _SHOT_LOCK:
|
||||||
|
subprocess.run(cmd, check=True, timeout=timeout, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||||
|
with Image.open(png_path) as im:
|
||||||
|
return im.convert("RGB").copy()
|
||||||
|
finally:
|
||||||
|
shutil.rmtree(tmpdir, ignore_errors=True)
|
||||||
|
|
||||||
|
|
||||||
|
def quantize6(img: Image.Image, threshold=None) -> Image.Image:
|
||||||
|
"""RGB → 六色纯色(最近色、不抖动)。见模块文档。"""
|
||||||
|
t = config.HTML_PNG_THRESHOLD if threshold is None else threshold
|
||||||
|
lut = [0 if v < t else 255 for v in range(256)] * 3
|
||||||
|
bin_img = img.convert("RGB").point(lut)
|
||||||
|
pal = Image.new("P", (1, 1))
|
||||||
|
flat = [c for rgb in SPECTRA6 for c in rgb]
|
||||||
|
pal.putpalette(flat + [0] * (768 - len(flat)))
|
||||||
|
q = bin_img.quantize(palette=pal, dither=Image.Dither.NONE)
|
||||||
|
return q.convert("RGB")
|
||||||
|
|
||||||
|
|
||||||
|
def html_to_png6(html: str) -> Image.Image:
|
||||||
|
return quantize6(screenshot_html(html))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
import sys, data, html_renderer
|
||||||
|
d = data.get_dashboard_data()
|
||||||
|
im = html_to_png6(html_renderer.render_html(d))
|
||||||
|
out = sys.argv[1] if len(sys.argv) > 1 else "output/e1002/web6.png"
|
||||||
|
os.makedirs(os.path.dirname(out), exist_ok=True)
|
||||||
|
im.save(out)
|
||||||
|
print(out, im.size, sorted(c for _, c in im.getcolors(64)))
|
||||||
27
main.py
27
main.py
@@ -4,8 +4,12 @@
|
|||||||
入口 —— 取数据 → 渲染 → 推送到设备。
|
入口 —— 取数据 → 渲染 → 推送到设备。
|
||||||
|
|
||||||
用法:
|
用法:
|
||||||
python3 main.py # 渲染并推送(设备 ID 取自 .env 的 ZECTRIX_DEVICE_ID)
|
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/,
|
||||||
|
# 不推送(静态部署形态;动态形态见 server.py)
|
||||||
|
python3 main.py --target push # 本机采集 Claude(钥匙串登录态 → /api/oauth/usage)与 Codex(codex app-server RPC)
|
||||||
|
# 两路账户级用量并推送到 server.py(PUSH_URL + PUSH_TOKEN),服务端渲染时现算 pace
|
||||||
|
|
||||||
配置统一从同目录 .env 读取(见 .env.example),真实环境变量优先。
|
配置统一从同目录 .env 读取(见 .env.example),真实环境变量优先。
|
||||||
"""
|
"""
|
||||||
@@ -19,9 +23,26 @@ 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", "push"), default="miao",
|
||||||
|
help="miao=喵喵固件 400x300 三色屏(默认,局域网推送);e1002=reTerminal E1002 800x480 六色(产出静态 HTML 页);"
|
||||||
|
"push=本机采集 Claude/Codex 账户级用量并推送到 server.py")
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
if args.target == "push":
|
||||||
|
from push_client import push_usage
|
||||||
|
push_usage()
|
||||||
|
return
|
||||||
|
|
||||||
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}")
|
||||||
|
|
||||||
@@ -29,8 +50,8 @@ def main():
|
|||||||
return
|
return
|
||||||
|
|
||||||
from pusher import push_image
|
from pusher import push_image
|
||||||
device_id, resp = push_image(path)
|
host, resp = push_image(path)
|
||||||
print(f"[推送] 设备 {device_id} 成功:{resp.get('data')}")
|
print(f"[推送] 设备 {host} 成功:{resp}")
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
142
probe_server.py
Normal file
142
probe_server.py
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
验证工具 —— SenseCraft HMI 的 Image/HTML 控件对**同一 URL** 是否每个刷新周期都重新抓取。
|
||||||
|
|
||||||
|
原理:本地起一个极简 HTTP 服务,固定路径 /probe.png 每 --every 秒重绘一张 800x480 探针图
|
||||||
|
(大号生成时刻 HH:MM:SS + 递增序号 + 六色校准块),响应头 Cache-Control: no-store;
|
||||||
|
每次有人请求就在终端打一行日志(时间、路径、User-Agent、来源 IP,含经隧道透传的 CF-Connecting-IP)。
|
||||||
|
|
||||||
|
判定:
|
||||||
|
- 终端日志每个设备刷新周期都出现一次 /probe.png 请求,且设备画面上的时刻/序号随之推进 → **每周期重抓,方案二成立**;
|
||||||
|
此后只要不断覆盖同一 URL 指向的文件即可,无需再经 HTML 页。
|
||||||
|
- 日志只在「部署」那一刻出现一次、之后再无请求 → 平台只在部署时抓一次并缓存,方案二不成立。
|
||||||
|
- 也可同时把 /index.html(HTML 控件方案)放到另一台画布对照。
|
||||||
|
|
||||||
|
用法:
|
||||||
|
python3 probe_server.py [--port 8787] [--every 60]
|
||||||
|
# 另开终端暴露成公网 HTTPS(云端抓不到局域网/明文 HTTP)。cloudflared 快速隧道无需账号:
|
||||||
|
brew install cloudflared
|
||||||
|
cloudflared tunnel --url http://localhost:8787
|
||||||
|
# 把打印出的 https://xxxx.trycloudflare.com/probe.png 填入 SenseCraft 画布的 Image 控件(铺满 800x480)→ 部署。
|
||||||
|
"""
|
||||||
|
import argparse
|
||||||
|
import io
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from datetime import datetime
|
||||||
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||||
|
|
||||||
|
from PIL import Image, ImageDraw, ImageFont
|
||||||
|
|
||||||
|
import config
|
||||||
|
|
||||||
|
BLACK, WHITE, RED = (0, 0, 0), (255, 255, 255), (255, 0, 0)
|
||||||
|
SIX = [BLACK, WHITE, (255, 255, 0), RED, (0, 255, 0), (0, 0, 255)]
|
||||||
|
|
||||||
|
_state = {"png": b"", "seq": 0, "stamp": ""}
|
||||||
|
_lock = threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
|
def render_probe(seq, now):
|
||||||
|
"""800x480 探针图:序号(左上)/ 大号时刻(居中,F96)/ 日期(下)/ 底部六色块。全部纯色。"""
|
||||||
|
img = Image.new("RGB", (800, 480), WHITE)
|
||||||
|
d = ImageDraw.Draw(img)
|
||||||
|
d.fontmode = "1"
|
||||||
|
f24 = ImageFont.truetype(config.FONT_PATH, 24)
|
||||||
|
f96 = ImageFont.truetype(config.FONT_PATH, 96)
|
||||||
|
d.text((20, 16), f"probe #{seq:04d}", font=f24, fill=BLACK)
|
||||||
|
d.text((20, 48), "SenseCraft re-fetch test / 同一 URL 每周期是否重抓", font=f24, fill=BLACK)
|
||||||
|
t = now.strftime("%H:%M:%S")
|
||||||
|
w = d.textlength(t, font=f96)
|
||||||
|
for dx in range(3): # 伪粗体
|
||||||
|
d.text(((800 - w) / 2 + dx, 150), t, font=f96, fill=RED)
|
||||||
|
ds = now.strftime("%Y/%m/%d %A")
|
||||||
|
d.text(((800 - d.textlength(ds, font=f24)) / 2, 290), ds, font=f24, fill=BLACK)
|
||||||
|
d.text((20, 400), "画面上的时刻若随设备刷新周期推进 → 平台每周期重新抓取了同一 URL", font=f24, fill=BLACK)
|
||||||
|
n = len(SIX)
|
||||||
|
for i, c in enumerate(SIX):
|
||||||
|
d.rectangle([round(800 / n * i), 456, round(800 / n * (i + 1)) - 1, 479], fill=c)
|
||||||
|
buf = io.BytesIO()
|
||||||
|
img.save(buf, "PNG")
|
||||||
|
return buf.getvalue()
|
||||||
|
|
||||||
|
|
||||||
|
def regenerate(every):
|
||||||
|
while True:
|
||||||
|
now = datetime.now()
|
||||||
|
with _lock:
|
||||||
|
_state["seq"] += 1
|
||||||
|
_state["png"] = render_probe(_state["seq"], now)
|
||||||
|
_state["stamp"] = now.strftime("%H:%M:%S")
|
||||||
|
print(f"[{now:%H:%M:%S}] 重绘 probe.png seq={_state['seq']}", flush=True)
|
||||||
|
time.sleep(every)
|
||||||
|
|
||||||
|
|
||||||
|
_INDEX = """<!doctype html><html><head><meta charset="utf-8">
|
||||||
|
<meta http-equiv="Cache-Control" content="no-store"><title>probe</title>
|
||||||
|
<style>html,body{margin:0;width:800px;height:480px;overflow:hidden;background:#fff}
|
||||||
|
img{display:block;width:800px;height:480px;image-rendering:pixelated}</style></head>
|
||||||
|
<body><img src="/probe.png" width="800" height="480"></body></html>"""
|
||||||
|
|
||||||
|
|
||||||
|
class Handler(BaseHTTPRequestHandler):
|
||||||
|
server_version = "probe/1.0"
|
||||||
|
|
||||||
|
def log_message(self, fmt, *args): # 关闭默认日志,用自定义格式
|
||||||
|
pass
|
||||||
|
|
||||||
|
def _log(self, code):
|
||||||
|
ua = self.headers.get("User-Agent", "-")
|
||||||
|
ip = self.headers.get("CF-Connecting-IP") or self.headers.get("X-Forwarded-For") or self.client_address[0]
|
||||||
|
print(f"[{datetime.now():%H:%M:%S}] {code} GET {self.path} ip={ip} ua={ua}", flush=True)
|
||||||
|
|
||||||
|
def _send(self, code, ctype, body):
|
||||||
|
self.send_response(code)
|
||||||
|
self.send_header("Content-Type", ctype)
|
||||||
|
self.send_header("Content-Length", str(len(body)))
|
||||||
|
self.send_header("Cache-Control", "no-store, no-cache, must-revalidate, max-age=0")
|
||||||
|
self.send_header("Pragma", "no-cache")
|
||||||
|
self.send_header("Expires", "0")
|
||||||
|
self.send_header("Access-Control-Allow-Origin", "*")
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(body)
|
||||||
|
|
||||||
|
def do_GET(self):
|
||||||
|
path = self.path.split("?", 1)[0]
|
||||||
|
if path == "/probe.png":
|
||||||
|
with _lock:
|
||||||
|
body = _state["png"]
|
||||||
|
self._log(200)
|
||||||
|
return self._send(200, "image/png", body)
|
||||||
|
if path in ("/", "/index.html"):
|
||||||
|
self._log(200)
|
||||||
|
return self._send(200, "text/html; charset=utf-8", _INDEX.encode("utf-8"))
|
||||||
|
if path == "/favicon.ico":
|
||||||
|
return self._send(204, "image/x-icon", b"")
|
||||||
|
self._log(404)
|
||||||
|
self._send(404, "text/plain; charset=utf-8", b"not found")
|
||||||
|
|
||||||
|
do_HEAD = do_GET
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
ap = argparse.ArgumentParser(description="SenseCraft 同 URL 重抓验证服务")
|
||||||
|
ap.add_argument("--port", type=int, default=8787)
|
||||||
|
ap.add_argument("--every", type=int, default=60, help="探针图重绘间隔(秒)")
|
||||||
|
args = ap.parse_args()
|
||||||
|
threading.Thread(target=regenerate, args=(args.every,), daemon=True).start()
|
||||||
|
while not _state["png"]:
|
||||||
|
time.sleep(0.05)
|
||||||
|
srv = ThreadingHTTPServer(("0.0.0.0", args.port), Handler)
|
||||||
|
print(f"探针服务已启动:http://localhost:{args.port}/probe.png (HTML 对照页:/index.html)")
|
||||||
|
print("下一步:另开终端执行 cloudflared tunnel --url http://localhost:%d ,把得到的 https 地址 + /probe.png 填入 Image 控件。" % args.port)
|
||||||
|
print("观察本终端:每个设备刷新周期是否出现一条 GET /probe.png。Ctrl+C 退出。", flush=True)
|
||||||
|
try:
|
||||||
|
srv.serve_forever()
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
74
push_client.py
Normal file
74
push_client.py
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
推送端(本机运行)—— 在本机采集两路账户级 AI 用量,POST 到 server.py;服务端暂存、渲染时现算 pace。
|
||||||
|
|
||||||
|
推什么(形态 A:凭证与采集都留在本机,服务端不持任何凭证、不读本机文件):
|
||||||
|
- Claude:claude_usage.get_usage()(复用 Claude Code 钥匙串登录态调 /api/oauth/usage)→ POST /push/usage
|
||||||
|
- Codex:codex_usage.get_usage()(`codex app-server` RPC,独立 CODEX_HOME 的 ChatGPT 登录)→ POST /push/usage_gpt
|
||||||
|
两路彼此独立:一路失败不影响另一路;两路都失败才以非零码退出。采集器自带 ≥300s 最小间隔缓存,
|
||||||
|
所以与 main.py(小屏渲染)在同一分钟内先后运行也只打一次接口。
|
||||||
|
鉴权:请求头 X-Push-Token = PUSH_TOKEN。网络:直连(不走代理),连接类异常重试 2 次。
|
||||||
|
调度:与喵喵设备推送同节律,由 launchd 每 5 分钟跑一次 `main.py --target push`(run.sh 末尾)。
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
import claude_usage
|
||||||
|
import codex_usage
|
||||||
|
import config
|
||||||
|
|
||||||
|
RETRY_TIMES, RETRY_DELAY, TIMEOUT = 2, 2, 10
|
||||||
|
|
||||||
|
|
||||||
|
def _post_json(url, payload):
|
||||||
|
body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
|
||||||
|
req = urllib.request.Request(url, data=body, method="POST", headers={
|
||||||
|
"Content-Type": "application/json; charset=utf-8",
|
||||||
|
"X-Push-Token": config.PUSH_TOKEN,
|
||||||
|
"User-Agent": "eink-push/push_client",
|
||||||
|
})
|
||||||
|
opener = urllib.request.build_opener(urllib.request.ProxyHandler({}))
|
||||||
|
last = None
|
||||||
|
for i in range(RETRY_TIMES + 1):
|
||||||
|
try:
|
||||||
|
with opener.open(req, timeout=TIMEOUT) as resp:
|
||||||
|
return json.loads(resp.read().decode("utf-8"))
|
||||||
|
except urllib.error.HTTPError as e: # 4xx/5xx 不重试,直接报出服务端的说明
|
||||||
|
raise RuntimeError(f"HTTP {e.code}: {e.read().decode('utf-8', 'replace')[:200]}") from e
|
||||||
|
except urllib.error.URLError as e: # 连接类:重试
|
||||||
|
last = e
|
||||||
|
if i < RETRY_TIMES:
|
||||||
|
time.sleep(RETRY_DELAY)
|
||||||
|
raise RuntimeError(f"连接失败:{last}")
|
||||||
|
|
||||||
|
|
||||||
|
SOURCES = (
|
||||||
|
("Claude", claude_usage.get_usage, "/push/usage"),
|
||||||
|
("Codex", codex_usage.get_usage, "/push/usage_gpt"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def push_usage():
|
||||||
|
if not config.PUSH_URL or not config.PUSH_TOKEN:
|
||||||
|
raise SystemExit("[推送] 需在 .env 配置 PUSH_URL 与 PUSH_TOKEN")
|
||||||
|
base = config.PUSH_URL.rstrip("/")
|
||||||
|
ok = 0
|
||||||
|
for name, getter, path in SOURCES:
|
||||||
|
try:
|
||||||
|
raw = getter()
|
||||||
|
status = raw.pop("status", "")
|
||||||
|
resp = _post_json(base + path, raw)
|
||||||
|
print(f"[推送] {name} 用量({status})已推送到 {base}{path}:{resp}")
|
||||||
|
ok += 1
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[推送] {name} 用量跳过:{e}")
|
||||||
|
if ok == 0:
|
||||||
|
raise SystemExit(1)
|
||||||
|
return ok
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
push_usage()
|
||||||
79
pusher.py
79
pusher.py
@@ -1,65 +1,62 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
"""
|
"""
|
||||||
推送层 —— 调用 Zectrix 极趣云平台 API(仅用标准库 urllib,无第三方依赖)。
|
推送层 —— 直连局域网墨水屏设备(喵喵固件,仅用标准库 urllib,无第三方依赖)。
|
||||||
文档:显示推送 / 设备管理。
|
固件开源:https://gitee.com/gxp666111/miaomiao(web/ 为设备自带管理页,可在浏览器打开
|
||||||
|
http://<EPD_HOST>/ 调试同一套接口)。
|
||||||
"""
|
"""
|
||||||
import json
|
import json
|
||||||
import uuid
|
import time
|
||||||
|
import urllib.parse
|
||||||
import urllib.request
|
import urllib.request
|
||||||
import urllib.error
|
import urllib.error
|
||||||
|
|
||||||
import config
|
import config
|
||||||
|
|
||||||
|
# 局域网 mDNS(.local)域名解析基于组播 UDP,偶发瞬时失败正常,重试即可恢复。
|
||||||
|
RETRY_TIMES = 2
|
||||||
|
RETRY_DELAY = 2
|
||||||
|
|
||||||
|
|
||||||
def _request(method, path, headers=None, body=None):
|
def _request(method, path, headers=None, body=None):
|
||||||
url = f"{config.API_BASE}{path}"
|
url = f"http://{config.EPD_HOST}{path}"
|
||||||
h = {"X-API-Key": config.API_KEY}
|
req = urllib.request.Request(url, data=body, headers=headers or {}, method=method)
|
||||||
if headers:
|
for attempt in range(RETRY_TIMES + 1):
|
||||||
h.update(headers)
|
|
||||||
req = urllib.request.Request(url, data=body, headers=h, method=method)
|
|
||||||
try:
|
try:
|
||||||
with urllib.request.urlopen(req, timeout=30) as resp:
|
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:
|
except urllib.error.HTTPError as e:
|
||||||
raise RuntimeError(f"HTTP {e.code}: {e.read().decode('utf-8', 'ignore')}") from e
|
raise RuntimeError(f"HTTP {e.code}: {e.read().decode('utf-8', 'ignore')}") from e
|
||||||
|
except urllib.error.URLError:
|
||||||
|
if attempt == RETRY_TIMES:
|
||||||
|
raise
|
||||||
|
time.sleep(RETRY_DELAY)
|
||||||
|
|
||||||
|
|
||||||
def resolve_device_id():
|
def _list_gallery_names():
|
||||||
"""返回目标设备 ID:直接取 config.DEVICE_ID(硬件 MAC),未配置则报错。"""
|
text = _request("GET", "/images")
|
||||||
if not config.DEVICE_ID:
|
return [item["name"] for item in json.loads(text).get("items", [])]
|
||||||
raise RuntimeError("未配置设备 ID:请在 .env 设置 ZECTRIX_DEVICE_ID=<硬件 MAC>")
|
|
||||||
return config.DEVICE_ID
|
|
||||||
|
|
||||||
|
|
||||||
def _multipart(fields, files):
|
def _clear_gallery():
|
||||||
"""构造 multipart/form-data 请求体。fields: dict[str,str]; files: list[(name, filename, bytes, mime)]"""
|
"""设备画廊按追加保存,用完即删,使其始终只留最新一张。
|
||||||
boundary = uuid.uuid4().hex
|
清理是锦上添花,不是推送的必要条件——拉列表或单张删除失败都不能阻塞后续上传。"""
|
||||||
crlf = b"\r\n"
|
try:
|
||||||
buf = bytearray()
|
names = _list_gallery_names()
|
||||||
for k, v in fields.items():
|
except Exception as e:
|
||||||
buf += b"--" + boundary.encode() + crlf
|
print(f"[推送] 拉取画廊列表失败,跳过本次清理:{e}")
|
||||||
buf += f'Content-Disposition: form-data; name="{k}"'.encode() + crlf + crlf
|
return
|
||||||
buf += str(v).encode() + crlf
|
for name in names:
|
||||||
for name, filename, content, mime in files:
|
try:
|
||||||
buf += b"--" + boundary.encode() + crlf
|
_request("POST", f"/delete_image?name={urllib.parse.quote(name)}")
|
||||||
buf += f'Content-Disposition: form-data; name="{name}"; filename="{filename}"'.encode() + crlf
|
except Exception as e:
|
||||||
buf += f"Content-Type: {mime}".encode() + crlf + crlf
|
print(f"[推送] 清理画廊旧图 {name} 失败:{e}")
|
||||||
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):
|
def push_image(image_path):
|
||||||
"""推送图片到设备显示。"""
|
"""推送图片到局域网墨水屏设备:先清空画廊旧图,再上传新图触发显示刷新。"""
|
||||||
device_id = device_id or resolve_device_id()
|
_clear_gallery()
|
||||||
with open(image_path, "rb") as fp:
|
with open(image_path, "rb") as fp:
|
||||||
content = fp.read()
|
content = fp.read()
|
||||||
fields = {"dither": "true" if dither else "false", "pageId": str(page_id)}
|
resp = _request("POST", "/upload", headers={"Content-Type": "image/png"}, body=content)
|
||||||
files = [("images", "dashboard.png", content, "image/png")]
|
return config.EPD_HOST, resp
|
||||||
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
|
|
||||||
|
|||||||
338
renderer.py
338
renderer.py
@@ -1,58 +1,110 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
"""
|
"""
|
||||||
渲染层 —— 将数据绘制成 400x300 纯黑白墨水屏仪表盘。
|
渲染层 —— 将数据绘制成 400x300 三色(黑/白/红)墨水屏仪表盘。
|
||||||
|
|
||||||
设计要点:
|
设计要点:
|
||||||
- 纯黑白(设备无真灰阶,灰阶只能抖动成网点,弃用);用构图 + 字号 + 留白建立层级。
|
- 三色 BWR:仍无真灰阶(灰阶只能抖动成网点,弃用);底色黑白,靠留白 + 大字号 + 一处大红块建层级。
|
||||||
|
克制而有分量:全图只一个「大红」焦点(用量超阈值的整条红进度条),其余红为小面积语义点缀。
|
||||||
|
- 关键墨水屏配色铁律:红与黑同属「深色」,红叠黑对比极低 → 红只出现在**白底**(红字/红三角/红块),
|
||||||
|
**绝不红字压黑底**;白底红块 + 白字最稳。
|
||||||
- Fusion Pixel 点阵字体,按整数倍尺寸(12/24/48)渲染,fontmode='1' 关闭抗锯齿 → 像素锐利。
|
- Fusion Pixel 点阵字体,按整数倍尺寸(12/24/48)渲染,fontmode='1' 关闭抗锯齿 → 像素锐利。
|
||||||
- 重点数据用「伪粗体」(偏移叠绘)加粗,仍对齐像素网格。
|
- 重点数据用「伪粗体」(偏移叠绘)加粗,仍对齐像素网格。
|
||||||
布局:上=大时钟+日期 / 天气;中=橘喵今日经营三列;下=近30天三指标趋势折线图。
|
布局(自上而下三段,两道横规分隔):
|
||||||
|
A 顶部:大时钟+日期(左)/ 天气(右),白底黑字 + 红点缀(定位红水滴、太阳/闪电红);
|
||||||
|
B 中部主角:Claude Usage——每条单行 = 左标签(5h/7d,F13) + 进度条 + 百分比(F16,主数字) + 时间维度对比 pace(F13);
|
||||||
|
pct ≥ 阈值(warn) 时该条进度条(含边框)与百分比整体转红;
|
||||||
|
pace = 用量% − 时间% ,用上/下三角(替代 +/−)表示超前/节余,超前(↑)红、节余(↓)黑;
|
||||||
|
C 底部:橘喵今日实时三列,环比/同比「涨=红·跌=黑」。
|
||||||
|
近30天趋势图暂隐藏(`_trend_chart` 及其依赖 `_styled_polyline`/`_style_sample` 保留、未调用,随时可再挂回)。
|
||||||
|
红出现处:① 定位水滴 ② 天气太阳/闪电 ③ 用量进度条与百分比(≥warn) ④ pace 超前(↑) ⑤ 环比/同比「涨」的三角与数值。
|
||||||
"""
|
"""
|
||||||
import math
|
import math
|
||||||
from PIL import Image, ImageDraw, ImageFont
|
from PIL import Image, ImageDraw, ImageFont
|
||||||
|
|
||||||
import config
|
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:
|
class DashboardRenderer:
|
||||||
def __init__(self, font_path=config.FONT_PATH, size=(config.WIDTH, config.HEIGHT)):
|
# 百分比数字的字符间距(px,乘 k):F16 为 16/12x 非整数倍缩放,数字墨迹无侧边距、相邻紧贴,需手动加距;
|
||||||
|
# 子类若把百分比换成原生 12px 字号(宽版),字形自带侧边距,应覆写为 0。
|
||||||
|
PCT_TRACK = 2
|
||||||
|
# 用量行左标签的字重与对齐:三色版 粗体、左对齐(贴 x0);宽版覆写为 常规字重、右对齐(贴进度条左侧留 LABEL_GAP)。
|
||||||
|
LABEL_BOLD = True
|
||||||
|
LABEL_ALIGN = "l"
|
||||||
|
LABEL_GAP = 8
|
||||||
|
|
||||||
|
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.F24 = ImageFont.truetype(font_path, 24)
|
self.F12 = ImageFont.truetype(font_path, 12 * k)
|
||||||
self.F48 = ImageFont.truetype(font_path, 48)
|
self.F13 = ImageFont.truetype(font_path, 13 * k) # 5h/7d 与 pace 用;接近原生 12px,畸变很小
|
||||||
|
self.F16 = ImageFont.truetype(font_path, 16 * k) # 非整数倍(1.33x):靠 fontmode='1' 保持纯色,笔画略不均可接受
|
||||||
|
self.FPACE = self.F13 # pace 数值字体;与标签解耦,宽版把它换成 24px 而标签仍 12px
|
||||||
|
self.F24 = ImageFont.truetype(font_path, 24 * k)
|
||||||
|
self.F36 = ImageFont.truetype(font_path, 36 * k) # 3 倍整数缩放,仍锐利;宽版今日实时数值用
|
||||||
|
self.F48 = ImageFont.truetype(font_path, 48 * k)
|
||||||
|
|
||||||
# ---------- 基础绘制工具 ----------
|
# ---------- 基础绘制工具 ----------
|
||||||
def _tw(self, s, ft):
|
def _tw(self, s, ft, track=0):
|
||||||
|
# track:额外字符间距(px)。非整数倍缩放字号(如 F16=16/12x)下,数字字形墨迹宽度
|
||||||
|
# 等于步进宽度(无侧边距),相邻字符几乎零间隙,需手动加距才不会紧贴。
|
||||||
|
if track:
|
||||||
|
return sum(self.d.textlength(c, font=ft) for c in s) + track * (len(s) - 1)
|
||||||
return self.d.textlength(s, font=ft)
|
return self.d.textlength(s, font=ft)
|
||||||
|
|
||||||
def _draw(self, x, y, s, ft, fill, bold):
|
def _draw(self, x, y, s, ft, fill, bold, track=0):
|
||||||
# 伪粗体:点阵字体单一字重,按 1~bold px 偏移叠绘加粗笔画
|
# 伪粗体:点阵字体单一字重,按 1~bold px 偏移叠绘加粗笔画
|
||||||
|
if not track:
|
||||||
self.d.text((x, y), s, font=ft, fill=fill)
|
self.d.text((x, y), s, font=ft, fill=fill)
|
||||||
for dx in range(1, bold + 1):
|
for dx in range(1, bold + 1):
|
||||||
self.d.text((x + dx, y), s, font=ft, fill=fill)
|
self.d.text((x + dx, y), s, font=ft, fill=fill)
|
||||||
|
return
|
||||||
|
cx = x
|
||||||
|
for ch in s:
|
||||||
|
self.d.text((cx, y), ch, font=ft, fill=fill)
|
||||||
|
for dx in range(1, bold + 1):
|
||||||
|
self.d.text((cx + dx, y), ch, font=ft, fill=fill)
|
||||||
|
cx += self.d.textlength(ch, font=ft) + track
|
||||||
|
|
||||||
|
def _track(self, ft, bold, track=0):
|
||||||
|
"""实际字距:显式 track 优先;否则「12px 且加粗」自动加 k——12px 点阵字形之间只有 1 列空隙,伪粗体右偏 1px 正好填满、
|
||||||
|
相邻字左右粘连(13/24px 空隙有 2 列所以正常),2026-09-14 用户要求拉开一点点。宽版 F13/F16/FPACE 都映射到 12px,同样命中。"""
|
||||||
|
if track or not bold:
|
||||||
|
return track
|
||||||
|
return self.k if getattr(ft, "size", 0) == 12 * self.k else 0
|
||||||
|
|
||||||
|
def _tw_b(self, s, ft, bold=0, track=0):
|
||||||
|
"""含伪粗体偏移与自动字距的实际占宽(手动推进 x / 居中时用它,别再手写 _tw + bold)。"""
|
||||||
|
return self._tw(s, ft, self._track(ft, bold, track)) + bold
|
||||||
|
|
||||||
def text(self, x, y, s, ft, fill=BLACK, bold=0):
|
def text(self, x, y, s, ft, fill=BLACK, bold=0):
|
||||||
self._draw(x, y, s, ft, fill, bold)
|
self._draw(x, y, s, ft, fill, bold, self._track(ft, bold))
|
||||||
|
|
||||||
def rtext(self, xr, y, s, ft, fill=BLACK, bold=0):
|
def rtext(self, xr, y, s, ft, fill=BLACK, bold=0):
|
||||||
self._draw(xr - self._tw(s, ft) - bold, y, s, ft, fill, bold)
|
self._draw(xr - self._tw_b(s, ft, bold), y, s, ft, fill, bold, self._track(ft, bold))
|
||||||
|
|
||||||
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_b(s, ft, bold) / 2, y, s, ft, fill, bold, self._track(ft, 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):
|
||||||
@@ -63,24 +115,153 @@ class DashboardRenderer:
|
|||||||
self.d.polygon(pts, fill=fill)
|
self.d.polygon(pts, fill=fill)
|
||||||
|
|
||||||
# ---------- 组件 ----------
|
# ---------- 组件 ----------
|
||||||
def _cmp_line(self, cx, y, label, pair, fg=BLACK):
|
def _cmp_w(self, label, pair):
|
||||||
"""环比/同比一行:标签 + 涨跌三角 + 数值,整体居中。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)
|
trw, tgap, vb = (8 * k, 4 * k, k) if direction else (0, 0, 0) # vb:数值伪粗体(占位 -- 不加粗)
|
||||||
x = cx - (lw + gap + trw + tgap + nw) / 2
|
return self._tw(label, self.F12) + 5 * k + trw + tgap + self._tw_b(val, self.F12, vb)
|
||||||
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):
|
def _cmp_line(self, cx, y, label, pair, fg=BLACK):
|
||||||
"""标题栏:■ 标题(左) + 上次更新 HH:mm(右)。"""
|
"""环比/同比一行:标签 + 涨跌三角 + 数值,整体居中于 cx。direction 为 None 时不画三角。"""
|
||||||
sq, sq_y = 6, y + 4
|
self._cmp_at(cx - self._cmp_w(label, pair) / 2, y, label, pair, fg)
|
||||||
self.d.rectangle([10, sq_y, 10 + sq, sq_y + sq], fill=BLACK)
|
|
||||||
self.text(10 + sq + 6, y, title, self.F12)
|
def _cmp_color(self, direction, fg=BLACK):
|
||||||
self.rtext(self.W - 10, y, f"上次更新 {upd}", self.F12)
|
"""环比/同比「三角 + 数值」的颜色钩子:三色版 涨红、其余沿用 fg。宽版覆写。"""
|
||||||
|
return RED if direction == "up" else fg
|
||||||
|
|
||||||
|
def _eq(self, cx, cy, w, fill=BLACK):
|
||||||
|
"""等号「=」:两道横杠各宽 w、厚 k,中间隔 2k,整体以 (cx, cy) 为中心——占三角的位子表示「持平」。
|
||||||
|
用量行 pace(diff=0)与今日实时环比/同比(增长率恰为 0)共用。"""
|
||||||
|
k = self.k
|
||||||
|
for top in (cy - 2 * k, cy + k):
|
||||||
|
self.d.rectangle([cx - w / 2, top, cx + w / 2 - 1, top + k - 1], fill=fill)
|
||||||
|
|
||||||
|
def _cmp_at(self, x, y, label, pair, fg=BLACK):
|
||||||
|
"""环比/同比一行,左对齐于 x(宽版把它放在数值右侧时用)。
|
||||||
|
direction: up 上三角 / down 下三角 / flat 等号(增长率恰为 0,2026-09-14 用户要求,原来 0 也画上三角)/ None 不画。"""
|
||||||
|
direction, val = pair
|
||||||
|
k = self.k
|
||||||
|
lw = self._tw(label, self.F12); gap = 5 * k
|
||||||
|
trw, tgap = (8 * k, 4 * k) if direction else (0, 0)
|
||||||
|
self.text(x, y, label, self.F12, fill=fg); x += lw + gap
|
||||||
|
# 方向语义配色由 _cmp_color 决定(三色版:红涨、跌/无/持平 → 黑;宽版覆写为 涨绿跌红 与 K 线一致);标签始终黑
|
||||||
|
val_color = self._cmp_color(direction, fg)
|
||||||
|
if direction == "flat":
|
||||||
|
self._eq(x + trw / 2, y + 8 * k, 7 * k, fill=val_color); x += trw + tgap # 横杠落在第 6/9 行,中心 8 ≈ 数字墨迹中心 8.5
|
||||||
|
elif direction:
|
||||||
|
# 三角中心对齐 F12 数字墨迹竖直中心(y+8.5),否则箭头偏高
|
||||||
|
self.tri(x + trw / 2, y + 8.5 * k, 7 * k, up=direction == "up", fill=val_color); x += trw + tgap
|
||||||
|
# 数值伪粗体、字号不变(2026-09-14 用户要求);标签常规字重;占位「--」不加粗(加粗会粘成一条长横)
|
||||||
|
self.text(x, y, val, self.F12, fill=val_color, bold=k if direction else 0)
|
||||||
|
|
||||||
|
def _value(self, cx, y, col, bold=1):
|
||||||
|
"""今日实时的数值:主数字 F24 粗,前缀(¥)/后缀(万)F12 小号、与主数字基线对齐(2026-09-14 用户要求「¥ 与 万 字号小一点」),
|
||||||
|
整体水平居中于 cx。col["vp"] 为 (前缀, 主数字, 后缀),缺失时退回整串 col["v"] 按 F24 画。"""
|
||||||
|
pre, num, suf = col.get("vp") or ("", col["v"], "")
|
||||||
|
k = self.k
|
||||||
|
GAP = 1 * k # 小号段与主数字的间距
|
||||||
|
sb = bold # 小号段同样伪粗体,否则挨着粗体主数字显得单薄
|
||||||
|
wp = self._tw_b(pre, self.F12, sb) + GAP if pre else 0
|
||||||
|
ws = GAP + self._tw_b(suf, self.F12, sb) if suf else 0
|
||||||
|
wn = self._tw_b(num, self.F24, bold)
|
||||||
|
x = cx - (wp + wn + ws) / 2.0
|
||||||
|
# 基线对齐:F12/F24 数字墨迹底沿分别为 13k/26k(getbbox),小号段下移其差值
|
||||||
|
ys = y + (self.F24.getbbox("0")[3] - self.F12.getbbox("0")[3])
|
||||||
|
if pre:
|
||||||
|
self.text(x, ys, pre, self.F12, bold=sb); x += wp
|
||||||
|
self.text(x, y, num, self.F24, bold=bold); x += wn
|
||||||
|
if suf:
|
||||||
|
self.text(x + GAP, ys, suf, self.F12, bold=sb)
|
||||||
|
|
||||||
|
def _label(self, y, title, right=None, x0=None, x1=None):
|
||||||
|
"""区块标题(两区块统一,对齐主分支 _title_bar 风格):
|
||||||
|
■ 方块装饰(8px,与 F12 标题墨迹竖直居中) + 标题(F12,常规字重) + 可选右侧信息。
|
||||||
|
x0/x1:标题起点 / 右侧信息右端,默认为页边距 10(宽版并排两块时可各自指定)。"""
|
||||||
|
k = self.k
|
||||||
|
SQ, M = 8 * k, 10 * k # 方块边长 / 左右页边距
|
||||||
|
x0 = M if x0 is None else x0
|
||||||
|
x1 = self.W - M if x1 is None else x1
|
||||||
|
self.d.rectangle([x0, y + 4 * k, x0 + SQ, y + 4 * k + SQ], fill=BLACK) # 中心≈y+8,对齐 F12 墨迹中心(≈y+8.5)
|
||||||
|
self.text(x0 + SQ + 6 * k, y, title, self.F12)
|
||||||
|
if right:
|
||||||
|
self.rtext(x1, y, right, self.F12)
|
||||||
|
|
||||||
|
def _mid(self, x, cy, s, ft, fill=BLACK, bold=0, align="l", track=0):
|
||||||
|
"""按字符串「墨迹竖直中心」对齐到 cy 绘制——跨字号(F12/F24)也能真正居中,
|
||||||
|
避免不同字号按顶部对齐时的视觉错位。align: l 左对齐 / r 右对齐 / c 居中于 x。
|
||||||
|
track:见 _tw,用于给紧贴的字符手动加间距。"""
|
||||||
|
_, t, _, b = ft.getbbox(s) # 该串墨迹在字身框内的上/下沿
|
||||||
|
y = cy - (t + b) / 2.0
|
||||||
|
track = self._track(ft, bold, track)
|
||||||
|
w = self._tw(s, ft, track)
|
||||||
|
if align == "r":
|
||||||
|
x -= w + bold
|
||||||
|
elif align == "c":
|
||||||
|
x -= (w + bold) / 2.0
|
||||||
|
self._draw(x, y, s, ft, fill, bold, track)
|
||||||
|
|
||||||
|
def _pace(self, xr, cy, diff):
|
||||||
|
"""时间维度对比(pace = 用量% − 时间%):>0 超前↑红;<0 节余↓黑;=0 持平。
|
||||||
|
数值(FPACE,默认 F13)两位补零、右端对齐 xr,小三角(6)紧贴其左;均竖直居中 cy。返回整体宽度供左侧定位。
|
||||||
|
diff=0(持平/无数据)三角位画一个与三角等宽的等号「=」(2026-09-14 用户要求,原为留空,后曾用「-」)——pace 列每行等宽,各行进度条右端齐平。"""
|
||||||
|
up = diff > 0
|
||||||
|
color = self._pace_color(diff)
|
||||||
|
txt = f"{abs(int(diff)):02d}"
|
||||||
|
tw = self._tw(txt, self.FPACE, self._track(self.FPACE, self.k)) # 宽版 FPACE=12px 加粗 → 含自动字距
|
||||||
|
self._mid(xr, cy, txt, self.FPACE, fill=color, bold=self.k, align="r")
|
||||||
|
TRI_GAP, TRI = 4 * self.k, 6 * self.k
|
||||||
|
cx = xr - tw - TRI_GAP - TRI / 2
|
||||||
|
if diff != 0:
|
||||||
|
self.tri(cx, cy, TRI, up=up, fill=color)
|
||||||
|
else:
|
||||||
|
self._eq(cx, cy, TRI, fill=color) # 等号与三角等宽
|
||||||
|
return tw + TRI_GAP + TRI
|
||||||
|
|
||||||
|
def _usage_row(self, x0, x1, y, bar, warn, label_w=None):
|
||||||
|
"""一条用量(单行):左标签(5h/7d, F13) + 进度条 + 百分比(F16, 主数字) + 时间维度对比 pace(F13)。
|
||||||
|
右侧从右往左排布,显式控制两处横向间距:G1 进度条↔百分比、G2 百分比↔pace;全部竖直居中 cy。
|
||||||
|
pct ≥ warn → 进度条(含整条边框)与百分比转红;边框随条色,红条即红框,无黑白错位。
|
||||||
|
label_w:标签列宽,默认 26*k(够放 5h/7d);宽版有 "Fable 7d" 这类长标签时由调用方按最长标签算好传入。"""
|
||||||
|
raw = bar.get("pct")
|
||||||
|
ok = isinstance(raw, (int, float)) # None/缺失 → 显示 "--"
|
||||||
|
pct = max(0, min(100, int(raw))) if ok else 0
|
||||||
|
fill, outline, color = self._usage_colors(ok, pct, warn, bar)
|
||||||
|
k = self.k
|
||||||
|
LABEL_W = 26 * k if label_w is None else label_w
|
||||||
|
G1, G2, H = 13 * k, 8 * k, 15 * k # G1 进度条↔百分比、G2 百分比↔pace(视觉约 12/11)
|
||||||
|
PCT_TRACK = self.PCT_TRACK * k # 见类属性 PCT_TRACK
|
||||||
|
cy = y + 10 * k # 行竖直中心
|
||||||
|
lb = k if self.LABEL_BOLD else 0 # 左标签 F13
|
||||||
|
if self.LABEL_ALIGN == "r":
|
||||||
|
self._mid(x0 + LABEL_W - self.LABEL_GAP * k, cy, bar["k"], self.F13, bold=lb, align="r")
|
||||||
|
else:
|
||||||
|
self._mid(x0, cy, bar["k"], self.F13, bold=lb)
|
||||||
|
# 右侧从右往左:pace(最右) → 百分比 → 进度条
|
||||||
|
pct_r = x1
|
||||||
|
if ok:
|
||||||
|
# 有百分比就必画 pace 列:time_pct 缺失(窗口无重置时刻)按 0 差值画 "00",不能整列省掉——否则该行进度条会变长、右端错位
|
||||||
|
pace_w = self._pace(x1, cy, pct - int(bar["time_pct"]) if "time_pct" in bar else 0)
|
||||||
|
pct_r = x1 - pace_w - G2
|
||||||
|
pct_txt = f"{pct:02d}%" if ok else "--"
|
||||||
|
self._mid(pct_r, cy, pct_txt, self.F16, fill=color, bold=k, align="r", track=PCT_TRACK) # 百分比 F16(主)
|
||||||
|
bx0 = x0 + LABEL_W
|
||||||
|
bx1 = pct_r - self._tw(pct_txt, self.F16, self._track(self.F16, k, PCT_TRACK)) - G1
|
||||||
|
by = cy - H // 2
|
||||||
|
self.d.rectangle([bx0, by, bx1, by + H], outline=outline, width=k) # 轨道边框随条色
|
||||||
|
fw = (bx1 - bx0) * pct / 100.0
|
||||||
|
if fw >= 2:
|
||||||
|
self.d.rectangle([bx0, by, bx0 + fw, by + H], fill=fill)
|
||||||
|
return bx0, bx1, by, H # 进度条几何(宽版合并行据此在边框内重绘上下两条实心填充)
|
||||||
|
|
||||||
|
def _usage_colors(self, ok, pct, warn, bar):
|
||||||
|
"""用量行配色钩子 → (进度条填充,轨道边框,百分比文字)。三色版:≥warn 整体红,否则黑。"""
|
||||||
|
c = RED if (ok and pct >= warn) else BLACK
|
||||||
|
return c, c, c
|
||||||
|
|
||||||
|
def _pace_color(self, diff):
|
||||||
|
"""pace(用量% − 时间%)配色钩子。三色版:>0 超前红,否则黑。"""
|
||||||
|
return RED if diff > 0 else BLACK
|
||||||
|
|
||||||
def _loc_pin(self, x, y, w=8, fg=BLACK, bg=WHITE):
|
def _loc_pin(self, x, y, w=8, fg=BLACK, bg=WHITE):
|
||||||
"""定位图标:实心水滴(圆头+尖尾) + 白色挖孔,1-bit 下轮廓清晰。
|
"""定位图标:实心水滴(圆头+尖尾) + 白色挖孔,1-bit 下轮廓清晰。
|
||||||
@@ -139,12 +320,16 @@ 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.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)
|
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):
|
def _weather_icon(self, x, y, category, s=1.0, fg=BLACK, bg=WHITE, accent=None, wet=None):
|
||||||
"""按天气类别画图标,s 为缩放系数,原生约 36x38(左上角 x,y)。"""
|
"""按天气类别画图标,s 为缩放系数,原生约 36x38(左上角 x,y)。
|
||||||
|
accent 用于「有能量」的元素——太阳、闪电——着红;wet 用于雨滴/雪花(六色版给蓝),默认随 fg;
|
||||||
|
云/雾/月保持 fg(黑)。"""
|
||||||
S = lambda v: v * s
|
S = lambda v: v * s
|
||||||
|
accent = accent or fg
|
||||||
|
wet = wet or fg
|
||||||
|
|
||||||
if category == "sunny":
|
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
|
return
|
||||||
|
|
||||||
if category == "clear_night": # 月牙:实心圆挖去偏上右的背景圆
|
if category == "clear_night": # 月牙:实心圆挖去偏上右的背景圆
|
||||||
@@ -165,7 +350,7 @@ class DashboardRenderer:
|
|||||||
return
|
return
|
||||||
|
|
||||||
if category != "rain" and category != "snow" and category != "thunder": # 多云及未识别:太阳 + 云
|
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)
|
self._cloud(x, y + S(24), s, fg, bg)
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -174,19 +359,19 @@ class DashboardRenderer:
|
|||||||
self._cloud(x, y + S(15), s, fg, bg)
|
self._cloud(x, y + S(15), s, fg, bg)
|
||||||
if category == "rain":
|
if category == "rain":
|
||||||
for dx in (9, 18, 27):
|
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)
|
self.d.line([x + S(dx) + S(3), cb + S(3), x + S(dx) - S(1), cb + S(10)], fill=wet, width=2)
|
||||||
elif category == "snow":
|
elif category == "snow":
|
||||||
for dx in (10, 19, 28):
|
for dx in (10, 19, 28):
|
||||||
fx, fy, rr = x + S(dx), cb + S(7), S(4)
|
fx, fy, rr = x + S(dx), cb + S(7), S(4)
|
||||||
for a in (0, 60, 120):
|
for a in (0, 60, 120):
|
||||||
rad = math.radians(a)
|
rad = math.radians(a)
|
||||||
self.d.line([fx - math.cos(rad) * rr, fy - math.sin(rad) * rr,
|
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)
|
fx + math.cos(rad) * rr, fy + math.sin(rad) * rr], fill=wet, width=1)
|
||||||
elif category == "thunder":
|
elif category == "thunder":
|
||||||
bx = x + S(18)
|
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)),
|
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(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 表示连续
|
# 线型样式:(实线段长, 间隙长);实线用 dash=0 表示连续
|
||||||
_STYLES = {"solid": (0, 0), "dashed": (7, 4), "dotted": (1, 2)}
|
_STYLES = {"solid": (0, 0), "dashed": (7, 4), "dotted": (1, 2)}
|
||||||
@@ -241,9 +426,10 @@ class DashboardRenderer:
|
|||||||
SAMP, G1, GAP = 16, 8, 10 # 样例线长 / 线与名间隙 / 图例项间隙
|
SAMP, G1, GAP = 16, 8, 10 # 样例线长 / 线与名间隙 / 图例项间隙
|
||||||
x = self.W - 10
|
x = self.W - 10
|
||||||
for s in reversed(series):
|
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
|
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
|
x -= SAMP + GAP
|
||||||
# ---- 绘图区 ----
|
# ---- 绘图区 ----
|
||||||
px0, px1 = 14, self.W - 10
|
px0, px1 = 14, self.W - 10
|
||||||
@@ -252,6 +438,7 @@ class DashboardRenderer:
|
|||||||
n = len(dates)
|
n = len(dates)
|
||||||
xstep = (px1 - px0) / (n - 1) if n > 1 else 0
|
xstep = (px1 - px0) / (n - 1) if n > 1 else 0
|
||||||
for s in series:
|
for s in series:
|
||||||
|
color = RED if s.get("k") == "流水" else BLACK # 主指标(流水)为主角,着红领读
|
||||||
data = s["data"]
|
data = s["data"]
|
||||||
lo, hi = min(data), max(data)
|
lo, hi = min(data), max(data)
|
||||||
span = (hi - lo) or 1
|
span = (hi - lo) or 1
|
||||||
@@ -261,7 +448,10 @@ class DashboardRenderer:
|
|||||||
x = px0 + xstep * i
|
x = px0 + xstep * i
|
||||||
yv = py1 - pad - (v - lo) / span * (py1 - py0 - 2 * pad)
|
yv = py1 - pad - (v - lo) / span * (py1 - py0 - 2 * pad)
|
||||||
pts.append((x, yv))
|
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 轴标签:仅首 / 中 / 末三个日期 ----
|
# ---- X 轴标签:仅首 / 中 / 末三个日期 ----
|
||||||
xl_y = py1 + 3
|
xl_y = py1 + 3
|
||||||
self.text(px0, xl_y, dates[0], self.F12)
|
self.text(px0, xl_y, dates[0], self.F12)
|
||||||
@@ -270,55 +460,65 @@ class DashboardRenderer:
|
|||||||
|
|
||||||
# ---------- 入口 ----------
|
# ---------- 入口 ----------
|
||||||
def render(self, data):
|
def render(self, data):
|
||||||
"""根据 data 渲染并返回 PIL.Image('L' 模式,纯 0/255)。"""
|
"""根据 data 渲染并返回 PIL.Image('RGB' 模式,仅黑/白/红三色)。
|
||||||
self.img = Image.new("L", (self.W, self.H), WHITE)
|
|
||||||
|
版式(自上而下三段,两道横规分隔):
|
||||||
|
A 顶部:大时钟+日期(左)/ 天气(右)——白底黑字,红点缀(定位/太阳闪电);
|
||||||
|
B 中部主角:Claude Code 用量——每条=标题行 + 一条粗进度条,pct≥阈值转红预警;
|
||||||
|
C 底部:橘喵今日实时三列(涨=红·跌=黑)。近30天趋势图暂隐藏(_trend_chart 保留)。
|
||||||
|
"""
|
||||||
|
self.img = Image.new("RGB", (self.W, self.H), WHITE)
|
||||||
self.d = ImageDraw.Draw(self.img)
|
self.d = ImageDraw.Draw(self.img)
|
||||||
self.d.fontmode = "1" # 关闭抗锯齿,点阵锐利
|
self.d.fontmode = "1" # 关闭抗锯齿,点阵锐利(RGB 上仍生效,纯色描边不发糊)
|
||||||
|
|
||||||
dt, wt, mao = data["date"], data["weather"], data["mao"]
|
dt, wt, mao, usage = data["date"], data["weather"], data["mao"], data["usage"]
|
||||||
|
|
||||||
# A. Hero:大时钟 + 日期(时钟上移使「顶部留白 ≈ 与日期间距」对称)
|
# ---- A. 顶部:大时钟+日期(左)/ 天气(右)。白底黑字,红点缀 ----
|
||||||
self.text(10, 0, dt["time"], self.F48, bold=2)
|
self.text(10, 0, dt["time"], self.F48, bold=2)
|
||||||
self.text(12, 64, f'{dt["week"]} {dt["greg"]}', self.F12)
|
self.text(12, 64, f'{dt["week"]} {dt["greg"]}', self.F12)
|
||||||
|
DIV_Y = 90
|
||||||
# B. 天气:右对齐文字 + 居中的 28° + 放大右靠的图标
|
|
||||||
DIV_Y = 90 # 天气区底部分割线
|
|
||||||
loc, temp, cr = wt["loc"], wt["temp"], f'{wt["cond"]} {wt["range"]}'
|
loc, temp, cr = wt["loc"], wt["temp"], f'{wt["cond"]} {wt["range"]}'
|
||||||
self.rtext(390, 8, loc, self.F12)
|
self.rtext(390, 8, loc, self.F12)
|
||||||
self.rtext(390, 30, temp, self.F24, bold=1)
|
self.rtext(390, 30, temp, self.F24, bold=1)
|
||||||
self.rtext(390, 64, cr, self.F12)
|
self.rtext(390, 64, cr, self.F12)
|
||||||
# 定位图标:置于 loc 文字左侧,留间距并与文字竖直居中
|
|
||||||
PIN_W, PIN_GAP = 8, 6
|
PIN_W, PIN_GAP = 8, 6
|
||||||
loc_pin_x = 390 - self._tw(loc, self.F12) - PIN_GAP - PIN_W
|
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
|
s = 1.5
|
||||||
iw, ih = 36 * s, 38 * s # 图标原生约 36x38
|
iw, ih = 36 * s, 38 * s # 图标原生约 36x38
|
||||||
text_left = min(loc_pin_x,
|
text_left = min(loc_pin_x,
|
||||||
390 - self._tw(temp, self.F24) - 1,
|
390 - self._tw(temp, self.F24) - 1,
|
||||||
390 - self._tw(cr, self.F12))
|
390 - self._tw(cr, self.F12))
|
||||||
icon_y = (DIV_Y - ih) / 2 # 顶部(0)与分割线正中:上下留白相等
|
self._weather_icon(text_left - 12 - iw, (DIV_Y - ih) / 2,
|
||||||
self._weather_icon(text_left - 12 - iw, icon_y, self._weather_category(wt.get("icon")), s=s)
|
self._weather_category(wt.get("icon")), s=s, accent=RED)
|
||||||
|
|
||||||
self.hdash(8, 392, DIV_Y)
|
self.hdash(8, 392, DIV_Y)
|
||||||
|
|
||||||
# C. 橘喵今日经营:三列(数值 + 环比/同比)
|
# ---- B. 中部主角:Claude Usage(单行:标签 + 进度条 + 百分比 + pace,≥阈值转红)----
|
||||||
self._title_bar(96, "橘喵·今日实时", mao["upd"])
|
# 标题贴近上分割线(与今日实时一致的 6px 间距);两条进度条在余下空间内等距(≈19px)
|
||||||
cols = mao["cols"]; n = len(cols)
|
self._label(96, usage.get("title", "Claude Usage"))
|
||||||
|
warn = usage.get("warn", 80)
|
||||||
|
ry = 123
|
||||||
|
for b in usage.get("bars", []):
|
||||||
|
if b.get("scoped"): # 按模型限定的额度(Fable 7d)只在 800x480 宽版展示,小屏放不下第三行
|
||||||
|
continue
|
||||||
|
self._usage_row(10, 390, ry, b, warn)
|
||||||
|
ry += 31 # 略压缩两条间距,给底部让出下边距
|
||||||
|
|
||||||
|
self.hdash(8, 392, 187) # B/C 分割线上移,最终给底部今日实时留出与整图下边的间距
|
||||||
|
|
||||||
|
# ---- C. 底部:橘喵·今日实时(三列,版式对齐主分支:标题+18=名/+34=值/+64=环比/+82=同比)----
|
||||||
|
self._label(193, "橘喵·今日实时", right=f'上次更新 {mao["upd"]}')
|
||||||
|
cols = [c for c in mao["cols"] if not c.get("wide")]; n = len(cols) # 仅宽版的列(团购)小屏放不下,跳过
|
||||||
ax0, ax1 = 8, 392
|
ax0, ax1 = 8, 392
|
||||||
cw = (ax1 - ax0) / n
|
cw = (ax1 - ax0) / n
|
||||||
for i in range(1, n):
|
for i in range(1, n):
|
||||||
self.vdash(ax0 + cw * i, 128, 192)
|
self.vdash(ax0 + cw * i, 225, 289)
|
||||||
for i, c in enumerate(cols):
|
for i, c in enumerate(cols):
|
||||||
cx = ax0 + cw * (i + 0.5)
|
cx = ax0 + cw * (i + 0.5)
|
||||||
self.ctext(cx, 114, c["k"], self.F12)
|
self.ctext(cx, 211, c["k"], self.F12) # 指标名(标题+18)
|
||||||
self.ctext(cx, 130, c["v"], self.F24, bold=1)
|
self._value(cx, 227, c, bold=1) # 数值 F24 伪粗体 + 小号 ¥/万(+34)
|
||||||
self._cmp_line(cx, 160, "环比", c["hb"])
|
self._cmp_line(cx, 257, "环比", c["hb"]) # +64
|
||||||
self._cmp_line(cx, 178, "同比", c["tb"])
|
self._cmp_line(cx, 275, "同比", c["tb"]) # +82,墨迹止于≈289,底部留≈10px
|
||||||
|
|
||||||
self.hdash(8, 392, 200)
|
|
||||||
|
|
||||||
# D. 近30天三指标趋势折线图
|
|
||||||
self._trend_chart(204, mao["trend"])
|
|
||||||
|
|
||||||
return self.img
|
return self.img
|
||||||
|
|
||||||
|
|||||||
400
renderer_e1002.py
Normal file
400
renderer_e1002.py
Normal file
@@ -0,0 +1,400 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
reTerminal E1002 渲染层 —— 复用 renderer.DashboardRenderer 的组件(**k=1,字号与 400x300 版完全相同**),
|
||||||
|
利用 800x480 的四倍面积重新排版为「AI 用量看板」,输出 **E Ink Spectra 6 原生六色**(黑/白/黄/红/绿/蓝)纯色图,
|
||||||
|
并生成一张固定 800x480 的 HTML 页面供 SenseCraft HMI 的 HTML 控件云端截图拉取。
|
||||||
|
|
||||||
|
版式(k=1,点阵字体且**只用整数倍字号 12/24/36/48/84**,不放大;四段,三道横规):
|
||||||
|
A 顶部(0~90) :**大时钟 F84**(墨迹 16~79)+ 其右一列三行、左缘对齐:① 电池图标+电量 ② 温度计+温度 | 水滴+湿度 ③ 星期/日期
|
||||||
|
/ 右侧上下两层:上 天气图标 ×1.25 | **实时温度 F48 粗 3**(墨迹顶沿 16 与时钟齐平、「°」F24 上标、右贴页边 790);下 一行「定位水滴+城市 天气 温区」F12 右对齐贴 790、与日期同基线
|
||||||
|
(F12,行距 22,日期与时钟数字**基线对齐**;设备数据来自 SenseCraft 遥测 `data.device`;2026-09-14 定稿,
|
||||||
|
此前「状态栏横排在时钟上方+时钟缩 F60」版被否)
|
||||||
|
|天气(右,贴 790)——天气组件与三色版同尺寸。
|
||||||
|
B 用量(96~206) :左半 **Claude Usage**(两行:5h + 合并行 7d——一个大边框内上下两条实心 = 总 7d / fable 7d,右侧数字取较大者,`_usage_row_split`)|竖规 x=400|右半 **Codex Usage**(5h / 7d,ChatGPT 订阅的 Codex 额度池;未推送时 mock 标「示例数据」)。
|
||||||
|
行组件沿用三色版的紧凑单行 `_usage_row`(行距 31);**三处文字统一 12px**(基类 F13/F16/FPACE 都映射到 12px):
|
||||||
|
标签**常规字重、在标签列内右对齐**(贴进度条),百分比与 pace 数字**粗体**(曾试过 24px,用户嫌大),
|
||||||
|
标签用 bar["label"](statusline 同款写法),列宽按最长标签自适应;标题右侧「上次更新 HH:MM」与今日实时同款。
|
||||||
|
C 今日(218~322):**橘喵·今日实时** 五列全宽(探店单量/探店流水/团购单量/团购流水/毛利):列内四行逐行居中——
|
||||||
|
名 F12 / 值 F24 粗 / 环比 / 同比(五列每列 ~157px,原「值 F36 + 右侧叠放环比/同比」并排组放不下)。
|
||||||
|
D 行情(~336~470):**BTC/USDT 时K(近 48 小时)** 蜡烛图(`_btc_chart`,数据来自 btc_api:OKX 主源、Coinbase/Huobi 备源,5 分钟缓存;
|
||||||
|
周期/根数由 BTC_BAR/BTC_LIMIT 配置)——标题右侧最新价 + 24h 涨跌幅;**涨(收≥开)绿、跌红**,实心蜡烛 + 1px 影线;
|
||||||
|
右侧最高/最低/最新价标签,最新价一道点规;底部首/中/末时间。
|
||||||
|
可选(470~479):六色测试色条(config.E1002_COLOR_STRIP,默认关;真机校准色彩量化时打开)。
|
||||||
|
|
||||||
|
六色的语义分工(每种颜色只有一个含义,不为「有」而用):
|
||||||
|
- 黑/白:结构与正文。红:危险、涨、定位、太阳/闪电;绿:健康、K 线上涨(红另用于 K 线下跌);蓝:天气雨滴/雪花。
|
||||||
|
黄:**不用**(2026-09-14 曾按要求加过四档含黄,随即被否)。
|
||||||
|
- 用量与 pace **各自三档、颜色顺序固定 绿 → 黑 → 红**,两者独立计算(`_usage_colors` / `_pace_color`):
|
||||||
|
用量 pct:≤ USAGE_OK(30) 绿「健康」|(30, 80) 黑「正常」|≥ USAGE_WARN(80) 红「危险」——进度条填充、边框与**百分比数字三者同色**;
|
||||||
|
pace = 用量% − 时间%:≤ −PACE_TOL(10) 绿「节余」|(−10, +10) 黑「持平」|≥ +10 红「偏快」。
|
||||||
|
注:绿色数字在白底上比黑字淡一些,24px 字号下可读;这是「百分比随条色」的已知取舍。
|
||||||
|
- 整图只允许六种纯色;渲染后可用 `img.getcolors()` 自查。
|
||||||
|
|
||||||
|
为什么是「固定 800x480 + 一张 PNG」而不是自适应 HTML:
|
||||||
|
- SenseCraft 的 HTML 控件是云端无头浏览器截图后再量化到六色下发。自适应布局在未知视口/DPR 下会被重排、
|
||||||
|
缩放、抗锯齿,产生大量中间色,最终被平台抖动成网点。页面锁死 800x480、内容就是 1:1 的 PNG
|
||||||
|
(`image-rendering: pixelated`),控件铺满画布即逐像素一致,每个像素本就是六色之一,量化零损失。
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
|
||||||
|
import config
|
||||||
|
from renderer import DashboardRenderer, BLACK, WHITE, RED
|
||||||
|
import config as _cfg
|
||||||
|
from PIL import ImageFont
|
||||||
|
|
||||||
|
# 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=1,字号不变),只重写版式与配色钩子。"""
|
||||||
|
|
||||||
|
PCT_TRACK = 0 # 百分比是整数倍字号(自带侧边距),不再手动加字符间距
|
||||||
|
LABEL_BOLD = False # 标签常规字重(数值才加粗,层级:数值 > 标签)
|
||||||
|
LABEL_ALIGN = "r" # 标签右对齐贴进度条,长短标签(5h / 7d)右缘齐平
|
||||||
|
|
||||||
|
def __init__(self, font_path=config.FONT_PATH):
|
||||||
|
super().__init__(font_path, size=(config.E1002_WIDTH, config.E1002_HEIGHT), k=1)
|
||||||
|
# 宽版只用点阵字体的**整数倍字号**(12 / 24 / 36 / 48 / 84):基类给用量行用的 F13(标签)/ FPACE(pace)/ F16(百分比)
|
||||||
|
# 是非整数倍缩放(笔画不均),这里统一映射到 12px(24px 试过、用户嫌大);标签常规字重、百分比与 pace 数字粗体;
|
||||||
|
# 行几何靠 _mid 按墨迹中心对齐,不受字号变化影响。F84 给顶部大时钟(7 倍整数缩放,墨迹 63px 恰好占满 A 段)。
|
||||||
|
self.F13 = ImageFont.truetype(font_path, 12)
|
||||||
|
self.F16 = self.F13
|
||||||
|
self.FPACE = self.F13
|
||||||
|
self.F84 = ImageFont.truetype(font_path, 84) # 顶部大时钟
|
||||||
|
|
||||||
|
# ---------- 配色钩子(三档,顺序固定 绿 → 黑 → 红;用量与 pace 各自独立计算)----------
|
||||||
|
_ok, _pace_tol = _cfg.USAGE_OK, _cfg.PACE_TOL # 由 _usage_block 按数据覆盖
|
||||||
|
|
||||||
|
def _usage_row(self, x0, x1, y, bar, warn, label_w=None):
|
||||||
|
"""宽版:pct 为 None(接口未返回该窗口,如 Codex Plus 的 5h)时不画 "--",而是按 0 画——百分比 "00%"、pace "00"、
|
||||||
|
空条、全黑(无数据的中性色,不落绿档)——使各行进度条右端对齐、宽度一致(2026-09-14 用户要求;"--" 会让该行条变短)。"""
|
||||||
|
if not isinstance(bar.get("pct"), (int, float)):
|
||||||
|
bar = dict(bar, pct=0, time_pct=0, _null=True)
|
||||||
|
return super()._usage_row(x0, x1, y, bar, warn, label_w=label_w)
|
||||||
|
|
||||||
|
def _usage_colors(self, ok, pct, warn, bar):
|
||||||
|
"""进度条填充、边框、百分比数字三者同色:pct ≤ ok 绿「健康」|其间黑「正常」|pct ≥ warn 红「危险」;无数据(_null)黑。"""
|
||||||
|
if not ok or bar.get("_null"):
|
||||||
|
return BLACK, BLACK, BLACK
|
||||||
|
c = RED if pct >= warn else GREEN if pct <= self._ok else BLACK
|
||||||
|
return c, c, c
|
||||||
|
|
||||||
|
def _cmp_color(self, direction, fg=BLACK):
|
||||||
|
"""环比/同比 涨绿 / 跌红(与底部 K 线「涨绿跌红」一致,2026-09-14 用户要求);无方向沿用 fg。"""
|
||||||
|
return GREEN if direction == "up" else RED if direction == "down" else fg
|
||||||
|
|
||||||
|
def _pace_color(self, diff):
|
||||||
|
"""pace = 用量% − 时间%(与用量档位独立):≤ −tol 绿「节余」|(−tol, +tol) 黑「持平」|≥ +tol 红「偏快」。"""
|
||||||
|
if diff >= self._pace_tol:
|
||||||
|
return RED
|
||||||
|
if diff <= -self._pace_tol:
|
||||||
|
return GREEN
|
||||||
|
return BLACK
|
||||||
|
|
||||||
|
# 天气图标各类别的真实墨迹上下沿(基类 _weather_icon 名义框 36×38 的坐标系、未乘 s):用于按墨迹而非名义框竖直居中。
|
||||||
|
_ICON_EXTENT = {"sunny": (1, 37), "clear_night": (6, 32), "overcast": (10, 34), "fog": (9, 34),
|
||||||
|
"cloudy": (-4, 38), "rain": (5, 39), "snow": (5, 40), "thunder": (5, 46)}
|
||||||
|
|
||||||
|
# ---------- 组件 ----------
|
||||||
|
def _status_battery(self, x, cy, dev):
|
||||||
|
"""状态项·电池(F12,数字伪粗体,墨迹中心 cy):外框 16×9 + 正极 2×4,内部按电量实心填充——充电中 GREEN、≤20% RED、其余 BLACK;
|
||||||
|
右侧电量数字,缺失 "--"。返回末端 x。"""
|
||||||
|
F, GAP_I = self.F12, 5
|
||||||
|
lvl, chg = (dev or {}).get("battery"), (dev or {}).get("charging")
|
||||||
|
self.d.rectangle([x, cy - 4, x + 15, cy + 4], outline=BLACK, width=1)
|
||||||
|
self.d.rectangle([x + 16, cy - 2, x + 17, cy + 2], fill=BLACK)
|
||||||
|
if lvl is not None:
|
||||||
|
fill = GREEN if chg else RED if lvl <= 20 else BLACK
|
||||||
|
fw = round(12 * max(0, min(100, lvl)) / 100)
|
||||||
|
if fw > 0:
|
||||||
|
self.d.rectangle([x + 2, cy - 2, x + 2 + fw, cy + 2], fill=fill)
|
||||||
|
x += 18 + GAP_I
|
||||||
|
txt = f"{lvl}%" if lvl is not None else "--"
|
||||||
|
b = 1 if lvl is not None else 0 # 数字伪粗体、字号不变(2026-09-14 用户要求);占位不加粗
|
||||||
|
self._mid(x, cy, txt, F, bold=b)
|
||||||
|
return x + self._tw_b(txt, F, b)
|
||||||
|
|
||||||
|
def _status_env(self, x, cy, dev):
|
||||||
|
"""状态项·环境(F12,数字伪粗体,墨迹中心 cy):温度计(细管 3×8 + 底部球 7×7,管内水银线)+温度 | 蓝色水滴(上尖下圆)+湿度,
|
||||||
|
项间距 16、图标↔数字 5;缺失 "--"。返回末端 x。"""
|
||||||
|
F, GAP_I, GAP = self.F12, 5, 16
|
||||||
|
dev = dev or {}
|
||||||
|
self.d.rectangle([x + 2, cy - 6, x + 4, cy + 1], outline=BLACK, width=1)
|
||||||
|
self.d.ellipse([x, cy + 1, x + 6, cy + 7], fill=BLACK)
|
||||||
|
self.d.line([x + 3, cy - 2, x + 3, cy + 1], fill=BLACK)
|
||||||
|
x += 7 + GAP_I
|
||||||
|
t = dev.get("temp")
|
||||||
|
txt = f"{t:.1f}℃" if t is not None else "--"
|
||||||
|
b = 1 if t is not None else 0 # 数字伪粗体、字号不变(2026-09-14 用户要求);占位不加粗
|
||||||
|
self._mid(x, cy, txt, F, bold=b); x += self._tw_b(txt, F, b) + GAP
|
||||||
|
self.d.polygon([(x + 4, cy - 6), (x, cy + 2), (x + 8, cy + 2)], fill=BLUE)
|
||||||
|
self.d.ellipse([x, cy - 1, x + 8, cy + 6], fill=BLUE)
|
||||||
|
x += 9 + GAP_I
|
||||||
|
h = dev.get("humidity")
|
||||||
|
txt = f"{h:.0f}%" if h is not None else "--"
|
||||||
|
b = 1 if h is not None else 0
|
||||||
|
self._mid(x, cy, txt, F, bold=b)
|
||||||
|
return x + self._tw_b(txt, F, b)
|
||||||
|
|
||||||
|
def _color_strip(self, y0, y1, x0=None, x1=None):
|
||||||
|
"""六色测试色条:六块等宽实心色块,白块加 1px 黑框以便可见。真机校准用。"""
|
||||||
|
x0 = 10 if x0 is None else x0
|
||||||
|
x1 = self.W - 10 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 _usage_block(self, x0, x1, y0, usage):
|
||||||
|
"""一个用量区块:标题(_label,右侧「上次更新 HH:MM」——与今日实时同款文案;mock 数据则标「示例数据」)
|
||||||
|
+ 逐行紧凑 `_usage_row`(行距 31)。标签列宽按本块最长标签墨迹 + LABEL_GAP 算(标签右对齐贴进度条,短标签左侧留白)。返回末行底部 y。"""
|
||||||
|
right = "示例数据" if usage.get("mock") else (f'上次更新 {usage["updated"]}' if usage.get("updated") else None)
|
||||||
|
self._label(y0, usage.get("title", "Usage"), right=right, x0=x0, x1=x1)
|
||||||
|
warn = usage.get("warn", 80)
|
||||||
|
self._ok, self._pace_tol = usage.get("ok", _cfg.USAGE_OK), usage.get("pace_tol", _cfg.PACE_TOL)
|
||||||
|
bars = [dict(b, k=b.get("label") or b["k"]) for b in usage.get("bars", [])] # 宽版用长标签(5h / 7d)
|
||||||
|
# scoped 行(fable 7d)并入紧邻其前的非 scoped 行,成为该行的下半细条 → 左右两块都是两行,对称
|
||||||
|
rows = []
|
||||||
|
for b in bars:
|
||||||
|
if b.get("scoped") and rows and rows[-1][1] is None:
|
||||||
|
rows[-1] = (rows[-1][0], b)
|
||||||
|
else:
|
||||||
|
rows.append((b, None))
|
||||||
|
label_w = max([self._tw(m["k"], self.F13) for m, _ in rows] + [26]) + self.LABEL_GAP
|
||||||
|
y = y0 + 27
|
||||||
|
for m, sub in rows:
|
||||||
|
if sub is None:
|
||||||
|
self._usage_row(x0, x1, y, m, warn, label_w=label_w)
|
||||||
|
else:
|
||||||
|
self._usage_row_split(x0, x1, y, m, sub, warn, label_w)
|
||||||
|
y += 31
|
||||||
|
return y - 31 + 18 # 末行进度条底部(cy=y+10,条高 15 → 底 y+17)
|
||||||
|
|
||||||
|
def _usage_row_split(self, x0, x1, y, main, sub, warn, label_w):
|
||||||
|
"""合并行「7d」(2026-09-14 定稿):**一个大边框**、整行高度与单行相同(H=15);边框内部上下**精确对半**两条**实心**填充
|
||||||
|
(各 7px、紧挨无空隙)——上 = main(总 7d),下 = sub(模型限定 fable 7d)。右侧百分比与 pace **只显示两者中较大者**
|
||||||
|
(同一窗口 time_pct 相同,pct 大者 pace 也大);边框与文字颜色随该较大者的档位,两条填充各按自身档位取色。
|
||||||
|
实现:以「较大者」合成一条 bar 交给基类 _usage_row 画边框/标签/文字,再把边框内部刷白、重绘两条半高填充。"""
|
||||||
|
k = self.k
|
||||||
|
def _p(b):
|
||||||
|
v = (b or {}).get("pct")
|
||||||
|
return int(v) if isinstance(v, (int, float)) else None
|
||||||
|
pm, ps = _p(main), _p(sub)
|
||||||
|
dom = sub if (ps is not None and (pm is None or ps > pm)) else main
|
||||||
|
bx0, bx1, by, H = self._usage_row(x0, x1, y, dict(dom, k=main["k"]), warn, label_w=label_w)
|
||||||
|
# 边框内部刷白(保留 1px 边框),再画上下两条实心
|
||||||
|
self.d.rectangle([bx0 + k, by + k, bx1 - k, by + H - k], fill=WHITE)
|
||||||
|
halves = ((pm, main, by + k, by + 7 * k), (ps, sub, by + 8 * k, by + 14 * k)) # 边框内 14px 精确对半:上 by+1..by+7,下 by+8..by+14,无空隙
|
||||||
|
for pct, b, top, bot in halves:
|
||||||
|
if pct is None:
|
||||||
|
continue
|
||||||
|
pct = max(0, min(100, pct))
|
||||||
|
fill = self._usage_colors(True, pct, warn, b or {})[0]
|
||||||
|
fw = (bx1 - bx0) * pct / 100.0
|
||||||
|
if fw >= 2:
|
||||||
|
self.d.rectangle([bx0 + k, top, min(bx0 + fw, bx1 - k), bot], fill=fill)
|
||||||
|
|
||||||
|
def _realtime(self, y0, mao, x0=8, x1=None):
|
||||||
|
"""橘喵·今日实时五列(宽版):探店单量 / 探店流水 / 团购单量 / 团购流水 / 毛利,列内四行逐行水平居中:
|
||||||
|
指标名 F12(y0+21)→ 值 F24 粗(y0+38)→ 环比(y0+72)→ 同比(y0+88,行距 16);按墨迹框算,标题↔名 ~9px(原 ~5px 太挤,整列下移 4px)、
|
||||||
|
名↔值 ~9px、值↔环比 ~6px、环比↔同比 5px。
|
||||||
|
为什么不再是「值 F36 + 右侧叠放环比/同比」的并排组:五列每列仅 ~157px,F36 并排组(如「2.12万」102+间隙+环比 77)
|
||||||
|
近 194px 放不下;降到 F24 后并排组仍有 156px,贴着两侧分隔线太挤。竖排最宽元素(「1234.56%」一行 89px)余量充足,
|
||||||
|
与 400×300 小屏同构。(更早曾试过名与值同行、名贴左上角角标,均已否。)返回区块底部 y。"""
|
||||||
|
x1 = self.W - 8 if x1 is None else x1
|
||||||
|
self._label(y0, "橘喵·今日实时", right=f'上次更新 {mao["upd"]}', x0=x0 + 2, x1=x1 - 2)
|
||||||
|
cols = mao["cols"]; n = len(cols)
|
||||||
|
cw = (x1 - x0) / n
|
||||||
|
TAG_Y, VAL_Y, HB_Y, TB_Y = y0 + 21, y0 + 38, y0 + 72, y0 + 88
|
||||||
|
for i in range(1, n):
|
||||||
|
self.vdash(x0 + cw * i, y0 + 26, y0 + 102)
|
||||||
|
for i, c in enumerate(cols):
|
||||||
|
cx = x0 + cw * (i + 0.5)
|
||||||
|
self.ctext(cx, TAG_Y, c["k"], self.F12)
|
||||||
|
self._value(cx, VAL_Y, c, bold=1) # F24 粗主数字 + 小号 ¥ / 万
|
||||||
|
self._cmp_line(cx, HB_Y, "环比", c["hb"])
|
||||||
|
self._cmp_line(cx, TB_Y, "同比", c["tb"])
|
||||||
|
return y0 + 104
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _fmt_price(v):
|
||||||
|
"""价格文本:≥1000 千分位取整,否则两位小数。"""
|
||||||
|
return f"{v:,.0f}" if abs(v) >= 1000 else f"{v:.2f}"
|
||||||
|
|
||||||
|
def _btc_chart(self, y0, y1, btc, x0=10, x1=None):
|
||||||
|
"""BTC K 线蜡烛图(y0 标题行顶 ~ y1 底;周期由 btc["bar"] 决定标题「时K/日K」):
|
||||||
|
标题右侧 最新价(F12 粗)+ 涨跌口径(24h/1d)+ 涨跌三角 + 涨跌幅(F12),涨绿 / 跌红;
|
||||||
|
绘图区:每根蜡烛 = 1px 影线(最高↔最低)+ 实心实体(开↔收,宽≈槽位 60%、奇数像素居中),收≥开 绿、否则 红;
|
||||||
|
右侧价格标签列:最高(顶)、最低(底)、最新(粗,随最新价位置浮动、避让顶底标签),最新价横贯一道点规;
|
||||||
|
底部基线点规 + 首/中/末三个日期。无数据时居中 "--"。"""
|
||||||
|
x1 = self.W - 10 if x1 is None else x1
|
||||||
|
btc = btc or {}
|
||||||
|
candles = btc.get("candles") or []
|
||||||
|
kind = "时K" if btc.get("bar", "1H") == "1H" else "日K"
|
||||||
|
self._label(y0, f'{btc.get("symbol") or "BTC/USDT"}·{kind}', x0=x0, x1=x1)
|
||||||
|
if candles and btc.get("last") is not None:
|
||||||
|
chg = btc.get("chg") or 0.0
|
||||||
|
col = GREEN if chg >= 0 else RED
|
||||||
|
xr = x1
|
||||||
|
pct_txt = f"{abs(chg):.2f}%"
|
||||||
|
self.rtext(xr, y0, pct_txt, self.F12, fill=col, bold=1); xr -= self._tw_b(pct_txt, self.F12, 1) + 4 # 涨跌幅伪粗体
|
||||||
|
self.tri(xr - 4, y0 + 8.5, 7, up=chg >= 0, fill=col); xr -= 8 + 6
|
||||||
|
if btc.get("chg_label"): # 涨跌幅口径(24h / 1d)
|
||||||
|
self.rtext(xr, y0, btc["chg_label"], self.F12); xr -= self._tw(btc["chg_label"], self.F12) + 10
|
||||||
|
self.rtext(xr, y0, self._fmt_price(btc["last"]), self.F12, bold=1)
|
||||||
|
if not candles:
|
||||||
|
self.ctext((x0 + x1) / 2, (y0 + y1) / 2 - 12, "--", self.F24)
|
||||||
|
return
|
||||||
|
LBL_W = 66 # 右侧价格标签列
|
||||||
|
px0, px1 = x0, x1 - LBL_W
|
||||||
|
py0, py1 = y0 + 26, y1 - 16 # 底部留 16px 给日期
|
||||||
|
hi_raw, lo_raw = max(c["h"] for c in candles), min(c["l"] for c in candles)
|
||||||
|
pad = (hi_raw - lo_raw or 1) * 0.04
|
||||||
|
lo, hi = lo_raw - pad, hi_raw + pad
|
||||||
|
span = hi - lo
|
||||||
|
Y = lambda v: py1 - (v - lo) / span * (py1 - py0)
|
||||||
|
n = len(candles)
|
||||||
|
slot = (px1 - px0) / n
|
||||||
|
bw = max(3, int(slot * 0.6)) | 1 # 实体宽度取奇数,影线居中
|
||||||
|
self.hdash(px0, px1, py1, dash=2, gap=3) # 基线
|
||||||
|
for i, c in enumerate(candles):
|
||||||
|
cx = round(px0 + slot * (i + 0.5))
|
||||||
|
col = GREEN if c["c"] >= c["o"] else RED
|
||||||
|
self.d.line([cx, round(Y(c["h"])), cx, round(Y(c["l"]))], fill=col, width=1)
|
||||||
|
top, bot = sorted((round(Y(c["o"])), round(Y(c["c"]))))
|
||||||
|
self.d.rectangle([cx - bw // 2, top, cx + bw // 2, bot], fill=col) # 十字星时 top==bot 退化为 1px 横线
|
||||||
|
# ---- 右侧价格标签:最高 / 最低 / 最新 ----
|
||||||
|
hi_y, lo_y = py0 - 6, py1 - 12
|
||||||
|
self.rtext(x1, hi_y, self._fmt_price(hi_raw), self.F12)
|
||||||
|
self.rtext(x1, lo_y, self._fmt_price(lo_raw), self.F12)
|
||||||
|
last = candles[-1]["c"]
|
||||||
|
ly = round(Y(last))
|
||||||
|
self.hdash(px0, px1, ly, dash=1, gap=3) # 最新价水平点规
|
||||||
|
ty = min(max(ly - 8, hi_y + 15), lo_y - 15) # 避让顶/底标签
|
||||||
|
self.rtext(x1, ty, self._fmt_price(last), self.F12, bold=1)
|
||||||
|
# ---- X 轴日期:首 / 中 / 末 ----
|
||||||
|
xl_y = py1 + 3
|
||||||
|
lab = lambda c: c.get("t") or c.get("d") or ""
|
||||||
|
self.text(px0, xl_y, lab(candles[0]), self.F12)
|
||||||
|
self.ctext((px0 + px1) / 2, xl_y, lab(candles[n // 2]), self.F12)
|
||||||
|
self.rtext(px1, xl_y, lab(candles[-1]), self.F12)
|
||||||
|
|
||||||
|
# ---------- 入口 ----------
|
||||||
|
def render(self, data):
|
||||||
|
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"
|
||||||
|
R = self.W - 10 # 右页边 790
|
||||||
|
|
||||||
|
dt, wt, mao = data["date"], data["weather"], data["mao"]
|
||||||
|
usage, gpt = data["usage"], data.get("usage_gpt") or {}
|
||||||
|
|
||||||
|
# ---- A. 顶部:大时钟 F84 占满整段(左);其右一列三行、左缘与日期对齐:① 电池 ② 温度+湿度 ③ 星期/日期(与时钟基线对齐)
|
||||||
|
# / 天气(右):上层 图标 ×1.25 | 温度 F48(顶沿与时钟齐平、右贴页边);下层 一行「定位+城市 天气 温区」右对齐、与日期同基线 ----
|
||||||
|
DIV_Y = 90
|
||||||
|
TIME_BOLD, INK_TOP = 3, 16 # 伪粗体 3px;墨迹顶沿 16,底沿 79(F84 数字墨迹高 63)
|
||||||
|
_, tt, _, tb = self.F84.getbbox(dt["time"])
|
||||||
|
time_y = INK_TOP - tt
|
||||||
|
base_y = time_y + tb # 时钟数字基线(墨迹底沿)
|
||||||
|
self.text(10, time_y, dt["time"], self.F84, bold=TIME_BOLD)
|
||||||
|
col_x = 10 + self._tw(dt["time"], self.F84) + TIME_BOLD + 14 # 右侧信息列左缘
|
||||||
|
date_txt = f'{dt["week"]} {dt["greg"]}'
|
||||||
|
_, _, _, db = self.F12.getbbox(dt["greg"]) # 用日期数字的底沿对齐基线(汉字底沿比数字低 1px,忽略)
|
||||||
|
date_y = base_y - db
|
||||||
|
self.text(col_x, date_y, date_txt, self.F12)
|
||||||
|
# 状态两行叠在日期上方:行距 22(F12 墨迹中心 ≈ 文字 y+8.5),三行中心约 30.5 / 52.5 / 74.5,落在时钟墨迹 16~79 之内
|
||||||
|
PITCH = 22
|
||||||
|
cy_date = date_y + 8.5
|
||||||
|
dev = data.get("device")
|
||||||
|
self._status_battery(col_x, cy_date - 2 * PITCH, dev)
|
||||||
|
self._status_env(col_x, cy_date - PITCH, dev)
|
||||||
|
# 天气(右,2026-09-15 用户要求)上下两层:上层 天气图标 | 实时温度 F48(右贴页边 R,墨迹顶沿 16 与时钟齐平);
|
||||||
|
# 下层 一行「定位水滴+城市 天气 温区」F12 右对齐贴 R,与日期行同基线(底沿 79,与时钟基线对齐)。
|
||||||
|
# 此前(同日上午)为「三行信息|图标 ×1.75|温度 F84 与时钟同高」,用户随即改为本版;更早为 F72「小一号」版(2026-09-14)。
|
||||||
|
loc, temp, cond, rng = wt["loc"], wt["temp"], wt["cond"], wt["range"]
|
||||||
|
FT, DEG_F = self.F48, self.F24 # 「°」小一号作上标(大字号度号又宽又高),墨迹顶沿对齐数字顶沿
|
||||||
|
has_deg = temp.endswith("°")
|
||||||
|
digits = temp[:-1] if has_deg else temp
|
||||||
|
DEG_BOLD, DEG_GAP = 1, 2
|
||||||
|
deg_w = self._tw_b("°", DEG_F, DEG_BOLD) + DEG_GAP if has_deg else 0
|
||||||
|
_, tdt, _, tdb = FT.getbbox(digits)
|
||||||
|
temp_x = R - deg_w - self._tw_b(digits, FT, TIME_BOLD)
|
||||||
|
temp_y = INK_TOP - tdt # 数字墨迹顶沿与时钟顶沿齐平(16),底沿 52
|
||||||
|
self.text(temp_x, temp_y, digits, FT, bold=TIME_BOLD)
|
||||||
|
if has_deg:
|
||||||
|
rb = DEG_F.getlength("°") - DEG_F.getmask("°").getbbox()[2] # 字形右侧空白,补回使墨迹右缘贴 R
|
||||||
|
self.rtext(R + rb, temp_y + tdt - DEG_F.getbbox("°")[1], "°", DEG_F, bold=DEG_BOLD)
|
||||||
|
temp_cy = INK_TOP + (tdb - tdt) / 2 # 温度墨迹竖直中心,图标以此居中
|
||||||
|
s = 1.25
|
||||||
|
cat = self._weather_category(wt.get("icon"))
|
||||||
|
et, eb = self._ICON_EXTENT.get(cat, (0, 38)) # 该类图标真实墨迹的上下沿(名义框 36×38 之外:多云太阳光芒探出顶部、雷阵雨闪电垂到云下)
|
||||||
|
icon_x = temp_x - 14 - 36 * s
|
||||||
|
self._weather_icon(icon_x, temp_cy - (et + eb) * s / 2, cat, s=s, accent=RED, wet=BLUE)
|
||||||
|
PIN_W, PIN_GAP, SEP = 8, 6, " "
|
||||||
|
info = f"{loc}{SEP}{cond}{SEP}{rng}"
|
||||||
|
info_x = R - self._tw(info, self.F12)
|
||||||
|
self.text(info_x, date_y, info, self.F12) # 底行与日期同基线
|
||||||
|
self._loc_pin(info_x - PIN_GAP - PIN_W, date_y + 2, PIN_W, fg=RED)
|
||||||
|
self.hdash(8, self.W - 8, DIV_Y)
|
||||||
|
|
||||||
|
# ---- B. 用量:左 Claude | 右 Codex(紧凑单行,与三色版同款)----
|
||||||
|
MID = self.W // 2 # 400
|
||||||
|
y_l = self._usage_block(10, MID - 14, DIV_Y + 6, usage)
|
||||||
|
y_r = self._usage_block(MID + 14, R, DIV_Y + 6, gpt) if gpt else y_l
|
||||||
|
self.vdash(MID, DIV_Y + 6, max(y_l, y_r) + 6)
|
||||||
|
DIV2_Y = max(y_l, y_r) + 16 # 分割线离末条进度条 16px,避免拥挤
|
||||||
|
self.hdash(8, self.W - 8, DIV2_Y)
|
||||||
|
|
||||||
|
# ---- C. 橘喵·今日实时(全宽五列)----
|
||||||
|
end_c = self._realtime(DIV2_Y + 6, mao)
|
||||||
|
DIV3_Y = end_c + 8
|
||||||
|
self.hdash(8, self.W - 8, DIV3_Y)
|
||||||
|
|
||||||
|
# ---- D. BTC/USDT 日 K ----
|
||||||
|
strip = config.E1002_COLOR_STRIP
|
||||||
|
self._btc_chart(DIV3_Y + 6, self.H - (16 if strip else 10), data.get("btc"))
|
||||||
|
|
||||||
|
# ---- C. 可选:六色测试色条 ----
|
||||||
|
if config.E1002_COLOR_STRIP:
|
||||||
|
self._color_strip(self.H - 10, 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
|
||||||
52
sensecraft_api.py
Normal file
52
sensecraft_api.py
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
SenseCraft HMI 设备遥测客户端 —— 读 reTerminal E1002 自身上报的电量 / 温度 / 湿度。
|
||||||
|
|
||||||
|
接口:GET {SENSECRAFT_API_BASE}/api/v1/user/device/iot_data/{SENSECRAFT_DEVICE_EUI},头 `api-key: <SENSECRAFT_API_KEY>`
|
||||||
|
(用户在 SenseCraft 后台生成的 API key,放 .env,不入库)。响应(2026-09-14 实测):
|
||||||
|
{"code":200,"result":{"battery":{"charging":true,"level":100},"sensor":{"humidity":42.9,"temp":30.3},
|
||||||
|
"dataaccess":{"interval":300},"devicestatus":{"status":1},...},"message":"success"}
|
||||||
|
取值:result.battery.level(%)/ result.battery.charging / result.sensor.temp(℃)/ result.sensor.humidity(%)。
|
||||||
|
设备本身每 dataaccess.interval(300s)上报一次,故 DEVICE_TTL 取 300 与之同步即可。直连、不走代理,超时 10s。
|
||||||
|
密钥未配置直接抛错 → 上层回退占位 "--"。
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
import config
|
||||||
|
|
||||||
|
TIMEOUT = 10
|
||||||
|
|
||||||
|
|
||||||
|
def get_iot_data():
|
||||||
|
"""返回 {battery(int|None), charging(bool), temp(float|None), humidity(float|None)};网络/鉴权失败抛出。"""
|
||||||
|
if not config.SENSECRAFT_API_KEY or not config.SENSECRAFT_DEVICE_EUI:
|
||||||
|
raise RuntimeError("未配置 SENSECRAFT_API_KEY / SENSECRAFT_DEVICE_EUI")
|
||||||
|
url = f"{config.SENSECRAFT_API_BASE.rstrip('/')}/api/v1/user/device/iot_data/{config.SENSECRAFT_DEVICE_EUI}"
|
||||||
|
req = urllib.request.Request(url, headers={"api-key": config.SENSECRAFT_API_KEY, "Accept": "application/json",
|
||||||
|
"User-Agent": "eink-push/sensecraft_api"})
|
||||||
|
opener = urllib.request.build_opener(urllib.request.ProxyHandler({}))
|
||||||
|
try:
|
||||||
|
with opener.open(req, timeout=TIMEOUT) as resp:
|
||||||
|
body = json.loads(resp.read().decode("utf-8"))
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
raise RuntimeError(f"HTTP {e.code}:{e.read().decode('utf-8', 'replace')[:200]}") from e
|
||||||
|
if body.get("code") != 200:
|
||||||
|
raise RuntimeError(f"接口返回 code={body.get('code')} message={body.get('message')}")
|
||||||
|
r = body.get("result") or {}
|
||||||
|
bat, sen = r.get("battery") or {}, r.get("sensor") or {}
|
||||||
|
return {"battery": _num(bat.get("level"), int), "charging": bool(bat.get("charging")),
|
||||||
|
"temp": _num(sen.get("temp"), float), "humidity": _num(sen.get("humidity"), float)}
|
||||||
|
|
||||||
|
|
||||||
|
def _num(v, cast):
|
||||||
|
try:
|
||||||
|
return cast(v) if v is not None else None
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
print(get_iot_data())
|
||||||
342
server.py
Normal file
342
server.py
Normal file
@@ -0,0 +1,342 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
图片/网页服务 —— 给 SenseCraft HMI 的 HTML 控件周期抓取的**恒定 URL**,每次请求都**现取数据、现渲染**,全程内存、不落盘。
|
||||||
|
|
||||||
|
真机验证结论(2026-09):SenseCraft 的 Image 控件只在填写 URL 时抓一次快照、此后不再更新;
|
||||||
|
HTML 控件填同一个 URL 则**每个刷新周期都重抓**。所以对外只暴露 HTML 控件用的 URL,两种形态并行供对比:
|
||||||
|
方案一 GET <IMAGE_PATH>(默认 /e1002.png)→ image/png:与 main.py --target e1002 同一套 Pillow 点阵渲染(renderer_e1002)
|
||||||
|
方案二 GET <HTML_PATH> (默认 /e1002.html)→ text/html:800x480 的现代网页(html_renderer,系统/网络字体 + 内联 SVG K 线),
|
||||||
|
由平台无头浏览器截图后量化(真机实拍:文字抗锯齿灰边被平台抖动成毛边、小字糊)
|
||||||
|
方案三 GET <HTML_PNG_PATH>(默认 /e1002-web.png)→ image/png:同一网页由**服务端**无头 Chrome 截图,再按最近色(不抖动)
|
||||||
|
固化为六色 PNG(html_shot),平台拿到的已是纯色图;加 ?raw=1 返回未量化的原始截图供对照。需服务器装 Chrome。
|
||||||
|
两者共用同一份 build_data():
|
||||||
|
- 日期时间:服务器按 config.TZ_NAME 时区现算
|
||||||
|
- 天气 / 橘喵今日实时 / BTC K 线:**内存缓存 + 有效期**(WEATHER_TTL / MAO_TTL / BTC_TTL,.env 可配,0=每次实拉):
|
||||||
|
收到请求先查该路缓存是否过期,过期才实拉并更新时间戳,否则直接用缓存渲染;拉取失败沿用旧值、不更新时间戳(下次再试),
|
||||||
|
从未成功则占位 "--"。整个服务**不读本机任何文件**(部署在公网服务器)。
|
||||||
|
- Claude Usage / Codex Usage:**不主动拉取、不持凭证**,由本机 `main.py --target push` 采集后推送原始快照到 POST /push/usage、/push/usage_gpt,
|
||||||
|
服务端暂存最新一份(内存;PUSH_STATE_PATH 非空时同时落盘,重启恢复);渲染时按当前时刻现算 pace,
|
||||||
|
- 三路上游过期时**并行**实拉,整体最多等 FETCH_BUDGET 秒(默认 5),超预算的一路本次用旧值、后台拉完更新缓存(见 build_data);
|
||||||
|
所以推送时机与设备抓取时机不必对齐。未推送过:Claude 显示 "--",ChatGPT 用 mock 并标「示例数据」。
|
||||||
|
- 推送鉴权:请求头 X-Push-Token 必须等于 PUSH_TOKEN;PUSH_TOKEN 为空时拒绝一切推送(服务在公网,务必配置)。
|
||||||
|
- 页面鉴权:IMAGE_TOKEN 非空时,三个渲染端点必须带 ?token=<IMAGE_TOKEN>,否则返回与未知路径一样的 404(防爬虫扫描)。
|
||||||
|
其他端点:GET /health → JSON 运行状态;GET /push/usage、/push/usage_gpt → 查看暂存的原始快照(需同样令牌)。
|
||||||
|
部署:python3 server.py(监听 SERVER_HOST:SERVER_PORT),前置 Caddy/Nginx 做 HTTPS,或 cloudflared tunnel。
|
||||||
|
"""
|
||||||
|
import hmac
|
||||||
|
import io
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
import urllib.parse
|
||||||
|
from concurrent.futures import ThreadPoolExecutor, wait
|
||||||
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||||
|
|
||||||
|
import config
|
||||||
|
import data
|
||||||
|
from renderer_e1002 import E1002Renderer
|
||||||
|
import html_renderer
|
||||||
|
import html_shot
|
||||||
|
|
||||||
|
|
||||||
|
class State:
|
||||||
|
"""服务端内存状态:推送来的原始用量快照 + 上游数据的「上一次成功值」+ 计数。"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.lock = threading.Lock()
|
||||||
|
self.raw = {"usage": None, "usage_gpt": None}
|
||||||
|
self.pushed_at = {"usage": None, "usage_gpt": None}
|
||||||
|
self.last_good = {"weather": None, "mao": None, "btc": None, "device": None} # 各路最近一次成功的数据
|
||||||
|
self.fetched_at = {"weather": 0.0, "mao": 0.0, "btc": 0.0, "device": 0.0} # 各路最近一次成功拉取的时间戳(缓存有效期起点)
|
||||||
|
self.inflight = {} # 进行中的拉取 future(key → Future),防同一路重复实拉
|
||||||
|
self.renders = {"png": 0, "html": 0}
|
||||||
|
self.last_render = None
|
||||||
|
self.started = time.time()
|
||||||
|
self._load()
|
||||||
|
|
||||||
|
# ---- 推送数据持久化(可选)----
|
||||||
|
def _load(self):
|
||||||
|
p = config.PUSH_STATE_PATH
|
||||||
|
if not p or not os.path.exists(p):
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
with open(p, encoding="utf-8") as f:
|
||||||
|
st = json.load(f)
|
||||||
|
self.raw.update(st.get("raw") or {})
|
||||||
|
self.pushed_at.update(st.get("pushed_at") or {})
|
||||||
|
print(f"[状态] 已从 {p} 恢复推送数据")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[警告] 推送状态恢复失败:{e}")
|
||||||
|
|
||||||
|
def _save(self):
|
||||||
|
p = config.PUSH_STATE_PATH
|
||||||
|
if not p:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
tmp = p + ".tmp"
|
||||||
|
with open(tmp, "w", encoding="utf-8") as f:
|
||||||
|
json.dump({"raw": self.raw, "pushed_at": self.pushed_at}, f, ensure_ascii=False)
|
||||||
|
os.replace(tmp, p)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[警告] 推送状态落盘失败:{e}")
|
||||||
|
|
||||||
|
def push(self, kind, raw):
|
||||||
|
with self.lock:
|
||||||
|
self.raw[kind] = raw
|
||||||
|
self.pushed_at[kind] = time.time()
|
||||||
|
self._save()
|
||||||
|
|
||||||
|
|
||||||
|
STATE = State()
|
||||||
|
_RENDER_LOCK = threading.Lock() # Pillow 渲染器持有 self.img/self.d,非线程安全
|
||||||
|
_PNG_RENDERER = E1002Renderer()
|
||||||
|
|
||||||
|
|
||||||
|
_POOL = ThreadPoolExecutor(max_workers=6, thread_name_prefix="fetch")
|
||||||
|
|
||||||
|
|
||||||
|
def _refresh(key, fetch):
|
||||||
|
"""在线程池里实拉一路上游:成功则更新 last_good/fetched_at;无论成败都清掉 inflight。返回 (data, ok, 耗时秒)。"""
|
||||||
|
t0 = time.time()
|
||||||
|
try:
|
||||||
|
d, ok = fetch()
|
||||||
|
except Exception as e: # noqa: BLE001 —— 上游任何异常都按失败处理
|
||||||
|
print(f"[警告] {key} 拉取异常:{e}")
|
||||||
|
d, ok = None, False
|
||||||
|
with STATE.lock:
|
||||||
|
if ok:
|
||||||
|
STATE.last_good[key], STATE.fetched_at[key] = d, time.time()
|
||||||
|
STATE.inflight.pop(key, None)
|
||||||
|
return d, ok, time.time() - t0
|
||||||
|
|
||||||
|
|
||||||
|
def _cached(key, ttl, fetch):
|
||||||
|
"""带有效期的内存缓存(提交阶段):未过期 → (缓存, 状态串, None);过期 → 提交到线程池实拉,返回 (None, None, future)。
|
||||||
|
同一路已有进行中的拉取(上一请求超预算后仍在跑)则复用其 future,不重复打上游。"""
|
||||||
|
now = time.time()
|
||||||
|
with STATE.lock:
|
||||||
|
cached, ts = STATE.last_good[key], STATE.fetched_at[key]
|
||||||
|
if cached is not None and ttl > 0 and now - ts < ttl:
|
||||||
|
return cached, f"{key}:cache({int(now - ts)}s)", None
|
||||||
|
fut = STATE.inflight.get(key)
|
||||||
|
if fut is None:
|
||||||
|
fut = _POOL.submit(_refresh, key, fetch)
|
||||||
|
STATE.inflight[key] = fut
|
||||||
|
return None, None, fut
|
||||||
|
|
||||||
|
|
||||||
|
def _settle(key, fut, fallback):
|
||||||
|
"""预算等待结束后结算一路:完成 → fetched(耗时)/stale/none;未完成 → 用上一次成功值(slow→stale)或占位(slow→none),
|
||||||
|
拉取线程继续跑完并写入缓存供下次请求使用(stale-while-revalidate)。"""
|
||||||
|
with STATE.lock:
|
||||||
|
cached = STATE.last_good[key]
|
||||||
|
if fut.done():
|
||||||
|
d, ok, dt = fut.result()
|
||||||
|
if ok:
|
||||||
|
return d, f"{key}:fetched({dt:.2f}s)"
|
||||||
|
return (cached, f"{key}:stale") if cached is not None else (fallback, f"{key}:none")
|
||||||
|
return (cached, f"{key}:slow→stale") if cached is not None else (fallback, f"{key}:slow→none")
|
||||||
|
|
||||||
|
|
||||||
|
def build_data(now=None):
|
||||||
|
"""组装渲染数据:三路上游按各自 TTL 走内存缓存;过期的**并行**实拉,整体最多等 FETCH_BUDGET 秒,
|
||||||
|
超预算的那路本次用旧值/占位、后台继续拉完更新缓存——响应时间上限 ≈ 预算 + 渲染,任何一路上游卡死都不再拖垮整图。
|
||||||
|
(2026-09-14 前为串行实拉、各上游 socket 超时 30s,单路卡住即整体超时。)用量取推送暂存。返回 (data, 各路状态列表)。"""
|
||||||
|
now = now or data.now_tz()
|
||||||
|
with STATE.lock:
|
||||||
|
raw_usage, raw_gpt = STATE.raw["usage"], STATE.raw["usage_gpt"]
|
||||||
|
|
||||||
|
fallbacks = {
|
||||||
|
"weather": {"loc": config.WEATHER_LOC_NAME, "cond": "--", "temp": "--", "range": "--", "icon": ""},
|
||||||
|
"mao": {"upd": now.strftime("%H:%M"), "cols": [data._placeholder_col(k) for k in ("单量", "流水", "毛利")],
|
||||||
|
"trend": {"dates": [], "series": []}},
|
||||||
|
"btc": data.btc_empty(),
|
||||||
|
"device": data.device_empty(),
|
||||||
|
}
|
||||||
|
fetchers = {
|
||||||
|
"weather": (config.WEATHER_TTL, data.weather_fetch),
|
||||||
|
"mao": (config.MAO_TTL, lambda: data.mao_fetch(now, with_trend=False)),
|
||||||
|
"btc": (config.BTC_TTL, lambda: (data.btc_fetch(), True)),
|
||||||
|
"device": (config.DEVICE_TTL, data.device_fetch),
|
||||||
|
}
|
||||||
|
result, status, pending = {}, {}, {}
|
||||||
|
for key, (ttl, fetch) in fetchers.items():
|
||||||
|
d, st, fut = _cached(key, ttl, fetch)
|
||||||
|
if fut is None:
|
||||||
|
result[key], status[key] = d, st
|
||||||
|
else:
|
||||||
|
pending[key] = fut
|
||||||
|
if pending:
|
||||||
|
wait(pending.values(), timeout=config.FETCH_BUDGET)
|
||||||
|
for key, fut in pending.items():
|
||||||
|
result[key], status[key] = _settle(key, fut, fallbacks[key])
|
||||||
|
|
||||||
|
d = {
|
||||||
|
"date": {"greg": now.strftime("%Y/%m/%d"), "week": data.WEEK_CN[now.weekday()], "time": now.strftime("%H:%M")},
|
||||||
|
"weather": result["weather"],
|
||||||
|
"mao": result["mao"],
|
||||||
|
"usage": data._usage(now, raw=raw_usage if raw_usage is not None else {}), # 未推送 → 全部 "--"(不读本机文件)
|
||||||
|
"usage_gpt": data._usage_gpt(now, raw=raw_gpt, allow_local=False), # 未推送 → mock(标「示例数据」),服务端不本地采集
|
||||||
|
"btc": result["btc"],
|
||||||
|
"device": result["device"],
|
||||||
|
}
|
||||||
|
return d, [status[k] for k in ("weather", "mao", "btc", "device")]
|
||||||
|
|
||||||
|
|
||||||
|
def render_png_bytes(d):
|
||||||
|
"""渲染 PNG 到内存字节。"""
|
||||||
|
with _RENDER_LOCK:
|
||||||
|
img = _PNG_RENDERER.render(d)
|
||||||
|
buf = io.BytesIO()
|
||||||
|
img.save(buf, "PNG", optimize=True)
|
||||||
|
return buf.getvalue()
|
||||||
|
|
||||||
|
|
||||||
|
class Handler(BaseHTTPRequestHandler):
|
||||||
|
server_version = "eink-push/1.0"
|
||||||
|
protocol_version = "HTTP/1.1"
|
||||||
|
|
||||||
|
def log_message(self, fmt, *args):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def _log(self, code, note=""):
|
||||||
|
ua = self.headers.get("User-Agent", "-")
|
||||||
|
ip = self.headers.get("CF-Connecting-IP") or self.headers.get("X-Forwarded-For") or self.client_address[0]
|
||||||
|
print(f"[{time.strftime('%H:%M:%S')}] {code} {self.command} {self.path} ip={ip} ua={ua[:60]} {note}", flush=True)
|
||||||
|
|
||||||
|
def _send(self, code, ctype, body, extra=None):
|
||||||
|
self.send_response(code)
|
||||||
|
self.send_header("Content-Type", ctype)
|
||||||
|
self.send_header("Content-Length", str(len(body)))
|
||||||
|
self.send_header("Cache-Control", "no-store, no-cache, must-revalidate, max-age=0")
|
||||||
|
self.send_header("Pragma", "no-cache")
|
||||||
|
self.send_header("Expires", "0")
|
||||||
|
for k, v in (extra or {}).items():
|
||||||
|
self.send_header(k, v)
|
||||||
|
self.end_headers()
|
||||||
|
if self.command != "HEAD":
|
||||||
|
self.wfile.write(body)
|
||||||
|
|
||||||
|
def _json(self, code, obj):
|
||||||
|
self._send(code, "application/json; charset=utf-8", json.dumps(obj, ensure_ascii=False).encode("utf-8"))
|
||||||
|
|
||||||
|
def _authed(self):
|
||||||
|
tok = config.PUSH_TOKEN
|
||||||
|
return bool(tok) and hmac.compare_digest(self.headers.get("X-Push-Token", ""), tok)
|
||||||
|
|
||||||
|
def _page_authed(self):
|
||||||
|
"""页面令牌:IMAGE_TOKEN 为空则放行;否则查询串里 token 必须相等(常量时间比较)。"""
|
||||||
|
tok = config.IMAGE_TOKEN
|
||||||
|
if not tok:
|
||||||
|
return True
|
||||||
|
qs = urllib.parse.parse_qs(urllib.parse.urlsplit(self.path).query)
|
||||||
|
return any(hmac.compare_digest(v, tok) for v in qs.get("token", []))
|
||||||
|
|
||||||
|
# ---------- GET ----------
|
||||||
|
def do_GET(self):
|
||||||
|
path = self.path.split("?", 1)[0]
|
||||||
|
try:
|
||||||
|
if path in (config.IMAGE_PATH, config.HTML_PATH, config.HTML_PNG_PATH) and not self._page_authed():
|
||||||
|
# 令牌不对与路径不存在返回完全一样的 404,不暴露端点存在
|
||||||
|
self._log(404, "bad page token")
|
||||||
|
return self._json(404, {"ok": False, "error": "not found"})
|
||||||
|
if path == config.IMAGE_PATH:
|
||||||
|
t0 = time.time()
|
||||||
|
d, st = build_data()
|
||||||
|
body = render_png_bytes(d)
|
||||||
|
with STATE.lock:
|
||||||
|
STATE.renders["png"] += 1
|
||||||
|
STATE.last_render = time.time()
|
||||||
|
self._log(200, f"png {len(body)}B {time.time() - t0:.2f}s [{' '.join(st)}]")
|
||||||
|
return self._send(200, "image/png", body)
|
||||||
|
if path == config.HTML_PATH:
|
||||||
|
t0 = time.time()
|
||||||
|
d, st = build_data()
|
||||||
|
body = html_renderer.render_html(d).encode("utf-8")
|
||||||
|
with STATE.lock:
|
||||||
|
STATE.renders["html"] += 1
|
||||||
|
STATE.last_render = time.time()
|
||||||
|
self._log(200, f"html {len(body)}B {time.time() - t0:.2f}s [{' '.join(st)}]")
|
||||||
|
return self._send(200, "text/html; charset=utf-8", body)
|
||||||
|
if path == config.HTML_PNG_PATH:
|
||||||
|
t0 = time.time()
|
||||||
|
raw = "raw=1" in (self.path.split("?", 1) + [""])[1]
|
||||||
|
d, st = build_data()
|
||||||
|
html = html_renderer.render_html(d)
|
||||||
|
img = html_shot.screenshot_html(html)
|
||||||
|
if not raw:
|
||||||
|
img = html_shot.quantize6(img)
|
||||||
|
buf = io.BytesIO(); img.save(buf, "PNG", optimize=True); body = buf.getvalue()
|
||||||
|
with STATE.lock:
|
||||||
|
STATE.renders["web_png"] = STATE.renders.get("web_png", 0) + 1
|
||||||
|
STATE.last_render = time.time()
|
||||||
|
self._log(200, f"web-png{'(raw)' if raw else ''} {len(body)}B {time.time() - t0:.2f}s [{' '.join(st)}]")
|
||||||
|
return self._send(200, "image/png", body)
|
||||||
|
if path == "/health":
|
||||||
|
with STATE.lock:
|
||||||
|
st = {"ok": True, "uptime_s": int(time.time() - STATE.started), "renders": dict(STATE.renders),
|
||||||
|
"last_render": STATE.last_render, "pushed_at": dict(STATE.pushed_at),
|
||||||
|
"has_last_good": {k: v is not None for k, v in STATE.last_good.items()},
|
||||||
|
"cache_age_s": {k: (int(time.time() - v) if v else None) for k, v in STATE.fetched_at.items()},
|
||||||
|
"ttl_s": {"weather": config.WEATHER_TTL, "mao": config.MAO_TTL, "btc": config.BTC_TTL, "device": config.DEVICE_TTL},
|
||||||
|
"fetch_budget_s": config.FETCH_BUDGET, "inflight": sorted(STATE.inflight.keys()),
|
||||||
|
"image_path": config.IMAGE_PATH, "html_path": config.HTML_PATH,
|
||||||
|
"html_png_path": config.HTML_PNG_PATH, "tz": config.TZ_NAME}
|
||||||
|
self._log(200)
|
||||||
|
return self._json(200, st)
|
||||||
|
if path in ("/push/usage", "/push/usage_gpt"):
|
||||||
|
if not self._authed():
|
||||||
|
self._log(401); return self._json(401, {"ok": False, "error": "bad token"})
|
||||||
|
kind = path.rsplit("/", 1)[1]
|
||||||
|
with STATE.lock:
|
||||||
|
raw, at = STATE.raw[kind], STATE.pushed_at[kind]
|
||||||
|
self._log(200)
|
||||||
|
return self._json(200, {"ok": True, "kind": kind, "pushed_at": at, "raw": raw})
|
||||||
|
if path == "/favicon.ico":
|
||||||
|
return self._send(204, "image/x-icon", b"")
|
||||||
|
self._log(404)
|
||||||
|
self._json(404, {"ok": False, "error": "not found"})
|
||||||
|
except Exception as e: # noqa: BLE001 —— 任何渲染异常都返回 500 而不是断开连接
|
||||||
|
self._log(500, repr(e))
|
||||||
|
self._json(500, {"ok": False, "error": repr(e)})
|
||||||
|
|
||||||
|
do_HEAD = do_GET
|
||||||
|
|
||||||
|
# ---------- POST:推送 ----------
|
||||||
|
def do_POST(self):
|
||||||
|
path = self.path.split("?", 1)[0]
|
||||||
|
if path not in ("/push/usage", "/push/usage_gpt"):
|
||||||
|
self._log(404); return self._json(404, {"ok": False, "error": "not found"})
|
||||||
|
if not self._authed():
|
||||||
|
self._log(401); return self._json(401, {"ok": False, "error": "bad token"})
|
||||||
|
try:
|
||||||
|
n = int(self.headers.get("Content-Length") or 0)
|
||||||
|
if n <= 0 or n > 64 * 1024:
|
||||||
|
raise ValueError(f"bad content-length {n}")
|
||||||
|
raw = json.loads(self.rfile.read(n).decode("utf-8"))
|
||||||
|
if not isinstance(raw, dict):
|
||||||
|
raise ValueError("body must be a JSON object")
|
||||||
|
except Exception as e: # noqa: BLE001
|
||||||
|
self._log(400, repr(e)); return self._json(400, {"ok": False, "error": f"bad json: {e}"})
|
||||||
|
kind = path.rsplit("/", 1)[1]
|
||||||
|
STATE.push(kind, raw)
|
||||||
|
self._log(200, f"push {kind} keys={sorted(raw)[:6]}")
|
||||||
|
self._json(200, {"ok": True, "kind": kind, "received_at": time.time()})
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
if not config.PUSH_TOKEN:
|
||||||
|
print("[警告] PUSH_TOKEN 未配置:将拒绝一切推送,用量区会一直显示 \"--\"")
|
||||||
|
srv = ThreadingHTTPServer((config.SERVER_HOST, config.SERVER_PORT), Handler)
|
||||||
|
print(f"服务已启动 http://{config.SERVER_HOST}:{config.SERVER_PORT} 方案一 {config.IMAGE_PATH} 方案二 {config.HTML_PATH} "
|
||||||
|
f"方案三 {config.HTML_PNG_PATH} /health", flush=True)
|
||||||
|
try:
|
||||||
|
srv.serve_forever()
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
62
usage_common.py
Normal file
62
usage_common.py
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
用量采集的公共骨架 —— 本地缓存 + 有效期(TTL)+ 失败退避(Claude / Codex 两个采集器共用)。
|
||||||
|
|
||||||
|
为什么需要:launchd 每 5 分钟起两个独立进程(小屏渲染 + 上报),且用户可能手动连跑几次;
|
||||||
|
账户级用量接口对突发调用敏感(Anthropic 的 /api/oauth/usage 一分钟内几十次即 429 并封约 24 小时,
|
||||||
|
连带 Claude Code `/usage` 与 claude.ai 一起看不到用量)。故:
|
||||||
|
- 距上次成功拉取不足 TTL 秒(CLAUDE_USAGE_TTL / CODEX_USAGE_TTL,语义同 WEATHER_TTL/MAO_TTL/BTC_TTL,0=每次实拉)→ 直接用缓存,不发请求;
|
||||||
|
- 拉取失败 → 沿用旧缓存(标 stale),并按 BACKOFF 秒退避,退避期内不再尝试;异常对象带 `backoff` 属性时按它退避
|
||||||
|
(claude_usage.TokenUnavailable = 一个 TTL:token 过期是本机状态、Claude Code 随时可能刷新,不必等满 BACKOFF);
|
||||||
|
- 从未成功且本次失败 → 抛出,由上层回退占位 "--"。
|
||||||
|
缓存文件只存快照与时间戳,**不存任何凭证**。
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
|
||||||
|
|
||||||
|
def cached_snapshot(path, ttl, backoff, fetch, force=False):
|
||||||
|
"""返回 (snapshot, status)。status ∈ {"fetched", "cache", "stale", "backoff"}。
|
||||||
|
fetch() 需返回可 JSON 序列化的快照 dict(含 updated_at)。"""
|
||||||
|
state = _load(path)
|
||||||
|
now = time.time()
|
||||||
|
snap = state.get("snapshot")
|
||||||
|
if not force and snap and now - state.get("fetched_at", 0) < ttl:
|
||||||
|
return snap, "cache"
|
||||||
|
if not force and state.get("backoff_until", 0) > now:
|
||||||
|
if snap:
|
||||||
|
return snap, "backoff"
|
||||||
|
raise RuntimeError(f"退避期内(至 {time.strftime('%H:%M:%S', time.localtime(state['backoff_until']))}),且无旧快照")
|
||||||
|
try:
|
||||||
|
snap_new = fetch()
|
||||||
|
except Exception as e:
|
||||||
|
state["backoff_until"] = now + getattr(e, "backoff", backoff) # 异常可自带更短退避(如本机 token 过期,只需等一个 TTL)
|
||||||
|
state["last_error"] = f"{type(e).__name__}: {e}"[:300]
|
||||||
|
_save(path, state)
|
||||||
|
if snap:
|
||||||
|
print(f"[用量] 拉取失败,沿用旧快照:{e}")
|
||||||
|
return snap, "stale"
|
||||||
|
raise
|
||||||
|
state = {"fetched_at": now, "snapshot": snap_new}
|
||||||
|
_save(path, state)
|
||||||
|
return snap_new, "fetched"
|
||||||
|
|
||||||
|
|
||||||
|
def _load(path):
|
||||||
|
try:
|
||||||
|
with open(path, encoding="utf-8") as f:
|
||||||
|
return json.load(f)
|
||||||
|
except Exception:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def _save(path, state):
|
||||||
|
tmp = path + ".tmp"
|
||||||
|
try:
|
||||||
|
with open(tmp, "w", encoding="utf-8") as f:
|
||||||
|
json.dump(state, f, ensure_ascii=False)
|
||||||
|
os.replace(tmp, path)
|
||||||
|
except Exception as e: # 缓存写不进去不算致命
|
||||||
|
print(f"[用量] 缓存写入失败:{e}")
|
||||||
Reference in New Issue
Block a user