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:
2026-09-14 15:02:34 +08:00
parent 7378d6bb88
commit f96ae29590
8 changed files with 255 additions and 41 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
@@ -55,3 +60,5 @@ CODEX_USAGE_TTL=300
CODEX_USAGE_BACKOFF=900 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

1
.gitignore vendored
View File

@@ -4,6 +4,7 @@ __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 .claude_usage_cache.json
.codex_usage_cache.json .codex_usage_cache.json
run.sh run.sh

File diff suppressed because one or more lines are too long

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"))
@@ -104,6 +107,14 @@ 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")
# ---- reTerminal E1002 设备遥测SenseCraft HMI电量/温度/湿度,宽版顶部状态栏)----
# api-key 由用户在 SenseCraft 后台生成,放 .env设备每 300s 上报一次DEVICE_TTL 与之同步0=每次实拉)。
SENSECRAFT_API_BASE = os.environ.get("SENSECRAFT_API_BASE", "https://sensecraft-hmi-api.seeed.cc")
SENSECRAFT_API_KEY = os.environ.get("SENSECRAFT_API_KEY", "")
SENSECRAFT_DEVICE_EUI = os.environ.get("SENSECRAFT_DEVICE_EUI", "")
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 两个模块)---- # ---- AI 用量数据源Claude Usage / Codex Usage 两个模块)----
# 形态 A两路都在**本机**采集(需本机的登录态),由 main.py --target push 上报给 server.py服务端不持任何凭证、不读本机文件。 # 形态 A两路都在**本机**采集(需本机的登录态),由 main.py --target push 上报给 server.py服务端不持任何凭证、不读本机文件。
# Claudeclaude_usage.py复用 Claude Code 自身的 OAuth 登录态macOS 钥匙串「Claude Code-credentials」只读、绝不刷新 # Claudeclaude_usage.py复用 Claude Code 自身的 OAuth 登录态macOS 钥匙串「Claude Code-credentials」只读、绝不刷新

45
data.py
View File

@@ -21,6 +21,7 @@ import weather_api
import claude_usage import claude_usage
import codex_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)
@@ -395,6 +396,49 @@ def _usage_gpt(now, raw=None, allow_local=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()
@@ -409,4 +453,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

@@ -6,7 +6,9 @@ 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 其右一列三行、左缘对齐:① 电池图标+电量 ② 温度计+温度 水滴+湿度 ③ 星期/日期
F12行距 22日期与时钟数字**基线对齐**;设备数据来自 SenseCraft 遥测 `data.device`2026-09-14 定稿,
此前「状态栏横排在时钟上方+时钟缩 F60」版被否
|天气(右,贴 790——天气组件与三色版同尺寸。 |天气(右,贴 790——天气组件与三色版同尺寸。
B 用量96206 :左半 **Claude Usage**两行5h 合并行 7d——一个大边框内上下两条实心 = 总 7d / fable 7d右侧数字取较大者`_usage_row_split`)|竖规 x=400右半 **Codex Usage**5h / 7dChatGPT 订阅的 Codex 额度池;未推送时 mock 标「示例数据」)。 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
@@ -98,6 +100,43 @@ class E1002Renderer(DashboardRenderer):
return BLACK return BLACK
# ---------- 组件 ---------- # ---------- 组件 ----------
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 "--"
self._mid(x, cy, txt, F)
return x + self._tw(txt, F)
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 "--"
self._mid(x, cy, txt, F); x += self._tw(txt, F) + 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 "--"
self._mid(x, cy, txt, F)
return x + self._tw(txt, F)
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
@@ -255,16 +294,24 @@ 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 占满整段(左);其右一列三行、左缘与日期对齐:① 电池 ② 温度+湿度 ③ 星期/日期与时钟基线对齐/ 天气(右)----
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
self.text(col_x, date_y, date_txt, self.F12)
# 状态两行叠在日期上方:行距 22F12 墨迹中心 ≈ 文字 y+8.5),三行中心约 30.5 / 52.5 / 74.5,落在时钟墨迹 1679 之内
PITCH = 22
cy_date = date_y + 8.5
dev = data.get("device")
self._status_battery(col_x, cy_date - 2 * PITCH, dev)
self._status_env(col_x, cy_date - PITCH, dev)
loc, temp, cr = wt["loc"], wt["temp"], f'{wt["cond"]} {wt["range"]}' loc, temp, cr = wt["loc"], wt["temp"], f'{wt["cond"]} {wt["range"]}'
self.rtext(R, 8, loc, self.F12) self.rtext(R, 8, loc, self.F12)
self.rtext(R, 30, temp, self.F24, bold=1) self.rtext(R, 30, temp, self.F24, bold=1)

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

107
server.py
View File

@@ -17,6 +17,7 @@ HTML 控件填同一个 URL 则**每个刷新周期都重抓**。所以对外只
从未成功则占位 "--"。整个服务**不读本机任何文件**(部署在公网服务器)。 从未成功则占位 "--"。整个服务**不读本机任何文件**(部署在公网服务器)。
- Claude Usage / Codex 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 为空时拒绝一切推送(服务在公网,务必配置)。
其他端点GET /health → JSON 运行状态GET /push/usage、/push/usage_gpt → 查看暂存的原始快照(需同样令牌)。 其他端点GET /health → JSON 运行状态GET /push/usage、/push/usage_gpt → 查看暂存的原始快照(需同样令牌)。
@@ -27,6 +28,7 @@ import json
import os import os
import threading import threading
import time import time
from concurrent.futures import ThreadPoolExecutor, wait
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
import config import config
@@ -43,8 +45,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 +91,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, allow_local=False), # 未推送 → 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):
@@ -217,7 +265,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)