- 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>
545 lines
19 KiB
HTML
545 lines
19 KiB
HTML
<!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>
|