这个项目本质上构建了一套基于ESP32芯片的无线传感器网络系统。通过ESP-NOW协议,我们实现了两块ESP32开发板之间的直接通信,无需依赖传统的Wi-Fi路由器。发射端(S)集成了MPU6050六轴运动传感器,负责采集三维空间中的加速度数据;接收端(R)则配备了OLED显示屏和WS2812B RGB灯,用于实时反馈姿态信息。
ESP-NOW是乐鑫公司开发的一种短距离无线通信协议,工作在2.4GHz频段,具有低功耗、低延迟的特点。与常规Wi-Fi通信相比,它省去了复杂的握手过程,平均延迟可以控制在3-5毫秒以内。这使得它特别适合需要快速响应的物联网应用场景,比如我们这个姿态遥测系统。
注意:ESP-NOW虽然基于Wi-Fi底层协议,但它工作在MAC层,不需要IP地址分配和TCP/IP协议栈,这也是它能实现毫秒级响应的关键原因。
硬件选型方面,我们使用了以下核心组件:
这套系统最有趣的应用是后期开发的"预言球"功能。通过算法检测剧烈摇晃动作,系统会随机显示预设答案,包括一个特殊的"Little Moon"隐藏款。这种互动设计展示了无线传感技术在人机交互中的创意应用可能。
发射端需要将ESP32与MPU6050传感器正确连接。MPU6050采用I2C通信协议,标准连接方式如下:
| ESP32引脚 | MPU6050引脚 | 线色建议 |
|---|---|---|
| 3.3V | VCC | 红色 |
| GND | GND | 黑色 |
| GPIO21 | SDA | 蓝色 |
| GPIO22 | SCL | 黄色 |
实际接线时需要注意:
接收端需要同时连接OLED显示屏和RGB灯:
OLED连接方案:
| ESP32引脚 | OLED引脚 | 功能说明 |
|---|---|---|
| 3.3V | VCC | 电源正极 |
| GND | GND | 电源负极 |
| GPIO21 | SDA | 数据线 |
| GPIO22 | SCL | 时钟线 |
WS2812B连接方案:
| ESP32引脚 | RGB灯引脚 | 备注 |
|---|---|---|
| GPIO16 | DIN | 数据输入 |
| 5V | VCC | 建议外接电源 |
| GND | GND | 必须共地 |
重要提示:WS2812B工作电流较大(全亮时约60mA/颗),不建议直接使用ESP32的3.3V引脚供电,否则可能导致电压不稳。理想方案是使用外部5V电源,同时确保与ESP32共地。
ESP-NOW工作在Wi-Fi的MAC层,其协议栈结构如下:
code复制应用层
ESP-NOW协议层
Wi-Fi MAC层
PHY射频层
与传统Wi-Fi通信相比,它省略了TCP/IP协议栈和HTTP/MQTT等应用层协议,数据包更小,处理更快。每个数据包包含:
在发送端代码中,我们需要先注册接收端的MAC地址:
cpp复制uint8_t broadcastAddress[] = {0x12, 0x34, 0x56, 0x78, 0x90, 0xAB}; // 替换为实际MAC
esp_now_peer_info_t peerInfo;
memcpy(peerInfo.peer_addr, broadcastAddress, 6);
peerInfo.channel = 0; // 自动选择信道
peerInfo.encrypt = false; // 不启用加密
if (esp_now_add_peer(&peerInfo) != ESP_OK) {
Serial.println("配对失败");
return;
}
发送数据时,我们需要将传感器读数打包成结构体:
cpp复制typedef struct struct_message {
float accelX;
float accelY;
float accelZ;
} struct_message;
struct_message sensorData;
void loop() {
sensors_event_t a, g, temp;
mpu.getEvent(&a, &g, &temp);
sensorData.accelX = a.acceleration.x;
sensorData.accelY = a.acceleration.y;
sensorData.accelZ = a.acceleration.z;
esp_now_send(broadcastAddress, (uint8_t *)&sensorData, sizeof(sensorData));
delay(10); // 控制发送频率
}
接收端采用事件驱动模式,通过回调函数处理数据:
cpp复制void OnDataRecv(const esp_now_recv_info_t *info, const uint8_t *data, int len) {
if (len == sizeof(struct_message)) {
struct_message receivedData;
memcpy(&receivedData, data, len);
// 更新显示
displayAccel(receivedData.accelX, receivedData.accelY, receivedData.accelZ);
// 控制RGB灯
updateLED(receivedData.accelX);
}
}
MPU6050原始数据存在零偏误差,建议在上电时进行自动校准:
cpp复制void calibrateMPU6050() {
float xSum = 0, ySum = 0, zSum = 0;
const int samples = 500;
for(int i=0; i<samples; i++) {
sensors_event_t a, g, temp;
mpu.getEvent(&a, &g, &temp);
xSum += a.acceleration.x;
ySum += a.acceleration.y;
zSum += a.acceleration.z;
delay(5);
}
offsetX = xSum / samples;
offsetY = ySum / samples;
offsetZ = zSum / samples - 9.81; // 减去重力加速度
}
为提高显示效果,我们可以实现以下增强功能:
1. 数据平滑滤波:
cpp复制#define FILTER_SIZE 5
float filterBuffer[FILTER_SIZE];
int filterIndex = 0;
float applyFilter(float newValue) {
filterBuffer[filterIndex] = newValue;
filterIndex = (filterIndex + 1) % FILTER_SIZE;
float sum = 0;
for(int i=0; i<FILTER_SIZE; i++) {
sum += filterBuffer[i];
}
return sum / FILTER_SIZE;
}
2. 可视化仪表盘:
cpp复制void drawMeter(float value, int x, int y, int width, int height) {
int barWidth = map(abs(value), 0, 10, 0, width/2);
display.fillRect(x + width/2, y, -barWidth, height, SSD1306_WHITE); // 左侧
display.fillRect(x + width/2, y, barWidth, height, SSD1306_WHITE); // 右侧
}
WS2812B可以实现丰富的视觉效果,以下是改进后的控制逻辑:
cpp复制void updateLED(float accelX) {
float intensity = map(abs(accelX), 0, 10, 0, 255);
intensity = constrain(intensity, 0, 255);
if(accelX > 1.0) { // 向右倾斜
pixels.setPixelColor(0, pixels.Color(intensity, 0, 0));
}
else if(accelX < -1.0) { // 向左倾斜
pixels.setPixelColor(0, pixels.Color(0, 0, intensity));
}
else { // 水平状态
pixels.setPixelColor(0, pixels.Color(0, intensity/2, 0));
}
pixels.show();
}
核心是通过计算加速度矢量和来检测剧烈运动:
cpp复制float getTotalAccel() {
return sqrt(sq(incomingReadings.x) +
sq(incomingReadings.y) +
sq(incomingReadings.z));
}
bool checkShaking() {
static float threshold = 15.0; // 摇晃阈值
static unsigned long lastShakeTime = 0;
if(getTotalAccel() > threshold) {
lastShakeTime = millis();
return true;
}
return (millis() - lastShakeTime) < 500; // 500ms余量
}
改进后的答案生成器增加了权重控制:
cpp复制String getRandomAnswer() {
int weights[] = {30, 30, 20, 15, 4, 1}; // 各答案权重
int total = 0;
for(int i=0; i<answerCount; i++) {
total += weights[i];
}
int randomValue = random(total);
int cumulative = 0;
for(int i=0; i<answerCount; i++) {
cumulative += weights[i];
if(randomValue < cumulative) {
return answers[i];
}
}
return answers[0]; // 默认返回
}
OLED显示增加了过渡动画:
cpp复制void showAnswer(String answer) {
// 清屏动画
for(int y=0; y<64; y+=4) {
display.drawLine(0, y, 127, y, SSD1306_BLACK);
display.display();
}
// 文字渐显
for(int i=0; i<=10; i++) {
display.clearDisplay();
display.setTextColor(SSD1306_WHITE);
display.setTextSize(2);
// 计算居中位置
int16_t x1, y1;
uint16_t w, h;
display.getTextBounds(answer, 0, 0, &x1, &y1, &w, &h);
int x = (128 - w) / 2;
int y = (64 - h) / 2;
// 绘制文字阴影
display.fillRect(x, y, w, h, SSD1306_WHITE);
display.setTextColor(SSD1306_BLACK);
display.setCursor(x, y);
display.print(answer);
display.display();
delay(50);
}
}
症状: 数据时断时续,接收端响应延迟大
解决方案:
cpp复制void sendDataWithRetry(struct_message data, int maxRetry=3) {
for(int i=0; i<maxRetry; i++) {
esp_err_t result = esp_now_send(broadcastAddress, (uint8_t*)&data, sizeof(data));
if(result == ESP_OK) return;
delay(5);
}
Serial.println("发送失败");
}
症状: 静止时加速度读数不为零且缓慢变化
解决方案:
cpp复制float applyTemperatureCompensation(float raw, float temp) {
static float tempCoeff = 0.01; // 需实验测定
return raw - (temp - 25.0) * tempCoeff;
}
症状: 刷新时出现上一帧残留图像
解决方案:
cpp复制void clearScreen() {
display.fillRect(0, 0, 128, 64, SSD1306_BLACK);
display.display();
delay(1);
display.fillRect(0, 0, 128, 64, SSD1306_BLACK);
}
对于电池供电场景,可实施以下优化:
cpp复制void setLowPowerMode() {
// 配置MPU6050低功耗模式
mpu.setSampleRate(10); // 10Hz采样率
mpu.setSleepEnabled(false);
// 设置ESP32 WiFi功率
esp_wifi_set_max_tx_power(8); // 8 = 2dBm
// 配置CPU频率
setCpuFrequencyMhz(80);
}
扩展为多节点网络时需要考虑:
cpp复制#define MAX_DEVICES 5
uint8_t deviceMacs[MAX_DEVICES][6] = {
{0x12,0x34,0x56,0x78,0x90,0xAB},
// 添加其他设备MAC
};
void sendToAllDevices(struct_message data) {
for(int i=0; i<MAX_DEVICES; i++) {
esp_now_send(deviceMacs[i], (uint8_t*)&data, sizeof(data));
}
}
通过串口将数据发送到PC进行记录和分析:
cpp复制void logToSerial(struct_message data) {
static uint32_t counter = 0;
Serial.print(counter++);
Serial.print(",");
Serial.print(data.accelX);
Serial.print(",");
Serial.print(data.accelY);
Serial.print(",");
Serial.println(data.accelZ);
}
在PC端可以使用Python实时绘图:
python复制import serial
import matplotlib.pyplot as plt
ser = serial.Serial('COM3', 115200)
plt.ion()
fig, ax = plt.subplots(3)
x, y1, y2, y3 = [], [], [], []
while True:
line = ser.readline().decode().strip()
if line.count(',') == 3:
_, v1, v2, v3 = map(float, line.split(','))
x.append(len(x))
y1.append(v1)
y2.append(v2)
y3.append(v3)
ax[0].plot(x, y1, 'r')
ax[1].plot(x, y2, 'g')
ax[2].plot(x, y3, 'b')
plt.pause(0.01)
基于加速度模式识别简单手势:
cpp复制enum Gesture {NONE, TAP, SWIPE_LEFT, SWIPE_RIGHT};
Gesture detectGesture(float *accelX, int size) {
float threshold = 2.0;
int peakCount = 0;
for(int i=1; i<size-1; i++) {
if(accelX[i]>threshold && accelX[i]>accelX[i-1] && accelX[i]>accelX[i+1]) {
peakCount++;
}
}
if(peakCount == 1) return TAP;
float sum = 0;
for(int i=0; i<size; i++) sum += accelX[i];
if(sum < -size*0.5) return SWIPE_LEFT;
if(sum > size*0.5) return SWIPE_RIGHT;
return NONE;
}
将系统改造成游戏手柄:
cpp复制typedef struct {
float joyX;
float joyY;
bool buttonA;
bool buttonB;
} GamepadState;
void sendGamepadState() {
GamepadState state;
state.joyX = map(incomingReadings.x, -10, 10, -128, 127);
state.joyY = map(incomingReadings.y, -10, 10, -128, 127);
// 通过摇晃模拟按钮按下
state.buttonA = (getTotalAccel() > 15.0);
state.buttonB = (abs(incomingReadings.z) > 8.0);
esp_now_send(broadcastAddress, (uint8_t*)&state, sizeof(state));
}
结合陀螺仪数据进行三维姿态估计:
cpp复制void updateOrientation(sensors_event_t *accel, sensors_event_t *gyro) {
static float pitch = 0, roll = 0;
static unsigned long lastTime = 0;
float dt = (millis() - lastTime) / 1000.0;
lastTime = millis();
// 互补滤波
float accPitch = atan2(accel->acceleration.y, accel->acceleration.z) * 180/PI;
float accRoll = atan2(accel->acceleration.x, accel->acceleration.z) * 180/PI;
pitch = 0.98 * (pitch + gyro->gyro.x * dt) + 0.02 * accPitch;
roll = 0.98 * (roll + gyro->gyro.y * dt) + 0.02 * accRoll;
Serial.print("Pitch: "); Serial.print(pitch);
Serial.print(" Roll: "); Serial.println(roll);
}
在实际部署这套系统时,我发现ESP32的GPIO21和GPIO22虽然默认是I2C引脚,但在某些开发板上可能与闪存芯片共用,导致初始化失败。解决方案是使用其他引脚并手动指定Wire实例:
cpp复制#define I2C_SDA 33
#define I2C_SCL 32
TwoWire customWire = TwoWire(1);
void setup() {
customWire.begin(I2C_SDA, I2C_SCL);
mpu.begin(0x68, &customWire);
}
另一个实用技巧是在ESP-NOW通信中添加简单的数据校验。虽然ESP-NOW本身有CRC校验,但应用层可以增加额外保护:
cpp复制typedef struct {
uint16_t checksum;
uint32_t timestamp;
float accel[3];
} SafeMessage;
SafeMessage createSafeMessage(float x, float y, float z) {
SafeMessage msg;
msg.timestamp = millis();
msg.accel[0] = x;
msg.accel[1] = y;
msg.accel[2] = z;
// 简单校验和
uint8_t *p = (uint8_t*)&msg + 2; // 跳过checksum字段
msg.checksum = 0;
for(int i=0; i<sizeof(SafeMessage)-2; i++) {
msg.checksum += *p++;
}
return msg;
}
bool validateMessage(SafeMessage msg) {
uint8_t *p = (uint8_t*)&msg + 2;
uint16_t sum = 0;
for(int i=0; i<sizeof(SafeMessage)-2; i++) {
sum += *p++;
}
return sum == msg.checksum;
}