feat: 按钮去抖+配网界面美化+水浸离线+LED独立任务+报警全拓展(人身优先)+有人才检测

This commit is contained in:
yangzhijia
2026-07-31 15:58:09 +08:00
parent 49e3abbb34
commit 69e927c5f0
16 changed files with 1352 additions and 549 deletions

View File

@@ -7,7 +7,19 @@
"WebSearch",
"WebFetch(domain:boardor.com)",
"Read(//d//**)",
"PowerShell(Get-Content \"D:\\\\R60AFD1_数据手册_V1.2.pdf\" -Raw -Encoding Byte | Out-Null; Write-Host \"File exists, size:\" \\(\\(Get-Item \"D:\\\\R60AFD1_数据手册_V1.2.pdf\"\\).Length\\);)"
"PowerShell(Get-Content \"D:\\\\R60AFD1_数据手册_V1.2.pdf\" -Raw -Encoding Byte | Out-Null; Write-Host \"File exists, size:\" \\(\\(Get-Item \"D:\\\\R60AFD1_数据手册_V1.2.pdf\"\\).Length\\);)",
"Bash(py -m pip install openpyxl -q)",
"Read(//d/PlatformIO1/Projects/**)",
"Bash(pio pkg *)",
"Bash(git stash *)",
"Bash(git checkout *)",
"Bash(sed -i 's/#define OLED_SDA_PIN 14/#define OLED_SDA_PIN 15/' include/config.h)",
"Bash(sed -i 's/#define OLED_SCL_PIN 15/#define OLED_SCL_PIN 16/' include/config.h)",
"Bash(sed -i 's/#define BTN_PROVISION_PIN 16/#define BTN_PROVISION_PIN 14/' include/config.h)",
"Bash(sed -i 's/#define BTN_PAGE_SWITCH_PIN 10/#define BTN_PAGE_SWITCH_PIN 5/' include/config.h)",
"Bash(sed -i 's/#define BTN_LONG_PRESS_MS 3000/#define BTN_LONG_PRESS_MS 1500/' include/config.h)",
"Bash(sed -i 's/#define WATER_SENSOR_PIN 6 /#define WATER_SENSOR_PIN 45/' include/config.h)",
"Bash(sed -i 's/#define BUZZER_PIN 7 /#define BUZZER_PIN 21/' include/config.h)"
]
}
}

View File

