Compare commits

...

11 Commits

Author SHA1 Message Date
YANG JIANKUAN
ea26f41298 feat: E1002 今日实时团购两列替换为赏金单量/赏金流水(含环比/同比)
- REALTIME_COLS 改取接口 bountyOrderCount/bountyPayAmount,旧后端缺字段时占位 --
- 毛利口径注释同步为含当天审核通过的赏金;小屏仍只显示三列不受影响

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 15:43:51 +08:00
06785f030b docs: 记录 NGCQ 上 Zectrix 版(~/eink-push-zectrix)与 E1002 服务并存、部署时不可覆盖
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-18 11:48:39 +08:00
04fa31cb94 feat: server.py 迁移部署到 NGCQ(1Panel Nginx 反代 eink.njcqit.com)+境内可达 BTC 行情源+页面访问令牌
- btc_api:新增 gateio/huobi_aws/binance_vision 三源,config 增 BTC_SOURCES 控制尝试顺序(境内 okx/coinbase/huobi 主域名不通)
- server:IMAGE_TOKEN 非空时三个渲染端点须带 ?token=…,否则与未知路径同样 404(防爬虫扫描);推送令牌改常量时间比较
- 文档:CLAUDE.md/README/.env.example 记录 NGCQ 部署(systemd eink-server、1Panel API 建站坑)、本机 server/cloudflared 已卸载

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-18 11:42:23 +08:00
7c60d3680c feat: E1002 右上角天气改「图标|F48 温度」在上、一行城市·天气·温区右对齐在下
温度 F48 粗 3 顶沿与时钟齐平、「°」F24 上标、右贴页边;底行「定位水滴+城市  天气  温区」
与日期同基线右对齐。图标 1.25 倍并按各类别真实墨迹范围(_ICON_EXTENT,多云太阳光芒探出顶部、
雷阵雨闪电垂到云下)竖直居中于温度中心,避免顶到页边或越过横规。移除不再使用的 F72。
同步 CLAUDE.md 版式说明并记录同日被否的「三行信息|图标 ×1.75|F84 温度」中间版。

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-15 16:53:05 +08:00
c836b0d6bc docs: CLAUDE.md 记录 server.py/cloudflared 改由 launchd 常驻及运维命令
- 说明 nohup 启动会随 Claude Code 会话回收被 SIGTERM、重启后不自起的事故原因
- 记录两个 LaunchAgent 的 label、kickstart/bootout 操作,原手工命令降级为应急参考

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-15 10:59:12 +08:00
cf9aa3b1bb feat: E1002 右上角天气改「城市天气|图标|F72 大号温度」
- 实时温度改 F72 粗 3(先做过 F84 与时钟同高被嫌大),与时钟同基线、右贴页边
- 「°」不用大字号(F84 下 35px 宽且比数字高 7px),改 F36 粗 2 作上标,顶沿对齐数字顶沿、补回字形右侧空白使墨迹贴页边
- 定位+城市 / 天气·温区两行左缘对齐、底行与时钟同基线,天气图标居中放在两行与温度之间
- 此前为「城市 / F24 温度 / 天气·温区」三行右对齐+图标在左

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-14 17:56:27 +08:00
3656d8d933 fix: 12px 伪粗体文字自动加 1px 字距,消除相邻字粘连
- 12px 点阵字形间只有 1 列空隙,伪粗体右偏 1px 正好填满导致左右重叠;13/24px 空隙 2 列不受影响
- 基类新增 _track():12px 且加粗时自动加 k 字距,text/rtext/ctext/_mid 统一走它,宽版映射到 12px 的 F13/F16/FPACE 同样命中
- 新增 _tw_b() 取含粗体偏移与字距的实际占宽,替换环比/同比居中、金额前后缀、状态栏推进、BTC 涨跌幅、用量行进度条右端的手写宽度
- 夹具回归:小屏 F13/F16 部分逐像素不变,各行进度条右端仍齐平

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-14 17:34:41 +08:00
3fa6bb5de6 feat: 今日实时 0% 画等号+金额小号 ¥/万,数值与状态栏数字加粗
- 环比/同比增长率恰为 0 时方向记 flat,三角位画等号(与用量行 pace 持平一致)
- 金额拆为「¥ / 主数字 / 万」三段,¥ 与 万 用 F12 小号伪粗体、与 F24 主数字基线对齐;单量列不变
- 环比/同比数值、BTC 涨跌幅、顶部电量/温度/湿度数字改 F12 伪粗体(字号不变),占位 -- 不加粗
- 等号绘制抽为基类 _eq 供 pace 与环比/同比共用;网页版 flat 同样输出等号

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-14 17:10:21 +08:00
d35f233ef3 fix: 用量行 pace 列真 0 窗口不再缺席,diff=0 三角位画等号
Claude 5h 空闲时接口回 utilization 0、resets_at null,算不出 time_pct,
渲染器整列跳过 pace 导致该行进度条比别行长。数据层对 pct=0 且无重置时刻的
窗口记 time_pct=0;基类 _usage_row 只要有百分比就画 pace(缺 time_pct 按 0)。
diff=0 时三角位由留空改为画等宽等号「=」,预留逻辑从宽版下沉到基类,小屏同样齐平。

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-14 15:33:21 +08:00
f96ae29590 feat: E1002 顶部加设备电量/温湿度列+server 四路上游并行拉取与响应预算
- 新增 SenseCraft 设备遥测客户端(api-key 走 .env),读电量/充电/温度/湿度,DEVICE_TTL 与设备 300s 上报周期同步
- 大时钟 F84 右侧一列三行左缘对齐:电池(充电绿/≤20% 红)、温度计+蓝色水滴、星期日期(与时钟基线对齐)
- server 过期上游由串行改并行,整体最多等 FETCH_BUDGET 秒(默认 5);超预算的一路本次用旧值、后台拉完回填缓存,同一路不重复实拉
- 此前串行且上游 socket 超时 30s,任一路卡住即整图拖到平台抓取超时;「多路 TTL 同时到期」猜想经日志核查不成立,排查顺序写入 CLAUDE.md

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-14 15:02:34 +08:00
7378d6bb88 feat: AI 用量改账户级本机采集(Claude 钥匙串/Codex RPC)+宽版 7d 合并行
- Claude:复用 Claude Code 钥匙串登录态直调 /api/oauth/usage,零依赖 statusline 与 Claude Code 进程;只读不刷新 token
- Codex:经 codex app-server JSON-RPC 读 ChatGPT 订阅的 Codex 额度池(普通聊天额度无账户级途径,右块改名 Codex Usage)
- 两路采集共用 TTL 缓存+失败退避(CLAUDE_USAGE_TTL/CODEX_USAGE_TTL,语义同 WEATHER_TTL),推送端两路独立上报
- E1002:Claude 7d 与 fable 7d 合并为一行(单边框内上下对半实心,数字取较大者)、标签去 all 前缀、空值按 00% 00 占位等宽
- E1002/网页版:今日实时环比同比改涨绿跌红,与 K 线一致

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-14 14:08:21 +08:00
18 changed files with 1020 additions and 216 deletions

View File

@@ -18,6 +18,11 @@ 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)---- # ---- reTerminal E1002 图片/网页服务(server.py,部署在公网;本机推送端 main.py --target push)----
# 服务端:监听地址/端口、两条恒定 URL 路径(可改成难猜的路径)、推送令牌(为空则拒绝一切推送)、推送数据落盘路径(空=仅内存) # 服务端:监听地址/端口、两条恒定 URL 路径(可改成难猜的路径)、推送令牌(为空则拒绝一切推送)、推送数据落盘路径(空=仅内存)
SERVER_HOST=0.0.0.0 SERVER_HOST=0.0.0.0
@@ -30,6 +35,9 @@ CHROME_BIN=
CHROME_EXTRA_ARGS= CHROME_EXTRA_ARGS=
HTML_PNG_THRESHOLD=160 HTML_PNG_THRESHOLD=160
PUSH_TOKEN= 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_STATE_PATH=
# 推送端(本机):服务地址(协议+域名/端口,不含路径),令牌与服务端一致 # 推送端(本机):服务地址(协议+域名/端口,不含路径),令牌与服务端一致
PUSH_URL= PUSH_URL=
@@ -42,5 +50,21 @@ PACE_TOL=10
BTC_BAR=1H BTC_BAR=1H
BTC_LIMIT=48 BTC_LIMIT=48
BTC_TTL=300 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 # 服务端(server.py)三路上游的内存缓存有效期(秒,0=每次实拉):天气/BTC 复用 WEATHER_TTL/BTC_TTL,橘喵实时用 MAO_TTL
MAO_TTL=60 MAO_TTL=60
# 服务端一次请求等待上游并行实拉的总预算(秒),超预算的一路本次用旧值、后台拉完更新缓存
FETCH_BUDGET=5

3
.gitignore vendored
View File

@@ -4,5 +4,8 @@ __pycache__/
.weather_cache.json .weather_cache.json
.trend_cache.json .trend_cache.json
.btc_cache.json .btc_cache.json
.device_cache.json
.claude_usage_cache.json
.codex_usage_cache.json
run.sh run.sh
.env .env

File diff suppressed because one or more lines are too long

View File

@@ -57,7 +57,8 @@ python3 main.py --target e1002 # reTerminal E1002(800x480 六色):产出 o
python3 server.py # reTerminal E1002 动态服务(部署公网 HTTPS):/e1002.png 每次请求内存渲染 PNG,/e1002.html 现代网页, python3 server.py # reTerminal E1002 动态服务(部署公网 HTTPS):/e1002.png 每次请求内存渲染 PNG,/e1002.html 现代网页,
# /e1002-web.png 网页经服务端无头 Chrome 截图后固化六色(需装 Chrome); # /e1002-web.png 网页经服务端无头 Chrome 截图后固化六色(需装 Chrome);
# 天气/橘喵/BTC 实时拉取,Claude/ChatGPT 用量由本机推送 # 天气/橘喵/BTC 实时拉取,Claude/ChatGPT 用量由本机推送
python3 main.py --target push # 本机:推送 ~/.claude/usage-snapshot.json 到 server.py(.env: PUSH_URL + PUSH_TOKEN) 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`):

