Compare commits

...

10 Commits

Author SHA1 Message Date
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 1012 additions and 209 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 不含 HomebrewCODEX_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 E1002800x480 六色):产出 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 passwordservice「Claude Code-credentials」JSON 的 claudeAiOauth.accessToken
3. 文件 ~/.claude/.credentials.jsonLinux / SSH 场景;尊重 CLAUDE_CONFIG_DIR
铁律:
- **绝不刷新 token**。refresh token 每次使用即轮换,第三方刷新而不写回会让 Claude Code 下次 401 并强制重新登录。
access token 约 8 小时有效,过期就沿用旧快照(标 stale等 Claude Code 自己下次启动刷新。
- **快照有效期 CLAUDE_USAGE_TTL 秒内不再调**.env 可调,默认 3000=每次实拉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 或已过期——本机状态而非接口限流:退避只需一个 TTLClaude 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/usagetoken 刷新由 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 可执行文件」)。窗口归类按 windowDurationMinsPro 计划常只回一个窗口、另一个为 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和风/橘喵、8sBTC若不设预算、任一路卡住会拖到平台抓取超时预算 ≥ 正常三路并行耗时(~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服务端不持任何凭证、不读本机文件。
# Claudeclaude_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") # 只存快照与时间戳,不存凭证
# Codexcodex_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 默认目录 ~/.codex2026-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% 黄档,随即被否——黄小字压白底发虚、且四档在三行里过于花):

122
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,33 +145,37 @@ 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
@@ -311,26 +317,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 秒或 ISOwindow 为该窗口真实长度秒(缺省用 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 +351,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 null5h 窗口重置后常见):窗口尚未开始,已流逝 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 +369,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=Truerenderer 在标题右侧标「示例数据」)。本机形态直接经 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 +459,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 时 KSVG - 版式与 PNG 版同构:顶部时钟/天气;左 Claude Usage、右 Codex Usage橘喵今日实时三列BTC 时 KSVG
- 配色规则与 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与 Codexcodex app-server RPC
# PUSH_URL + PUSH_TOKEN服务端渲染时按当前时刻现算 pace # 两路账户级用量并推送到 server.pyPUSH_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 - Claudeclaude_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 - Codexcodex_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) 为中心——占三角的位子表示「持平」。
用量行 pacediff=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 等号(增长率恰为 02026-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/26kgetbbox小号段下移其差值
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 整体红,否则黑。"""
@@ -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,9 +6,12 @@ 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 顶部090 **大时钟 F84**(墨迹 1679,占满上下边距内的整段高度)+ 星期/日期 F12 紧随其右、与时钟数字**基线对齐**(左) A 顶部090 **大时钟 F84**(墨迹 1679 其右一列三行、左缘对齐:① 电池图标+电量 ② 温度计+温度 水滴+湿度 ③ 星期/日期
右侧上下两层:上 天气图标 ×1.25 **实时温度 F48 粗 3**(墨迹顶沿 16 与时钟齐平、「°」F24 上标、右贴页边 790下 一行「定位水滴+城市 天气 温区」F12 右对齐贴 790、与日期同基线
F12行距 22日期与时钟数字**基线对齐**;设备数据来自 SenseCraft 遥测 `data.device`2026-09-14 定稿,
此前「状态栏横排在时钟上方+时钟缩 F60」版被否
|天气(右,贴 790——天气组件与三色版同尺寸。 |天气(右,贴 790——天气组件与三色版同尺寸。
B 用量96206 :左半 **Claude Usage**all 5h / all 7d / fable 7d)|竖规 x=400右半 **ChatGPT Usage**mockall 5h / all 7d / codex 7d)。 B 用量96206 :左半 **Claude Usage**两行5h 合并行 7d——一个大边框内上下两条实心 = 总 7d / fable 7d右侧数字取较大者`_usage_row_split`)|竖规 x=400右半 **Codex Usage**5h / 7dChatGPT 订阅的 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」与今日实时同款。
@@ -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,14 +159,48 @@ 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):
"""橘喵·今日实时五列(宽版):探店单量 / 探店流水 / 团购单量 / 团购流水 / 毛利,列内四行逐行水平居中: """橘喵·今日实时五列(宽版):探店单量 / 探店流水 / 团购单量 / 团购流水 / 毛利,列内四行逐行水平居中:
指标名 F12y0+21→ 值 F24 粗y0+38→ 环比y0+72→ 同比y0+88行距 16按墨迹框算标题↔名 ~9px原 ~5px 太挤,整列下移 4px 指标名 F12y0+21→ 值 F24 粗y0+38→ 环比y0+72→ 同比y0+88行距 16按墨迹框算标题↔名 ~9px原 ~5px 太挤,整列下移 4px
@@ -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,底沿 79F84 数字墨迹高 63
_, tt, _, tb = self.F84.getbbox(dt["time"]) # F84 数字墨迹 2891 → 高 63摆到 1679距横规 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) # 状态两行叠在日期上方:行距 22F12 墨迹中心 ≈ 文字 y+8.5),三行中心约 30.5 / 52.5 / 74.5,落在时钟墨迹 1679 之内
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.interval300s上报一次故 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_TOKENPUSH_TOKEN 为空时拒绝一切推送(服务在公网,务必配置)。 - 推送鉴权:请求头 X-Push-Token 必须等于 PUSH_TOKENPUSH_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 = {} # 进行中的拉取 futurekey → 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_TTL0=每次实拉)→ 直接用缓存,不发请求;
- 拉取失败 → 沿用旧缓存(标 stale并按 BACKOFF 秒退避,退避期内不再尝试;异常对象带 `backoff` 属性时按它退避
claude_usage.TokenUnavailable = 一个 TTLtoken 过期是本机状态、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"))