Files
kushidouandClaude Fable 5 e6fbe550bb 重构屏幕控制引擎 + 新增 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>
2026-07-20 00:15:07 +08:00

545 lines
19 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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>