feat: init
This commit is contained in:
13
.claude/settings.local.json
Normal file
13
.claude/settings.local.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(\"D:/PlatformIO/penv/Scripts/platformio.exe\" run *)",
|
||||
"WebFetch(domain:www.micradar.cn)",
|
||||
"WebFetch(domain:m.elecfans.com)",
|
||||
"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\\);)"
|
||||
]
|
||||
}
|
||||
}
|
||||
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
.pio
|
||||
.vscode/.browse.c_cpp.db*
|
||||
.vscode/c_cpp_properties.json
|
||||
.vscode/launch.json
|
||||
.vscode/ipch
|
||||
10
.vscode/extensions.json
vendored
Normal file
10
.vscode/extensions.json
vendored
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
// See http://go.microsoft.com/fwlink/?LinkId=827846
|
||||
// for the documentation about the extensions.json format
|
||||
"recommendations": [
|
||||
"platformio.platformio-ide"
|
||||
],
|
||||
"unwantedRecommendations": [
|
||||
"ms-vscode.cpptools-extension-pack"
|
||||
]
|
||||
}
|
||||
461
data/index.html
Normal file
461
data/index.html
Normal file
@@ -0,0 +1,461 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
|
||||
<title>健康监测系统 - 配网设置</title>
|
||||
<style>
|
||||
:root {
|
||||
--primary: #4A90D9;
|
||||
--danger: #E74C3C;
|
||||
--success: #27AE60;
|
||||
--bg: #F5F7FA;
|
||||
--card-bg: #FFFFFF;
|
||||
--text: #2C3E50;
|
||||
--text-light: #7F8C8D;
|
||||
--border: #E1E8ED;
|
||||
--radius: 12px;
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
min-height: 100vh;
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 420px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* 头部 */
|
||||
.header {
|
||||
text-align: center;
|
||||
padding: 24px 0;
|
||||
}
|
||||
|
||||
.header .icon {
|
||||
font-size: 48px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.header h1 {
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
color: var(--text);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.header p {
|
||||
font-size: 14px;
|
||||
color: var(--text-light);
|
||||
}
|
||||
|
||||
/* 卡片 */
|
||||
.card {
|
||||
background: var(--card-bg);
|
||||
border-radius: var(--radius);
|
||||
padding: 20px;
|
||||
margin-bottom: 12px;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.08);
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.card-title .emoji {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
/* 表单 */
|
||||
.form-group {
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.form-group:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-light);
|
||||
margin-bottom: 4px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.form-group 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;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.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-group input::placeholder {
|
||||
color: #BDC3C7;
|
||||
}
|
||||
|
||||
/* 环境备注 - 高亮 */
|
||||
.env-note-card {
|
||||
background: linear-gradient(135deg, #FFF9E6, #FFF3CD);
|
||||
border: 2px solid #FFC107;
|
||||
}
|
||||
|
||||
.env-note-card .card-title {
|
||||
color: #856404;
|
||||
}
|
||||
|
||||
.env-note-card input {
|
||||
background: #FFFFFF;
|
||||
border-color: #FFC107;
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.env-note-hint {
|
||||
font-size: 12px;
|
||||
color: #856404;
|
||||
margin-top: 6px;
|
||||
padding-left: 2px;
|
||||
}
|
||||
|
||||
/* 提交按钮 */
|
||||
.btn-submit {
|
||||
width: 100%;
|
||||
height: 50px;
|
||||
background: var(--primary);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: var(--radius);
|
||||
font-size: 17px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s, transform 0.1s;
|
||||
margin-top: 8px;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.btn-submit:active {
|
||||
transform: scale(0.98);
|
||||
background: #357ABD;
|
||||
}
|
||||
|
||||
.btn-submit:hover {
|
||||
background: #3D7EC0;
|
||||
}
|
||||
|
||||
/* 重置按钮 */
|
||||
.btn-reset {
|
||||
width: 100%;
|
||||
height: 44px;
|
||||
background: transparent;
|
||||
color: var(--danger);
|
||||
border: 2px solid var(--danger);
|
||||
border-radius: var(--radius);
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
margin-top: 8px;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.btn-reset:active {
|
||||
background: #FDEDEC;
|
||||
}
|
||||
|
||||
/* 状态栏 */
|
||||
.status-bar {
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
color: var(--text-light);
|
||||
padding: 16px 0;
|
||||
}
|
||||
|
||||
.status-bar .dot {
|
||||
display: inline-block;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
margin-right: 6px;
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
|
||||
.dot.connected { background: var(--success); }
|
||||
.dot.disconnected { background: var(--danger); }
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.4; }
|
||||
}
|
||||
|
||||
/* Toast 提示 */
|
||||
.toast {
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: var(--success);
|
||||
color: white;
|
||||
padding: 12px 24px;
|
||||
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);
|
||||
}
|
||||
|
||||
.toast.show {
|
||||
display: block;
|
||||
animation: slideDown 0.3s ease;
|
||||
}
|
||||
|
||||
@keyframes slideDown {
|
||||
from { opacity: 0; transform: translateX(-50%) translateY(-10px); }
|
||||
to { opacity: 1; transform: translateX(-50%) translateY(0); }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<!-- 头部 -->
|
||||
<div class="header">
|
||||
<div class="icon">🏥</div>
|
||||
<h1>健康监测系统</h1>
|
||||
<p>ESP32-S3 + 毫米波雷达 · 配网设置</p>
|
||||
</div>
|
||||
|
||||
<!-- WiFi 设置 -->
|
||||
<div class="card">
|
||||
<div class="card-title">
|
||||
<span class="emoji">📶</span> WiFi 设置
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>WiFi 名称 (SSID)</label>
|
||||
<input type="text" id="wifi-ssid" placeholder="请输入 WiFi 名称" autocomplete="off">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>WiFi 密码</label>
|
||||
<input type="password" id="wifi-pass" placeholder="请输入 WiFi 密码">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- MQTT 设置 -->
|
||||
<div class="card">
|
||||
<div class="card-title">
|
||||
<span class="emoji">☁️</span> MQTT 云平台设置
|
||||
</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>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<!-- 环境备注 - 重点突出 -->
|
||||
<div class="card env-note-card">
|
||||
<div class="card-title">
|
||||
<span class="emoji">🏠</span> 环境备注
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>设备位置 / 房间名称</label>
|
||||
<input type="text" id="env-note" placeholder="例如: 主卧 / 客厅 / 老人房 / 婴儿房" value="主卧">
|
||||
</div>
|
||||
<div class="env-note-hint">
|
||||
💡 请备注此设备安装的环境位置,方便在云平台上区分不同房间的数据
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 设备设置 -->
|
||||
<div class="card">
|
||||
<div class="card-title">
|
||||
<span class="emoji">📱</span> 设备设置
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>设备名称 / ID</label>
|
||||
<input type="text" id="device-id" placeholder="例如: health_001" value="health_001">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 按钮 -->
|
||||
<button class="btn-submit" onclick="submitConfig()">
|
||||
💾 保存配置并连接
|
||||
</button>
|
||||
<button class="btn-reset" onclick="resetConfig()">
|
||||
🔄 恢复出厂设置
|
||||
</button>
|
||||
|
||||
<!-- 状态 -->
|
||||
<div class="status-bar" id="status-bar">
|
||||
<span class="dot disconnected"></span> 等待配置...
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Toast -->
|
||||
<div class="toast" id="toast"></div>
|
||||
|
||||
<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);
|
||||
}
|
||||
|
||||
// 读取已保存的配置 (从 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 */ }
|
||||
}
|
||||
|
||||
// 保存配置到 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));
|
||||
}
|
||||
|
||||
// 提交配置
|
||||
function submitConfig() {
|
||||
const envNote = document.getElementById('env-note').value.trim();
|
||||
if (!envNote) {
|
||||
showToast('⚠️ 请填写环境备注 (设备安装位置)');
|
||||
document.getElementById('env-note').focus();
|
||||
return;
|
||||
}
|
||||
|
||||
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('📋 配置参数已保存到本地浏览器');
|
||||
});
|
||||
}
|
||||
|
||||
// 重置
|
||||
function resetConfig() {
|
||||
if (confirm('确定要恢复出厂设置吗? 所有配置将被清除!')) {
|
||||
localStorage.removeItem('health_config');
|
||||
document.querySelectorAll('input').forEach(el => {
|
||||
if (el.type !== 'button') {
|
||||
el.value = el.defaultValue || '';
|
||||
}
|
||||
});
|
||||
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('🔄 已恢复出厂设置');
|
||||
}
|
||||
}
|
||||
|
||||
// 页面加载
|
||||
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);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
85
include/alarm_manager.h
Normal file
85
include/alarm_manager.h
Normal file
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* @file alarm_manager.h
|
||||
* @brief 报警管理器 - 双模式 (本地判定 + 服务端指令)
|
||||
*
|
||||
* 本地模式: 离线也能用, 跌倒/水浸/心率异常/呼吸异常 本地判定触发
|
||||
* 服务端模式: 收到 cmd/{gateid}/alarm, alarm/restore 执行联动
|
||||
*/
|
||||
|
||||
#ifndef ALARM_MANAGER_H
|
||||
#define ALARM_MANAGER_H
|
||||
|
||||
#include <Arduino.h>
|
||||
#include "config.h"
|
||||
|
||||
class AlarmManager {
|
||||
public:
|
||||
// 告警类型 (本地 + 服务端共用)
|
||||
enum AlarmType {
|
||||
ALARM_NONE = 0,
|
||||
ALARM_FALL, // 跌倒 (本地 + 服务端)
|
||||
ALARM_WATER_LEAK, // 水浸 (本地 + 服务端)
|
||||
ALARM_HEART_ABNORMAL, // 心率异常 (本地)
|
||||
ALARM_BREATH_ABNORMAL, // 呼吸异常 (本地)
|
||||
ALARM_SERVER, // 服务端下发 (通用)
|
||||
};
|
||||
|
||||
AlarmManager();
|
||||
~AlarmManager();
|
||||
|
||||
bool begin();
|
||||
void loop();
|
||||
|
||||
// ============================================================
|
||||
// 本地报警接口 (离线可用)
|
||||
// ============================================================
|
||||
|
||||
/** 触发本地报警 */
|
||||
void triggerLocal(AlarmType type);
|
||||
/** 清除本地报警 */
|
||||
void clearLocal(AlarmType type);
|
||||
/** 清除所有本地报警 */
|
||||
void clearAllLocal();
|
||||
|
||||
// ============================================================
|
||||
// 服务端指令接口 (协议规范 §5.1, §5.2)
|
||||
// ============================================================
|
||||
|
||||
/** 执行服务端告警指令 */
|
||||
void executeAlarm(const AlarmCommand& cmd);
|
||||
/** 执行服务端恢复指令 */
|
||||
void executeRestore(const AlarmCommand& cmd);
|
||||
/** 蜂鸣器测试 */
|
||||
void buzzerTest(uint32_t durationSec);
|
||||
|
||||
// ============================================================
|
||||
// 状态查询 (上报用)
|
||||
// ============================================================
|
||||
|
||||
bool isBuzzerOn() const { return _buzzerOn; }
|
||||
bool hasActiveAlarm() const { return _activeCount > 0; }
|
||||
const char* getActiveAlarmType() const;
|
||||
|
||||
private:
|
||||
void _setBuzzer(bool on);
|
||||
void _setLED(bool on);
|
||||
void _updateBuzzer();
|
||||
void _updateLED();
|
||||
|
||||
// 蜂鸣器/LED 状态
|
||||
bool _buzzerOn;
|
||||
bool _ledOn;
|
||||
uint32_t _buzzerOffTime; // 定时关闭时刻 (0=手动关)
|
||||
uint32_t _buzzerToggleTime; // 间歇切换时刻
|
||||
bool _buzzerBeepState; // 间歇状态 (响/停)
|
||||
uint8_t _beepCount; // 当前周期哔了几声
|
||||
|
||||
// 活动报警列表 (最多 4 个同时)
|
||||
AlarmType _activeAlarms[4];
|
||||
uint8_t _activeCount;
|
||||
|
||||
// LED 闪烁
|
||||
uint32_t _lastLEDToggle;
|
||||
};
|
||||
|
||||
#endif // ALARM_MANAGER_H
|
||||
54
include/button_manager.h
Normal file
54
include/button_manager.h
Normal file
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* @file button_manager.h
|
||||
* @brief 三按钮管理器 - 去抖 + 长按/短按识别
|
||||
*
|
||||
* 按钮1 (GPIO14): 长按3秒 → 配网
|
||||
* 按钮2 (GPIO15): 短按 → 解除报警
|
||||
* 按钮3 (GPIO16): 短按 → 翻页
|
||||
*/
|
||||
|
||||
#ifndef BUTTON_MANAGER_H
|
||||
#define BUTTON_MANAGER_H
|
||||
|
||||
#include <Arduino.h>
|
||||
#include "config.h"
|
||||
|
||||
typedef void (*ButtonCallback)(void);
|
||||
|
||||
class ButtonManager {
|
||||
public:
|
||||
ButtonManager();
|
||||
|
||||
/** 初始化三个按钮引脚 */
|
||||
void begin();
|
||||
|
||||
/** 主循环轮询 (每 10ms 调用) */
|
||||
void loop();
|
||||
|
||||
/** 注册回调 */
|
||||
void onProvisionLongPress(ButtonCallback cb) { _cbProvision = cb; }
|
||||
void onAlarmDismiss(ButtonCallback cb) { _cbDismiss = cb; }
|
||||
void onPageSwitch(ButtonCallback cb) { _cbPage = cb; }
|
||||
|
||||
private:
|
||||
struct ButtonState {
|
||||
uint8_t pin;
|
||||
bool pressed; // 当前是否在按下状态
|
||||
uint32_t pressTime; // 按下时刻 (ms)
|
||||
bool longTriggered; // 防止长按时重复触发短按
|
||||
};
|
||||
|
||||
/** 处理单个按钮的状态机 */
|
||||
void _processButton(ButtonState& btn, bool hasShort, bool hasLong);
|
||||
bool _readPin(uint8_t pin);
|
||||
|
||||
ButtonState _btnProvision;
|
||||
ButtonState _btnDismiss;
|
||||
ButtonState _btnPage;
|
||||
|
||||
ButtonCallback _cbProvision;
|
||||
ButtonCallback _cbDismiss;
|
||||
ButtonCallback _cbPage;
|
||||
};
|
||||
|
||||
#endif // BUTTON_MANAGER_H
|
||||
246
include/config.h
Normal file
246
include/config.h
Normal file
@@ -0,0 +1,246 @@
|
||||
/**
|
||||
* @file config.h
|
||||
* @brief 全局配置文件 - 五劳无感康养监测系统
|
||||
*
|
||||
* 协议版本: V0.08 (2026-07-17)
|
||||
* 包含: 硬件引脚定义、WiFi/MQTT 默认参数、
|
||||
* 雷达协议常量、告警阈值、任务配置等
|
||||
*/
|
||||
|
||||
#ifndef CONFIG_H
|
||||
#define CONFIG_H
|
||||
|
||||
#include <Arduino.h>
|
||||
|
||||
// ============================================================
|
||||
// 1. 硬件引脚定义 (ESP32-S3)
|
||||
// ============================================================
|
||||
|
||||
// --- R60ABD1 毫米波雷达 ---
|
||||
#define RADAR_UART_PORT Serial2
|
||||
#define RADAR_RX_PIN 17
|
||||
#define RADAR_TX_PIN 1 // UART 发送 (从18挪到1)
|
||||
#define RADAR_BAUD_RATE 115200
|
||||
|
||||
// --- 雷达备用 GPIO ---
|
||||
#define RADAR_GPIO1_PIN 2 // 活跃/静止指示 (从10挪到2)
|
||||
#define RADAR_GPIO2_PIN 11 // 有人/无人指示
|
||||
|
||||
// --- DHT11 温湿度传感器 ---
|
||||
#define DHT11_PIN 4 // 温湿度传感器
|
||||
|
||||
// --- 水浸传感器 ---
|
||||
#define WATER_SENSOR_PIN 6 // 数字输入, 低电平=有水
|
||||
|
||||
// --- 蜂鸣器 (PWM) ---
|
||||
#define BUZZER_PIN 7
|
||||
#define BUZZER_PWM_CHANNEL 0
|
||||
#define BUZZER_PWM_FREQ 2000
|
||||
#define BUZZER_PWM_RESOLUTION 8
|
||||
|
||||
// --- 配网按钮 ---
|
||||
#define CONFIG_BUTTON_PIN 0 // 长按 3 秒复位 WiFi
|
||||
#define BUTTON_LONG_PRESS_MS 3000
|
||||
|
||||
// --- 状态指示灯 ---
|
||||
#define LED_WIFI_PIN 8 // 蓝色 - WiFi 状态
|
||||
#define LED_ALARM_PIN 9 // 红色 - 报警指示
|
||||
|
||||
// --- OLED 显示屏 (SSD1306 128x64 I2C) ---
|
||||
#define OLED_SDA_PIN 14 // I2C 数据线
|
||||
#define OLED_SCL_PIN 15 // I2C 时钟线
|
||||
|
||||
// --- 交互按钮 ---
|
||||
#define BTN_PROVISION_PIN 16 // 擦除WiFi配网按钮 (长按3秒)
|
||||
#define BTN_ALARM_DISMISS_PIN 13 // 解除报警按钮
|
||||
#define BTN_PAGE_SWITCH_PIN 10 // 翻页按钮
|
||||
#define BTN_DEBOUNCE_MS 50 // 去抖时间
|
||||
#define BTN_LONG_PRESS_MS 3000 // 长按判定
|
||||
|
||||
// ============================================================
|
||||
// 2. 设备标识 (协议规范 §2)
|
||||
// ============================================================
|
||||
|
||||
#define SALEID_DEFAULT "10100001" // 站点/系统编号
|
||||
#define GATEID_DEFAULT "10100001001" // 网关唯一编号
|
||||
#define METER_ID_DEFAULT "ENV001" // 感知节点ID (卧室=ENV001, 卫生间=ENV002, 客厅=ENV003)
|
||||
#define METER_NAME_DEFAULT "RadarDHT11" // 设备型号名称
|
||||
#define FW_VERSION "V1.0.0"
|
||||
|
||||
// ============================================================
|
||||
// 3. WiFi 默认配置 (配网使用)
|
||||
// ============================================================
|
||||
|
||||
#define WIFI_AP_SSID "HealthMonitor-Setup"
|
||||
#define WIFI_AP_PASSWORD "12345678"
|
||||
#define WIFI_CONFIG_TIMEOUT 300
|
||||
#define WIFI_CONNECT_TIMEOUT 15
|
||||
#define WIFI_RETRY_INTERVAL 10000
|
||||
|
||||
// ============================================================
|
||||
// 4. MQTT 默认配置 (协议规范 §2)
|
||||
// ============================================================
|
||||
|
||||
#define MQTT_DEFAULT_SERVER "202.110.234.102"
|
||||
#define MQTT_DEFAULT_PORT 8000
|
||||
#define MQTT_DEFAULT_PORT_STR "8000" // WiFiManager 参数用字符串
|
||||
#define MQTT_DEFAULT_USER "test"
|
||||
#define MQTT_DEFAULT_PASS "testtest"
|
||||
#define MQTT_DEFAULT_DEVICE "10100001001" // 默认网关ID = gateid
|
||||
#define MQTT_DEFAULT_ENV "主卧" // 默认环境备注
|
||||
#undef MQTT_KEEPALIVE
|
||||
#define MQTT_KEEPALIVE 30 // 30秒 (协议规范)
|
||||
#define MQTT_RECONNECT_MIN 1000
|
||||
#define MQTT_RECONNECT_MAX 60000
|
||||
|
||||
// ============================================================
|
||||
// 5. MQTT 主题定义 (协议规范 §3)
|
||||
// ============================================================
|
||||
|
||||
// --- 上行主题 (设备 → 服务端) ---
|
||||
#define MQTT_TOPIC_REPORT_ALLPOINTS "report/allpoints" // 全量遥测上报 QoS0
|
||||
#define MQTT_TOPIC_STATUS_ONLINE "status/%s/online" // 上线通知 QoS1
|
||||
#define MQTT_TOPIC_STATUS_OFFLINE "status/%s/offline" // 离线遗嘱 QoS1
|
||||
#define MQTT_TOPIC_STATUS_PROVISION "status/%s/provision" // 配网状态 QoS1
|
||||
#define MQTT_TOPIC_RESPONSE_CMD "response/%s/cmd" // 指令响应 QoS1
|
||||
#define MQTT_TOPIC_RESPONSE_PROVISION "response/%s/provision" // 配网响应 QoS1
|
||||
|
||||
// --- 下行主题 (服务端 → 设备) ---
|
||||
#define MQTT_TOPIC_CMD_ALARM "cmd/%s/alarm" // 告警指令 QoS1
|
||||
#define MQTT_TOPIC_CMD_ALARM_RESTORE "cmd/%s/alarm/restore" // 告警恢复 QoS1
|
||||
#define MQTT_TOPIC_CMD_CONTROL "cmd/%s/control" // 控制指令 QoS1
|
||||
#define MQTT_TOPIC_CMD_PROVISION_WIFI "cmd/%s/provision/wifi" // WiFi配网 QoS1
|
||||
|
||||
// ============================================================
|
||||
// 6. 上报配置 (协议规范 §4.1, §7.2)
|
||||
// ============================================================
|
||||
|
||||
#define REPORT_INTERVAL_MS 1000 // 遥测上报周期 1秒
|
||||
#define REPORT_QOS 0 // 遥测 QoS 0
|
||||
#define STATUS_QOS 1 // 状态/响应 QoS 1
|
||||
|
||||
// ============================================================
|
||||
// 7. 报警阈值 (协议规范 §6, 服务端判定用)
|
||||
// ============================================================
|
||||
|
||||
#define HEART_RATE_MIN 50
|
||||
#define HEART_RATE_MAX 120
|
||||
#define BREATH_RATE_MIN 8
|
||||
#define BREATH_RATE_MAX 24
|
||||
#define TEMP_LOW 5.0f
|
||||
#define TEMP_HIGH 45.0f
|
||||
#define HUMI_LOW 15.0f
|
||||
#define HUMI_HIGH 90.0f
|
||||
#define WATER_DEBOUNCE_MS 50
|
||||
|
||||
// ============================================================
|
||||
// 8. 任务配置
|
||||
// ============================================================
|
||||
|
||||
#define RADAR_TASK_STACK 4096
|
||||
#define RADAR_TASK_PRIORITY 5
|
||||
#define RADAR_TASK_CORE 0
|
||||
|
||||
#define MQTT_TASK_STACK 8192
|
||||
#define MQTT_TASK_PRIORITY 4
|
||||
#define MQTT_TASK_CORE 1
|
||||
|
||||
// ============================================================
|
||||
// 9. NTP 时间同步
|
||||
// ============================================================
|
||||
|
||||
#define NTP_SERVER "ntp.aliyun.com"
|
||||
#define NTP_GMT_OFFSET_SEC 28800 // UTC+8 (北京时间)
|
||||
#define NTP_DAYLIGHT_OFFSET 0
|
||||
|
||||
// ============================================================
|
||||
// 10. LittleFS 配置文件名
|
||||
// ============================================================
|
||||
|
||||
#define CONFIG_FILE_MQTT "/mqtt_config.json"
|
||||
#define CONFIG_FILE_WIFI "/wifi_config.json"
|
||||
#define CONFIG_FILE_DEVICE "/device_config.json"
|
||||
|
||||
// ============================================================
|
||||
// 11. 结构体: 全量遥测数据 (协议规范 §4.1)
|
||||
// ============================================================
|
||||
|
||||
struct TelemetryData {
|
||||
// --- 设备标识 ---
|
||||
char saleid[12]; // 站点编号 "10100001"
|
||||
char gateid[16]; // 网关编号 "10100001001"
|
||||
char meterId[8]; // 节点ID "ENV001"
|
||||
char meterName[16]; // 设备型号 "RadarDHT11"
|
||||
|
||||
// --- 环境数据 (DHT11) ---
|
||||
float temp; // 温度 ℃
|
||||
float humi; // 湿度 %RH
|
||||
bool dhtValid; // 温湿度有效性
|
||||
|
||||
// --- 雷达: 存在/运动 ---
|
||||
bool presence; // 有人=1 无人=0
|
||||
bool motion; // 有运动=1 无运动=0
|
||||
bool fall; // 跌倒检测 1=触发
|
||||
bool resident; // 驻留状态 1=驻留
|
||||
|
||||
// --- 雷达: 生命体征 ---
|
||||
uint8_t heartRate; // 心率 bpm (无效=0)
|
||||
uint8_t breathRate; // 呼吸 次/分 (无效=0)
|
||||
uint8_t body; // 人体能量/体征值
|
||||
bool radarValid; // 雷达数据有效性
|
||||
bool vitalValid; // 心率呼吸有效性
|
||||
|
||||
// --- 水浸 ---
|
||||
bool rainDO; // 1=正常 0=水浸触发
|
||||
bool rainValid; // 水浸传感器有效性
|
||||
|
||||
// --- 设备状态 ---
|
||||
bool wifiReady; // WiFi/MQTT 连接状态
|
||||
uint32_t uptimeSec; // 设备累计运行秒数
|
||||
bool buzzerState; // 蜂鸣器状态 1=开启
|
||||
|
||||
// --- 时间戳 ---
|
||||
uint32_t lastUpdate; // 最后更新 ms
|
||||
};
|
||||
|
||||
// 结构体: 下行报警指令 (协议规范 §5.1, §5.2)
|
||||
struct AlarmCommand {
|
||||
char cmdId[32]; // 指令唯一ID
|
||||
char type[20]; // alarm / alarm_restore
|
||||
char alarmType[24]; // fall/water_leak/heart_abnormal/breath_abnormal
|
||||
char alarmLevel[8]; // low/medium/high
|
||||
char alarmDesc[64]; // 告警描述
|
||||
char meterId[8]; // 触发节点ID
|
||||
char relatedCmdId[32]; // 关联原始告警ID (恢复用)
|
||||
|
||||
// 联动动作
|
||||
bool buzzerOn; // 蜂鸣器开关
|
||||
uint16_t buzzerDuration; // 蜂鸣持续秒数 (0=持续到恢复)
|
||||
bool nightLight; // 夜灯
|
||||
|
||||
uint32_t timestamp;
|
||||
};
|
||||
|
||||
// 结构体: 下行控制指令 (协议规范 §5.3)
|
||||
struct ControlCommand {
|
||||
char cmdId[32];
|
||||
char type[20]; // control
|
||||
char controlType[24]; // buzzer_test/device_restart/report_interval_set/factory_reset
|
||||
uint32_t params_duration; // 参数: 持续秒数
|
||||
uint32_t params_interval; // 参数: 上报间隔
|
||||
uint32_t timestamp;
|
||||
};
|
||||
|
||||
// 结构体: 下行配网指令 (协议规范 §5.4)
|
||||
struct ProvisionCommand {
|
||||
char cmdId[32];
|
||||
char wifi_ssid[33];
|
||||
char wifi_password[65];
|
||||
char wifi_encrypt[8]; // OPEN/WPA2/WPA3
|
||||
char mqtt_broker[64];
|
||||
uint16_t mqtt_port;
|
||||
bool mqtt_useTls;
|
||||
uint32_t timestamp;
|
||||
};
|
||||
|
||||
#endif // CONFIG_H
|
||||
58
include/display_manager.h
Normal file
58
include/display_manager.h
Normal file
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* @file display_manager.h
|
||||
* @brief OLED 显示管理器 - SSD1306 128x64 I2C
|
||||
*
|
||||
* 4 个显示页面:
|
||||
* Page 0 - 环境数据 (温湿度 + WiFi)
|
||||
* Page 1 - 生命体征 (心率 + 呼吸 + 体动)
|
||||
* Page 2 - 人体状态 (有人/运动/跌倒/驻留)
|
||||
* Page 3 - 安全信息 (水浸 + 蜂鸣器 + 运行时间)
|
||||
*/
|
||||
|
||||
#ifndef DISPLAY_MANAGER_H
|
||||
#define DISPLAY_MANAGER_H
|
||||
|
||||
#include <Arduino.h>
|
||||
#include <U8g2lib.h>
|
||||
#include "config.h"
|
||||
|
||||
class DisplayManager {
|
||||
public:
|
||||
DisplayManager();
|
||||
~DisplayManager();
|
||||
|
||||
/** 初始化 OLED */
|
||||
bool begin();
|
||||
|
||||
/**
|
||||
* @brief 更新显示内容 (每 5 秒调用一次即可)
|
||||
* @param data 遥测数据
|
||||
* @param wifiOk WiFi 是否已连接
|
||||
* @param buzzerOn 蜂鸣器是否在响
|
||||
*/
|
||||
void update(const TelemetryData& data, bool wifiOk, bool buzzerOn);
|
||||
|
||||
/** 切换到下一页 (0→1→2→3→0 循环) */
|
||||
void nextPage();
|
||||
|
||||
/** 获取当前页码 */
|
||||
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);
|
||||
|
||||
/** 格式化秒数为 HH:MM:SS */
|
||||
String _fmtUptime(uint32_t secs) const;
|
||||
/** 格式化浮点数为一位小数 */
|
||||
String _fmtFloat(float val) const;
|
||||
|
||||
U8G2_SSD1306_128X64_NONAME_F_HW_I2C _u8g2;
|
||||
uint8_t _pageIndex;
|
||||
uint32_t _lastDrawTime;
|
||||
bool _initialized;
|
||||
};
|
||||
|
||||
#endif // DISPLAY_MANAGER_H
|
||||
131
include/mqtt_manager.h
Normal file
131
include/mqtt_manager.h
Normal file
@@ -0,0 +1,131 @@
|
||||
/**
|
||||
* @file mqtt_manager.h
|
||||
* @brief MQTT 通信管理器 (协议规范 V0.08)
|
||||
*
|
||||
* 上行: report/allpoints, status/{gateid}/online, status/{gateid}/offline,
|
||||
* status/{gateid}/provision, response/{gateid}/cmd, response/{gateid}/provision
|
||||
* 下行: cmd/{gateid}/alarm, cmd/{gateid}/alarm/restore,
|
||||
* cmd/{gateid}/control, cmd/{gateid}/provision/wifi
|
||||
*/
|
||||
|
||||
#ifndef MQTT_MANAGER_H
|
||||
#define MQTT_MANAGER_H
|
||||
|
||||
#include <Arduino.h>
|
||||
#include <WiFi.h>
|
||||
#include <PubSubClient.h>
|
||||
#include <ArduinoJson.h>
|
||||
#include "config.h"
|
||||
|
||||
// ============================================================
|
||||
// 回调函数类型
|
||||
// ============================================================
|
||||
|
||||
// 告警指令回调 (alarm / alarm_restore)
|
||||
typedef void (*MQTTAlarmCallback)(const AlarmCommand& cmd);
|
||||
|
||||
// 控制指令回调
|
||||
typedef void (*MQTTControlCallback)(const ControlCommand& cmd);
|
||||
|
||||
// 配网指令回调 (WiFi 凭证下发)
|
||||
typedef void (*MQTTProvisionCallback)(const ProvisionCommand& cmd);
|
||||
|
||||
// ============================================================
|
||||
// MQTTManager 类
|
||||
// ============================================================
|
||||
|
||||
class MQTTManager {
|
||||
public:
|
||||
MQTTManager();
|
||||
~MQTTManager();
|
||||
|
||||
/** 初始化 */
|
||||
bool begin(const char* gateid);
|
||||
|
||||
/** 主循环 */
|
||||
void loop();
|
||||
|
||||
/** 连接/断开 */
|
||||
bool connect();
|
||||
void disconnect();
|
||||
bool isConnected();
|
||||
|
||||
/** 设置服务器 */
|
||||
void setServer(const char* host, uint16_t port,
|
||||
const char* user, const char* pass);
|
||||
|
||||
// ============================================================
|
||||
// 上行: 发布方法 (协议规范 §4)
|
||||
// ============================================================
|
||||
|
||||
/** 全量遥测上报 report/allpoints (§4.1) */
|
||||
void publishAllPoints(const TelemetryData& data, uint32_t sequence);
|
||||
|
||||
/** 设备上线通知 status/{gateid}/online (§4.2) */
|
||||
void publishOnline();
|
||||
|
||||
/** 设备离线通知 status/{gateid}/offline (§4.3) — 主动调用 */
|
||||
void publishOffline();
|
||||
|
||||
/** 配网状态通知 status/{gateid}/provision (§4.4) */
|
||||
void publishProvisionStatus(const char* state, const char* bleName);
|
||||
|
||||
/** 指令执行响应 response/{gateid}/cmd (§4.5) */
|
||||
void publishCmdResponse(const char* cmdId, const char* cmdType,
|
||||
const char* alarmType, bool success,
|
||||
const char* errorCode, const char* errorMsg);
|
||||
|
||||
/** 配网指令响应 response/{gateid}/provision (§4.6) */
|
||||
void publishProvisionResponse(const char* cmdId, bool success,
|
||||
const char* errorCode, const char* errorMsg,
|
||||
const char* wifiSignal = "");
|
||||
|
||||
// ============================================================
|
||||
// 回调注册 (协议规范 §5)
|
||||
// ============================================================
|
||||
|
||||
void onAlarmCommand(MQTTAlarmCallback cb);
|
||||
void onControlCommand(MQTTControlCallback cb);
|
||||
void onProvisionCommand(MQTTProvisionCallback cb);
|
||||
|
||||
/** 获取 gateid */
|
||||
const char* getGateId() const { return _gateid; }
|
||||
|
||||
private:
|
||||
static void _mqttCallback(char* topic, byte* payload, unsigned int length);
|
||||
static MQTTManager* _instance;
|
||||
|
||||
// 主题构建
|
||||
String _buildTopic(const char* tmpl) const;
|
||||
|
||||
// 下行报文解析
|
||||
void _handleMessage(const char* topic, const char* payload);
|
||||
void _parseAlarm(const char* payload, bool isRestore);
|
||||
void _parseControl(const char* payload);
|
||||
void _parseProvision(const char* payload);
|
||||
|
||||
// 发布辅助
|
||||
bool _publish(const char* topicTmpl, const char* payload, uint8_t qos, bool retain = false);
|
||||
|
||||
WiFiClient _wifiClient;
|
||||
PubSubClient _mqtt;
|
||||
char _gateid[16];
|
||||
|
||||
// 服务器配置
|
||||
char _server[64];
|
||||
uint16_t _port;
|
||||
char _user[32];
|
||||
char _pass[32];
|
||||
|
||||
// 重连
|
||||
uint32_t _reconnectInterval;
|
||||
uint32_t _lastReconnectAttempt;
|
||||
bool _connected;
|
||||
|
||||
// 回调
|
||||
MQTTAlarmCallback _alarmCb;
|
||||
MQTTControlCallback _controlCb;
|
||||
MQTTProvisionCallback _provisionCb;
|
||||
};
|
||||
|
||||
#endif // MQTT_MANAGER_H
|
||||
90
include/radar_manager.h
Normal file
90
include/radar_manager.h
Normal file
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* @file radar_manager.h
|
||||
* @brief R60ABD1 毫米波雷达管理模块
|
||||
*/
|
||||
|
||||
#ifndef RADAR_MANAGER_H
|
||||
#define RADAR_MANAGER_H
|
||||
|
||||
#include <Arduino.h>
|
||||
#include "config.h"
|
||||
#include "radar_protocol.h"
|
||||
|
||||
// 雷达原始数据结构
|
||||
struct RadarRawData {
|
||||
bool presence; // 有人/无人
|
||||
bool motion; // 运动 (false=静止)
|
||||
bool fall; // 跌倒
|
||||
bool resident; // 驻留
|
||||
|
||||
uint8_t heartRate; // 心率
|
||||
uint8_t breathRate; // 呼吸
|
||||
uint8_t body; // 体动/能量值
|
||||
uint16_t distance; // 距离 cm
|
||||
|
||||
bool radarValid; // 雷达有效性
|
||||
bool vitalValid; // 生命体征有效性
|
||||
|
||||
uint32_t lastUpdate;
|
||||
};
|
||||
|
||||
typedef void (*RadarCallback)(const RadarRawData& data);
|
||||
|
||||
class RadarManager {
|
||||
public:
|
||||
RadarManager();
|
||||
~RadarManager();
|
||||
|
||||
bool begin();
|
||||
void loop();
|
||||
|
||||
/** 获取最新数据 */
|
||||
RadarRawData getData() const;
|
||||
|
||||
/** 数据更新回调 */
|
||||
void onDataUpdate(RadarCallback cb);
|
||||
|
||||
/** 发送命令 */
|
||||
void sendCommand(uint8_t control, uint8_t command,
|
||||
const uint8_t* data = nullptr, uint16_t len = 0);
|
||||
void queryInitStatus();
|
||||
void queryDetectRange();
|
||||
|
||||
bool isInitialized() const { return _initialized; }
|
||||
|
||||
/** 检查雷达是否超时未收到数据 (>5秒无有效帧) */
|
||||
bool isTimeout() const {
|
||||
return (_frameCount > 0) && (millis() - _data.lastUpdate > 5000);
|
||||
}
|
||||
/** 雷达数据是否有效 */
|
||||
bool isDataValid() const { return _data.radarValid; }
|
||||
|
||||
private:
|
||||
void _parseByte(uint8_t byte);
|
||||
void _resetParser();
|
||||
bool _validateChecksum();
|
||||
void _processCompleteFrame();
|
||||
|
||||
void _extractPresence(const RadarFrame& frame);
|
||||
void _extractBreathing(const RadarFrame& frame);
|
||||
void _extractHeartRate(const RadarFrame& frame);
|
||||
void _extractSleep(const RadarFrame& frame);
|
||||
void _extractWorkStatus(const RadarFrame& frame);
|
||||
void _extractDetectRange(const RadarFrame& frame);
|
||||
|
||||
uint8_t _calcChecksum(const uint8_t* data, uint16_t len);
|
||||
void _notifyUpdate();
|
||||
|
||||
HardwareSerial* _serial;
|
||||
FrameState _state;
|
||||
RadarFrame _currentFrame;
|
||||
uint16_t _dataIndex;
|
||||
RadarRawData _data;
|
||||
bool _initialized;
|
||||
|
||||
RadarCallback _callback;
|
||||
uint32_t _frameCount;
|
||||
uint32_t _errorCount;
|
||||
};
|
||||
|
||||
#endif // RADAR_MANAGER_H
|
||||
114
include/radar_protocol.h
Normal file
114
include/radar_protocol.h
Normal file
@@ -0,0 +1,114 @@
|
||||
/**
|
||||
* @file radar_protocol.h
|
||||
* @brief R60ABD1 60G 毫米波雷达通信协议定义
|
||||
*
|
||||
* 协议: SIP-S v1.0 串口协议 (云帆瑞达 Micradar)
|
||||
* 帧格式: 帧头(2B) + 控制字(1B) + 命令字(1B) + 长度(2B) + 数据(NB) + 校验(1B) + 帧尾(2B)
|
||||
*/
|
||||
|
||||
#ifndef RADAR_PROTOCOL_H
|
||||
#define RADAR_PROTOCOL_H
|
||||
|
||||
#include <stdint.h>
|
||||
|
||||
// ============================================================
|
||||
// 帧定界符
|
||||
// ============================================================
|
||||
#define FRAME_HEADER_1 0x53 // 'S'
|
||||
#define FRAME_HEADER_2 0x59 // 'Y'
|
||||
#define FRAME_TAIL_1 0x54 // 'T'
|
||||
#define FRAME_TAIL_2 0x43 // 'C'
|
||||
|
||||
#define FRAME_HEADER_SIZE 2
|
||||
#define FRAME_TAIL_SIZE 2
|
||||
#define FRAME_MIN_SIZE 8 // 最小帧: 头2+控制1+命令1+长度2+校验1+尾2 = 9 字节
|
||||
|
||||
// ============================================================
|
||||
// 控制字 (Control Word)
|
||||
// ============================================================
|
||||
enum RadarControl : uint8_t {
|
||||
CTRL_HEARTBEAT = 0x01, // 心跳包
|
||||
CTRL_PRODUCT_INFO = 0x02, // 产品信息
|
||||
CTRL_OTA = 0x03, // OTA 升级
|
||||
CTRL_WORK_STATUS = 0x05, // 工作状态
|
||||
CTRL_DETECT_RANGE = 0x07, // 探测范围
|
||||
CTRL_PRESENCE = 0x80, // 人体存在
|
||||
CTRL_BREATHING = 0x81, // 呼吸检测
|
||||
CTRL_SLEEP = 0x84, // 睡眠监测
|
||||
CTRL_HEART_RATE = 0x85, // 心率监测
|
||||
};
|
||||
|
||||
// ============================================================
|
||||
// 命令字 (Command Word)
|
||||
// ============================================================
|
||||
enum RadarCommand : uint8_t {
|
||||
CMD_QUERY = 0x01, // 查询命令
|
||||
CMD_REPORT = 0x02, // 主动上报
|
||||
CMD_SET = 0x03, // 设置命令
|
||||
CMD_ACK = 0x04, // 应答
|
||||
};
|
||||
|
||||
// ============================================================
|
||||
// 工作状态命令 (CTRL_WORK_STATUS 下的子命令)
|
||||
// ============================================================
|
||||
enum WorkStatusCmd : uint8_t {
|
||||
WS_INIT_COMPLETE = 0x01, // 初始化完成
|
||||
WS_INIT_QUERY = 0x81, // 查询初始化状态
|
||||
WS_POS_OUT_BOUNDS = 0x07, // 位置超出范围
|
||||
};
|
||||
|
||||
// ============================================================
|
||||
// 呼吸状态
|
||||
// ============================================================
|
||||
enum RespirationStatus : uint8_t {
|
||||
RESPI_NORMAL = 0, // 正常 (10~25 次/分)
|
||||
RESPI_HIGH = 1, // 过高 (>25)
|
||||
RESPI_LOW = 2, // 过低 (<10)
|
||||
};
|
||||
|
||||
// ============================================================
|
||||
// 睡眠状态
|
||||
// ============================================================
|
||||
enum SleepStatus : uint8_t {
|
||||
SLEEP_AWAKE = 0, // 清醒
|
||||
SLEEP_LIGHT = 1, // 浅睡
|
||||
SLEEP_DEEP = 2, // 深睡
|
||||
};
|
||||
|
||||
// ============================================================
|
||||
// 帧解析状态机
|
||||
// ============================================================
|
||||
enum FrameState {
|
||||
STATE_IDLE, // 等待帧头 0x53
|
||||
STATE_GOT_HEADER1, // 已收到 0x53, 等待 0x59
|
||||
STATE_GOT_HEADER2, // 已收到帧头, 等待控制字
|
||||
STATE_GOT_CONTROL, // 已收到控制字, 等待命令字
|
||||
STATE_GOT_COMMAND, // 已收到命令字, 等待长度高字节
|
||||
STATE_GOT_LEN_H, // 已收到长度高字节, 等待长度低字节
|
||||
STATE_GOT_LEN_L, // 已收到长度低字节, 读取数据
|
||||
STATE_GOT_DATA, // 数据接收完成, 等待校验
|
||||
STATE_GOT_CHECKSUM, // 已收到校验, 等待帧尾 0x54
|
||||
STATE_GOT_TAIL1, // 已收到 0x54, 等待 0x43
|
||||
STATE_FRAME_COMPLETE, // 帧接收完成
|
||||
};
|
||||
|
||||
// ============================================================
|
||||
// 帧缓冲区大小
|
||||
// ============================================================
|
||||
#define RADAR_FRAME_BUF_SIZE 128 // 单帧最大缓冲区
|
||||
#define RADAR_RING_BUF_SIZE 512 // 环形接收缓冲区
|
||||
|
||||
// ============================================================
|
||||
// 解析后的雷达帧数据 (原始)
|
||||
// ============================================================
|
||||
struct RadarFrame {
|
||||
uint8_t control;
|
||||
uint8_t command;
|
||||
uint16_t data_length;
|
||||
uint8_t data[RADAR_FRAME_BUF_SIZE];
|
||||
uint8_t checksum;
|
||||
bool valid; // 校验通过
|
||||
uint32_t timestamp;
|
||||
};
|
||||
|
||||
#endif // RADAR_PROTOCOL_H
|
||||
61
include/sensor_manager.h
Normal file
61
include/sensor_manager.h
Normal file
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* @file sensor_manager.h
|
||||
* @brief 传感器管理器 - DHT11温湿度 + 水浸检测
|
||||
*/
|
||||
|
||||
#ifndef SENSOR_MANAGER_H
|
||||
#define SENSOR_MANAGER_H
|
||||
|
||||
#include <Arduino.h>
|
||||
#include <DHT.h>
|
||||
#include "config.h"
|
||||
|
||||
#define DHT_TYPE DHT11
|
||||
|
||||
typedef void (*SensorCallback)(void); // 数据变化通知
|
||||
|
||||
class SensorManager {
|
||||
public:
|
||||
SensorManager();
|
||||
~SensorManager();
|
||||
|
||||
/** 初始化 */
|
||||
bool begin();
|
||||
|
||||
/** 读取所有传感器 (定时调用) */
|
||||
void read();
|
||||
|
||||
/** 获取温度 */
|
||||
float getTemp() const { return _temp; }
|
||||
/** 获取湿度 */
|
||||
float getHumi() const { return _humi; }
|
||||
/** DHT11 有效性 */
|
||||
bool isDhtValid() const { return _dhtValid; }
|
||||
/** DHT 是否持续故障 */
|
||||
bool isDhtFault() const { return _dhtErrorCnt >= 10; }
|
||||
|
||||
/** 获取水浸状态 */
|
||||
bool getRainDO() const { return _rainDO; }
|
||||
/** 水浸传感器有效性 */
|
||||
bool isRainValid() const { return _rainValid; }
|
||||
|
||||
/** 设置数据变化回调 */
|
||||
void onChange(SensorCallback cb);
|
||||
|
||||
private:
|
||||
DHT* _dht;
|
||||
float _temp;
|
||||
float _humi;
|
||||
bool _dhtValid;
|
||||
uint8_t _dhtErrorCnt; // 连续失败计数
|
||||
|
||||
bool _rainDO; // 1=正常 0=水浸
|
||||
bool _rainValid;
|
||||
bool _lastRainRaw;
|
||||
uint32_t _rainChangeTime;
|
||||
|
||||
SensorCallback _callback;
|
||||
uint32_t _lastReadTime;
|
||||
};
|
||||
|
||||
#endif // SENSOR_MANAGER_H
|
||||
132
include/wifi_manager.h
Normal file
132
include/wifi_manager.h
Normal file
@@ -0,0 +1,132 @@
|
||||
/**
|
||||
* @file wifi_manager.h
|
||||
* @brief WiFi 配网管理器
|
||||
*
|
||||
* 功能:
|
||||
* - 自动连接已保存的 WiFi
|
||||
* - 配网失败/首次启动 → AP 模式 + 强制门户页面
|
||||
* - 自定义参数: MQTT 服务器/端口/用户/密码、设备名称、环境备注
|
||||
* - 长按按钮重置配置
|
||||
* - LED 状态指示
|
||||
* - LittleFS 持久化存储
|
||||
*/
|
||||
|
||||
#ifndef WIFI_MANAGER_H
|
||||
#define WIFI_MANAGER_H
|
||||
|
||||
#include <Arduino.h>
|
||||
#include <WiFiManager.h>
|
||||
#include <LittleFS.h>
|
||||
#include <ArduinoJson.h>
|
||||
#include "config.h"
|
||||
|
||||
// 设备配置结构体
|
||||
struct DeviceConfig {
|
||||
char mqtt_server[40];
|
||||
char mqtt_port[6];
|
||||
char mqtt_user[20];
|
||||
char mqtt_pass[20];
|
||||
char device_id[20];
|
||||
char env_note[20]; // 环境备注 (如: "主卧", "客厅", "老人房")
|
||||
};
|
||||
|
||||
class WiFiMgmt {
|
||||
public:
|
||||
WiFiMgmt();
|
||||
~WiFiMgmt();
|
||||
|
||||
// 单例指针 (用于静态回调)
|
||||
static WiFiMgmt* _instance;
|
||||
|
||||
/**
|
||||
* @brief 初始化 WiFi 管理器
|
||||
* @return true=成功
|
||||
*/
|
||||
bool begin();
|
||||
|
||||
/**
|
||||
* @brief 主循环, 处理配网流程和按钮检测
|
||||
* @return true=WiFi 已连接
|
||||
*/
|
||||
bool loop();
|
||||
|
||||
/**
|
||||
* @brief WiFi 是否已连接
|
||||
*/
|
||||
bool isConnected() const;
|
||||
|
||||
/**
|
||||
* @brief 获取设备配置
|
||||
*/
|
||||
DeviceConfig getConfig() const;
|
||||
|
||||
/**
|
||||
* @brief 获取设备 ID (用于 MQTT client_id)
|
||||
*/
|
||||
const char* getDeviceId() const;
|
||||
|
||||
/**
|
||||
* @brief 获取环境备注
|
||||
*/
|
||||
const char* getEnvNote() const;
|
||||
|
||||
/**
|
||||
* @brief 强制进入配网模式
|
||||
*/
|
||||
void startConfigPortal();
|
||||
|
||||
/**
|
||||
* @brief 清除所有配置并重启
|
||||
*/
|
||||
void resetSettings();
|
||||
|
||||
private:
|
||||
/**
|
||||
* @brief 尝试连接 WiFi
|
||||
*/
|
||||
bool _connectWiFi();
|
||||
|
||||
/**
|
||||
* @brief 设置 LED 状态
|
||||
* @param mode 0=慢闪(未连接) 1=快闪(配网) 2=常亮(已连接)
|
||||
*/
|
||||
void _setLEDMode(uint8_t mode);
|
||||
|
||||
/**
|
||||
* @brief 检测按钮长按
|
||||
*/
|
||||
bool _checkButtonLongPress();
|
||||
|
||||
/**
|
||||
* @brief 加载/保存配置到 LittleFS
|
||||
*/
|
||||
bool _loadConfig();
|
||||
bool _saveConfig();
|
||||
|
||||
/**
|
||||
* @brief 保存 WiFi 参数回调 (WiFiManager 的回调)
|
||||
*/
|
||||
static void _saveParamsCallback();
|
||||
static void _apCallback(WiFiManager* wm);
|
||||
|
||||
WiFiManager _wm;
|
||||
DeviceConfig _config;
|
||||
bool _connected;
|
||||
uint32_t _lastCheckTime;
|
||||
uint32_t _buttonPressTime;
|
||||
bool _buttonPressed;
|
||||
|
||||
// LED 闪烁状态
|
||||
uint32_t _ledLastToggle;
|
||||
bool _ledState;
|
||||
|
||||
// WiFiManager 自定义参数
|
||||
WiFiManagerParameter* _param_mqtt_server;
|
||||
WiFiManagerParameter* _param_mqtt_port;
|
||||
WiFiManagerParameter* _param_mqtt_user;
|
||||
WiFiManagerParameter* _param_mqtt_pass;
|
||||
WiFiManagerParameter* _param_device_id;
|
||||
WiFiManagerParameter* _param_env_note;
|
||||
};
|
||||
|
||||
#endif // WIFI_MANAGER_H
|
||||
262
include/wm_strings_zh.h
Normal file
262
include/wm_strings_zh.h
Normal file
@@ -0,0 +1,262 @@
|
||||
/**
|
||||
* wm_strings_zh.h
|
||||
* 中文语言包 for WiFiManager
|
||||
* 基于 wm_strings_en.h 翻译
|
||||
*
|
||||
* WiFiManager, a library for the ESP8266/Arduino platform
|
||||
* for configuration of WiFi credentials using a Captive Portal
|
||||
*
|
||||
* @author Creator tzapu
|
||||
* @author tablatronix
|
||||
* @version 0.0.0
|
||||
* @license MIT
|
||||
*/
|
||||
|
||||
#ifndef _WM_STRINGS_ZH_H_
|
||||
#define _WM_STRINGS_ZH_H_
|
||||
|
||||
// strings files must include a consts file!
|
||||
#include "wm_consts_en.h" // include constants, tokens, routes
|
||||
|
||||
const char WM_LANGUAGE[] PROGMEM = "zh-CN"; // i18n lang code
|
||||
|
||||
const char HTTP_HEAD_START[] PROGMEM = "<!DOCTYPE html>"
|
||||
"<html lang='zh-CN'><head>"
|
||||
"<meta name='format-detection' content='telephone=no'>"
|
||||
"<meta charset='UTF-8'>"
|
||||
"<meta name='viewport' content='width=device-width,initial-scale=1,user-scalable=no'/>"
|
||||
"<title>{v}</title>";
|
||||
|
||||
const char HTTP_SCRIPT[] PROGMEM = "<script>function c(l){"
|
||||
"document.getElementById('s').value=l.getAttribute('data-ssid')||l.innerText||l.textContent;"
|
||||
"p = l.nextElementSibling.classList.contains('l');"
|
||||
"document.getElementById('p').disabled = !p;"
|
||||
"if(p)document.getElementById('p').focus();};"
|
||||
"function f() {var x = document.getElementById('p');x.type==='password'?x.type='text':x.type='password';}"
|
||||
"</script>";
|
||||
|
||||
const char HTTP_HEAD_END[] PROGMEM = "</head><body class='{c}'><div class='wrap'>"; // {c} = _bodyclass
|
||||
|
||||
const char HTTP_ROOT_MAIN[] PROGMEM = "<h1>{t}</h1><h3>{v}</h3>";
|
||||
|
||||
const char * const HTTP_PORTAL_MENU[] PROGMEM = {
|
||||
"<form action='/wifi' method='get'><button>配置 WiFi</button></form><br/>\n", // MENU_WIFI
|
||||
"<form action='/0wifi' method='get'><button>配置 WiFi (不扫描)</button></form><br/>\n", // MENU_WIFINOSCAN
|
||||
"<form action='/info' method='get'><button>系统信息</button></form><br/>\n", // MENU_INFO
|
||||
"<form action='/param' method='get'><button>参数设置</button></form><br/>\n", // MENU_PARAM
|
||||
"<form action='/close' method='get'><button>关闭页面</button></form><br/>\n", // MENU_CLOSE
|
||||
"<form action='/restart' method='get'><button>重启设备</button></form><br/>\n", // MENU_RESTART
|
||||
"<form action='/exit' method='get'><button>退出配网</button></form><br/>\n", // MENU_EXIT
|
||||
"<form action='/erase' method='get'><button class='D'>清除配置</button></form><br/>\n", // MENU_ERASE
|
||||
"<form action='/update' method='get'><button>固件升级</button></form><br/>\n", // MENU_UPDATE
|
||||
"<hr><br/>" // MENU_SEP
|
||||
};
|
||||
|
||||
const char HTTP_PORTAL_OPTIONS[] PROGMEM = "";
|
||||
const char HTTP_ITEM_QI[] PROGMEM = "<div role='img' aria-label='{r}%' title='{r}%' class='q q-{q} {i} {h}'></div>";
|
||||
const char HTTP_ITEM_QP[] PROGMEM = "<div class='q {h}'>{r}%</div>";
|
||||
const char HTTP_ITEM[] PROGMEM = "<div><a href='#p' onclick='c(this)' data-ssid='{V}'>{v}</a>{qi}{qp}</div>";
|
||||
|
||||
const char HTTP_FORM_START[] PROGMEM = "<form method='POST' action='{v}'>";
|
||||
const char HTTP_FORM_WIFI[] PROGMEM = "<label for='s'>WiFi 名称 (SSID)</label><input id='s' name='s' maxlength='32' autocorrect='off' autocapitalize='none' placeholder='{v}'><br/><label for='p'>WiFi 密码</label><input id='p' name='p' maxlength='64' type='password' placeholder='{p}'><input type='checkbox' id='showpass' onclick='f()'> <label for='showpass'>显示密码</label><br/>";
|
||||
const char HTTP_FORM_WIFI_END[] PROGMEM = "";
|
||||
const char HTTP_FORM_STATIC_HEAD[] PROGMEM = "<hr><br/>";
|
||||
const char HTTP_FORM_END[] PROGMEM = "<br/><br/><button type='submit'>保存配置</button></form>";
|
||||
const char HTTP_FORM_LABEL[] PROGMEM = "<label for='{i}'>{t}</label>";
|
||||
const char HTTP_FORM_PARAM_HEAD[] PROGMEM = "<hr><br/>";
|
||||
const char HTTP_FORM_PARAM[] PROGMEM = "<br/><input id='{i}' name='{n}' maxlength='{l}' value='{v}' {c}>\n";
|
||||
|
||||
const char HTTP_SCAN_LINK[] PROGMEM = "<br/><form action='/wifi?refresh=1' method='POST'><button name='refresh' value='1'>重新扫描</button></form>";
|
||||
const char HTTP_SAVED[] PROGMEM = "<div class='msg'>正在保存配置<br/>尝试连接 WiFi...<br/>如果连接失败,请重新连接 AP 再试一次</div>";
|
||||
const char HTTP_PARAMSAVED[] PROGMEM = "<div class='msg S'>配置已保存<br/></div>";
|
||||
const char HTTP_END[] PROGMEM = "</div></body></html>";
|
||||
const char HTTP_ERASEBTN[] PROGMEM = "<br/><form action='/erase' method='get'><button class='D'>清除 WiFi 配置</button></form>";
|
||||
const char HTTP_UPDATEBTN[] PROGMEM = "<br/><form action='/update' method='get'><button>固件升级</button></form>";
|
||||
const char HTTP_BACKBTN[] PROGMEM = "<hr><br/><form action='/' method='get'><button>返回首页</button></form>";
|
||||
|
||||
const char HTTP_STATUS_ON[] PROGMEM = "<div class='msg S'><strong>已连接</strong> {v}<br/><em><small>IP 地址: {i}</small></em></div>";
|
||||
const char HTTP_STATUS_OFF[] PROGMEM = "<div class='msg {c}'><strong>未连接</strong> {v}{r}</div>";
|
||||
const char HTTP_STATUS_OFFPW[] PROGMEM = "<br/>WiFi 密码错误";
|
||||
const char HTTP_STATUS_OFFNOAP[] PROGMEM = "<br/>未找到该 WiFi";
|
||||
const char HTTP_STATUS_OFFFAIL[] PROGMEM = "<br/>无法连接";
|
||||
const char HTTP_STATUS_NONE[] PROGMEM = "<div class='msg'>未设置 WiFi</div>";
|
||||
const char HTTP_BR[] PROGMEM = "<br/>";
|
||||
|
||||
const char HTTP_STYLE[] PROGMEM = "<style>"
|
||||
".c,body{text-align:center;font-family:'PingFang SC','Microsoft YaHei','Helvetica Neue',sans-serif}div,input,select{padding:5px;font-size:1em;margin:5px 0;box-sizing:border-box}"
|
||||
"input,button,select,.msg{border-radius:.3rem;width: 100%}input[type=radio],input[type=checkbox]{width:auto}"
|
||||
"button,input[type='button'],input[type='submit']{cursor:pointer;border:0;background-color:#1fa3ec;color:#fff;line-height:2.4rem;font-size:1.2rem;width:100%}"
|
||||
"input[type='file']{border:1px solid #1fa3ec}"
|
||||
".wrap {text-align:left;display:inline-block;min-width:260px;max-width:500px}"
|
||||
"a{color:#000;font-weight:700;text-decoration:none}a:hover{color:#1fa3ec;text-decoration:underline}"
|
||||
".q{height:16px;margin:0;padding:0 5px;text-align:right;min-width:38px;float:right}.q.q-0:after{background-position-x:0}.q.q-1:after{background-position-x:-16px}.q.q-2:after{background-position-x:-32px}.q.q-3:after{background-position-x:-48px}.q.q-4:after{background-position-x:-64px}.q.l:before{background-position-x:-80px;padding-right:5px}.ql .q{float:left}.q:after,.q:before{content:'';width:16px;height:16px;display:inline-block;background-repeat:no-repeat;background-position: 16px 0;"
|
||||
"background-image:url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAGAAAAAQCAMAAADeZIrLAAAAJFBMVEX///8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADHJj5lAAAAC3RSTlMAIjN3iJmqu8zd7vF8pzcAAABsSURBVHja7Y1BCsAwCASNSVo3/v+/BUEiXnIoXkoX5jAQMxTHzK9cVSnvDxwD8bFx8PhZ9q8FmghXBhqA1faxk92PsxvRc2CCCFdhQCbRkLoAQ3q/wWUBqG35ZxtVzW4Ed6LngPyBU2CobdIDQ5oPWI5nCUwAAAAASUVORK5CYII=');}"
|
||||
"@media (-webkit-min-device-pixel-ratio: 2),(min-resolution: 192dpi){.q:before,.q:after {"
|
||||
"background-image:url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAALwAAAAgCAMAAACfM+KhAAAALVBMVEX///8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADAOrOgAAAADnRSTlMAESIzRGZ3iJmqu8zd7gKjCLQAAACmSURBVHgB7dDBCoMwEEXRmKlVY3L//3NLhyzqIqSUggy8uxnhCR5Mo8xLt+14aZ7wwgsvvPA/ofv9+44334UXXngvb6XsFhO/VoC2RsSv9J7x8BnYLW+AjT56ud/uePMdb7IP8Bsc/e7h8Cfk912ghsNXWPpDC4hvN+D1560A1QPORyh84VKLjjdvfPFm++i9EWq0348XXnjhhT+4dIbCW+WjZim9AKk4UZMnnCEuAAAAAElFTkSuQmCC');"
|
||||
"background-size: 95px 16px;}}"
|
||||
".msg{padding:20px;margin:20px 0;border:1px solid #eee;border-left-width:5px;border-left-color:#777}.msg h4{margin-top:0;margin-bottom:5px}.msg.P{border-left-color:#1fa3ec}.msg.P h4{color:#1fa3ec}.msg.D{border-left-color:#dc3630}.msg.D h4{color:#dc3630}.msg.S{border-left-color: #5cb85c}.msg.S h4{color: #5cb85c}"
|
||||
"dt{font-weight:bold}dd{margin:0;padding:0 0 0.5em 0;min-height:12px}"
|
||||
"td{vertical-align: top;}"
|
||||
".h{display:none}"
|
||||
"button{transition: 0s opacity;transition-delay: 3s;transition-duration: 0s;cursor: pointer}"
|
||||
"button.D{background-color:#dc3630}"
|
||||
"button:active{opacity:50% !important;cursor:wait;transition-delay: 0s}"
|
||||
"body.invert,body.invert a,body.invert h1 {background-color:#060606;color:#fff;}"
|
||||
"body.invert .msg{color:#fff;background-color:#282828;border-top:1px solid #555;border-right:1px solid #555;border-bottom:1px solid #555;}"
|
||||
"body.invert .q[role=img]{-webkit-filter:invert(1);filter:invert(1);}"
|
||||
":disabled {opacity: 0.5;}"
|
||||
"</style>";
|
||||
|
||||
#ifndef WM_NOHELP
|
||||
const char HTTP_HELP[] PROGMEM =
|
||||
"<br/><h3>可用页面</h3><hr>"
|
||||
"<table class='table'>"
|
||||
"<thead><tr><th>页面</th><th>功能</th></tr></thead><tbody>"
|
||||
"<tr><td><a href='/'>/</a></td>"
|
||||
"<td>主菜单页面</td></tr>"
|
||||
"<tr><td><a href='/wifi'>/wifi</a></td>"
|
||||
"<td>显示 WiFi 扫描结果并配置连接 (/0wifi 不扫描)</td></tr>"
|
||||
"<tr><td><a href='/wifisave'>/wifisave</a></td>"
|
||||
"<td>保存 WiFi 配置并连接设备</td></tr>"
|
||||
"<tr><td><a href='/param'>/param</a></td>"
|
||||
"<td>参数设置页面</td></tr>"
|
||||
"<tr><td><a href='/info'>/info</a></td>"
|
||||
"<td>系统信息页面</td></tr>"
|
||||
"<tr><td><a href='/u'>/u</a></td>"
|
||||
"<td>固件 OTA 升级</td></tr>"
|
||||
"<tr><td><a href='/close'>/close</a></td>"
|
||||
"<td>关闭弹出窗口,配网服务保持运行</td></tr>"
|
||||
"<tr><td>/exit</td>"
|
||||
"<td>退出配网模式,配网服务将关闭</td></tr>"
|
||||
"<tr><td>/restart</td>"
|
||||
"<td>重启设备</td></tr>"
|
||||
"<tr><td>/erase</td>"
|
||||
"<td>清除 WiFi 配置并重启。清除后设备不会自动连接,需重新配网</td></tr>"
|
||||
"</table>"
|
||||
"<p/>GitHub <a href='https://github.com/tzapu/WiFiManager'>https://github.com/tzapu/WiFiManager</a>.";
|
||||
#else
|
||||
const char HTTP_HELP[] PROGMEM = "";
|
||||
#endif
|
||||
|
||||
const char HTTP_UPDATE[] PROGMEM = "上传新固件<br/><form method='POST' action='u' enctype='multipart/form-data' onchange=\"(function(el){document.getElementById('uploadbin').style.display = el.value=='' ? 'none' : 'initial';})(this)\"><input type='file' name='update' accept='.bin,application/octet-stream'><button id='uploadbin' type='submit' class='h D'>开始升级</button></form><small><a href='http://192.168.4.1/update' target='_blank'>* 如果在配网页面内无法使用,请在浏览器中打开 http://192.168.4.1</a><small>";
|
||||
const char HTTP_UPDATE_FAIL[] PROGMEM = "<div class='msg D'><strong>升级失败!</strong><Br/>请重启设备后重试</div>";
|
||||
const char HTTP_UPDATE_SUCCESS[] PROGMEM = "<div class='msg S'><strong>升级成功!</strong><Br/>设备正在重启...</div>";
|
||||
|
||||
#ifdef WM_JSTEST
|
||||
const char HTTP_JS[] PROGMEM =
|
||||
"<script>function postAjax(url, data, success) {"
|
||||
" var params = typeof data == 'string' ? data : Object.keys(data).map("
|
||||
" function(k){ return encodeURIComponent(k) + '=' + encodeURIComponent(data[k]) }"
|
||||
" ).join('&');"
|
||||
" var xhr = window.XMLHttpRequest ? new XMLHttpRequest() : new ActiveXObject(\"Microsoft.XMLHTTP\");"
|
||||
" xhr.open('POST', url);"
|
||||
" xhr.onreadystatechange = function() {"
|
||||
" if (xhr.readyState>3 && xhr.status==200) { success(xhr.responseText); }"
|
||||
" };"
|
||||
" xhr.setRequestHeader('X-Requested-With', 'XMLHttpRequest');"
|
||||
" xhr.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded');"
|
||||
" xhr.send(params);"
|
||||
" return xhr;}"
|
||||
"postAjax('/status', 'p1=1&p2=Hello+World', function(data){ console.log(data); });"
|
||||
"postAjax('/status', { p1: 1, p2: 'Hello World' }, function(data){ console.log(data); });"
|
||||
"</script>";
|
||||
#endif
|
||||
|
||||
// Info html
|
||||
#ifdef ESP32
|
||||
const char HTTP_INFO_esphead[] PROGMEM = "<h3>ESP32 信息</h3><hr><dl>";
|
||||
const char HTTP_INFO_chiprev[] PROGMEM = "<dt>芯片版本</dt><dd>{1}</dd>";
|
||||
const char HTTP_INFO_lastreset[] PROGMEM = "<dt>上次复位原因</dt><dd>CPU0: {1}<br/>CPU1: {2}</dd>";
|
||||
const char HTTP_INFO_aphost[] PROGMEM = "<dt>AP 主机名</dt><dd>{1}</dd>";
|
||||
const char HTTP_INFO_psrsize[] PROGMEM = "<dt>PSRAM 大小</dt><dd>{1} 字节</dd>";
|
||||
const char HTTP_INFO_temp[] PROGMEM = "<dt>芯片温度</dt><dd>{1}°C / {2}°F</dd>";
|
||||
const char HTTP_INFO_hall[] PROGMEM = "<dt>霍尔传感器</dt><dd>{1}</dd>";
|
||||
#else
|
||||
const char HTTP_INFO_esphead[] PROGMEM = "<h3>ESP8266 信息</h3><hr><dl>";
|
||||
const char HTTP_INFO_fchipid[] PROGMEM = "<dt>Flash 芯片 ID</dt><dd>{1}</dd>";
|
||||
const char HTTP_INFO_corever[] PROGMEM = "<dt>Core 版本</dt><dd>{1}</dd>";
|
||||
const char HTTP_INFO_bootver[] PROGMEM = "<dt>Boot 版本</dt><dd>{1}</dd>";
|
||||
const char HTTP_INFO_lastreset[] PROGMEM = "<dt>上次复位原因</dt><dd>{1}</dd>";
|
||||
const char HTTP_INFO_flashsize[] PROGMEM = "<dt>实际 Flash 大小</dt><dd>{1} 字节</dd>";
|
||||
#endif
|
||||
|
||||
const char HTTP_INFO_memsmeter[] PROGMEM = "<br/><progress value='{1}' max='{2}'></progress></dd>";
|
||||
const char HTTP_INFO_memsketch[] PROGMEM = "<dt>内存 - 程序大小</dt><dd>已用 / 总计<br/>{1} / {2} 字节";
|
||||
const char HTTP_INFO_freeheap[] PROGMEM = "<dt>内存 - 可用堆</dt><dd>{1} 字节</dd>";
|
||||
const char HTTP_INFO_wifihead[] PROGMEM = "<br/><h3>WiFi 信息</h3><hr>";
|
||||
const char HTTP_INFO_uptime[] PROGMEM = "<dt>运行时间</dt><dd>{1} 分 {2} 秒</dd>";
|
||||
const char HTTP_INFO_chipid[] PROGMEM = "<dt>芯片 ID</dt><dd>{1}</dd>";
|
||||
const char HTTP_INFO_idesize[] PROGMEM = "<dt>Flash 大小</dt><dd>{1} 字节</dd>";
|
||||
const char HTTP_INFO_sdkver[] PROGMEM = "<dt>SDK 版本</dt><dd>{1}</dd>";
|
||||
const char HTTP_INFO_cpufreq[] PROGMEM = "<dt>CPU 频率</dt><dd>{1}MHz</dd>";
|
||||
const char HTTP_INFO_apip[] PROGMEM = "<dt>AP IP 地址</dt><dd>{1}</dd>";
|
||||
const char HTTP_INFO_apmac[] PROGMEM = "<dt>AP MAC 地址</dt><dd>{1}</dd>";
|
||||
const char HTTP_INFO_apssid[] PROGMEM = "<dt>AP SSID</dt><dd>{1}</dd>";
|
||||
const char HTTP_INFO_apbssid[] PROGMEM = "<dt>BSSID</dt><dd>{1}</dd>";
|
||||
const char HTTP_INFO_stassid[] PROGMEM = "<dt>Station SSID</dt><dd>{1}</dd>";
|
||||
const char HTTP_INFO_staip[] PROGMEM = "<dt>Station IP</dt><dd>{1}</dd>";
|
||||
const char HTTP_INFO_stagw[] PROGMEM = "<dt>网关地址</dt><dd>{1}</dd>";
|
||||
const char HTTP_INFO_stasub[] PROGMEM = "<dt>子网掩码</dt><dd>{1}</dd>";
|
||||
const char HTTP_INFO_dnss[] PROGMEM = "<dt>DNS 服务器</dt><dd>{1}</dd>";
|
||||
const char HTTP_INFO_host[] PROGMEM = "<dt>主机名</dt><dd>{1}</dd>";
|
||||
const char HTTP_INFO_stamac[] PROGMEM = "<dt>Station MAC</dt><dd>{1}</dd>";
|
||||
const char HTTP_INFO_conx[] PROGMEM = "<dt>连接状态</dt><dd>{1}</dd>";
|
||||
const char HTTP_INFO_autoconx[] PROGMEM = "<dt>自动连接</dt><dd>{1}</dd>";
|
||||
|
||||
const char HTTP_INFO_aboutver[] PROGMEM = "<dt>WiFiManager 版本</dt><dd>{1}</dd>";
|
||||
const char HTTP_INFO_aboutarduino[] PROGMEM = "<dt>Arduino 版本</dt><dd>{1}</dd>";
|
||||
const char HTTP_INFO_aboutsdk[] PROGMEM = "<dt>ESP-SDK/IDF 版本</dt><dd>{1}</dd>";
|
||||
const char HTTP_INFO_aboutdate[] PROGMEM = "<dt>构建日期</dt><dd>{1}</dd>";
|
||||
|
||||
// ============================================================
|
||||
// 文本常量
|
||||
// ============================================================
|
||||
const char S_brand[] PROGMEM = "健康监测配网";
|
||||
const char S_debugPrefix[] PROGMEM = "*wm:";
|
||||
const char S_y[] PROGMEM = "是";
|
||||
const char S_n[] PROGMEM = "否";
|
||||
const char S_enable[] PROGMEM = "已启用";
|
||||
const char S_disable[] PROGMEM = "已禁用";
|
||||
const char S_GET[] PROGMEM = "GET";
|
||||
const char S_POST[] PROGMEM = "POST";
|
||||
const char S_NA[] PROGMEM = "未知";
|
||||
const char S_passph[] PROGMEM = "********";
|
||||
const char S_titlewifisaved[] PROGMEM = "WiFi 已保存";
|
||||
const char S_titlewifisettings[] PROGMEM = "设置已保存";
|
||||
const char S_titlewifi[] PROGMEM = "配置 WiFi";
|
||||
const char S_titleinfo[] PROGMEM = "系统信息";
|
||||
const char S_titleparam[] PROGMEM = "参数设置";
|
||||
const char S_titleparamsaved[] PROGMEM = "设置已保存";
|
||||
const char S_titleexit[] PROGMEM = "退出配网";
|
||||
const char S_titlereset[] PROGMEM = "重置";
|
||||
const char S_titleerase[] PROGMEM = "清除配置";
|
||||
const char S_titleclose[] PROGMEM = "关闭页面";
|
||||
const char S_options[] PROGMEM = "选项";
|
||||
const char S_nonetworks[] PROGMEM = "未发现 WiFi 网络,请点击刷新重新扫描。";
|
||||
const char S_staticip[] PROGMEM = "静态 IP";
|
||||
const char S_staticgw[] PROGMEM = "静态网关";
|
||||
const char S_staticdns[] PROGMEM = "静态 DNS";
|
||||
const char S_subnet[] PROGMEM = "子网掩码";
|
||||
const char S_exiting[] PROGMEM = "正在退出配网模式";
|
||||
const char S_resetting[] PROGMEM = "设备将在几秒后重启";
|
||||
const char S_closing[] PROGMEM = "可以关闭此页面,配网服务将继续运行";
|
||||
const char S_error[] PROGMEM = "发生错误";
|
||||
const char S_notfound[] PROGMEM = "页面未找到\n\n";
|
||||
const char S_uri[] PROGMEM = "URI: ";
|
||||
const char S_method[] PROGMEM = "\n请求方法: ";
|
||||
const char S_args[] PROGMEM = "\n参数: ";
|
||||
const char S_parampre[] PROGMEM = "param_";
|
||||
|
||||
// debug strings
|
||||
const char D_HR[] PROGMEM = "--------------------";
|
||||
|
||||
// softap ssid default prefix
|
||||
#ifdef ESP8266
|
||||
const char S_ssidpre[] PROGMEM = "ESP";
|
||||
#elif defined(ESP32)
|
||||
const char S_ssidpre[] PROGMEM = "ESP32";
|
||||
#else
|
||||
const char S_ssidpre[] PROGMEM = "WM";
|
||||
#endif
|
||||
|
||||
#endif
|
||||
46
platformio.ini
Normal file
46
platformio.ini
Normal file
@@ -0,0 +1,46 @@
|
||||
; PlatformIO Project Configuration File
|
||||
; ESP32-S3 + R60ABD1 60G 毫米波雷达 健康监测系统
|
||||
|
||||
[env:esp32-s3-devkitc-1]
|
||||
platform = espressif32
|
||||
board = esp32-s3-devkitc-1
|
||||
framework = arduino
|
||||
|
||||
; 串口监视器
|
||||
monitor_speed = 115200
|
||||
monitor_filters = direct
|
||||
|
||||
; Flash 配置
|
||||
board_build.flash_mode = qio
|
||||
board_build.filesystem = littlefs
|
||||
board_build.partitions = default_8MB.csv
|
||||
board_build.arduino.memory_type = qio_opi
|
||||
|
||||
; 编译选项 - 优化速度
|
||||
build_flags =
|
||||
-Os
|
||||
-DCORE_DEBUG_LEVEL=0
|
||||
-DARDUINO_USB_CDC_ON_BOOT=1
|
||||
-DBOARD_HAS_PSRAM
|
||||
|
||||
; 库依赖
|
||||
lib_deps =
|
||||
; WiFi 配网 (自带 captive portal)
|
||||
tzapu/WiFiManager@^2.0.17
|
||||
|
||||
; MQTT 客户端
|
||||
knolleary/PubSubClient@^2.8
|
||||
|
||||
; JSON 解析 (更省内存)
|
||||
bblanchon/ArduinoJson@^7.1.0
|
||||
|
||||
; DHT11 温湿度传感器
|
||||
adafruit/DHT sensor library@^1.4.6
|
||||
adafruit/Adafruit Unified Sensor@^1.1.14
|
||||
|
||||
; OLED 显示屏 (SSD1306 128x64 I2C)
|
||||
olikraus/U8g2@^2.35.30
|
||||
|
||||
; 上传配置
|
||||
upload_speed = 921600
|
||||
upload_port = COM15
|
||||
266
src/alarm_manager.cpp
Normal file
266
src/alarm_manager.cpp
Normal file
@@ -0,0 +1,266 @@
|
||||
/**
|
||||
* @file alarm_manager.cpp
|
||||
* @brief 报警管理器 - 双模式实现
|
||||
*
|
||||
* 本地: 跌倒/水浸/心率异常/呼吸异常 → 立即蜂鸣器+LED
|
||||
* 服务端: cmd/alarm, cmd/alarm/restore → 执行联动 + 回复响应
|
||||
*/
|
||||
|
||||
#include "alarm_manager.h"
|
||||
|
||||
AlarmManager::AlarmManager()
|
||||
: _buzzerOn(false)
|
||||
, _ledOn(false)
|
||||
, _buzzerOffTime(0)
|
||||
, _buzzerToggleTime(0)
|
||||
, _buzzerBeepState(false)
|
||||
, _beepCount(0)
|
||||
, _activeCount(0)
|
||||
, _lastLEDToggle(0)
|
||||
{
|
||||
for (int i = 0; i < 4; i++) _activeAlarms[i] = ALARM_NONE;
|
||||
}
|
||||
|
||||
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); // 关
|
||||
|
||||
pinMode(LED_ALARM_PIN, OUTPUT);
|
||||
digitalWrite(LED_ALARM_PIN, LOW);
|
||||
|
||||
_buzzerToggleTime = 0;
|
||||
_buzzerBeepState = false;
|
||||
_beepCount = 0;
|
||||
|
||||
Serial.println(F("[告警] ✅ 双模式报警 (本地+服务端)"));
|
||||
return true;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 主循环
|
||||
// ============================================================
|
||||
|
||||
void AlarmManager::loop() {
|
||||
_updateBuzzer();
|
||||
_updateLED();
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 本地报警触发 (离线可用)
|
||||
// ============================================================
|
||||
|
||||
void AlarmManager::triggerLocal(AlarmType type) {
|
||||
// 去重
|
||||
for (uint8_t i = 0; i < _activeCount; i++) {
|
||||
if (_activeAlarms[i] == type) return;
|
||||
}
|
||||
|
||||
if (_activeCount < 4) {
|
||||
_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;
|
||||
}
|
||||
|
||||
Serial.printf("[告警] 🚨 本地报警触发: %s (活动报警:%d)\n", name, _activeCount);
|
||||
|
||||
// 立即开启蜂鸣器和 LED
|
||||
_setBuzzer(true);
|
||||
_setLED(true);
|
||||
}
|
||||
|
||||
void AlarmManager::clearLocal(AlarmType type) {
|
||||
for (uint8_t i = 0; i < _activeCount; i++) {
|
||||
if (_activeAlarms[i] == type) {
|
||||
for (uint8_t j = i; j < _activeCount - 1; j++) {
|
||||
_activeAlarms[j] = _activeAlarms[j + 1];
|
||||
}
|
||||
_activeAlarms[_activeCount - 1] = ALARM_NONE;
|
||||
_activeCount--;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
Serial.printf("[告警] 本地报警清除, 剩余活动:%d\n", _activeCount);
|
||||
|
||||
// 没有活动报警时关闭
|
||||
if (_activeCount == 0) {
|
||||
_setBuzzer(false);
|
||||
_setLED(false);
|
||||
}
|
||||
}
|
||||
|
||||
void AlarmManager::clearAllLocal() {
|
||||
_activeCount = 0;
|
||||
for (int i = 0; i < 4; i++) _activeAlarms[i] = ALARM_NONE;
|
||||
_setBuzzer(false);
|
||||
_setLED(false);
|
||||
Serial.println(F("[告警] 清除所有本地报警"));
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 服务端告警指令 (§5.1)
|
||||
// ============================================================
|
||||
|
||||
void AlarmManager::executeAlarm(const AlarmCommand& cmd) {
|
||||
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);
|
||||
if (cmd.buzzerDuration > 0) {
|
||||
_buzzerOffTime = millis() + (uint32_t)cmd.buzzerDuration * 1000;
|
||||
} else {
|
||||
_buzzerOffTime = 0;
|
||||
}
|
||||
}
|
||||
|
||||
if (cmd.nightLight || cmd.buzzerOn) {
|
||||
_setLED(true);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 服务端恢复指令 (§5.2)
|
||||
// ============================================================
|
||||
|
||||
void AlarmManager::executeRestore(const AlarmCommand& cmd) {
|
||||
Serial.printf("[告警] ☁️ 服务端恢复: %s\n", cmd.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;
|
||||
|
||||
clearLocal(localType);
|
||||
|
||||
if (!cmd.buzzerOn) {
|
||||
_setBuzzer(false);
|
||||
}
|
||||
if (!cmd.nightLight) {
|
||||
_setLED(false);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 蜂鸣器测试
|
||||
// ============================================================
|
||||
|
||||
void AlarmManager::buzzerTest(uint32_t durationSec) {
|
||||
Serial.printf("[告警] 🔔 蜂鸣器测试 %d秒\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 "";
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 内部控制
|
||||
// ============================================================
|
||||
|
||||
void AlarmManager::_setBuzzer(bool on) {
|
||||
_buzzerOn = on;
|
||||
_buzzerBeepState = false;
|
||||
_buzzerToggleTime = 0;
|
||||
_beepCount = 0;
|
||||
if (on) {
|
||||
_buzzerOffTime = 0; // 本地报警=无限哔哔
|
||||
} else {
|
||||
ledcWrite(BUZZER_PWM_CHANNEL, 0);
|
||||
_buzzerOffTime = 0;
|
||||
}
|
||||
}
|
||||
|
||||
void AlarmManager::_setLED(bool on) {
|
||||
_ledOn = on;
|
||||
if (!on) digitalWrite(LED_ALARM_PIN, LOW);
|
||||
}
|
||||
|
||||
void AlarmManager::_updateBuzzer() {
|
||||
// 定时关闭检查
|
||||
if (_buzzerOn && _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大停顿
|
||||
if (!_buzzerBeepState) {
|
||||
// 在停顿中,检查是否该下一声
|
||||
uint32_t pause = (_beepCount >= 3) ? 600 : 100;
|
||||
if (now - _buzzerToggleTime >= pause) {
|
||||
_buzzerToggleTime = now;
|
||||
if (_beepCount >= 3) _beepCount = 0; // 重置周期
|
||||
_buzzerBeepState = true;
|
||||
ledcWrite(BUZZER_PWM_CHANNEL, 128);
|
||||
_beepCount++;
|
||||
}
|
||||
} else {
|
||||
// 在响中,150ms后停下
|
||||
if (now - _buzzerToggleTime >= 150) {
|
||||
_buzzerToggleTime = now;
|
||||
_buzzerBeepState = false;
|
||||
ledcWrite(BUZZER_PWM_CHANNEL, 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
// 云端定时: 持续响
|
||||
else if (_buzzerOn && _buzzerOffTime > 0) {
|
||||
ledcWrite(BUZZER_PWM_CHANNEL, 128);
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
if (millis() - _lastLEDToggle >= interval) {
|
||||
_lastLEDToggle = millis();
|
||||
digitalWrite(LED_ALARM_PIN, !digitalRead(LED_ALARM_PIN));
|
||||
}
|
||||
}
|
||||
88
src/button_manager.cpp
Normal file
88
src/button_manager.cpp
Normal file
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* @file button_manager.cpp
|
||||
* @brief 三按钮管理器实现 - 去抖 + 长按/短按
|
||||
*/
|
||||
|
||||
#include "button_manager.h"
|
||||
|
||||
// ============================================================
|
||||
// 构造
|
||||
// ============================================================
|
||||
|
||||
ButtonManager::ButtonManager()
|
||||
: _cbProvision(nullptr)
|
||||
, _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 };
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 初始化
|
||||
// ============================================================
|
||||
|
||||
void ButtonManager::begin() {
|
||||
pinMode(BTN_PROVISION_PIN, INPUT_PULLUP);
|
||||
pinMode(BTN_ALARM_DISMISS_PIN, INPUT_PULLUP);
|
||||
pinMode(BTN_PAGE_SWITCH_PIN, INPUT_PULLUP);
|
||||
|
||||
Serial.println(F("[按钮] ✅ 三按钮初始化完成"));
|
||||
Serial.printf(" 配网: GPIO%d | 解除报警: GPIO%d | 翻页: GPIO%d\n",
|
||||
BTN_PROVISION_PIN, BTN_ALARM_DISMISS_PIN, BTN_PAGE_SWITCH_PIN);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 主循环轮询
|
||||
// ============================================================
|
||||
|
||||
void ButtonManager::loop() {
|
||||
// 按钮1: 配网 (仅长按)
|
||||
_processButton(_btnProvision, false, true);
|
||||
|
||||
// 按钮2: 解除报警 (仅短按)
|
||||
_processButton(_btnDismiss, true, false);
|
||||
|
||||
// 按钮3: 翻页 (仅短按)
|
||||
_processButton(_btnPage, true, false);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 单按钮状态机
|
||||
// ============================================================
|
||||
|
||||
void ButtonManager::_processButton(ButtonState& btn, bool hasShort, bool hasLong) {
|
||||
bool raw = _readPin(btn.pin); // LOW = 按下
|
||||
|
||||
if (raw && !btn.pressed) {
|
||||
// 按下沿
|
||||
btn.pressed = true;
|
||||
btn.pressTime = millis();
|
||||
btn.longTriggered = false;
|
||||
}
|
||||
else if (!raw && btn.pressed) {
|
||||
// 松开沿
|
||||
btn.pressed = false;
|
||||
uint32_t holdTime = millis() - btn.pressTime;
|
||||
|
||||
if (holdTime < BTN_DEBOUNCE_MS) {
|
||||
return; // 抖动脉冲,忽略
|
||||
}
|
||||
|
||||
if (hasLong && holdTime >= BTN_LONG_PRESS_MS && !btn.longTriggered) {
|
||||
// 长按触发
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
bool ButtonManager::_readPin(uint8_t pin) {
|
||||
return digitalRead(pin) == LOW; // INPUT_PULLUP: LOW = 按下
|
||||
}
|
||||
248
src/display_manager.cpp
Normal file
248
src/display_manager.cpp
Normal file
@@ -0,0 +1,248 @@
|
||||
/**
|
||||
* @file display_manager.cpp
|
||||
* @brief OLED 显示管理器实现 - SSD1306 128x64 I2C
|
||||
*/
|
||||
|
||||
#include "display_manager.h"
|
||||
|
||||
// ============================================================
|
||||
// 构造 & 析构
|
||||
// ============================================================
|
||||
|
||||
DisplayManager::DisplayManager()
|
||||
: _u8g2(U8G2_R0, U8X8_PIN_NONE, OLED_SCL_PIN, OLED_SDA_PIN)
|
||||
, _pageIndex(0)
|
||||
, _lastDrawTime(0)
|
||||
, _initialized(false)
|
||||
{
|
||||
}
|
||||
|
||||
DisplayManager::~DisplayManager() {
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 初始化
|
||||
// ============================================================
|
||||
|
||||
bool DisplayManager::begin() {
|
||||
// SSD1306 常见地址: 0x3C 或 0x3D
|
||||
// 先用 0x3C 尝试,失败再用 0x3D
|
||||
_u8g2.setI2CAddress(0x3C << 1); // U8g2 需要左移一位
|
||||
if (!_u8g2.begin()) {
|
||||
// 尝试 0x3D
|
||||
_u8g2.setI2CAddress(0x3D << 1);
|
||||
if (!_u8g2.begin()) {
|
||||
Serial.println(F("[显示] ❌ OLED 未检测到! (I2C 0x3C/0x3D)"));
|
||||
Serial.println(F("[显示] 请检查接线: VCC→3.3V GND→GND SCL→22 SDA→21"));
|
||||
Serial.println(F("[显示] 系统将继续运行, 不影响其他功能"));
|
||||
return false;
|
||||
}
|
||||
Serial.println(F("[显示] 使用 I2C 地址 0x3D"));
|
||||
} else {
|
||||
Serial.println(F("[显示] 使用 I2C 地址 0x3C"));
|
||||
}
|
||||
|
||||
_u8g2.enableUTF8Print();
|
||||
_u8g2.setFont(u8g2_font_wqy12_t_gb2312);
|
||||
_initialized = true;
|
||||
|
||||
// 显示启动画面
|
||||
_u8g2.clearBuffer();
|
||||
_u8g2.setFont(u8g2_font_wqy16_t_gb2312);
|
||||
_u8g2.drawUTF8(10, 30, "健康监测系统");
|
||||
_u8g2.setFont(u8g2_font_wqy12_t_gb2312);
|
||||
_u8g2.drawUTF8(30, 52, "启动中...");
|
||||
_u8g2.sendBuffer();
|
||||
|
||||
Serial.println(F("[显示] ✅ OLED 初始化完成 (SSD1306 128x64 I2C)"));
|
||||
return true;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 翻页
|
||||
// ============================================================
|
||||
|
||||
void DisplayManager::nextPage() {
|
||||
_pageIndex = (_pageIndex + 1) % 4;
|
||||
_lastDrawTime = 0; // 强制立即刷新
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 主更新入口
|
||||
// ============================================================
|
||||
|
||||
void DisplayManager::update(const TelemetryData& data, bool wifiOk, bool buzzerOn) {
|
||||
if (!_initialized) return;
|
||||
|
||||
// 每 5 秒刷新一次 (防止 OLED 烧屏,也避免 SPI 占用过多)
|
||||
if (millis() - _lastDrawTime < 5000) return;
|
||||
_lastDrawTime = millis();
|
||||
|
||||
_u8g2.clearBuffer();
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
_u8g2.sendBuffer();
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 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);
|
||||
|
||||
// 温湿度
|
||||
_u8g2.setFont(u8g2_font_wqy12_t_gb2312);
|
||||
_u8g2.drawUTF8(4, 30, "温度:");
|
||||
_u8g2.setCursor(52, 30);
|
||||
if (data.dhtValid) {
|
||||
_u8g2.printf("%s°C", _fmtFloat(data.temp).c_str());
|
||||
} else {
|
||||
_u8g2.print("-- °C");
|
||||
}
|
||||
|
||||
_u8g2.drawUTF8(4, 44, "湿度:");
|
||||
_u8g2.setCursor(52, 44);
|
||||
if (data.dhtValid) {
|
||||
_u8g2.printf("%s%%", _fmtFloat(data.humi).c_str());
|
||||
} else {
|
||||
_u8g2.print("-- %");
|
||||
}
|
||||
|
||||
// WiFi 状态
|
||||
_u8g2.drawUTF8(4, 58, "WiFi:");
|
||||
_u8g2.setCursor(52, 58);
|
||||
_u8g2.print(wifiOk ? "已连接" : "未连接");
|
||||
|
||||
// 运行时间 + 蜂鸣器状态
|
||||
_u8g2.setFont(u8g2_font_wqy12_t_gb2312);
|
||||
_u8g2.setCursor(2, 64);
|
||||
_u8g2.print(_fmtUptime(data.uptimeSec));
|
||||
|
||||
if (buzzerOn) {
|
||||
_u8g2.drawUTF8(104, 64, "🔔");
|
||||
}
|
||||
|
||||
// 页码
|
||||
_u8g2.setCursor(110, 12);
|
||||
_u8g2.print("1/4");
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Page 1: 生命体征
|
||||
// ============================================================
|
||||
|
||||
void DisplayManager::_drawPage1(const TelemetryData& data) {
|
||||
// 标题栏
|
||||
_u8g2.setFont(u8g2_font_wqy12_t_gb2312);
|
||||
_u8g2.drawUTF8(2, 12, "🫀 生命体征");
|
||||
_u8g2.drawHLine(0, 15, 128);
|
||||
|
||||
// 心率
|
||||
_u8g2.drawUTF8(4, 32, "心率:");
|
||||
_u8g2.setCursor(52, 32);
|
||||
if (data.vitalValid && data.heartRate > 0) {
|
||||
_u8g2.printf("%d bpm", data.heartRate);
|
||||
} else {
|
||||
_u8g2.print("-- bpm");
|
||||
}
|
||||
|
||||
// 呼吸
|
||||
_u8g2.drawUTF8(4, 48, "呼吸:");
|
||||
_u8g2.setCursor(52, 48);
|
||||
if (data.vitalValid && data.breathRate > 0) {
|
||||
_u8g2.printf("%d 次/分", data.breathRate);
|
||||
} else {
|
||||
_u8g2.print("-- 次/分");
|
||||
}
|
||||
|
||||
// 体动/能量值
|
||||
_u8g2.drawUTF8(4, 64, "体动:");
|
||||
_u8g2.setCursor(52, 64);
|
||||
_u8g2.printf("%d", data.body);
|
||||
|
||||
// 页码
|
||||
_u8g2.setCursor(110, 12);
|
||||
_u8g2.print("2/4");
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Page 2: 人体状态
|
||||
// ============================================================
|
||||
|
||||
void DisplayManager::_drawPage2(const TelemetryData& data) {
|
||||
_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");
|
||||
|
||||
_u8g2.drawUTF8(90, 30, "运动:");
|
||||
_u8g2.drawUTF8(120, 30, data.motion ? "V" : "X");
|
||||
|
||||
_u8g2.drawUTF8(4, 44, "跌倒:");
|
||||
_u8g2.drawUTF8(60, 44, data.fall ? "!!" : "X");
|
||||
|
||||
_u8g2.drawUTF8(90, 44, "驻留:");
|
||||
_u8g2.drawUTF8(120, 44, data.resident ? "V" : "X");
|
||||
|
||||
_u8g2.drawUTF8(4, 58, "体动:");
|
||||
_u8g2.setCursor(60, 58);
|
||||
_u8g2.print(data.body);
|
||||
|
||||
_u8g2.drawUTF8(110, 12, "3/4");
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 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);
|
||||
|
||||
_u8g2.drawUTF8(4, 30, "水浸:");
|
||||
_u8g2.drawUTF8(60, 30, data.rainDO ? "正常" : "!!报警");
|
||||
|
||||
_u8g2.drawUTF8(4, 44, "蜂鸣器:");
|
||||
_u8g2.drawUTF8(60, 44, buzzerOn ? "响" : "关");
|
||||
|
||||
_u8g2.drawUTF8(4, 58, "WiFi:");
|
||||
_u8g2.drawUTF8(60, 58, wifiOk ? "已连接" : "未连接");
|
||||
|
||||
_u8g2.drawUTF8(2, 64, _fmtUptime(data.uptimeSec).c_str());
|
||||
_u8g2.drawUTF8(110, 12, "4/4");
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 辅助方法
|
||||
// ============================================================
|
||||
|
||||
String DisplayManager::_fmtUptime(uint32_t secs) const {
|
||||
uint32_t h = secs / 3600;
|
||||
uint32_t m = (secs % 3600) / 60;
|
||||
uint32_t s = secs % 60;
|
||||
char buf[12];
|
||||
snprintf(buf, sizeof(buf), "%02d:%02d:%02d", (int)h, (int)m, (int)s);
|
||||
return String(buf);
|
||||
}
|
||||
|
||||
String DisplayManager::_fmtFloat(float val) const {
|
||||
char buf[8];
|
||||
snprintf(buf, sizeof(buf), "%.1f", val);
|
||||
return String(buf);
|
||||
}
|
||||
463
src/main.cpp
Normal file
463
src/main.cpp
Normal file
@@ -0,0 +1,463 @@
|
||||
/**
|
||||
* @file main.cpp
|
||||
* @brief 五劳无感康养监测系统 - 主程序 (协议 V0.08)
|
||||
*
|
||||
* ESP32-S3 + R60ABD1 60G 毫米波雷达 + DHT11 + 水浸
|
||||
* 本地紧急报警 (离线可用) + 服务端告警联动
|
||||
*/
|
||||
|
||||
#include <Arduino.h>
|
||||
#include <WiFi.h>
|
||||
#include "config.h"
|
||||
#include "radar_protocol.h"
|
||||
#include "radar_manager.h"
|
||||
#include "wifi_manager.h"
|
||||
#include "mqtt_manager.h"
|
||||
#include "sensor_manager.h"
|
||||
#include "alarm_manager.h"
|
||||
#include "display_manager.h"
|
||||
#include "button_manager.h"
|
||||
|
||||
// ============================================================
|
||||
// 全局对象
|
||||
// ============================================================
|
||||
|
||||
RadarManager g_radar;
|
||||
WiFiMgmt g_wifi;
|
||||
MQTTManager g_mqtt;
|
||||
SensorManager g_sensor;
|
||||
AlarmManager g_alarm;
|
||||
DisplayManager g_display;
|
||||
ButtonManager g_buttons;
|
||||
|
||||
// 上报控制
|
||||
uint32_t g_lastReportTime = 0;
|
||||
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;
|
||||
|
||||
// ============================================================
|
||||
// 时间同步
|
||||
// ============================================================
|
||||
|
||||
void syncTime() {
|
||||
Serial.println(F("[时间] 正在同步时间..."));
|
||||
configTime(NTP_GMT_OFFSET_SEC, NTP_DAYLIGHT_OFFSET, NTP_SERVER, "pool.ntp.org");
|
||||
|
||||
struct tm timeinfo;
|
||||
int retry = 0;
|
||||
while (!getLocalTime(&timeinfo) && retry < 20) {
|
||||
delay(500);
|
||||
Serial.print(".");
|
||||
retry++;
|
||||
}
|
||||
|
||||
if (retry < 20) {
|
||||
Serial.println();
|
||||
Serial.printf("[时间] ✅ 时间同步成功: %04d-%02d-%02d %02d:%02d:%02d\n",
|
||||
timeinfo.tm_year + 1900, timeinfo.tm_mon + 1,
|
||||
timeinfo.tm_mday, timeinfo.tm_hour,
|
||||
timeinfo.tm_min, timeinfo.tm_sec);
|
||||
} else {
|
||||
Serial.println(F("\n[时间] ⚠️ 时间同步失败, 使用默认时间"));
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// MQTT 告警指令回调 (协议规范 §5.1, §5.2)
|
||||
// ============================================================
|
||||
|
||||
void handleAlarmCommand(const AlarmCommand& cmd) {
|
||||
if (strcmp(cmd.type, "alarm_restore") == 0) {
|
||||
// 告警恢复
|
||||
g_alarm.executeRestore(cmd);
|
||||
|
||||
// 清除对应本地告警状态
|
||||
if (strcmp(cmd.alarmType, "fall") == 0) g_localFallActive = false;
|
||||
else if (strcmp(cmd.alarmType, "water_leak") == 0) g_localWaterActive = false;
|
||||
else if (strcmp(cmd.alarmType, "heart_abnormal") == 0) g_localHeartAbnActive = false;
|
||||
else if (strcmp(cmd.alarmType, "breath_abnormal") == 0) g_localBreathAbnActive = false;
|
||||
} else {
|
||||
// 告警触发
|
||||
g_alarm.executeAlarm(cmd);
|
||||
}
|
||||
|
||||
// 回复响应 (§4.5)
|
||||
g_mqtt.publishCmdResponse(cmd.cmdId, cmd.type, cmd.alarmType,
|
||||
true, "0", "");
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// MQTT 控制指令回调 (§5.3)
|
||||
// ============================================================
|
||||
|
||||
void handleControlCommand(const ControlCommand& cmd) {
|
||||
Serial.printf("[控制] 收到控制指令: %s\n", cmd.controlType);
|
||||
|
||||
bool success = true;
|
||||
const char* errCode = "0";
|
||||
const char* errMsg = "";
|
||||
|
||||
if (strcmp(cmd.controlType, "buzzer_test") == 0) {
|
||||
uint32_t dur = cmd.params_duration > 0 ? cmd.params_duration : 3;
|
||||
g_alarm.buzzerTest(dur);
|
||||
}
|
||||
else if (strcmp(cmd.controlType, "device_restart") == 0) {
|
||||
Serial.println(F("[控制] 🔄 远程重启..."));
|
||||
g_mqtt.publishCmdResponse(cmd.cmdId, "control", "", true, "0", "");
|
||||
g_mqtt.publishOffline();
|
||||
delay(500);
|
||||
ESP.restart();
|
||||
}
|
||||
else if (strcmp(cmd.controlType, "factory_reset") == 0) {
|
||||
Serial.println(F("[控制] 🔄 恢复出厂设置..."));
|
||||
g_wifi.resetSettings();
|
||||
}
|
||||
else if (strcmp(cmd.controlType, "enter_provision") == 0) {
|
||||
Serial.println(F("[控制] 📶 进入配网模式"));
|
||||
g_wifi.startConfigPortal();
|
||||
}
|
||||
else {
|
||||
success = false;
|
||||
errCode = "2001";
|
||||
errMsg = "不支持的指令类型";
|
||||
}
|
||||
|
||||
g_mqtt.publishCmdResponse(cmd.cmdId, "control", "",
|
||||
success, errCode, errMsg);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// MQTT 配网指令回调 (§5.4)
|
||||
// ============================================================
|
||||
|
||||
void handleProvisionCommand(const ProvisionCommand& cmd) {
|
||||
Serial.printf("[配网] 📶 收到配网指令: SSID=%s\n", cmd.wifi_ssid);
|
||||
|
||||
if (strlen(cmd.wifi_ssid) == 0) {
|
||||
g_mqtt.publishProvisionResponse(cmd.cmdId, false,
|
||||
"1002", "未提供WiFi SSID");
|
||||
return;
|
||||
}
|
||||
|
||||
// 连接新 WiFi
|
||||
g_mqtt.publishProvisionStatus("wifi_connecting", "");
|
||||
|
||||
WiFi.begin(cmd.wifi_ssid, cmd.wifi_password);
|
||||
int retry = 0;
|
||||
while (WiFi.status() != WL_CONNECTED && retry < 30) {
|
||||
delay(500);
|
||||
Serial.print(".");
|
||||
retry++;
|
||||
}
|
||||
|
||||
if (WiFi.status() == WL_CONNECTED) {
|
||||
Serial.printf("\n[配网] ✅ WiFi 已连接, IP=%s\n",
|
||||
WiFi.localIP().toString().c_str());
|
||||
|
||||
// 更新 MQTT 服务器配置
|
||||
if (strlen(cmd.mqtt_broker) > 0) {
|
||||
g_mqtt.setServer(cmd.mqtt_broker, cmd.mqtt_port,
|
||||
GATEID_DEFAULT, GATEID_DEFAULT); // 正式账号 = gateid
|
||||
}
|
||||
|
||||
char signal[8];
|
||||
snprintf(signal, sizeof(signal), "%d", WiFi.RSSI());
|
||||
g_mqtt.publishProvisionResponse(cmd.cmdId, true, "0", "", signal);
|
||||
|
||||
// 重连 MQTT
|
||||
g_mqtt.disconnect();
|
||||
g_mqtt.connect();
|
||||
g_mqtt.publishOnline();
|
||||
} else {
|
||||
Serial.println(F("\n[配网] ❌ WiFi 连接失败"));
|
||||
g_mqtt.publishProvisionStatus("wifi_failed", "");
|
||||
g_mqtt.publishProvisionResponse(cmd.cmdId, false,
|
||||
"1003", "WiFi连接超时");
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 构建遥测数据
|
||||
// ============================================================
|
||||
|
||||
TelemetryData buildTelemetry(const RadarRawData& radar) {
|
||||
TelemetryData t;
|
||||
memset(&t, 0, sizeof(t));
|
||||
|
||||
// 设备标识
|
||||
strncpy(t.saleid, SALEID_DEFAULT, sizeof(t.saleid) - 1);
|
||||
strncpy(t.gateid, g_mqtt.getGateId(), sizeof(t.gateid) - 1);
|
||||
strncpy(t.meterId, METER_ID_DEFAULT, sizeof(t.meterId) - 1);
|
||||
strncpy(t.meterName, METER_NAME_DEFAULT, sizeof(t.meterName) - 1);
|
||||
|
||||
// DHT11
|
||||
t.temp = g_sensor.getTemp();
|
||||
t.humi = g_sensor.getHumi();
|
||||
t.dhtValid = g_sensor.isDhtValid();
|
||||
|
||||
// 雷达
|
||||
t.presence = radar.presence;
|
||||
t.motion = radar.motion;
|
||||
t.fall = radar.fall;
|
||||
t.resident = radar.resident;
|
||||
t.heartRate = radar.heartRate;
|
||||
t.breathRate = radar.breathRate;
|
||||
t.body = radar.body;
|
||||
t.radarValid = radar.radarValid;
|
||||
t.vitalValid = radar.vitalValid;
|
||||
|
||||
// 水浸
|
||||
t.rainDO = g_sensor.getRainDO();
|
||||
t.rainValid = g_sensor.isRainValid();
|
||||
|
||||
// 设备状态
|
||||
t.wifiReady = g_wifi.isConnected() && g_mqtt.isConnected();
|
||||
t.uptimeSec = (millis() - g_bootTime) / 1000;
|
||||
t.buzzerState = g_alarm.isBuzzerOn();
|
||||
t.lastUpdate = millis();
|
||||
|
||||
return t;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 本地报警检查 (离线可用)
|
||||
// ============================================================
|
||||
|
||||
void checkLocalAlarms(const RadarRawData& radar) {
|
||||
uint32_t now = millis();
|
||||
|
||||
// --- 跌倒检测 (本地紧急报警, 立即触发) ---
|
||||
if (radar.fall && radar.radarValid && !g_localFallActive) {
|
||||
g_localFallActive = true;
|
||||
g_alarm.triggerLocal(AlarmManager::ALARM_FALL);
|
||||
} else if (!radar.fall && g_localFallActive) {
|
||||
g_localFallActive = false;
|
||||
g_alarm.clearLocal(AlarmManager::ALARM_FALL);
|
||||
}
|
||||
|
||||
// --- 水浸检测 ---
|
||||
bool waterLeak = !g_sensor.getRainDO() && g_sensor.isRainValid();
|
||||
if (waterLeak && !g_localWaterActive) {
|
||||
g_localWaterActive = true;
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
// --- 呼吸异常 ---
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 调试输出
|
||||
// ============================================================
|
||||
|
||||
void printDebug(const TelemetryData& t) {
|
||||
static uint32_t lastPrint = 0;
|
||||
if (millis() - lastPrint < 5000) return;
|
||||
lastPrint = millis();
|
||||
|
||||
Serial.println(F("─── 遥测数据 ───"));
|
||||
Serial.printf(" 环境: T=%.1f°C H=%.1f%% (温湿度:%d)\n",
|
||||
t.temp, t.humi, t.dhtValid);
|
||||
Serial.printf(" 存在:%d 运动:%d 跌倒:%d 驻留:%d (雷达:%d)\n",
|
||||
t.presence, t.motion, t.fall, t.resident, t.radarValid);
|
||||
Serial.printf(" 心率:%d 呼吸:%d (体征:%d)\n",
|
||||
t.heartRate, t.breathRate, t.vitalValid);
|
||||
Serial.printf(" 水浸:%d (水浸有效:%d) | WiFi:%d 蜂鸣器:%d\n",
|
||||
t.rainDO, t.rainValid, t.wifiReady, t.buzzerState);
|
||||
Serial.printf(" 运行:%ds 序号:%d\n", t.uptimeSec, g_sequence);
|
||||
Serial.println(F("─────────────────"));
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 雷达任务 - 核心0 专用
|
||||
// ============================================================
|
||||
|
||||
void radarTask(void* param) {
|
||||
Serial.println(F("[任务] 📡 雷达任务启动 (核心0)"));
|
||||
TickType_t lastWake = xTaskGetTickCount();
|
||||
const TickType_t freq = pdMS_TO_TICKS(10);
|
||||
|
||||
while (true) {
|
||||
g_radar.loop();
|
||||
vTaskDelayUntil(&lastWake, freq);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Setup
|
||||
// ============================================================
|
||||
|
||||
void setup() {
|
||||
Serial.begin(115200);
|
||||
delay(500);
|
||||
|
||||
Serial.println(F("\n"));
|
||||
Serial.println(F("╔══════════════════════════════════════════╗"));
|
||||
Serial.println(F("║ 🏥 五劳无感康养监测系统 v1.0.0 ║"));
|
||||
Serial.println(F("║ ESP32-S3 + R60ABD1 + DHT11 + 水浸 ║"));
|
||||
Serial.println(F("║ MQTT 协议 V0.08 ║"));
|
||||
Serial.println(F("╚══════════════════════════════════════════╝"));
|
||||
|
||||
g_bootTime = millis();
|
||||
|
||||
// 1. 报警模块 (最先, 引脚安全)
|
||||
g_alarm.begin();
|
||||
|
||||
// 2. 雷达
|
||||
g_radar.begin();
|
||||
|
||||
// 3. OLED 显示
|
||||
g_display.begin();
|
||||
|
||||
// 4. 按钮
|
||||
g_buttons.begin();
|
||||
g_buttons.onPageSwitch([]() {
|
||||
g_display.nextPage();
|
||||
});
|
||||
g_buttons.onAlarmDismiss([]() {
|
||||
g_alarm.clearAllLocal();
|
||||
});
|
||||
g_buttons.onProvisionLongPress([]() {
|
||||
g_wifi.startConfigPortal();
|
||||
});
|
||||
|
||||
// 5. WiFi (WiFiManager 配网)
|
||||
g_wifi.begin();
|
||||
|
||||
// 6. 时间同步
|
||||
if (g_wifi.isConnected()) {
|
||||
syncTime();
|
||||
}
|
||||
|
||||
// 7. MQTT
|
||||
DeviceConfig cfg = g_wifi.getConfig();
|
||||
g_mqtt.setServer(cfg.mqtt_server, atoi(cfg.mqtt_port),
|
||||
cfg.mqtt_user, cfg.mqtt_pass);
|
||||
g_mqtt.begin(cfg.device_id);
|
||||
|
||||
// 注册 MQTT 回调
|
||||
g_mqtt.onAlarmCommand(handleAlarmCommand);
|
||||
g_mqtt.onControlCommand(handleControlCommand);
|
||||
g_mqtt.onProvisionCommand(handleProvisionCommand);
|
||||
|
||||
// 连接 MQTT
|
||||
if (g_wifi.isConnected()) {
|
||||
g_mqtt.connect();
|
||||
}
|
||||
|
||||
// 8. 传感器
|
||||
g_sensor.begin();
|
||||
|
||||
// 9. 雷达任务 (核心0)
|
||||
xTaskCreatePinnedToCore(
|
||||
radarTask, "RadarTask",
|
||||
RADAR_TASK_STACK, nullptr,
|
||||
RADAR_TASK_PRIORITY, nullptr,
|
||||
RADAR_TASK_CORE
|
||||
);
|
||||
|
||||
Serial.println(F("[系统] ✅ 系统就绪"));
|
||||
Serial.println(F("══════════════════════════════════════════\n"));
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 主循环 (核心1)
|
||||
// ============================================================
|
||||
|
||||
void loop() {
|
||||
uint32_t now = millis();
|
||||
|
||||
// --- 0. 按钮轮询 ---
|
||||
g_buttons.loop();
|
||||
|
||||
// --- 1. WiFi ---
|
||||
bool wifiOk = g_wifi.loop();
|
||||
|
||||
// --- 2. MQTT (含自动重连) ---
|
||||
if (wifiOk) {
|
||||
g_mqtt.loop();
|
||||
|
||||
// WiFi 刚连上但 MQTT 还没连
|
||||
static bool wasDisconnected = true;
|
||||
if (g_mqtt.isConnected()) {
|
||||
if (wasDisconnected) {
|
||||
// 刚恢复连接, 同步时间
|
||||
syncTime();
|
||||
wasDisconnected = false;
|
||||
}
|
||||
} else {
|
||||
wasDisconnected = true;
|
||||
}
|
||||
}
|
||||
|
||||
// --- 3. 传感器 (独立,任何传感器故障不影响其他) ---
|
||||
g_sensor.read();
|
||||
|
||||
// --- 4. 本地报警检查 (离线也工作) ---
|
||||
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 未检测到 (不影响运行)"));
|
||||
}
|
||||
if (g_radar.isTimeout()) {
|
||||
Serial.println(F("[系统] ⚠️ 雷达超时无数据 (不影响运行)"));
|
||||
}
|
||||
}
|
||||
|
||||
// --- 5. 报警模块更新 (定时器/闪烁) ---
|
||||
g_alarm.loop();
|
||||
|
||||
// --- 6. 遥测上报 (1秒周期) ---
|
||||
if (now - g_lastReportTime >= REPORT_INTERVAL_MS) {
|
||||
g_lastReportTime = now;
|
||||
|
||||
TelemetryData tele = buildTelemetry(radar);
|
||||
g_mqtt.publishAllPoints(tele, g_sequence);
|
||||
g_sequence++;
|
||||
|
||||
// 调试输出 + OLED 显示
|
||||
printDebug(tele);
|
||||
g_display.update(tele, wifiOk, g_alarm.isBuzzerOn());
|
||||
}
|
||||
|
||||
// --- 7. MQTT 配网状态 (WiFiManager 触发时) ---
|
||||
// WiFiManager 的 captive portal 启动/关闭时更新状态
|
||||
// 此逻辑已集成在 wifi_manager 中
|
||||
|
||||
delay(10);
|
||||
}
|
||||
552
src/mqtt_manager.cpp
Normal file
552
src/mqtt_manager.cpp
Normal file
@@ -0,0 +1,552 @@
|
||||
/**
|
||||
* @file mqtt_manager.cpp
|
||||
* @brief MQTT 通信管理器实现 (协议规范 V0.08)
|
||||
*/
|
||||
|
||||
#include "mqtt_manager.h"
|
||||
|
||||
MQTTManager* MQTTManager::_instance = nullptr;
|
||||
|
||||
// ============================================================
|
||||
// 构造函数 & 析构函数
|
||||
// ============================================================
|
||||
|
||||
MQTTManager::MQTTManager()
|
||||
: _mqtt(_wifiClient)
|
||||
, _port(1883)
|
||||
, _reconnectInterval(MQTT_RECONNECT_MIN)
|
||||
, _lastReconnectAttempt(0)
|
||||
, _connected(false)
|
||||
, _alarmCb(nullptr)
|
||||
, _controlCb(nullptr)
|
||||
, _provisionCb(nullptr)
|
||||
{
|
||||
memset(_gateid, 0, sizeof(_gateid));
|
||||
memset(_server, 0, sizeof(_server));
|
||||
memset(_user, 0, sizeof(_user));
|
||||
memset(_pass, 0, sizeof(_pass));
|
||||
_instance = this;
|
||||
}
|
||||
|
||||
MQTTManager::~MQTTManager() {
|
||||
disconnect();
|
||||
_instance = nullptr;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 初始化
|
||||
// ============================================================
|
||||
|
||||
bool MQTTManager::begin(const char* gateid) {
|
||||
strncpy(_gateid, gateid, sizeof(_gateid) - 1);
|
||||
|
||||
Serial.println(F("[MQTT] 初始化 (协议 V0.08)"));
|
||||
Serial.printf(" 网关ID: %s\n", _gateid);
|
||||
Serial.printf(" 服务器: %s:%d\n", _server, _port);
|
||||
|
||||
_mqtt.setServer(_server, _port);
|
||||
_mqtt.setCallback(_mqttCallback);
|
||||
_mqtt.setKeepAlive(MQTT_KEEPALIVE);
|
||||
_mqtt.setBufferSize(1024); // JSON 报文较大
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 设置服务器
|
||||
// ============================================================
|
||||
|
||||
void MQTTManager::setServer(const char* host, uint16_t port,
|
||||
const char* user, const char* pass) {
|
||||
strncpy(_server, host, sizeof(_server) - 1);
|
||||
_port = port;
|
||||
strncpy(_user, user, sizeof(_user) - 1);
|
||||
strncpy(_pass, pass, sizeof(_pass) - 1);
|
||||
_mqtt.setServer(_server, _port);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 连接 & 断开
|
||||
// ============================================================
|
||||
|
||||
bool MQTTManager::connect() {
|
||||
if (_connected && _mqtt.connected()) return true;
|
||||
if (!WiFi.isConnected()) return false;
|
||||
|
||||
Serial.printf("[MQTT] 连接 %s:%d ...\n", _server, _port);
|
||||
|
||||
// Client ID = gateid
|
||||
String clientId = _gateid;
|
||||
|
||||
// 遗嘱消息 (§4.3): status/{gateid}/offline
|
||||
String willTopic = _buildTopic(MQTT_TOPIC_STATUS_OFFLINE);
|
||||
JsonDocument willDoc;
|
||||
willDoc["gateid"] = _gateid;
|
||||
willDoc["type"] = "offline";
|
||||
willDoc["time"] = ""; // Broker 不会填充时间
|
||||
String willPayload;
|
||||
serializeJson(willDoc, willPayload);
|
||||
|
||||
bool hasAuth = (strlen(_user) > 0);
|
||||
bool ok = false;
|
||||
|
||||
if (hasAuth) {
|
||||
ok = _mqtt.connect(clientId.c_str(),
|
||||
_user, _pass,
|
||||
willTopic.c_str(), 1, true, willPayload.c_str());
|
||||
} else {
|
||||
ok = _mqtt.connect(clientId.c_str(),
|
||||
willTopic.c_str(), 1, true, willPayload.c_str());
|
||||
}
|
||||
|
||||
if (ok) {
|
||||
_connected = true;
|
||||
Serial.println(F("[MQTT] ✅ 已连接!"));
|
||||
|
||||
// 订阅下行主题 (§3.2)
|
||||
String subAlarm = _buildTopic(MQTT_TOPIC_CMD_ALARM);
|
||||
String subRestore = _buildTopic(MQTT_TOPIC_CMD_ALARM_RESTORE);
|
||||
String subControl = _buildTopic(MQTT_TOPIC_CMD_CONTROL);
|
||||
String subProv = _buildTopic(MQTT_TOPIC_CMD_PROVISION_WIFI);
|
||||
|
||||
_mqtt.subscribe(subAlarm.c_str(), 1);
|
||||
_mqtt.subscribe(subRestore.c_str(), 1);
|
||||
_mqtt.subscribe(subControl.c_str(), 1);
|
||||
_mqtt.subscribe(subProv.c_str(), 1);
|
||||
|
||||
Serial.printf(" +订阅: %s\n", subAlarm.c_str());
|
||||
Serial.printf(" +订阅: %s\n", subRestore.c_str());
|
||||
Serial.printf(" +订阅: %s\n", subControl.c_str());
|
||||
Serial.printf(" +订阅: %s\n", subProv.c_str());
|
||||
} else {
|
||||
_connected = false;
|
||||
Serial.printf("[MQTT] ❌ 连接失败, rc=%d\n", _mqtt.state());
|
||||
}
|
||||
|
||||
return _connected;
|
||||
}
|
||||
|
||||
void MQTTManager::disconnect() {
|
||||
if (_connected) {
|
||||
// 主动发布离线
|
||||
publishOffline();
|
||||
delay(100);
|
||||
_mqtt.disconnect();
|
||||
_connected = false;
|
||||
}
|
||||
}
|
||||
|
||||
bool MQTTManager::isConnected() {
|
||||
return _connected && _mqtt.connected();
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 主循环
|
||||
// ============================================================
|
||||
|
||||
void MQTTManager::loop() {
|
||||
if (!_connected) {
|
||||
uint32_t now = millis();
|
||||
if (now - _lastReconnectAttempt >= _reconnectInterval) {
|
||||
_lastReconnectAttempt = now;
|
||||
if (connect()) {
|
||||
_reconnectInterval = MQTT_RECONNECT_MIN;
|
||||
publishOnline(); // 上线通知
|
||||
} else {
|
||||
_reconnectInterval = min(_reconnectInterval * 2,
|
||||
(uint32_t)MQTT_RECONNECT_MAX);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
_mqtt.loop();
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 上行: 全量遥测上报 (§4.1)
|
||||
// ============================================================
|
||||
|
||||
void MQTTManager::publishAllPoints(const TelemetryData& d, uint32_t sequence) {
|
||||
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");
|
||||
}
|
||||
|
||||
// 构建 JSON (协议规范 §4.1)
|
||||
JsonDocument doc;
|
||||
doc["saleid"] = d.saleid;
|
||||
doc["gateid"] = d.gateid;
|
||||
doc["type"] = "report";
|
||||
doc["time"] = timeStr;
|
||||
doc["sequence"] = String(sequence);
|
||||
doc["source"] = "da";
|
||||
|
||||
JsonArray meter = doc["meter"].to<JsonArray>();
|
||||
JsonObject m = meter.add<JsonObject>();
|
||||
m["id"] = d.meterId;
|
||||
m["status"] = "1";
|
||||
m["name"] = d.meterName;
|
||||
|
||||
JsonObject v = m["values"].to<JsonObject>();
|
||||
// 环境数据
|
||||
v["Temp"] = String(d.temp, 1);
|
||||
v["Humi"] = String(d.humi, 1);
|
||||
v["DhtValid"] = d.dhtValid ? "1" : "0";
|
||||
// 雷达存在/运动
|
||||
v["Presence"] = d.presence ? "1" : "0";
|
||||
v["Motion"] = d.motion ? "1" : "0";
|
||||
v["Fall"] = d.fall ? "1" : "0";
|
||||
v["Resident"] = d.resident ? "1" : "0";
|
||||
// 生命体征
|
||||
v["Body"] = String(d.body);
|
||||
v["HeartRate"] = String(d.heartRate);
|
||||
v["BreathRate"] = String(d.breathRate);
|
||||
v["RadarValid"] = d.radarValid ? "1" : "0";
|
||||
v["VitalValid"] = d.vitalValid ? "1" : "0";
|
||||
// 水浸
|
||||
v["RainDO"] = d.rainDO ? "1" : "0";
|
||||
v["RainValid"] = d.rainValid ? "1" : "0";
|
||||
// 设备状态
|
||||
v["WifiReady"] = d.wifiReady ? "1" : "0";
|
||||
v["UptimeSec"] = String(d.uptimeSec);
|
||||
v["BuzzerState"] = d.buzzerState ? "1" : "0";
|
||||
|
||||
// 序列化
|
||||
String payload;
|
||||
serializeJson(doc, payload);
|
||||
|
||||
// 发布到 report/allpoints (QoS 0)
|
||||
_mqtt.publish("report/allpoints", payload.c_str());
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 上行: 上线通知 (§4.2)
|
||||
// ============================================================
|
||||
|
||||
void MQTTManager::publishOnline() {
|
||||
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");
|
||||
}
|
||||
|
||||
JsonDocument doc;
|
||||
doc["gateid"] = _gateid;
|
||||
doc["type"] = "online";
|
||||
doc["time"] = timeStr;
|
||||
doc["firmwareVersion"] = FW_VERSION;
|
||||
doc["wifiSignal"] = String(WiFi.RSSI());
|
||||
|
||||
JsonArray devList = doc["deviceList"].to<JsonArray>();
|
||||
devList.add(METER_ID_DEFAULT);
|
||||
|
||||
String payload;
|
||||
serializeJson(doc, payload);
|
||||
|
||||
_publish(MQTT_TOPIC_STATUS_ONLINE, payload.c_str(), STATUS_QOS);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 上行: 离线通知 (§4.3)
|
||||
// ============================================================
|
||||
|
||||
void MQTTManager::publishOffline() {
|
||||
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");
|
||||
}
|
||||
|
||||
JsonDocument doc;
|
||||
doc["gateid"] = _gateid;
|
||||
doc["type"] = "offline";
|
||||
doc["time"] = timeStr;
|
||||
|
||||
String payload;
|
||||
serializeJson(doc, payload);
|
||||
|
||||
_publish(MQTT_TOPIC_STATUS_OFFLINE, payload.c_str(), STATUS_QOS);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 上行: 配网状态通知 (§4.4)
|
||||
// ============================================================
|
||||
|
||||
void MQTTManager::publishProvisionStatus(const char* state, const char* bleName) {
|
||||
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");
|
||||
}
|
||||
|
||||
JsonDocument doc;
|
||||
doc["gateid"] = _gateid;
|
||||
doc["type"] = "provision_status";
|
||||
doc["time"] = timeStr;
|
||||
doc["state"] = state;
|
||||
doc["bleName"] = bleName ? bleName : "";
|
||||
|
||||
String payload;
|
||||
serializeJson(doc, payload);
|
||||
|
||||
_publish(MQTT_TOPIC_STATUS_PROVISION, payload.c_str(), STATUS_QOS);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 上行: 指令执行响应 (§4.5)
|
||||
// ============================================================
|
||||
|
||||
void MQTTManager::publishCmdResponse(const char* cmdId, const char* cmdType,
|
||||
const char* alarmType, bool success,
|
||||
const char* errorCode, const char* errorMsg) {
|
||||
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");
|
||||
}
|
||||
|
||||
JsonDocument doc;
|
||||
doc["gateid"] = _gateid;
|
||||
doc["type"] = "cmd_response";
|
||||
doc["time"] = timeStr;
|
||||
doc["cmdId"] = cmdId;
|
||||
doc["cmdType"] = cmdType; // alarm / alarm_restore / control
|
||||
doc["alarmType"] = alarmType ? alarmType : "";
|
||||
doc["result"] = success ? "success" : "fail";
|
||||
doc["errorCode"] = errorCode;
|
||||
doc["errorMsg"] = errorMsg;
|
||||
|
||||
String payload;
|
||||
serializeJson(doc, payload);
|
||||
|
||||
_publish(MQTT_TOPIC_RESPONSE_CMD, payload.c_str(), STATUS_QOS);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 上行: 配网指令响应 (§4.6)
|
||||
// ============================================================
|
||||
|
||||
void MQTTManager::publishProvisionResponse(const char* cmdId, bool success,
|
||||
const char* errorCode, const char* errorMsg,
|
||||
const char* wifiSignal) {
|
||||
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");
|
||||
}
|
||||
|
||||
JsonDocument doc;
|
||||
doc["gateid"] = _gateid;
|
||||
doc["type"] = "provision_response";
|
||||
doc["time"] = timeStr;
|
||||
doc["cmdId"] = cmdId;
|
||||
doc["result"] = success ? "success" : "fail";
|
||||
doc["errorCode"] = errorCode;
|
||||
doc["errorMsg"] = errorMsg;
|
||||
if (success && wifiSignal && strlen(wifiSignal) > 0) {
|
||||
doc["wifiSignal"] = wifiSignal;
|
||||
}
|
||||
|
||||
String payload;
|
||||
serializeJson(doc, payload);
|
||||
|
||||
_publish(MQTT_TOPIC_RESPONSE_PROVISION, payload.c_str(), STATUS_QOS);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 回调注册
|
||||
// ============================================================
|
||||
|
||||
void MQTTManager::onAlarmCommand(MQTTAlarmCallback cb) { _alarmCb = cb; }
|
||||
void MQTTManager::onControlCommand(MQTTControlCallback cb) { _controlCb = cb; }
|
||||
void MQTTManager::onProvisionCommand(MQTTProvisionCallback cb) { _provisionCb = cb; }
|
||||
|
||||
// ============================================================
|
||||
// 静态 MQTT 回调
|
||||
// ============================================================
|
||||
|
||||
void MQTTManager::_mqttCallback(char* topic, byte* payload, unsigned int length) {
|
||||
char msg[512];
|
||||
unsigned int l = min(length, (unsigned int)(sizeof(msg) - 1));
|
||||
memcpy(msg, payload, l);
|
||||
msg[l] = '\0';
|
||||
|
||||
Serial.printf("[MQTT] 📩 [%s]\n", topic);
|
||||
// Serial.println(msg); // 完整打印在 debug 时打开
|
||||
|
||||
if (_instance) {
|
||||
_instance->_handleMessage(topic, msg);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 下行报文路由
|
||||
// ============================================================
|
||||
|
||||
void MQTTManager::_handleMessage(const char* topic, const char* payload) {
|
||||
// 判断 topic 属于哪种指令
|
||||
String t(topic);
|
||||
|
||||
if (t.indexOf("/alarm/restore") > 0) {
|
||||
_parseAlarm(payload, true);
|
||||
} else if (t.indexOf("/alarm") > 0) {
|
||||
_parseAlarm(payload, false);
|
||||
} else if (t.indexOf("/control") > 0) {
|
||||
_parseControl(payload);
|
||||
} else if (t.indexOf("/provision/wifi") > 0) {
|
||||
_parseProvision(payload);
|
||||
} else {
|
||||
Serial.printf("[MQTT] ⚠️ 未识别的主题: %s\n", topic);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 解析告警指令 (§5.1, §5.2)
|
||||
// ============================================================
|
||||
|
||||
void MQTTManager::_parseAlarm(const char* payload, bool isRestore) {
|
||||
JsonDocument doc;
|
||||
DeserializationError err = deserializeJson(doc, payload);
|
||||
if (err) {
|
||||
Serial.printf("[MQTT] 告警指令 JSON 解析失败: %s\n", err.c_str());
|
||||
return;
|
||||
}
|
||||
|
||||
AlarmCommand cmd;
|
||||
memset(&cmd, 0, sizeof(cmd));
|
||||
|
||||
strncpy(cmd.cmdId, doc["cmdId"] | "", sizeof(cmd.cmdId) - 1);
|
||||
strncpy(cmd.type, doc["type"] | (isRestore ? "alarm_restore" : "alarm"), sizeof(cmd.type) - 1);
|
||||
strncpy(cmd.alarmType, doc["alarmType"] | "", sizeof(cmd.alarmType) - 1);
|
||||
strncpy(cmd.alarmLevel, doc["alarmLevel"] | "medium", sizeof(cmd.alarmLevel) - 1);
|
||||
strncpy(cmd.alarmDesc, doc["alarmDesc"] | "", sizeof(cmd.alarmDesc) - 1);
|
||||
strncpy(cmd.meterId, doc["meterId"] | "", sizeof(cmd.meterId) - 1);
|
||||
strncpy(cmd.relatedCmdId, doc["relatedCmdId"] | "", sizeof(cmd.relatedCmdId) - 1);
|
||||
|
||||
JsonObject action = doc["action"];
|
||||
if (!action.isNull()) {
|
||||
cmd.buzzerOn = action["buzzer"] | false;
|
||||
cmd.buzzerDuration = action["buzzerDuration"] | 0;
|
||||
cmd.nightLight = action["nightLight"] | false;
|
||||
}
|
||||
cmd.timestamp = millis();
|
||||
|
||||
Serial.printf("[MQTT] 🚨 告警指令: type=%s alarmType=%s buzzer=%d\n",
|
||||
cmd.type, cmd.alarmType, cmd.buzzerOn);
|
||||
|
||||
if (_alarmCb) {
|
||||
_alarmCb(cmd);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 解析控制指令 (§5.3)
|
||||
// ============================================================
|
||||
|
||||
void MQTTManager::_parseControl(const char* payload) {
|
||||
JsonDocument doc;
|
||||
DeserializationError err = deserializeJson(doc, payload);
|
||||
if (err) {
|
||||
Serial.printf("[MQTT] 控制指令 JSON 解析失败: %s\n", err.c_str());
|
||||
return;
|
||||
}
|
||||
|
||||
ControlCommand cmd;
|
||||
memset(&cmd, 0, sizeof(cmd));
|
||||
|
||||
strncpy(cmd.cmdId, doc["cmdId"] | "", sizeof(cmd.cmdId) - 1);
|
||||
strncpy(cmd.type, doc["type"] | "control", sizeof(cmd.type) - 1);
|
||||
strncpy(cmd.controlType, doc["controlType"] | "", sizeof(cmd.controlType) - 1);
|
||||
|
||||
JsonObject params = doc["params"];
|
||||
if (!params.isNull()) {
|
||||
cmd.params_duration = params["duration"] | 0;
|
||||
cmd.params_interval = params["interval"] | 0;
|
||||
}
|
||||
cmd.timestamp = millis();
|
||||
|
||||
Serial.printf("[MQTT] 🎛️ 控制指令: %s\n", cmd.controlType);
|
||||
|
||||
if (_controlCb) {
|
||||
_controlCb(cmd);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 解析配网指令 (§5.4)
|
||||
// ============================================================
|
||||
|
||||
void MQTTManager::_parseProvision(const char* payload) {
|
||||
JsonDocument doc;
|
||||
DeserializationError err = deserializeJson(doc, payload);
|
||||
if (err) {
|
||||
Serial.printf("[MQTT] 配网指令 JSON 解析失败: %s\n", err.c_str());
|
||||
return;
|
||||
}
|
||||
|
||||
ProvisionCommand cmd;
|
||||
memset(&cmd, 0, sizeof(cmd));
|
||||
|
||||
strncpy(cmd.cmdId, doc["cmdId"] | "", sizeof(cmd.cmdId) - 1);
|
||||
|
||||
JsonObject wifi = doc["wifi"];
|
||||
if (!wifi.isNull()) {
|
||||
strncpy(cmd.wifi_ssid, wifi["ssid"] | "", sizeof(cmd.wifi_ssid) - 1);
|
||||
strncpy(cmd.wifi_password, wifi["password"] | "", sizeof(cmd.wifi_password) - 1);
|
||||
strncpy(cmd.wifi_encrypt, wifi["encryptType"] | "WPA2", sizeof(cmd.wifi_encrypt) - 1);
|
||||
}
|
||||
|
||||
JsonObject mqtt = doc["mqtt"];
|
||||
if (!mqtt.isNull()) {
|
||||
strncpy(cmd.mqtt_broker, mqtt["broker"] | "", sizeof(cmd.mqtt_broker) - 1);
|
||||
cmd.mqtt_port = mqtt["port"] | 1883;
|
||||
cmd.mqtt_useTls = mqtt["useTls"] | false;
|
||||
}
|
||||
cmd.timestamp = millis();
|
||||
|
||||
Serial.printf("[MQTT] 📶 配网指令: SSID=%s Broker=%s:%d\n",
|
||||
cmd.wifi_ssid, cmd.mqtt_broker, cmd.mqtt_port);
|
||||
|
||||
if (_provisionCb) {
|
||||
_provisionCb(cmd);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 辅助方法
|
||||
// ============================================================
|
||||
|
||||
String MQTTManager::_buildTopic(const char* tmpl) const {
|
||||
char buf[128];
|
||||
snprintf(buf, sizeof(buf), tmpl, _gateid);
|
||||
return String(buf);
|
||||
}
|
||||
|
||||
bool MQTTManager::_publish(const char* topicTmpl, const char* payload,
|
||||
uint8_t qos, bool retain) {
|
||||
if (!isConnected()) return false;
|
||||
String topic = _buildTopic(topicTmpl);
|
||||
bool ok = _mqtt.publish(topic.c_str(), payload, retain);
|
||||
if (!ok) {
|
||||
Serial.printf("[MQTT] ⚠️ 发布失败: %s\n", topic.c_str());
|
||||
}
|
||||
return ok;
|
||||
}
|
||||
473
src/radar_manager.cpp
Normal file
473
src/radar_manager.cpp
Normal file
@@ -0,0 +1,473 @@
|
||||
/**
|
||||
* @file radar_manager.cpp
|
||||
* @brief R60ABD1 毫米波雷达管理模块实现
|
||||
*
|
||||
* 状态机逐字节解析 UART 帧, 提取生命体征数据
|
||||
*/
|
||||
|
||||
#include "radar_manager.h"
|
||||
|
||||
// ============================================================
|
||||
// 构造函数 & 析构函数
|
||||
// ============================================================
|
||||
|
||||
RadarManager::RadarManager()
|
||||
: _serial(nullptr)
|
||||
, _state(STATE_IDLE)
|
||||
, _dataIndex(0)
|
||||
, _initialized(false)
|
||||
, _callback(nullptr)
|
||||
, _frameCount(0)
|
||||
, _errorCount(0)
|
||||
{
|
||||
memset(&_currentFrame, 0, sizeof(_currentFrame));
|
||||
memset(&_data, 0, sizeof(_data));
|
||||
}
|
||||
|
||||
RadarManager::~RadarManager() {
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 初始化
|
||||
// ============================================================
|
||||
|
||||
bool RadarManager::begin() {
|
||||
_serial = &RADAR_UART_PORT;
|
||||
_serial->begin(RADAR_BAUD_RATE, SERIAL_8N1,
|
||||
RADAR_RX_PIN, RADAR_TX_PIN);
|
||||
|
||||
// 可选: 初始化雷达 GPIO 作为辅助
|
||||
pinMode(RADAR_GPIO1_PIN, INPUT);
|
||||
pinMode(RADAR_GPIO2_PIN, INPUT);
|
||||
|
||||
_resetParser();
|
||||
memset(&_data, 0, sizeof(_data));
|
||||
_data.lastUpdate = millis();
|
||||
|
||||
// 不给雷达发任何命令,让它自己初始化并开始自动上报
|
||||
// R60ABD1 上电后通常需要 15-30 秒自检,之后自动输出数据
|
||||
Serial.println(F("[雷达] 雷达模块初始化完成, 等待自动上报..."));
|
||||
Serial.println(F("[雷达] (R60ABD1 自检约15-30秒,心率和呼吸需检测到静止人体后输出)"));
|
||||
return true;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 主循环 - 逐字节解析 UART 数据
|
||||
// ============================================================
|
||||
|
||||
void RadarManager::loop() {
|
||||
while (_serial->available() > 0) {
|
||||
uint8_t byte = _serial->read();
|
||||
_parseByte(byte);
|
||||
}
|
||||
|
||||
// 首次收到数据时打印提示
|
||||
static bool firstData = false;
|
||||
if (!firstData && _frameCount > 0) {
|
||||
firstData = true;
|
||||
Serial.printf("[雷达] ✅ 开始接收数据 (首帧)\n");
|
||||
}
|
||||
|
||||
// 超时检测:如果 10 秒还没收到任何数据,打印一次
|
||||
static uint32_t noDataWarn = 0;
|
||||
if (_frameCount == 0 && millis() - _data.lastUpdate > 10000) {
|
||||
if (millis() - noDataWarn > 10000) {
|
||||
noDataWarn = millis();
|
||||
Serial.println(F("[雷达] ⚠️ 10秒内未收到雷达数据, 请检查:"));
|
||||
Serial.println(F("[雷达] 1.雷达供电 5V? 2.TX→GPIO17 RX→GPIO1? 3.交叉了吗?"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 帧解析状态机
|
||||
// ============================================================
|
||||
|
||||
void RadarManager::_parseByte(uint8_t byte) {
|
||||
switch (_state) {
|
||||
|
||||
case STATE_IDLE:
|
||||
// 等待帧头第一个字节 0x53
|
||||
if (byte == FRAME_HEADER_1) {
|
||||
_resetParser();
|
||||
_currentFrame.data[0] = byte;
|
||||
_state = STATE_GOT_HEADER1;
|
||||
}
|
||||
break;
|
||||
|
||||
case STATE_GOT_HEADER1:
|
||||
// 等待帧头第二个字节 0x59
|
||||
if (byte == FRAME_HEADER_2) {
|
||||
_currentFrame.data[1] = byte;
|
||||
_state = STATE_GOT_HEADER2;
|
||||
} else {
|
||||
// 不是 0x59, 重新开始
|
||||
_errorCount++;
|
||||
_state = STATE_IDLE;
|
||||
if (byte == FRAME_HEADER_1) {
|
||||
// 连续 0x53, 重新开始
|
||||
_currentFrame.data[0] = byte;
|
||||
_state = STATE_GOT_HEADER1;
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case STATE_GOT_HEADER2:
|
||||
// 读取控制字
|
||||
_currentFrame.control = byte;
|
||||
_state = STATE_GOT_CONTROL;
|
||||
break;
|
||||
|
||||
case STATE_GOT_CONTROL:
|
||||
// 读取命令字
|
||||
_currentFrame.command = byte;
|
||||
_state = STATE_GOT_COMMAND;
|
||||
break;
|
||||
|
||||
case STATE_GOT_COMMAND:
|
||||
// 读取长度高字节
|
||||
_currentFrame.data_length = ((uint16_t)byte) << 8;
|
||||
_state = STATE_GOT_LEN_H;
|
||||
break;
|
||||
|
||||
case STATE_GOT_LEN_H:
|
||||
// 读取长度低字节
|
||||
_currentFrame.data_length |= byte;
|
||||
|
||||
// 长度安全检查
|
||||
if (_currentFrame.data_length > RADAR_FRAME_BUF_SIZE) {
|
||||
Serial.printf("[雷达] 帧数据长度异常: %d\n",
|
||||
_currentFrame.data_length);
|
||||
_errorCount++;
|
||||
_state = STATE_IDLE;
|
||||
return;
|
||||
}
|
||||
|
||||
_dataIndex = 0;
|
||||
|
||||
// 如果数据长度为 0, 直接跳到校验状态
|
||||
if (_currentFrame.data_length == 0) {
|
||||
_state = STATE_GOT_DATA;
|
||||
} else {
|
||||
_state = STATE_GOT_LEN_L;
|
||||
}
|
||||
break;
|
||||
|
||||
case STATE_GOT_LEN_L:
|
||||
// 读取数据字节
|
||||
_currentFrame.data[_dataIndex++] = byte;
|
||||
|
||||
if (_dataIndex >= _currentFrame.data_length) {
|
||||
_state = STATE_GOT_DATA;
|
||||
}
|
||||
break;
|
||||
|
||||
case STATE_GOT_DATA:
|
||||
// 读取校验码
|
||||
_currentFrame.checksum = byte;
|
||||
_state = STATE_GOT_CHECKSUM;
|
||||
break;
|
||||
|
||||
case STATE_GOT_CHECKSUM:
|
||||
// 等待帧尾第一个字节 0x54
|
||||
if (byte == FRAME_TAIL_1) {
|
||||
_state = STATE_GOT_TAIL1;
|
||||
} else {
|
||||
_errorCount++;
|
||||
_state = STATE_IDLE;
|
||||
}
|
||||
break;
|
||||
|
||||
case STATE_GOT_TAIL1:
|
||||
// 等待帧尾第二个字节 0x43
|
||||
if (byte == FRAME_TAIL_2) {
|
||||
_state = STATE_FRAME_COMPLETE;
|
||||
_currentFrame.timestamp = millis();
|
||||
_processCompleteFrame();
|
||||
_state = STATE_IDLE;
|
||||
} else {
|
||||
_errorCount++;
|
||||
_state = STATE_IDLE;
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
_state = STATE_IDLE;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 帧处理
|
||||
// ============================================================
|
||||
|
||||
void RadarManager::_processCompleteFrame() {
|
||||
_frameCount++;
|
||||
|
||||
// 校验
|
||||
if (!_validateChecksum()) {
|
||||
return;
|
||||
}
|
||||
|
||||
_currentFrame.valid = true;
|
||||
|
||||
// 根据控制字提取数据
|
||||
switch (_currentFrame.control) {
|
||||
case CTRL_PRESENCE:
|
||||
_extractPresence(_currentFrame);
|
||||
break;
|
||||
case CTRL_BREATHING:
|
||||
_extractBreathing(_currentFrame);
|
||||
break;
|
||||
case CTRL_HEART_RATE:
|
||||
_extractHeartRate(_currentFrame);
|
||||
break;
|
||||
case CTRL_SLEEP:
|
||||
_extractSleep(_currentFrame);
|
||||
break;
|
||||
case CTRL_WORK_STATUS:
|
||||
_extractWorkStatus(_currentFrame);
|
||||
break;
|
||||
case CTRL_DETECT_RANGE:
|
||||
_extractDetectRange(_currentFrame);
|
||||
break;
|
||||
default:
|
||||
Serial.printf("[雷达] 未识别控制字=0x%02X 长度=%d\n",
|
||||
_currentFrame.control, _currentFrame.data_length);
|
||||
// 打印数据内容辅助分析
|
||||
Serial.print(" 数据: ");
|
||||
for (int i = 0; i < _currentFrame.data_length && i < 16; i++) {
|
||||
Serial.printf("%02X ", _currentFrame.data[i]);
|
||||
}
|
||||
Serial.println();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 数据提取函数
|
||||
// ============================================================
|
||||
|
||||
void RadarManager::_extractPresence(const RadarFrame& frame) {
|
||||
uint8_t raw = frame.data_length > 0 ? frame.data[0] : 0;
|
||||
|
||||
// cmd 0x00 (len=1): 有人状态 (1=有人 0=无人)
|
||||
if (frame.command == 0x00 && frame.data_length >= 1) {
|
||||
_data.presence = (raw == 1);
|
||||
_data.radarValid = true;
|
||||
_data.lastUpdate = millis();
|
||||
_notifyUpdate();
|
||||
}
|
||||
// cmd 0x01 (len=1): 运动状态/体动 (值越大越活跃)
|
||||
else if (frame.command == 0x01 && frame.data_length >= 1) {
|
||||
_data.body = raw; // 0=静止, 1+=有体动
|
||||
_data.motion = (raw >= 1); // >=1 有运动
|
||||
_data.radarValid = true;
|
||||
_data.lastUpdate = millis();
|
||||
_notifyUpdate();
|
||||
}
|
||||
// cmd 0x03 (len=1): 有人状态备用 (非0=有人)
|
||||
else if (frame.command == 0x03 && frame.data_length >= 1) {
|
||||
_data.presence = (raw != 0);
|
||||
_data.radarValid = true;
|
||||
_data.lastUpdate = millis();
|
||||
_notifyUpdate();
|
||||
}
|
||||
// cmd 0x04 (len=2): [0]=presence [1]=motion
|
||||
else if (frame.command == 0x04 && frame.data_length >= 2) {
|
||||
_data.presence = (frame.data[0] != 0);
|
||||
_data.motion = (frame.data[1] == 0);
|
||||
_data.radarValid = true;
|
||||
_data.lastUpdate = millis();
|
||||
_notifyUpdate();
|
||||
}
|
||||
// cmd 0x05 (len=6): 完整包
|
||||
else if (frame.command == 0x05 && frame.data_length >= 6) {
|
||||
_data.presence = (frame.data[0] != 0);
|
||||
_data.motion = (frame.data[1] == 0);
|
||||
_data.distance = ((uint16_t)frame.data[2] << 8) | frame.data[3];
|
||||
_data.body = frame.data[4];
|
||||
_data.radarValid = true;
|
||||
_data.lastUpdate = millis();
|
||||
_notifyUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
void RadarManager::_extractBreathing(const RadarFrame& frame) {
|
||||
if (frame.data_length >= 1) {
|
||||
uint8_t rate = frame.data[0];
|
||||
if (rate != 0x80 && rate != 0x00 && rate <= 40) {
|
||||
_data.breathRate = rate;
|
||||
_data.vitalValid = true;
|
||||
_data.presence = true; // 有呼吸=有人
|
||||
_data.radarValid = true;
|
||||
}
|
||||
_data.lastUpdate = millis();
|
||||
_notifyUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
void RadarManager::_extractHeartRate(const RadarFrame& frame) {
|
||||
if (frame.data_length >= 1) {
|
||||
uint8_t rate = frame.data[0];
|
||||
if (rate != 0x80 && rate != 0x00 && rate >= 40) {
|
||||
_data.heartRate = rate;
|
||||
_data.vitalValid = true;
|
||||
_data.presence = true; // 有心跳=有人
|
||||
_data.radarValid = true;
|
||||
}
|
||||
_data.lastUpdate = millis();
|
||||
_notifyUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
void RadarManager::_extractSleep(const RadarFrame& frame) {
|
||||
// 从睡眠数据提取: cmd=0x00/0x01 为入床离床状态
|
||||
if (frame.data_length >= 1) {
|
||||
// data[0]: 0=离床/无驻留, 非0=入床/驻留
|
||||
if (frame.command == 0x00 || frame.command == 0x01) {
|
||||
_data.resident = (frame.data[0] != 0);
|
||||
_data.lastUpdate = millis();
|
||||
_notifyUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
if (frame.data_length >= 3) {
|
||||
// data[0]: 入床/离床
|
||||
// data[1]: 睡眠状态
|
||||
// data[2]: 评分
|
||||
|
||||
// 跌倒检测: 可能在睡眠异常标志中
|
||||
if (frame.data_length >= 4) {
|
||||
_data.fall = (frame.data[3] & 0x01) != 0;
|
||||
_data.resident = (frame.data[3] & 0x02) != 0;
|
||||
}
|
||||
|
||||
_data.lastUpdate = millis();
|
||||
_notifyUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
void RadarManager::_extractWorkStatus(const RadarFrame& frame) {
|
||||
if (frame.command == WS_INIT_COMPLETE && frame.data_length >= 1) {
|
||||
if (frame.data[0] == 0x0F) {
|
||||
_initialized = true;
|
||||
Serial.println(F("[雷达] ✅ 雷达初始化完成"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void RadarManager::_extractDetectRange(const RadarFrame& frame) {
|
||||
// 探测范围信息
|
||||
if (frame.data_length >= 1) {
|
||||
Serial.printf("[雷达] 探测范围状态: 0x%02X\n", frame.data[0]);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 校验码计算 & 验证
|
||||
// ============================================================
|
||||
|
||||
uint8_t RadarManager::_calcChecksum(const uint8_t* data, uint16_t len) {
|
||||
uint16_t sum = 0;
|
||||
for (uint16_t i = 0; i < len; i++) {
|
||||
sum += data[i];
|
||||
}
|
||||
return (uint8_t)(sum & 0xFF);
|
||||
}
|
||||
|
||||
bool RadarManager::_validateChecksum() {
|
||||
// 计算: 帧头(2) + 控制字(1) + 命令字(1) + 长度(2) + 数据(N) 求和取低8位
|
||||
uint8_t header[6];
|
||||
header[0] = FRAME_HEADER_1;
|
||||
header[1] = FRAME_HEADER_2;
|
||||
header[2] = _currentFrame.control;
|
||||
header[3] = _currentFrame.command;
|
||||
header[4] = (_currentFrame.data_length >> 8) & 0xFF;
|
||||
header[5] = _currentFrame.data_length & 0xFF;
|
||||
|
||||
uint16_t sum = 0;
|
||||
for (int i = 0; i < 6; i++) sum += header[i];
|
||||
for (uint16_t i = 0; i < _currentFrame.data_length; i++) sum += _currentFrame.data[i];
|
||||
|
||||
uint8_t expected = (uint8_t)(sum & 0xFF);
|
||||
return (expected == _currentFrame.checksum);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 解析器重置
|
||||
// ============================================================
|
||||
|
||||
void RadarManager::_resetParser() {
|
||||
_state = STATE_IDLE;
|
||||
_dataIndex = 0;
|
||||
memset(&_currentFrame, 0, sizeof(_currentFrame));
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 获取最新数据 (线程安全版本)
|
||||
// ============================================================
|
||||
|
||||
RadarRawData RadarManager::getData() const {
|
||||
return _data;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 回调注册
|
||||
// ============================================================
|
||||
|
||||
void RadarManager::onDataUpdate(RadarCallback cb) {
|
||||
_callback = cb;
|
||||
}
|
||||
|
||||
void RadarManager::_notifyUpdate() {
|
||||
if (_callback) {
|
||||
_callback(_data);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 发送命令到雷达
|
||||
// ============================================================
|
||||
|
||||
void RadarManager::sendCommand(uint8_t control, uint8_t command,
|
||||
const uint8_t* data, uint16_t len) {
|
||||
if (!_serial) return;
|
||||
|
||||
// 构建帧
|
||||
uint8_t frame[RADAR_FRAME_BUF_SIZE + 9];
|
||||
uint16_t idx = 0;
|
||||
|
||||
frame[idx++] = FRAME_HEADER_1;
|
||||
frame[idx++] = FRAME_HEADER_2;
|
||||
frame[idx++] = control;
|
||||
frame[idx++] = command;
|
||||
frame[idx++] = (len >> 8) & 0xFF;
|
||||
frame[idx++] = len & 0xFF;
|
||||
|
||||
if (data && len > 0) {
|
||||
memcpy(&frame[idx], data, len);
|
||||
idx += len;
|
||||
}
|
||||
|
||||
// 校验: 帧头(2) + 控制(1) + 命令(1) + 长度(2) + 数据(N)
|
||||
uint16_t sum = 0;
|
||||
for (uint16_t i = 0; i < idx; i++) sum += frame[i];
|
||||
frame[idx++] = (uint8_t)(sum & 0xFF);
|
||||
|
||||
frame[idx++] = FRAME_TAIL_1;
|
||||
frame[idx++] = FRAME_TAIL_2;
|
||||
|
||||
_serial->write(frame, idx);
|
||||
_serial->flush();
|
||||
}
|
||||
|
||||
void RadarManager::queryInitStatus() {
|
||||
// 查询初始化状态: 控制字 0x05, 命令字 0x81, 数据 0x0F
|
||||
uint8_t d = 0x0F;
|
||||
sendCommand(CTRL_WORK_STATUS, WS_INIT_QUERY, &d, 1);
|
||||
}
|
||||
|
||||
void RadarManager::queryDetectRange() {
|
||||
sendCommand(CTRL_DETECT_RANGE, CMD_QUERY);
|
||||
}
|
||||
118
src/sensor_manager.cpp
Normal file
118
src/sensor_manager.cpp
Normal file
@@ -0,0 +1,118 @@
|
||||
/**
|
||||
* @file sensor_manager.cpp
|
||||
* @brief 传感器管理器实现 - DHT11 + 水浸
|
||||
*/
|
||||
|
||||
#include "sensor_manager.h"
|
||||
|
||||
SensorManager::SensorManager()
|
||||
: _dht(nullptr)
|
||||
, _temp(0)
|
||||
, _humi(0)
|
||||
, _dhtValid(false)
|
||||
, _dhtErrorCnt(0)
|
||||
, _rainDO(true) // 1=正常
|
||||
, _rainValid(false)
|
||||
, _lastRainRaw(true)
|
||||
, _rainChangeTime(0)
|
||||
, _callback(nullptr)
|
||||
, _lastReadTime(0)
|
||||
{
|
||||
}
|
||||
|
||||
SensorManager::~SensorManager() {
|
||||
if (_dht) delete _dht;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 初始化
|
||||
// ============================================================
|
||||
|
||||
bool SensorManager::begin() {
|
||||
// DHT11 — 允许失败,不影响水浸
|
||||
_dht = new DHT(DHT11_PIN, DHT_TYPE);
|
||||
_dht->begin();
|
||||
_dhtValid = false;
|
||||
_dhtErrorCnt = 0;
|
||||
|
||||
// 水浸传感器 — 独立初始化
|
||||
pinMode(WATER_SENSOR_PIN, INPUT_PULLUP);
|
||||
_lastRainRaw = (digitalRead(WATER_SENSOR_PIN) == HIGH); // HIGH=正常
|
||||
_rainDO = _lastRainRaw;
|
||||
_rainValid = true;
|
||||
|
||||
// 首次读取 (DHT11 可能失败)
|
||||
read();
|
||||
|
||||
Serial.println(F("[传感器] 初始化完成"));
|
||||
Serial.printf(" DHT11: %s\n",
|
||||
_dhtValid ? "✅ 正常" : "⚠️ 未检测到 (不影响系统运行)");
|
||||
Serial.printf(" 水浸: %s\n",
|
||||
_rainValid ? "✅ 正常" : "⚠️ 未检测到");
|
||||
return true; // 始终成功,不阻塞系统启动
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 读取传感器
|
||||
// ============================================================
|
||||
|
||||
void SensorManager::read() {
|
||||
// --- DHT11 读取 (独立,失败不影响水浸) ---
|
||||
if (millis() - _lastReadTime >= 2000) {
|
||||
_lastReadTime = millis();
|
||||
|
||||
float t = _dht->readTemperature();
|
||||
float h = _dht->readHumidity();
|
||||
|
||||
if (!isnan(t) && !isnan(h)) {
|
||||
if (t > -20 && t < 80 && h >= 0 && h <= 100) {
|
||||
_temp = t;
|
||||
_humi = h;
|
||||
_dhtValid = true;
|
||||
_dhtErrorCnt = 0; // 恢复正常,清零计数
|
||||
} else {
|
||||
_dhtValid = false;
|
||||
_dhtErrorCnt++;
|
||||
}
|
||||
} else {
|
||||
_dhtValid = false;
|
||||
_dhtErrorCnt++;
|
||||
}
|
||||
|
||||
// 连续失败 10 次 (~20秒) 后只报一次,避免刷屏
|
||||
if (_dhtErrorCnt == 10) {
|
||||
Serial.println(F("[传感器] ⚠️ DHT11 持续读取失败 (不影响其他传感器)"));
|
||||
}
|
||||
}
|
||||
|
||||
// 水浸传感器读取 (防抖)
|
||||
bool raw = (digitalRead(WATER_SENSOR_PIN) == HIGH); // HIGH=正常
|
||||
if (raw != _lastRainRaw) {
|
||||
_rainChangeTime = millis();
|
||||
_lastRainRaw = raw;
|
||||
}
|
||||
|
||||
if (millis() - _rainChangeTime >= WATER_DEBOUNCE_MS) {
|
||||
bool newState = _lastRainRaw;
|
||||
if (newState != _rainDO) {
|
||||
_rainDO = newState;
|
||||
_rainValid = true;
|
||||
|
||||
if (!_rainDO) {
|
||||
Serial.println(F("[传感器] ⚠️ 检测到水浸!"));
|
||||
} else {
|
||||
Serial.println(F("[传感器] ✅ 水浸解除"));
|
||||
}
|
||||
|
||||
if (_callback) _callback();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 回调
|
||||
// ============================================================
|
||||
|
||||
void SensorManager::onChange(SensorCallback cb) {
|
||||
_callback = cb;
|
||||
}
|
||||
412
src/wifi_manager.cpp
Normal file
412
src/wifi_manager.cpp
Normal file
@@ -0,0 +1,412 @@
|
||||
/**
|
||||
* @file wifi_manager.cpp
|
||||
* @brief WiFi 配网管理器实现
|
||||
*/
|
||||
|
||||
#include "wifi_manager.h"
|
||||
|
||||
// 单例指针
|
||||
WiFiMgmt* WiFiMgmt::_instance = nullptr;
|
||||
|
||||
// ============================================================
|
||||
// 构造函数 & 析构函数
|
||||
// ============================================================
|
||||
|
||||
WiFiMgmt::WiFiMgmt()
|
||||
: _connected(false)
|
||||
, _lastCheckTime(0)
|
||||
, _buttonPressTime(0)
|
||||
, _buttonPressed(false)
|
||||
, _ledLastToggle(0)
|
||||
, _ledState(false)
|
||||
, _param_mqtt_server(nullptr)
|
||||
, _param_mqtt_port(nullptr)
|
||||
, _param_mqtt_user(nullptr)
|
||||
, _param_mqtt_pass(nullptr)
|
||||
, _param_device_id(nullptr)
|
||||
, _param_env_note(nullptr)
|
||||
{
|
||||
memset(&_config, 0, sizeof(_config));
|
||||
_instance = this; // 设置单例
|
||||
}
|
||||
|
||||
WiFiMgmt::~WiFiMgmt() {
|
||||
_instance = nullptr;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 初始化
|
||||
// ============================================================
|
||||
|
||||
bool WiFiMgmt::begin() {
|
||||
Serial.println(F("\n========================================"));
|
||||
Serial.println(F(" WiFi 配网管理器 初始化"));
|
||||
Serial.println(F("========================================"));
|
||||
|
||||
// 初始化引脚
|
||||
pinMode(CONFIG_BUTTON_PIN, INPUT_PULLUP);
|
||||
pinMode(LED_WIFI_PIN, OUTPUT);
|
||||
digitalWrite(LED_WIFI_PIN, LOW);
|
||||
|
||||
// 初始化 LittleFS
|
||||
if (!LittleFS.begin(true)) {
|
||||
Serial.println(F("[WiFi] ❌ LittleFS 挂载失败!"));
|
||||
return false;
|
||||
}
|
||||
Serial.println(F("[WiFi] ✅ LittleFS 已挂载"));
|
||||
|
||||
// 加载配置
|
||||
if (!_loadConfig()) {
|
||||
Serial.println(F("[WiFi] 使用默认配置"));
|
||||
// 设置默认值
|
||||
strncpy(_config.mqtt_server, MQTT_DEFAULT_SERVER, sizeof(_config.mqtt_server) - 1);
|
||||
strncpy(_config.mqtt_port, MQTT_DEFAULT_PORT_STR, sizeof(_config.mqtt_port) - 1);
|
||||
strncpy(_config.mqtt_user, MQTT_DEFAULT_USER, sizeof(_config.mqtt_user) - 1);
|
||||
strncpy(_config.mqtt_pass, MQTT_DEFAULT_PASS, sizeof(_config.mqtt_pass) - 1);
|
||||
strncpy(_config.device_id, MQTT_DEFAULT_DEVICE, sizeof(_config.device_id) - 1);
|
||||
strncpy(_config.env_note, MQTT_DEFAULT_ENV, sizeof(_config.env_note) - 1);
|
||||
}
|
||||
|
||||
// 配置 WiFiManager
|
||||
_wm.setDebugOutput(false); // 关闭调试输出 (避免干扰)
|
||||
_wm.setConfigPortalTimeout(WIFI_CONFIG_TIMEOUT);
|
||||
_wm.setConnectTimeout(WIFI_CONNECT_TIMEOUT);
|
||||
_wm.setTitle("🏥 五劳无感康养监测 - 设备配网"); // 配网页面的中文标题
|
||||
_wm.setCustomHeadElement(
|
||||
"<style>"
|
||||
"body { font-family: 'Microsoft YaHei', 'PingFang SC', sans-serif; }"
|
||||
".msg .info { font-size: 14px; }"
|
||||
"</style>"
|
||||
);
|
||||
WiFi.setAutoReconnect(true); // ESP32 自动重连
|
||||
|
||||
// 注册保存回调 (必须使用静态函数, 不能使用 lambda)
|
||||
_wm.setSaveConfigCallback(_saveParamsCallback);
|
||||
|
||||
// 创建自定义参数
|
||||
_param_mqtt_server = new WiFiManagerParameter(
|
||||
"mqtt_server", "MQTT 服务器地址",
|
||||
_config.mqtt_server, 40);
|
||||
_param_mqtt_port = new WiFiManagerParameter(
|
||||
"mqtt_port", "MQTT 端口",
|
||||
_config.mqtt_port, 6);
|
||||
_param_mqtt_user = new WiFiManagerParameter(
|
||||
"mqtt_user", "MQTT 用户名",
|
||||
_config.mqtt_user, 20);
|
||||
_param_mqtt_pass = new WiFiManagerParameter(
|
||||
"mqtt_pass", "MQTT 密码",
|
||||
_config.mqtt_pass, 20);
|
||||
_param_device_id = new WiFiManagerParameter(
|
||||
"device_id", "设备名称",
|
||||
_config.device_id, 20);
|
||||
_param_env_note = new WiFiManagerParameter(
|
||||
"env_note", "🏠 环境备注 (如: 主卧/客厅/老人房)",
|
||||
_config.env_note, 20);
|
||||
|
||||
// 注册自定义参数
|
||||
_wm.addParameter(_param_mqtt_server);
|
||||
_wm.addParameter(_param_mqtt_port);
|
||||
_wm.addParameter(_param_mqtt_user);
|
||||
_wm.addParameter(_param_mqtt_pass);
|
||||
_wm.addParameter(_param_device_id);
|
||||
_wm.addParameter(_param_env_note);
|
||||
|
||||
// 设置 AP 模式回调 (静态函数)
|
||||
_wm.setAPCallback(_apCallback);
|
||||
|
||||
// 尝试连接 WiFi
|
||||
_connected = _connectWiFi();
|
||||
|
||||
if (!_connected) {
|
||||
// 如果自动连接失败, 启动配网页面
|
||||
Serial.println(F("[WiFi] 自动连接失败, 启动配网模式..."));
|
||||
_setLEDMode(1); // 快闪 = 配网模式
|
||||
_connected = _connectWiFi(); // 内部会启动配网页面
|
||||
}
|
||||
|
||||
if (_connected) {
|
||||
_setLEDMode(2); // 常亮 = 已连接
|
||||
}
|
||||
|
||||
_lastCheckTime = millis();
|
||||
return _connected;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 主循环
|
||||
// ============================================================
|
||||
|
||||
bool WiFiMgmt::loop() {
|
||||
// 检测按钮长按
|
||||
if (_checkButtonLongPress()) {
|
||||
Serial.println(F("[WiFi] ⚠️ 检测到长按, 清除配置并重启..."));
|
||||
resetSettings();
|
||||
return false;
|
||||
}
|
||||
|
||||
// 检查 WiFi 状态
|
||||
if (millis() - _lastCheckTime > WIFI_RETRY_INTERVAL) {
|
||||
_lastCheckTime = millis();
|
||||
|
||||
if (WiFi.status() != WL_CONNECTED) {
|
||||
_connected = false;
|
||||
_setLEDMode(0); // 慢闪 = 未连接
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
return _connected;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// WiFi 连接
|
||||
// ============================================================
|
||||
|
||||
bool WiFiMgmt::_connectWiFi() {
|
||||
// 检查是否有已保存的配置
|
||||
// autoConnect 会先尝试连接, 失败后启动 AP 配网页面
|
||||
_connected = _wm.autoConnect(WIFI_AP_SSID, WIFI_AP_PASSWORD);
|
||||
|
||||
if (_connected) {
|
||||
Serial.println(F("[WiFi] ✅ 已连接!"));
|
||||
Serial.printf(" WiFi名称: %s\n", WiFi.SSID().c_str());
|
||||
Serial.printf(" IP地址: %s\n", WiFi.localIP().toString().c_str());
|
||||
Serial.printf(" MAC地址: %s\n", WiFi.macAddress().c_str());
|
||||
|
||||
// 重新加载配置 (确保获取最新值)
|
||||
// autoConnect 过程中如果有保存, 回调已写入 LittleFS
|
||||
_loadConfig();
|
||||
|
||||
// 同步自定义参数 (如果配置是从 LittleFS 加载的)
|
||||
_param_mqtt_server->setValue(_config.mqtt_server, sizeof(_config.mqtt_server));
|
||||
_param_mqtt_port->setValue(_config.mqtt_port, sizeof(_config.mqtt_port));
|
||||
_param_mqtt_user->setValue(_config.mqtt_user, sizeof(_config.mqtt_user));
|
||||
_param_mqtt_pass->setValue(_config.mqtt_pass, sizeof(_config.mqtt_pass));
|
||||
_param_device_id->setValue(_config.device_id, sizeof(_config.device_id));
|
||||
_param_env_note->setValue(_config.env_note, sizeof(_config.env_note));
|
||||
|
||||
Serial.printf(" 设备名: %s\n", _config.device_id);
|
||||
Serial.printf(" 环境: %s\n", _config.env_note);
|
||||
Serial.printf(" MQTT服务器: %s:%s\n", _config.mqtt_server, _config.mqtt_port);
|
||||
}
|
||||
|
||||
return _connected;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 配网页面
|
||||
// ============================================================
|
||||
|
||||
void WiFiMgmt::startConfigPortal() {
|
||||
Serial.println(F("[WiFi] 🔧 手动启动配网页面..."));
|
||||
_setLEDMode(1);
|
||||
_wm.startConfigPortal(WIFI_AP_SSID, WIFI_AP_PASSWORD);
|
||||
_connected = (WiFi.status() == WL_CONNECTED);
|
||||
if (_connected) {
|
||||
_setLEDMode(2);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 重置设置
|
||||
// ============================================================
|
||||
|
||||
void WiFiMgmt::resetSettings() {
|
||||
Serial.println(F("[WiFi] 清除所有配置..."));
|
||||
|
||||
// 删除 LittleFS 中的配置文件
|
||||
if (LittleFS.exists(CONFIG_FILE_WIFI)) LittleFS.remove(CONFIG_FILE_WIFI);
|
||||
if (LittleFS.exists(CONFIG_FILE_MQTT)) LittleFS.remove(CONFIG_FILE_MQTT);
|
||||
if (LittleFS.exists(CONFIG_FILE_DEVICE)) LittleFS.remove(CONFIG_FILE_DEVICE);
|
||||
|
||||
// 清除 WiFiManager 的配置
|
||||
_wm.resetSettings();
|
||||
|
||||
// LED 快闪提示
|
||||
for (int i = 0; i < 10; i++) {
|
||||
digitalWrite(LED_WIFI_PIN, !digitalRead(LED_WIFI_PIN));
|
||||
delay(100);
|
||||
}
|
||||
|
||||
Serial.println(F("[WiFi] 🔄 正在重启..."));
|
||||
delay(500);
|
||||
ESP.restart();
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 按钮长按检测
|
||||
// ============================================================
|
||||
|
||||
bool WiFiMgmt::_checkButtonLongPress() {
|
||||
bool pressed = (digitalRead(CONFIG_BUTTON_PIN) == LOW);
|
||||
|
||||
if (pressed && !_buttonPressed) {
|
||||
// 按下
|
||||
_buttonPressed = true;
|
||||
_buttonPressTime = millis();
|
||||
} else if (!pressed && _buttonPressed) {
|
||||
// 松开
|
||||
_buttonPressed = false;
|
||||
if (millis() - _buttonPressTime >= BUTTON_LONG_PRESS_MS) {
|
||||
return true; // 长按确认
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// LittleFS 配置持久化
|
||||
// ============================================================
|
||||
|
||||
bool WiFiMgmt::_loadConfig() {
|
||||
if (!LittleFS.exists(CONFIG_FILE_DEVICE)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
File file = LittleFS.open(CONFIG_FILE_DEVICE, "r");
|
||||
if (!file) return false;
|
||||
|
||||
JsonDocument doc;
|
||||
DeserializationError err = deserializeJson(doc, file);
|
||||
file.close();
|
||||
|
||||
if (err) {
|
||||
Serial.printf("[WiFi] JSON 解析失败: %s\n", err.c_str());
|
||||
return false;
|
||||
}
|
||||
|
||||
strncpy(_config.mqtt_server, doc["mqtt_server"] | MQTT_DEFAULT_SERVER, sizeof(_config.mqtt_server) - 1);
|
||||
strncpy(_config.mqtt_port, doc["mqtt_port"] | MQTT_DEFAULT_PORT_STR, sizeof(_config.mqtt_port) - 1);
|
||||
strncpy(_config.mqtt_user, doc["mqtt_user"] | MQTT_DEFAULT_USER, sizeof(_config.mqtt_user) - 1);
|
||||
strncpy(_config.mqtt_pass, doc["mqtt_pass"] | MQTT_DEFAULT_PASS, sizeof(_config.mqtt_pass) - 1);
|
||||
strncpy(_config.device_id, doc["device_id"] | MQTT_DEFAULT_DEVICE, sizeof(_config.device_id) - 1);
|
||||
strncpy(_config.env_note, doc["env_note"] | MQTT_DEFAULT_ENV, sizeof(_config.env_note) - 1);
|
||||
|
||||
Serial.println(F("[WiFi] ✅ 设备配置已加载"));
|
||||
Serial.printf(" 设备名: %s, 环境: %s\n", _config.device_id, _config.env_note);
|
||||
return true;
|
||||
}
|
||||
|
||||
bool WiFiMgmt::_saveConfig() {
|
||||
JsonDocument doc;
|
||||
doc["mqtt_server"] = _config.mqtt_server;
|
||||
doc["mqtt_port"] = _config.mqtt_port;
|
||||
doc["mqtt_user"] = _config.mqtt_user;
|
||||
doc["mqtt_pass"] = _config.mqtt_pass;
|
||||
doc["device_id"] = _config.device_id;
|
||||
doc["env_note"] = _config.env_note;
|
||||
|
||||
File file = LittleFS.open(CONFIG_FILE_DEVICE, "w");
|
||||
if (!file) {
|
||||
Serial.println(F("[WiFi] ❌ 无法创建配置文件"));
|
||||
return false;
|
||||
}
|
||||
|
||||
if (serializeJson(doc, file) == 0) {
|
||||
Serial.println(F("[WiFi] ❌ 配置写入失败"));
|
||||
file.close();
|
||||
return false;
|
||||
}
|
||||
|
||||
file.close();
|
||||
Serial.println(F("[WiFi] ✅ 设备配置已保存"));
|
||||
return true;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// WiFiManager 保存参数回调
|
||||
// ============================================================
|
||||
|
||||
// ============================================================
|
||||
// 静态回调函数 (WiFiManager 要求 C 函数指针)
|
||||
// ============================================================
|
||||
|
||||
void WiFiMgmt::_apCallback(WiFiManager* wm) {
|
||||
Serial.println(F("[WiFi] 🔧 进入配网模式"));
|
||||
Serial.printf(" AP WiFi名称: %s\n", WIFI_AP_SSID);
|
||||
Serial.printf(" AP 密码: %s\n", WIFI_AP_PASSWORD);
|
||||
Serial.println(F(" 手机连接此 WiFi 即可打开配网页面 (自动弹出)"));
|
||||
}
|
||||
|
||||
void WiFiMgmt::_saveParamsCallback() {
|
||||
if (!_instance) return;
|
||||
|
||||
Serial.println(F("[WiFi] 💾 参数已保存"));
|
||||
|
||||
// 从 WiFiManager 参数中读取值
|
||||
strncpy(_instance->_config.mqtt_server, _instance->_param_mqtt_server->getValue(),
|
||||
sizeof(_instance->_config.mqtt_server) - 1);
|
||||
strncpy(_instance->_config.mqtt_port, _instance->_param_mqtt_port->getValue(),
|
||||
sizeof(_instance->_config.mqtt_port) - 1);
|
||||
strncpy(_instance->_config.mqtt_user, _instance->_param_mqtt_user->getValue(),
|
||||
sizeof(_instance->_config.mqtt_user) - 1);
|
||||
strncpy(_instance->_config.mqtt_pass, _instance->_param_mqtt_pass->getValue(),
|
||||
sizeof(_instance->_config.mqtt_pass) - 1);
|
||||
strncpy(_instance->_config.device_id, _instance->_param_device_id->getValue(),
|
||||
sizeof(_instance->_config.device_id) - 1);
|
||||
strncpy(_instance->_config.env_note, _instance->_param_env_note->getValue(),
|
||||
sizeof(_instance->_config.env_note) - 1);
|
||||
|
||||
_instance->_saveConfig();
|
||||
|
||||
Serial.println(F("[WiFi] 📋 新配置:"));
|
||||
Serial.printf(" MQTT: %s:%s\n",
|
||||
_instance->_config.mqtt_server,
|
||||
_instance->_config.mqtt_port);
|
||||
Serial.printf(" 设备: %s\n", _instance->_config.device_id);
|
||||
Serial.printf(" 环境: %s\n", _instance->_config.env_note);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Getter 方法
|
||||
// ============================================================
|
||||
|
||||
bool WiFiMgmt::isConnected() const {
|
||||
return _connected && (WiFi.status() == WL_CONNECTED);
|
||||
}
|
||||
|
||||
DeviceConfig WiFiMgmt::getConfig() const {
|
||||
return _config;
|
||||
}
|
||||
|
||||
const char* WiFiMgmt::getDeviceId() const {
|
||||
return _config.device_id;
|
||||
}
|
||||
|
||||
const char* WiFiMgmt::getEnvNote() const {
|
||||
return _config.env_note;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user