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>
This commit is contained in:
107
server.py
107
server.py
@@ -17,6 +17,7 @@ HTML 控件填同一个 URL 则**每个刷新周期都重抓**。所以对外只
|
||||
从未成功则占位 "--"。整个服务**不读本机任何文件**(部署在公网服务器)。
|
||||
- Claude Usage / Codex Usage:**不主动拉取、不持凭证**,由本机 `main.py --target push` 采集后推送原始快照到 POST /push/usage、/push/usage_gpt,
|
||||
服务端暂存最新一份(内存;PUSH_STATE_PATH 非空时同时落盘,重启恢复);渲染时按当前时刻现算 pace,
|
||||
- 三路上游过期时**并行**实拉,整体最多等 FETCH_BUDGET 秒(默认 5),超预算的一路本次用旧值、后台拉完更新缓存(见 build_data);
|
||||
所以推送时机与设备抓取时机不必对齐。未推送过:Claude 显示 "--",ChatGPT 用 mock 并标「示例数据」。
|
||||
- 推送鉴权:请求头 X-Push-Token 必须等于 PUSH_TOKEN;PUSH_TOKEN 为空时拒绝一切推送(服务在公网,务必配置)。
|
||||
其他端点:GET /health → JSON 运行状态;GET /push/usage、/push/usage_gpt → 查看暂存的原始快照(需同样令牌)。
|
||||
@@ -27,6 +28,7 @@ import json
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
from concurrent.futures import ThreadPoolExecutor, wait
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
|
||||
import config
|
||||
@@ -43,8 +45,9 @@ class State:
|
||||
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.last_good = {"weather": None, "mao": None, "btc": None, "device": None} # 各路最近一次成功的数据
|
||||
self.fetched_at = {"weather": 0.0, "mao": 0.0, "btc": 0.0, "device": 0.0} # 各路最近一次成功拉取的时间戳(缓存有效期起点)
|
||||
self.inflight = {} # 进行中的拉取 future(key → Future),防同一路重复实拉
|
||||
self.renders = {"png": 0, "html": 0}
|
||||
self.last_render = None
|
||||
self.started = time.time()
|
||||
@@ -88,50 +91,95 @@ _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)"
|
||||
_POOL = ThreadPoolExecutor(max_workers=6, thread_name_prefix="fetch")
|
||||
|
||||
|
||||
def _refresh(key, fetch):
|
||||
"""在线程池里实拉一路上游:成功则更新 last_good/fetched_at;无论成败都清掉 inflight。返回 (data, ok, 耗时秒)。"""
|
||||
t0 = time.time()
|
||||
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"
|
||||
with STATE.lock:
|
||||
if ok:
|
||||
STATE.last_good[key], STATE.fetched_at[key] = d, time.time()
|
||||
STATE.inflight.pop(key, None)
|
||||
return d, ok, time.time() - t0
|
||||
|
||||
|
||||
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):
|
||||
"""组装渲染数据:三路上游按各自 TTL 走内存缓存,用量取推送暂存。返回 (data, 各路状态列表)。"""
|
||||
"""组装渲染数据:三路上游按各自 TTL 走内存缓存;过期的**并行**实拉,整体最多等 FETCH_BUDGET 秒,
|
||||
超预算的那路本次用旧值/占位、后台继续拉完更新缓存——响应时间上限 ≈ 预算 + 渲染,任何一路上游卡死都不再拖垮整图。
|
||||
(2026-09-14 前为串行实拉、各上游 socket 超时 30s,单路卡住即整体超时。)用量取推送暂存。返回 (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())
|
||||
fallbacks = {
|
||||
"weather": {"loc": config.WEATHER_LOC_NAME, "cond": "--", "temp": "--", "range": "--", "icon": ""},
|
||||
"mao": {"upd": now.strftime("%H:%M"), "cols": [data._placeholder_col(k) for k in ("单量", "流水", "毛利")],
|
||||
"trend": {"dates": [], "series": []}},
|
||||
"btc": 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 = {
|
||||
"date": {"greg": now.strftime("%Y/%m/%d"), "week": data.WEEK_CN[now.weekday()], "time": now.strftime("%H:%M")},
|
||||
"weather": weather,
|
||||
"mao": mao,
|
||||
"weather": result["weather"],
|
||||
"mao": result["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,
|
||||
"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):
|
||||
@@ -217,7 +265,8 @@ class Handler(BaseHTTPRequestHandler):
|
||||
"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},
|
||||
"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,
|
||||
"html_png_path": config.HTML_PNG_PATH, "tz": config.TZ_NAME}
|
||||
self._log(200)
|
||||
|
||||
Reference in New Issue
Block a user