增加补充相关资料和小程序源码

This commit is contained in:
Wang Beihong
2026-02-07 23:14:57 +08:00
parent a0febb1e5b
commit a9be1dd6b9
1255 changed files with 476253 additions and 0 deletions

View File

@@ -0,0 +1,323 @@
# HomeControl 页面功能说明
## 概述
HomeControl 页面是用于控制 ESP32 设备的核心页面,支持 MQTT 消息通信、设备状态监控和控制指令发送等功能。
## 核心功能
### 1. MQTT 消息通信
- 自动连接 MQTT 服务器
- 订阅设备主题,接收设备消息
- 处理设备上报的传感器数据
- 发送控制指令到设备
### 2. 设备数据管理
- 存储和解析 ESP32 设备数据
- 实时更新设备状态
- 提取特定设备信息(如温度、湿度、空气质量等)
### 3. 控制指令发送
支持发送控制指令到 ESP32 设备,并接收设备响应。
#### 基本用法
```javascript
// 最简单的用法 - 只控制灯光
this.sendControlCommand({
light: "on",
});
// 控制多个设备
this.sendControlCommand({
light: "on",
fan: "off",
curtain: "open",
});
```
#### 高级用法 - 带配置参数
```javascript
// 控制灯光并设置亮度
this.sendControlCommand({ light: "on" }, { brightness: 80 });
```
#### 高级用法 - 带回调函数
```javascript
// 发送控制并处理响应
this.sendControlCommand({ light: "on" }, null, (response) => {
console.log("控制结果:", response);
if (response.success) {
console.log("控制成功");
} else {
console.log("控制失败:", response.message);
}
});
```
#### 完整用法 - 所有参数
```javascript
this.sendControlCommand(
{ light: "on", fan: "off" }, // 控制参数
{ brightness: 80 }, // 配置参数
(response) => {
// 回调函数
console.log("响应:", response);
}
);
```
### 4. 请求-响应匹配机制
系统实现了完整的请求追踪和响应匹配功能:
#### 工作原理
1. 每个控制指令都会生成唯一的`request_id`
2. 发送指令时自动保存请求信息到`pendingRequests`
3. 接收到 ESP32 响应后,通过`request_id`匹配原始请求
4. 根据响应状态自动显示提示信息
5. 如果有回调函数,自动调用并传递响应数据
6. 5 秒后自动清理已完成的请求
#### 请求格式
```json
{
"type": "control_command",
"device_id": "esp32_bedroom_001",
"device_type": "bedroom_controller",
"timestamp": 1768495346335,
"message_type": "control_request",
"request_id": "req_1768495346336_n3bhf90pu",
"data": {
"controls": {
"light": "on"
}
}
}
```
#### 响应格式
```json
{
"type": "control_response",
"device_id": "esp32_bedroom_001",
"device_type": "bedroom_controller",
"timestamp": 1768495550428,
"message_type": "control_result",
"request_id": "req_1768495550428_d47w3fu8j",
"data": {
"result": {
"status": "success",
"message": "Control executed successfully"
}
}
}
```
#### 响应状态说明
- `success`: 控制指令执行成功
- `error`: 控制指令执行出错
- `failed`: 控制指令执行失败
## 数据结构
### 页面数据
```javascript
data: {
receivedMessages: [], // 存储接收到的消息
esp32Device: null, // 存储ESP32设备数据
deviceOnline: false, // 设备在线状态
lastUpdateTime: null, // 最后更新时间
pendingRequests: {} // 存储待处理的控制请求
}
```
### ESP32 设备数据结构
```javascript
{
type: "device_message",
deviceId: "esp32_bedroom_001",
deviceType: "bedroom_controller",
timestamp: 1768495346335,
messageType: "telemetry_update",
requestId: null,
statusCode: null,
statusMessage: null,
state: {
online: true,
// 其他状态信息
},
telemetry: {
temperature: 25.5,
humidity: 60,
air_quality: 85,
// 其他遥测数据
},
controlResult: null,
rawData: {...}
}
```
## API 说明
### sendControlCommand(controls, config, callback)
发送控制指令到 ESP32 设备
**参数:**
- `controls` (Object): 控制参数对象,例如 `{ light: "on" }`
- `config` (Object, 可选): 配置参数对象,例如 `{ brightness: 80 }`
- `callback` (Function, 可选): 响应回调函数
**返回值:**
- 成功: 返回 `request_id` (字符串)
- 失败: 返回 `null`
**示例:**
```javascript
const requestId = this.sendControlCommand({ light: "on" }, null, (response) => {
if (response.success) {
console.log("控制成功");
}
});
```
### handleControlResponse(response)
处理 ESP32 设备的控制响应
**参数:**
- `response` (Object): 响应消息对象
**功能:**
- 通过 request_id 匹配原始请求
- 解析响应状态
- 显示提示信息
- 调用回调函数(如果有)
- 自动清理已完成的请求
### parseESP32Data(message)
解析 ESP32 设备上传的数据
**参数:**
- `message` (Object|string): 接收到的消息内容
**返回值:**
- 成功: 返回解析后的数据对象
- 失败: 返回 `null`
### extractESP32Info(path)
从解析后的 ESP32 数据中提取特定信息
**参数:**
- `path` (string): 数据路径,使用点号分隔,例如 "telemetry.air_quality"
**返回值:**
- 成功: 返回提取的值
- 失败: 返回 `null`
**示例:**
```javascript
// 提取空气质量
const airQuality = this.extractESP32Info("telemetry.air_quality");
// 提取温度
const temperature = this.extractESP32Info("telemetry.temperature");
// 获取所有数据
const allData = this.extractESP32Info();
```
## 注意事项
1. **回调函数是可选的**
- 不使用回调时,系统会自动显示 Toast 提示
- 使用回调时,可以自定义处理响应逻辑
2. **请求自动清理**
- 已完成的请求会在 5 秒后自动清理
- 发送失败的请求会立即移除
3. **错误处理**
- 所有函数都包含错误处理
- 错误信息会输出到控制台
- 关键错误会显示 Toast 提示
4. **MQTT 连接**
- 使用前确保 MQTT 已连接
- 未连接时会显示提示信息
- 需要正确配置 MQTT 主题
## ESP32 端实现参考
### 接收控制指令
```cpp
void handleControlCommand(String payload) {
DynamicJsonDocument doc(1024);
deserializeJson(doc, payload);
String requestId = doc["request_id"];
String lightControl = doc["data"]["controls"]["light"];
// 执行控制操作
if (lightControl == "on") {
digitalWrite(LED_PIN, HIGH);
} else if (lightControl == "off") {
digitalWrite(LED_PIN, LOW);
}
// 发送响应
sendControlResponse(requestId, "success", "Control executed successfully");
}
```
### 发送控制响应
```cpp
void sendControlResponse(String requestId, String status, String message) {
DynamicJsonDocument doc(1024);
doc["type"] = "control_response";
doc["device_id"] = deviceId;
doc["device_type"] = "bedroom_controller";
doc["timestamp"] = millis();
doc["message_type"] = "control_result";
doc["request_id"] = requestId;
doc["data"]["result"]["status"] = status;
doc["data"]["result"]["message"] = message;
String response;
serializeJson(doc, response);
client.publish(responseTopic, response.c_str());
}
```
## 更新日志

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,3 @@
{
"usingComponents": {}
}

