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:
@@ -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
1
.gitignore
vendored
@@ -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
|
||||||
|
|||||||
11
config.py
11
config.py
@@ -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(和风/橘喵)、8s(BTC),若不设预算、任一路卡住会拖到平台抓取超时;预算 ≥ 正常三路并行耗时(~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;服务端不持任何凭证、不读本机文件。
|
||||||
# Claude(claude_usage.py):复用 Claude Code 自身的 OAuth 登录态(macOS 钥匙串「Claude Code-credentials」,只读、绝不刷新),
|
# Claude(claude_usage.py):复用 Claude Code 自身的 OAuth 登录态(macOS 钥匙串「Claude Code-credentials」,只读、绝不刷新),
|
||||||
|
|||||||
45
data.py
45
data.py
@@ -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),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 顶部(0~90) :**大时钟 F84**(墨迹 16~79,占满上下边距内的整段高度)+ 星期/日期 F12 紧随其右、与时钟数字**基线对齐**(左)
|
A 顶部(0~90) :**大时钟 F84**(墨迹 16~79)+ 其右一列三行、左缘对齐:① 电池图标+电量 ② 温度计+温度 | 水滴+湿度 ③ 星期/日期
|
||||||
|
(F12,行距 22,日期与时钟数字**基线对齐**;设备数据来自 SenseCraft 遥测 `data.device`;2026-09-14 定稿,
|
||||||
|
此前「状态栏横排在时钟上方+时钟缩 F60」版被否)
|
||||||
|天气(右,贴 790)——天气组件与三色版同尺寸。
|
|天气(右,贴 790)——天气组件与三色版同尺寸。
|
||||||
B 用量(96~206) :左半 **Claude Usage**(两行:5h + 合并行 7d——一个大边框内上下两条实心 = 总 7d / fable 7d,右侧数字取较大者,`_usage_row_split`)|竖规 x=400|右半 **Codex Usage**(5h / 7d,ChatGPT 订阅的 Codex 额度池;未推送时 mock 标「示例数据」)。
|
B 用量(96~206) :左半 **Claude Usage**(两行:5h + 合并行 7d——一个大边框内上下两条实心 = 总 7d / fable 7d,右侧数字取较大者,`_usage_row_split`)|竖规 x=400|右半 **Codex Usage**(5h / 7d,ChatGPT 订阅的 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,底沿 79(F84 数字墨迹高 63)
|
||||||
_, tt, _, tb = self.F84.getbbox(dt["time"]) # F84 数字墨迹 28~91 → 高 63,摆到 16~79,距横规 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)
|
||||||
|
# 状态两行叠在日期上方:行距 22(F12 墨迹中心 ≈ 文字 y+8.5),三行中心约 30.5 / 52.5 / 74.5,落在时钟墨迹 16~79 之内
|
||||||
|
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
52
sensecraft_api.py
Normal 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.interval(300s)上报一次,故 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
105
server.py
@@ -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_TOKEN;PUSH_TOKEN 为空时拒绝一切推送(服务在公网,务必配置)。
|
- 推送鉴权:请求头 X-Push-Token 必须等于 PUSH_TOKEN;PUSH_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 = {} # 进行中的拉取 future(key → 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:
|
||||||
STATE.last_good[key], STATE.fetched_at[key] = d, now
|
if ok:
|
||||||
return d, f"{key}:fetched"
|
STATE.last_good[key], STATE.fetched_at[key] = d, time.time()
|
||||||
if cached is not None:
|
STATE.inflight.pop(key, None)
|
||||||
return cached, f"{key}:stale"
|
return d, ok, time.time() - t0
|
||||||
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)
|
||||||
|
|||||||
Reference in New Issue
Block a user