View File

@@ -7,7 +7,13 @@
1. OKX GET /api/v5/market/candles?instId=BTC-USDT&bar=<1H|1D> —— 主源,本地直连可达、数据最新 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 数值几乎一致) 2. Coinbase GET /products/BTC-USD/candles?granularity=<3600|86400> —— 备源(USD 计价,与 USDT 数值几乎一致)
3. Huobi GET /market/history/kline?period=<60min|1day>&symbol=btcusdt —— 备源 3. Huobi GET /market/history/kline?period=<60min|1day>&symbol=btcusdt —— 备源
(Binance 在本地区返回 restricted location,不列入。) 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 映射到各源的写法。 周期用统一代号 bar:"1H"(时 K)/ "1D"(日 K),由 BARS 映射到各源的写法。
统一输出:按时间**升序**的 list[dict],每根 {"ts": epoch 秒, "o", "h", "l", "c"}(float),最后一根为当前未收盘的那根。 统一输出:按时间**升序**的 list[dict],每根 {"ts": epoch 秒, "o", "h", "l", "c"}(float),最后一根为当前未收盘的那根。
@@ -19,8 +25,8 @@ import urllib.request
TIMEOUT = 8 TIMEOUT = 8
UA = "eink-push/1.0 (+https://github.com/)" UA = "eink-push/1.0 (+https://github.com/)"
# 周期代号 → 各源参数:(OKX bar, Coinbase granularity 秒, Huobi period) # 周期代号 → 各源参数:(OKX bar, Coinbase granularity 秒, Huobi period, Gate.io/Binance interval)
BARS = {"1H": ("1H", 3600, "60min"), "1D": ("1D", 86400, "1day")} BARS = {"1H": ("1H", 3600, "60min", "1h"), "1D": ("1D", 86400, "1day", "1d")}
def _get_json(url): def _get_json(url):
@@ -45,8 +51,8 @@ def _coinbase(bar, limit):
return sorted(out, key=lambda x: x["ts"])[-limit:] return sorted(out, key=lambda x: x["ts"])[-limit:]
def _huobi(bar, limit): def _huobi(bar, limit, host="api.huobi.pro"):
j = _get_json(f"https://api.huobi.pro/market/history/kline?period={BARS[bar][2]}&size={limit}&symbol=btcusdt") j = _get_json(f"https://{host}/market/history/kline?period={BARS[bar][2]}&size={limit}&symbol=btcusdt")
if j.get("status") != "ok": if j.get("status") != "ok":
raise RuntimeError(f"huobi status={j.get('status')} err={j.get('err-msg')}") 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"])} out = [{"ts": int(r["id"]), "o": float(r["open"]), "h": float(r["high"]), "l": float(r["low"]), "c": float(r["close"])}
@@ -54,7 +60,39 @@ def _huobi(bar, limit):
return sorted(out, key=lambda x: x["ts"]) return sorted(out, key=lambda x: x["ts"])
SOURCES = (("okx", _okx), ("coinbase", _coinbase), ("huobi", _huobi)) 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): def get_candles(bar="1H", limit=36):
@@ -62,7 +100,7 @@ def get_candles(bar="1H", limit=36):
if bar not in BARS: if bar not in BARS:
raise ValueError(f"不支持的周期 {bar!r},可选:{list(BARS)}") raise ValueError(f"不支持的周期 {bar!r},可选:{list(BARS)}")
errors = [] errors = []
for name, fn in SOURCES: for name, fn in _ordered_sources():
try: try:
rows = fn(bar, limit) rows = fn(bar, limit)
if len(rows) >= 2: if len(rows) >= 2:

155
claude_usage.py Normal file
View 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
View 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))

View File

@@ -48,6 +48,9 @@ E1002_COLOR_STRIP = os.environ.get("E1002_COLOR_STRIP", "0") == "1" # 底部六
# 服务端三路上游的内存缓存有效期(秒):收到请求先查缓存是否过期,过期才实拉并更新时间戳,否则直接用缓存渲染; # 服务端三路上游的内存缓存有效期(秒):收到请求先查缓存是否过期,过期才实拉并更新时间戳,否则直接用缓存渲染;
# 拉取失败沿用旧值且不更新时间戳(下次请求再试)。设 0 = 每次请求都实拉。天气/BTC 复用上面的 WEATHER_TTL/BTC_TTL。 # 拉取失败沿用旧值且不更新时间戳(下次请求再试)。设 0 = 每次请求都实拉。天气/BTC 复用上面的 WEATHER_TTL/BTC_TTL。
MAO_TTL = int(os.environ.get("MAO_TTL", "60")) # 橘喵今日实时,默认 60 秒 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 则每周期重抓。 # 真机验证结论:Image 控件只在填写 URL 时抓一次快照、此后不再更新;HTML 控件填同一图片 URL 则每周期重抓。
SERVER_HOST = os.environ.get("SERVER_HOST", "0.0.0.0") SERVER_HOST = os.environ.get("SERVER_HOST", "0.0.0.0")
SERVER_PORT = int(os.environ.get("SERVER_PORT", "8790")) SERVER_PORT = int(os.environ.get("SERVER_PORT", "8790"))
@@ -59,6 +62,9 @@ CHROME_BIN = os.environ.get("CHROME_BIN", "") # 为空则按
CHROME_EXTRA_ARGS = os.environ.get("CHROME_EXTRA_ARGS", "") # 例如 Linux 以 root 运行需 "--no-sandbox" CHROME_EXTRA_ARGS = os.environ.get("CHROME_EXTRA_ARGS", "") # 例如 Linux 以 root 运行需 "--no-sandbox"
# 每通道二值化阈值(0~255):低于阈值归 0、否则归 255;取 160(而非 128)偏向保留墨迹,细笔画的浅灰边不至于被丢成白 # 每通道二值化阈值(0~255):低于阈值归 0、否则归 255;取 160(而非 128)偏向保留墨迹,细笔画的浅灰边不至于被丢成白
HTML_PNG_THRESHOLD = int(os.environ.get("HTML_PNG_THRESHOLD", "160")) 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_TOKEN = os.environ.get("PUSH_TOKEN", "") # 推送鉴权令牌(X-Push-Token 头);为空则拒绝一切推送
PUSH_STATE_PATH = os.environ.get("PUSH_STATE_PATH", "") # 推送数据落盘路径(重启后恢复);为空=仅内存 PUSH_STATE_PATH = os.environ.get("PUSH_STATE_PATH", "") # 推送数据落盘路径(重启后恢复);为空=仅内存
# 本机推送端(main.py --target push):服务地址(含协议与端口,不含路径),令牌同 PUSH_TOKEN # 本机推送端(main.py --target push):服务地址(含协议与端口,不含路径),令牌同 PUSH_TOKEN
@@ -102,14 +108,50 @@ TREND_CACHE_PATH = os.path.join(BASE_DIR, ".trend_cache.json")
BTC_BAR = os.environ.get("BTC_BAR", "1H") # 周期:"1H" 时K / "1D" 日K 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_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_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") BTC_CACHE_PATH = os.path.join(BASE_DIR, ".btc_cache.json")
# ---- Claude Code 用量(Claude Usage 模块)数据源 ---- # ---- reTerminal E1002 设备遥测(SenseCraft HMI,电量/温度/湿度,宽版顶部状态栏)----
# 只读本地、**不发任何网络请求**(详见 usage_local.py)——生产者/消费者模式: # api-key 由用户在 SenseCraft 后台生成,放 .env;设备每 300s 上报一次,DEVICE_TTL 与之同步(0=每次实拉)。
# 生产者 = 打过补丁的 claude-statusline,把每次从 Claude Code stdin 拿到的**权威实时**额度 SENSECRAFT_API_BASE = os.environ.get("SENSECRAFT_API_BASE", "https://sensecraft-hmi-api.seeed.cc")
# (five_hour/seven_day 的 utilization + resets_at)落盘到 ~/.claude/usage-snapshot.json;本项目读它。 SENSECRAFT_API_KEY = os.environ.get("SENSECRAFT_API_KEY", "")
# 活跃使用时该快照几乎持续刷新、准确;空闲时停在最后一次。文件缺失时用量区显示 "--"。 SENSECRAFT_DEVICE_EUI = os.environ.get("SENSECRAFT_DEVICE_EUI", "")
USAGE_LOCAL_PATH = os.environ.get("USAGE_LOCAL_PATH", os.path.expanduser("~/.claude/usage-snapshot.json")) 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")) # 用量 ≥ 此百分比 → 进度条与百分比转红预警(两个版面统一) USAGE_WARN = int(os.environ.get("USAGE_WARN", "80")) # 用量 ≥ 此百分比 → 进度条与百分比转红预警(两个版面统一)
# 以下两项仅六色宽版(E1002)使用,三色小屏只有 黑/红 两档。用量与 pace 各自三档、独立取色,颜色顺序固定为 绿 → 黑 → 红 # 以下两项仅六色宽版(E1002)使用,三色小屏只有 黑/红 两档。用量与 pace 各自三档、独立取色,颜色顺序固定为 绿 → 黑 → 红
# (黄不用:2026-09-14 曾按要求加过 60%~80% 黄档,随即被否——黄小字压白底发虚、且四档在三行里过于花): # (黄不用:2026-09-14 曾按要求加过 60%~80% 黄档,随即被否——黄小字压白底发虚、且四档在三行里过于花):

131
data.py
View File

