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>
This commit is contained in:
2026-09-10 18:55:47 +08:00
parent 6dc89b504e
commit 85b5113ba4
14 changed files with 1192 additions and 114 deletions

142
probe_server.py Normal file
View File

@@ -0,0 +1,142 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
验证工具 —— SenseCraft HMI 的 Image/HTML 控件对**同一 URL** 是否每个刷新周期都重新抓取。
原理:本地起一个极简 HTTP 服务,固定路径 /probe.png 每 --every 秒重绘一张 800x480 探针图
(大号生成时刻 HH:MM:SS + 递增序号 + 六色校准块),响应头 Cache-Control: no-store
每次有人请求就在终端打一行日志时间、路径、User-Agent、来源 IP含经隧道透传的 CF-Connecting-IP
判定:
- 终端日志每个设备刷新周期都出现一次 /probe.png 请求,且设备画面上的时刻/序号随之推进 → **每周期重抓,方案二成立**
此后只要不断覆盖同一 URL 指向的文件即可,无需再经 HTML 页。
- 日志只在「部署」那一刻出现一次、之后再无请求 → 平台只在部署时抓一次并缓存,方案二不成立。
- 也可同时把 /index.htmlHTML 控件方案)放到另一台画布对照。
用法:
python3 probe_server.py [--port 8787] [--every 60]
# 另开终端暴露成公网 HTTPS云端抓不到局域网/明文 HTTP。cloudflared 快速隧道无需账号:
brew install cloudflared
cloudflared tunnel --url http://localhost:8787
# 把打印出的 https://xxxx.trycloudflare.com/probe.png 填入 SenseCraft 画布的 Image 控件(铺满 800x480→ 部署。
"""
import argparse
import io
import threading
import time
from datetime import datetime
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from PIL import Image, ImageDraw, ImageFont
import config
BLACK, WHITE, RED = (0, 0, 0), (255, 255, 255), (255, 0, 0)
SIX = [BLACK, WHITE, (255, 255, 0), RED, (0, 255, 0), (0, 0, 255)]
_state = {"png": b"", "seq": 0, "stamp": ""}
_lock = threading.Lock()
def render_probe(seq, now):
"""800x480 探针图:序号(左上)/ 大号时刻居中F96/ 日期(下)/ 底部六色块。全部纯色。"""
img = Image.new("RGB", (800, 480), WHITE)
d = ImageDraw.Draw(img)
d.fontmode = "1"
f24 = ImageFont.truetype(config.FONT_PATH, 24)
f96 = ImageFont.truetype(config.FONT_PATH, 96)
d.text((20, 16), f"probe #{seq:04d}", font=f24, fill=BLACK)
d.text((20, 48), "SenseCraft re-fetch test / 同一 URL 每周期是否重抓", font=f24, fill=BLACK)
t = now.strftime("%H:%M:%S")
w = d.textlength(t, font=f96)
for dx in range(3): # 伪粗体
d.text(((800 - w) / 2 + dx, 150), t, font=f96, fill=RED)
ds = now.strftime("%Y/%m/%d %A")
d.text(((800 - d.textlength(ds, font=f24)) / 2, 290), ds, font=f24, fill=BLACK)
d.text((20, 400), "画面上的时刻若随设备刷新周期推进 → 平台每周期重新抓取了同一 URL", font=f24, fill=BLACK)
n = len(SIX)
for i, c in enumerate(SIX):
d.rectangle([round(800 / n * i), 456, round(800 / n * (i + 1)) - 1, 479], fill=c)
buf = io.BytesIO()
img.save(buf, "PNG")
return buf.getvalue()
def regenerate(every):
while True:
now = datetime.now()
with _lock:
_state["seq"] += 1
_state["png"] = render_probe(_state["seq"], now)
_state["stamp"] = now.strftime("%H:%M:%S")
print(f"[{now:%H:%M:%S}] 重绘 probe.png seq={_state['seq']}", flush=True)
time.sleep(every)
_INDEX = """<!doctype html><html><head><meta charset="utf-8">
<meta http-equiv="Cache-Control" content="no-store"><title>probe</title>
<style>html,body{margin:0;width:800px;height:480px;overflow:hidden;background:#fff}
img{display:block;width:800px;height:480px;image-rendering:pixelated}</style></head>
<body><img src="/probe.png" width="800" height="480"></body></html>"""
class Handler(BaseHTTPRequestHandler):
server_version = "probe/1.0"
def log_message(self, fmt, *args): # 关闭默认日志,用自定义格式
pass
def _log(self, code):
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"[{datetime.now():%H:%M:%S}] {code} GET {self.path} ip={ip} ua={ua}", flush=True)
def _send(self, code, ctype, body):
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")
self.send_header("Access-Control-Allow-Origin", "*")
self.end_headers()
self.wfile.write(body)
def do_GET(self):
path = self.path.split("?", 1)[0]
if path == "/probe.png":
with _lock:
body = _state["png"]
self._log(200)
return self._send(200, "image/png", body)
if path in ("/", "/index.html"):
self._log(200)
return self._send(200, "text/html; charset=utf-8", _INDEX.encode("utf-8"))
if path == "/favicon.ico":
return self._send(204, "image/x-icon", b"")
self._log(404)
self._send(404, "text/plain; charset=utf-8", b"not found")
do_HEAD = do_GET
def main():
ap = argparse.ArgumentParser(description="SenseCraft 同 URL 重抓验证服务")
ap.add_argument("--port", type=int, default=8787)
ap.add_argument("--every", type=int, default=60, help="探针图重绘间隔(秒)")
args = ap.parse_args()
threading.Thread(target=regenerate, args=(args.every,), daemon=True).start()
while not _state["png"]:
time.sleep(0.05)
srv = ThreadingHTTPServer(("0.0.0.0", args.port), Handler)
print(f"探针服务已启动http://localhost:{args.port}/probe.png HTML 对照页:/index.html")
print("下一步:另开终端执行 cloudflared tunnel --url http://localhost:%d ,把得到的 https 地址 + /probe.png 填入 Image 控件。" % args.port)
print("观察本终端:每个设备刷新周期是否出现一条 GET /probe.png。Ctrl+C 退出。", flush=True)
try:
srv.serve_forever()
except KeyboardInterrupt:
pass
if __name__ == "__main__":
main()