文章目录
1 项目简介
本项目是基于ESP32单片机的智能物联网小车,旨在参与第二届人工智能创新技能竞赛(智能物联网小车赛道),此处见官网链接。小车具备循迹、速度控制、光照强度检测、距离测量、灯光控制等功能,并通过蓝牙或WiFi与上位机进行通信,实现远程操控和数据处理。
- 循迹: 利用红外反射传感器实现自主循迹,完成赛道上的行驶任务。
- 速度控制: 通过上位机或按键设置目标车速,并控制小车以目标速度行驶。
- 光照强度检测: 利用光敏电阻传感器测量光照强度,并在车载显示屏和上位机上显示结果。
- 距离测量: 利用超声波传感器测量小车与路障之间的距离,并控制小车在指定距离内停车。
- 灯光控制: 支持远程控制和自动控制两种模式,可控制RGB灯的颜色和动作。
- 远程操控: 通过蓝牙或WiFi与上位机进行通信,实现远程操控小车行驶。
- 主控: ESP32S3核心板;
- 电机驱动: TB6612FNG模块;
- 循迹传感器: TCRT5000模块;
- 电机: N20 减速电机;
- 电池: 单节18650 电池;
- 传感器接口: 支持拓展光照强度传感器、超声波传感器等
- 通信方式: 蓝牙、WiFi
- 传感器支架 见附件
功能测试代码:AIA-T Car Test Program
开源资料地址:AIA-T-Car 巡线小车 – 立创开源硬件平台
1.1 组装教程
小车细节图


1.2 基本循迹功能
1.3 遥控与摄像头图传
2 小车运动学模型
2.1 基础运动学模型
运动形式
轮式机器人一种常见的构型如下,由两个独立驱动的轮子驱动(有一个或多个随动轮来支撑),如下图所示:

o_motion为圆弧运动圆心
o_robot为机器人几何质心
v为小车车体质心运动速度
ω为小车绕轴转速
当v1=v2时
机器人作直线运动,此时圆弧运动圆心o_motion位于无穷远处,v=v1=v2
当v1!=v2时
当v1与v2为同方向时, 机器人绕远处圆心o_motion,做圆周运动,机器人作圆弧运动,此时有

其中H为两个驱动轮的距离, R为两轮中心的运动半径。
当v1,v2异号或其中一个为零时
差动机器人作绕自身中心的自转(因为力矩中心位于机器人两轮中心),机器人中处的线速度v为零,其角速度ω为:

通过上式可求解机器人做圆周运动时两轮速度与两轮中心处的v,w之间的解算关系。
已知v1,v2求解v,ω称作正运动学:

已知v,ω求解v1,v2称作逆运动学:

上面便是机器人当中的正逆解的概念,也完成了轮速与运动线速度与角速度之间的推导。
2.2 AIA-T 小车 逆运动学模型

如图所示为小车的设计尺寸(实际尺寸需根据实际装配情况测量),可得小车的轮距为95.4mm。
则可编写小车的逆运动学算法,代码如下:
// 定义轮子之间的距离(轴距),单位:mm #define WHEEL_BASE 95.4 // 逆运动学函数 void inverse_kinematics(double v, double omega, double *left_wheel_speed, double *right_wheel_speed) { // 计算左右轮的速度 *left_wheel_speed = v - omega * WHEEL_BASE / 2.0; *right_wheel_speed = v + omega * WHEEL_BASE / 2.0; }
该函数可传入目标速度v和ω,传入左轮速度和右轮速度结果变量的指针。
3. 光电循迹小车循迹原理
3.1循迹原理

小车循迹的原理是根据前面5个光电管所读取的5个数据进行检测地面上的黑线,当传感器检测到黑线时,会发出信号,然后根据这些信号判断小车的位置,并通过调整左右轮子的速度来纠正小车的行进方向,确保小车能够沿着黑线行驶。
简单讲就是,通过5个光电管输出的不同信号进行排列并判断小车的状态,从而进行不一样的调整
3.2光电管

对于输出信号的光电管需要进行适当的了解:光电管是一种利用光电效应将光能转换为电能的敏感器件。就是,其会根据不同的光亮程度输出不一样的信号。
而输出的信号又有区别,分为模拟量和数字量。他们最大的区别就是模拟量是连续的量,可以取无限多个值,而数字量是离散的,只能取有限个特定的值。因为,模拟量输出的是一个连续变化的电压或电流信号,这个信号的强度与接收到的光强度成正比,数字量输出的是数字信号,通常是0和1,表示光是否存在或者是否达到某个阈值。
特别的,光电管后面红色的键帽就是控制什么输出信号的,图中,就是模拟量输出信号。
3.3 信息处理

正如之前所说的,通过5个光电管输出的不同信号进行排列并判断小车的状态,从而进行不一样的调整。这里,有5个光电管,设为:D1,D2,D3,D4,D5。则他们分别乘上10000,1000,100,10,1,可得到一个代表小车当前状态的值,然后就可以通过这个值进行判断,是否进行什么操作。比如:00100,也就是100,就是正中间的光电管检测到了黑线,就不做任何变化。除了这些还引进了变量error和a,error是用来判断偏移方向,用来判断偏移程度的。后面代码再详细讲怎么用。
3.4 小车转速调整

这里就是error和a的用处了,两边电机分别用一个基础值加与减error * a,从而实现两轮的差速,从而实现小车微调。
3.5 实物展示

