重构屏幕控制引擎 + 新增 Web 监控面板

- ScreenManager: 基于优先级(9/5/0)的统一屏幕控制引擎,替代 _tick 中的
  散乱 if-else 逻辑
- 四个亮屏事件: scheduled(p9), sensor(p9), manual(p5), external(p0)
- GPIO 回调: 电平变化立即触发亮屏(异步线程,不阻塞轮询)
- _fast_check: 每秒轻量评估,提高息屏超时精度(±1s 替代 ±30s)
- padsleep_web: 通过 IPC 读取守护进程状态的实时 Web 面板
  (GPIO 电平/屏幕状态/ADB 连接,端口 31400)
- padsleep-web.service: 关联 padsleep.service 的自启动服务
- install.sh: 修复 venv 初始化目录上下文,使用 requirements.txt
- IpcClient: 修复 socket 复用导致交替失败的问题
- padsleep_tui: 适配 ScreenManager 状态显示

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
kushidou
2026-07-20 00:15:07 +08:00
co-authored by Claude Fable 5
parent 9940db451f
commit e6fbe550bb
8 changed files with 1134 additions and 103 deletions
+201
View File
@@ -0,0 +1,201 @@
#!/usr/bin/env python3
"""
padsleep Web Monitor
====================
通过 Unix Socket 从 padsleep 守护进程读取全量状态(含 GPIO 实时电平),
通过 WebSocket 推送到浏览器实时展示。不与守护进程竞争硬件资源。
用法:
# 先启动 padsleep 守护进程
python padsleep.py
# 再启动 Web 监控(新终端)
python padsleep_web.py
# 浏览器打开 http://树莓派IP:31400
依赖:
pip install flask flask-socketio
配置项(环境变量):
PORT=31400 Web 端口
"""
import json
import os
import socket
import threading
import time
from flask import Flask, render_template
from flask_socketio import SocketIO, emit
# ── 配置 ────────────────────────────────────────────
PORT = int(os.environ.get("PORT", "31400"))
SOCKET_PATH = "/tmp/padsleep.sock"
POLL_FAST_S = 0.2 # GPIO 电平轮询间隔(200ms,跟随守护进程 50ms 更新)
POLL_SLOW_S = 1 # 其他状态轮询间隔
# ═══════════════════════════════════════════════════════════════════
# IpcClient — 与 padsleep 守护进程通信(Unix Socket
# ═══════════════════════════════════════════════════════════════════
class IpcClient:
"""Unix Socket JSON 行协议客户端(每次 send 新建连接)。"""
def __init__(self, socket_path=SOCKET_PATH):
self.socket_path = socket_path
self.last_error = ""
def _connect(self) -> socket.socket | None:
try:
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
sock.settimeout(2)
sock.connect(self.socket_path)
return sock
except (socket.error, OSError) as e:
self.last_error = str(e)
return None
def send(self, cmd: str, **kwargs) -> dict:
"""发送命令,每次新建连接(守护进程每请求关闭连接)。"""
sock = self._connect()
if sock is None:
return {"ok": False, "error": f"未连接: {self.last_error}"}
req = {"cmd": cmd, **kwargs}
try:
sock.sendall((json.dumps(req) + "\n").encode("utf-8"))
resp = sock.recv(65536)
return json.loads(resp.decode("utf-8").strip())
except (socket.error, json.JSONDecodeError) as e:
return {"ok": False, "error": str(e)}
finally:
try:
sock.close()
except Exception:
pass
# ═══════════════════════════════════════════════════════════════════
# Flask 应用
# ═══════════════════════════════════════════════════════════════════
app = Flask(__name__)
app.config["SECRET_KEY"] = os.urandom(16).hex()
socketio = SocketIO(app, cors_allowed_origins="*")
ipc = IpcClient()
# ── 全局缓存(供不同频率的轮询线程共享)──
_data_lock = threading.Lock()
_cached = {
"gpio_level": -1,
"gpio_time": "--",
"status": None,
}
# ── 路由 ─────────────────────────────────────────────
@app.route("/")
def index():
return render_template("index.html")
# ── 后台线程:高频轮询 GPIO 电平 ────────────────────
def poll_gpio():
"""通过 IPC 高频读取 GPIO 电平,变化时 WebSocket 广播。"""
last_val = -1
while True:
r = ipc.send("status")
if r.get("ok"):
level = r["data"].get("gpio_level", -1)
now = time.strftime("%H:%M:%S") + f".{int(time.time() * 1000) % 1000:03d}"
if level != -1 and level != last_val:
socketio.emit("gpio_update", {
"value": level,
"time": now,
})
last_val = level
# 更新缓存(补上 connected 字段,前端据此判断连接状态)
r["data"]["connected"] = True
with _data_lock:
_cached["gpio_level"] = level
_cached["gpio_time"] = now
_cached["status"] = r["data"]
socketio.sleep(POLL_FAST_S)
# ── 后台线程:慢速轮询全量状态 ──────────────────────
def poll_status():
"""定期查询守护进程全量状态,通过 WebSocket 广播。"""
while True:
with _data_lock:
status = _cached.get("status")
if status:
socketio.emit("status_update", status)
socketio.sleep(POLL_SLOW_S)
# ── WebSocket 事件 ──────────────────────────────────
@socketio.on("connect")
def on_connect(auth=None):
print("[WS] 客户端已连接")
# 推送初始状态
r = ipc.send("status")
if r.get("ok"):
data = r["data"]
data["connected"] = True
level = data.get("gpio_level", -1)
now = time.strftime("%H:%M:%S") + f".{int(time.time() * 1000) % 1000:03d}"
with _data_lock:
_cached["gpio_level"] = level
_cached["gpio_time"] = now
_cached["status"] = data
socketio.emit("gpio_update", {"value": level, "time": now})
socketio.emit("status_update", data)
else:
socketio.emit("status_update", {
"connected": False,
"error": r.get("error", "无法连接守护进程"),
})
@socketio.on("disconnect")
def on_disconnect():
print("[WS] 客户端已断开")
# ── 入口 ─────────────────────────────────────────────
def main():
print("╔══════════════════════════════════════════╗")
print("║ padsleep Web Monitor ║")
print("║ (通过 IPC 读取守护进程状态) ║")
print("╠══════════════════════════════════════════╣")
print(f"║ 监听地址: http://0.0.0.0:{PORT}")
print(f"║ 数据源: {SOCKET_PATH}")
print(f"║ GPIO 轮询: {POLL_FAST_S*1000:.0f}ms — 跟随守护进程 50ms 线程")
print(f"║ 状态轮询: {POLL_SLOW_S}s")
print(f"║ 请先启动 padsleep.py 守护进程")
print(f"║ 按 Ctrl+C 停止 ║")
print("╚══════════════════════════════════════════╝")
# 启动后台线程
socketio.start_background_task(poll_gpio)
socketio.start_background_task(poll_status)
try:
socketio.run(app, host="0.0.0.0", port=PORT, allow_unsafe_werkzeug=True)
except KeyboardInterrupt:
pass
finally:
print("👋 已退出")
if __name__ == "__main__":
main()