重构屏幕控制引擎 + 新增 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:
co-authored by
Claude Fable 5
parent
9940db451f
commit
e6fbe550bb
@@ -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 守护进程 · 通过 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>
|
||||
Reference in New Issue
Block a user