零知派——STM32智能小车系列教程(六):蓝牙/串口双通道通信协议设计
引言:智能小车要实现的功能很多——避障、跟随、贴边、灭火、直线行驶,每一个模块单独调试起来都有自己的难点。如果一开始就把所有模块塞进一个工程里联调,出了问题很难分清到底是传感器硬件的问题、电平极性的问题,还是上层控制算法的问题所以这一系列教程的思路是:先把每个功能模块单独拎出来,配一个不依赖其他硬件的最小测试程序,把模块本身调通调透,再回头看它在完整项目里是怎么被使用的
目录
一、引言
二、蓝牙串口模块硬件原理
2.1 SPP协议的本质2.2 两种工作模式2.3 常用 AT 指令三、硬件接线说明
3.1 引脚定义3.2 接线方案表3.3 连接示意图四、通信协议设计
4.1 双通道架构4.2 单字符命令解析4.3 多字符命令解析4.4 模式切换与状态管理五、独立测试程序
5.1 测试步骤5.2 系统流程图六、蓝牙控制模式
七、常见问题 FAQ
Q1:蓝牙模块可以同时连接多个设备吗?Q2:收到数据全是乱码?一、引言 前五篇教程分别讲了循迹、超声波云台、红外双目跟随、测速+PID闭环、DHT11与灭火,每一篇的核心都是某一个功能模块。这一篇不一样——它讲的是把前面所有模块串起来、对外提供操作入口的那一层:通信协议。
这台小车目前支持两条完全独立的通信通道同时工作:
[*]一条是USB转串口(连电脑,通常配合XCOM或Arduino串口监视器调试)
[*]另一条是HC-05/BT06蓝牙模块(连手机APP或者电脑虚拟串口)
特性说明
双通道USB 串口(有线调试)+ 蓝牙(无线遥控),共用同一套命令解析
双模式单字符命令(模式切换/方向控制)+ 多字符命令(参数设置/查询)
实时性非阻塞轮询,蓝牙超时自动刷帧
可扩展性命令表驱动,新增命令无需改动核心解析逻辑
两条通道物理上互不干扰,但需要共享同一套命令解析逻辑——用户不管是在电脑上敲命令还是拿手机发指令,最终都应该执行完全一致的行为,"双通道、单一解析入口"的架构设计,
系列进度:
篇号主题核心硬件
第一篇(已完成)五路循迹模块TCRT5000 + LM393/LM339
第二篇(已完成)HC-SR04超声波+SG90舵机云台HC-SR04 + SG90
第三篇(已完成)红外双目跟随模块红外避障对管
第四篇(已完成)红外对射式测速+直线行驶PID闭环槛型红外计数模块+码盘+PID
第五篇(已完成)DHT11温湿度、火焰传感器、风扇灭火DHT11+火焰传感器+风扇
第六篇(本文)蓝牙/串口双通道通信协议设计HC-05/BT06
终篇(待更新)整车项目架构总览全模块整合
二、蓝牙串口模块硬件原理蓝牙串口模块是一种基于蓝牙 SPP(Serial Port Profile,串口端口协议) 的无线通信模块。它的核心作用是“模拟有线串口”——把蓝牙无线通信转换成标准的 UART 串口通信,开发者不需要理解复杂的蓝牙协议栈,只需要像操作普通串口一样收发数据即可
2.1 SPP协议的本质SPP 是蓝牙协议栈中的一个标准规范,它规定如何在两个蓝牙设备之间建立一个虚拟串行链路
https://file1.elecfans.com/web3/M00/71/6A/wKgZPGp-5AyAfFLhAADSqsUXQ3k922.png 这个链路模拟了传统 RS-232 串口的所有特性——数据以字节流形式传输、全双工通信、无需复杂的握手协议。对上层应用来说,蓝牙连接和一根 USB 串口线没有区别。
HC-05 与 HC-06 的区别:
本项目同时支持 HC-05 和 HC-06 两种蓝牙模块:
对比项HC-05HC-06
主从模式主从一体,可动态切换纯从机,只能被连接
角色切换支持(AT+ROLE=1 切为主机)不支持
AT 指令完整指令集,可配置所有参数精简指令集,仅支持基础配置
默认波特率38400(AT 模式)9600
价格较高较低
适用场景需要主动连接其他设备手机→小车单向控制
如果只需要手机控制小车(手机作为主机主动连接),HC-06 完全够用且更便宜。如果需要小车之间组网通信或主动发起连接,选择 HC-05
2.2 两种工作模式 HC-05 模块具有两种工作模式:
① 命令响应工作模式(AT 模式) :
用于配置模块参数(修改名称、波特率、主从角色等)
进入方式:上电时将 KEY 引脚拉高
串口波特率通常为 38400(HC-05)或 9600(HC-06)
数据格式固定:8 位数据位、1 位停止位、无校验位、无流控制
② 自动连接工作模式(透传模式) :
正常通信模式,数据透明传输
模块可作为主机(Master)、从机(Slave)或回环(Loopback)角色
配对成功后,串口数据直接通过蓝牙发送
2.3 常用 AT 指令HC-05 模块的所有功能通过 AT 指令集控制。以下是最常用的几条:
AT 指令功能示例响应
AT测试指令OK
AT+NAME=设置模块名称OK
AT+BAUD=设置波特率(1=1200, 2=2400, 3=4800, 4=9600, 5=19200, 6=38400...)OK
AT+ROLE=设置角色(0=从机, 1=主机)OK
AT+ADDR?查询模块地址+ADDR:xxxx:xx:xxxxxx
AT+RESET复位模块OK
发送 AT 指令时,每条指令必须以 \r\n
三、硬件接线说明3.1 引脚定义本项目蓝牙模块的引脚定义(与 pinsdefine.h 一致):
<pre><code class="language-cpp">#define BTSerial Serial2 // 蓝牙模块使用硬件串口3</code></pre><p></p>
零知派迷你板的硬件串口分配:
串口引脚用途
SerialPA9(TX)/PA10(RX)USB 串口(调试用)
Serial1PA2(TX)/PA3(RX)预留
Serial2PB10(TX)/PB11(RX)蓝牙模块
3.2 接线方案表
模块引脚零知派迷你板引脚说明
HC-05/06VCC5V供电
HC-05/06GNDGND共地
HC-05/06TXDPB11(RX3)模块发送→主控接收
HC-05/06RXDPB10(TX3)主控发送→模块接收
HC-05KEY可选 GPIOAT 模式使能(HC-06 无此引脚)
模块的 TXD 接主控的 RX,模块的 RXD 接主控的 TX——这是串口通信的基本规则,接反了将无法通信
3.3 连接示意图https://file1.elecfans.com/web3/M00/71/6A/wKgZPGp-4_WAQa80AAYbY9V4ZVs345.png蓝牙模块初始化
在 setup() 中初始化蓝牙串口
Serial.begin(115200); // USB 串口,115200
delay(100);
BTSerial.begin(9600); // 蓝牙模块,9600(与手机蓝牙助手匹配)
delay(50);
波特率匹配:蓝牙模块的波特率必须与手机蓝牙助手 APP 中设置的波特率一致
四、通信协议设计4.1 双通道架构整车项目的通信系统采用双通道并行架构,给USB串口写一套parseUSB(),给蓝牙写一套parseBT(),两边各自处理各自的命令
class CommManager {
public:
void update(); // 每帧调用,非阻塞轮询两条物理通道
private:
char _usbBuf;
uint8_t _usbIdx = 0;
char _btBuf;
uint8_t _btIdx = 0;
unsigned long _btLastByte = 0;
static const uint8_t BT_FLUSH_MS = 30;
void parseLine(const char *raw, bool fromBT); // 统一入口
void parseCmd(char c, bool fromBT);
};
①双通道优势
通道用途特点
USB 串口(Serial)固件调试、参数设置、日志输出有线稳定,适合开发调试
蓝牙(Serial2)无线遥控、实时命令无线便捷,适合成品使用
两个通道共用同一套命令解析逻辑,保证有线调试和无线遥控的行为完全一致
②结束行判定
手机蓝牙串口助手APP发送数据时不一定带换行符,如果只靠换行判断结束,用户在APP里点发送后命令会一直卡在缓冲区里等不到\n
void CommManager::update()
{
// USB Serial(XCOM,换行结帧)
while (Serial.available()) {
char c = (char)Serial.read();
if (c == '\n' || c == '\r') {
if (_usbIdx > 0) { _usbBuf = '\0'; parseLine(_usbBuf, false); _usbIdx = 0; }
} else if (_usbIdx < sizeof(_usbBuf) - 1) {
_usbBuf = c;
} else { _usbIdx = 0; }
}
// BTSerial(超时30ms自动刷帧,兼容不发换行的蓝牙助手)
while (BTSerial.available()) {
char c = (char)BTSerial.read();
_btLastByte = millis();
if (c == '\n' || c == '\r') {
if (_btIdx > 0) { _btBuf = '\0'; parseLine(_btBuf, true); _btIdx = 0; }
} else if (_btIdx < sizeof(_btBuf) - 1) {
_btBuf = c;
} else { _btIdx = 0; }
}
if (_btIdx > 0 && (millis() - _btLastByte) >= BT_FLUSH_MS) {
_btBuf = '\0'; parseLine(_btBuf, true); _btIdx = 0;
}
}
额外记录最后一次收到字节的时间_btLastByte,缓冲区里有数据、且超过30毫秒没有收到新字节,就认为这一帧已经发送完毕,主动触发解析
关键设计:
设计点说明
非阻塞轮询不会因为等待串口数据而阻塞主循环
行缓冲分别维护 USB 和蓝牙的接收缓冲区
蓝牙超时刷帧30ms 无新数据自动处理已收到的字符【sc_comms.h†L35】
双通道隔离USB 和蓝牙独立缓冲,互不干扰
4.2 单字符命令解析void CommManager::parseCmd(char c, bool /*fromBT*/)
{
switch (c) {
// ── 模式切换 ──────────────────────────────────────────────
case 'A': setMode(MODE_MANUAL); return;
case 'B': setMode(MODE_AVOID); return;
// ... 其他模式
// ── 功能查询 ──────────────────────────────────────────────
case 'T': {// DHT11 温湿度
byte t = 0, h = 0;
g_env.getTempHumi(&t, &h);
return;
}
case 'I': {// IR 传感器状态
bool lIR = false, rIR = false;
g_ir.read(&lIR, &rIR);
Serial.print(F(" L=")); Serial.print(lIR);
Serial.print(F(" R=")); Serial.println(rIR);
return;
}
case 'U': {// 启动自整定
if (g_mode != MODE_STRAIGHT) {
Serial.println(F(" 请先切换到H(直线)模式"));
return;
}
startAutoTune();
return;
}
}
// ── 方向控制(手动/直线/灭火模式有效) ──────────────────────
if (g_mode == MODE_MANUAL || g_mode == MODE_STRAIGHT || g_mode == MODE_FIRE) {
switch (c) {
case '1': g_motor.goWithDirec(carForward);break;
case '2': g_motor.goWithDirec(carBack); break;
// ... 其他方向
case '0': case '9': g_motor.stop(); break;
}
}
}
单字符命令格式(即时响应)
命令功能说明
A切换到手动模式用户遥控驾驶
B切换到避障模式自动避障
C切换到循迹模式自动循迹
D切换到魔术手模式超声波手势跟随
E切换到贴边模式沿墙行驶
F切换到灭火模式手动驾驶 + 自动灭火
G切换到跟随模式红外双目跟随
H切换到直线模式PID 闭环直线行驶
1~8方向控制前后左右及组合方向
0/9停止急停
T读取温湿度DHT11 数据
I读取 IR 状态红外传感器调试
U启动自整定仅在 H 模式下有效
HELP打印命令帮助多字符命令
4.3 多字符命令解析void CommManager::parseLine(const char *raw, bool fromBT)
{
char buf;
uint8_t len = 0;
for (const char *p = raw; *p && len < 47; p++) {
char ch = *p;
if (ch == ' ' || ch == '\t') continue;
buf = (ch >= 'a' && ch <= 'z') ? (ch - 32) : ch;
}
buf = '\0';
if (len == 0) return;
if (len == 1) { parseCmd(buf, fromBT); return; }
if (strcmp(buf, "INFO") == 0) { printInfo(); return; }
if (strcmp(buf, "HELP") == 0) { printHelp(); return; }
if (startsWithCS(buf, "TKP=")) {
g_track.setTunings(floatAfter(buf, 4), g_track.Kd);
Serial.print(F(" TKp=")); Serial.println(g_track.Kp);
BTSerial.print(F(" TKp=")); BTSerial.println(g_track.Kp); return;
}
if (startsWithCS(buf, "TKD=")) {
g_track.setTunings(g_track.Kp, floatAfter(buf, 4));
Serial.print(F(" TKd=")); Serial.println(g_track.Kd);
BTSerial.print(F(" TKd=")); BTSerial.println(g_track.Kd); return;
}
if (startsWithCS(buf, "SPD=")) {
g_track.setBaseSpeed(intAfter(buf, 4));
Serial.print(F(" TSPD=")); Serial.println(g_track.baseSpeed);
BTSerial.print(F(" TSPD=")); BTSerial.println(g_track.baseSpeed); return;
}
if (startsWithCS(buf, "KP=")) { g_track.setTunings(floatAfter(buf, 3), g_track.Kd); return; }
if (startsWithCS(buf, "KD=")) { g_track.setTunings(g_track.Kp, floatAfter(buf, 3)); return; }
if (startsWithCS(buf, "WKP=")) {
g_wall.Kp = floatAfter(buf, 4);
g_wall.setTunings(g_wall.Kp, g_wall.Ki, g_wall.Kd);
Serial.print(F(" WKp=")); Serial.println(g_wall.Kp);
BTSerial.print(F(" WKp=")); BTSerial.println(g_wall.Kp); return;
}
if (startsWithCS(buf, "WKI=")) {
g_wall.Ki = floatAfter(buf, 4);
g_wall.setTunings(g_wall.Kp, g_wall.Ki, g_wall.Kd);
Serial.print(F(" WKi=")); Serial.println(g_wall.Ki);
BTSerial.print(F(" WKi=")); BTSerial.println(g_wall.Ki); return;
}
if (startsWithCS(buf, "WKD=")) {
g_wall.Kd = floatAfter(buf, 4);
g_wall.setTunings(g_wall.Kp, g_wall.Ki, g_wall.Kd);
Serial.print(F(" WKd=")); Serial.println(g_wall.Kd);
BTSerial.print(F(" WKd=")); BTSerial.println(g_wall.Kd); return;
}
if (startsWithCS(buf, "WSET=")) {
g_wall.setDistance(floatAfter(buf, 5));
Serial.print(F(" WDist=")); Serial.println(g_wall.setpoint);
BTSerial.print(F(" WDist=")); BTSerial.println(g_wall.setpoint); return;
}
if (strcmp(buf, "RESET")== 0) { resetStraightPID(); return; }
if (strcmp(buf, "LRESET") == 0) { setStraightPID(true,0.5, 0.01, 0.0); return; }
if (strcmp(buf, "RRESET") == 0) { setStraightPID(false, 0.5, 0.01, 0.0); return; }
if (startsWithCS(buf, "LKP=")) { setStraightPID(true,floatAfter(buf, 4), s_lKi, s_lKd); return; }
if (startsWithCS(buf, "LKI=")) { setStraightPID(true,s_lKp, floatAfter(buf, 4), s_lKd); return; }
if (startsWithCS(buf, "LKD=")) { setStraightPID(true,s_lKp, s_lKi, floatAfter(buf, 4)); return; }
if (startsWithCS(buf, "RKP=")) { setStraightPID(false, floatAfter(buf, 4), s_rKi, s_rKd); return; }
if (startsWithCS(buf, "RKI=")) { setStraightPID(false, s_rKp, floatAfter(buf, 4), s_rKd); return; }
if (startsWithCS(buf, "RKD=")) { setStraightPID(false, s_rKp, s_rKi, floatAfter(buf, 4)); return; }
if (startsWithCS(buf, "SSPD=")) {
s_setpoint = floatAfter(buf, 5);
Serial.print(F(" Setpoint=")); Serial.println(s_setpoint);
BTSerial.print(F(" Setpoint=")); BTSerial.println(s_setpoint); return;
}
// 新增:码盘每圈脉冲数现场调整(原来写死为20)
if (startsWithCS(buf, "PPR=")) {
int ppr = intAfter(buf, 4);
setEncoderPPR(ppr);
Serial.print(F(" PPR=")); Serial.println(ppr);
BTSerial.print(F(" PPR=")); BTSerial.println(ppr); return;
}
if (startsWithCS(buf, "EA=")) {
setEncoderEmaAlpha(floatAfter(buf, 3));
BTSerial.print(F(" EA=")); BTSerial.println(floatAfter(buf, 3));
return;
}
Serial.print(F(" 未知命令: ")); Serial.println(raw);
if (fromBT) { BTSerial.print(F(" Unknown: ")); BTSerial.println(raw); }
}
①字符串解析优化
// 全部基于 char*,不触碰堆,避免内存碎片
static bool startsWithCS(const char* s, const char* prefix) {
return strncmp(s, prefix, strlen(prefix)) == 0;
}
static float floatAfter(const char* s, uint8_t prefixLen) {
return atof(s + prefixLen);
}
static int intAfter(const char* s, uint8_t prefixLen) {
return atoi(s + prefixLen);
}
String 类在堆上动态分配内存,长时间运行会产生内存碎片。buf本身是栈上的固定大小数组,strncmp和atof都是不涉及堆分配的标准库函数,全程不产生任何动态内存分配
②多字符命令格式(参数设置/查询)
命令格式功能示例
TKP=数值设置循迹比例系数TKP=6.5
TKD=数值设置循迹微分系数TKD=0.5
SPD=数值设置循迹基础速度SPD=80
WKP=数值设置贴边比例系数WKP=4.0
WKI=数值设置贴边积分系数WKI=0.02
WKD=数值设置贴边微分系数WKD=0.2
WSET=数值设置贴边目标距离WSET=17
LKP=数值设置左轮比例系数LKP=1.2
LKI=数值设置左轮积分系数LKI=0.45
LKD=数值设置左轮微分系数LKD=0.05
RKP=数值设置右轮比例系数RKP=1.2
RKI=数值设置右轮积分系数RKI=0.45
RKD=数值设置右轮微分系数RKD=0.05
SSPD=数值设置目标转速SSPD=120
PPR=数值设置码盘每圈脉冲数PPR=20
EA=数值设置滤波 EMA 系数EA=0.4
RESET恢复 PID 默认参数—
LRESET恢复左轮默认参数—
RRESET恢复右轮默认参数—
INFO打印全部当前参数—
HELP打印命令帮助—
4.4 模式切换与状态管理void CommManager::setMode(CarMode m)
{
if (g_mode == m) return;
// ── 离开直线模式时强制中止自整定 ────────────────────────────
if (g_mode == MODE_STRAIGHT && g_autoTuning) {
g_autoTuning = false;
Serial.println(F(" 强制中止"));
}
// ── 离开舵机相关模式时舵机回正 ──────────────────────────────
if (g_mode == MODE_AVOID || g_mode == MODE_NEARWALL || g_mode == MODE_MAGIC) {
g_sonar.setAngle(SERVO_DEFAULT_POS);
}
g_motor.stop();
g_mode = m;
// ── 进入新模式时调用 onEnter() ──────────────────────────────
switch (m) {
case MODE_AVOID: g_avoid.onEnter(); break;
case MODE_TRACK: g_track.onEnter(); break;
// ...
}
}
状态管理的核心原则
原则实现
离开时清理停止电机、中止自整定、舵机回正
进入时初始化调用对应模式的 onEnter()
状态隔离不同模式的状态变量互不干扰
五、独立测试程序不依赖整车其他模块,单独验证双通道命令解析架构本身:
/**************************************************************************************
* 文件: DualChannel_Comm_Standalone_Test.ino
* 作者:零知实验室(深圳市在芯间科技有限公司)
* 功能:蓝牙/串口双通道命令解析架构 独立测试沙盒
* 复现主项目 sc_comms.cpp 的核心解析逻辑,验证USB和蓝牙两条通道
* 是否都能正确识别单字符命令和多字符命令(尤其是带=号的参数命令)
*
* 引脚:BTSerial = Serial2,与主项目 pinsdefine.h 保持一致
*
* 测试方法:
* 1. USB串口(Serial)用Arduino串口监视器测试,可以正常带换行发送
* 2. 蓝牙通道用手机APP或电脑虚拟串口测试,即使不带换行符也应该
* 在约30ms后被自动识别执行
* 3. 分别测试单字符命令(如 A)和多字符命令(如 INFO),两条通道
* 应该有完全一致的响应
**************************************************************************************/
#define BTSerialSerial2
char g_usbBuf;
uint8_t g_usbIdx = 0;
char g_btBuf;
uint8_t g_btIdx = 0;
unsigned long g_btLastByte = 0;
const uint8_t BT_FLUSH_MS = 30;
void parseCmd(char c, bool fromBT)
{
Serial.print(F(" 单字符命令: '"));
Serial.print(c);
Serial.print(F("'来源: "));
Serial.println(fromBT ? F("蓝牙") : F("USB"));
switch (c) {
case 'A': case 'a': Serial.println(F(" 切换到手动模式")); break;
case 'B': case 'b': Serial.println(F(" 切换到避障模式")); break;
case '1': Serial.println(F(" 前进")); break;
case '2': Serial.println(F(" 后退")); break;
default:Serial.println(F(" 未识别的单字符命令")); break;
}
if (fromBT) BTSerial.println(F(" 命令已收到"));
}
void parseLine(const char *raw, bool fromBT)
{
char buf;
uint8_t len = 0;
for (const char *p = raw; *p && len < 47; p++) {
char ch = *p;
if (ch == ' ' || ch == '\t') continue;
buf = (ch >= 'a' && ch <= 'z') ? (ch - 32) : ch;
}
buf = '\0';
if (len == 0) return;
if (len == 1) { parseCmd(buf, fromBT); return; }
Serial.print(F(" 多字符命令: \""));
Serial.print(buf);
Serial.print(F("\"来源: "));
Serial.println(fromBT ? F("蓝牙") : F("USB"));
if (strcmp(buf, "INFO") == 0) {
Serial.println(F(" 打印系统信息(模拟)"));
if (fromBT) BTSerial.println(F(" System OK"));
return;
}
if (strncmp(buf, "TKP=", 4) == 0) {
float val = atof(buf + 4);
Serial.print(F(" 设置 Kp = ")); Serial.println(val);
if (fromBT) { BTSerial.print(F(" Kp=")); BTSerial.println(val); }
return;
}
Serial.println(F(" 未识别的多字符命令"));
}
void setup()
{
Serial.begin(115200);
BTSerial.begin(9600);
delay(200);
Serial.println(F("=========================================="));
Serial.println(F(" 蓝牙/串口双通道命令解析 独立测试沙盒 "));
Serial.println(F(" USB波特率:115200蓝牙波特率:9600 "));
Serial.println(F(" 测试命令: A B 1 2 INFO TKP=数值 "));
Serial.println(F("=========================================="));
}
void loop()
{
// ── USB通道:换行结帧 ──────────────────────────────────────
while (Serial.available()) {
char c = (char)Serial.read();
if (c == '\n' || c == '\r') {
if (g_usbIdx > 0) { g_usbBuf = '\0'; parseLine(g_usbBuf, false); g_usbIdx = 0; }
} else if (g_usbIdx < sizeof(g_usbBuf) - 1) {
g_usbBuf = c;
} else { g_usbIdx = 0; }
}
// ── 蓝牙通道:换行或30ms超时自动结帧 ────────────────────────
while (BTSerial.available()) {
char c = (char)BTSerial.read();
g_btLastByte = millis();
if (c == '\n' || c == '\r') {
if (g_btIdx > 0) { g_btBuf = '\0'; parseLine(g_btBuf, true); g_btIdx = 0; }
} else if (g_btIdx < sizeof(g_btBuf) - 1) {
g_btBuf = c;
} else { g_btIdx = 0; }
}
if (g_btIdx > 0 && (millis() - g_btLastByte) >= BT_FLUSH_MS) {
g_btBuf = '\0'; parseLine(g_btBuf, true); g_btIdx = 0;
}
}
https://file1.elecfans.com/web2/M00/03/FB/poYBAGDYdXCAWkKMAAAAK8RNs4s030.png5.1 测试步骤①验证USB通道
零知派串口监视器发送INFO(带换行),确认打印出 多字符命令: "INFO" 来源: USB
②验证蓝牙通道多字符命令
用手机蓝牙APP连接后发送INFO(不带换行也可以),确认约30ms后同样能正确识别为多字符命令而不是被拆成I、N、F、O四条无效单字符命令
③验证带参数命令
发送TKP=6.5,确认能正确解析出数值6.5,两条通道结果应该一致
5.2 系统流程图https://file1.elecfans.com/web3/M00/71/6A/wKgZO2p-47iAc71TABpWif_5QYY783.png
六、蓝牙控制模式 本项目支持四种蓝牙控制模式
enum BTCtrlMode : uint8_t {
Button = 0, // 按键模式:单字节命令
Gravity= 1, // 重力感应:手机姿态控制
JoyStick = 2, // 摇杆模式:双轴遥感
Rocker = 3 // 摇杆模式:另一种协议
};
按键模式(Button)
最简单的控制模式,每个按键对应一个单字节命令
void LZSmartCarMotor::btnControl()
{
switch (cmd) {
case 1: goWithDirec(carForward);break;
case 2: goWithDirec(carBack); break;
case 3: goWithDirec(carForLeft);break;
case 4: goWithDirec(carForRight); break;
case 0: case 9: stop(); break;
}
}
重力感应模式(Gravity)
利用手机陀螺仪数据控制小车方向
void LZSmartCarMotor::gravityControl()
{
x = cmd; y = cmd;
speed = map(x, 10, 90, 20, 255);
// y 值决定转向方向和幅度
int sR = (y < 90 && y > -90) ? map(y, 10, 90, speed, 20) : map(y, 170, 90, speed, 0);
int sL = (y < 90 && y > -90) ? map(y, -90, -10, 20, speed) : map(y, -90, -170, 0, speed);
// ...
}
摇杆模式(JoyStick / Rocker)
双轴摇杆控制,x 轴控制速度,y 轴控制转向
void LZSmartCarMotor::joyStickControl()
{
x = cmd; y = cmd;
speedLeft= (y < 470) ? map(y, 470, 0, 0, -255) :
(y > 550) ? map(y, 550, 1023, 0,255) : 0;
speedRight = speedLeft;
int xAdj= (x < 470) ? map(x, 470, 0, 0,255) :
(x > 550) ? map(x, 550, 1023, 0,255) : 0;
// 原代码 xAdj 在左右两侧都是 left+adj/right-adj(只向左偏),
// x >550 应为 left-adj / right+adj,此处仍保持原逻辑待实车验证
speedLeft+= xAdj;
speedRight -= xAdj;
motorRun(speedLeft, speedRight);
}
void LZSmartCarMotor::bleControl()
{
if (getCommand(cmd)) {
switch (_mode) {
case Button: btnControl(); break;
case Gravity:gravityControl();break;
case Rocker: rockerControl(); break;
case JoyStick: joyStickControl(); break;
}
}
}
七、常见问题 FAQQ1:蓝牙模块可以同时连接多个设备吗? A:不能。HC-05 和 HC-06 都是点对点蓝牙模块,同一时间只能与一个设备保持连接。如果需要多设备连接,需要考虑其他方案(如 WiFi 或 BLE 多连接)
Q2:收到数据全是乱码? A:波特率不匹配或数据格式不一致。确认 USB 串口监视器波特率为 115200、确认蓝牙模块波特率为 9600(HC-06 默认)或 38400(HC-05)、蓝牙模块数据格式固定为 8N1(8 位数据、无校验、1 位停止位)
通信协议是整车项目的“神经系统”——它把用户的意图从手机/电脑传递到小车,把小车状态从传感器反馈给用户。一套设计良好的通信协议,应该做到:有线/无线双通道统一、单字符/多字符命令兼容、解析逻辑高效稳定、模式切换干净利落
页:
[1]