@@ -3,459 +3,696 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<title>健康监测系统 - 配网设置</title>
<title>健康监测系统 · 设备配网</title>
<style>
:root {
--primary: #4A90D9;
--danger: #E74C3C;
--success: #27AE60;
--bg: #F5F7FA;
--card-bg: #FFFFFF;
--text: #2C3E50;
--text-light: #7F8C8D;
--border: #E1E8ED;
--radius: 12px;
--bg: #f0f4ff;
--card: #ffffff;
--text: #1e293b;
--muted: #94a3b8;
--border: #e2e8f0;
--accent: #6366f1;
--accent-hover: #4f46e5;
--accent-light: #eef2ff;
--green: #16a34a;
--red: #dc2626;
--amber: #f59e0b;
--radius: 16px;
--radius-sm: 12px;
--transition: 0.2s cubic-bezier(0.4, 0, 0.2, 1);
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", system-ui, sans-serif;
background: var(--bg);
color: var(--text);
min-height: 100vh;
padding: 16px;
min-height: 100dvh;
display: flex;
flex-direction: column;
align-items: center;
padding: 24px 16px 40px;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
/* ---- Decorative blobs ---- */
body::before,
body::after {
content: '';
position: fixed;
border-radius: 50%;
pointer-events: none;
z-index: 0;
}
body::before {
top: -180px; right: -180px;
width: 380px; height: 380px;
background: radial-gradient(circle, rgba(99,102,241,0.07) 0%, transparent 70%);
}
body::after {
bottom: -120px; left: -120px;
width: 300px; height: 300px;
background: radial-gradient(circle, rgba(34,197,94,0.05) 0%, transparent 70%);
}
.container {
max-width: 420px;
width: 100%;
max-width: 440px;
position: relative;
z-index: 1;
}
/* 头部 */
/* ---- Header ---- */
.header {
text-align: center;
padding: 24px 0;
padding: 12px 0 20px;
}
.header .icon {
font-size: 48px;
margin-bottom: 8px;
.logo {
width: 60px;
height: 60px;
margin: 0 auto 14px;
background: linear-gradient(135deg, #6366f1, #8b5cf6);
border-radius: 18px;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 10px 28px rgba(99, 102, 241, 0.28);
}
.logo svg {
width: 30px;
height: 30px;
}
.header h1 {
font-size: 22px;
font-weight: 700;
letter-spacing: -0.3px;
color: var(--text);
margin-bottom: 4px;
}
.header p {
font-size: 14px;
color: var(--text-light);
.header .sub {
font-size: 13px;
color: var(--muted);
line-height: 1.5;
}
/* 卡片 */
/* ---- Card ---- */
.card {
background: var(--card-bg);
background: var(--card);
border-radius: var(--radius);
padding: 20px;
margin-bottom: 12px;
box-shadow: 0 1px 3px rgba(0,0,0,0.08);
padding: 20px 22px;
margin-bottom: 14px;
box-shadow:
0 1px 3px rgba(0,0,0,0.03),
0 6px 18px rgba(0,0,0,0.05);
transition: box-shadow var(--transition);
}
.card-title {
font-size: 16px;
font-weight: 600;
margin-bottom: 16px;
.card-header {
display: flex;
align-items: center;
gap: 8px;
gap: 10px;
margin-bottom: 16px;
font-size: 15px;
font-weight: 700;
color: var(--text);
}
.card-title .emoji {
font-size: 20px;
.card-header .icon-circle {
width: 32px; height: 32px;
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
font-size: 16px;
flex-shrink: 0;
}
/* 表单 */
.form-group {
margin-bottom: 14px;
}
.icon-wifi { background: #eef2ff; color: #6366f1; }
.icon-mqtt { background: #ecfdf5; color: #16a34a; }
.icon-room { background: #fffbeb; color: #f59e0b; }
.icon-device{ background: #fdf2f8; color: #db2777; }
.form-group:last-child {
/* ---- Form ---- */
.form-row {
margin-bottom: 12px;
}
.form-row:last-child {
margin-bottom: 0;
}
.form-group label {
.form-row label {
display: block;
font-size: 13px;
font-size: 11.5px;
font-weight: 600;
color: var(--text-light);
margin-bottom: 4px;
text-transform: uppercase;
letter-spacing: 0.5px;
letter-spacing: 0.7px;
color: var(--accent);
margin-bottom: 5px;
}
.form-group input {
.form-row input {
width: 100%;
height: 44px;
padding: 0 14px;
border: 2px solid var(--border);
border-radius: 8px;
font-size: 15px;
color: var(--text);
background: var(--bg);
transition: border-color 0.2s, box-shadow 0.2s;
background: #f8fafc;
border: 2px solid var(--border);
border-radius: var(--radius-sm);
outline: none;
-webkit-appearance: none;
transition: all var(--transition);
}
.form-group input:focus {
border-color: var(--primary);
box-shadow: 0 0 0 3px rgba(74, 144, 217, 0.15);
background: var(--card-bg);
.form-row input:focus {
border-color: var(--accent);
background: #fff;
box-shadow: 0 0 0 4px rgba(99, 102, 241, 0.08);
}
.form-group input::placeholder {
color: #BDC3C7;
.form-row input::placeholder {
color: #cbd5e1;
}
/* 环境备注 - 高亮 */
.env-note-card {
background: linear-gradient(135deg, #FFF9E6, #FFF3CD);
border: 2px solid #FFC107;
/* inline row */
.form-inline {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
.env-note-card .card-title {
color: #856404;
/* ---- Highlight card (环境备注) ---- */
.card-highlight {
border: 2px solid #fcd34d;
background: linear-gradient(135deg, #fffdf5, #fffbea);
}
.env-note-card input {
background: #FFFFFF;
border-color: #FFC107;
font-size: 16px;
.card-highlight .card-header {
color: #92400e;
}
.card-highlight label {
color: #b45309;
}
.card-highlight input {
background: #fff;
border-color: #fcd34d;
font-weight: 500;
}
.env-note-hint {
font-size: 12px;
color: #856404;
margin-top: 6px;
padding-left: 2px;
.card-highlight input:focus {
border-color: var(--amber);
box-shadow: 0 0 0 4px rgba(245, 158, 11, 0.1);
}
/* 提交按钮 */
.btn-submit {
.card-highlight .hint {
margin-top: 6px;
font-size: 12px;
color: #92400e;
opacity: 0.8;
}
/* ---- Buttons ---- */
.btn {
width: 100%;
height: 50px;
background: var(--primary);
color: white;
border: none;
border-radius: var(--radius);
font-size: 17px;
border-radius: 14px;
font-size: 16px;
font-weight: 600;
letter-spacing: 0.5px;
cursor: pointer;
transition: background 0.2s, transform 0.1s;
margin-top: 8px;
letter-spacing: 1px;
transition: all var(--transition);
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
}
.btn-submit:active {
transform: scale(0.98);
background: #357ABD;
.btn:active {
transform: scale(0.97);
}
.btn-submit:hover {
background: #3D7EC0;
.btn-primary {
background: linear-gradient(135deg, #6366f1, #8b5cf6);
color: #fff;
box-shadow: 0 6px 20px rgba(99, 102, 241, 0.28);
margin-top: 4px;
}
/* 重置按钮 */
.btn-reset {
width: 100%;
height: 44px;
.btn-primary:active {
box-shadow: 0 3px 10px rgba(99, 102, 241, 0.25);
}
.btn-danger {
background: transparent;
color: var(--danger);
border: 2px solid var(--danger);
border-radius: var(--radius);
font-size: 15px;
color: var(--red);
border: 2px solid #fecaca;
margin-top: 10px;
height: 44px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
margin-top: 8px;
transition: background 0.2s;
}
.btn-reset:active {
background: #FDEDEC;
.btn-danger:active {
background: #fef2f2;
}
/* 状态栏 */
/* ---- Status bar ---- */
.status-bar {
text-align: center;
font-size: 12px;
color: var(--text-light);
padding: 16px 0;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 18px 0;
font-size: 12.5px;
color: var(--muted);
}
.status-bar .dot {
display: inline-block;
width: 8px;
height: 8px;
.status-dot {
width: 8px; height: 8px;
border-radius: 50%;
margin-right: 6px;
animation: pulse 2s infinite;
flex-shrink: 0;
}
.dot.connected { background: var(--success); }
.dot.disconnected { background: var(--danger); }
.status-dot.on { background: var(--green); animation: dotPulse 2s infinite; }
.status-dot.off { background: var(--red); animation: dotPulse 2s infinite; }
.status-dot.wait{ background: var(--amber); animation: dotPulse 1s infinite; }
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
@keyframes dotPulse {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.35; transform: scale(0.85); }
}
/* Toast 提示 */
.toast {
/* ---- Toast ---- */
.toast-stack {
position: fixed;
top: 20px;
left: 50%;
transform: translateX(-50%);
background: var(--success);
color: white;
padding: 12px 24px;
z-index: 9999;
display: flex;
flex-direction: column;
gap: 8px;
pointer-events: none;
}
.toast {
pointer-events: auto;
padding: 12px 20px;
border-radius: 24px;
font-size: 14px;
font-weight: 500;
z-index: 1000;
display: none;
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
color: #fff;
white-space: nowrap;
text-align: center;
box-shadow: 0 6px 20px rgba(0,0,0,0.15);
animation: toastIn 0.35s ease forwards;
transition: opacity 0.3s, transform 0.3s;
}
.toast.show {
display: block;
animation: slideDown 0.3s ease;
.toast.out {
opacity: 0;
transform: translateY(-10px);
}
@keyframes slideDown {
from { opacity: 0; transform: translateX(-50%) translateY(-10px); }
to { opacity: 1; transform: translateX(-50%) translateY(0); }
.toast-ok { background: #16a34a; }
.toast-warn { background: #f59e0b; color: #1e293b; }
.toast-error { background: #dc2626; }
@keyframes toastIn {
from { opacity: 0; transform: translateY(-12px) scale(0.95); }
to { opacity: 1; transform: translateY(0) scale(1); }
}
/* ---- WiFi scan list ---- */
.scan-btn {
font-size: 12px; font-weight: 600;
color: var(--accent); background: var(--accent-light);
border: none; padding: 6px 14px; border-radius: 20px;
cursor: pointer; transition: all var(--transition);
margin-top: 4px; margin-bottom: 8px;
}
.scan-btn:active { background: #dde2ff; }
.ssid-list {
max-height: 180px; overflow-y: auto;
border: 2px solid var(--border); border-radius: var(--radius-sm);
margin-bottom: 10px; display: none;
}
.ssid-list.open { display: block; }
.ssid-item {
padding: 11px 14px; font-size: 14px; border-bottom: 1px solid #f1f5f9;
cursor: pointer; transition: background 0.15s;
display: flex; justify-content: space-between; align-items: center;
}
.ssid-item:last-child { border-bottom: none; }
.ssid-item:hover, .ssid-item:active { background: #f8fafc; }
.ssid-item .rssi { font-size: 12px; color: var(--muted); }
/* ---- Responsive ---- */
@media (max-width: 380px) {
body { padding: 12px 12px 32px; }
.card { padding: 16px 16px; }
.header h1 { font-size: 20px; }
.logo { width: 48px; height: 48px; border-radius: 14px; }
.logo svg { width: 24px; height: 24px; }
}
</style>
</head>
<body>
<!-- Toast stack -->
<div class="toast-stack" id="toast-stack"></div>
<div class="container">
<!-- 头部 -->
<!-- ====== Header ====== -->
<div class="header">
<div class="icon">🏥</div>
<div class="logo">
<!-- Medical cross / heart icon -->
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12 5.5C12 3.02 14.02 1 16.5 1S21 3.02 21 5.5c0 4.5-6 10.5-9 13.5-3-3-9-9-9-13.5C3 3.02 5.02 1 7.5 1S12 3.02 12 5.5z"
fill="white" stroke="none"/>
<circle cx="17" cy="6" r="1.8" fill="#6366f1" opacity="0.5"/>
</svg>
</div>
<h1>健康监测系统</h1>
<p>ESP32-S3 + 毫米波雷达 · 配网设置</p>
<p class="sub">
ESP32-S3 · 60G 毫米波雷达<br>五劳无感康养监测
</p>
</div>
<!-- WiFi 设置 -->
<!-- ====== WiFi 设置 ====== -->
<div class="card">
<div class="card-title">
<span class="emoji">📶</span> WiFi 设置
<div class="card-header">
<div class="icon-circle icon-wifi">📶</div>
无线网络
</div>
<div class="form-group">
<div class="form-row">
<label>WiFi 名称 (SSID)</label>
<input type="text" id="wifi-ssid" placeholder="输入 WiFi 名称" autocomplete="off">
<input type="text" id="wifi-ssid" placeholder="选择或输入 WiFi 名称" autocomplete="off" autocorrect="off">
</div>
<div class="form-group">
<button class="scan-btn" id="scan-btn" type="button">
🔍 扫描附近 WiFi
</button>
<div class="ssid-list" id="ssid-list"></div>
<div class="form-row">
<label>WiFi 密码</label>
<input type="password" id="wifi-pass" placeholder="请输入 WiFi 密码">
</div>
</div>
<!-- MQTT 设置 -->
<!-- ====== MQTT 设置 ====== -->
<div class="card">
<div class="card-title">
<span class="emoji">☁️</span> MQTT 云平台设置
<div class="card-header">
<div class="icon-circle icon-mqtt">☁️</div>
云平台连接
</div>
<div class="form-group">
<label>MQTT 服务器地址</label>
<input type="text" id="mqtt-server" placeholder="例如: 192.168.1.100" value="192.168.1.100">
<div class="form-inline">
<div class="form-row">
<label>服务器地址</label>
<input type="text" id="mqtt-server" placeholder="IP 或域名">
</div>
<div class="form-row">
<label>端口</label>
<input type="number" id="mqtt-port" placeholder="1883">
</div>
</div>
<div class="form-group">
<label>MQTT 端口</label>
<input type="number" id="mqtt-port" placeholder="1883" value="1883">
</div>
<div class="form-group">
<label>MQTT 用户名 (可选)</label>
<input type="text" id="mqtt-user" placeholder="用户名">
</div>
<div class="form-group">
<label>MQTT 密码 (可选)</label>
<input type="password" id="mqtt-pass" placeholder="密码">
<div class="form-inline">
<div class="form-row">
<label>用户名</label>
<input type="text" id="mqtt-user" placeholder="选填" autocomplete="off">
</div>
<div class="form-row">
<label>密码</label>
<input type="password" id="mqtt-pass" placeholder="选填">
</div>
</div>
</div>
<!-- 环境备注 - 重点突出 -->
<div class="card env-note-card">
<div class="card-title">
<span class="emoji">🏠</span> 环境备注
<!-- ====== 环境备注 ====== -->
<div class="card card-highlight">
<div class="card-header">
<div class="icon-circle icon-room">🏠</div>
安装位置
</div>
<div class="form-group">
<label>设备位置 / 房间名称</label>
<input type="text" id="env-note" placeholder="例如: 主卧 / 客厅 / 老人房 / 婴儿房" value="主卧">
</div>
<div class="env-note-hint">
💡 请备注此设备安装的环境位置,方便在云平台上区分不同房间的数据
<div class="form-row">
<label>房间名称 / 环境备注</label>
<input type="text" id="env-note" placeholder="如:主卧 / 老人房 / 客厅" autocomplete="off">
</div>
<p class="hint">
💡 请备注设备安装的房间,便于在云平台区分不同区域的数据
</p>
</div>
<!-- 设备设置 -->
<!-- ====== 设备 ID ====== -->
<div class="card">
<div class="card-title">
<span class="emoji">📱</span> 设备设置
<div class="card-header">
<div class="icon-circle icon-device">📱</div>
设备标识
</div>
<div class="form-group">
<label>设备名称 / ID</label>
<input type="text" id="device-id" placeholder="例如: health_001" value="health_001">
<div class="form-row">
<label>设备编号</label>
<input type="text" id="device-id" placeholder="如:health_001" autocomplete="off">
</div>
</div>
<!-- 按钮 -->
<button class="btn-submit" onclick="submitConfig()">
💾 保存配置并连接
<!-- ====== 按钮组 ====== -->
<button class="btn btn-primary" id="btn-submit" type="button">
<span>💾</span> 保存并连接
</button>
<button class="btn-reset" onclick="resetConfig()">
🔄 恢复出厂设置
<button class="btn btn-danger" id="btn-reset" type="button">
<span>🔄</span> 恢复出厂设置
</button>
<!-- 状态 -->
<!-- ====== 状态 ====== -->
<div class="status-bar" id="status-bar">
<span class="dot disconnected"></span> 等待配置...
<span class="status-dot off"></span>
<span id="status-text">等待配置…</span>
</div>
</div>
<!-- Toast -->
<div class="toast" id="toast"></div>
</div><!-- /.container -->
<script>
// 显示 Toast
function showToast(msg, duration = 3000) {
const t = document.getElementById('toast');
t.textContent = msg;
t.classList.add('show');
setTimeout(() => t.classList.remove('show'), duration);
// ── 全局缓冲 ──────────────────────────────────
const SELF = (() => {
try {
return JSON.parse(localStorage.getItem('health_config') || '{}');
} catch (_) { return {}; }
})();
const E = id => document.getElementById(id);
// ── Toast ─────────────────────────────────────
function toast(msg, type = 'ok', duration = 2800) {
const stack = E('toast-stack');
const el = document.createElement('div');
el.className = `toast toast-${type}`;
el.textContent = msg;
stack.appendChild(el);
setTimeout(() => {
el.classList.add('out');
setTimeout(() => el.remove(), 300);
}, duration);
}
// ── 状态栏 ────────────────────────────────────
function setStatus(state) {
const dot = E('status-bar').querySelector('.status-dot');
const txt = E('status-text');
dot.className = `status-dot ${state}`;
const map = {
on: '✅ 已连接',
off: '⏳ 等待配置…',
wait: '⏳ 连接中…',
ok: '📋 配置已就绪',
};
txt.textContent = map[state] || state;
}
// ── 加载已保存配置 ────────────────────────────
function loadConfig() {
const f = (id, val) => { if (val) E(id).value = val; };
f('wifi-ssid', SELF.wifi_ssid);
f('mqtt-server', SELF.mqtt_server || '202.110.234.102');
f('mqtt-port', SELF.mqtt_port || '8000');
f('mqtt-user', SELF.mqtt_user || 'test');
f('mqtt-pass', SELF.mqtt_pass || '');
f('device-id', SELF.device_id || '10100001001');
f('env-note', SELF.env_note || '主卧');
}
// ── 持久化到 localStorage ────────────────────
function saveLocal() {
const cfg = {
wifi_ssid: E('wifi-ssid').value,
wifi_pass: E('wifi-pass').value,
mqtt_server: E('mqtt-server').value,
mqtt_port: E('mqtt-port').value,
mqtt_user: E('mqtt-user').value,
mqtt_pass: E('mqtt-pass').value,
device_id: E('device-id').value,
env_note: E('env-note').value,
};
localStorage.setItem('health_config', JSON.stringify(cfg));
}
// ── 提交 ──────────────────────────────────────
function submitConfig() {
const env = E('env-note').value.trim();
if (!env) {
toast('⚠️ 请先填写安装位置(房间名称)', 'warn');
E('env-note').focus();
return;
}
// 读取已保存的配置 (从 LocalStorage)
function loadConfig() {
try {
const saved = JSON.parse(localStorage.getItem('health_config') || '{}');
if (saved.mqtt_server) document.getElementById('mqtt-server').value = saved.mqtt_server;
if (saved.mqtt_port) document.getElementById('mqtt-port').value = saved.mqtt_port;
if (saved.mqtt_user) document.getElementById('mqtt-user').value = saved.mqtt_user;
if (saved.mqtt_pass) document.getElementById('mqtt-pass').value = saved.mqtt_pass;
if (saved.device_id) document.getElementById('device-id').value = saved.device_id;
if (saved.env_note) document.getElementById('env-note').value = saved.env_note;
if (saved.wifi_ssid) document.getElementById('wifi-ssid').value = saved.wifi_ssid;
} catch(e) { /* ignore */ }
const ssid = E('wifi-ssid').value.trim();
if (!ssid) {
toast('⚠️ 请填写 WiFi 名称', 'warn');
E('wifi-ssid').focus();
return;
}
// 保存配置到 LocalStorage
function saveConfigLocal() {
const config = {
wifi_ssid: document.getElementById('wifi-ssid').value,
wifi_pass: document.getElementById('wifi-pass').value,
mqtt_server: document.getElementById('mqtt-server').value,
mqtt_port: document.getElementById('mqtt-port').value,
mqtt_user: document.getElementById('mqtt-user').value,
mqtt_pass: document.getElementById('mqtt-pass').value,
device_id: document.getElementById('device-id').value,
env_note: document.getElementById('env-note').value,
};
localStorage.setItem('health_config', JSON.stringify(config));
}
saveLocal();
setStatus('wait');
toast('📡 正在连接…', 'ok', 2000);
// 提交配置
function submitConfig() {
const envNote = document.getElementById('env-note').value.trim();
if (!envNote) {
showToast('⚠️ 请填写环境备注 (设备安装位置)');
document.getElementById('env-note').focus();
return;
}
const params = new URLSearchParams();
params.append('s', ssid);
params.append('p', E('wifi-pass').value);
params.append('ms', E('mqtt-server').value);
params.append('mp', E('mqtt-port').value);
params.append('mu', E('mqtt-user').value);
params.append('mw', E('mqtt-pass').value);
params.append('id', E('device-id').value);
params.append('env',env);
const ssid = document.getElementById('wifi-ssid').value.trim();
if (!ssid) {
showToast('⚠️ 请填写 WiFi 名称');
document.getElementById('wifi-ssid').focus();
return;
}
// 保存到本地
saveConfigLocal();
// 构建提交参数
const params = new URLSearchParams();
params.append('s', ssid);
params.append('p', document.getElementById('wifi-pass').value);
params.append('ms', document.getElementById('mqtt-server').value);
params.append('mp', document.getElementById('mqtt-port').value);
params.append('mu', document.getElementById('mqtt-user').value);
params.append('mw', document.getElementById('mqtt-pass').value);
params.append('id', document.getElementById('device-id').value);
params.append('env', envNote);
// 更新状态
document.getElementById('status-bar').innerHTML =
'<span class="dot connected"></span> 正在保存配置...';
showToast('✅ 配置已保存! 设备正在连接...');
// 实际设备中,这里通过 WiFiManager API 提交
// 开发环境中模拟提交
fetch('/save', {
method: 'POST',
body: params
}).then(() => {
document.getElementById('status-bar').innerHTML =
'<span class="dot connected"></span> ✅ 配置成功! WiFi 已连接';
showToast('🎉 连接成功! 设备已上线');
}).catch(() => {
// 如果是离线 HTML (直接打开), 提示用户
document.getElementById('status-bar').innerHTML =
'<span class="dot connected"></span> 📋 配置已准备 (请通过 ESP32 配网页面提交)';
showToast('📋 配置参数已保存到本地浏览器');
fetch('/save', { method: 'POST', body: params })
.then(() => {
setStatus('on');
toast('🎉 设备已上线WiFi 连接成功');
})
.catch(() => {
setStatus('ok');
toast('📋 配置已保存(设备端已接收)');
});
}
}
// 重置
function resetConfig() {
if (confirm('确定恢复出厂设置吗? 所有配置将被清除!')) {
localStorage.removeItem('health_config');
document.querySelectorAll('input').forEach(el => {
if (el.type !== 'button') {
el.value = el.defaultValue || '';
}
// ── 重置 ──────────────────────────────────────
function resetConfig() {
if (!confirm('⚠️ 确定恢复出厂设置吗\n\n所有 WiFi / MQTT / 设备配置将被清除,设备将重启。')) return;
localStorage.removeItem('health_config');
['wifi-ssid','wifi-pass','mqtt-server','mqtt-port','mqtt-user','mqtt-pass','device-id','env-note'].forEach(id => {
E(id).value = '';
});
E('mqtt-server').value = '202.110.234.102';
E('mqtt-port').value = '8000';
E('mqtt-user').value = 'test';
E('device-id').value = '10100001001';
E('env-note').value = '主卧';
setStatus('off');
toast('🔄 已恢复出厂设置');
}
// ── WiFi 扫描 (模拟 - 实际由 ESP32 提供) ────
function scanWiFi() {
const btn = E('scan-btn');
const list = E('ssid-list');
btn.textContent = '⏳ 扫描中…';
btn.disabled = true;
fetch('/scan')
.then(r => r.json())
.then(nets => {
list.innerHTML = '';
nets.forEach(n => {
const div = document.createElement('div');
div.className = 'ssid-item';
const bars = n.rssi > -50 ? '▂▄▆█' : n.rssi > -70 ? '▂▄▆' : '▂▄';
div.innerHTML = `<span>${escHtml(n.ssid)}</span><span class="rssi">${bars} ${n.rssi}dBm</span>`;
div.addEventListener('click', () => {
E('wifi-ssid').value = n.ssid;
list.classList.remove('open');
});
list.appendChild(div);
});
document.getElementById('env-note').value = '主卧';
document.getElementById('mqtt-server').value = '192.168.1.100';
document.getElementById('mqtt-port').value = '1883';
document.getElementById('device-id').value = 'health_001';
showToast('🔄 已恢复出厂设置');
}
list.classList.add('open');
btn.textContent = '🔍 扫描附近 WiFi';
btn.disabled = false;
})
.catch(() => {
// 离线/ESP32 未连接时用 demo 数据
const demo = [
{ ssid: 'TP-LINK_Health', rssi: -42 },
{ ssid: 'ChinaNet-5G', rssi: -58 },
{ ssid: 'Xiaomi_Home', rssi: -63 },
{ ssid: 'ASUS_Medical_Ward', rssi: -71 },
{ ssid: 'HUAWEI-WiFi6', rssi: -77 },
];
list.innerHTML = '';
demo.forEach(n => {
const div = document.createElement('div');
div.className = 'ssid-item';
const bars = n.rssi > -50 ? '▂▄▆█' : n.rssi > -70 ? '▂▄▆' : '▂▄';
div.innerHTML = `<span>${escHtml(n.ssid)}</span><span class="rssi">${bars} ${n.rssi}dBm</span>`;
div.addEventListener('click', () => {
E('wifi-ssid').value = n.ssid;
list.classList.remove('open');
});
list.appendChild(div);
});
list.classList.add('open');
btn.textContent = '🔍 扫描附近 WiFi';
btn.disabled = false;
toast('⚠️ 离线模式,显示模拟列表', 'warn');
});
}
function escHtml(s) {
const m = { '&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;' };
return String(s).replace(/[&<>"']/g, c => m[c]);
}
// ── Init ──────────────────────────────────────
loadConfig();
E('btn-submit').addEventListener('click', submitConfig);
E('btn-reset').addEventListener('click', resetConfig);
E('scan-btn').addEventListener('click', scanWiFi);
// 点击输入框时关闭 SSID 列表
document.addEventListener('click', e => {
if (!e.target.closest('#ssid-list') && !e.target.closest('#scan-btn')) {
E('ssid-list').classList.remove('open');
}
});
// 页面加载
loadConfig();
// 每 5 秒检查设备状态 (模拟)
setInterval(() => {
const bar = document.getElementById('status-bar');
if (!bar.textContent.includes('连接成功') && !bar.textContent.includes('配置已准备')) {
const dots = ['⠋','⠙','⠹','⠸','⠼','⠴','⠦','⠧','⠇','⠏'];
const d = dots[Math.floor(Date.now()/300) % dots.length];
bar.innerHTML = `<span class="dot disconnected"></span> ${d} 等待配置...`;
}
}, 300);
// ── 空闲动画 ──────────────────────────────────
let idleTick = 0;
setInterval(() => {
const txt = E('status-text');
if (txt.textContent.includes('等待配置')) {
const frames = ['⠋','⠙','⠹','⠸','⠼','⠴','⠦','⠧','⠇',''];
idleTick = (idleTick + 1) % frames.length;
// 只在未连接时显示动画
}
}, 300);
</script>
</body>
</html>

View File

@@ -1,9 +1,12 @@
/**
* @file alarm_manager.h
* @brief 报警管理器 - 双模式 (本地判定 + 服务端指令)
* @brief 报警管理器 - 多类型分优先级报警 (本地判定 + 服务端指令)
*
* 本地模式: 离线也能用, 跌倒/水浸/心率异常/呼吸异常 本地判定触发
* 优先级: 跌倒 > 水浸 > 心率异常 > 呼吸异常 > 温湿度异常 > 传感器故障
*
* 本地模式: 离线也能用, 全部类型本地判定触发
* 服务端模式: 收到 cmd/{gateid}/alarm, alarm/restore 执行联动
* 防抖: 传感器故障需连续 N 次确认才触发, 避免误报
*/
#ifndef ALARM_MANAGER_H
@@ -12,16 +15,23 @@
#include <Arduino.h>
#include "config.h"
// 报警回调 — 通知外部 (如 MQTT 上报)
typedef void (*AlarmCallback)(uint8_t type, bool triggered, const char* desc);
class AlarmManager {
public:
// 告警类型 (本地 + 服务端共用)
enum AlarmType {
// 告警类型 (按优先级从高到低)
enum AlarmType : uint8_t {
ALARM_NONE = 0,
ALARM_FALL, // 跌倒 (本地 + 服务端)
ALARM_WATER_LEAK, // 水浸 (本地 + 服务端)
ALARM_HEART_ABNORMAL, // 心率异常 (本地)
ALARM_BREATH_ABNORMAL, // 呼吸异常 (本地)
ALARM_SERVER, // 服务端下发 (通用)
ALARM_FALL, // 跌倒 (本地 + 服务端) ★★★★★
ALARM_WATER_LEAK, // 水浸 (本地 + 服务端) ★★★★
ALARM_HEART_ABNORMAL, // 心率异常 (本地) ★★★
ALARM_BREATH_ABNORMAL, // 呼吸异常 (本地) ★★★
ALARM_TEMP_ABNORMAL, // 温度异常 (本地) ★★
ALARM_HUMI_ABNORMAL, // 湿度异常 (本地) ★★
ALARM_SENSOR_FAULT, // 传感器故障 (本地) ★
ALARM_SERVER, // 服务端下发 (通用)
ALARM_COUNT
};
AlarmManager();
@@ -34,11 +44,11 @@ public:
// 本地报警接口 (离线可用)
// ============================================================
/** 触发本地报警 */
void triggerLocal(AlarmType type);
/** 触发本地报警 (防抖由调用方处理) */
void triggerLocal(AlarmType type, const char* detail = nullptr);
/** 清除本地报警 */
void clearLocal(AlarmType type);
/** 清除所有本地报警 */
/** 清除所有本地报警 (含防抖计数器) */
void clearAllLocal();
// ============================================================
@@ -53,33 +63,58 @@ public:
void buzzerTest(uint32_t durationSec);
// ============================================================
// 状态查询 (上报用)
// 回调注册
// ============================================================
bool isBuzzerOn() const { return _buzzerOn; }
bool hasActiveAlarm() const { return _activeCount > 0; }
const char* getActiveAlarmType() const;
/** 报警状态变化回调 (触发/清除时调用, 用于MQTT上报) */
void onAlarmChange(AlarmCallback cb) { _alarmCb = cb; }
// ============================================================
// 状态查询
// ============================================================
bool isBuzzerOn() const { return _buzzerOn; }
bool hasActiveAlarm() const { return _activeCount > 0; }
uint8_t getActiveCount() const { return _activeCount; }
AlarmType getHighestAlarm() const;
const char* getAlarmName(AlarmType type) const;
const char* getActiveAlarmStr() const;
private:
// 报警优先级 (越小越紧急)
static uint8_t _priority(AlarmType t);
void _setBuzzer(bool on);
void _setLED(bool on);
void _updateBuzzer();
void _updateLED();
uint32_t _buzzerIntervalFor(AlarmType type);
// 蜂鸣器/LED 状态
bool _buzzerOn;
bool _ledOn;
uint32_t _buzzerOffTime; // 定时关闭时刻 (0=手动)
uint32_t _buzzerOffTime; // 定时关闭时刻 (0=手动/无限)
uint32_t _buzzerToggleTime; // 间歇切换时刻
bool _buzzerBeepState; // 间歇状态 (响/停)
uint8_t _beepCount; // 当前周期哔了几声
uint8_t _beepsPerCycle; // 当前报警类型每周期几声
uint32_t _beepOnMs; // 每声持续 ms
uint32_t _beepOffMs; // 每声间隔 ms
uint32_t _cyclePauseMs; // 周期间停顿 ms
// 活动报警列表 (最多 4 个同时)
AlarmType _activeAlarms[4];
// 活动报警列表 (最多 5 个同时)
static const uint8_t MAX_ACTIVE = 5;
AlarmType _activeAlarms[MAX_ACTIVE];
uint8_t _activeCount;
// 当前详情 (用于上报)
char _activeDetail[64];
// LED 闪烁
uint32_t _lastLEDToggle;
// 回调
AlarmCallback _alarmCb;
};
#endif // ALARM_MANAGER_H

View File

@@ -1,10 +1,13 @@
/**
* @file button_manager.h
* @brief 三按钮管理器 - 去抖 + 长按/短按识别
* @brief 三按钮管理器 - 双向去抖 + 长按/短按识别 + 超时自愈
*
* 按钮1 (GPIO14): 长按3秒 → 配网
* 按钮2 (GPIO15): 短按 → 解除报警
* 按钮3 (GPIO16): 短按 → 翻页
* 按钮1 (GPIO14): 长按1.5秒 → 配网
* 按钮2 (GPIO13): 短按 → 解除报警
* 按钮3 (GPIO46): 短按 → 翻页
*
* 去抖策略: 状态变化需持续稳定 BTN_DEBOUNCE_MS 才确认
* 自愈策略: 按钮按下超过 10 秒强制复位,防止卡死
*/
#ifndef BUTTON_MANAGER_H
@@ -13,6 +16,8 @@
#include <Arduino.h>
#include "config.h"
#define BTN_STUCK_TIMEOUT_MS 10000 // 按钮卡死超时 (10秒强制复位)
typedef void (*ButtonCallback)(void);
class ButtonManager {
@@ -31,17 +36,32 @@ public:
void onPageSwitch(ButtonCallback cb) { _cbPage = cb; }
private:
/**
* 按钮状态机 (基于稳定读数而非边沿)
*
* 状态转换:
* IDLE ──[raw稳定LOW×BTN_DEBOUNCE_MS]──→ PRESSED (记录pressTime)
* PRESSED ──[hold≥BTN_LONG_PRESS_MS]──→ 触发长按回调 (仅限hasLong)
* PRESSED ──[raw稳定HIGH×BTN_DEBOUNCE_MS]──→ 检查hold时长触发短按 → IDLE
* PRESSED ──[hold≥BTN_STUCK_TIMEOUT_MS]──→ 强制复位 → IDLE (防卡死)
*/
struct ButtonState {
uint8_t pin;
bool pressed; // 当前是否在按下状态
uint32_t pressTime; // 按下时刻 (ms)
bool longTriggered; // 防止长按时重复触发短按
bool stableState; // 当前确认的稳定状态 (true=按下)
bool rawState; // 上一次 raw 读值
uint32_t changeTime; // raw 状态变化时刻
uint32_t pressTime; // 稳定按下时刻 (ms)
bool longTriggered; // 长按已触发
bool shortPending; // 等待释放以判定短按
};
/** 处理单个按钮的状态机 */
/** 处理单个按钮 (基于稳定状态而非瞬时边沿) */
void _processButton(ButtonState& btn, bool hasShort, bool hasLong);
bool _readPin(uint8_t pin);
/** 强制复位单个按钮 */
void _resetButton(ButtonState& btn);
ButtonState _btnProvision;
ButtonState _btnDismiss;
ButtonState _btnPage;

View File

@@ -30,10 +30,10 @@
#define DHT11_PIN 4 // 温湿度传感器
// --- 水浸传感器 ---
#define WATER_SENSOR_PIN 6 // 数字输入, 低电平=有水
#define WATER_SENSOR_PIN 45 // 数字输入, 低电平=有水
// --- 蜂鸣器 (PWM) ---
#define BUZZER_PIN 7
#define BUZZER_PIN 21
#define BUZZER_PWM_CHANNEL 0
#define BUZZER_PWM_FREQ 2000
#define BUZZER_PWM_RESOLUTION 8
@@ -47,15 +47,15 @@
#define LED_ALARM_PIN 9 // 红色 - 报警指示
// --- OLED 显示屏 (SSD1306 128x64 I2C) ---
#define OLED_SDA_PIN 14 // I2C 数据线
#define OLED_SCL_PIN 15 // I2C 时钟线
#define OLED_SDA_PIN 15 // I2C 数据线
#define OLED_SCL_PIN 16 // I2C 时钟线
// --- 交互按钮 ---
#define BTN_PROVISION_PIN 16 // 擦除WiFi配网按钮 (长按3秒)
#define BTN_PROVISION_PIN 14 // 擦除WiFi配网按钮 (长按3秒)
#define BTN_ALARM_DISMISS_PIN 13 // 解除报警按钮
#define BTN_PAGE_SWITCH_PIN 10 // 翻页按钮
#define BTN_PAGE_SWITCH_PIN 46 // 翻页按钮
#define BTN_DEBOUNCE_MS 50 // 去抖时间
#define BTN_LONG_PRESS_MS 3000 // 长按判定
#define BTN_LONG_PRESS_MS 1500 // 长按判定
// ============================================================
// 2. 设备标识 (协议规范 §2)

View File

@@ -26,11 +26,14 @@ public:
/**
* @brief 更新显示内容 (每 5 秒调用一次即可)
* @param data 遥测数据
* @param wifiOk WiFi 是否已连接
* @param buzzerOn 蜂鸣器是否在响
* @param data 遥测数据
* @param wifiOk WiFi 是否已连接
* @param buzzerOn 蜂鸣器是否在响
* @param hasAlarm 是否有活跃报警
* @param alarmName 报警名称 (如 "跌倒"/"心率异常")
*/
void update(const TelemetryData& data, bool wifiOk, bool buzzerOn);
void update(const TelemetryData& data, bool wifiOk, bool buzzerOn,
bool hasAlarm = false, const char* alarmName = "");
/** 切换到下一页 (0→1→2→3→0 循环) */
void nextPage();
@@ -39,10 +42,11 @@ public:
uint8_t getPage() const { return _pageIndex; }
private:
void _drawPage0(const TelemetryData& data, bool wifiOk, bool buzzerOn);
void _drawPage1(const TelemetryData& data);
void _drawPage2(const TelemetryData& data);
void _drawPage3(const TelemetryData& data, bool buzzerOn, bool wifiOk);
void _drawPage0(const TelemetryData& data, bool wifiOk, bool buzzerOn, bool hasAlarm);
void _drawPage1(const TelemetryData& data, bool hasAlarm);
void _drawPage2(const TelemetryData& data, bool hasAlarm);
void _drawPage3(const TelemetryData& data, bool buzzerOn, bool wifiOk, bool hasAlarm);
void _drawAlarmBanner(const char* name);
/** 格式化秒数为 HH:MM:SS */
String _fmtUptime(uint32_t secs) const;

View File

@@ -80,6 +80,9 @@ public:
const char* errorCode, const char* errorMsg,
const char* wifiSignal = "");
/** 本地报警上报 status/{gateid}/alarm (§4.7 扩展) */
void publishLocalAlarm(uint8_t alarmType, bool triggered, const char* desc);
// ============================================================
// 回调注册 (协议规范 §5)
// ============================================================

View File

@@ -87,11 +87,16 @@ private:
bool _connectWiFi();
/**
* @brief 设置 LED 状态
* @param mode 0=慢闪(未连接) 1=快闪(配网) 2=常亮(已连接)
* @brief 设置 LED 模式 (线程安全, 无阻塞)
* @param mode 0=灭 1=慢闪(断连1s) 2=快闪(配网300ms) 3=常亮(已连接)
*/
void _setLEDMode(uint8_t mode);
/**
* @brief LED 闪烁独立任务 (FreeRTOS, 不受主循环阻塞影响)
*/
static void _ledTaskFunc(void* param);
/**
* @brief 检测按钮长按
*/
@@ -116,9 +121,9 @@ private:
uint32_t _buttonPressTime;
bool _buttonPressed;
// LED 闪烁状态
uint32_t _ledLastToggle;
bool _ledState;
// LED 独立任务 (不被 startConfigPortal 阻塞影响)
TaskHandle_t _ledTaskHandle;
volatile uint8_t _ledMode; // 0=灭 1=慢闪 2=快闪 3=常亮
// WiFiManager 自定义参数
WiFiManagerParameter* _param_mqtt_server;

View File

@@ -43,4 +43,4 @@ lib_deps =
; 上传配置
upload_speed = 921600
upload_port = COM15
upload_port = COM16

View File

@@ -1,13 +1,43 @@
/**
* @file alarm_manager.cpp
* @brief 报警管理器 - 双模式实现
* @brief 报警管理器 - 多类型分优先级实现
*
* 本地: 跌倒/水浸/心率异常/呼吸异常 → 立即蜂鸣器+LED
* 服务端: cmd/alarm, cmd/alarm/restore → 执行联动 + 回复响应
* 蜂鸣模式 (人身安全 > 环境 > 设备):
* 跌倒: 连速哔 (60ms/60ms) — 最急 ★★★★★
* 心率/呼吸: 5连急哔 (80ms/80ms) — 次急 ★★★★★
* 水浸: 3连哔 (120ms/100ms) — 中等 ★★★
* 温湿度: 2声长停 (200ms/400ms) — 温和 ★★
* 传感器: 单声长哔 (300ms/3s) — 提示 ★
*/
#include "alarm_manager.h"
// ============================================================
// 优先级定义 — 人身安全 > 环境安全 > 设备故障
// ============================================================
uint8_t AlarmManager::_priority(AlarmType t) {
switch (t) {
// ★★★★★ 人身安全 — 直接威胁生命
case ALARM_FALL: return 0;
case ALARM_HEART_ABNORMAL: return 1;
case ALARM_BREATH_ABNORMAL: return 1;
// ★★★ 环境安全 — 潜在危险
case ALARM_WATER_LEAK: return 2;
// ★★ 环境舒适 — 非紧急
case ALARM_TEMP_ABNORMAL: return 3;
case ALARM_HUMI_ABNORMAL: return 3;
// ★ 设备状态 — 仅提示
case ALARM_SENSOR_FAULT: return 4;
case ALARM_SERVER: return 5;
default: return 9;
}
}
// ============================================================
// 构造 & 析构
// ============================================================
AlarmManager::AlarmManager()
: _buzzerOn(false)
, _ledOn(false)
@@ -15,10 +45,16 @@ AlarmManager::AlarmManager()
, _buzzerToggleTime(0)
, _buzzerBeepState(false)
, _beepCount(0)
, _beepsPerCycle(3)
, _beepOnMs(150)
, _beepOffMs(100)
, _cyclePauseMs(600)
, _activeCount(0)
, _lastLEDToggle(0)
, _alarmCb(nullptr)
{
for (int i = 0; i < 4; i++) _activeAlarms[i] = ALARM_NONE;
for (uint8_t i = 0; i < MAX_ACTIVE; i++) _activeAlarms[i] = ALARM_NONE;
memset(_activeDetail, 0, sizeof(_activeDetail));
}
AlarmManager::~AlarmManager() {}
@@ -30,16 +66,12 @@ AlarmManager::~AlarmManager() {}
bool AlarmManager::begin() {
ledcSetup(BUZZER_PWM_CHANNEL, BUZZER_PWM_FREQ, BUZZER_PWM_RESOLUTION);
ledcAttachPin(BUZZER_PIN, BUZZER_PWM_CHANNEL);
ledcWrite(BUZZER_PWM_CHANNEL, 0); // 关
ledcWrite(BUZZER_PWM_CHANNEL, 0);
pinMode(LED_ALARM_PIN, OUTPUT);
digitalWrite(LED_ALARM_PIN, LOW);
_buzzerToggleTime = 0;
_buzzerBeepState = false;
_beepCount = 0;
Serial.println(F("[告警] ✅ 双模式报警 (本地+服务端)"));
Serial.println(F("[告警] ✅ 多类型分优先级报警就绪 (7种类型)"));
return true;
}
@@ -53,33 +85,129 @@ void AlarmManager::loop() {
}
// ============================================================
// 本地报警触发 (离线可用)
// 报警名称映射
// ============================================================
void AlarmManager::triggerLocal(AlarmType type) {
const char* AlarmManager::getAlarmName(AlarmType type) const {
switch (type) {
case ALARM_FALL: return "跌倒";
case ALARM_WATER_LEAK: return "水浸";
case ALARM_HEART_ABNORMAL: return "心率异常";
case ALARM_BREATH_ABNORMAL: return "呼吸异常";
case ALARM_TEMP_ABNORMAL: return "温度异常";
case ALARM_HUMI_ABNORMAL: return "湿度异常";
case ALARM_SENSOR_FAULT: return "传感器故障";
case ALARM_SERVER: return "云端指令";
default: return "未知";
}
}
// ============================================================
// 最高优先级报警
// ============================================================
AlarmManager::AlarmType AlarmManager::getHighestAlarm() const {
if (_activeCount == 0) return ALARM_NONE;
AlarmType best = ALARM_NONE;
uint8_t bestP = 99;
for (uint8_t i = 0; i < _activeCount; i++) {
uint8_t p = _priority(_activeAlarms[i]);
if (p < bestP) { bestP = p; best = _activeAlarms[i]; }
}
return best;
}
const char* AlarmManager::getActiveAlarmStr() const {
AlarmType t = getHighestAlarm();
if (t == ALARM_NONE) return "";
return getAlarmName(t);
}
// ============================================================
// 蜂鸣参数表 (按最高优先级报警驱动)
// ============================================================
uint32_t AlarmManager::_buzzerIntervalFor(AlarmType type) {
switch (type) {
case ALARM_FALL: return 200; // LED 快闪
case ALARM_WATER_LEAK: return 300;
case ALARM_HEART_ABNORMAL: return 500;
case ALARM_BREATH_ABNORMAL: return 500;
case ALARM_TEMP_ABNORMAL: return 800;
case ALARM_HUMI_ABNORMAL: return 800;
case ALARM_SENSOR_FAULT: return 1000;
default: return 500;
}
}
// ============================================================
// 更新蜂鸣参数 (根据当前最高优先级报警)
// ============================================================
static void _calcBuzzerParams(AlarmManager::AlarmType t,
uint8_t& beeps, uint32_t& onMs,
uint32_t& offMs, uint32_t& pauseMs) {
switch (t) {
// 人身安全 — 连速急哔 (最刺耳)
case AlarmManager::ALARM_FALL:
beeps = 0; onMs = 60; offMs = 60; pauseMs = 0; // 跌倒: 连速哔
break;
case AlarmManager::ALARM_HEART_ABNORMAL:
case AlarmManager::ALARM_BREATH_ABNORMAL:
beeps = 5; onMs = 80; offMs = 80; pauseMs = 400; // 心率/呼吸: 5连急哔
break;
// 环境安全 — 次急
case AlarmManager::ALARM_WATER_LEAK:
beeps = 3; onMs = 120; offMs = 100; pauseMs = 500; // 水浸: 3连哔
break;
// 环境舒适 — 温和
case AlarmManager::ALARM_TEMP_ABNORMAL:
case AlarmManager::ALARM_HUMI_ABNORMAL:
beeps = 2; onMs = 200; offMs = 400; pauseMs = 1200; // 温湿度: 2声长停
break;
// 设备状态 — 仅提示
case AlarmManager::ALARM_SENSOR_FAULT:
beeps = 1; onMs = 300; offMs = 3000; pauseMs = 0; // 传感器: 单声提醒
break;
default:
beeps = 0; onMs = 500; offMs = 500; pauseMs = 0; // 持续
break;
}
}
// ============================================================
// 本地报警触发
// ============================================================
void AlarmManager::triggerLocal(AlarmType type, const char* detail) {
// 去重
for (uint8_t i = 0; i < _activeCount; i++) {
if (_activeAlarms[i] == type) return;
}
if (_activeCount < 4) {
if (_activeCount < MAX_ACTIVE) {
_activeAlarms[_activeCount++] = type;
}
const char* name = "";
switch (type) {
case ALARM_FALL: name = "跌倒"; break;
case ALARM_WATER_LEAK: name = "水浸"; break;
case ALARM_HEART_ABNORMAL: name = "心率异常"; break;
case ALARM_BREATH_ABNORMAL: name = "呼吸异常"; break;
default: name = "未知"; break;
const char* name = getAlarmName(type);
Serial.printf("[告警] 🚨 触发: %s (活动:%d)", name, _activeCount);
if (detail && detail[0]) Serial.printf(" | %s", detail);
Serial.println();
// 更新蜂鸣参数 (按最高优先级)
AlarmType highest = getHighestAlarm();
_calcBuzzerParams(highest, _beepsPerCycle, _beepOnMs, _beepOffMs, _cyclePauseMs);
// 保存详情
if (detail) {
strncpy(_activeDetail, detail, sizeof(_activeDetail) - 1);
}
Serial.printf("[告警] 🚨 本地报警触发: %s (活动报警:%d)\n", name, _activeCount);
// 立即开启蜂鸣器和 LED
_setBuzzer(true);
_setLED(true);
// 通知外部
if (_alarmCb) _alarmCb((uint8_t)type, true, detail ? detail : name);
}
void AlarmManager::clearLocal(AlarmType type) {
@@ -94,21 +222,29 @@ void AlarmManager::clearLocal(AlarmType type) {
}
}
Serial.printf("[告警] 本地报警清除, 剩余活动:%d\n", _activeCount);
const char* name = getAlarmName(type);
Serial.printf("[告警] ✅ 清除: %s (剩余:%d)\n", name, _activeCount);
// 没有活动报警时关闭
if (_activeCount == 0) {
_setBuzzer(false);
_setLED(false);
_activeDetail[0] = '\0';
} else {
// 降级到次高优先级报警的蜂鸣参数
AlarmType highest = getHighestAlarm();
_calcBuzzerParams(highest, _beepsPerCycle, _beepOnMs, _beepOffMs, _cyclePauseMs);
}
if (_alarmCb) _alarmCb((uint8_t)type, false, name);
}
void AlarmManager::clearAllLocal() {
_activeCount = 0;
for (int i = 0; i < 4; i++) _activeAlarms[i] = ALARM_NONE;
for (uint8_t i = 0; i < MAX_ACTIVE; i++) _activeAlarms[i] = ALARM_NONE;
_activeDetail[0] = '\0';
_setBuzzer(false);
_setLED(false);
Serial.println(F("[告警] 清除所有本地报警"));
Serial.println(F("[告警] 清除所有报警"));
}
// ============================================================
@@ -116,16 +252,15 @@ void AlarmManager::clearAllLocal() {
// ============================================================
void AlarmManager::executeAlarm(const AlarmCommand& cmd) {
Serial.printf("[告警] ☁️ 服务端告警: %s (等级=%s, 蜂鸣=%d)\n",
Serial.printf("[告警] ☁️ 服务端: %s (等级=%s 蜂鸣=%d)\n",
cmd.alarmType, cmd.alarmLevel, cmd.buzzerOn);
// 映射 alarmType 到本地类型
AlarmType localType = ALARM_SERVER;
if (strcmp(cmd.alarmType, "fall") == 0) localType = ALARM_FALL;
else if (strcmp(cmd.alarmType, "water_leak") == 0) localType = ALARM_WATER_LEAK;
if (cmd.buzzerOn) {
triggerLocal(localType);
triggerLocal(localType, cmd.alarmDesc);
if (cmd.buzzerDuration > 0) {
_buzzerOffTime = millis() + (uint32_t)cmd.buzzerDuration * 1000;
} else {
@@ -138,12 +273,8 @@ void AlarmManager::executeAlarm(const AlarmCommand& cmd) {
}
}
// ============================================================
// 服务端恢复指令 (§5.2)
// ============================================================
void AlarmManager::executeRestore(const AlarmCommand& cmd) {
Serial.printf("[告警] ☁️ 服务端恢复: %s\n", cmd.alarmType);
Serial.printf("[告警] ☁️ 恢复: %s\n", cmd.alarmType);
AlarmType localType = ALARM_SERVER;
if (strcmp(cmd.alarmType, "fall") == 0) localType = ALARM_FALL;
@@ -151,12 +282,8 @@ void AlarmManager::executeRestore(const AlarmCommand& cmd) {
clearLocal(localType);
if (!cmd.buzzerOn) {
_setBuzzer(false);
}
if (!cmd.nightLight) {
_setLED(false);
}
if (!cmd.buzzerOn) _setBuzzer(false);
if (!cmd.nightLight) _setLED(false);
}
// ============================================================
@@ -164,27 +291,11 @@ void AlarmManager::executeRestore(const AlarmCommand& cmd) {
// ============================================================
void AlarmManager::buzzerTest(uint32_t durationSec) {
Serial.printf("[告警] 🔔 蜂鸣器测试 %d\n", durationSec);
Serial.printf("[告警] 🔔 蜂鸣器测试 %ds\n", durationSec);
_setBuzzer(true);
_buzzerOffTime = millis() + durationSec * 1000;
}
// ============================================================
// 状态查询
// ============================================================
const char* AlarmManager::getActiveAlarmType() const {
if (_activeCount == 0) return "";
switch (_activeAlarms[0]) {
case ALARM_FALL: return "fall";
case ALARM_WATER_LEAK: return "water_leak";
case ALARM_HEART_ABNORMAL: return "heart_abnormal";
case ALARM_BREATH_ABNORMAL: return "breath_abnormal";
case ALARM_SERVER: return "server";
default: return "";
}
}
// ============================================================
// 内部控制
// ============================================================
@@ -195,7 +306,7 @@ void AlarmManager::_setBuzzer(bool on) {
_buzzerToggleTime = 0;
_beepCount = 0;
if (on) {
_buzzerOffTime = 0; // 本地报警=无限哔哔
_buzzerOffTime = 0;
} else {
ledcWrite(BUZZER_PWM_CHANNEL, 0);
_buzzerOffTime = 0;
@@ -207,57 +318,72 @@ void AlarmManager::_setLED(bool on) {
if (!on) digitalWrite(LED_ALARM_PIN, LOW);
}
// ============================================================
// 蜂鸣器更新 (优先级驱动模式)
// ============================================================
void AlarmManager::_updateBuzzer() {
// 定时关闭检查
if (_buzzerOn && _buzzerOffTime > 0 && millis() >= _buzzerOffTime) {
if (!_buzzerOn) return;
// 定时关闭
if (_buzzerOffTime > 0 && millis() >= _buzzerOffTime) {
_buzzerOn = false;
ledcWrite(BUZZER_PWM_CHANNEL, 0);
Serial.println(F("[告警] ⏰ 蜂鸣器定时关闭"));
return;
}
// 本地报警: 三连哔 停 — 循环 (哔哔哔...哔哔哔...)
if (_buzzerOn && _buzzerOffTime == 0) {
uint32_t now = millis();
// 周期: 3声哔 (每声150ms响+100ms停) + 600ms大停顿
uint32_t now = millis();
// 云端定时: 持续响
if (_buzzerOffTime > 0) {
ledcWrite(BUZZER_PWM_CHANNEL, 128);
return;
}
// 本地报警: 按优先级模式 — 哔声/停顿交替
if (_beepsPerCycle == 0) {
// 连速哔 (跌倒): 简单翻转
if (now - _buzzerToggleTime >= (_buzzerBeepState ? _beepOnMs : _beepOffMs)) {
_buzzerToggleTime = now;
_buzzerBeepState = !_buzzerBeepState;
ledcWrite(BUZZER_PWM_CHANNEL, _buzzerBeepState ? 128 : 0);
}
} else {
// 多声一组: N声哔 + 长停顿
if (!_buzzerBeepState) {
// 停顿中,检查是否该下一声
uint32_t pause = (_beepCount >= 3) ? 600 : 100;
// 停顿中
uint32_t pause = (_beepCount >= _beepsPerCycle) ? _cyclePauseMs : _beepOffMs;
if (now - _buzzerToggleTime >= pause) {
_buzzerToggleTime = now;
if (_beepCount >= 3) _beepCount = 0; // 重置周期
if (_beepCount >= _beepsPerCycle) _beepCount = 0;
_buzzerBeepState = true;
ledcWrite(BUZZER_PWM_CHANNEL, 128);
_beepCount++;
}
} else {
// 在响中150ms后停下
if (now - _buzzerToggleTime >= 150) {
// 响声中
if (now - _buzzerToggleTime >= _beepOnMs) {
_buzzerToggleTime = now;
_buzzerBeepState = false;
ledcWrite(BUZZER_PWM_CHANNEL, 0);
}
}
}
// 云端定时: 持续响
else if (_buzzerOn && _buzzerOffTime > 0) {
ledcWrite(BUZZER_PWM_CHANNEL, 128);
}
}
// ============================================================
// LED 更新 (按最高优先级决定闪烁频率)
// ============================================================
void AlarmManager::_updateLED() {
if (!_ledOn) {
digitalWrite(LED_ALARM_PIN, LOW);
return;
}
// 根据告警类型调整闪烁频率
uint32_t interval = 500;
for (uint8_t i = 0; i < _activeCount; i++) {
if (_activeAlarms[i] == ALARM_FALL) {
interval = 200; // 跌倒快闪
break;
}
}
AlarmType highest = getHighestAlarm();
uint32_t interval = _buzzerIntervalFor(highest);
if (millis() - _lastLEDToggle >= interval) {
_lastLEDToggle = millis();

View File

@@ -1,6 +1,12 @@
/**
* @file button_manager.cpp
* @brief 三按钮管理器实现 - 去抖 + 长按/短按
* @brief 三按钮管理器实现 - 双向去抖 + 长按/短按 + 超时自愈
*
* 核心改进 (v2):
* 1. 按下沿和释放沿都经过 BTN_DEBOUNCE_MS 稳定确认
* 2. 长按期间持续检测 (不再等到松手才触发)
* 3. 按下超过 BTN_STUCK_TIMEOUT_MS 自动复位, 防止状态机卡死
* 4. 用 stableState 替代瞬时 raw 驱动状态, 消除噪声误触发
*/
#include "button_manager.h"
@@ -14,9 +20,19 @@ ButtonManager::ButtonManager()
, _cbDismiss(nullptr)
, _cbPage(nullptr)
{
_btnProvision = { BTN_PROVISION_PIN, false, 0, false };
_btnDismiss = { BTN_ALARM_DISMISS_PIN, false, 0, false };
_btnPage = { BTN_PAGE_SWITCH_PIN, false, 0, false };
auto initBtn = [](ButtonState& s, uint8_t pin) {
s.pin = pin;
s.stableState = false;
s.rawState = false;
s.changeTime = 0;
s.pressTime = 0;
s.longTriggered = false;
s.shortPending = false;
};
initBtn(_btnProvision, BTN_PROVISION_PIN);
initBtn(_btnDismiss, BTN_ALARM_DISMISS_PIN);
initBtn(_btnPage, BTN_PAGE_SWITCH_PIN);
}
// ============================================================
@@ -28,9 +44,19 @@ void ButtonManager::begin() {
pinMode(BTN_ALARM_DISMISS_PIN, INPUT_PULLUP);
pinMode(BTN_PAGE_SWITCH_PIN, INPUT_PULLUP);
Serial.println(F("[按钮] ✅ 三按钮初始化完成"));
// 读取初始状态, 避免启动时的虚假边沿
_btnProvision.rawState = _readPin(BTN_PROVISION_PIN);
_btnProvision.stableState = _btnProvision.rawState;
_btnDismiss.rawState = _readPin(BTN_ALARM_DISMISS_PIN);
_btnDismiss.stableState = _btnDismiss.rawState;
_btnPage.rawState = _readPin(BTN_PAGE_SWITCH_PIN);
_btnPage.stableState = _btnPage.rawState;
Serial.println(F("[按钮] ✅ 三按钮初始化完成 (双向去抖+超时自愈)"));
Serial.printf(" 配网: GPIO%d | 解除报警: GPIO%d | 翻页: GPIO%d\n",
BTN_PROVISION_PIN, BTN_ALARM_DISMISS_PIN, BTN_PAGE_SWITCH_PIN);
Serial.printf(" 去抖:%dms | 长按:%dms | 卡死超时:%dms\n",
BTN_DEBOUNCE_MS, BTN_LONG_PRESS_MS, BTN_STUCK_TIMEOUT_MS);
}
// ============================================================
@@ -49,40 +75,91 @@ void ButtonManager::loop() {
}
// ============================================================
// 单按钮状态机
// 单按钮状态机 (基于稳定状态, 非瞬时边沿)
// ============================================================
void ButtonManager::_processButton(ButtonState& btn, bool hasShort, bool hasLong) {
bool raw = _readPin(btn.pin); // LOW = 按下
uint32_t now = millis();
bool raw = _readPin(btn.pin); // true = 按下 (LOW)
if (raw && !btn.pressed) {
// 按下沿
btn.pressed = true;
btn.pressTime = millis();
btn.longTriggered = false;
// ── 第1步: raw 状态变化时启动去抖计时 ──
if (raw != btn.rawState) {
btn.rawState = raw;
btn.changeTime = now;
// 不立即改变 stableState — 等稳定确认
}
else if (!raw && btn.pressed) {
// 松开沿
btn.pressed = false;
uint32_t holdTime = millis() - btn.pressTime;
if (holdTime < BTN_DEBOUNCE_MS) {
return; // 抖动脉冲,忽略
// ── 第2步: 去抖确认 — raw 保持稳定超过 BTN_DEBOUNCE_MS 才生效 ──
bool debounced = (now - btn.changeTime >= BTN_DEBOUNCE_MS);
bool stateChanged = (btn.rawState != btn.stableState);
if (debounced && stateChanged) {
// 状态正式切换
btn.stableState = btn.rawState;
if (btn.stableState) {
// ── 确认按下 ──
btn.pressTime = now;
btn.longTriggered = false;
btn.shortPending = hasShort; // 只有支持短按的按钮才标记待判定
} else {
// ── 确认释放 ──
uint32_t holdTime = now - btn.pressTime;
// 短按判定: 按下时长在去抖和长按阈值之间
if (btn.shortPending && holdTime >= BTN_DEBOUNCE_MS
&& holdTime < BTN_LONG_PRESS_MS && !btn.longTriggered) {
// 根据引脚分发短按回调
if (btn.pin == BTN_ALARM_DISMISS_PIN && _cbDismiss) {
_cbDismiss();
}
if (btn.pin == BTN_PAGE_SWITCH_PIN && _cbPage) {
_cbPage();
}
}
btn.shortPending = false;
btn.longTriggered = false;
}
}
if (hasLong && holdTime >= BTN_LONG_PRESS_MS && !btn.longTriggered) {
// 长按触发
// ── 第3步: 长按检测 (在按下稳定期间持续检查) ──
if (hasLong && btn.stableState && !btn.longTriggered) {
uint32_t holdTime = now - btn.pressTime;
if (holdTime >= BTN_LONG_PRESS_MS) {
btn.longTriggered = true;
if (_cbProvision) _cbProvision();
}
else if (hasShort && holdTime >= BTN_DEBOUNCE_MS && holdTime < BTN_LONG_PRESS_MS) {
// 短按触发
if (btn.pin == BTN_ALARM_DISMISS_PIN && _cbDismiss) _cbDismiss();
if (btn.pin == BTN_PAGE_SWITCH_PIN && _cbPage) _cbPage();
btn.shortPending = false; // 长按已触发, 不再判定短按
if (_cbProvision) {
_cbProvision();
}
}
}
// ── 第4步: 卡死自愈 — 按下超时强制复位 ──
if (btn.stableState && (now - btn.pressTime >= BTN_STUCK_TIMEOUT_MS)) {
Serial.printf("[按钮] ⚠️ GPIO%d 按下超时 %lums, 强制复位\n",
btn.pin, now - btn.pressTime);
_resetButton(btn);
}
}
// ============================================================
// 强制复位
// ============================================================
void ButtonManager::_resetButton(ButtonState& btn) {
btn.stableState = false;
btn.rawState = _readPin(btn.pin); // 重新同步真实硬件状态
btn.changeTime = 0;
btn.pressTime = 0;
btn.longTriggered = false;
btn.shortPending = false;
}
// ============================================================
// 读引脚 (INPUT_PULLUP → LOW = 按下)
// ============================================================
bool ButtonManager::_readPin(uint8_t pin) {
return digitalRead(pin) == LOW; // INPUT_PULLUP: LOW = 按下
return digitalRead(pin) == LOW;
}

View File

@@ -71,37 +71,63 @@ void DisplayManager::nextPage() {
// 主更新入口
// ============================================================
void DisplayManager::update(const TelemetryData& data, bool wifiOk, bool buzzerOn) {
void DisplayManager::update(const TelemetryData& data, bool wifiOk, bool buzzerOn,
bool hasAlarm, const char* alarmName) {
if (!_initialized) return;
// 每 5 秒刷新一次 (防止 OLED 烧屏,也避免 SPI 占用过多)
if (millis() - _lastDrawTime < 5000) return;
// 有报警时每秒刷新 (闪烁提示),否则 5 秒
uint32_t interval = hasAlarm ? 1000 : 5000;
if (millis() - _lastDrawTime < interval) return;
_lastDrawTime = millis();
_u8g2.clearBuffer();
// 活跃报警 — 所有页面顶部闪烁提示
if (hasAlarm && alarmName && alarmName[0]) {
_drawAlarmBanner(alarmName);
}
switch (_pageIndex) {
case 0: _drawPage0(data, wifiOk, buzzerOn); break;
case 1: _drawPage1(data); break;
case 2: _drawPage2(data); break;
case 3: _drawPage3(data, buzzerOn, wifiOk); break;
default: _drawPage0(data, wifiOk, buzzerOn); break;
case 0: _drawPage0(data, wifiOk, buzzerOn, hasAlarm); break;
case 1: _drawPage1(data, hasAlarm); break;
case 2: _drawPage2(data, hasAlarm); break;
case 3: _drawPage3(data, buzzerOn, wifiOk, hasAlarm); break;
default: _drawPage0(data, wifiOk, buzzerOn, hasAlarm); break;
}
_u8g2.sendBuffer();
}
// ============================================================
// 报警横幅 (顶部闪烁)
// ============================================================
void DisplayManager::_drawAlarmBanner(const char* name) {
// 每秒翻转,实现闪烁效果
bool blink = ((millis() / 1000) % 2 == 0);
_u8g2.setFont(u8g2_font_wqy12_t_gb2312);
if (blink) {
_u8g2.drawUTF8(2, 12, "🚨");
_u8g2.drawUTF8(26, 12, name);
} else {
_u8g2.drawUTF8(2, 12, " ");
}
_u8g2.drawHLine(0, 15, 128);
}
// ============================================================
// Page 0: 环境数据
// ============================================================
void DisplayManager::_drawPage0(const TelemetryData& data, bool wifiOk, bool buzzerOn) {
// 标题栏
_u8g2.setFont(u8g2_font_wqy12_t_gb2312);
_u8g2.drawUTF8(2, 12, "🏥 健康监测 V1.0");
// 分隔线
_u8g2.drawHLine(0, 15, 128);
void DisplayManager::_drawPage0(const TelemetryData& data, bool wifiOk,
bool buzzerOn, bool hasAlarm) {
// 标题栏 (报警横幅已占顶部时跳过)
if (!hasAlarm) {
_u8g2.setFont(u8g2_font_wqy12_t_gb2312);
_u8g2.drawUTF8(2, 12, "🏥 健康监测 V1.0");
_u8g2.drawHLine(0, 15, 128);
}
// 温湿度
_u8g2.setFont(u8g2_font_wqy12_t_gb2312);
@@ -144,11 +170,13 @@ void DisplayManager::_drawPage0(const TelemetryData& data, bool wifiOk, bool buz
// Page 1: 生命体征
// ============================================================
void DisplayManager::_drawPage1(const TelemetryData& data) {
void DisplayManager::_drawPage1(const TelemetryData& data, bool hasAlarm) {
// 标题栏
_u8g2.setFont(u8g2_font_wqy12_t_gb2312);
_u8g2.drawUTF8(2, 12, "🫀 生命体征");
_u8g2.drawHLine(0, 15, 128);
if (!hasAlarm) {
_u8g2.setFont(u8g2_font_wqy12_t_gb2312);
_u8g2.drawUTF8(2, 12, "🫀 生命体征");
_u8g2.drawHLine(0, 15, 128);
}
// 心率
_u8g2.drawUTF8(4, 32, "心率:");
@@ -182,10 +210,12 @@ void DisplayManager::_drawPage1(const TelemetryData& data) {
// Page 2: 人体状态
// ============================================================
void DisplayManager::_drawPage2(const TelemetryData& data) {
_u8g2.setFont(u8g2_font_wqy12_t_gb2312);
_u8g2.drawUTF8(2, 12, "人体状态");
_u8g2.drawHLine(0, 15, 128);
void DisplayManager::_drawPage2(const TelemetryData& data, bool hasAlarm) {
if (!hasAlarm) {
_u8g2.setFont(u8g2_font_wqy12_t_gb2312);
_u8g2.drawUTF8(2, 12, "人体状态");
_u8g2.drawHLine(0, 15, 128);
}
_u8g2.drawUTF8(4, 30, "有人:");
_u8g2.drawUTF8(60, 30, data.presence ? "V" : "X");
@@ -210,13 +240,20 @@ void DisplayManager::_drawPage2(const TelemetryData& data) {
// Page 3: 安全信息
// ============================================================
void DisplayManager::_drawPage3(const TelemetryData& data, bool buzzerOn, bool wifiOk) {
_u8g2.setFont(u8g2_font_wqy12_t_gb2312);
_u8g2.drawUTF8(2, 12, "安全信息");
_u8g2.drawHLine(0, 15, 128);
void DisplayManager::_drawPage3(const TelemetryData& data, bool buzzerOn,
bool wifiOk, bool hasAlarm) {
if (!hasAlarm) {
_u8g2.setFont(u8g2_font_wqy12_t_gb2312);
_u8g2.drawUTF8(2, 12, "安全信息");
_u8g2.drawHLine(0, 15, 128);
}
_u8g2.drawUTF8(4, 30, "水浸:");
_u8g2.drawUTF8(60, 30, data.rainDO ? "正常" : "!!报警");
if (!data.rainValid) {
_u8g2.drawUTF8(60, 30, "离线");
} else {
_u8g2.drawUTF8(60, 30, data.rainDO ? "正常" : "!!报警");
}
_u8g2.drawUTF8(4, 44, "蜂鸣器:");
_u8g2.drawUTF8(60, 44, buzzerOn ? "" : "");

View File

@@ -36,10 +36,20 @@ uint32_t g_sequence = 1; // 上报序号
uint32_t g_bootTime = 0; // 启动时刻
// 本地报警跟踪 (防重复触发)
bool g_localFallActive = false;
bool g_localWaterActive = false;
bool g_localHeartAbnActive = false;
bool g_localBreathAbnActive = false;
bool g_localFallActive = false;
bool g_localWaterActive = false;
bool g_localHeartAbnActive = false;
bool g_localBreathAbnActive = false;
bool g_localTempAbnActive = false;
bool g_localHumiAbnActive = false;
bool g_localRadarFaultActive = false;
bool g_localDhtFaultActive = false;
// 报警防抖计数器 (连续N次确认才触发)
uint8_t g_tempAbnCnt = 0;
uint8_t g_humiAbnCnt = 0;
uint8_t g_radarTimeoutCnt = 0;
#define ALARM_HYSTERESIS 3 // 连续3次确认才触发
// ============================================================
// 时间同步
@@ -226,53 +236,136 @@ TelemetryData buildTelemetry(const RadarRawData& radar) {
}
// ============================================================
// 本地报警检查 (离线可用)
// 本地报警回调 → MQTT 上报 (连接时)
// ============================================================
void onLocalAlarmChange(uint8_t type, bool triggered, const char* desc) {
if (g_mqtt.isConnected()) {
g_mqtt.publishLocalAlarm(type, triggered, desc);
}
}
// ============================================================
// 本地报警检查 (离线可用) — 人身安全 > 环境 > 设备
// ============================================================
void checkLocalAlarms(const RadarRawData& radar) {
uint32_t now = millis();
// ─── 前置条件: 有人才检测人体生命体征 ───
bool humanPresent = radar.presence && radar.radarValid;
// --- 跌倒检测 (本地紧急报警, 立即触发) ---
if (radar.fall && radar.radarValid && !g_localFallActive) {
// ─── ★★★★★ 人身安全: 跌倒 (立即触发, 无防抖) ───
if (radar.fall && humanPresent && !g_localFallActive) {
g_localFallActive = true;
g_alarm.triggerLocal(AlarmManager::ALARM_FALL);
g_alarm.triggerLocal(AlarmManager::ALARM_FALL, "雷达检测到跌倒");
} else if (!radar.fall && g_localFallActive) {
g_localFallActive = false;
g_alarm.clearLocal(AlarmManager::ALARM_FALL);
}
// --- 水浸检测 ---
// ─── ★★★★★ 人身安全: 心率异常 (有人才检测) ───
if (humanPresent && radar.vitalValid && radar.heartRate > 0) {
bool heartAbn = (radar.heartRate < HEART_RATE_MIN || radar.heartRate > HEART_RATE_MAX);
if (heartAbn && !g_localHeartAbnActive) {
g_localHeartAbnActive = true;
char buf[32];
snprintf(buf, sizeof(buf), "心率异常: %d bpm", radar.heartRate);
g_alarm.triggerLocal(AlarmManager::ALARM_HEART_ABNORMAL, buf);
} else if (!heartAbn && g_localHeartAbnActive) {
g_localHeartAbnActive = false;
g_alarm.clearLocal(AlarmManager::ALARM_HEART_ABNORMAL);
}
} else if (!humanPresent && g_localHeartAbnActive) {
// 人走了 — 清除之前的报警
g_localHeartAbnActive = false;
g_alarm.clearLocal(AlarmManager::ALARM_HEART_ABNORMAL);
}
// ─── ★★★★★ 人身安全: 呼吸异常 (有人才检测) ───
if (humanPresent && radar.vitalValid && radar.breathRate > 0) {
bool breathAbn = (radar.breathRate < BREATH_RATE_MIN || radar.breathRate > BREATH_RATE_MAX);
if (breathAbn && !g_localBreathAbnActive) {
g_localBreathAbnActive = true;
char buf[32];
snprintf(buf, sizeof(buf), "呼吸异常: %d 次/分", radar.breathRate);
g_alarm.triggerLocal(AlarmManager::ALARM_BREATH_ABNORMAL, buf);
} else if (!breathAbn && g_localBreathAbnActive) {
g_localBreathAbnActive = false;
g_alarm.clearLocal(AlarmManager::ALARM_BREATH_ABNORMAL);
}
} else if (!humanPresent && g_localBreathAbnActive) {
g_localBreathAbnActive = false;
g_alarm.clearLocal(AlarmManager::ALARM_BREATH_ABNORMAL);
}
// ─── ★★★ 环境安全: 水浸 ───
bool waterLeak = !g_sensor.getRainDO() && g_sensor.isRainValid();
if (waterLeak && !g_localWaterActive) {
g_localWaterActive = true;
g_alarm.triggerLocal(AlarmManager::ALARM_WATER_LEAK);
g_alarm.triggerLocal(AlarmManager::ALARM_WATER_LEAK, "水浸传感器触发");
} else if (!waterLeak && g_localWaterActive) {
g_localWaterActive = false;
g_alarm.clearLocal(AlarmManager::ALARM_WATER_LEAK);
}
// --- 心率异常 (离线本地判定, 阈值预留后续调整) ---
if (radar.vitalValid && radar.heartRate > 0) {
bool heartAbn = (radar.heartRate < HEART_RATE_MIN || radar.heartRate > HEART_RATE_MAX);
if (heartAbn && !g_localHeartAbnActive) {
g_localHeartAbnActive = true;
g_alarm.triggerLocal(AlarmManager::ALARM_HEART_ABNORMAL);
} else if (!heartAbn && g_localHeartAbnActive) {
g_localHeartAbnActive = false;
g_alarm.clearLocal(AlarmManager::ALARM_HEART_ABNORMAL);
// ─── ★★ 环境舒适: 温度异常 (防抖3次) ───
if (g_sensor.isDhtValid()) {
float temp = g_sensor.getTemp();
bool tempAbn = (temp < TEMP_LOW || temp > TEMP_HIGH);
if (tempAbn) {
if (++g_tempAbnCnt >= ALARM_HYSTERESIS && !g_localTempAbnActive) {
g_localTempAbnActive = true;
char buf[32];
snprintf(buf, sizeof(buf), "温度%.1f°C 超出范围", temp);
g_alarm.triggerLocal(AlarmManager::ALARM_TEMP_ABNORMAL, buf);
}
} else {
g_tempAbnCnt = 0;
if (g_localTempAbnActive) {
g_localTempAbnActive = false;
g_alarm.clearLocal(AlarmManager::ALARM_TEMP_ABNORMAL);
}
}
// ─── ★★ 环境舒适: 湿度异常 (防抖3次) ───
float humi = g_sensor.getHumi();
bool humiAbn = (humi < HUMI_LOW || humi > HUMI_HIGH);
if (humiAbn) {
if (++g_humiAbnCnt >= ALARM_HYSTERESIS && !g_localHumiAbnActive) {
g_localHumiAbnActive = true;
char buf[32];
snprintf(buf, sizeof(buf), "湿度%.1f%% 超出范围", humi);
g_alarm.triggerLocal(AlarmManager::ALARM_HUMI_ABNORMAL, buf);
}
} else {
g_humiAbnCnt = 0;
if (g_localHumiAbnActive) {
g_localHumiAbnActive = false;
g_alarm.clearLocal(AlarmManager::ALARM_HUMI_ABNORMAL);
}
}
}
// --- 呼吸异常 ---
if (radar.vitalValid && radar.breathRate > 0) {
bool breathAbn = (radar.breathRate < BREATH_RATE_MIN || radar.breathRate > BREATH_RATE_MAX);
if (breathAbn && !g_localBreathAbnActive) {
g_localBreathAbnActive = true;
g_alarm.triggerLocal(AlarmManager::ALARM_BREATH_ABNORMAL);
} else if (!breathAbn && g_localBreathAbnActive) {
g_localBreathAbnActive = false;
g_alarm.clearLocal(AlarmManager::ALARM_BREATH_ABNORMAL);
// ─── ★ 设备:雷达超时 (连续5秒无数据) ───
if (g_radar.isTimeout()) {
if (++g_radarTimeoutCnt >= 5 && !g_localRadarFaultActive) {
g_localRadarFaultActive = true;
g_alarm.triggerLocal(AlarmManager::ALARM_SENSOR_FAULT, "雷达超时无数据");
}
} else {
g_radarTimeoutCnt = 0;
if (g_localRadarFaultActive) {
g_localRadarFaultActive = false;
g_alarm.clearLocal(AlarmManager::ALARM_SENSOR_FAULT);
}
}
// ─── ★ 设备DHT 持续故障 (已由 SensorManager 内部计数) ───
if (g_sensor.isDhtFault() && !g_localDhtFaultActive && millis() > 60000) {
g_localDhtFaultActive = true;
g_alarm.triggerLocal(AlarmManager::ALARM_SENSOR_FAULT, "DHT11温湿度传感器故障");
} else if (!g_sensor.isDhtFault() && g_localDhtFaultActive) {
g_localDhtFaultActive = false;
g_alarm.clearLocal(AlarmManager::ALARM_SENSOR_FAULT);
}
}
@@ -370,6 +463,9 @@ void setup() {
g_mqtt.onControlCommand(handleControlCommand);
g_mqtt.onProvisionCommand(handleProvisionCommand);
// 注册本地报警回调 → MQTT 上报
g_alarm.onAlarmChange(onLocalAlarmChange);
// 连接 MQTT
if (g_wifi.isConnected()) {
g_mqtt.connect();
@@ -427,15 +523,15 @@ void loop() {
RadarRawData radar = g_radar.getData();
checkLocalAlarms(radar);
// --- 传感器故障检测 (每30秒检查一次避免刷屏) ---
static uint32_t lastFaultCheck = 0;
if (now - lastFaultCheck > 30000) {
lastFaultCheck = now;
if (!g_sensor.isDhtValid() && millis() > 60000) {
Serial.println(F("[系统] ⚠️ DHT11 未检测到 (不影响运行)"));
// --- 传感器故障日志 (报警系统统一管理触发, 此处仅记录) ---
static uint32_t lastFaultLog = 0;
if (now - lastFaultLog > 60000) {
lastFaultLog = now;
if (!g_sensor.isDhtValid() && millis() > 60000 && !g_localDhtFaultActive) {
Serial.println(F("[系统] ⚠️ DHT11 读数异常"));
}
if (g_radar.isTimeout()) {
Serial.println(F("[系统] ⚠️ 雷达超时无数据 (不影响运行)"));
if (g_radar.isTimeout() && !g_localRadarFaultActive) {
Serial.println(F("[系统] ⚠️ 雷达超时无数据"));
}
}
@@ -452,7 +548,8 @@ void loop() {
// 调试输出 + OLED 显示
printDebug(tele);
g_display.update(tele, wifiOk, g_alarm.isBuzzerOn());
g_display.update(tele, wifiOk, g_alarm.isBuzzerOn(),
g_alarm.hasActiveAlarm(), g_alarm.getActiveAlarmStr());
}
// --- 7. MQTT 配网状态 (WiFiManager 触发时) ---

View File

@@ -371,6 +371,51 @@ void MQTTManager::publishProvisionResponse(const char* cmdId, bool success,
_publish(MQTT_TOPIC_RESPONSE_PROVISION, payload.c_str(), STATUS_QOS);
}
// ============================================================
// 上行: 本地报警上报 (§4.7 扩展)
// 设备本地判定报警后立即上报,格式兼容服务端告警协议
// ============================================================
void MQTTManager::publishLocalAlarm(uint8_t alarmType, bool triggered, const char* desc) {
if (!isConnected()) return;
struct tm timeinfo;
char timeStr[24];
if (getLocalTime(&timeinfo)) {
strftime(timeStr, sizeof(timeStr), "%Y-%m-%d %H:%M:%S", &timeinfo);
} else {
snprintf(timeStr, sizeof(timeStr), "2026-01-01 00:00:00");
}
// 类型映射
const char* typeStr = "unknown";
switch (alarmType) {
case 1: typeStr = "fall"; break; // ALARM_FALL
case 2: typeStr = "water_leak"; break; // ALARM_WATER_LEAK
case 3: typeStr = "heart_abnormal"; break; // ALARM_HEART_ABNORMAL
case 4: typeStr = "breath_abnormal"; break; // ALARM_BREATH_ABNORMAL
case 5: typeStr = "temp_abnormal"; break; // ALARM_TEMP_ABNORMAL
case 6: typeStr = "humi_abnormal"; break; // ALARM_HUMI_ABNORMAL
case 7: typeStr = "sensor_fault"; break; // ALARM_SENSOR_FAULT
default: typeStr = "unknown"; break;
}
JsonDocument doc;
doc["gateid"] = _gateid;
doc["type"] = triggered ? "alarm" : "alarm_restore";
doc["time"] = timeStr;
doc["alarmType"] = typeStr;
doc["source"] = "local"; // 标识本地判定
doc["desc"] = desc ? desc : "";
String payload;
serializeJson(doc, payload);
// 通过 status 通道上报本地报警
String topic = _buildTopic("status/%s/alarm");
_mqtt.publish(topic.c_str(), payload.c_str());
}
// ============================================================
// 回调注册
// ============================================================

View File

@@ -39,7 +39,7 @@ bool SensorManager::begin() {
pinMode(WATER_SENSOR_PIN, INPUT_PULLUP);
_lastRainRaw = (digitalRead(WATER_SENSOR_PIN) == HIGH); // HIGH=正常
_rainDO = _lastRainRaw;
_rainValid = true;
_rainValid = false; // 默认离线,直到检测到真正的状态变化才设为有效
// 首次读取 (DHT11 可能失败)
read();

View File

@@ -17,8 +17,8 @@ WiFiMgmt::WiFiMgmt()
, _lastCheckTime(0)
, _buttonPressTime(0)
, _buttonPressed(false)
, _ledLastToggle(0)
, _ledState(false)
, _ledTaskHandle(nullptr)
, _ledMode(0)
, _param_mqtt_server(nullptr)
, _param_mqtt_port(nullptr)
, _param_mqtt_user(nullptr)
@@ -48,6 +48,9 @@ bool WiFiMgmt::begin() {
pinMode(LED_WIFI_PIN, OUTPUT);
digitalWrite(LED_WIFI_PIN, LOW);
// 启动 LED 独立任务 (避免 startConfigPortal 阻塞影响闪烁)
xTaskCreate(_ledTaskFunc, "WiFiLed", 1024, nullptr, 1, &_ledTaskHandle);
// 初始化 LittleFS
if (!LittleFS.begin(true)) {
Serial.println(F("[WiFi] ❌ LittleFS 挂载失败!"));
@@ -71,11 +74,91 @@ bool WiFiMgmt::begin() {
_wm.setDebugOutput(false); // 关闭调试输出 (避免干扰)
_wm.setConfigPortalTimeout(WIFI_CONFIG_TIMEOUT);
_wm.setConnectTimeout(WIFI_CONNECT_TIMEOUT);
_wm.setTitle("🏥 五劳无感康养监测 - 设备配网"); // 配网页面的中文标题
_wm.setTitle("健康监测系统 - 设备配网");
_wm.setCustomHeadElement(
"<style>"
"body { font-family: 'Microsoft YaHei', 'PingFang SC', sans-serif; }"
".msg .info { font-size: 14px; }"
/* ===== 字体与全局 ===== */
"*{margin:0;padding:0;box-sizing:border-box}"
"body{"
" font-family:-apple-system,BlinkMacSystemFont,'Segoe UI','PingFang SC','Microsoft YaHei',sans-serif;"
" background:linear-gradient(160deg, #f0f5ff 0%, #e8f0fe 40%, #f3f0ff 100%);"
" min-height:100vh;display:flex;flex-direction:column;align-items:center;"
" padding:20px 16px 40px;color:#1e293b"
"}"
"body::before{"
" content:'';position:fixed;top:-200px;right:-200px;"
" width:400px;height:400px;border-radius:50%;"
" background:radial-gradient(circle, rgba(99,102,241,0.08) 0%, transparent 70%);"
" pointer-events:none;z-index:0"
"}"
"body::after{"
" content:'';position:fixed;bottom:-100px;left:-100px;"
" width:300px;height:300px;border-radius:50%;"
" background:radial-gradient(circle, rgba(34,197,94,0.06) 0%, transparent 70%);"
" pointer-events:none;z-index:0"
"}"
/* ===== 头部卡片 ===== */
".msg{"
" width:100%;max-width:440px;position:relative;z-index:1;"
" background:#fff;border-radius:20px;padding:32px 24px 24px;"
" margin-bottom:16px;text-align:center;"
" box-shadow:0 1px 3px rgba(0,0,0,0.04),0 8px 24px rgba(0,0,0,0.06)"
"}"
".msg::before{"
" content:'';display:block;width:56px;height:56px;"
" margin:0 auto 16px;"
" background:linear-gradient(135deg,#6366f1,#8b5cf6);border-radius:16px;"
" box-shadow:0 8px 24px rgba(99,102,241,0.25)"
"}"
".msg h1,.msg h2{font-size:20px;font-weight:700;color:#1e293b;margin-bottom:6px}"
".msg p,.msg .info{font-size:13px;color:#94a3b8;line-height:1.5}"
/* 隐藏 WiFiManager 默认的 h1 重复标题 */
".msg h1{font-size:0;margin:0;padding:0}"
".msg h1::after{content:'健康监测系统';font-size:22px;font-weight:700;color:#1e293b}"
".msg .info{font-size:13px!important}"
/* ===== 表单卡片 ===== */
"form{"
" width:100%;max-width:440px;position:relative;z-index:1;"
" background:#fff;border-radius:20px;padding:24px;"
" box-shadow:0 1px 3px rgba(0,0,0,0.04),0 8px 24px rgba(0,0,0,0.06)"
"}"
"label{"
" display:block;font-size:11px;font-weight:600;"
" text-transform:uppercase;letter-spacing:0.8px;"
" color:#6366f1;margin:16px 0 6px"
"}"
"label:first-of-type{margin-top:0}"
"input[type=text],input[type=password],input[type=number]{"
" width:100%;height:46px;padding:0 14px;"
" border:2px solid #e2e8f0;border-radius:12px;"
" font-size:15px;color:#1e293b;background:#f8fafc;"
" outline:none;transition:all 0.2s;-webkit-appearance:none"
"}"
"input:focus{"
" border-color:#6366f1;background:#fff;"
" box-shadow:0 0 0 4px rgba(99,102,241,0.1)"
"}"
"input::placeholder{color:#cbd5e1}"
/* ===== 提交按钮 ===== */
"button[type=submit]{"
" width:100%;height:50px;margin-top:24px;"
" background:linear-gradient(135deg,#6366f1,#8b5cf6);"
" color:#fff;border:none;border-radius:14px;"
" font-size:16px;font-weight:600;letter-spacing:1px;"
" cursor:pointer;transition:all 0.2s;"
" box-shadow:0 4px 16px rgba(99,102,241,0.3)"
"}"
"button:active{transform:scale(0.97);box-shadow:0 2px 8px rgba(99,102,241,0.3)}"
/* ===== 页脚 ===== */
".msg:last-of-type{"
" background:transparent;box-shadow:none;padding:16px;margin-top:4px"
"}"
".msg:last-of-type::before{display:none}"
".msg:last-of-type p,.msg:last-of-type .info{font-size:12px;color:#94a3b8}"
/* ===== 成功/错误状态页 ===== */
".msg .success{color:#16a34a}"
".msg .error{color:#dc2626}"
"a{color:#6366f1;text-decoration:none;font-weight:500}"
"</style>"
);
WiFi.setAutoReconnect(true); // ESP32 自动重连
@@ -120,12 +203,12 @@ bool WiFiMgmt::begin() {
if (!_connected) {
// 如果自动连接失败, 启动配网页面
Serial.println(F("[WiFi] 自动连接失败, 启动配网模式..."));
_setLEDMode(1); // 快闪 = 配网模式
_setLEDMode(2); // 快闪 = 配网模式
_connected = _connectWiFi(); // 内部会启动配网页面
}
if (_connected) {
_setLEDMode(2); // 常亮 = 已连接
_setLEDMode(3); // 常亮 = 已连接
}
_lastCheckTime = millis();
@@ -150,25 +233,16 @@ bool WiFiMgmt::loop() {
if (WiFi.status() != WL_CONNECTED) {
_connected = false;
_setLEDMode(0); // 慢闪 = 未连接
_setLEDMode(1); // 慢闪 = 未连接
Serial.println(F("[WiFi] ⚠️ WiFi 断开, 尝试重连..."));
_connectWiFi();
} else {
_connected = true;
_setLEDMode(2); // 常亮
}
}
// LED 闪烁处理
if (!_connected) {
uint32_t interval = (_wm.getConfigPortalActive()) ? 300 : 1000;
if (millis() - _ledLastToggle > interval) {
_ledLastToggle = millis();
_ledState = !_ledState;
digitalWrite(LED_WIFI_PIN, _ledState ? HIGH : LOW);
_setLEDMode(3); // 常亮 = 已连接
}
}
// LED 由独立 FreeRTOS 任务处理 (不受 startConfigPortal 阻塞影响)
return _connected;
}
@@ -213,11 +287,11 @@ bool WiFiMgmt::_connectWiFi() {
void WiFiMgmt::startConfigPortal() {
Serial.println(F("[WiFi] 🔧 手动启动配网页面..."));
_setLEDMode(1);
_setLEDMode(2); // 快闪 = 配网模式
_wm.startConfigPortal(WIFI_AP_SSID, WIFI_AP_PASSWORD);
_connected = (WiFi.status() == WL_CONNECTED);
if (_connected) {
_setLEDMode(2);
_setLEDMode(3); // 常亮 = 已连接
}
}
@@ -393,20 +467,51 @@ const char* WiFiMgmt::getEnvNote() const {
}
// ============================================================
// LED 模式控制
// LED 模式控制 (线程安全, 仅写模式变量,由独立任务执行)
// ============================================================
void WiFiMgmt::_setLEDMode(uint8_t mode) {
// mode 由 loop() 中的闪烁逻辑处理
// 0=慢闪 1=快闪 2=常亮
switch (mode) {
case 0:
case 1:
// 闪烁由 loop 处理
break;
case 2:
digitalWrite(LED_WIFI_PIN, HIGH);
_ledState = true;
break;
_ledMode = mode; // uint8_t 赋值在 ESP32 上是原子的
}
// ============================================================
// LED 闪烁独立任务 (FreeRTOS)
// 独立于主循环startConfigPortal 阻塞期间也能正常闪烁
// ============================================================
void WiFiMgmt::_ledTaskFunc(void* param) {
const TickType_t fastMs = pdMS_TO_TICKS(300); // 快闪 = 配网模式
const TickType_t slowMs = pdMS_TO_TICKS(1000); // 慢闪 = 未连接
while (true) {
if (!_instance) {
vTaskDelay(slowMs);
continue;
}
uint8_t mode = _instance->_ledMode;
switch (mode) {
case 0: // 灭
digitalWrite(LED_WIFI_PIN, LOW);
vTaskDelay(slowMs);
break;
case 1: // 慢闪 — WiFi 断连
digitalWrite(LED_WIFI_PIN, HIGH);
vTaskDelay(slowMs);
digitalWrite(LED_WIFI_PIN, LOW);
vTaskDelay(slowMs);
break;
case 2: // 快闪 — 配网模式
digitalWrite(LED_WIFI_PIN, HIGH);
vTaskDelay(fastMs);
digitalWrite(LED_WIFI_PIN, LOW);
vTaskDelay(fastMs);
break;
case 3: // 常亮 — 已连接
digitalWrite(LED_WIFI_PIN, HIGH);
vTaskDelay(slowMs);
break;
}
}
}