看oled屏,前两行,第一行是左轮pwm值,第二行是右轮pwm值,第三行是五个光电管的模拟量归一化后的值,第三行是对归一化后的值改成数字量的值。而为什么要读取模拟量,再改成数字量,因为这样的话,我们的阈值就能自己控制了,更方便调车。
这是00100的情况,因为我设的阈值为15,那我D3的值51大于15,也就变成1了,其余的都小于15,也就是0了,可以看到两边的pwm是一样的,也就是直行。




上面四张图分别是向左或者右偏移的不同情况,分别就是D1,D2,D4,D5在黑线上的情况,这里明显看到两边pwm值是不一样的,而偏移程度不一样,pwm差也会更大,因为我改了a的大小,而error的正负则决定了偏移的方向

这就是在直角的情况:00111,这里我将a的值调的很大,导致轮子pwm,一为255,另一个为0,则实现了直角转弯的效果。
4 车载RGB小灯控制
4.1 RGB LED的介绍
首先介绍一下关于rgb的部分:
RGB LED 元件通常指的是能够发出红、绿、蓝三种颜色的电子元件,原理是通过混合不同比例的红、绿、蓝光线来生成各种颜色。
我们的RGB LED则用的是WS2812B 芯片
以下是关于该芯片的引脚定义和引出端功能:包括数据输出与输入,逻辑电源,供电电源,接地

4.2 调用RGB的库
而调用RGB的库相当多,我们这里主要用的是Adafruit_NeoPixel库,它是第三方库,也是需要下载的:


Pio插件中Libraries中,直接搜索Adafruit_NeoPixel即可,第一个39万下载的就是了。
4.3 代码实现
Rgb运用的代码主要的组成包括:引入头文件,定义引脚,创建 NeoPixel 对象,初始化 NeoPixel,设置亮度,控制 LED 颜色并显示:
引入头文件

如果下载好了第三方库,直接如上调用
定义引脚

定义我们所用RGB的引脚,方便后面的建立实例对象,我们两RGB左边就是g6,右边就是g7
创建 NeoPixel 对象

分别创建了两个 Adafruit_NeoPixel 类的实例 RGB1 和 RGB2,用于控制两个不同的 NeoPixel LED。构造函数 Adafruit_NeoPixel 有三个参数:
- 像素数量 (int numPixels):这个参数指定了要控制的 NeoPixel LED 的数量。在这个例子中,1 表示每个对象只控制一个 LED。
- 数据引脚 (int pin):这个参数指定了连接到 NeoPixel LED 数据输入的微控制器引脚。RGB1 和 RGB2 是之前定义的宏或常量,它们代表了连接到每个 NeoPixel LED 的特定引脚编号。
- 像素类型 (uint8_t type):这个参数指定了 NeoPixel LED 的颜色编码顺序和数据传输速率。它是由两个预定义的常量组合而成的:
- NEO_GRB:表示颜色编码顺序是绿色、红色、蓝色(GRB)。这是最常见的编码顺序,但根据不同的 NeoPixel 产品,还可能是 RGB、GRBW(加上白色)等。
- NEO_KHZ800:表示数据传输速率是 800 kHz。这是 NeoPixel 的标准通信速率,但也有其他速率如 400 kHz(NEO_KHZ400)。
初始化 NeoPixel

初始化是库定义好的,直接用就可以了
设置亮度

这里的方法函数就是调节rgb的亮度,256是满亮度,64就是1/4了
控制 LED 颜色并显示

最后也是最关键的一步,
.clear()将RGB颜色设置为 (0, 0, 0),即关闭红色、绿色和蓝色通道。
setPixelColor(0, rgb1.Color(0, 0, 255))给RGB三个通道分别给不一样的值,以此实现不一样的颜色显示
第一个参数是像素的索引。在我们的实例中,0 是第一个像素的索引。由于 rgb1 对象被初始化为只包含一个像素(Adafruit_NeoPixel rgb1(1, RGB1, NEO_GRB + NEO_KHZ800);),所以只有一个像素,其索引为 0。
第二个参数是像素的颜色,由 .Color() 方法返回。.Color() 方法接受三个参数,分别代表红色、绿色和蓝色的亮度值,范围从 0 到 255。在这个例子中,.Color(0, 0, 255) 设置颜色为蓝色,因为只有蓝色通道的亮度被设置为最大。
.show() 将所有通过 .setPixelColor() 设置的颜色更改应用到实际的 NeoPixel LED 上,这样将会发送数据到 LED,使其显示新的颜色。
4.4 整体思路
用一个switch放循环里
建立一个标志位
标志位的不同值为RGB的不同亮情况
如:
直行,标志位赋予0: RGB亮情况为两边RGB常亮
左转,标志位赋予1: RGB亮情况为左RGB闪烁
右边,标志位赋予2: RGB亮情况为右RGB闪烁
5 速度闭环控制
关于闭环控制的思想可以查看【智能系统设计教程 阶段二 7 智能控制概述】相关内容,此处不再介绍。闭环控制主要就是根据被控对象的状态动态调整控制量的输出从而让被控对象在面对外界扰动的作用时依旧维持在我们想要的目标上。
要实现闭环控制需要有三个环节:感知→计算→执行,分别对应了传感器、控制器(控制算法)、执行器。具体到我们的小车速度闭环控制来看,传感器需要感受小车车轮的转速、控制器(控制算法)需要根据当前速度和目标速度的误差来计算需要何种大小的控制量、执行器需要根据控制量来控制电机进而影响车轮转速。则:
传感器:编码器
控制器(控制算法):PID算法
执行器:电机
5.1 速度获取

