Files
eink-push/server.py
YANG JIANKUAN 85b5113ba4 feat: E1002 新增公网图片服务(恒定 URL 现渲染)+底部改为 BTC 时K
- server.py:三条恒定 URL 并行——/e1002.png 内存现渲染 PNG(主力)、
  /e1002.html 现代网页、/e1002-web.png 网页经无头 Chrome 截图后固化六色;
  天气/橘喵/BTC 按 WEATHER_TTL/MAO_TTL/BTC_TTL 走内存缓存,过期才实拉、
  失败沿用旧值;用量不读本机文件,改由 POST /push/usage(_gpt) 推送暂存
- push_client.py:本机把 Claude 用量快照推送到服务(X-Push-Token 鉴权)
- btc_api.py:OKX/Coinbase/Huobi 免费接口逐源兜底,时K 近 72 小时,涨绿跌红
- data.py 拆出 weather_fetch/mao_fetch/btc_fetch 与 usage_from_raw,
  统一按 TZ_NAME 时区;宽版只用点阵字体整数倍字号 12/24/36/48
- probe_server.py:验证平台对同一 URL 是否周期重抓(结论:Image 控件
  不重抓,HTML 控件重抓)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-10 18:55:47 +08:00

279 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
图片/网页服务 —— 给 SenseCraft HMI 的 HTML 控件周期抓取的**恒定 URL**,每次请求都**现取数据、现渲染**,全程内存、不落盘。
真机验证结论2026-09SenseCraft 的 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/html800x480 的现代网页html_renderer系统/网络字体 + 内联 SVG K 线),
由平台无头浏览器截图后量化(真机实拍:文字抗锯齿灰边被平台抖动成毛边、小字糊)
方案三 GET <HTML_PNG_PATH>(默认 /e1002-web.png→ image/png同一网页由**服务端**无头 Chrome 截图,再按最近色(不抖动)
固化为六色 PNGhtml_shot平台拿到的已是纯色图加 ?raw=1 返回未量化的原始截图供对照。需服务器装 Chrome。
两者共用同一份 build_data()
- 日期时间:服务器按 config.TZ_NAME 时区现算
- 天气 / 橘喵今日实时 / BTC K 线:**内存缓存 + 有效期**WEATHER_TTL / MAO_TTL / BTC_TTL.env 可配0=每次实拉):
收到请求先查该路缓存是否过期,过期才实拉并更新时间戳,否则直接用缓存渲染;拉取失败沿用旧值、不更新时间戳(下次再试),
从未成功则占位 "--"。整个服务**不读本机任何文件**(部署在公网服务器)。
- Claude Usage / ChatGPT Usage**不主动拉取**,由本机 `main.py --target push` 推送原始快照到 POST /push/usage、/push/usage_gpt
服务端暂存最新一份内存PUSH_STATE_PATH 非空时同时落盘,重启恢复);渲染时按当前时刻现算 pace
所以推送时机与设备抓取时机不必对齐。未推送过Claude 显示 "--"ChatGPT 用 mock 并标「示例数据」。
- 推送鉴权:请求头 X-Push-Token 必须等于 PUSH_TOKENPUSH_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), # 未推送 → 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()