@@ -18,8 +18,10 @@ from zoneinfo import ZoneInfo
import config import config
import jm_api import jm_api
import weather_api import weather_api
import usage_local import claude_usage
import codex_usage
import btc_api import btc_api
import sensecraft_api
WEEK_CN = ["周一", "周二", "周三", "周四", "周五", "周六", "周日"] WEEK_CN = ["周一", "周二", "周三", "周四", "周五", "周六", "周日"]
TZ = ZoneInfo(config.TZ_NAME) TZ = ZoneInfo(config.TZ_NAME)
@@ -143,43 +145,48 @@ def weather_fetch():
# ---------- 格式化 ---------- # ---------- 格式化 ----------
# 数值统一拆成三段 (前缀, 主数字, 后缀):渲染器把主数字画大、前后缀画小(如「¥」「万」,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, wide=False): def _col(k, parts, wow, yoy, wide=False):
"""一列指标。wide=True 表示仅宽版(800×480)展示,400×300 小屏三列放不下、渲染时跳过。""" """一列指标。parts 为 (前缀, 主数字, 后缀);wide=True 表示仅宽版(800×480)展示,400×300 小屏三列放不下、渲染时跳过。"""
return {"k": k, "v": v, "hb": _fmt_rate(wow), "tb": _fmt_rate(yoy), "wide": wide} return {"k": k, "v": "".join(parts), "vp": tuple(parts), "hb": _fmt_rate(wow), "tb": _fmt_rate(yoy), "wide": wide}
def _placeholder_col(k, wide=False): def _placeholder_col(k, wide=False):
return {"k": k, "v": "--", "hb": (None, "--"), "tb": (None, "--"), "wide": wide} return {"k": k, "v": "--", "vp": ("", "--", ""), "hb": (None, "--"), "tb": (None, "--"), "wide": wide}
# 今日实时五列:(标签、接口字段前缀、格式化、是否仅宽版)。字段前缀 base 对应 today{Base} / {base}WowRate / {base}YoyRate, # 今日实时五列:(标签、接口字段前缀、格式化、是否仅宽版)。字段前缀 base 对应 today{Base} / {base}WowRate / {base}YoyRate,
# 见 docs/首页看板接口文档-20260911.md。团购两列为新增字段,旧后端无此键 → 占位 "--"(不报错、不影响其余列)。 # 见 docs/首页看板接口文档-20260911.md。赏金两列为新增字段,旧后端无此键 → 占位 "--"(不报错、不影响其余列)。
# 2026-10-26 宽版团购两列替换为赏金两列(团购字段接口仍返回,此处不再展示)。
REALTIME_COLS = ( REALTIME_COLS = (
("探店单量", "orderCount", _fmt_count, False), ("探店单量", "orderCount", _fmt_count, False),
("探店流水", "payAmount", _fmt_money, False), ("探店流水", "payAmount", _fmt_money, False),
("团购单量", "groupbuyOrderCount", _fmt_count, True), ("赏金单量", "bountyOrderCount", _fmt_count, True), # 当天报名单量
("团购流水", "groupbuyPayAmount", _fmt_money, True), ("赏金流水", "bountyPayAmount", _fmt_money, True), # 任务奖励 × 2(奖励为返给用户的部分)
("毛利", "grossProfit", _fmt_money, False), # 口径已含团购毛利(接口合计,未拆分) ("毛利", "grossProfit", _fmt_money, False), # 口径已含团购毛利+当天审核通过的赏金奖励(接口合计,未拆分)
) )
@@ -311,26 +318,28 @@ def _fmt_reset(resets_at, window):
# Claude 用量窗口:(快照键,短标签,长标签,窗口长度秒,scoped)。 # Claude 用量窗口:(快照键,短标签,长标签,窗口长度秒,scoped)。
# 短标签 k 给 400x300 小屏(标签列只有 26px);长标签 label 给 800x480 宽版,沿用 statusline 的写法 # 短标签 k 给 400x300 小屏(标签列只有 26px);长标签 label 给 800x480 宽版(2026-09-14 去掉 all 前缀,就叫 5h / 7d)。
# (all 5h / all 7d / fable 7d,小写开头)。scoped=True 为「按模型限定」的额度(Fable 7d,来自 # scoped=True 为「按模型限定」的额度(Fable 7d,来自 /api/oauth/usage 的 limits[] weekly_scoped,由 claude_usage.normalize
# /api/oauth/usage 的 limits[],由 statusline 写入快照 seven_day_fable),小屏放不下第三行,只在宽版展示。 # 写入快照 seven_day_fable):小屏 render() 跳过;宽版**并入紧邻其前的非 scoped 行**(7d 行拆成上下两条细条:上 = 总 7d,
# 下 = fable 7d),使左右两块都是两行、视觉对称(此前三行 vs 两行不对称)。
USAGE_WINDOWS = ( USAGE_WINDOWS = (
("five_hour", "5h", "all 5h", FIVE_HOUR, False), ("five_hour", "5h", "5h", FIVE_HOUR, False),
("seven_day", "7d", "all 7d", SEVEN_DAY, False), ("seven_day", "7d", "7d", SEVEN_DAY, False),
("seven_day_fable", "Fable 7d", "fable 7d", SEVEN_DAY, True), ("seven_day_fable", "Fable 7d", "fable 7d", SEVEN_DAY, True),
) )
# ChatGPT 用量窗口:键名沿用同一套约定(five_hour / seven_day / seven_day_codex),推送端照此结构提供原始数据即可。 # Codex 用量窗口(ChatGPT 订阅的 Codex 额度池,由 codex_usage.py 经 app-server RPC 取得):只有 5h / 7d 两个窗口。
# ChatGPT 普通聊天额度没有账户级读取途径(2026-09 调研结论),故右侧块监控的就是 Codex 池并如实命名「Codex Usage」。
# 快照里各窗口可带 window(真实窗口秒数),pace 优先按它算;rateLimitsByLimitId 多桶原样存于 buckets,看到真实数据后再决定是否加第三行。
GPT_WINDOWS = ( GPT_WINDOWS = (
("five_hour", "5h", "all 5h", FIVE_HOUR, False), ("five_hour", "5h", "5h", FIVE_HOUR, False),
("seven_day", "7d", "all 7d", SEVEN_DAY, False), ("seven_day", "7d", "7d", SEVEN_DAY, False),
("seven_day_codex", "Codex 7d", "codex 7d", SEVEN_DAY, True),
) )
def usage_from_raw(now, raw, windows, title): def usage_from_raw(now, raw, windows, title):
"""原始快照 → 渲染结构(Claude / ChatGPT 通用)。 """原始快照 → 渲染结构(Claude / ChatGPT 通用)。
raw 形如 {five_hour:{utilization, resets_at}, seven_day:{...}, <scoped 键>:{...}|null, updated_at?}; raw 形如 {five_hour:{utilization, resets_at, window?}, seven_day:{...}, <scoped 键>:{...}|null, updated_at?};
utilization 为已用百分比,resets_at 为 epoch 秒或 ISO;缺哪个窗口哪个显示 "--"。 utilization 为已用百分比,resets_at 为 epoch 秒或 ISO,window 为该窗口真实长度秒(缺省用 windows 表的常量);缺哪个窗口哪个显示 "--"(宽版按 0 画);utilization 为 0 且 resets_at 为 null 的空闲窗口记 time_pct=0,保证 pace 列不缺席。
time_pct(时间已流逝%)与 pace 按传入的 now **现算**——所以推送端只需推原始快照,服务端渲染时 pace 仍随时钟准确推进。 time_pct(时间已流逝%)与 pace 按传入的 now **现算**——所以推送端只需推原始快照,服务端渲染时 pace 仍随时钟准确推进。
结构契约(renderer 依赖):{title, warn, ok, pace_tol, updated?, bars:[{k, label, pct(0~100 或 None), time_pct?, reset?, scoped}]}; 结构契约(renderer 依赖):{title, warn, ok, pace_tol, updated?, bars:[{k, label, pct(0~100 或 None), time_pct?, reset?, scoped}]};
pct ≥ warn 时该条进度条与百分比转红(宽版另按 ok/warn 分 绿/黑/红 三档,百分比随条色); pct ≥ warn 时该条进度条与百分比转红(宽版另按 ok/warn 分 绿/黑/红 三档,百分比随条色);
@@ -343,7 +352,10 @@ def usage_from_raw(now, raw, windows, title):
util = seg.get("utilization") util = seg.get("utilization")
pct = int(round(float(util))) if util is not None else None pct = int(round(float(util))) if util is not None else None
bar = {"k": k, "label": label, "pct": pct, "scoped": scoped} 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) 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: if pct is not None and tp is not None:
bar["time_pct"] = tp bar["time_pct"] = tp
rs = _fmt_reset(seg.get("resets_at"), win) rs = _fmt_reset(seg.get("resets_at"), win)
@@ -358,36 +370,82 @@ def usage_from_raw(now, raw, windows, title):
def _usage(now, raw=None): def _usage(now, raw=None):
"""Claude Code 用量(5h / 7d / Fable 7d)。raw 为 None 时读本地 statusline 快照(见 usage_local.py,**不发请求**); """Claude 账户级用量(5h / 7d / Fable 7d)。raw 为 None 时本机经 claude_usage 采集(复用 Claude Code 钥匙串登录态调
服务端传入推送来的原始快照。utilization 来自 statusline 从 Claude Code stdin 落盘的权威实时值(Fable 行来自其转存的 API limits)。""" /api/oauth/usage,带 ≥300s 最小间隔缓存与退避);服务端传入推送来的原始快照。"""
if raw is None: if raw is None:
raw = {} raw = {}
try: try:
raw = usage_local.get_usage() raw = claude_usage.get_usage()
except Exception as e: except Exception as e:
print(f"[警告] Claude 用量读取失败:{e}") print(f"[警告] Claude 用量读取失败:{e}")
return usage_from_raw(now, raw, USAGE_WINDOWS, "Claude Usage") return usage_from_raw(now, raw, USAGE_WINDOWS, "Claude Usage")
def gpt_mock_raw(now): def gpt_mock_raw(now):
"""ChatGPT 原始快照的 mock(尚无真实数据源时用):刻意覆盖宽版三档配色—— """Codex 原始快照的 mock(服务端尚未收到推送时用):5h 88%(≥warn 红)、7d 22%(≤ok 绿)覆盖两端配色。"""
5h 88%(≥warn 红)、7d 71%(黑)、Codex 7d 22%(≤ok 绿,scoped,与左侧 fable 行对称)。"""
t = now.timestamp() t = now.timestamp()
return {"five_hour": {"utilization": 88, "resets_at": t + 2 * 3600}, return {"five_hour": {"utilization": 88, "resets_at": t + 2 * 3600},
"seven_day": {"utilization": 71, "resets_at": t + 3 * 86400}, "seven_day": {"utilization": 22, "resets_at": t + 3 * 86400}}
"seven_day_codex": {"utilization": 22, "resets_at": t + 3 * 86400}}
def _usage_gpt(now, raw=None): def _usage_gpt(now, raw=None, allow_local=True):
"""ChatGPT 用量。raw 为推送来的原始快照(结构同 gpt_mock_raw);为 None 时用 mock 并标 mock=True """Codex 用量(ChatGPT 订阅的 Codex 额度池)。raw 为推送来的原始快照(codex_usage 产出,结构同 gpt_mock_raw);
(renderer 在标题右侧标「示例数据」)。接入真实数据源时由推送端提供 raw,本函数无需改动。""" 服务端未收到推送时(raw=None)用 mock 并标 mock=True(renderer 在标题右侧标「示例数据」)。本机形态直接经 codex_usage 采集。"""
mock = raw is None mock = raw is None
out = usage_from_raw(now, gpt_mock_raw(now) if mock else raw, GPT_WINDOWS, "ChatGPT Usage") 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: if mock:
out["mock"] = True out["mock"] = True
return out 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 now_tz() now = now or now_tz()
@@ -402,4 +460,5 @@ def get_dashboard_data(now=None):
"usage": _usage(now), "usage": _usage(now),
"usage_gpt": _usage_gpt(now), "usage_gpt": _usage_gpt(now),
"btc": _btc(now), "btc": _btc(now),
"device": _device(now),
} }

