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>
This commit is contained in:
2026-09-14 14:08:21 +08:00
parent 2015947812
commit 7378d6bb88
15 changed files with 561 additions and 108 deletions

View File

@@ -42,5 +42,16 @@ PACE_TOL=10
BTC_BAR=1H BTC_BAR=1H
BTC_LIMIT=48 BTC_LIMIT=48
BTC_TTL=300 BTC_TTL=300
# ---- 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

2
.gitignore vendored
View File

@@ -4,5 +4,7 @@ __pycache__/
.weather_cache.json .weather_cache.json
.trend_cache.json .trend_cache.json
.btc_cache.json .btc_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

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

@@ -104,12 +104,37 @@ BTC_LIMIT = int(os.environ.get("BTC_LIMIT", "48")) # 展示最近 N 根
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_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 模块)数据源 ---- # ---- AI 用量数据源Claude Usage / Codex Usage 两个模块)----
# 只读本地、**不发任何网络请求**(详见 usage_local.py——生产者/消费者模式: # 形态 A两路都在**本机**采集(需本机的登录态),由 main.py --target push 上报给 server.py服务端不持任何凭证、不读本机文件。
# 生产者 = 打过补丁的 claude-statusline把每次从 Claude Code stdin 拿到的**权威实时**额度 # Claudeclaude_usage.py复用 Claude Code 自身的 OAuth 登录态macOS 钥匙串「Claude Code-credentials」只读、绝不刷新
# five_hour/seven_day 的 utilization + resets_at落盘到 ~/.claude/usage-snapshot.json本项目读它。 # 直接调 https://api.anthropic.com/api/oauth/usage——与 Claude Code `/usage` 同源、**账户级**CLI/桌面/网页消耗都算),
# 活跃使用时该快照几乎持续刷新、准确;空闲时停在最后一次。文件缺失时用量区显示 "--" # 不依赖 Claude Code 进程在跑、不依赖 statusline 插件
USAGE_LOCAL_PATH = os.environ.get("USAGE_LOCAL_PATH", os.path.expanduser("~/.claude/usage-snapshot.json")) 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% 黄档,随即被否——黄小字压白底发虚、且四档在三行里过于花):

53
data.py
View File

@@ -18,7 +18,8 @@ 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
WEEK_CN = ["周一", "周二", "周三", "周四", "周五", "周六", "周日"] WEEK_CN = ["周一", "周二", "周三", "周四", "周五", "周六", "周日"]
@@ -311,26 +312,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 表的常量);缺哪个窗口哪个显示 "--"
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,6 +346,7 @@ 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 not None: if pct is not None and tp is not None:
bar["time_pct"] = tp bar["time_pct"] = tp
@@ -358,31 +362,34 @@ 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

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,7 +95,7 @@ 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 "")
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 +173,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

@@ -115,6 +115,10 @@ class DashboardRenderer:
"""环比/同比一行:标签 + 涨跌三角 + 数值,整体居中于 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 _cmp_at(self, x, y, label, pair, fg=BLACK): def _cmp_at(self, x, y, label, pair, fg=BLACK):
"""环比/同比一行,左对齐于 x宽版把它放在数值右侧时用""" """环比/同比一行,左对齐于 x宽版把它放在数值右侧时用"""
direction, val = pair direction, val = pair
@@ -122,9 +126,9 @@ class DashboardRenderer:
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" up = direction == "up"
val_color = RED if up else fg val_color = self._cmp_color(direction, fg)
if direction: if 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=up, fill=val_color); x += trw + tgap
@@ -203,6 +207,7 @@ class DashboardRenderer:
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 整体红,否则黑。"""

View File

@@ -8,7 +8,7 @@ reTerminal E1002 渲染层 —— 复用 renderer.DashboardRenderer 的组件(
版式k=1点阵字体且**只用整数倍字号 12/24/36/48/84**,不放大;四段,三道横规): 版式k=1点阵字体且**只用整数倍字号 12/24/36/48/84**,不放大;四段,三道横规):
A 顶部090 **大时钟 F84**(墨迹 1679占满上下边距内的整段高度 星期/日期 F12 紧随其右、与时钟数字**基线对齐**(左) A 顶部090 **大时钟 F84**(墨迹 1679占满上下边距内的整段高度 星期/日期 F12 紧随其右、与时钟数字**基线对齐**(左)
|天气(右,贴 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 +51,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)
@@ -66,13 +66,29 @@ class E1002Renderer(DashboardRenderer):
# ---------- 配色钩子(三档,顺序固定 绿 → 黑 → 红;用量与 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 _pace(self, xr, cy, diff):
"""宽版diff=0持平/无数据)不画三角,但**仍预留三角位**6 + 间隙 4使每行 pace 列等宽、进度条右端齐平。"""
w = super()._pace(xr, cy, diff)
return w if diff != 0 else w + 10 * self.k
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:
@@ -101,14 +117,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
@@ -229,7 +279,7 @@ class E1002Renderer(DashboardRenderer):
self._weather_category(wt.get("icon")), s=s, accent=RED, wet=BLUE) self._weather_category(wt.get("icon")), s=s, accent=RED, wet=BLUE)
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

View File

@@ -15,7 +15,7 @@ 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
所以推送时机与设备抓取时机不必对齐。未推送过Claude 显示 "--"ChatGPT 用 mock 并标「示例数据」。 所以推送时机与设备抓取时机不必对齐。未推送过Claude 显示 "--"ChatGPT 用 mock 并标「示例数据」。
- 推送鉴权:请求头 X-Push-Token 必须等于 PUSH_TOKENPUSH_TOKEN 为空时拒绝一切推送(服务在公网,务必配置)。 - 推送鉴权:请求头 X-Push-Token 必须等于 PUSH_TOKENPUSH_TOKEN 为空时拒绝一切推送(服务在公网,务必配置)。
@@ -128,7 +128,7 @@ def build_data(now=None):
"weather": weather, "weather": weather,
"mao": mao, "mao": 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": btc,
} }
return d, [s1, s2, s3] return d, [s1, s2, s3]

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"))