其中编码器和电机集成在一起,在电机的尾部有一个黑色圆形块,这是一个径向磁体(什么是径向磁铁见下图),在白色电路板上有霍尔传感器可以感受磁场变化。这里我们简单理解一下即磁铁转一圈,霍尔传感器会输出7个脉冲信号。

由于这个磁体是连接在电机输出轴的,而电机输出轴到车轮需要经过减速器机构,因此车轮转速和脉冲之间还需要换算。

AIA-T车模使用的是减速比为50的N20电机。

因此车轮旋转一周,则编码器应输出350个脉冲。而根据脉冲计算轮速则有两种方法,分别为周期法和频率法。
5.1.1 频率法计算轮速
在给定时间内,计编码器产生的脉冲数。然后由下式求出其转速

其中N1是给定时间内编码器产生的脉冲数量,t是给定的时间长度,N是轮子转一圈所应产生的脉冲数量。
需要注意的是这里计算出的轮速是转速,还需乘轮子的周长才能得到线速度。
例如 设某编码器的额定工作参数是 N=2048 脉冲/转,在0.2 S时间内测得8192个脉冲,求其转速。
解:根据上式有:

5.1.2 周期法求转速
通过计数编码器一个脉冲间隔内(半个脉冲周期)标准时钟脉冲个数来计算其转速,因此,要求时钟脉冲的频率必须高于编码器脉冲的频率。

同时也要注意以上两种方法只是计算出了速度数值,未作方向辨别。如何辨别方向,我们可以以光学编码器为例来了解一下。
脉冲盘式编码器是在圆盘上开有内、外两圈相等角矩的缝隙,内、外圈的相邻两缝隙之间的距离错开半条缝宽。在内外圈之外的某一径向位置,也开有一缝隙,表示码盘的零位,码盘每转一圈,零位对应的光敏元件就产生一个脉冲,称为“零位脉冲”。


光栏板上有两个狭缝,其距离是码盘上两个相邻狭缝距离的四分之一倍,并设置了两组对应的光敏元件,对应图中的A、B两个信号(四分之一间距差保证了两路信号的相位差为90°,便于辨向),C信号代表零位脉冲。
当码盘随被测工作轴转动时,每转过一个缝隙就发生一次光线明暗的变化,通过光敏元件产生一次电信号的变化,所以每圈码道上的缝隙数将等于其光敏元件每一转输出的脉冲数。利用计数器记录脉冲数,就能反映码盘转过的角度。