View File

@@ -8,8 +8,8 @@
(平台截图机若无中文字体会掉字,网络字体是保险;能否加载取决于其出网能力)。 (平台截图机若无中文字体会掉字,网络字体是保险;能否加载取决于其出网能力)。
- 纯 HTML+CSS+内联 SVG,**不依赖 JavaScript**(平台是否执行 JS 未证实),数据全部服务端写死进 HTML。 - 纯 HTML+CSS+内联 SVG,**不依赖 JavaScript**(平台是否执行 JS 未证实),数据全部服务端写死进 HTML。
- 颜色只用六色纯值 #000/#fff/#f00/#0f0(黄/蓝未用),抗锯齿边缘交给平台抖动——这正是要和 PNG 版对比的点。 - 颜色只用六色纯值 #000/#fff/#f00/#0f0(黄/蓝未用),抗锯齿边缘交给平台抖动——这正是要和 PNG 版对比的点。
- 版式与 PNG 版同构:顶部时钟/天气;左 Claude Usage、右 ChatGPT Usage;橘喵今日实时三列;BTC 时 K(SVG)。 - 版式与 PNG 版同构:顶部时钟/天气;左 Claude Usage、右 Codex Usage;橘喵今日实时三列;BTC 时 K(SVG)。
- 配色规则与 PNG 版一致:用量条 ≥warn 红 / ≤ok 绿 / 其间黑;pace ≥+tol 红 / ≤−tol 绿 / 其间黑;环比同比 涨红 跌黑;K 线 涨绿 跌红。 - 配色规则与 PNG 版一致:用量条 ≥warn 红 / ≤ok 绿 / 其间黑;pace ≥+tol 红 / ≤−tol 绿 / 其间黑;环比同比 涨绿 跌红(与 K 线一致);K 线 涨绿 跌红。
""" """
from html import escape as _e from html import escape as _e
@@ -95,8 +95,9 @@ def _usage_card(usage):
def _cmp(label, pair): def _cmp(label, pair):
direction, val = pair direction, val = pair
col = RED if direction == "up" else BLACK 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 "") 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>' return f'<div>{_e(label)} <span style="color:{col}">{tri}{_e(val)}</span></div>'
@@ -173,7 +174,7 @@ def render_html(d):
f'<div class="wx"><div class="c"><div class="loc"><span class="pin"></span>{_e(wt["loc"])}</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>') f'{_e(wt["cond"])}<br>{_e(wt["range"])}</div><div class="t">{_e(wt["temp"])}</div></div></header>')
body = (head body = (head
+ f'<div class="row">{_usage_card(d["usage"])}{_usage_card(d.get("usage_gpt") or {"title": "ChatGPT Usage"})}</div>' + f'<div class="row">{_usage_card(d["usage"])}{_usage_card(d.get("usage_gpt") or {"title": "Codex Usage"})}</div>'
+ _realtime(d["mao"]) + _realtime(d["mao"])
+ _btc(d.get("btc"))) + _btc(d.get("btc")))
return ("<!doctype html><html lang=\"zh-CN\"><head><meta charset=\"utf-8\">" return ("<!doctype html><html lang=\"zh-CN\"><head><meta charset=\"utf-8\">"

View File

@@ -8,8 +8,8 @@
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/, python3 main.py --target e1002 # reTerminal E1002:渲染 800x480 六色图 + 固定尺寸 index.html 到 output/e1002/,
# 不推送(静态部署形态;动态形态见 server.py) # 不推送(静态部署形态;动态形态见 server.py)
python3 main.py --target push # 把本机 Claude 用量快照(~/.claude/usage-snapshot.json)推送到 server.py python3 main.py --target push # 本机采集 Claude(钥匙串登录态 → /api/oauth/usage)与 Codex(codex app-server RPC)
# (PUSH_URL + PUSH_TOKEN),服务端渲染时按当前时刻现算 pace # 两路账户级用量并推送到 server.py(PUSH_URL + PUSH_TOKEN),服务端渲染时现算 pace
配置统一从同目录 .env 读取(见 .env.example),真实环境变量优先。 配置统一从同目录 .env 读取(见 .env.example),真实环境变量优先。
""" """
@@ -25,7 +25,7 @@ def main():
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", parser.add_argument("--target", choices=("miao", "e1002", "push"), default="miao",
help="miao=喵喵固件 400x300 三色屏(默认,局域网推送);e1002=reTerminal E1002 800x480 六色(产出静态 HTML 页);" help="miao=喵喵固件 400x300 三色屏(默认,局域网推送);e1002=reTerminal E1002 800x480 六色(产出静态 HTML 页);"
"push=把本机用量快照推送到 server.py") "push=本机采集 Claude/Codex 账户级用量并推送到 server.py")
args = parser.parse_args() args = parser.parse_args()
if args.target == "push": if args.target == "push":

View File

@@ -1,22 +1,24 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
""" """
推送端(本机运行)—— 把本机的用量原始快照 POST 到 server.py,服务端暂存、渲染时现算 pace。 推送端(本机运行)—— 在本机采集两路账户级 AI 用量,POST 到 server.py;服务端暂存、渲染时现算 pace。
推什么: 推什么(形态 A:凭证与采集都留在本机,服务端不持任何凭证、不读本机文件):
- Claude:~/.claude/usage-snapshot.json 原文(statusline 生产,含 five_hour / seven_day / seven_day_fable / updated_at)→ POST /push/usage - Claude:claude_usage.get_usage()(复用 Claude Code 钥匙串登录态调 /api/oauth/usage)→ POST /push/usage
- ChatGPT:暂无本机数据源;将来有了按 data.GPT_WINDOWS 的键(five_hour / seven_day / seven_day_codex,各含 utilization + resets_at) - Codex:codex_usage.get_usage()(`codex app-server` RPC,独立 CODEX_HOME 的 ChatGPT 登录)→ POST /push/usage_gpt
组一个 dict → POST /push/usage_gpt。目前不推,服务端用 mock 并标「示例数据」。 两路彼此独立:一路失败不影响另一路;两路都失败才以非零码退出。采集器自带 ≥300s 最小间隔缓存,
所以与 main.py(小屏渲染)在同一分钟内先后运行也只打一次接口。
鉴权:请求头 X-Push-Token = PUSH_TOKEN。网络:直连(不走代理),连接类异常重试 2 次。 鉴权:请求头 X-Push-Token = PUSH_TOKEN。网络:直连(不走代理),连接类异常重试 2 次。
调度:与喵喵设备推送同节律,由 launchd 每 5 分钟跑一次 `main.py --target push`(或加进 run.sh)。 调度:与喵喵设备推送同节律,由 launchd 每 5 分钟跑一次 `main.py --target push`(run.sh 末尾)。
""" """
import json import json
import time import time
import urllib.error import urllib.error
import urllib.request import urllib.request
import claude_usage
import codex_usage
import config import config
import usage_local
RETRY_TIMES, RETRY_DELAY, TIMEOUT = 2, 2, 10 RETRY_TIMES, RETRY_DELAY, TIMEOUT = 2, 2, 10
@@ -28,10 +30,11 @@ def _post_json(url, payload):
"X-Push-Token": config.PUSH_TOKEN, "X-Push-Token": config.PUSH_TOKEN,
"User-Agent": "eink-push/push_client", "User-Agent": "eink-push/push_client",
}) })
opener = urllib.request.build_opener(urllib.request.ProxyHandler({}))
last = None last = None
for i in range(RETRY_TIMES + 1): for i in range(RETRY_TIMES + 1):
try: try:
with urllib.request.urlopen(req, timeout=TIMEOUT) as resp: with opener.open(req, timeout=TIMEOUT) as resp:
return json.loads(resp.read().decode("utf-8")) return json.loads(resp.read().decode("utf-8"))
except urllib.error.HTTPError as e: # 4xx/5xx 不重试,直接报出服务端的说明 except urllib.error.HTTPError as e: # 4xx/5xx 不重试,直接报出服务端的说明
raise RuntimeError(f"HTTP {e.code}: {e.read().decode('utf-8', 'replace')[:200]}") from e raise RuntimeError(f"HTTP {e.code}: {e.read().decode('utf-8', 'replace')[:200]}") from e
@@ -42,13 +45,29 @@ def _post_json(url, payload):
raise RuntimeError(f"连接失败:{last}") raise RuntimeError(f"连接失败:{last}")
SOURCES = (
("Claude", claude_usage.get_usage, "/push/usage"),
("Codex", codex_usage.get_usage, "/push/usage_gpt"),
)
def push_usage(): def push_usage():
if not config.PUSH_URL or not config.PUSH_TOKEN: if not config.PUSH_URL or not config.PUSH_TOKEN:
raise SystemExit("[推送] 需在 .env 配置 PUSH_URL 与 PUSH_TOKEN") raise SystemExit("[推送] 需在 .env 配置 PUSH_URL 与 PUSH_TOKEN")
raw = usage_local.get_usage() # 缺文件直接抛错——没有快照就没什么可推 base = config.PUSH_URL.rstrip("/")
resp = _post_json(config.PUSH_URL.rstrip("/") + "/push/usage", raw) ok = 0
print(f"[推送] Claude 用量快照已推送到 {config.PUSH_URL}:{resp}") for name, getter, path in SOURCES:
return resp 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__": if __name__ == "__main__":