View File

@@ -0,0 +1,234 @@
<wxs module="utils">
// utils.wxs
var formatState = function(value) {
if(value === true || value === 1 || value === "1" || value === "on" || value === "open") {
return "开启";
} else if(value === false || value === 0 || value === "0" || value === "off" || value === "close") {
return "关闭";
} else {
return "--";
}
}
module.exports = {
formatState: formatState
}
</wxs>
<van-nav-bar title="控制页面" fixed placeholder safe-area-inset-top />
<van-notice-bar left-icon="volume-o" text="必须确定已经添加了真实的设备。" />
<van-tabs active="{{ active }}" bind:change="onChange" swipeable animated>
<!-- 传感器数据 -->
<van-tab title="传感器数据">
<scroll-view scroll-y class="tab-scroll" style="height: 600px;">
<van-cell-group inset title="环境数据">
<van-cell title="温度" value="{{temperature != null ? temperature + '°C' : '--'}}" />
<van-cell title="湿度" value="{{humidity != null ? humidity + '%' : '--'}}" />
<van-cell title="光照强度" value="{{light_intensity != null ? light_intensity + 'lx' : '--'}}" />
<van-cell title="空气质量" value="{{air_quality != null ? air_quality + 'Index':'--'}}" />
</van-cell-group>
<van-cell-group inset title="控制状态">
<van-cell title="窗帘状态" value="{{utils.formatState(esp32Device && esp32Device.telemetry ? esp32Device.telemetry.curtain_state : null)}}" />
<van-cell title="警报状态" value="{{utils.formatState(esp32Device && esp32Device.telemetry ? esp32Device.telemetry.buzzer_state : null)}}" />
<van-cell title="风扇状态" value="{{utils.formatState(esp32Device && esp32Device.telemetry ? esp32Device.telemetry.fan_state : null)}}" />
<van-cell title="灯光状态" value="{{utils.formatState(esp32Device && esp32Device.telemetry ? esp32Device.telemetry.led_state : null)}}" />
</van-cell-group>
<van-cell-group inset title="设备功率">
<van-cell title="灯光功率" value="{{led_power != null ? led_power + '%' : '--'}}" />
</van-cell-group>
<van-cell-group inset title="控制参数">
<van-cell title="设备状态">
<van-tag slot="right-icon" type="{{deviceOnline ? 'success' : 'danger'}}">{{deviceOnline ? '在线' : '离线'}}</van-tag>
</van-cell>
<van-cell title="最后更新" value="{{lastUpdateTime || '--'}}" />
</van-cell-group>
<view style="height: env(safe-area-inset-bottom);" />
</scroll-view>
</van-tab>
<!-- 物理设备控制 -->
<van-tab title="物理设备控制">
<scroll-view scroll-y class="tab-scroll" style="height: 600px;">
<van-grid direction="horizontal" column-num="2" >
<van-grid-item use-slot >
<text>风扇开关</text>
<van-switch checked="{{switch1}}" bind:change="onSwitch1Change" />
</van-grid-item>
<van-grid-item use-slot>
<text>窗帘开关</text>
<van-switch checked="{{switch2}}" bind:change="onSwitch2Change" />
</van-grid-item>
<van-grid-item use-slot>
<text>警报开关</text>
<van-switch checked="{{switch3}}" bind:change="onSwitch3Change" />
</van-grid-item>
<van-grid-item use-slot>
<text>灯光开关</text>
<van-switch checked="{{switch4}}" bind:change="onSwitch4Change" />
</van-grid-item>
</van-grid>
<view class="slider-container">
<van-tag plain type="primary">灯光功率控制</van-tag>
<van-slider value="{{led_power_value}}" bind:change="led_powerChange">
<view class="slider-btn">{{led_power_value}}%</view>
</van-slider>
</view>
<view style="height: env(safe-area-inset-bottom);" />
</scroll-view>
</van-tab>
<!-- 模式设置 -->
<van-tab title="模式设置">
<scroll-view scroll-y class="tab-scroll" style="height: 600px;">
<!-- 闹钟设置 -->
<van-cell-group inset title="闹钟设置">
<!-- 闹钟1 -->
<van-cell title="起床时间" icon="clock-o" use-slot>
<view slot="right-icon" class="alarm-cell-right">
<view class="alarm-time" bindtap="onAlarmTimeClick" data-alarm="1">
<text class="time-text">{{alarm1.time}}</text>
<van-icon name="arrow-down" size="14px" color="#969799" />
</view>
<van-switch checked="{{alarm1.enabled}}" bind:change="onAlarmEnableChange" data-alarm="1" size="24px" />
</view>
</van-cell>
<!-- 闹钟2 -->
<van-cell title="闹钟2" icon="clock-o" use-slot>
<view slot="right-icon" class="alarm-cell-right">
<view class="alarm-time" bindtap="onAlarmTimeClick" data-alarm="2">
<text class="time-text">{{alarm2.time}}</text>
<van-icon name="arrow-down" size="14px" color="#969799" />
</view>
<van-switch checked="{{alarm2.enabled}}" bind:change="onAlarmEnableChange" data-alarm="2" size="24px" />
</view>
</van-cell>
<!-- 闹钟3 -->
<van-cell title="闹钟3" icon="clock-o" use-slot>
<view slot="right-icon" class="alarm-cell-right">
<view class="alarm-time" bindtap="onAlarmTimeClick" data-alarm="3">
<text class="time-text">{{alarm3.time}}</text>
<van-icon name="arrow-down" size="14px" color="#969799" />
</view>
<van-switch checked="{{alarm3.enabled}}" bind:change="onAlarmEnableChange" data-alarm="3" size="24px" />
</view>
</van-cell>
</van-cell-group>
<!-- 时间段设置 -->
<van-cell-group inset title="时间段设置" custom-class="period-group">
<!-- 白天时间 -->
<van-cell title="白天时间" icon="sun-o" use-slot label="设置白天的起始和结束时间">
<view slot="right-icon" class="period-cell-right">
<view class="period-time-row">
<view class="period-time-label">开始</view>
<view class="period-time-value" bindtap="onPeriodTimeClick" data-period="day" data-type="start">
<text>{{dayPeriod.start}}</text>
<van-icon name="arrow-down" size="12px" color="#969799" />
</view>
</view>
<view class="period-time-row">
<view class="period-time-label">结束</view>
<view class="period-time-value" bindtap="onPeriodTimeClick" data-period="day" data-type="end">
<text>{{dayPeriod.end}}</text>
<van-icon name="arrow-down" size="12px" color="#969799" />
</view>
</view>
</view>
</van-cell>
<!-- 晚上时间 -->
<van-cell title="晚上时间" icon="moon-o" use-slot label="设置夜晚的起始和结束时间">
<view slot="right-icon" class="period-cell-right">
<view class="period-time-row">
<view class="period-time-label">开始</view>
<view class="period-time-value" bindtap="onPeriodTimeClick" data-period="night" data-type="start">
<text>{{nightPeriod.start}}</text>
<van-icon name="arrow-down" size="12px" color="#969799" />
</view>
</view>
<view class="period-time-row">
<view class="period-time-label">结束</view>
<view class="period-time-value" bindtap="onPeriodTimeClick" data-period="night" data-type="end">
<text>{{nightPeriod.end}}</text>
<van-icon name="arrow-down" size="12px" color="#969799" />
</view>
</view>
</view>
</van-cell>
<!-- 下发按钮 -->
<van-cell use-slot custom-class="period-action-cell">
<van-button type="info" size="small" bind:click="sendPeriodControlCommand" icon="guide-o">
下发时间段设置
</van-button>
</van-cell>
</van-cell-group>
<!-- 温度阈值设置 -->
<van-cell-group inset title="温度阈值设置" custom-class="threshold-group">
<van-cell title="自动降温温度" icon="fire-o" use-slot label="当温度超过此值时自动开启降温模式">
<view slot="right-icon" class="threshold-value">
<text class="threshold-temp">{{temperatureThreshold}}°C</text>
</view>
</van-cell>
<view class="slider-container">
<van-slider
value="{{temperatureThreshold}}"
bind:change="onTemperatureThresholdChange"
min="20"
max="40"
step="1"
bar-height="4px"
active-color="#ee0a24"
/>
<view class="slider-marks">
<text>20°C</text>
<text>30°C</text>
<text>40°C</text>
</view>
</view>
<!-- 下发按钮 -->
<van-cell use-slot custom-class="threshold-action-cell">
<van-button type="danger" size="small" bind:click="sendTemperatureThresholdCommand" icon="warning-o">
下发温度阈值设置
</van-button>
</van-cell>
</van-cell-group>
<!-- 时间选择器 -->
<van-popup show="{{showTimePicker}}" position="bottom" bind:close="onTimePickerClose" custom-style="background: #fff;">
<view class="time-picker-header">
<text class="time-picker-title">选择时间</text>
<van-icon name="cross" size="20px" color="#969799" bind:click="onTimePickerClose" />
</view>
<picker-view class="time-picker-view" value="{{timePickerValue}}" bind:change="onTimePickerChange">
<picker-view-column>
<view wx:for="{{hours}}" wx:key="*this" class="picker-column-item">{{item}}时</view>
</picker-view-column>
<picker-view-column>
<view wx:for="{{minutes}}" wx:key="*this" class="picker-column-item">{{item}}分</view>
</picker-view-column>
</picker-view>
<view class="time-picker-btn" bind:tap="onTimePickerConfirm">确定</view>
</van-popup>
<view style="height: env(safe-area-inset-bottom);" />
</scroll-view>
</van-tab>
</van-tabs>

