项目实战背景-智能鱼箱设计
- 远程控制:可将温度、浑浊度、液位等数据上传至手机APP,并可通过远程控制进行手动加热、加水和喂食操作。
最终实现效果展示,实现显示数据与按键控制并数据下发。
1、硬件设计与软件搭建
硬件设计主要用到单片机串口3的资源
ESP32-CAM实物如图,最好带下载器下载更稳定。
ESP32-CAM与硬件连接需要引出ESP32排线与STM32连接
软件需要用到keil与 Visual Studio Code
Visual Studio Code需安装platformio编译与烧录代码给单片机
详细的安装教程参考:
https://blog.csdn.net/qq_40018676/article/details/128680677
我是挂梯子在线下载的,不使用梯子时间可能花费比较长,请耐心等待~
2、STM32部分代码
实现思路,定义帧头与帧尾,并定义需传输与接受的数据为结构体。并将需传输的数据统一格式uint8_t,这样每个字节的长度固定方便后续解析。
当有效数据包被完整接收并校验通过后,直接将缓冲区数据按字节映射到全局控制结构体auto_control
中(rx_buffer[1]
对应cold_enable
,rx_buffer[2]
对应heat_enable
等,按固定偏移解析)。
下面为全部的代码,可删去不需要的结构体,并设置合理的Send_Data结构体(帧头+实际值+参数+控制位+帧尾)的方式。
1)单片机ESP32-CAM.h
#ifndef __ESP32_CAM_H
#define __ESP32_CAM_H
#include <stdint.h>
#include "main.h"
#include "stm32f1xx_hal.h"
#include "ESP32_CAM.h"
#include <string.h>
#include "KeyMode.h"
#include "Show.h"
#define SEND_DATA_SIZE 13
#define FRAME_HEADER 0X7B //Frame_header //帧头
#define FRAME_TAIL 0X7D //Frame_tail //帧尾
typedef struct {
uint8_t temperature; // 单位:℃
uint8_t turbidity; // 单位:NTU
uint8_t level; // 单位:cm
} show_data;
typedef struct _SEND_DATA_
{
unsigned char buffer[SEND_DATA_SIZE];
struct _Sensor_Str_
{
unsigned char Frame_Header; //1个字节
show_data usart_Data; //传输实际数值
SensorParameters params; //传输参数
ControlFlags flags; //传输结构体
unsigned char Frame_Tail; //1 bytes
}Sensor_Str;
}SEND_DATA;
typedef struct __attribute__((packed)) {
uint8_t header; // 0x7B
uint8_t cold_enable; // 制冷
uint8_t heat_enable; // 加热
uint8_t water_add; // 加水
uint8_t pump_active; // 水泵
uint8_t feed_trigger; // 喂食
uint8_t checksum; // 校验和
uint8_t footer; // 0x7D
} ControlPacket;
extern SensorParameters sensor_params;
extern ControlFlags control;
extern float DS18B20_ShowData;
extern float VoltageValue[3];
// 函数声明
void StartTask04_ESP32_CAM(void);
void SendSensorData(void);
void data_transition(void);
2)单片机ESP32-CAM.c
(代码不够简洁,请见谅),主要就是收发数据与数据处理。
#include "main.h"
#include "stm32f1xx_hal.h"
#include "ESP32_CAM.h"
#include <string.h>
#include "KeyMode.h"
#include "Show.h"
#include <string.h>
SEND_DATA Send_Data;
extern UART_HandleTypeDef huart3;
extern AUTO_CONTROL auto_control;
volatile ControlPacket receivedPacket;
volatile uint8_t packetReady = 0;
void usart3_send(uint8_t data)
{
USART3->DR = data;
while((USART3->SR&0x40)==0);
}
void StartTask04_ESP32_CAM(void)
{
data_transition();
SendSensorData(); //Serial port 3 (ROS) sends data //串口3发送数据
}
uint8_t rx_buffer[sizeof(ControlPacket)]; // 全局接收缓冲区
volatile uint8_t packetReady_2 = 0; // 数据包就绪标志
void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart)
{
static uint8_t rx_index = 0; // 当前接收位置
static uint32_t last_rx = 0; // 最后接收时间戳
if(huart->Instance == USART3)
{
// 超时重置(300ms无新数据则清空缓冲区)
if(HAL_GetTick() - last_rx > 300) {
rx_index = 0;
}
last_rx = HAL_GetTick();
// 写入缓冲区
if(rx_index < sizeof(ControlPacket))
{
rx_buffer[rx_index++] = huart->Instance->DR;
// 帧头即时校验(首字节必须为0x7B)
if(rx_index == 1 && rx_buffer[0] != 0x7B) {
rx_index = 0; // 帧头错误则重置
}
// 完整包接收完成
if(rx_index == sizeof(ControlPacket))
{
// 基础校验:帧头0x7B + 帧尾0x7D
if(rx_buffer[0] == 0x7B && rx_buffer[sizeof(ControlPacket)-1] == 0x7D)
{
// 直接操作全局结构体(无需临时变量)
auto_control.cold_enable = rx_buffer[1];
auto_control.heat_enable = rx_buffer[2];
auto_control.water_add = rx_buffer[3];
auto_control.pump_active = rx_buffer[4];
auto_control.feed_trigger = rx_buffer[5];
packetReady_2 = 1; // 设置就绪标志
}
rx_index = 0; // 无论校验是否通过都重置
}
}
// 继续接收下一字节
HAL_UART_Receive_IT(huart, &rx_buffer[rx_index], 1);
}
}
void SendSensorData(void)
{
unsigned char i = 0;
for(i=0; i<13; i++)
{
usart3_send(Send_Data.buffer[i]);
}
}
void data_transition(void)
{
Send_Data.Sensor_Str.Frame_Header = FRAME_HEADER; //Frame_header //帧头
Send_Data.Sensor_Str.Frame_Tail = FRAME_TAIL; //Frame_tail //帧尾
//实际数值
Send_Data.Sensor_Str.usart_Data.temperature = (uint8_t)DS18B20_ShowData;
Send_Data.Sensor_Str.usart_Data.turbidity = (uint8_t)VoltageValue[0];
Send_Data.Sensor_Str.usart_Data.level = (uint8_t)VoltageValue[1];
//参数
Send_Data.Sensor_Str.params.temperature = (uint8_t)sensor_params.temperature;
Send_Data.Sensor_Str.params.tds_value = (uint8_t)sensor_params.tds_value;
Send_Data.Sensor_Str.params.height = (uint8_t)sensor_params.height;
//标志位
Send_Data.Sensor_Str.flags.cold_enable = control.cold_enable;
Send_Data.Sensor_Str.flags.heat_enable = control.heat_enable;
Send_Data.Sensor_Str.flags.water_add = control.water_add;
Send_Data.Sensor_Str.flags.pump_active = control.pump_active;
Send_Data.Sensor_Str.flags.feed_trigger = control.feed_trigger;
//传输buffer
Send_Data.buffer[0] = Send_Data.Sensor_Str.Frame_Header;
Send_Data.buffer[1] = Send_Data.Sensor_Str.usart_Data.temperature;
Send_Data.buffer[2] = Send_Data.Sensor_Str.usart_Data.turbidity;
Send_Data.buffer[3] = Send_Data.Sensor_Str.usart_Data.level;
Send_Data.buffer[4] = Send_Data.Sensor_Str.params.temperature;
Send_Data.buffer[5] = Send_Data.Sensor_Str.params.tds_value;
Send_Data.buffer[6] = Send_Data.Sensor_Str.params.height;
Send_Data.buffer[7] = Send_Data.Sensor_Str.flags.cold_enable;
Send_Data.buffer[8] = Send_Data.Sensor_Str.flags.heat_enable;
Send_Data.buffer[9] = Send_Data.Sensor_Str.flags.water_add;
Send_Data.buffer[10] = Send_Data.Sensor_Str.flags.pump_active;
Send_Data.buffer[11] = Send_Data.Sensor_Str.flags.feed_trigger;
Send_Data.buffer[12] = Send_Data.Sensor_Str.Frame_Tail;
}
如何验证STM32发送是否正确呢?
使用串口助手,看是否是满足自己定义的数据
3、ESP32-CAM部分代码
附上参考资料:
ESP32-CAM摄像头开发板上手体验 - 《ESP项目学习分享》 - 极客文档
【VScode技巧】:platformio部署ESP32Cam开发板_platform 使用esp32cam-CSDN博客
我在使用ESP32-CAM主要使用的还是去物联网功能,对摄像头功能并未使用(也可自行添加,代码详见上述资料)
按照参考资料2操作后,还需下载Blinker库
并引用到include中
在main函数中编写所需的物联网核心代码
主要实现功能
- 通过硬件串口 2 连接 STM32,定义 14、15 引脚为收发引脚。使用 Blinker 库实现物联网功能,配置 WiFi 和设备密钥连接平台。
- 定义了 5 个控制按钮及回调函数,按下时切换对应设备(制冷、加热等)的开关状态并标记发送。自定义 SensorPacket 结构体与 STM32 保持通信协议一致,包含帧头、传感器数据、控制标志等。
- 接收 STM32 数据时通过帧头 0x7B 和帧尾 0x7D 校验,超时 50ms 重置接收。收到有效数据后更新 Blinker 平台显示。发送数据时生成含校验和的 8 字节数据包,包含各设备控制状态。
下面是对几个重点函数的解析最后给出全部代码
1)对显示组件
- 定义 6 个 BlinkerNumber 组件(Temp、TDS 等)分别关联对应标识符。
- 接收 STM32 数据时,在 SerialSTM32.available () 循环中解析 SensorPacket 结构体,提取温度、浊度等数据。
- 调用 Temp.print ()、TDS.print () 等函数,将解析出的传感器数据发送到 Blinker 平台显示。
- 设备状态通过 Button1.print () 等函数更新,根据 cooling_flag 等标志位,调用 Button1.text () 设置显示文本(START/STOP),并通过 Button1.print () 推送至平台。
- 每次 loop () 中会刷新按钮状态,确保显示与实际控制标志同步。
显示组件定义
// 自定义Blinker显示组件,对应APP中的数据展示控件
// 标识符需与Blinker APP中配置的控件标识符一致
BlinkerNumber Temp(const_cast<char*>("temp")); // 温度显示组件(标识符"temp")
BlinkerNumber TDS(const_cast<char*>("tds")); // 浊度显示组件(标识符"tds")
BlinkerNumber Height(const_cast<char*>("height")); // 液位高度显示组件(标识符"height")
BlinkerNumber P_Temp(const_cast<char*>("P_temp")); // 温度参数显示组件(标识符"P_temp")
BlinkerNumber P_TDS(const_cast<char*>("P_tds")); // 浊度参数显示组件(标识符"P_tds")
BlinkerNumber P_Height(const_cast<char*>("P_height")); // 液位参数显示组件(标识符"P_height")
// 控制按钮组件(同时用于显示设备状态)
BlinkerButton Button1(const_cast<const char*>(BUTTON_1)); // 制冷按钮(标识符"btn-1")
BlinkerButton Button2(const_cast<const char*>(BUTTON_2)); // 加热按钮(标识符"btn-2")
BlinkerButton Button3(const_cast<const char*>(BUTTON_3)); // 加水按钮(标识符"btn-3")
BlinkerButton Button4(const_cast<const char*>(BUTTON_4)); // 水泵按钮(标识符"btn-4")
BlinkerButton Button5(const_cast<const char*>(BUTTON_5)); // 喂食按钮(标识符"btn-5")
数据接收与解析(loop 函数中)
见下方详细代码
2)对按键组件
- 回调函数是按键与逻辑的工具,当 APP 中按下对应按键时,Blinker 库会自动调用绑定的回调函数。
state == BLINKER_CMD_BUTTON_PRESSED
用于判断按键为 “按下” 状态(避免重复触发)。- 核心逻辑是通过
!
运算符切换状态标志(如cooling_flag
),并设置sendFlag = 1
,通知主循环将新状态发送给 STM32。 attach
方法用于将按键组件与回调函数关联,确保按键被按下时能触发对应的逻辑。
按键组件定义
// 定义按键标识符(需与Blinker APP中控件的标识符完全一致)
#define BUTTON_1 "btn-1" // 制冷按键标识符
#define BUTTON_2 "btn-2" // 加热按键标识符
#define BUTTON_3 "btn-3" // 加水按键标识符
#define BUTTON_4 "btn-4" // 水泵按键标识符
#define BUTTON_5 "btn-5" // 喂食按键标识符
// 定义Blinker按键组件,关联标识符与实际功能
BlinkerButton Button1(const_cast<const char*>(BUTTON_1)); // 制冷控制按键
BlinkerButton Button2(const_cast<const char*>(BUTTON_2)); // 加热控制按键
BlinkerButton Button3(const_cast<const char*>(BUTTON_3)); // 加水控制按键
BlinkerButton Button4(const_cast<const char*>(BUTTON_4)); // 水泵控制按键
BlinkerButton Button5(const_cast<const char*>(BUTTON_5)); // 喂食控制按键
// 按键控制标志(0:关闭,1:开启),用于记录按键状态
uint8_t cooling_flag = 0; // 制冷状态标志
uint8_t heating_flag = 0; // 加热状态标志
uint8_t water_add_flag = 0; // 加水状态标志
uint8_t pump_flag = 0; // 水泵状态标志
uint8_t feed_flag = 0; // 喂食状态标志
按键回调函数(响应按键按下事件)
// 制冷按键回调函数(APP中按下"btn-1"时触发)
void button1_callback(const String & state) {
if (state == BLINKER_CMD_BUTTON_PRESSED) { // 判断按键状态为"按下"
cooling_flag = !cooling_flag; // 切换制冷状态(0→1或1→0)
sendFlag = 1; // 置位发送标志,通知STM32状态变化
Serial.printf("[DEBUG] Button1 pressed! sendFlag=%d\n", sendFlag); // 调试信息
}
}
void setup()
{
// 其他初始化(串口、WiFi等)...
// 绑定按键与回调函数:将按键组件与对应的逻辑处理函数关联
Button1.attach(button1_callback); // 制冷按键绑定回调
Button2.attach(button2_callback); // 加热按键绑定回调
Button3.attach(button3_callback); // 加水按键绑定回调
Button4.attach(button4_callback); // 水泵按键绑定回调
Button5.attach(button5_callback); // 喂食按键绑定回调
}
3)ESP32端代码
以下为全部ESP32端全部代码,对auth[](设备密钥)、 ssid[](WIFI名称可设自己的手机热点)和 pswd[](WIFI密码)自己按要求设置。设备密钥在Blinker手机端查看。
#define BLINKER_WIFI
#include <Blinker.h>
#include <HardwareSerial.h>
#include "ESP32_CAM_SERVER.h"
// 硬件串口配置
#define STM32_RX_PIN 14 // ESP32的RX接STM32的TX
#define STM32_TX_PIN 15 // ESP32的TX接STM32的RX
HardwareSerial SerialSTM32(2);
#define BUTTON_1 "btn-1"
#define BUTTON_2 "btn-2"
#define BUTTON_3 "btn-3"
#define BUTTON_4 "btn-4"
#define BUTTON_5 "btn-5"
void button1_callback(const String & state);
void button2_callback(const String & state);
void button3_callback(const String & state);
void button4_callback(const String & state);
void button5_callback(const String & state);
// Blinker配置
#define TEXTE_1 "sensor"
char auth[] = "xxx";
char ssid[] = "xxx";
char pswd[] = "xxx";
bool setup_camera = false;
uint8_t sendFlag = 0;
// 自定义组件
BlinkerNumber Temp(const_cast<char*>("temp"));
BlinkerNumber TDS(const_cast<char*>("tds"));
BlinkerNumber Height(const_cast<char*>("height"));
BlinkerNumber P_Temp(const_cast<char*>("P_temp"));
BlinkerNumber P_TDS(const_cast<char*>("P_tds"));
BlinkerNumber P_Height(const_cast<char*>("P_height"));
BlinkerButton Button1(const_cast<const char*>(BUTTON_1)); // 设备控制按钮-制冷
BlinkerButton Button2(const_cast<const char*>(BUTTON_2)); // 设备控制按钮-加热
BlinkerButton Button3(const_cast<const char*>(BUTTON_3)); // 设备控制按钮-加水
BlinkerButton Button4(const_cast<const char*>(BUTTON_4)); // 设备控制按钮-泵
BlinkerButton Button5(const_cast<const char*>(BUTTON_5)); // 设备控制按钮-喂食
// 按键控制标志(0:关闭,1:开启)
uint8_t cooling_flag = 0; // 制冷
uint8_t heating_flag = 0; // 加热
uint8_t water_add_flag = 0; // 加水
uint8_t pump_flag = 0; // 泵
uint8_t feed_flag = 0; // 喂食
// BlinkerText Text1(const_cast<char*>(TEXTE_1));
// 协议定义(必须与STM32完全一致)
// 在ESP32端定义相同协议结构体
#pragma pack(push, 1) // 紧凑对齐
typedef struct {
uint8_t frame_header; // 帧头
uint8_t temperature; // 实际温度(STM32发送时转换为uint8_t)
uint8_t tds_value; // 浊度
uint8_t height; // 高度
uint8_t temp; // 参数
uint8_t turbidity;
uint8_t level;
uint8_t cold_enable; // 位域定义(与STM32位域顺序一致)
uint8_t heat_enable;
uint8_t water_add;
uint8_t pump_active;
uint8_t feed_trigger;
uint8_t frame_tail; // 帧尾
} SensorPacket;
#pragma pack(pop) // 恢复默认对齐
// 全局变量
uint8_t rx_buffer[sizeof(SensorPacket)]; // 接收缓冲区
uint8_t rx_index = 0; // 缓冲区索引
bool packet_started = false; // 是否开始接收数据包
unsigned long last_rx_time = 0; // 最后接收时间(用于超时检测)
#define FRAME_HEADER 0x7B // 帧头 '{'
#define FRAME_TAIL 0x7D // 帧尾 '}'
#define SEND_SIZE 8 // 发送数据包大小改为8字节
uint8_t sendBuffer[SEND_SIZE]; // 发送缓冲区
uint8_t Start_flag = 0; // 启停状态
unsigned long BlinkerTime = 0;
// 在现有函数声明区域添加
uint8_t calculateChecksum(uint8_t *buffer, int length)
{
uint8_t checksum = 0;
for(int i = 0; i < length; i++)
{
checksum ^= buffer[i];
}
return checksum;
}
#define BLINKER_PRINT_DEBUG // 启用Blinker内部调试日志
#define BLINKER_PRINT Serial // 指定调试输出到Serial
// 制冷按键回调
void button1_callback(const String & state) {
if (state == BLINKER_CMD_BUTTON_PRESSED) {
cooling_flag = !cooling_flag;
sendFlag = 1;
Serial.printf("[DEBUG] Button1 pressed! sendFlag=%d\n", sendFlag);
}
}
// 加热按键回调
void button2_callback(const String & state) {
if (state == BLINKER_CMD_BUTTON_PRESSED) {
heating_flag = !heating_flag;
sendFlag = 1;
Serial.printf("[DEBUG] Button2 pressed! sendFlag=%d\n", sendFlag);
}
}
// 加水按键回调
void button3_callback(const String & state) {
if (state == BLINKER_CMD_BUTTON_PRESSED) {
water_add_flag = !water_add_flag;
sendFlag = 1;
Serial.printf("[DEBUG] Button3 pressed! sendFlag=%d\n", sendFlag);
}
}
// 泵控制回调
void button4_callback(const String & state) {
if (state == BLINKER_CMD_BUTTON_PRESSED) {
pump_flag = !pump_flag;
sendFlag = 1;
Serial.printf("[DEBUG] Button4 pressed! sendFlag=%d\n", sendFlag);
}
}
// 喂食按键回调
void button5_callback(const String & state) {
if (state == BLINKER_CMD_BUTTON_PRESSED) {
feed_flag = !feed_flag;
sendFlag = 1;
Serial.printf("[DEBUG] Button5 pressed! sendFlag=%d\n", sendFlag);
}
}
// 数据发送函数
void sendDataToSTM32()
{
sendBuffer[0] = FRAME_HEADER;
sendBuffer[1] = cooling_flag;
sendBuffer[2] = heating_flag;
sendBuffer[3] = water_add_flag;
sendBuffer[4] = pump_flag;
sendBuffer[5] = feed_flag;
sendBuffer[6] = calculateChecksum(sendBuffer, 6); // 校验和
sendBuffer[7] = FRAME_TAIL;
SerialSTM32.write(sendBuffer, SEND_SIZE); // 发送数据
Serial.println("==================================\n");
for(int i=0; i<=7; i++)
{
Serial.printf("sendBuffer: %02X ", sendBuffer[i]);
SerialSTM32.printf("%02X ", sendBuffer[i]);
}
Serial.println("==================================\n");
}
void setup()
{
Serial.begin(115200);
BLINKER_DEBUG.stream(Serial);
SerialSTM32.begin(115200, SERIAL_8N1, STM32_RX_PIN, STM32_TX_PIN);
Blinker.begin(auth, ssid, pswd);
// Blinker.attachData(dataRead);
Button1.attach(button1_callback); // 制冷
Button2.attach(button2_callback); // 加热
Button3.attach(button3_callback); // 加水
Button4.attach(button4_callback); // 泵
Button5.attach(button5_callback); // 喂食
}
void loop()
{
Blinker.run();
// 初始化摄像头(仅一次)
if (Blinker.connected() && !setup_camera)
{
setupCamera();
setup_camera = true;
Blinker.printObject("video", "{\"str\":\"mjpg\",\"url\":\"http://"+ WiFi.localIP().toString() + "\"}");
}
// 发送控制指令
if(sendFlag == 1)
{
sendDataToSTM32();
sendFlag = 0; // 重置发送标志
}
// 接收数据包
while(SerialSTM32.available())
{
uint8_t c = SerialSTM32.read();
if(millis() - last_rx_time > 50)
{
rx_index = 0;
packet_started = false;
}
last_rx_time = millis();
if(c == 0x7B) // 帧头
{
packet_started = true;
rx_index = 0; // 重置索引
rx_buffer[rx_index++] = c; // 存储帧头
continue;
}
if(packet_started)
{
rx_buffer[rx_index++] = c;
if(rx_index >= sizeof(SensorPacket))
{
packet_started = false;
SensorPacket *packet = (SensorPacket *)rx_buffer;
if(packet->frame_tail == 0x7D) // 帧尾
{
// 处理数据包
Temp.print(packet->temperature);
TDS.print(packet->tds_value);
Height.print(packet->height);
}
cooling_flag = packet->cold_enable; // 更新控制标志
heating_flag = packet->heat_enable; // 更新控制标志
water_add_flag = packet->water_add; // 更新控制标志
pump_flag = packet->pump_active; // 更新控制标志
feed_flag = packet->feed_trigger; // 更新控制标志
Button1.print(cooling_flag ? "ON" : "OFF");
Button2.print(heating_flag ? "ON" : "OFF");
Button3.print(water_add_flag ? "ON" : "OFF");
Button4.print(pump_flag ? "ON" : "OFF");
Button5.print(feed_flag ? "ON" : "OFF");
rx_index = 0; // 重置索引
}
}
}
// Button1状态更新(制冷)
if(cooling_flag) {
Button1.icon("icon_1");
Button1.color("#FFFFFF");
Button1.text("START");
} else {
Button1.icon("icon_1");
Button1.color("#FFFFFF");
Button1.text("STOP");
}
Button1.print();
// Button2状态更新(加热)
if(heating_flag) {
Button2.icon("icon_1");
Button2.color("#FFFFFF");
Button2.text("START");
} else {
Button2.icon("icon_1");
Button2.color("#FFFFFF");
Button2.text("STOP");
}
Button2.print();
// Button3状态更新(加水)
if(water_add_flag) {
Button3.icon("icon_1");
Button3.color("#FFFFFF");
Button3.text("START");
} else {
Button3.icon("icon_1");
Button3.color("#FFFFFF");
Button3.text("STOP");
}
Button3.print();
// Button4状态更新(泵)
if(pump_flag) {
Button4.icon("icon_1");
Button4.color("#FFFFFF");
Button4.text("START");
} else {
Button4.icon("icon_1");
Button4.color("#FFFFFF");
Button4.text("STOP");
}
Button4.print();
// Button5状态更新(喂食)
if(feed_flag) {
Button5.icon("icon_1");
Button5.color("#FFFFFF");
Button5.text("START");
} else {
Button5.icon("icon_1");
Button5.color("#FFFFFF");
Button5.text("STOP");
}
Button5.print();
}
4、现象与总结
单片机部分最重要是定义帧格式,设置发送数据给串口看帧是否准确,ESP32端若只是显示数据那么不是很难只需要接受并解析数据,若是要按键回调数据稍复杂一些需要定义回调函数与绑定链接,Blinker也需要设置合适的组件,同时Blinker按键按下单片机需要有一个响应延迟最好按住按键不松手。