重构屏幕控制引擎 + 新增 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
+544
View File
@@ -0,0 +1,544 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>padsleep 监控面板</title>
<script src="https://cdn.socket.io/4.7.5/socket.io.min.js"></script>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
background: #0f172a;
color: #e2e8f0;
min-height: 100vh;
padding: 20px;
}
.container {
max-width: 900px;
margin: 0 auto;
}
h1 {
font-size: 1.4rem;
font-weight: 500;
color: #94a3b8;
margin-bottom: 24px;
letter-spacing: 0.02em;
text-align: center;
}
h1 small {
display: block;
font-size: 0.8rem;
color: #64748b;
margin-top: 4px;
font-weight: 400;
}
/* ── 网格布局 ── */
.dashboard {
display: grid;
grid-template-columns: 280px 1fr;
gap: 16px;
margin-bottom: 16px;
}
.card {
background: #1e293b;
border-radius: 12px;
padding: 20px;
border: 1px solid #334155;
}
.card-title {
font-size: 0.75rem;
color: #64748b;
text-transform: uppercase;
letter-spacing: 0.08em;
margin-bottom: 14px;
}
/* ── GPIO 指示球 ── */
.indicator {
width: 160px;
height: 160px;
border-radius: 50%;
margin: 0 auto 16px;
transition: all 0.15s ease;
display: flex;
align-items: center;
justify-content: center;
position: relative;
}
.indicator::after {
content: "";
position: absolute;
inset: 0;
border-radius: 50%;
box-shadow: 0 0 50px currentColor;
opacity: 0.3;
transition: opacity 0.15s ease;
}
.indicator.low {
background: radial-gradient(circle at 35% 35%, #1e40af, #1e3a5f);
color: #3b82f6;
box-shadow: inset 0 -6px 24px rgba(0,0,0,0.5), 0 4px 16px rgba(59,130,246,0.12);
}
.indicator.high {
background: radial-gradient(circle at 35% 35%, #dc2626, #7f1d1d);
color: #ef4444;
box-shadow: inset 0 -6px 24px rgba(0,0,0,0.5), 0 4px 24px rgba(239,68,68,0.3);
}
.indicator .gpio-value {
font-size: 3rem;
font-weight: 700;
z-index: 1;
text-shadow: 0 2px 8px rgba(0,0,0,0.4);
font-variant-numeric: tabular-nums;
}
.indicator.low .gpio-value { color: #60a5fa; }
.indicator.high .gpio-value { color: #fca5a5; }
.gpio-label {
text-align: center;
font-size: 1rem;
font-weight: 500;
}
.gpio-label.low { color: #60a5fa; }
.gpio-label.high { color: #fca5a5; }
.gpio-pin-info {
text-align: center;
color: #64748b;
font-size: 0.8rem;
margin-top: 4px;
}
.gpio-update-time {
text-align: center;
color: #475569;
font-size: 0.75rem;
margin-top: 2px;
}
/* ── 历史波形 ── */
.history {
margin-top: 12px;
}
.history-bar {
display: flex;
gap: 2px;
height: 28px;
align-items: flex-end;
justify-content: center;
}
.history-bar .bar {
width: 6px;
border-radius: 2px 2px 0 0;
transition: background 0.1s ease, height 0.2s ease;
min-height: 2px;
}
.history-bar .bar.high { background: #ef4444; }
.history-bar .bar.low { background: #3b82f6; }
/* ── 状态项 ── */
.status-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
}
.status-item {
padding: 8px 10px;
background: #0f172a;
border-radius: 8px;
display: flex;
justify-content: space-between;
align-items: center;
}
.status-item .label {
font-size: 0.78rem;
color: #94a3b8;
}
.status-item .value {
font-size: 0.85rem;
font-weight: 600;
font-variant-numeric: tabular-nums;
}
.status-item .value.ok { color: #22c55e; }
.status-item .value.warn { color: #eab308; }
.status-item .value.err { color: #ef4444; }
.status-item .value.info { color: #60a5fa; }
.status-item .value.neutral { color: #e2e8f0; }
/* ── 事件日志 ── */
.events-card {
border: 1px solid #334155;
}
.events-scroll {
max-height: 140px;
overflow-y: auto;
font-family: "SF Mono", "Fira Code", monospace;
font-size: 0.78rem;
line-height: 1.6;
}
.events-scroll::-webkit-scrollbar { width: 4px; }
.events-scroll::-webkit-scrollbar-thumb { background: #475569; border-radius: 2px; }
.event-entry {
display: flex;
gap: 8px;
padding: 2px 0;
border-bottom: 1px solid #1e293b;
}
.event-entry .event-time {
color: #475569;
white-space: nowrap;
min-width: 70px;
}
.event-entry .event-type {
color: #60a5fa;
white-space: nowrap;
}
.event-entry .event-detail {
color: #94a3b8;
overflow: hidden;
text-overflow: ellipsis;
}
.event-entry .event-detail.high { color: #fca5a5; }
.event-entry .event-detail.low { color: #60a5fa; }
.no-data {
color: #475569;
font-size: 0.8rem;
text-align: center;
padding: 20px;
}
/* ── 连接状态 ── */
.footer-status {
margin-top: 12px;
display: flex;
justify-content: space-between;
align-items: center;
font-size: 0.78rem;
color: #64748b;
}
.footer-status .dot {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
margin-right: 6px;
}
.footer-status .dot.connected { background: #22c55e; }
.footer-status .dot.disconnected { background: #ef4444; }
.gpio-card { text-align: center; }
/* ── 响应式 ── */
@media (max-width: 680px) {
.dashboard { grid-template-columns: 1fr; }
.status-grid { grid-template-columns: 1fr; }
.indicator { width: 130px; height: 130px; }
.indicator .gpio-value { font-size: 2.4rem; }
}
</style>
</head>
<body>
<div class="container">
<h1>
📊 padsleep 监控面板
<small>padsleep_adb 守护进程 &middot; 通过 IPC 读取状态</small>
</h1>
<div class="dashboard">
<!-- 左侧:GPIO -->
<div class="card gpio-card">
<div class="card-title">GPIO 实时电平</div>
<div class="indicator low" id="gpioIndicator">
<span class="gpio-value" id="gpioValue">0</span>
</div>
<div class="gpio-label low" id="gpioLabel">LOW (0V)</div>
<div class="gpio-pin-info">BCM GPIO <strong id="gpioPin">--</strong></div>
<div class="gpio-update-time" id="gpioUpdateTime">--</div>
<div class="history">
<div class="history-bar" id="historyBar"></div>
</div>
</div>
<!-- 右侧:状态 -->
<div class="card">
<div class="card-title">守护进程状态</div>
<div class="status-grid" id="statusGrid">
<div class="status-item">
<span class="label">屏幕</span>
<span class="value neutral" id="sScreen">--</span>
</div>
<div class="status-item">
<span class="label">亮屏事件</span>
<span class="value neutral" id="sReason">--</span>
</div>
<div class="status-item">
<span class="label">在定时时段内</span>
<span class="value neutral" id="sPeriod">--</span>
</div>
<div class="status-item">
<span class="label">ADB</span>
<span class="value neutral" id="sAdb">--</span>
</div>
<div class="status-item">
<span class="label">连接类型</span>
<span class="value neutral" id="sConn">--</span>
</div>
<div class="status-item">
<span class="label">GPIO 传感器</span>
<span class="value neutral" id="sGpio">--</span>
</div>
<div class="status-item">
<span class="label">守护运行</span>
<span class="value neutral" id="sUptime">--</span>
</div>
<div class="status-item">
<span class="label">配置时间段</span>
<span class="value neutral" id="sPeriods">--</span>
</div>
</div>
</div>
</div>
<!-- 底部:事件日志 -->
<div class="card events-card">
<div class="card-title">GPIO 电平事件</div>
<div class="events-scroll" id="eventsList">
<div class="no-data">等待数据…</div>
</div>
</div>
<div class="footer-status">
<span>
<span class="dot disconnected" id="wsDot"></span>
<span id="wsStatus">⏳ 连接中…</span>
</span>
<span id="logUpdate">--</span>
</div>
</div>
<script>
const HISTORY_SIZE = 40;
const MAX_EVENTS = 50;
// ── 事件流(最近的在上面)──
let events = [];
// ── 格式化 ──
const REASON_LABELS = {
scheduled: "定时点亮",
sensor: "传感器触发",
manual: "手动控制",
external: "外部亮屏",
};
function formatUptime(sec) {
if (sec == null) return "--";
const h = Math.floor(sec / 3600);
const m = Math.floor((sec % 3600) / 60);
const s = sec % 60;
if (h > 0) return `${h}h${String(m).padStart(2, "0")}m`;
if (m > 0) return `${m}m${String(s).padStart(2, "0")}s`;
return `${s}s`;
}
// ── DOM refs ──
const indicator = document.getElementById("gpioIndicator");
const gpioValue = document.getElementById("gpioValue");
const gpioLabel = document.getElementById("gpioLabel");
const gpioUpdateTime = document.getElementById("gpioUpdateTime");
const historyBar = document.getElementById("historyBar");
const eventsList = document.getElementById("eventsList");
const wsDot = document.getElementById("wsDot");
const wsStatus = document.getElementById("wsStatus");
const gpioPin = document.getElementById("gpioPin");
// ── 状态 DOM refs ──
const statusFields = {
screen: document.getElementById("sScreen"),
reason: document.getElementById("sReason"),
period: document.getElementById("sPeriod"),
adb: document.getElementById("sAdb"),
conn: document.getElementById("sConn"),
gpio: document.getElementById("sGpio"),
uptime: document.getElementById("sUptime"),
periods: document.getElementById("sPeriods"),
};
// ── 初始化历史 ──
let history = new Array(HISTORY_SIZE).fill(0);
function initHistory() {
historyBar.innerHTML = "";
for (let i = 0; i < HISTORY_SIZE; i++) {
const bar = document.createElement("div");
bar.className = "bar low";
bar.style.height = "2px";
historyBar.appendChild(bar);
}
}
initHistory();
function updateHistory(value) {
history.push(value);
if (history.length > HISTORY_SIZE) history.shift();
const bars = historyBar.querySelectorAll(".bar");
for (let i = 0; i < bars.length; i++) {
const v = history[i];
bars[i].className = `bar ${v === 1 ? "high" : "low"}`;
bars[i].style.height = v === 1 ? "24px" : "2px";
}
}
// ── GPIO 更新 ──
function updateGPIO(data) {
const v = data.value;
if (v === 1) {
indicator.className = "indicator high";
gpioLabel.className = "gpio-label high";
gpioLabel.textContent = "HIGH (3.3V)";
} else {
indicator.className = "indicator low";
gpioLabel.className = "gpio-label low";
gpioLabel.textContent = "LOW (0V)";
}
gpioValue.textContent = v;
gpioUpdateTime.textContent = data.time || new Date().toLocaleTimeString();
updateHistory(v);
addEvent(v, data.time);
}
// ── 事件日志 ──
function addEvent(value, timeStr) {
const now = timeStr || new Date().toLocaleTimeString();
events.unshift({
time: now,
type: value === 1 ? "HIGH" : "LOW",
value: value,
});
if (events.length > MAX_EVENTS) events.pop();
renderEvents();
}
function renderEvents() {
if (events.length === 0) {
eventsList.innerHTML = '<div class="no-data">等待数据…</div>';
return;
}
let html = "";
for (const e of events) {
const cls = e.value === 1 ? "high" : "low";
html += `<div class="event-entry">`;
html += `<span class="event-time">${e.time}</span>`;
html += `<span class="event-type ${cls}">${e.type}</span>`;
html += `<span class="event-detail ${cls}">GPIO ${e.value === 1 ? "↑ 上升" : "↓ 下降"}</span>`;
html += `</div>`;
}
eventsList.innerHTML = html;
}
// ── 状态更新 ──
function updateStatus(data) {
if (!data.connected) {
for (const key of Object.keys(statusFields)) {
statusFields[key].className = "value err";
statusFields[key].textContent = "×";
}
statusFields.screen.textContent = "未连接";
return;
}
// 屏幕
const screenEl = statusFields.screen;
if (data.screen_on) {
screenEl.textContent = `亮 (${data.wakefulness || ""})`;
screenEl.className = "value ok";
} else {
screenEl.textContent = `灭 (${data.wakefulness || ""})`;
screenEl.className = "value err";
}
// 亮屏事件
const reasonEl = statusFields.reason;
if (data.active_reason) {
reasonEl.textContent = REASON_LABELS[data.active_reason] || data.active_reason;
reasonEl.className = "value info";
} else {
reasonEl.textContent = "无";
reasonEl.className = "value neutral";
}
// 在定时时段内
const periodEl = statusFields.period;
periodEl.textContent = data.within_period ? "是" : "否";
periodEl.className = data.within_period ? "value ok" : "value neutral";
// ADB
const adbEl = statusFields.adb;
adbEl.textContent = data.adb_ready ? "就绪" : "未就绪";
adbEl.className = data.adb_ready ? "value ok" : "value err";
// 连接类型
const connEl = statusFields.conn;
const connMap = { usb: "USB", wireless: "WiFi", none: "无" };
connEl.textContent = connMap[data.connection_type] || data.connection_type || "--";
connEl.className = data.connection_type !== "none" ? "value ok" : "value err";
// GPIO 传感器
const gpioEl = statusFields.gpio;
if (data.gpio_available) {
if (data.gpio_triggered) {
gpioEl.textContent = `触发中 ⏱ ${data.gpio_countdown}s`;
gpioEl.className = "value ok";
} else {
gpioEl.textContent = "等待中";
gpioEl.className = "value neutral";
}
} else {
gpioEl.textContent = "不可用";
gpioEl.className = "value err";
}
// 运行时间
statusFields.uptime.textContent = formatUptime(data.uptime);
statusFields.uptime.className = "value neutral";
// 时间段数
statusFields.periods.textContent = `${data.periods_count || 0}`;
statusFields.periods.className = "value neutral";
// GPIO 引脚号
gpioPin.textContent = data.gpio_pin || "--";
// 日志更新时间
document.getElementById("logUpdate").textContent = `更新: ${new Date().toLocaleTimeString()}`;
}
// ── SocketIO ──
const socket = io();
socket.on("connect", () => {
wsDot.className = "dot connected";
wsStatus.textContent = "🟢 已连接 (实时)";
});
socket.on("disconnect", () => {
wsDot.className = "dot disconnected";
wsStatus.textContent = "🔴 连接断开";
});
socket.on("connect_error", (err) => {
wsDot.className = "dot disconnected";
wsStatus.textContent = "⚠️ 连接失败: " + err.message;
});
socket.on("gpio_update", updateGPIO);
socket.on("status_update", updateStatus);
</script>
</body>
</html>