光敏元件1和2的输出信号经放大整形后,产生矩形脉冲P1和P2,它们分别接到D触发器的D端和C端,D触发器在C脉冲(即P2 )的上升沿触发。两个矩形脉冲相差四分之一个周期(或相位相差90°)。
5.1.3 转速测量编程
#include <ESP32Encoder.h> #define L_Encoder_A 11 //左编码器A相 #define L_Encoder_B 12 //左编码器B相 #define R_Encoder_A 13 //右编码器A相 #define R_Encoder_B 14 //右编码器B相 #define diameter 18 //直径(mm) #define Control_Frequency 100 //读取倍频 #define EncoderMultiples 4 //倍频数 #define Reduction_Ratio 50 //减速比 #define Encoder_precision 7 //编码器精度 //初始化编码器 void MotorEncoder_Init(void) { //初始化编码器,向上计数 ESP32Encoder::useInternalWeakPullResistors = puType::up; encoder_L.attachSingleEdge(L_Encoder_A, L_Encoder_B); encoder_L.clearCount(); encoder_R.attachSingleEdge(R_Encoder_A, R_Encoder_B); encoder_R.clearCount(); //绑定中断函数,每10ms进入一次中断 drive_control_intterupt.attach_ms(interrupt_time, PID_TIM_IRQHandler); } //左速度 float Get_Left_Velocity_Form_Encoder(int encoder_value_L) { float Rotation_Speed_L; float Velocity_Left; //电机转速 转速=编码器读数(10ms读一次) * 读取倍频(100Hz)/倍频数/减速比/编码器精度 Rotation_Speed_L = float(encoder_value_L)*Control_Frequency/EncoderMultiples/Reduction_Ratio/Encoder_precision; //编码器转速(mm/s) = 转速 * 周长 Velocity_Left = Rotation_Speed_L * (PI * diameter); return Velocity_Left; } //右速度 float Get_Right_Velocity_Form_Encoder_R(int encoder_value_R) { float Rotation_Speed_R; float Velocity_Right; //电机转速 转速=编码器读数(10ms读一次) * 读取倍频(100Hz)/倍频数/减速比/编码器精度 Rotation_Speed_R = float(encoder_value_R)*Control_Frequency/EncoderMultiples/Reduction_Ratio/Encoder_precision; // Serial.printf("Rotation_Speed_R =%f\n",Rotation_Speed_R); //编码器转速(mm/s) = 转速 * 周长 Velocity_Right = Rotation_Speed_R * (PI * diameter); return Velocity_Right; }
5.2 PID闭环转速控制
关于电机的PWM波转速控制此处不再赘述,可查看 智能系统设计教程 【阶段一】2 PWM与电机控制 – AIMC智能运控实验室章节内容。
PID算法包括比例、微分、积分三个环节。这三个环节分别是以误差、误差的微分、误差的积分为变量进行计算的。而在小车轮速闭环控制中,所谓误差就是通过编码器获取的实际轮速和目标速度之间的差值。
而PID算法的输出值则是控制电机的PWM占空比。假设占空比可调节范围为-255~+255。其中占空比的正负号代表轮子的旋转方向。我们可以编写一个C++类来实现PID控制器。
class PIDController { private: double Kp; // 比例系数 double Ki; // 积分系数 double Kd; // 微分系数 double prev_error; // 上一次的误差 double integral; // 积分项 double target_speed; // 目标速度,单位:mm/s public: PIDController(double Kp, double Ki, double Kd, double target_speed) : Kp(Kp), Ki(Ki), Kd(Kd), prev_error(0), integral(0), target_speed(target_speed) {} // 计算 PID 控制量 int compute(double current_speed) { // 计算误差 double error = target_speed - current_speed; // 计算积分项 integral += error; // 计算微分项 double derivative = error - prev_error; // 计算 PID 输出 double output = Kp * error + Ki * integral + Kd * derivative; // 更新上一次的误差 prev_error = error; // 限制输出在 -255 到 255 之间 if (output > 255) { output = 255; } else if (output < -255) { output = -255; } return static_cast<int>(output); } // 设置新的目标速度 void setTargetSpeed(double new_target_speed) { target_speed = new_target_speed; } };
而后实例化两个PIDController 类分别用于左右电机的控制。
PIDController L_Ctr(1.0, 0.1, 0.05, 0), R_Ctr(1.0, 0.1, 0.05, 0);
实例化时传入KI、KD、KP三个参数。
此后以一定的频率调用PIDController 类中的方法不断的根据实际轮速计算下一时刻应该输出的PWM占空比的值。
//对电机速度进行PI控制中断函数 void PID_TIM_IRQHandler(void) { if (run_flag) { //每次中断触发时读取编码器值 encoder_value_L = -encoder_L.getCount(); encoder_value_R = encoder_R.getCount(); //清除编码器本次读取的数值 encoder_L.clearCount(); encoder_R.clearCount(); Velocity_Left = -L_Filter.filter(Get_Left_Velocity_Form_Encoder(encoder_value_L)); //获取左电机速度 Velocity_Right = R_Filter.filter(Get_Right_Velocity_Form_Encoder_R(encoder_value_R)); //获取右电机速度 //对左右电机进行闭环控制 L_Ctr.setTargetSpeed(TargetVelocity_L); R_Ctr.setTargetSpeed(TargetVelocity_R); int pwm_duty_L = L_Ctr.compute(Velocity_Left); int pwm_duty_R = R_Ctr.compute(Velocity_Right); Serial.printf("L:%d|R:%d\r\n", pwm_duty_L, pwm_duty_R); Set_Pwm(pwm_duty_L, pwm_duty_R); //打印左右速度波形 Serial.printf("channels: %f, %f, %d, %d\n",Velocity_Left, Velocity_Right, TargetVelocity_L, TargetVelocity_R); } else { Set_Pwm(0, 0); } }
6 图传功能
AIA-T小车车模预留了一个摄像头接口,支持连接OV2640摄像头,已测试下列摄像头模块:
OV2640摄像头OV5640模块200W像素硬件兼容友商 STM32F103ZE/F4/F7-淘宝网



由于ESP32的引脚数量有限,小车连接摄像头时请勿同时使用光电对管传感器、光敏电阻传感器。

例程采用esp_cam库驱动ov2640摄像头模块,关于esp_cam的说明可参考项目开源地址:espressif/esp32-camera,本教程提供一个基于UDP协议配合python上位机的图传例程。
#include <WiFi.h> #include "esp_camera.h" #include <WiFiUdp.h> #define CAM_PIN_PWDN -1 //power down is not used #define CAM_PIN_RESET 46 //software reset will be performed #define CAM_PIN_XCLK -1 #define CAM_PIN_SIOD 2 #define CAM_PIN_SIOC 1 #define CAM_PIN_D7 5 #define CAM_PIN_D6 18 #define CAM_PIN_D5 4 #define CAM_PIN_D4 17 #define CAM_PIN_D3 10 #define CAM_PIN_D2 16 #define CAM_PIN_D1 9 #define CAM_PIN_D0 15 #define CAM_PIN_VSYNC 20 #define CAM_PIN_HREF 3 #define CAM_PIN_PCLK 8 // =========================== // Enter your WiFi credentials // =========================== const char* ssid = "CMCC"; const char* password = "*************"; WiFiUDP udp1; const char* udpAddress = "192.168.3.83"; // Python 端的 IP const int udpPort = 12345; // 目标端口 int packetSize = 1024; // 每个数据包的最大大小(字节) const int serverPort = 12345; const char* serverIP = "192.168.3.83"; // 替换为 Python 服务端的 IP 地址 WiFiClient client; void setup() { Serial.begin(115200); delay(10000); Serial.setDebugOutput(true); Serial.println(); Moter_Init(); MotorEncoder_Init(); camera_config_t config; config.ledc_channel = LEDC_CHANNEL_0; config.ledc_timer = LEDC_TIMER_0; config.pin_pwdn = CAM_PIN_PWDN, config.pin_reset = CAM_PIN_RESET, config.pin_xclk = CAM_PIN_XCLK, config.pin_sccb_sda = CAM_PIN_SIOD, config.pin_sccb_scl = CAM_PIN_SIOC, config.pin_d7 = CAM_PIN_D7, config.pin_d6 = CAM_PIN_D6, config.pin_d5 = CAM_PIN_D5, config.pin_d4 = CAM_PIN_D4, config.pin_d3 = CAM_PIN_D3, config.pin_d2 = CAM_PIN_D2, config.pin_d1 = CAM_PIN_D1, config.pin_d0 = CAM_PIN_D0, config.pin_vsync = CAM_PIN_VSYNC, config.pin_href = CAM_PIN_HREF, config.pin_pclk = CAM_PIN_PCLK, config.xclk_freq_hz = 24000000; config.frame_size = FRAMESIZE_240X240 ; config.pixel_format = PIXFORMAT_JPEG; // for streaming //config.pixel_format = PIXFORMAT_RGB565; // for face detection/recognition config.grab_mode = CAMERA_GRAB_WHEN_EMPTY; config.fb_location = CAMERA_FB_IN_PSRAM;// 讲图像缓存在PSRAM中,无PSRAM需更改 config.jpeg_quality = 12; config.fb_count = 1; // camera init esp_err_t err = esp_camera_init(&config); if (err != ESP_OK) { Serial.printf("Camera init failed with error 0x%x", err); return; } sensor_t * s = esp_camera_sensor_get(); // initial sensors are flipped vertically and colors are a bit saturated if (s->id.PID == OV2640_PID) { s->set_vflip(s, 1); // flip it back s->set_brightness(s, 1); // up the brightness just a bit s->set_saturation(s, -2); // lower the saturation } WiFi.mode(WIFI_STA); WiFi.begin(ssid, password); while (!WiFi.isConnected()) { delay(500); Serial.print("."); } Serial.println("Connected"); Serial.print("IP Address:"); Serial.println(WiFi.localIP()); udp1.begin(udpPort); HttpServerInit(); } void sendImageUDP(const uint8_t* buffer, size_t length) { size_t offset = 0; while (offset < length) { size_t chunkSize = min((size_t)packetSize, length - offset); udp1.beginPacket(udpAddress, udpPort); udp1.write(buffer + offset, chunkSize); if (udp1.endPacket() <= 0) { Serial.println("Failed to send packet"); return; // 发送失败时,退出函数 } offset += chunkSize; delay(1); // 每个片段发送后加一点延时,避免网络拥塞 } // 发送结束标志 uint8_t endFlag = 0xFF; // 结束标志,可以根据需要选择其他值 udp1.beginPacket(udpAddress, udpPort); udp1.write(&endFlag, sizeof(endFlag)); udp1.endPacket(); } void loop() { camera_fb_t *pic = esp_camera_fb_get(); sendImageUDP(pic->buf, pic->len); esp_camera_fb_return(pic); delay(10); }
该例程会不断的获取一帧图像,并根据设置的最大数据帧大小将图像帧数据分割,而后通过UDP协议发送至指定的地址和端口上。对应的python上位机程序如下:
import socket import cv2 import numpy as np UDP_IP = "0.0.0.0" # 本地监听地址 UDP_PORT = 12345 # 与 ESP32 端的目标端口一致 BUFFER_SIZE = 1472 # 根据网络 MTU 进行设置,最大应为 1472 sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) sock.bind((UDP_IP, UDP_PORT)) # 调整图像亮度和对比度的函数 def adjust_brightness_contrast(image, alpha=1.2, beta=50): adjusted = cv2.convertScaleAbs(image, alpha=alpha, beta=beta) return adjusted # 调整图像尺寸的函数 def resize_image(image, width=640, height=480): resized = cv2.resize(image, (width, height), interpolation=cv2.INTER_LINEAR) return resized def receive_image(): img_data = bytearray() while True: try: # 接收来自 ESP32 的数据 data, addr = sock.recvfrom(BUFFER_SIZE) # 接收每个片段 # print(len(data)) if data: # 如果接收到数据 img_data.extend(data) # 检查是否接收到结束标志 if img_data and img_data[-1] == 0xFF: # 0xFF 是结束标志 # 去掉结束标志 img_data = img_data[:-1] # 确保转换为 NumPy 数组时数据有效 if len(img_data) > 0: np_data = np.frombuffer(img_data, dtype=np.uint8) frame = cv2.imdecode(np_data, cv2.IMREAD_COLOR) # 如果解码成功,显示图像 if frame is not None: # 调整亮度和对比度 frame = adjust_brightness_contrast(frame, alpha=1.5, beta=50) cv2.imshow('Received Image', frame) cv2.waitKey(1) else: print("Failed to decode image data") # 清空缓冲区,准备接收下一帧 img_data = bytearray() except Exception as e: print(f"Error receiving data: {e}") if __name__ == "__main__": receive_image()
7 基于WiFi HTTP协议的远程控制
7.1 小车端远程控制API
ESP32 是乐鑫公司推出的一款高度集成的物联网芯片,具有低功耗、高性能等特点,内置了丰富的通信接口,其中 WiFi 功能尤为强大。它支持 802.11b/g/n 标准,可工作在 STA(Station,客户端模式)、AP(Access Point,接入点模式)以及 STA+AP 共存模式。
7.1.1 工作模式
STA 模式:ESP32 作为客户端连接到现有的 WiFi 网络,就像手机连接到家里的无线路由器一样。在远程控制小车的场景中,ESP32 通常以 STA 模式连接到家庭或办公网络,以便与远程服务器或控制端进行通信。
AP 模式:ESP32 作为接入点,其他设备可以连接到它创建的 WiFi 网络。这种模式适用于需要独立组网的场景,例如小车自成一个网络,手机等控制设备直接连接到小车的 ESP32 热点进行控制。
STA+AP 共存模式:ESP32 既可以连接到其他 WiFi 网络,又可以创建自己的热点,实现更灵活的通信方式。
7.1.2 ESP32 WiFi 连接流程
在代码中,使用 WiFi.begin(ssid, password) 函数连接到指定的 WiFi 网络,ssid 是网络名称,password 是网络密码。通过 WiFi.isConnected() 函数检查连接状态,若未连接则循环等待,直到连接成功。示例代码如下:
WiFi.begin(ssid, password); while (!WiFi.isConnected()) { delay(500); Serial.print("."); } Serial.println("Connected"); Serial.print("IP Address:"); Serial.println(WiFi.localIP());
7.1.3 HTTP 协议概述
HTTP(Hypertext Transfer Protocol,超文本传输协议)是一种用于传输超文本的协议,它基于请求 – 响应模型,是互联网上应用最为广泛的一种网络协议。HTTP 协议工作在 TCP/IP 协议族的应用层,具有无状态、简单快速、灵活等特点。
GET:用于请求指定资源,通常用于获取数据,如获取网页内容、图片等。请求参数通常附加在 URL 后面,例如 http://example.com/api/data?id=1。

POST:用于向服务器提交数据,通常用于表单提交、文件上传等场景。请求数据放在请求体中,相对 GET 方法更安全,可传输的数据量也更大。在远程控制小车的代码中,使用 POST 方法向 /ctr 路径发送运动控制指令。
请求结构:由请求行、请求头和请求体组成。请求行包含请求方法、请求 URL 和 HTTP 版本;请求头包含一些额外的信息,如用户代理、内容类型等;请求体包含要发送的数据。
响应结构:由状态行、响应头和响应体组成。状态行包含 HTTP 版本、状态码和状态消息;响应头包含一些额外的信息,如服务器类型、内容长度等;响应体包含服务器返回的数据。
7.1.4 在 ESP32 中使用 HTTP 服务器
使用 ESPAsyncWebServer 库可以在 ESP32 上轻松搭建一个异步 HTTP 服务器。通过 server.on() 方法绑定不同的路径和请求方法,处理相应的请求。例如:
server.on("/ctr", HTTP_POST, InterinalRequestDefault, 0, API_Post_MotionCtr);
上述代码表示当有 POST 请求发送到 /ctr 路径时,调用 API_Post_MotionCtr 函数进行处理。
7.1.5 JSON 编解码概述
JSON(JavaScript Object Notation,JavaScript 对象表示法)是一种轻量级的数据交换格式,易于人类阅读和编写,同时也易于机器解析和生成。它基于 JavaScript 的一个子集,采用键值对的形式存储数据,支持多种数据类型,如字符串、数字、布尔值、数组和对象等。
{ "en": true, "v": 1.5, "w": 0.5 }
在上述示例中,en 是一个布尔类型的值,表示是否启用控制;v 和 w 分别是线速度和角速度,为数字类型。这种结构非常适合在网络通信中传输结构化的数据,比如在远程控制小车的场景中,用于传递运动控制指令。
JSON 编码是将程序中的数据结构转换为 JSON 字符串的过程。在 Arduino 环境中,使用 ArduinoJson 库可以方便地进行 JSON 编码。例如,若要创建一个包含运动控制指令的 JSON 对象并编码为字符串,可以这样做:
#include "ArduinoJson.h" void createJsonCommand() { const int capacity = JSON_OBJECT_SIZE(3); DynamicJsonDocument doc(capacity); doc["en"] = true; doc["v"] = 1.5; doc["w"] = 0.5; String jsonString; serializeJson(doc, jsonString); Serial.println(jsonString); }
在上述代码中,首先创建一个 DynamicJsonDocument 对象 doc,并指定其容量。然后向 doc 中添加键值对,最后使用 serializeJson 函数将 doc 编码为 JSON 字符串 jsonString。
JSON 解码是将 JSON 字符串解析为程序中的数据结构的过程。在接收端,如 ESP32 小车控制代码中,需要对接收到的 JSON 字符串进行解码,提取其中的关键信息。代码示例如下:
#include "ArduinoJson.h" void decodeJsonCommand(const char* jsonString) { const int capacity = JSON_OBJECT_SIZE(3); DynamicJsonDocument doc(capacity); DeserializationError error = deserializeJson(doc, jsonString); if (error) { Serial.print(F("deserializeJson() failed: ")); Serial.println(error.f_str()); return; } bool en = doc["en"]; double v = doc["v"]; double w = doc["w"]; Serial.print("en: "); Serial.println(en); Serial.print("v: "); Serial.println(v); Serial.print("w: "); Serial.println(w); }
7.1.6 整体流程
ESP32 初始化:在 setup 函数中,ESP32 首先初始化串口通信,然后连接到指定的 WiFi 网络。连接成功后,启动 HTTP 服务器,监听特定的路径和请求方法。
控制端发送指令:控制端(如手机应用、网页等)通过 HTTP POST 请求将运动控制指令以 JSON 格式发送到 ESP32 的指定路径(如 /ctr)。
ESP32 接收并处理指令:ESP32 的 HTTP 服务器接收到请求后,调用相应的处理函数(如 API_Post_MotionCtr)对请求体中的 JSON 数据进行解码,提取出关键信息(如 en、v、w)。
计算目标速度:根据提取的线速度 v 和角速度 w,通过逆运动学函数计算出左右轮的目标速度。
控制小车运动:通过前述中小车运动控制代码完成。
以下是示例代码:
#include <drive.h> #include <tb6612.h> #include <WiFi.h> extern bool run_flag; extern int distance; extern int pwmA_set; extern int pwmB_set; #include <WiFi.h> #include "ArduinoJson.h" #define JSON_BUFFER_SIZE 128 StaticJsonDocument<JSON_BUFFER_SIZE> doc; // =========================== // Enter your WiFi credentials // =========================== const char* ssid = "CMCC"; const char* password = "*************"; int TargetVelocity_L=20; //目标速度 int TargetVelocity_R=20; //目标速度 double L_M = 0.0; double R_M = 0.0; // 定义轮子之间的距离(轴距),单位:mm #define WHEEL_BASE 95.4 // 逆运动学函数 void inverse_kinematics(double v, double omega, double *left_wheel_speed, double *right_wheel_speed) { // 计算左右轮的速度 *left_wheel_speed = v - omega * WHEEL_BASE / 2.0; *right_wheel_speed = v + omega * WHEEL_BASE / 2.0; } #include <AsyncTCP.h> #include <ESPAsyncWebServer.h> AsyncWebServer server(80); void InterinalRequestDefault(AsyncWebServerRequest *request) { } void API_GetWWWRoot(AsyncWebServerRequest *request) { } void API_Post_MotionCtr(AsyncWebServerRequest *request, uint8_t *data, size_t len, size_t index, size_t total) { if(!index) { DeserializationError error = deserializeJson(doc, data); Serial.printf("%s\r\n", data); bool hasCommand_en = doc.containsKey("en"); bool hasCommand_v = doc.containsKey("v"); bool hasCommand_w = doc.containsKey("w"); if (hasCommand_en && hasCommand_v && hasCommand_w) { if (doc["en"]) { run_flag = true; inverse_kinematics(doc["v"], doc["w"], &L_M, &R_M); TargetVelocity_L = int(L_M); TargetVelocity_R = int(R_M); } else { run_flag = false; TargetVelocity_L = 0; TargetVelocity_R = 0; } } request->send(200, "text/plain", "success"); } } void HttpServerInit(void) { // 绑定监听路径 // server.on("/", handleRoot); server.on("/ctr", HTTP_POST, InterinalRequestDefault, 0, API_Post_MotionCtr); // 启动服务器 server.begin(); Serial.print("HTTP server started\r\n"); } void setup() { Serial.begin(115200); delay(10000); WiFi.mode(WIFI_STA); WiFi.begin(ssid, password); while (!WiFi.isConnected()) { delay(500); Serial.print("."); } Serial.println("Connected"); Serial.print("IP Address:"); Serial.println(WiFi.localIP()); HttpServerInit(); } float Velcity_Kp=0.4, Velcity_Ki=0.5, Velcity_Kd=0; //PID参数 void loop() { delay(10); }
7.2 python上位机
在这里给出一个使用PS4手柄连接电脑,用python读取手柄操作动作控制小车运动代码。

代码利用 pygame
库来读取 Xbox One 控制器的输入,并根据控制器的操作生成相应的运动控制指令(线速度 v
、角速度 w
和使能标志 en
)。然后,使用 requests
库将这些指令以 JSON 格式通过 HTTP POST 请求发送到指定的 ESP32 服务器(http://192.168.3.126:80/ctr
),以实现对小车的远程控制。
import pygame from pygame.locals import * import requests import json
pygame
:一个用于开发游戏和多媒体应用的 Python 库,这里主要用于读取 Xbox One / PS4控制器的输入。pygame.locals
:包含了pygame
中的一些常量,例如事件类型(如QUIT
、JOYBUTTONDOWN
等)。requests
:一个用于发送 HTTP 请求的 Python 库,方便向服务器发送 POST 请求。json
:Python 的内置库,用于处理 JSON 数据的编码和解码。
# 初始化PyGame pygame.init() # 初始化Xbox One控制器 joystick = pygame.joystick.Joystick(0) joystick.init()
pygame.init()
:初始化pygame
库的所有模块,为后续的操作做好准备。pygame.joystick.Joystick(0)
:创建一个Joystick
对象,0
表示使用第一个连接的游戏手柄(在这个例子中是 Xbox One 控制器)。joystick.init()
:初始化该游戏手柄,使其可以正常工作。
# 游戏循环 running = True v_max = 50.0 v = 0 w = 0 en = False change_flag = False
running
:作为游戏循环的控制变量,当running
为False
时,循环结束,程序退出。v_max
:最大线速度,初始值为 50.0。v
:当前线速度,初始值为 0。w
:当前角速度,初始值为 0。en
:使能标志,用于控制小车是否运动,初始值为False
,表示小车停止。change_flag
:标志变量,用于标记控制指令是否发生变化,初始值为False
。
while running: # 处理事件 for event in pygame.event.get(): if event.type == QUIT: running = False
pygame.event.get()
:获取当前队列中的所有事件,通过遍历这些事件来处理不同的用户输入。event.type == QUIT
:当用户点击窗口的关闭按钮时,将running
设置为False
,退出游戏循环。
elif event.type == JOYBUTTONDOWN: # 获取按下的按钮编号 button = event.button print("Button", button, "pressed") if button == 7: en = not en change_flag = True if button == 9: v_max = - v_max print(v_max)
event.type == JOYBUTTONDOWN
:当检测到游戏手柄上的按钮被按下时,执行以下操作。button = event.button
:获取被按下的按钮编号。button == 7
:如果按下的是编号为 7 的按钮,将en
取反(即切换使能状态),并将change_flag
设置为True
,表示控制指令发生了变化。button == 9
:如果按下的是编号为 9 的按钮,将v_max
取反(改变最大线速度的方向),并打印新的v_max
值。
elif event.type == JOYAXISMOTION: # 获取摇杆的位置 axis = event.axis value = round(event.value, 3) print("Axis", axis, "moved to", value) if axis == 4: v = ((value + 1)/2) * v_max change_flag = True elif event.type == JOYAXISMOTION: # 获取摇杆的位置 axis = event.axis value = round(event.value, 3) print("Axis", axis, "moved to", value) if axis == 4: v = ((value + 1)/2) * v_max change_flag = True elif axis == 0: w = -(value/5) change_flag = True
event.type == JOYAXISMOTION
:当检测到游戏手柄上的摇杆发生移动时,执行以下操作。axis = event.axis
:获取发生移动的摇杆轴编号。不同的轴编号对应手柄上不同的摇杆,具体的映射关系取决于手柄的硬件设计。value = round(event.value, 3)
:获取摇杆在该轴上的位置值,并将其保留三位小数。摇杆的位置值通常在 -1.0 到 1.0 之间,-1.0 表示摇杆向一个方向推到底,1.0 表示向另一个方向推到底,0.0 表示摇杆处于中间位置。axis == 4
:如果是编号为 4 的轴发生移动,根据该轴的位置值计算当前线速度v
。计算公式((value + 1)/2) * v_max
的作用是将摇杆位置值从 -1.0 到 1.0 的范围映射到 0 到v_max
的范围。例如,当摇杆推到最前方(value = 1.0
)时,v
等于v_max
;当摇杆处于中间位置(value = 0.0
)时,v
等于v_max / 2
;当摇杆推到最后方(value = -1.0
)时,v
等于 0。计算完成后,将change_flag
设置为True
,表示控制指令发生了变化。axis == 0
:如果是编号为 0 的轴发生移动,根据该轴的位置值计算当前角速度w
。计算公式-(value/5)
是将摇杆位置值按一定比例缩放后取反,作为角速度的值。同样,计算完成后将change_flag
设置为True
。
if change_flag: change_flag = False # 创建要发送的 JSON 数据 data = { "en": en, "v": v, "w": w } json_data = json.dumps(data) url = "http://192.168.3.126:80/ctr" headers = { "Content-Type": "application/json" } try: # 发送 POST 请求 response = requests.post(url, data=json_data, headers=headers) # 检查响应状态码 if response.status_code == 200: print("请求成功") print("响应内容:", response.text) else: print(f"请求失败,状态码: {response.status_code}") except requests.RequestException as e: print(f"请求发生错误: {e}")
if change_flag
:如果change_flag
为True
,表示控制指令发生了变化,需要将新的指令发送给服务器。首先将change_flag
重置为False
,以便下次检测新的变化。data = {"en": en, "v": v, "w": w}
:创建一个 Python 字典,包含当前的使能标志en
、线速度v
和角速度w
。json_data = json.dumps(data)
:使用json.dumps()
函数将 Python 字典转换为 JSON 格式的字符串。url = "http://192.168.3.126:80/ctr"
:指定要发送请求的服务器地址和路径,这里假设 ESP32 服务器的 IP 地址是192.168.3.126
,端口号是 80,路径是/ctr
。headers = {"Content-Type": "application/json"}
:设置请求头,指定请求体的内容类型为 JSON。response = requests.post(url, data=json_data, headers=headers)
:requests
库的post
方法向指定的url
发送一个 HTTP POST 请求。data
参数传入之前转换好的 JSON 格式字符串json_data
,headers
参数指定请求头,告知服务器请求体的内容类型是application/json
。requests.post
方法会返回一个Response
对象,该对象包含了服务器返回的响应信息,存储在response
变量中。
# 检查响应状态码 if response.status_code == 200: print("请求成功") print("响应内容:", response.text) else: print(f"请求失败,状态码: {response.status_code}")
response.status_code
表示服务器返回的 HTTP 状态码。HTTP 状态码是一个三位数字,用于表示请求的结果。常见的状态码如200
表示请求成功,404
表示请求的资源未找到,500
表示服务器内部错误等。- 如果状态码为
200
,说明请求成功发送到服务器并被正确处理,打印出 “请求成功” 并输出服务器返回的响应内容(存储在response.text
中)。 - 如果状态码不是
200
,则打印出 “请求失败” 并显示具体的状态码,方便调试和定位问题。