- 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>
279 lines
14 KiB
Python
279 lines
14 KiB
Python
#!/usr/bin/env python3
|
||
# -*- coding: utf-8 -*-
|
||
"""
|
||
图片/网页服务 —— 给 SenseCraft HMI 的 HTML 控件周期抓取的**恒定 URL**,每次请求都**现取数据、现渲染**,全程内存、不落盘。
|
||
|
||
真机验证结论(2026-09):SenseCraft 的 Image 控件只在填写 URL 时抓一次快照、此后不再更新;
|
||
HTML 控件填同一个 URL 则**每个刷新周期都重抓**。所以对外只暴露 HTML 控件用的 URL,两种形态并行供对比:
|
||
方案一 GET <IMAGE_PATH>(默认 /e1002.png)→ image/png:与 main.py --target e1002 同一套 Pillow 点阵渲染(renderer_e1002)
|
||
方案二 GET <HTML_PATH> (默认 /e1002.html)→ text/html:800x480 的现代网页(html_renderer,系统/网络字体 + 内联 SVG K 线),
|
||
由平台无头浏览器截图后量化(真机实拍:文字抗锯齿灰边被平台抖动成毛边、小字糊)
|
||
方案三 GET <HTML_PNG_PATH>(默认 /e1002-web.png)→ image/png:同一网页由**服务端**无头 Chrome 截图,再按最近色(不抖动)
|
||
固化为六色 PNG(html_shot),平台拿到的已是纯色图;加 ?raw=1 返回未量化的原始截图供对照。需服务器装 Chrome。
|
||
两者共用同一份 build_data():
|
||
- 日期时间:服务器按 config.TZ_NAME 时区现算
|
||
- 天气 / 橘喵今日实时 / BTC K 线:**内存缓存 + 有效期**(WEATHER_TTL / MAO_TTL / BTC_TTL,.env 可配,0=每次实拉):
|
||
收到请求先查该路缓存是否过期,过期才实拉并更新时间戳,否则直接用缓存渲染;拉取失败沿用旧值、不更新时间戳(下次再试),
|
||
从未成功则占位 "--"。整个服务**不读本机任何文件**(部署在公网服务器)。
|
||
- Claude Usage / Codex Usage:**不主动拉取、不持凭证**,由本机 `main.py --target push` 采集后推送原始快照到 POST /push/usage、/push/usage_gpt,
|
||
服务端暂存最新一份(内存;PUSH_STATE_PATH 非空时同时落盘,重启恢复);渲染时按当前时刻现算 pace,
|
||
所以推送时机与设备抓取时机不必对齐。未推送过:Claude 显示 "--",ChatGPT 用 mock 并标「示例数据」。
|
||
- 推送鉴权:请求头 X-Push-Token 必须等于 PUSH_TOKEN;PUSH_TOKEN 为空时拒绝一切推送(服务在公网,务必配置)。
|
||
其他端点:GET /health → JSON 运行状态;GET /push/usage、/push/usage_gpt → 查看暂存的原始快照(需同样令牌)。
|
||
部署:python3 server.py(监听 SERVER_HOST:SERVER_PORT),前置 Caddy/Nginx 做 HTTPS,或 cloudflared tunnel。
|
||
"""
|
||
import io
|
||
import json
|
||
import os
|
||
import threading
|
||
import time
|
||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||
|
||
import config
|
||
import data
|
||
from renderer_e1002 import E1002Renderer
|
||
import html_renderer
|
||
import html_shot
|
||
|
||
|
||
class State:
|
||
"""服务端内存状态:推送来的原始用量快照 + 上游数据的「上一次成功值」+ 计数。"""
|
||
|
||
def __init__(self):
|
||
self.lock = threading.Lock()
|
||
self.raw = {"usage": None, "usage_gpt": None}
|
||
self.pushed_at = {"usage": None, "usage_gpt": None}
|
||
self.last_good = {"weather": None, "mao": None, "btc": None} # 各路最近一次成功的数据
|
||
self.fetched_at = {"weather": 0.0, "mao": 0.0, "btc": 0.0} # 各路最近一次成功拉取的时间戳(缓存有效期起点)
|
||
self.renders = {"png": 0, "html": 0}
|
||
self.last_render = None
|
||
self.started = time.time()
|
||
self._load()
|
||
|
||
# ---- 推送数据持久化(可选)----
|
||
def _load(self):
|
||
p = config.PUSH_STATE_PATH
|
||
if not p or not os.path.exists(p):
|
||
return
|
||
try:
|
||
with open(p, encoding="utf-8") as f:
|
||
st = json.load(f)
|
||
self.raw.update(st.get("raw") or {})
|
||
self.pushed_at.update(st.get("pushed_at") or {})
|
||
print(f"[状态] 已从 {p} 恢复推送数据")
|
||
except Exception as e:
|
||
print(f"[警告] 推送状态恢复失败:{e}")
|
||
|
||
def _save(self):
|
||
p = config.PUSH_STATE_PATH
|
||
if not p:
|
||
return
|
||
try:
|
||
tmp = p + ".tmp"
|
||
with open(tmp, "w", encoding="utf-8") as f:
|
||
json.dump({"raw": self.raw, "pushed_at": self.pushed_at}, f, ensure_ascii=False)
|
||
os.replace(tmp, p)
|
||
except Exception as e:
|
||
print(f"[警告] 推送状态落盘失败:{e}")
|
||
|
||
def push(self, kind, raw):
|
||
with self.lock:
|
||
self.raw[kind] = raw
|
||
self.pushed_at[kind] = time.time()
|
||
self._save()
|
||
|
||
|
||
STATE = State()
|
||
_RENDER_LOCK = threading.Lock() # Pillow 渲染器持有 self.img/self.d,非线程安全
|
||
_PNG_RENDERER = E1002Renderer()
|
||
|
||
|
||
def _cached(key, ttl, fetch, fallback):
|
||
"""带有效期的内存缓存:未过期 → 直接返回缓存;过期 → 实拉,成功则更新数据与时间戳,失败沿用旧值(无旧值用 fallback)。
|
||
fetch() 返回 (data, ok)。返回 (data, 状态字串) 供日志。"""
|
||
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)"
|
||
try:
|
||
d, ok = fetch()
|
||
except Exception as e: # noqa: BLE001 —— 上游任何异常都按失败处理
|
||
print(f"[警告] {key} 拉取异常:{e}")
|
||
d, ok = None, False
|
||
if ok:
|
||
with STATE.lock:
|
||
STATE.last_good[key], STATE.fetched_at[key] = d, now
|
||
return d, f"{key}:fetched"
|
||
if cached is not None:
|
||
return cached, f"{key}:stale"
|
||
return fallback, f"{key}:none"
|
||
|
||
|
||
def build_data(now=None):
|
||
"""组装渲染数据:三路上游按各自 TTL 走内存缓存,用量取推送暂存。返回 (data, 各路状态列表)。"""
|
||
now = now or data.now_tz()
|
||
with STATE.lock:
|
||
raw_usage, raw_gpt = STATE.raw["usage"], STATE.raw["usage_gpt"]
|
||
|
||
weather, s1 = _cached("weather", config.WEATHER_TTL, data.weather_fetch,
|
||
{"loc": config.WEATHER_LOC_NAME, "cond": "--", "temp": "--", "range": "--", "icon": ""})
|
||
mao, s2 = _cached("mao", config.MAO_TTL, lambda: data.mao_fetch(now, with_trend=False),
|
||
{"upd": now.strftime("%H:%M"), "cols": [data._placeholder_col(k) for k in ("单量", "流水", "毛利")],
|
||
"trend": {"dates": [], "series": []}})
|
||
btc, s3 = _cached("btc", config.BTC_TTL, lambda: (data.btc_fetch(), True), data.btc_empty())
|
||
|
||
d = {
|
||
"date": {"greg": now.strftime("%Y/%m/%d"), "week": data.WEEK_CN[now.weekday()], "time": now.strftime("%H:%M")},
|
||
"weather": weather,
|
||
"mao": mao,
|
||
"usage": data._usage(now, raw=raw_usage if raw_usage is not None else {}), # 未推送 → 全部 "--"(不读本机文件)
|
||
"usage_gpt": data._usage_gpt(now, raw=raw_gpt, allow_local=False), # 未推送 → mock(标「示例数据」),服务端不本地采集
|
||
"btc": btc,
|
||
}
|
||
return d, [s1, s2, s3]
|
||
|
||
|
||
def render_png_bytes(d):
|
||
"""渲染 PNG 到内存字节。"""
|
||
with _RENDER_LOCK:
|
||
img = _PNG_RENDERER.render(d)
|
||
buf = io.BytesIO()
|
||
img.save(buf, "PNG", optimize=True)
|
||
return buf.getvalue()
|
||
|
||
|
||
class Handler(BaseHTTPRequestHandler):
|
||
server_version = "eink-push/1.0"
|
||
protocol_version = "HTTP/1.1"
|
||
|
||
def log_message(self, fmt, *args):
|
||
pass
|
||
|
||
def _log(self, code, note=""):
|
||
ua = self.headers.get("User-Agent", "-")
|
||
ip = self.headers.get("CF-Connecting-IP") or self.headers.get("X-Forwarded-For") or self.client_address[0]
|
||
print(f"[{time.strftime('%H:%M:%S')}] {code} {self.command} {self.path} ip={ip} ua={ua[:60]} {note}", flush=True)
|
||
|
||
def _send(self, code, ctype, body, extra=None):
|
||
self.send_response(code)
|
||
self.send_header("Content-Type", ctype)
|
||
self.send_header("Content-Length", str(len(body)))
|
||
self.send_header("Cache-Control", "no-store, no-cache, must-revalidate, max-age=0")
|
||
self.send_header("Pragma", "no-cache")
|
||
self.send_header("Expires", "0")
|
||
for k, v in (extra or {}).items():
|
||
self.send_header(k, v)
|
||
self.end_headers()
|
||
if self.command != "HEAD":
|
||
self.wfile.write(body)
|
||
|
||
def _json(self, code, obj):
|
||
self._send(code, "application/json; charset=utf-8", json.dumps(obj, ensure_ascii=False).encode("utf-8"))
|
||
|
||
def _authed(self):
|
||
tok = config.PUSH_TOKEN
|
||
return bool(tok) and self.headers.get("X-Push-Token", "") == tok
|
||
|
||
# ---------- GET ----------
|
||
def do_GET(self):
|
||
path = self.path.split("?", 1)[0]
|
||
try:
|
||
if path == config.IMAGE_PATH:
|
||
t0 = time.time()
|
||
d, st = build_data()
|
||
body = render_png_bytes(d)
|
||
with STATE.lock:
|
||
STATE.renders["png"] += 1
|
||
STATE.last_render = time.time()
|
||
self._log(200, f"png {len(body)}B {time.time() - t0:.2f}s [{' '.join(st)}]")
|
||
return self._send(200, "image/png", body)
|
||
if path == config.HTML_PATH:
|
||
t0 = time.time()
|
||
d, st = build_data()
|
||
body = html_renderer.render_html(d).encode("utf-8")
|
||
with STATE.lock:
|
||
STATE.renders["html"] += 1
|
||
STATE.last_render = time.time()
|
||
self._log(200, f"html {len(body)}B {time.time() - t0:.2f}s [{' '.join(st)}]")
|
||
return self._send(200, "text/html; charset=utf-8", body)
|
||
if path == config.HTML_PNG_PATH:
|
||
t0 = time.time()
|
||
raw = "raw=1" in (self.path.split("?", 1) + [""])[1]
|
||
d, st = build_data()
|
||
html = html_renderer.render_html(d)
|
||
img = html_shot.screenshot_html(html)
|
||
if not raw:
|
||
img = html_shot.quantize6(img)
|
||
buf = io.BytesIO(); img.save(buf, "PNG", optimize=True); body = buf.getvalue()
|
||
with STATE.lock:
|
||
STATE.renders["web_png"] = STATE.renders.get("web_png", 0) + 1
|
||
STATE.last_render = time.time()
|
||
self._log(200, f"web-png{'(raw)' if raw else ''} {len(body)}B {time.time() - t0:.2f}s [{' '.join(st)}]")
|
||
return self._send(200, "image/png", body)
|
||
if path == "/health":
|
||
with STATE.lock:
|
||
st = {"ok": True, "uptime_s": int(time.time() - STATE.started), "renders": dict(STATE.renders),
|
||
"last_render": STATE.last_render, "pushed_at": dict(STATE.pushed_at),
|
||
"has_last_good": {k: v is not None for k, v in STATE.last_good.items()},
|
||
"cache_age_s": {k: (int(time.time() - v) if v else None) for k, v in STATE.fetched_at.items()},
|
||
"ttl_s": {"weather": config.WEATHER_TTL, "mao": config.MAO_TTL, "btc": config.BTC_TTL},
|
||
"image_path": config.IMAGE_PATH, "html_path": config.HTML_PATH,
|
||
"html_png_path": config.HTML_PNG_PATH, "tz": config.TZ_NAME}
|
||
self._log(200)
|
||
return self._json(200, st)
|
||
if path in ("/push/usage", "/push/usage_gpt"):
|
||
if not self._authed():
|
||
self._log(401); return self._json(401, {"ok": False, "error": "bad token"})
|
||
kind = path.rsplit("/", 1)[1]
|
||
with STATE.lock:
|
||
raw, at = STATE.raw[kind], STATE.pushed_at[kind]
|
||
self._log(200)
|
||
return self._json(200, {"ok": True, "kind": kind, "pushed_at": at, "raw": raw})
|
||
if path == "/favicon.ico":
|
||
return self._send(204, "image/x-icon", b"")
|
||
self._log(404)
|
||
self._json(404, {"ok": False, "error": "not found"})
|
||
except Exception as e: # noqa: BLE001 —— 任何渲染异常都返回 500 而不是断开连接
|
||
self._log(500, repr(e))
|
||
self._json(500, {"ok": False, "error": repr(e)})
|
||
|
||
do_HEAD = do_GET
|
||
|
||
# ---------- POST:推送 ----------
|
||
def do_POST(self):
|
||
path = self.path.split("?", 1)[0]
|
||
if path not in ("/push/usage", "/push/usage_gpt"):
|
||
self._log(404); return self._json(404, {"ok": False, "error": "not found"})
|
||
if not self._authed():
|
||
self._log(401); return self._json(401, {"ok": False, "error": "bad token"})
|
||
try:
|
||
n = int(self.headers.get("Content-Length") or 0)
|
||
if n <= 0 or n > 64 * 1024:
|
||
raise ValueError(f"bad content-length {n}")
|
||
raw = json.loads(self.rfile.read(n).decode("utf-8"))
|
||
if not isinstance(raw, dict):
|
||
raise ValueError("body must be a JSON object")
|
||
except Exception as e: # noqa: BLE001
|
||
self._log(400, repr(e)); return self._json(400, {"ok": False, "error": f"bad json: {e}"})
|
||
kind = path.rsplit("/", 1)[1]
|
||
STATE.push(kind, raw)
|
||
self._log(200, f"push {kind} keys={sorted(raw)[:6]}")
|
||
self._json(200, {"ok": True, "kind": kind, "received_at": time.time()})
|
||
|
||
|
||
def main():
|
||
if not config.PUSH_TOKEN:
|
||
print("[警告] PUSH_TOKEN 未配置:将拒绝一切推送,用量区会一直显示 \"--\"")
|
||
srv = ThreadingHTTPServer((config.SERVER_HOST, config.SERVER_PORT), Handler)
|
||
print(f"服务已启动 http://{config.SERVER_HOST}:{config.SERVER_PORT} 方案一 {config.IMAGE_PATH} 方案二 {config.HTML_PATH} "
|
||
f"方案三 {config.HTML_PNG_PATH} /health", flush=True)
|
||
try:
|
||
srv.serve_forever()
|
||
except KeyboardInterrupt:
|
||
pass
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|