View File

@@ -0,0 +1,202 @@
/* 页面容器 */
page {
height: 100vh;
display: flex;
flex-direction: column;
}
/* tab容器 */
.van-tabs {
flex: 1;
display: flex;
flex-direction: column;
}
/* tab内容容器 */
.van-tabs__content {
flex: 1;
overflow: hidden;
}
/* tab项 */
.van-tab__pane {
height: 100%;
overflow: hidden;
}
/* 滚动容器 */
.tab-scroll {
height: 100%;
width: 100%;
padding: 0 16px;
box-sizing: border-box;
-webkit-overflow-scrolling: touch;
}
/*.grid-item {*/
/* display: flex;*/
/* flex-direction: column;*/
/* align-items: center;*/
/* justify-content: center;*/
/* height: 120rpx; !* 固定高度让 square 生效 *!*/
/*}*/
.slider-container {
margin: 20px 0;
}
.slider-btn {
width: 40px;
color: #fff;
font-size: 10px;
line-height: 18px;
text-align: center;
background-color: #1989fa;
border-radius: 100px;
}
/* 闹钟时间选择器样式 */
.alarm-cell-right {
display: flex;
align-items: center;
gap: 16rpx;
}
.alarm-time {
display: flex;
align-items: center;
gap: 8rpx;
padding: 8rpx 16rpx;
background-color: #f5f5f5;
border-radius: 8rpx;
}
.time-text {
font-size: 28rpx;
color: #323233;
font-weight: 500;
}
/* 时间选择器样式 */
.time-picker-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 24rpx 32rpx;
border-bottom: 1rpx solid #ebedf0;
}
.time-picker-title {
font-size: 32rpx;
font-weight: 500;
color: #323233;
}
.time-picker-view {
height: 400rpx;
text-align: center;
}
.picker-column-item {
line-height: 80rpx;
font-size: 32rpx;
color: #323233;
}
.time-picker-btn {
margin: 24rpx 32rpx 32rpx;
height: 88rpx;
line-height: 88rpx;
text-align: center;
background-color: #1989fa;
color: #fff;
font-size: 32rpx;
border-radius: 8rpx;
}
/* 时间段设置样式 */
.period-group {
margin-top: 24rpx;
}
.period-cell-right {
display: flex;
flex-direction: column;
gap: 16rpx;
}
.period-time-row {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 16rpx;
}
.period-time-label {
font-size: 24rpx;
color: #969799;
}
.period-time-value {
display: flex;
align-items: center;
gap: 8rpx;
padding: 8rpx 16rpx;
background-color: #f5f5f5;
border-radius: 8rpx;
font-size: 28rpx;
color: #323233;
min-width: 100rpx;
justify-content: center;
}
/* 下发按钮样式 */
.period-action-cell {
padding: 16rpx 32rpx !important;
}
.period-action-cell .van-cell__value {
display: flex;
justify-content: center;
}
/* 温度阈值设置样式 */
.threshold-group {
margin-top: 24rpx;
}
.threshold-value {
display: flex;
align-items: center;
}
.threshold-temp {
font-size: 32rpx;
font-weight: 500;
color: #ee0a24;
margin-right: 8rpx;
}
/* 滑块样式 */
.slider-container {
margin: 20px 32rpx;
}
.slider-marks {
display: flex;
justify-content: space-between;
margin-top: 16rpx;
font-size: 24rpx;
color: #969799;
}
/* 温度阈值下发按钮样式 */
.threshold-action-cell {
padding: 16rpx 32rpx !important;
margin-top: 32rpx;
}
.threshold-action-cell .van-cell__value {
display: flex;
justify-content: center;
}