View File

@@ -75,14 +75,25 @@ class DashboardRenderer:
self.d.text((cx + dx, y), ch, font=ft, fill=fill) self.d.text((cx + dx, y), ch, font=ft, fill=fill)
cx += self.d.textlength(ch, font=ft) + track 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, width=1): def hdash(self, x0, x1, y, dash=4, gap=4, fill=BLACK, width=1):
x = x0 x = x0
@@ -108,27 +119,60 @@ class DashboardRenderer:
"""环比/同比一行的总宽度(标签 + 间隙 + 三角 + 数值),供居中/右对齐排布。""" """环比/同比一行的总宽度(标签 + 间隙 + 三角 + 数值),供居中/右对齐排布。"""
direction, val = pair direction, val = pair
k = self.k k = self.k
trw, tgap = (8 * k, 4 * k) if direction else (0, 0) trw, tgap, vb = (8 * k, 4 * k, k) if direction else (0, 0, 0) # vb:数值伪粗体(占位 -- 不加粗)
return self._tw(label, self.F12) + 5 * k + trw + tgap + self._tw(val, self.F12) return self._tw(label, self.F12) + 5 * k + trw + tgap + self._tw_b(val, self.F12, vb)
def _cmp_line(self, cx, y, label, pair, fg=BLACK): def _cmp_line(self, cx, y, label, pair, fg=BLACK):
"""环比/同比一行:标签 + 涨跌三角 + 数值,整体居中于 cx。direction 为 None 时不画三角。""" """环比/同比一行:标签 + 涨跌三角 + 数值,整体居中于 cx。direction 为 None 时不画三角。"""
self._cmp_at(cx - self._cmp_w(label, pair) / 2, y, label, pair, fg) self._cmp_at(cx - self._cmp_w(label, pair) / 2, y, label, pair, fg)
def _cmp_color(self, direction, fg=BLACK):
"""环比/同比「三角 + 数值」的颜色钩子:三色版 涨红、其余沿用 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): def _cmp_at(self, x, y, label, pair, fg=BLACK):
"""环比/同比一行,左对齐于 x(宽版把它放在数值右侧时用)。""" """环比/同比一行,左对齐于 x(宽版把它放在数值右侧时用)。
direction: up 上三角 / down 下三角 / flat 等号(增长率恰为 0,2026-09-14 用户要求,原来 0 也画上三角)/ None 不画。"""
direction, val = pair direction, val = pair
k = self.k k = self.k
lw = self._tw(label, self.F12); gap = 5 * k lw = self._tw(label, self.F12); gap = 5 * k
trw, tgap = (8 * k, 4 * k) if direction else (0, 0) trw, tgap = (8 * k, 4 * k) if direction else (0, 0)
self.text(x, y, label, self.F12, fill=fg); x += lw + gap self.text(x, y, label, self.F12, fill=fg); x += lw + gap
# 方向语义配色(红涨):涨 → 箭头与百分比数值一起红;跌/无 → 黑(标签始终黑) # 方向语义配色由 _cmp_color 决定(三色版:红涨、跌/无/持平 → 黑;宽版覆写为 涨绿跌红 与 K 线一致);标签始终黑
up = direction == "up" val_color = self._cmp_color(direction, fg)
val_color = RED if up else fg if direction == "flat":
if direction: 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),否则箭头偏高 # 三角中心对齐 F12 数字墨迹竖直中心(y+8.5),否则箭头偏高
self.tri(x + trw / 2, y + 8.5 * k, 7 * k, up=up, fill=val_color); x += trw + tgap self.tri(x + trw / 2, y + 8.5 * k, 7 * k, up=direction == "up", fill=val_color); x += trw + tgap
self.text(x, y, val, self.F12, fill=val_color) # 数值伪粗体、字号不变(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): def _label(self, y, title, right=None, x0=None, x1=None):
"""区块标题(两区块统一,对齐主分支 _title_bar 风格): """区块标题(两区块统一,对齐主分支 _title_bar 风格):
@@ -149,6 +193,7 @@ class DashboardRenderer:
track:见 _tw,用于给紧贴的字符手动加间距。""" track:见 _tw,用于给紧贴的字符手动加间距。"""
_, t, _, b = ft.getbbox(s) # 该串墨迹在字身框内的上/下沿 _, t, _, b = ft.getbbox(s) # 该串墨迹在字身框内的上/下沿
y = cy - (t + b) / 2.0 y = cy - (t + b) / 2.0
track = self._track(ft, bold, track)
w = self._tw(s, ft, track) w = self._tw(s, ft, track)
if align == "r": if align == "r":
x -= w + bold x -= w + bold
@@ -158,16 +203,19 @@ class DashboardRenderer:
def _pace(self, xr, cy, diff): def _pace(self, xr, cy, diff):
"""时间维度对比(pace = 用量% − 时间%):>0 超前↑红;<0 节余↓黑;=0 持平。 """时间维度对比(pace = 用量% − 时间%):>0 超前↑红;<0 节余↓黑;=0 持平。
数值(FPACE,默认 F13)两位补零、右端对齐 xr,小三角(6)紧贴其左;均竖直居中 cy。返回整体宽度供左侧定位。""" 数值(FPACE,默认 F13)两位补零、右端对齐 xr,小三角(6)紧贴其左;均竖直居中 cy。返回整体宽度供左侧定位。
diff=0(持平/无数据)三角位画一个与三角等宽的等号「=」(2026-09-14 用户要求,原为留空,后曾用「-」)——pace 列每行等宽,各行进度条右端齐平。"""
up = diff > 0 up = diff > 0
color = self._pace_color(diff) color = self._pace_color(diff)
txt = f"{abs(int(diff)):02d}" txt = f"{abs(int(diff)):02d}"
tw = self._tw(txt, self.FPACE) 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") self._mid(xr, cy, txt, self.FPACE, fill=color, bold=self.k, align="r")
if diff == 0:
return tw
TRI_GAP, TRI = 4 * self.k, 6 * self.k TRI_GAP, TRI = 4 * self.k, 6 * self.k
self.tri(xr - tw - TRI_GAP - TRI / 2, cy, TRI, up=up, fill=color) 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 return tw + TRI_GAP + TRI
def _usage_row(self, x0, x1, y, bar, warn, label_w=None): def _usage_row(self, x0, x1, y, bar, warn, label_w=None):
@@ -191,18 +239,20 @@ class DashboardRenderer:
self._mid(x0, cy, bar["k"], self.F13, bold=lb) self._mid(x0, cy, bar["k"], self.F13, bold=lb)
# 右侧从右往左:pace(最右) → 百分比 → 进度条 # 右侧从右往左:pace(最右) → 百分比 → 进度条
pct_r = x1 pct_r = x1
if ok and "time_pct" in bar: if ok:
pace_w = self._pace(x1, cy, pct - int(bar["time_pct"])) # 有百分比就必画 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_r = x1 - pace_w - G2
pct_txt = f"{pct:02d}%" if ok else "--" 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(主) self._mid(pct_r, cy, pct_txt, self.F16, fill=color, bold=k, align="r", track=PCT_TRACK) # 百分比 F16(主)
bx0 = x0 + LABEL_W bx0 = x0 + LABEL_W
bx1 = pct_r - self._tw(pct_txt, self.F16, PCT_TRACK) - G1 bx1 = pct_r - self._tw(pct_txt, self.F16, self._track(self.F16, k, PCT_TRACK)) - G1
by = cy - H // 2 by = cy - H // 2
self.d.rectangle([bx0, by, bx1, by + H], outline=outline, width=k) # 轨道边框随条色 self.d.rectangle([bx0, by, bx1, by + H], outline=outline, width=k) # 轨道边框随条色
fw = (bx1 - bx0) * pct / 100.0 fw = (bx1 - bx0) * pct / 100.0
if fw >= 2: if fw >= 2:
self.d.rectangle([bx0, by, bx0 + fw, by + H], fill=fill) self.d.rectangle([bx0, by, bx0 + fw, by + H], fill=fill)
return bx0, bx1, by, H # 进度条几何(宽版合并行据此在边框内重绘上下两条实心填充)
def _usage_colors(self, ok, pct, warn, bar): def _usage_colors(self, ok, pct, warn, bar):
"""用量行配色钩子 → (进度条填充,轨道边框,百分比文字)。三色版:≥warn 整体红,否则黑。""" """用量行配色钩子 → (进度条填充,轨道边框,百分比文字)。三色版:≥warn 整体红,否则黑。"""
@@ -458,7 +508,7 @@ class DashboardRenderer:
# ---- C. 底部:橘喵·今日实时(三列,版式对齐主分支:标题+18=名/+34=值/+64=环比/+82=同比)---- # ---- C. 底部:橘喵·今日实时(三列,版式对齐主分支:标题+18=名/+34=值/+64=环比/+82=同比)----
self._label(193, "橘喵·今日实时", right=f'上次更新 {mao["upd"]}') self._label(193, "橘喵·今日实时", right=f'上次更新 {mao["upd"]}')
cols = [c for c in mao["cols"] if not c.get("wide")]; n = len(cols) # 仅宽版的列(团购)小屏放不下,跳过 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):
@@ -466,7 +516,7 @@ class DashboardRenderer:
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, 211, c["k"], self.F12) # 指标名(标题+18) self.ctext(cx, 211, c["k"], self.F12) # 指标名(标题+18)
self.ctext(cx, 227, c["v"], self.F24, bold=1) # 数值 伪粗体(+34) self._value(cx, 227, c, bold=1) # 数值 F24 伪粗体 + 小号 ¥/万(+34)
self._cmp_line(cx, 257, "环比", c["hb"]) # +64 self._cmp_line(cx, 257, "环比", c["hb"]) # +64
self._cmp_line(cx, 275, "同比", c["tb"]) # +82,墨迹止于≈289,底部留≈10px self._cmp_line(cx, 275, "同比", c["tb"]) # +82,墨迹止于≈289,底部留≈10px

View File

@@ -6,13 +6,16 @@ reTerminal E1002 渲染层 —— 复用 renderer.DashboardRenderer 的组件(
并生成一张固定 800x480 的 HTML 页面供 SenseCraft HMI 的 HTML 控件云端截图拉取。 并生成一张固定 800x480 的 HTML 页面供 SenseCraft HMI 的 HTML 控件云端截图拉取。
版式(k=1,点阵字体且**只用整数倍字号 12/24/36/48/84**,不放大;四段,三道横规): 版式(k=1,点阵字体且**只用整数倍字号 12/24/36/48/84**,不放大;四段,三道横规):
A 顶部(0~90) :**大时钟 F84**(墨迹 16~79,占满上下边距内的整段高度)+ 星期/日期 F12 紧随其右、与时钟数字**基线对齐**(左) 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)——天气组件与三色版同尺寸。 |天气(右,贴 790)——天气组件与三色版同尺寸。
B 用量(96~206) :左半 **Claude Usage**(all 5h / all 7d / fable 7d)|竖规 x=400|右半 **ChatGPT Usage**(mock:all 5h / all 7d / codex 7d)。 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): 行组件沿用三色版的紧凑单行 `_usage_row`(行距 31);**三处文字统一 12px**(基类 F13/F16/FPACE 都映射到 12px):
标签**常规字重、在标签列内右对齐**(贴进度条),百分比与 pace 数字**粗体**(曾试过 24px,用户嫌大), 标签**常规字重、在标签列内右对齐**(贴进度条),百分比与 pace 数字**粗体**(曾试过 24px,用户嫌大),
标签用 bar["label"](statusline 同款写法),列宽按最长标签自适应;标题右侧「上次更新 HH:MM」与今日实时同款。 标签用 bar["label"](statusline 同款写法),列宽按最长标签自适应;标题右侧「上次更新 HH:MM」与今日实时同款。
C 今日(218~322):**橘喵·今日实时** 五列全宽(探店单量/探店流水/团购单量/团购流水/毛利):列内四行逐行居中—— C 今日(218~322):**橘喵·今日实时** 五列全宽(探店单量/探店流水/赏金单量/赏金流水/毛利):列内四行逐行居中——
名 F12 / 值 F24 粗 / 环比 / 同比(五列每列 ~157px,原「值 F36 + 右侧叠放环比/同比」并排组放不下)。 名 F12 / 值 F24 粗 / 环比 / 同比(五列每列 ~157px,原「值 F36 + 右侧叠放环比/同比」并排组放不下)。
D 行情(~336~470):**BTC/USDT 时K(近 48 小时)** 蜡烛图(`_btc_chart`,数据来自 btc_api:OKX 主源、Coinbase/Huobi 备源,5 分钟缓存; D 行情(~336~470):**BTC/USDT 时K(近 48 小时)** 蜡烛图(`_btc_chart`,数据来自 btc_api:OKX 主源、Coinbase/Huobi 备源,5 分钟缓存;
周期/根数由 BTC_BAR/BTC_LIMIT 配置)——标题右侧最新价 + 24h 涨跌幅;**涨(收≥开)绿、跌红**,实心蜡烛 + 1px 影线; 周期/根数由 BTC_BAR/BTC_LIMIT 配置)——标题右侧最新价 + 24h 涨跌幅;**涨(收≥开)绿、跌红**,实心蜡烛 + 1px 影线;
@@ -51,7 +54,7 @@ class E1002Renderer(DashboardRenderer):
PCT_TRACK = 0 # 百分比是整数倍字号(自带侧边距),不再手动加字符间距 PCT_TRACK = 0 # 百分比是整数倍字号(自带侧边距),不再手动加字符间距
LABEL_BOLD = False # 标签常规字重(数值才加粗,层级:数值 > 标签) LABEL_BOLD = False # 标签常规字重(数值才加粗,层级:数值 > 标签)
LABEL_ALIGN = "r" # 标签右对齐贴进度条,长短标签(all 5h / fable 7d)右缘齐平 LABEL_ALIGN = "r" # 标签右对齐贴进度条,长短标签(5h / 7d)右缘齐平
def __init__(self, font_path=config.FONT_PATH): def __init__(self, font_path=config.FONT_PATH):
super().__init__(font_path, size=(config.E1002_WIDTH, config.E1002_HEIGHT), k=1) super().__init__(font_path, size=(config.E1002_WIDTH, config.E1002_HEIGHT), k=1)
@@ -61,18 +64,29 @@ class E1002Renderer(DashboardRenderer):
self.F13 = ImageFont.truetype(font_path, 12) self.F13 = ImageFont.truetype(font_path, 12)
self.F16 = self.F13 self.F16 = self.F13
self.FPACE = self.F13 self.FPACE = self.F13
self.F84 = ImageFont.truetype(font_path, 84) self.F84 = ImageFont.truetype(font_path, 84) # 顶部大时钟
# ---------- 配色钩子(三档,顺序固定 绿 → 黑 → 红;用量与 pace 各自独立计算)---------- # ---------- 配色钩子(三档,顺序固定 绿 → 黑 → 红;用量与 pace 各自独立计算)----------
_ok, _pace_tol = _cfg.USAGE_OK, _cfg.PACE_TOL # 由 _usage_block 按数据覆盖 _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): def _usage_colors(self, ok, pct, warn, bar):
"""进度条填充、边框、百分比数字三者同色:pct ≤ ok 绿「健康」|其间黑「正常」|pct ≥ warn 红「危险」。""" """进度条填充、边框、百分比数字三者同色:pct ≤ ok 绿「健康」|其间黑「正常」|pct ≥ warn 红「危险」;无数据(_null)黑。"""
if not ok: if not ok or bar.get("_null"):
return BLACK, BLACK, BLACK return BLACK, BLACK, BLACK
c = RED if pct >= warn else GREEN if pct <= self._ok else BLACK c = RED if pct >= warn else GREEN if pct <= self._ok else BLACK
return c, c, c 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): def _pace_color(self, diff):
"""pace = 用量% − 时间%(与用量档位独立):≤ −tol 绿「节余」|(−tol, +tol) 黑「持平」|≥ +tol 红「偏快」。""" """pace = 用量% − 时间%(与用量档位独立):≤ −tol 绿「节余」|(−tol, +tol) 黑「持平」|≥ +tol 红「偏快」。"""
if diff >= self._pace_tol: if diff >= self._pace_tol:
@@ -81,7 +95,51 @@ class E1002Renderer(DashboardRenderer):
return GREEN return GREEN
return BLACK 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): def _color_strip(self, y0, y1, x0=None, x1=None):
"""六色测试色条:六块等宽实心色块,白块加 1px 黑框以便可见。真机校准用。""" """六色测试色条:六块等宽实心色块,白块加 1px 黑框以便可见。真机校准用。"""
x0 = 10 if x0 is None else x0 x0 = 10 if x0 is None else x0
@@ -101,16 +159,50 @@ class E1002Renderer(DashboardRenderer):
self._label(y0, usage.get("title", "Usage"), right=right, x0=x0, x1=x1) self._label(y0, usage.get("title", "Usage"), right=right, x0=x0, x1=x1)
warn = usage.get("warn", 80) warn = usage.get("warn", 80)
self._ok, self._pace_tol = usage.get("ok", _cfg.USAGE_OK), usage.get("pace_tol", _cfg.PACE_TOL) 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", [])] # 宽版用长标签(all 5h / fable 7d) bars = [dict(b, k=b.get("label") or b["k"]) for b in usage.get("bars", [])] # 宽版用长标签(5h / 7d)
label_w = max([self._tw(b["k"], self.F13) for b in bars] + [26]) + self.LABEL_GAP # scoped 行(fable 7d)并入紧邻其前的非 scoped 行,成为该行的下半细条 → 左右两块都是两行,对称
y = y0 + 27 rows = []
for b in bars: for b in bars:
self._usage_row(x0, x1, y, b, warn, label_w=label_w) 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 y += 31
return y - 31 + 18 # 末行进度条底部(cy=y+10,条高 15 → 底 y+17) 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): def _realtime(self, y0, mao, x0=8, x1=None):
"""橘喵·今日实时五列(宽版):探店单量 / 探店流水 / 团购单量 / 团购流水 / 毛利,列内四行逐行水平居中: """橘喵·今日实时五列(宽版):探店单量 / 探店流水 / 赏金单量 / 赏金流水 / 毛利,列内四行逐行水平居中:
指标名 F12(y0+21)→ 值 F24 粗(y0+38)→ 环比(y0+72)→ 同比(y0+88,行距 16);按墨迹框算,标题↔名 ~9px(原 ~5px 太挤,整列下移 4px)、 指标名 F12(y0+21)→ 值 F24 粗(y0+38)→ 环比(y0+72)→ 同比(y0+88,行距 16);按墨迹框算,标题↔名 ~9px(原 ~5px 太挤,整列下移 4px)、
名↔值 ~9px、值↔环比 ~6px、环比↔同比 5px。 名↔值 ~9px、值↔环比 ~6px、环比↔同比 5px。
为什么不再是「值 F36 + 右侧叠放环比/同比」的并排组:五列每列仅 ~157px,F36 并排组(如「2.12万」102+间隙+环比 77) 为什么不再是「值 F36 + 右侧叠放环比/同比」的并排组:五列每列仅 ~157px,F36 并排组(如「2.12万」102+间隙+环比 77)
@@ -126,7 +218,7 @@ class E1002Renderer(DashboardRenderer):
for i, c in enumerate(cols): for i, c in enumerate(cols):
cx = x0 + cw * (i + 0.5) cx = x0 + cw * (i + 0.5)
self.ctext(cx, TAG_Y, c["k"], self.F12) self.ctext(cx, TAG_Y, c["k"], self.F12)
self.ctext(cx, VAL_Y, c["v"], self.F24, bold=1) self._value(cx, VAL_Y, c, bold=1) # F24 粗主数字 + 小号 ¥ / 万
self._cmp_line(cx, HB_Y, "环比", c["hb"]) self._cmp_line(cx, HB_Y, "环比", c["hb"])
self._cmp_line(cx, TB_Y, "同比", c["tb"]) self._cmp_line(cx, TB_Y, "同比", c["tb"])
return y0 + 104 return y0 + 104
@@ -152,7 +244,7 @@ class E1002Renderer(DashboardRenderer):
col = GREEN if chg >= 0 else RED col = GREEN if chg >= 0 else RED
xr = x1 xr = x1
pct_txt = f"{abs(chg):.2f}%" pct_txt = f"{abs(chg):.2f}%"
self.rtext(xr, y0, pct_txt, self.F12, fill=col); xr -= self._tw(pct_txt, self.F12) + 4 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 self.tri(xr - 4, y0 + 8.5, 7, up=chg >= 0, fill=col); xr -= 8 + 6
if btc.get("chg_label"): # 涨跌幅口径(24h / 1d) 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, btc["chg_label"], self.F12); xr -= self._tw(btc["chg_label"], self.F12) + 10
@@ -205,31 +297,55 @@ class E1002Renderer(DashboardRenderer):
dt, wt, mao = data["date"], data["weather"], data["mao"] dt, wt, mao = data["date"], data["weather"], data["mao"]
usage, gpt = data["usage"], data.get("usage_gpt") or {} usage, gpt = data["usage"], data.get("usage_gpt") or {}
# ---- A. 顶部:大时钟 F84 占满整段(左)+ 星期/日期紧随其右、与时钟数字基线对齐 / 天气(右)---- # ---- A. 顶部:大时钟 F84 占满整段(左);其右一列三行、左缘与日期对齐:① 电池 ② 温度+湿度 ③ 星期/日期(与时钟基线对齐)
# / 天气(右):上层 图标 ×1.25 | 温度 F48(顶沿与时钟齐平、右贴页边);下层 一行「定位+城市 天气 温区」右对齐、与日期同基线 ----
DIV_Y = 90 DIV_Y = 90
TIME_BOLD, INK_TOP = 3, 16 # 伪粗体 3px;墨迹顶沿 16 与旧版 F48 相同,保住上边距 TIME_BOLD, INK_TOP = 3, 16 # 伪粗体 3px;墨迹顶沿 16,底沿 79(F84 数字墨迹高 63)
_, tt, _, tb = self.F84.getbbox(dt["time"]) # F84 数字墨迹 28~91 → 高 63,摆到 16~79,距横规 11px _, tt, _, tb = self.F84.getbbox(dt["time"])
time_y = INK_TOP - tt time_y = INK_TOP - tt
base_y = time_y + tb # 时钟数字基线(墨迹底沿) base_y = time_y + tb # 时钟数字基线(墨迹底沿)
self.text(10, time_y, dt["time"], self.F84, bold=TIME_BOLD) 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"]}' date_txt = f'{dt["week"]} {dt["greg"]}'
_, _, _, db = self.F12.getbbox(dt["greg"]) # 用日期数字的底沿对齐基线(汉字底沿比数字低 1px,忽略) _, _, _, db = self.F12.getbbox(dt["greg"]) # 用日期数字的底沿对齐基线(汉字底沿比数字低 1px,忽略)
self.text(10 + self._tw(dt["time"], self.F84) + TIME_BOLD + 14, base_y - db, date_txt, self.F12) date_y = base_y - db
loc, temp, cr = wt["loc"], wt["temp"], f'{wt["cond"]} {wt["range"]}' self.text(col_x, date_y, date_txt, self.F12)
self.rtext(R, 8, loc, self.F12) # 状态两行叠在日期上方:行距 22(F12 墨迹中心 ≈ 文字 y+8.5),三行中心约 30.5 / 52.5 / 74.5,落在时钟墨迹 16~79 之内
self.rtext(R, 30, temp, self.F24, bold=1) PITCH = 22
self.rtext(R, 64, cr, self.F12) cy_date = date_y + 8.5
PIN_W, PIN_GAP = 8, 6 dev = data.get("device")
loc_pin_x = R - self._tw(loc, self.F12) - PIN_GAP - PIN_W self._status_battery(col_x, cy_date - 2 * PITCH, dev)
self._loc_pin(loc_pin_x, 10, PIN_W, fg=RED) self._status_env(col_x, cy_date - PITCH, dev)
s = 1.5 # 天气(右,2026-09-15 用户要求)上下两层:上层 天气图标 | 实时温度 F48(右贴页边 R,墨迹顶沿 16 与时钟齐平);
iw, ih = 36 * s, 38 * s # 下层 一行「定位水滴+城市 天气 温区」F12 右对齐贴 R,与日期行同基线(底沿 79,与时钟基线对齐)。
text_left = min(loc_pin_x, R - self._tw(temp, self.F24) - 1, R - self._tw(cr, self.F12)) # 此前(同日上午)为「三行信息|图标 ×1.75|温度 F84 与时钟同高」,用户随即改为本版;更早为 F72「小一号」版(2026-09-14)。
self._weather_icon(text_left - 12 - iw, (DIV_Y - ih) / 2, loc, temp, cond, rng = wt["loc"], wt["temp"], wt["cond"], wt["range"]
self._weather_category(wt.get("icon")), s=s, accent=RED, wet=BLUE) 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) self.hdash(8, self.W - 8, DIV_Y)
# ---- B. 用量:左 Claude | 右 ChatGPT(紧凑单行,与三色版同款)---- # ---- B. 用量:左 Claude | 右 Codex(紧凑单行,与三色版同款)----
MID = self.W // 2 # 400 MID = self.W // 2 # 400
y_l = self._usage_block(10, MID - 14, DIV_Y + 6, usage) 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 y_r = self._usage_block(MID + 14, R, DIV_Y + 6, gpt) if gpt else y_l

52
sensecraft_api.py Normal file
View 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())

128
server.py
View File

@@ -15,18 +15,23 @@ HTML 控件填同一个 URL 则**每个刷新周期都重抓**。所以对外只
- 天气 / 橘喵今日实时 / BTC K 线:**内存缓存 + 有效期**(WEATHER_TTL / MAO_TTL / BTC_TTL,.env 可配,0=每次实拉): - 天气 / 橘喵今日实时 / BTC K 线:**内存缓存 + 有效期**(WEATHER_TTL / MAO_TTL / BTC_TTL,.env 可配,0=每次实拉):
收到请求先查该路缓存是否过期,过期才实拉并更新时间戳,否则直接用缓存渲染;拉取失败沿用旧值、不更新时间戳(下次再试), 收到请求先查该路缓存是否过期,过期才实拉并更新时间戳,否则直接用缓存渲染;拉取失败沿用旧值、不更新时间戳(下次再试),
从未成功则占位 "--"。整个服务**不读本机任何文件**(部署在公网服务器)。 从未成功则占位 "--"。整个服务**不读本机任何文件**(部署在公网服务器)。
- Claude Usage / ChatGPT Usage:**不主动拉取**,由本机 `main.py --target push` 推送原始快照到 POST /push/usage、/push/usage_gpt, - Claude Usage / Codex Usage:**不主动拉取、不持凭证**,由本机 `main.py --target push` 采集后推送原始快照到 POST /push/usage、/push/usage_gpt,
服务端暂存最新一份(内存;PUSH_STATE_PATH 非空时同时落盘,重启恢复);渲染时按当前时刻现算 pace, 服务端暂存最新一份(内存;PUSH_STATE_PATH 非空时同时落盘,重启恢复);渲染时按当前时刻现算 pace,
- 三路上游过期时**并行**实拉,整体最多等 FETCH_BUDGET 秒(默认 5),超预算的一路本次用旧值、后台拉完更新缓存(见 build_data);
所以推送时机与设备抓取时机不必对齐。未推送过:Claude 显示 "--",ChatGPT 用 mock 并标「示例数据」。 所以推送时机与设备抓取时机不必对齐。未推送过:Claude 显示 "--",ChatGPT 用 mock 并标「示例数据」。
- 推送鉴权:请求头 X-Push-Token 必须等于 PUSH_TOKEN;PUSH_TOKEN 为空时拒绝一切推送(服务在公网,务必配置)。 - 推送鉴权:请求头 X-Push-Token 必须等于 PUSH_TOKEN;PUSH_TOKEN 为空时拒绝一切推送(服务在公网,务必配置)。
- 页面鉴权:IMAGE_TOKEN 非空时,三个渲染端点必须带 ?token=<IMAGE_TOKEN>,否则返回与未知路径一样的 404(防爬虫扫描)。
其他端点:GET /health → JSON 运行状态;GET /push/usage、/push/usage_gpt → 查看暂存的原始快照(需同样令牌)。 其他端点:GET /health → JSON 运行状态;GET /push/usage、/push/usage_gpt → 查看暂存的原始快照(需同样令牌)。
部署:python3 server.py(监听 SERVER_HOST:SERVER_PORT),前置 Caddy/Nginx 做 HTTPS,或 cloudflared tunnel。 部署:python3 server.py(监听 SERVER_HOST:SERVER_PORT),前置 Caddy/Nginx 做 HTTPS,或 cloudflared tunnel。
""" """
import hmac
import io import io
import json import json
import os import os
import threading import threading
import time import time
import urllib.parse
from concurrent.futures import ThreadPoolExecutor, wait
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
import config import config
@@ -43,8 +48,9 @@ class State:
self.lock = threading.Lock() self.lock = threading.Lock()
self.raw = {"usage": None, "usage_gpt": None} self.raw = {"usage": None, "usage_gpt": None}
self.pushed_at = {"usage": None, "usage_gpt": None} self.pushed_at = {"usage": None, "usage_gpt": None}
self.last_good = {"weather": None, "mao": None, "btc": None} # 各路最近一次成功的数据 self.last_good = {"weather": None, "mao": None, "btc": None, "device": None} # 各路最近一次成功的数据
self.fetched_at = {"weather": 0.0, "mao": 0.0, "btc": 0.0} # 各路最近一次成功拉取的时间戳(缓存有效期起点) 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.renders = {"png": 0, "html": 0}
self.last_render = None self.last_render = None
self.started = time.time() self.started = time.time()
@@ -88,50 +94,95 @@ _RENDER_LOCK = threading.Lock() # Pillow 渲染器持有 self.img/self.d,
_PNG_RENDERER = E1002Renderer() _PNG_RENDERER = E1002Renderer()
def _cached(key, ttl, fetch, fallback): _POOL = ThreadPoolExecutor(max_workers=6, thread_name_prefix="fetch")
"""带有效期的内存缓存:未过期 → 直接返回缓存;过期 → 实拉,成功则更新数据与时间戳,失败沿用旧值(无旧值用 fallback)。
fetch() 返回 (data, ok)。返回 (data, 状态字串) 供日志。"""
now = time.time() def _refresh(key, fetch):
with STATE.lock: """在线程池里实拉一路上游:成功则更新 last_good/fetched_at;无论成败都清掉 inflight。返回 (data, ok, 耗时秒)。"""
cached, ts = STATE.last_good[key], STATE.fetched_at[key] t0 = time.time()
if cached is not None and ttl > 0 and now - ts < ttl:
return cached, f"{key}:cache({int(now - ts)}s)"
try: try:
d, ok = fetch() d, ok = fetch()
except Exception as e: # noqa: BLE001 —— 上游任何异常都按失败处理 except Exception as e: # noqa: BLE001 —— 上游任何异常都按失败处理
print(f"[警告] {key} 拉取异常:{e}") print(f"[警告] {key} 拉取异常:{e}")
d, ok = None, False d, ok = None, False
if ok: with STATE.lock:
with STATE.lock: if ok:
STATE.last_good[key], STATE.fetched_at[key] = d, now STATE.last_good[key], STATE.fetched_at[key] = d, time.time()
return d, f"{key}:fetched" STATE.inflight.pop(key, None)
if cached is not None: return d, ok, time.time() - t0
return cached, f"{key}:stale"
return fallback, f"{key}:none"
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): def build_data(now=None):
"""组装渲染数据:三路上游按各自 TTL 走内存缓存,用量取推送暂存。返回 (data, 各路状态列表)。""" """组装渲染数据:三路上游按各自 TTL 走内存缓存;过期的**并行**实拉,整体最多等 FETCH_BUDGET 秒,
超预算的那路本次用旧值/占位、后台继续拉完更新缓存——响应时间上限 ≈ 预算 + 渲染,任何一路上游卡死都不再拖垮整图。
(2026-09-14 前为串行实拉、各上游 socket 超时 30s,单路卡住即整体超时。)用量取推送暂存。返回 (data, 各路状态列表)。"""
now = now or data.now_tz() now = now or data.now_tz()
with STATE.lock: with STATE.lock:
raw_usage, raw_gpt = STATE.raw["usage"], STATE.raw["usage_gpt"] raw_usage, raw_gpt = STATE.raw["usage"], STATE.raw["usage_gpt"]
weather, s1 = _cached("weather", config.WEATHER_TTL, data.weather_fetch, fallbacks = {
{"loc": config.WEATHER_LOC_NAME, "cond": "--", "temp": "--", "range": "--", "icon": ""}) "weather": {"loc": config.WEATHER_LOC_NAME, "cond": "--", "temp": "--", "range": "--", "icon": ""},
mao, s2 = _cached("mao", config.MAO_TTL, lambda: data.mao_fetch(now, with_trend=False), "mao": {"upd": now.strftime("%H:%M"), "cols": [data._placeholder_col(k) for k in ("单量", "流水", "毛利")],
{"upd": now.strftime("%H:%M"), "cols": [data._placeholder_col(k) for k in ("单量", "流水", "毛利")], "trend": {"dates": [], "series": []}},
"trend": {"dates": [], "series": []}}) "btc": data.btc_empty(),
btc, s3 = _cached("btc", config.BTC_TTL, lambda: (data.btc_fetch(), True), 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 = { d = {
"date": {"greg": now.strftime("%Y/%m/%d"), "week": data.WEEK_CN[now.weekday()], "time": now.strftime("%H:%M")}, "date": {"greg": now.strftime("%Y/%m/%d"), "week": data.WEEK_CN[now.weekday()], "time": now.strftime("%H:%M")},
"weather": weather, "weather": result["weather"],
"mao": mao, "mao": result["mao"],
"usage": data._usage(now, raw=raw_usage if raw_usage is not None else {}), # 未推送 → 全部 "--"(不读本机文件) "usage": data._usage(now, raw=raw_usage if raw_usage is not None else {}), # 未推送 → 全部 "--"(不读本机文件)
"usage_gpt": data._usage_gpt(now, raw=raw_gpt), # 未推送 → mock(标「示例数据」) "usage_gpt": data._usage_gpt(now, raw=raw_gpt, allow_local=False), # 未推送 → mock(标「示例数据」),服务端不本地采集
"btc": btc, "btc": result["btc"],
"device": result["device"],
} }
return d, [s1, s2, s3] return d, [status[k] for k in ("weather", "mao", "btc", "device")]
def render_png_bytes(d): def render_png_bytes(d):
@@ -173,12 +224,24 @@ class Handler(BaseHTTPRequestHandler):
def _authed(self): def _authed(self):
tok = config.PUSH_TOKEN tok = config.PUSH_TOKEN
return bool(tok) and self.headers.get("X-Push-Token", "") == tok 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 ---------- # ---------- GET ----------
def do_GET(self): def do_GET(self):
path = self.path.split("?", 1)[0] path = self.path.split("?", 1)[0]
try: 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: if path == config.IMAGE_PATH:
t0 = time.time() t0 = time.time()
d, st = build_data() d, st = build_data()
@@ -217,7 +280,8 @@ class Handler(BaseHTTPRequestHandler):
"last_render": STATE.last_render, "pushed_at": dict(STATE.pushed_at), "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()}, "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()}, "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}, "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, "image_path": config.IMAGE_PATH, "html_path": config.HTML_PATH,
"html_png_path": config.HTML_PNG_PATH, "tz": config.TZ_NAME} "html_png_path": config.HTML_PNG_PATH, "tz": config.TZ_NAME}
self._log(200) self._log(200)

62
usage_common.py Normal file
View 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}")

View File

@@ -1,33 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Claude Code 用量本地读取 —— 只读 claude-statusline 生产的用量快照,**不发任何网络请求**。
生产者/消费者模式:生产者 = 打过补丁的 claude-statusline(见其仓库 bin/statusline.sh 的
「Usage snapshot for external consumers」段),它把每次从 Claude Code stdin 拿到的**权威实时**额度
落盘到 config.USAGE_LOCAL_PATH(默认 ~/.claude/usage-snapshot.json);本模块作为消费者读取。
结构含 five_hour / seven_day / seven_day_fable(后者为 Fable 模型限定的 7 天额度,statusline 从
/api/oauth/usage 的 limits[] 转存,旧版 statusline 或 API 无该项时为 null),各有 utilization(已用百分比)
与 resets_at(重置时间,epoch 秒或 ISO,供上层算「时间已流逝%」做 pace 对比),外加 updated_at(落盘 epoch)。
活跃使用时快照几乎持续刷新、准确;空闲时停在最后一次。文件缺失/损坏时抛错 → 上层回退占位 "--"。
"""
import json
import config
def get_usage() -> dict:
"""读取本地 statusline 用量缓存并返回(含 five_hour / seven_day)。缺失/损坏时抛出。"""
with open(config.USAGE_LOCAL_PATH, encoding="utf-8") as f:
data = json.load(f)
if "five_hour" not in data:
raise RuntimeError(f"用量缓存缺 five_hour 字段:{config.USAGE_LOCAL_PATH}")
return data
if __name__ == "__main__":
d = get_usage()
for k in ("five_hour", "seven_day", "seven_day_fable"):
s = d.get(k) or {}
print(k, "util=", s.get("utilization"), "resets_at=", s.get("resets_at"))