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_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----
# 服务端:监听地址/端口、两条恒定 URL 路径(可改成难猜的路径)、推送令牌(为空则拒绝一切推送)、推送数据落盘路径(空=仅内存)
SERVER_HOST=0.0.0.0
@@ -55,3 +60,5 @@ CODEX_USAGE_TTL=300
CODEX_USAGE_BACKOFF=900
# 服务端server.py三路上游的内存缓存有效期0=每次实拉):天气/BTC 复用 WEATHER_TTL/BTC_TTL橘喵实时用 MAO_TTL
MAO_TTL=60
# 服务端一次请求等待上游并行实拉的总预算(秒),超预算的一路本次用旧值、后台拉完更新缓存
FETCH_BUDGET=5

1
.gitignore vendored
View File

@@ -4,6 +4,7 @@ __pycache__/
.weather_cache.json
.trend_cache.json
.btc_cache.json
.device_cache.json
.claude_usage_cache.json
.codex_usage_cache.json
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。
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 则每周期重抓。
SERVER_HOST = os.environ.get("SERVER_HOST", "0.0.0.0")
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_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 两个模块)----
# 形态 A两路都在**本机**采集(需本机的登录态),由 main.py --target push 上报给 server.py服务端不持任何凭证、不读本机文件。
# 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 codex_usage
import btc_api
import sensecraft_api
WEEK_CN = ["周一", "周二", "周三", "周四", "周五", "周六", "周日"]
TZ = ZoneInfo(config.TZ_NAME)
@@ -395,6 +396,49 @@ def _usage_gpt(now, raw=None, allow_local=True):
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):
"""返回渲染所需的完整数据字典(本机脚本形态:带文件缓存,用量读本地快照)。"""
now = now or now_tz()
@@ -409,4 +453,5 @@ def get_dashboard_data(now=None):
"usage": _usage(now),
"usage_gpt": _usage_gpt(now),
"btc": _btc(now),
"device": _device(now),
}

View File

@@ -6,7 +6,9 @@ reTerminal E1002 渲染层 —— 复用 renderer.DashboardRenderer 的组件(
并生成一张固定 800x480 的 HTML 页面供 SenseCraft HMI 的 HTML 控件云端截图拉取。
版式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——天气组件与三色版同尺寸。
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
@@ -98,6 +100,43 @@ class E1002Renderer(DashboardRenderer):
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):
"""六色测试色条:六块等宽实心色块,白块加 1px 黑框以便可见。真机校准用。"""
x0 = 10 if x0 is None else x0
@@ -255,16 +294,24 @@ class E1002Renderer(DashboardRenderer):
dt, wt, mao = data["date"], data["weather"], data["mao"]
usage, gpt = data["usage"], data.get("usage_gpt") or {}
# ---- A. 顶部:大时钟 F84 占满整段(左)+ 星期/日期紧随其右、与时钟数字基线对齐 / 天气(右)----
# ---- A. 顶部:大时钟 F84 占满整段(左);其右一列三行、左缘与日期对齐:① 电池 ② 温度+湿度 ③ 星期/日期与时钟基线对齐/ 天气(右)----
DIV_Y = 90
TIME_BOLD, INK_TOP = 3, 16 # 伪粗体 3px墨迹顶沿 16 与旧版 F48 相同,保住上边距
_, tt, _, tb = self.F84.getbbox(dt["time"]) # F84 数字墨迹 2891 → 高 63摆到 1679距横规 11px
TIME_BOLD, INK_TOP = 3, 16 # 伪粗体 3px墨迹顶沿 16,底沿 79F84 数字墨迹高 63
_, tt, _, tb = self.F84.getbbox(dt["time"])
time_y = INK_TOP - tt
base_y = time_y + tb # 时钟数字基线(墨迹底沿)
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"]}'
_, _, _, 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"]}'
self.rtext(R, 8, loc, self.F12)
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())

105
server.py
View File

@@ -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_TOKENPUSH_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 = {} # 进行中的拉取 futurekey → 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"
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)