STM32 从 0 到 1 做智能家居(十一):蓝牙远程控制(HC-06)

本文详解STM32通过HC-06模块实现手机蓝牙远程控制智能家居设备的完整方案,涵盖HC-06透传特性、9600波特率默认配置、拨码开关硬件切换、远程模式状态机设计、整串命令协议(ON/OFF等7条)、逐字节接收与缓冲区命中清空机制、回显机制及与MQTT共用执行函数等关键技术点,并提供裸测验证、联调七步法和典型故障排查路径。

文章目录

系列第十一篇。到这一章,第一次可以"离开电脑"控制设备:手机点一下,风扇转。

但它也是我踩坑最多的一章——不是代码难,而是每一层都可能坏:手机没配对、波特率不是 9600、拨码开关还在 WiFi 侧、忘了先退出手动模式、命令拼写多了两个字母……最离谱的一次:APP 明明显示"已连接",串口日志却连续打印 [CMD] unknown: F、unknown: A、unknown: N……后来才发现是命令缓冲区每收到一个字节就被清空了一次,完整命令永远拼不起来。

这一篇会把这条链路从手机一路拆到定时器,并且把每个"看起来通、实际不通"的现象,对应到具体原因。

一、HC-06 是什么

1.1 三句话说完

特性说明
角色从机(Slave),只能被手机/电脑连,不能主动连别人
工作方式透传:手机发什么,它就从 TXD 原样吐什么;STM32 往 RXD 写什么,手机就收到什么
协议经典蓝牙 SPP,不是 BLE(低功耗蓝牙)

由此推出三条必须记住的结论:

  1. 它不懂任何协议。它不会帮你解析 FAN_ON,也不认识 JSON。谁是命令、谁是数据,全靠 STM32 自己判断。
  2. 默认波特率 9600。出厂就是 9600 8N1,不是 115200。这是本章第一大坑。
  3. iPhone 连不上。iOS 不开放 SPP。安卓要用支持 SPP 的串口助手(如 Serial Bluetooth Terminal),或者微信里那些"蓝牙串口"小程序。

1.2 模块上的灯:最快的诊断工具

LED 状态含义
快闪(约每秒 2 次)未连接,正在等人配对
常亮已有一台设备连上
上电瞬间闪一下再快闪正常启动

排错时先看灯,再看代码。灯还在快闪,就说明手机根本没连上,这时候去改代码全是白费。

1.3 先裸测,别急着接单片机

这是我在第 2 章就强调过的习惯:通信模块一定要先单独验证。

  1. USB 转 TTL 接 HC-06:TTL 的 RX → 模块 TXD,TTL 的 TX → 模块 RXD,VCC、GND 接好;
  2. 串口助手选 9600 8N1,打开串口;
  3. 发 AT(不带换行),应回 OK;
  4. 回乱码就换波特率试 38400、115200——说明模块被人改过。

裸测通过,才说明"模块本身是好的、波特率是 9600",后面的问题就只剩 STM32 这一侧了。这一步能省掉一半的排查时间。

1.4 HC-06 的常用 AT 指令

指令作用典型回显备注
AT测试通信OK只能在未连接状态发,连着手机时发无效
AT+VERSION查固件版本OKlinvorV1.8不同批次字串不同,正常
AT+NAMEabc改蓝牙名OKsetname名字 ≤20 字符
AT+BAUD8改波特率OKsetbaud改完必须断电重上才生效
AT+PSWD"1234"改配对码OKsetPIN有些版本是 AT+PIN1234,没有引号

AT+BAUD 后面那个数字是编码,不是波特率本身:

数字波特率数字波特率
11200519200
22400638400
34800757600
49600(默认)8115200

给你的建议:不要改。 本工程默认按 9600 配 USART2,你改成 115200 之后,手机端、单片机端、串口助手端三处都得跟着改,忘一处就是乱码。真要改,记住用 AT+BAUD4 改回来。

顺便区分一下 HC-05:它是主从一体,进 AT 模式要把 EN/KEY 脚拉高,指令末尾还要带 \r\n,和 HC-06 完全不同。网上教程混着写,别照抄错版本。

二、接线:拨码开关是前提

2.1 四根线

HC-06接到说明
VCC5V 或 3.3V看模块丝印,带底板的多为 3.6~6V(板上有 LDO)
GNDGND必须和 STM32 共地
TXDPA3(USART2_RX)交叉:模块发 → 单片机收
RXDPA2(USART2_TX)交叉:单片机发 → 模块收

电平不用担心:STM32 输出 3.3V,HC-06 的 RXD 兼容;模块 TXD 也是 3.3V,STM32 能直接识别。不需要电平转换。

2.2 拨码开关必须在蓝牙侧

USART2 只有一组引脚(PA2/PA3),蓝牙和 ESP-12F 共用,靠双刀双掷拨码开关二选一(第 3 章 5.2 节)。

PA2 ── 公共端 1 ──┬── A 侧:HC-06 RXD
                 └── B 侧:ESP  RXD

PA3 ── 公共端 2 ──┬── A 侧:HC-06 TXD
                 └── B 侧:ESP  TXD
你要用拨码开关软件里的协议波特率
蓝牙蓝牙侧(A)NET_BT9600
WiFi/MQTTWiFi 侧(B)NET_MQTT115200

软件读不到开关状态。所以"命令发出去没反应"时,先低头看一眼开关——这是排查顺序里的第一步,不是最后一步。

2.3 调试串口要单独留着

手机上发命令的同时,你还需要在电脑上看日志。这两件事用的是不同的串口:

  • 电脑:USART1(PA9/PA10)+ USB 转 TTL,115200;
  • 手机命令:USART2(PA2/PA3)+ HC-06,9600。

如果你为了"省一个 USB 转 TTL"把日志也接到 USART2 上,日志会和蓝牙回显、命令搅在一起,最后一句话都读不懂(第 10 章开篇就是这么翻车的)。

三、进入远程模式的正确路径

3.1 上电路径:默认是手动模式

这是第二大坑:上电默认是手动模式,此时 KEY4 是"加热",不是"进远程"。

上电
 │
 ├─ 默认:MODE_MANUAL(手动)
 │     KEY4 = 加热开关   ← 按了只会加热,不会进远程
 │
 ├─ 按 KEY2 ──→ MODE_AUTO(自动)
 │
 └─ 再按 KEY4 ──→ MODE_REMOTE(远程,默认蓝牙 9600)
       │
       └─ 再按 KEY4 ──→ 切 MQTT(115200)
             │
             └─ 再按 KEY4 ──→ 切回蓝牙(9600)

所以正确路径是:KEY2 → KEY4。

操作从到日志
按 KEY2手动自动[MODE] Auto | Temp Control ON
按 KEY4自动远程/蓝牙[MODE] Remote (KEY4) | Bluetooth ON → [NET] Bluetooth @9600
再按 KEY4远程/蓝牙远程/MQTT[KEY4] request MQTT → [NET] WiFi/MQTT @115200

看到 [NET] Bluetooth @9600,才说明 USART2 已经切好波特率、挂好接收了。

3.2 为什么 KEY4 在中断里只"置请求"

第 9 章讲过中断禁区,这里再具体一点。KEY4 的处理在 HAL_GPIO_EXTI_Callback() 里:

else if(GPIO_Pin == KEY4_Pin)
{
  if(g_mode == MODE_MANUAL)
  {
    // 手动模式:KEY4 = 加热 开/关(和远程模式无关)
    g_heat_on = !g_heat_on;
    __HAL_TIM_SET_COMPARE(&htim3, TIM_CHANNEL_2, g_heat_on ? HER_PWM : 0);
    printf("[MANUAL] HEATER %s\r\n", g_heat_on ? "ON" : "OFF");
  }
  else
  {
    if(g_mode != MODE_REMOTE)
    {
      Switch_Mode(MODE_REMOTE);      // 只改状态、关输出
      g_net_req = NET_REQ_BT;        // ★ 只置请求,不进真正的切换
    }
    else if(g_net_busy)              // MQTT 正在初始化 → 直接丢弃
      printf("[KEY4] busy: MQTT init in progress, ignored\r\n");
    else if(g_net == NET_BT) g_net_req = NET_REQ_MQTT;
    else                     g_net_req = NET_REQ_BT;
  }
}

真正的动作在主循环:

if(g_net_req != NET_REQ_NONE)
{
  uint8_t req = g_net_req;
  g_net_req = NET_REQ_NONE;          // 先清,避免重复执行

  if(req == NET_REQ_MQTT) { /* 切 115200 + 连 WiFi */ }
  else
  {
    g_net = NET_BT;                   // 先置协议标志,再挂接收
    Uart2_SetBaudrate(9600);          // ★ 这里才真正切波特率
    HAL_UART_Receive_IT(&huart2, bt_buf, 1);
    printf("[NET] Bluetooth @9600\r\n");
  }
}

原因很简单:Uart2_SetBaudrate() 里要"等 TC 发完、DeInit、Init、清标志",全程带等待(最坏几十毫秒)。放在中断里做,SysTick 优先级最低(15),HAL_GetTick() 会停住,所有超时判断全乱,严重时直接卡死。

顺序也很讲究:先把 g_net 置成目标协议,再挂接收。反过来的话,切换过程中到达的第一个字节会在回调里走错分支(进 ESP 缓冲而不是蓝牙缓冲)。

3.3 切换波特率到底做了什么

Uart2_SetBaudrate() 五步,每一步都不能省:

void Uart2_SetBaudrate(uint32_t baud)
{
  uint32_t t0;

  /* 1) 先关中断:DeInit/Init 过程中有字节进来会把 HAL 状态搞乱 */
  HAL_NVIC_DisableIRQ(USART2_IRQn);

  /* 2) 等最后一个字节真正发完(TC=1),50ms 超时防死等 */
  t0 = HAL_GetTick();
  while(__HAL_UART_GET_FLAG(&huart2, UART_FLAG_TC) == RESET)
  {
    if(HAL_GetTick() - t0 > 50) break;
  }

  /* 3) 若有挂起的接收请求,先中止:否则 RxState 停在 BUSY,
        后面的阻塞式 HAL_UART_Receive 会立刻返回 HAL_BUSY */
  HAL_UART_AbortReceive_IT(&huart2);

  /* 4) DeInit 关时钟/复位引脚 → 改波特率 → Init 重新配时钟/GPIO/NVIC */
  HAL_UART_DeInit(&huart2);
  huart2.Init.BaudRate = baud;
  HAL_UART_Init(&huart2);

  /* 5) 清残留标志,避免把旧数据当成新模块的第一个字节 */
  __HAL_UART_CLEAR_PEFLAG(&huart2);               // 清 ORE/NE/FE/PE
  __HAL_UART_CLEAR_FLAG(&huart2, UART_FLAG_RXNE); // 丢掉残留数据

  HAL_NVIC_EnableIRQ(USART2_IRQn);
}

两个细节值得单独说:

  • 第 2 步等的是 TC(发送完成),不是 TXE。 TXE 只代表"数据寄存器空了",最后一个字节可能还在移位寄存器里往外发;这时候 DeInit 会把它掐断,手机收到半截。
  • 第 3 步的 AbortReceive_IT 是保命的一行。 少了它,RxState 可能一直停在 HAL_UART_STATE_BUSY_RX,接下来所有 HAL_UART_Receive() 都返回 HAL_BUSY——现象就是"AT 指令发给 ESP 了,但 recv 永远是空的",能查一整天。

四、命令协议:为什么用整串字符串

4.1 七条命令

命令动作落到硬件
FAN_ON风扇开TIM3_CH1(PA6) CCR=800 → 80%
FAN_OFF风扇关CCR=0
HER_ON加热开TIM3_CH2(PA7) CCR=800
HER_OFF加热关CCR=0
CURTAIN_ON窗帘拉开TIM5_CH1(PA0) CCR=500μs
CURTAIN_OFF窗帘关闭CCR=2000μs
ALL_OFF一键全关风扇+加热 CCR=0,窗帘关

三个"必须照抄"的点:

  1. 是 CURTAIN_ON / CURTAIN_OFF,不是 CURTAIN_OPEN / CURTAIN_CLOSE。工程早期用过 OPEN/CLOSE,后来统一成了 ON/OFF,代码里只认后者。发错就打印 [CMD] unknown。
  2. 全大写 + 下划线,strstr() 区分大小写,fan_on 永远匹配不上。
  3. 不要带 \r\n。手机 APP 里如果有"发送时追加换行"的开关,务必关掉(原因 5.7 节细说)。

4.2 为什么不用单字符(1/2/3)

  • 单字符太容易误触,APP 上滑一下就发出去一条;
  • 无法扩展:1 到底是"风扇开"还是"模式1"?三个月后你自己都忘了;
  • 最关键的:能和 MQTT 复用。 第 12 章的 H5 页面下发的是同一套字符串,Remote_Cmd_Exec() 一行都不用改,蓝牙和 WiFi 两条通道行为完全一致。

五、逐字节拼命令(本章最坑的一节)

5.1 为什么只能一个字节一个字节收

蓝牙中断每次只搬 1 个字节:

HAL_UART_Receive_IT(&huart2, bt_buf, 1);   // 第二个参数=本次收 1 个

收满 1 个字节,HAL 就关掉接收中断、调一次回调,然后停了。所以:

  • 回调里只做一件事:标记"有数据"(uartlog = 2);
  • 拼接放到主循环。中断里做 strlen、strstr、printf 都属于"太长",会把下一个字节挤丢。

5.2 设计意图:攒够一整条再匹配

手机发 F A N _ O N
      │ │ │ │ │ │
      ▼ ▼ ▼ ▼ ▼ ▼
   bt_cmd_buf:  F → FA → FAN → FAN_ → FAN_O → FAN_ON
                                               │
                                        strstr 命中 → 执行

理想状态下,每次收到字节就往缓冲区追加,然后拿整个缓冲区去 strstr。前缀阶段匹配不上,攒齐了才命中——这是本设计最省事的地方。

5.3 实际代码里藏着一个顺序问题

先看工程里的这段(main.c 远程模式分支):

if(uartlog == 2)
{
  uartlog = 0;
  uint8_t clen = strlen((char*)bt_cmd_buf);       // 当前已经攒了多少字符
  if(clen < sizeof(bt_cmd_buf) - 1)
  {
    bt_cmd_buf[clen]     = bt_buf[0];             // 追加新字节
    bt_cmd_buf[clen + 1] = '\0';                  // 补字符串结束符
  }
  else bt_cmd_buf[0] = '\0';                      // 攒满还没命中 → 清空

  Remote_Cmd_Exec((char*)bt_cmd_buf, 1);          // 拿整串去匹配
  bt_cmd_buf[0] = '\0';                           // ★ 问题在这一行
  HAL_UART_Receive_IT(&huart2, bt_buf, 1);
}

每一字节执行完,缓冲区立刻被清空。 于是下一字节到来时 strlen() 永远返回 0,缓冲区里永远只有一个字符。

而 strstr(cmd, "FAN_ON") 要求"包含完整的 6 个字符"——1 个字符的字符串永远不可能包含 6 个字符。

结果就是:发一条 FAN_ON,日志是这样的:

收到bt_cmd_buf 内容匹配结果串口日志
F"F"无[CMD] unknown: F
A"A"无[CMD] unknown: A
N"N"无[CMD] unknown: N
_"_"无[CMD] unknown: _
O"O"无[CMD] unknown: O
N"N"无[CMD] unknown: N

设备一点反应都没有,APP 也收不到任何回显。 如果你在自己的板子上看到"连续 unknown、每个只有 1 个字符",别怀疑蓝牙、别怀疑波特率——就是这个清空顺序的问题。

顺带说一句:MQTT 通道不受影响。第 12 章的 MQTT 是按行解析的,esp_sub_data 里拿到的是整条字符串,直接调 Remote_Cmd_Exec(),压根不走 bt_cmd_buf。所以会出现"WiFi 能控、蓝牙不能控"的诡异现象。

5.4 修法:只有命中才清空

最小改动是让执行函数返回是否命中,调用方据此决定清不清:

// 返回 1 = 匹配到并执行了;0 = 还没攒成完整命令
static uint8_t Remote_Cmd_Exec(const char *cmd, uint8_t echo_back)
{
  if(strstr(cmd, "FAN_ON"))       { /* ... */ }
  else if(strstr(cmd, "FAN_OFF")) { /* ... */ }
  /* ... 中间省略 ... */
  else if(strstr(cmd, "ALL_OFF")) { /* ... */ }
  else
  {
    return 0;                    // ★ 没命中:静默返回,让调用方继续攒
  }

  if(echo_back)
    HAL_UART_Transmit(&huart2, (uint8_t*)cmd, strlen(cmd), HAL_MAX_DELAY);

  return 1;                      // ★ 命中并执行完
}

调用方:

uint8_t clen = strlen((char*)bt_cmd_buf);

if(clen >= sizeof(bt_cmd_buf) - 1)          // 攒满 15 字符还没命中 → 判定乱码
{
  printf("[CMD] unknown: %s\r\n", bt_cmd_buf);
  bt_cmd_buf[0] = '\0';
}
else
{
  bt_cmd_buf[clen]     = bt_buf[0];
  bt_cmd_buf[clen + 1] = '\0';

  if(Remote_Cmd_Exec((char*)bt_cmd_buf, 1))
    bt_cmd_buf[0] = '\0';                   // ★ 只有命中才清空
}

这样 FAN_ON 的累积过程是:F → FA → FAN → FAN_ → FAN_O → 命中 FAN_ON → 执行 + 回显 + 清空。中间五步都返回 0,日志干净,不会刷五行 unknown。

5.5 为什么 16 字节够用

命令长度
ALL_OFF7
CURTAIN_ON10
CURTAIN_OFF11

最长 11 个字符,bt_cmd_buf[16] 留了 5 字节余量。

工程注释里写的是"最长 CURTAIN_CLOSE=13 字符"——那是早期命名版本留下的,现在命令已经改成 CURTAIN_OFF。注释和代码会一起变旧,看代码为准。

5.6 strstr 的包含匹配:是优点也是陷阱

优点:脏字节不影响执行。比如 APP 自动补了 \r\n,缓冲区里会出现 "\r\nFAN_ON",strstr 依然能命中并执行。

陷阱有两类:

  • 误命中:如果你在调试时手发 XXFAN_ONYY,它照样会开风扇。strstr 是"包含",不是"等于"。
  • 前缀混淆:FAN_ON 和 FAN_OFF 没有前缀关系,安全;但设计命令时千万不要让一条命令成为另一条的前缀(比如 FAN_ON 和 FAN_ON_TIMER),否则先命中的永远是短的那条。

5.7 命令后面多出来的 \r\n 会去哪

假设 APP 自动追加了 \r\n:

  1. 攒到 FAN_ON → 命中 → 执行 → 回显 → 清空;
  2. 接着收到 \r → 缓冲区变成 "\r" → 不命中 → 留在缓冲区;
  3. 收到 \n → 变成 "\r\n" → 不命中 → 继续留在缓冲区;
  4. 下一条命令进来 → 缓冲区变成 "\r\nFAN_OFF" → strstr 命中 FAN_OFF → 正常执行。

也就是说:功能上不会坏,但缓冲区会一直带着两个脏字节。真正的坏处是日志——如果按 5.3 的原始代码,每来一个 \r、\n 都会打印一条 [CMD] unknown:。

最干净的做法还是 APP 里关掉"自动换行"。

六、回显:让 APP 知道命令真的执行了

if(echo_back)
  HAL_UART_Transmit(&huart2, (uint8_t*)cmd, strlen(cmd), HAL_MAX_DELAY);

蓝牙必须回显(1),MQTT 必须不回显(0)。

通道echo_back原因
蓝牙1手机 APP 靠"收到同名回显"确认命中;同时把命令原样显示在接收框
MQTT0往 USART2 发任何东西都会插进 ESP 的 AT 指令流,把 AT 应答搅乱

三个容易忽略的细节:

  1. 未知命令不回显。 else 分支是 return 0;(原版是 return;),在回显之前就返回了。所以 APP 端"没收到回显"="命令没被执行",这个判据是可靠的。
  2. 回显是阻塞的。 HAL_UART_Transmit(..., HAL_MAX_DELAY) 会等每个字节进移位寄存器:11 个字符 @9600 ≈ 11.5ms。虽然不长,但在这 11ms 里主循环是停的。所以别在回显里塞长文本。
  3. 回显内容就是收到的整串。 如果缓冲区里带着残留的 \r\n,回显也会带——APP 端最好 trim() 一下再判断。

七、统一执行函数:蓝牙和 MQTT 共用一套

7.1 完整代码

// cmd      :收到的命令字符串,如 FAN_ON / CURTAIN_OFF / ALL_OFF
// echo_back:1=回显给手机(蓝牙);0=不回显(MQTT)
static uint8_t Remote_Cmd_Exec(const char *cmd, uint8_t echo_back)
{
  if(strstr(cmd, "FAN_ON"))                       // 风扇开
  {
    __HAL_TIM_SET_COMPARE(&htim3, TIM_CHANNEL_1, FAN_PWM);
    g_fan_on = 1;
    printf("[CMD] FAN ON\r\n");
  }
  else if(strstr(cmd, "FAN_OFF"))
  {
    __HAL_TIM_SET_COMPARE(&htim3, TIM_CHANNEL_1, 0);
    g_fan_on = 0;
    printf("[CMD] FAN OFF\r\n");
  }
  else if(strstr(cmd, "HER_ON"))                  // 加热开
  {
    __HAL_TIM_SET_COMPARE(&htim3, TIM_CHANNEL_2, HER_PWM);
    g_heat_on = 1;
    printf("[CMD] HER ON\r\n");
  }
  else if(strstr(cmd, "HER_OFF"))
  {
    __HAL_TIM_SET_COMPARE(&htim3, TIM_CHANNEL_2, 0);
    g_heat_on = 0;
    printf("[CMD] HER OFF\r\n");
  }
  else if(strstr(cmd, "CURTAIN_ON"))              // 窗帘拉开
  {
    __HAL_TIM_SET_COMPARE(&htim5, TIM_CHANNEL_1, CURTAIN_OPEN_PULSE);
    g_curtain_open = 1;
    printf("[CMD] CURTAIN OPEN\r\n");
  }
  else if(strstr(cmd, "CURTAIN_OFF"))             // 窗帘关闭
  {
    __HAL_TIM_SET_COMPARE(&htim5, TIM_CHANNEL_1, CURTAIN_CLOSE_PULSE);
    g_curtain_open = 0;
    printf("[CMD] CURTAIN CLOSE\r\n");
  }
  else if(strstr(cmd, "ALL_OFF"))                 // 一键全关
  {
    __HAL_TIM_SET_COMPARE(&htim3, TIM_CHANNEL_1, 0);
    __HAL_TIM_SET_COMPARE(&htim3, TIM_CHANNEL_2, 0);
    __HAL_TIM_SET_COMPARE(&htim5, TIM_CHANNEL_1, CURTAIN_CLOSE_PULSE);
    g_fan_on = 0; g_heat_on = 0; g_curtain_open = 0;
    printf("[CMD] ALL OFF\r\n");
  }
  else
  {
    return 0;                                     // 未命中:交给调用方继续攒
  }

  if(echo_back)
    HAL_UART_Transmit(&huart2, (uint8_t*)cmd, strlen(cmd), HAL_MAX_DELAY);
  return 1;
}

7.2 三个设计要点

① 只改 CCR,不碰定时器配置。
FAN_PWM=800 对应 CCR/(ARR+1)=800/1000=80%;窗帘的两个常量直接就是高电平微秒数(500μs / 2000μs,50Hz 的 20ms 周期里)。这些在第 8 章推导过,这里只负责"写数"。

② 标志位必须同步。
g_fan_on / g_heat_on / g_curtain_open 是手动模式的"账本"。远程改硬件时不更新它们,等下切回手动模式,KEY1 第一次按下去会先"关"(因为标志还是 0,翻转成 1 才开),用户会觉得"按一下没反应、按两下才对"。

③ ALL_OFF 的顺序无所谓,但必须三个都写。
只关风扇不关窗帘,是最常见的"漏一条"。

7.3 和模式状态机的关系

Remote_Cmd_Exec() 只在远程模式跑:

switch(g_mode)
{
  case MODE_REMOTE:
    ...
    if(uartlog == 2) { /* 拼命令 + Remote_Cmd_Exec */ }
    break;
  case MODE_AUTO:   /* 每 2s 读传感器 + 四分支控设备 */ break;
  case MODE_MANUAL: /* 外设由按键控制,主循环只刷缓存 */ break;
}

所以:

  • 在自动模式下发命令 → 没人接,uartlog 停在 2(这个隐患见 10.2 节);
  • 在手动模式下发命令 → 同上;
  • 这也是"命令发了没反应"的第二大原因:模式不对。

八、远程模式还兼职"上报环境数据"

远程模式下,主循环还有一段 2 秒定时任务:

case MODE_REMOTE:
  if(HAL_GetTick() - last_ctrl_tick >= 2000)
  {
    last_ctrl_tick = HAL_GetTick();
    Sensors_Update();                       // 刷新 DHT11 + ENS160 缓存

    if(g_dht11_ok && g_net == NET_BT)       // ★ 只有蓝牙协议才直接往 USART2 发
    {
      char json_buf[48];
      int len = snprintf(json_buf, sizeof(json_buf),
                         "{\"t\":%d,\"h\":%d}\r\n", g_TempH, g_HumiH);
      HAL_UART_Transmit(&huart2, (uint8_t*)json_buf, len, 100);
    }
  }

几点必须知道:

  1. 蓝牙通道只报温湿度(t、h),没有 AQI/eCO2/TVOC。完整的五项数据只走 MQTT(第 12 章)。所以接蓝牙时,APP 上空气质量那一块永远是空的——这是设计如此,不是 bug。
  2. g_net == NET_BT 这个条件是保命条件。 MQTT 模式下如果也往 USART2 发这串 JSON,ESP 会把它当成 AT 指令,回你 ERROR,甚至打乱正在进行的 MQTT 会话。
  3. snprintf 的 48 字节绰绰有余。 {"t":-10,"h":100}\r\n 也才 20 字节。
  4. 数据是"主动推"的,不需要 APP 去问。APP 只要按行读、JSON.parse 就能更新界面。注意结尾有 \r\n,所以 APP 侧必须按行切分,不能每收到几个字节就 parse 一次。

九、手把手联调:七步走完

步骤 0:硬件

  • 拨码开关 → 蓝牙侧;
  • HC-06:TXD→PA3、RXD→PA2、VCC、GND;
  • USB 转 TTL 接 USART1(PA9/PA10),串口助手 115200。

步骤 1:裸测模块

不接 STM32,USB 转 TTL 直接接 HC-06,9600 发 AT → 应回 OK。

步骤 2:上电看日志

预期能看到初始化打印(模式是手动):

[MODE] Manual (KEY2) | Keys: 1=FAN 3=CURTAIN 4=HEATER

步骤 3:进远程

按 KEY2(进自动)→ 按 KEY4(进远程):

[MODE] Auto | Temp Control ON
[MODE] Remote (KEY4) | Bluetooth ON
[NET] Bluetooth @9600

如果按 KEY4 只打印 [MANUAL] HEATER ON,说明你还留在手动模式——回步骤 3 开头按 KEY2。

步骤 4:手机连接

安卓装 Serial Bluetooth Terminal → 扫描 → 选 HC-06(名字可能是 HC-06 或改过的名)→ 配对码 1234 → 连接。

模块 LED 由快闪变常亮 = 连上了。

步骤 5:发命令

在 APP 的发送框输入 FAN_ON(关掉自动换行),发送。预期:

[CMD] FAN ON

同时 APP 接收区收到回显 FAN_ON,风扇开始转(80% 占空比)。

步骤 6:验证其余命令

发送日志现象
FAN_OFF[CMD] FAN OFF风扇停
HER_ON[CMD] HER ON加热片发热(别用手直接摸)
CURTAIN_ON[CMD] CURTAIN OPEN舵机转到一端
CURTAIN_OFF[CMD] CURTAIN CLOSE舵机转到另一端
ALL_OFF[CMD] ALL OFF全部停、窗帘落下

步骤 7:验证"只在远程生效"

按 KEY2 退出手动/回自动,再发一次 FAN_ON:

  • 日志没有 [CMD] FAN ON;
  • 设备无反应。

这就证明了命令解析确实只在 MODE_REMOTE 分支里跑。

十、调不通:按这个顺序查

10.1 手机根本连不上

现象原因处理
搜不到设备模块没供电 / 灯不闪量 VCC、看灯
搜得到、配对失败配对码不对试 1234、0000
配对成功又断开已被别的手机占着HC-06 只能连一台,先断开旧设备
iPhone 搜不到可用服务iOS 不支持 SPP换安卓
APP 显示连上、发数据无反应APP 没选"文本/ASCII"、误开 HEX切到文本模式

10.2 连上了,但设备不动

按这个顺序,一条一条排除(顺序就是命中率):

  1. 拨码开关在蓝牙侧吗?(最常见)
  2. 现在真的是远程模式吗? 日志里要有 [MODE] Remote 和 [NET] Bluetooth @9600。
  3. 命令拼写对吗? CURTAIN_ON 不是 CURTAIN_OPEN。
  4. 日志里是不是连续一串单字符 unknown? 是 → 5.3 节的清空顺序问题。
  5. 日志里连 unknown 都没有? 说明主循环压根没进拼接分支,uartlog 可能被"上一次"残留的值占住了(见下面的说明)。
  6. 波特率对吗? 手机端不需要设波特率(蓝牙链路是透明的),但模块自身的串口波特率必须是 9600。如果模块被人改成 115200,你会看到"能收但全是乱码"。

关于 uartlog 残留:在非远程模式时收到过字节 → 回调把 uartlog 置 2,但那个模式没人消费它,接收也不再重挂。等进入远程模式,主循环第一次处理的其实是"旧字节",还会顺手把接收重挂上。现象:刚进远程就冒一条 [CMD] unknown: X(X 是你在别的模式下收到的字节),随后才恢复正常。

修法很简单,切协议时顺手清一下:

g_net = NET_BT;                 // 先定协议
uartlog = 0;                    // ★ 清掉别处残留的"有数据"标记
Uart2_SetBaudrate(9600);        // 再切波特率
HAL_UART_Receive_IT(&huart2, bt_buf, 1);

10.3 有反应,但日志是 unknown 或乱码

日志/现象原因处理
[CMD] unknown: F 连续单字符缓冲区每字节被清空5.4 节的修法
[CMD] unknown: FAN_ON 完整但报未知命令名不匹配(大小写/下划线/OPEN vs ON)对照 4.1 表格
[CMD] unknown: \r / \n 零星出现APP 自动追加换行关掉自动换行
收到的字符时对时错波特率不匹配 / 没共地量 GND、确认 9600
偶发丢命令DHT11 关中断约 4ms,9600 下丢 3~4 字节命令重发;或改环形缓冲(第 10 章 6.4、第 12 章)

最后一条要解释一下:远程模式每 2 秒调一次 Sensors_Update(),里面 __disable_irq() 保护 40 位时序约 4ms。9600 波特率下一个字节 1.04ms,4ms 里正好能进来 3~4 个字节——如果你恰好在那一瞬间发命令,就会残缺,日志表现为 [CMD] unknown: FAN_O。

这不是玄学,是可复现的。 连续发同一条命令 20 次,偶尔失败一两次,基本就是它。

10.4 回显收不到

  • 未知命令不回显(7.1 节代码里 return 0 在回显之前),所以先看日志有没有 [CMD] xxx;
  • APP 端如果开了"自动换行/时间戳",回显会混在别的字符里,看起来像没收到;
  • 手机端收到的是原始字节流,包括 2 秒一条的 {"t":..,"h":..},APP 要能区分"回显行"和"数据行"。

十一、本章易错点总结

现象真正原因处理
手机连着,设备不动还在手动模式,KEY4 只是加热先按 KEY2 进自动,再按 KEY4
命令发了完全没反应拨码开关还在 WiFi 侧拨到蓝牙侧,断电重上
日志连续 unknown: F/A/N命令缓冲每字节被清空,永远拼不齐只有命中才清空(5.4 节)
[CMD] unknown: FAN_ON命令字面量不对(CURTAIN_OPEN vs CURTAIN_ON)对照命令表
蓝牙收发全乱码模块波特率不是 9600裸测确认,AT+BAUD4 改回
修改波特率后模块失联AT+BAUD 后没断电重上断电再上电
裸测发 AT 无回显手机还连着模块(AT 只在未连接时有效)断开手机再试
手机搜不到 HC-06iPhone 不支持 SPP换安卓
第二台手机连不上HC-06 只支持一台主机先断开第一台
蓝牙 OK、WiFi 不行(或反之)拨码没跟着切硬件和软件协议必须一致
刚进远程就冒一条 unknown非远程模式残留的 uartlog=2 和旧字节切协议时 uartlog=0
命令偶尔丢一两个字符DHT11 关中断 4ms 撞上 9600 收字节重发;或改环形缓冲
切协议后第一字节丢失g_net 和挂接收的顺序反了先置协议标志,再 Receive_IT
切波特率后接收一直 HAL_BUSY少了 HAL_UART_AbortReceive_IT见 3.3 第 3 步
切波特率后最后一个字节残缺只等 TXE 没等 TC等 UART_FLAG_TC
中断里切波特率导致卡死带等待的操作放进了中断中断只置 g_net_req,主循环执行
远程控制后切回手动,第一次按键"没反应"标志位没同步Remote_Cmd_Exec 里同步 g_*
APP 上空气质量一直空白蓝牙通道只报 t/h正常,五项数据走 MQTT
APP 收到一长串粘在一起的 JSON没按行切分按 \n 断行再 JSON.parse
日志里没有 [NET] Bluetooth @9600g_net_req 没被执行检查主循环 g_net_req 处理段是否被 return/死循环挡住

十二、小结与下一篇

这一章记住六件事:

  • HC-06 是从机、透传、默认 9600、只支持一台、iPhone 连不上;
  • 上电默认手动模式,进远程的正确路径是 KEY2 → KEY4;
  • 拨码开关软件读不到,硬件和协议必须人工保持一致;
  • 命令是整串字符串,CURTAIN_ON 不是 CURTAIN_OPEN,APP 别自动加换行;
  • 中断只收字节,拼接和解析在主循环;缓冲区要"命中才清空";
  • 蓝牙只报温湿度,MQTT 才报五项,两条通道共用同一个 Remote_Cmd_Exec()。

下一篇是全系列最难的一章:USART2 分时复用 —— ESP-12F + MQTT。同一组引脚、两种波特率、十几秒的阻塞初始化、AT 指令的增量匹配、AT+MQTTPUBRAW 的 > 握手……第 10 章和第 11 章铺的所有地基,都是为了它。

如果这篇文章对你有帮助,欢迎点赞收藏。系列更新中,下一篇见。


附录 A:本章涉及的完整代码

A.1 初始化:接收缓冲与首次挂载

/* main.c,MX_USART2_UART_Init() 之后 */
static uint8_t bt_buf[32]     = {0};   // 中断每次只搬 1 个字节,实际只用到 bt_buf[0]
static uint8_t bt_cmd_buf[16] = {0};   // 命令拼接缓冲(最长 CURTAIN_OFF = 11 字符)

HAL_UART_Receive_IT(&huart2, bt_buf, 1);   // 第二个参数是"本次收几个",不是缓冲容量

bt_buf[32] 里的 32 是历史遗留,Receive_IT(..., 1) 只写 bt_buf[0]。别以为一次能收 32 个。

A.2 接收中断回调:按协议分派

void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart)
{
  if(huart->Instance == USART1)
  {
    uartlog = 1;                        // 调试口收到的数据(本工程只标记,不消费)
  }
  else if(huart->Instance == USART2)
  {
    if(g_mode == MODE_REMOTE && g_net == NET_MQTT)
    {
      // MQTT:这 1 字节属于 ESP 的 AT 应答/订阅消息 → 进环形缓冲
      Esp_Uart_RxByte(esp_rx_byte);
      HAL_UART_Receive_IT(&huart2, &esp_rx_byte, 1);   // 立刻重挂
    }
    else
    {
      // 蓝牙:只标记,主循环拼命令后再重挂(这样主循环能稳定读 bt_buf[0])
      uartlog = 2;
    }
  }
}

两种重挂位置是有意不同的:

通道重挂位置原因
MQTT中断里立刻重挂连续流(AT 应答、订阅消息),晚一点就丢字节
蓝牙主循环处理后重挂主循环要先读 bt_buf[0],中断里重挂会把它覆盖

A.3 远程模式主循环:2 秒上报 + 命令拼接(修复版)

case MODE_REMOTE:
{
  // ---- 每 2 秒:读传感器 + 蓝牙通道上报温湿度 ----
  if(HAL_GetTick() - last_ctrl_tick >= 2000)
  {
    last_ctrl_tick = HAL_GetTick();
    Sensors_Update();

    if(g_dht11_ok && g_net == NET_BT)
    {
      char json_buf[48];
      int len = snprintf(json_buf, sizeof(json_buf),
                         "{\"t\":%d,\"h\":%d}\r\n", g_TempH, g_HumiH);
      HAL_UART_Transmit(&huart2, (uint8_t*)json_buf, len, 100);
    }
  }

  // ---- 蓝牙命令:逐字节攒,凑齐一通就执行 ----
  if(uartlog == 2)
  {
    uartlog = 0;
    uint8_t clen = strlen((char*)bt_cmd_buf);

    if(clen >= sizeof(bt_cmd_buf) - 1)          // 攒满还没命中 → 乱码,丢弃重来
    {
      printf("[CMD] unknown: %s\r\n", bt_cmd_buf);
      bt_cmd_buf[0] = '\0';
    }
    else
    {
      bt_cmd_buf[clen]     = bt_buf[0];
      bt_cmd_buf[clen + 1] = '\0';

      if(Remote_Cmd_Exec((char*)bt_cmd_buf, 1)) // 命中才清空
        bt_cmd_buf[0] = '\0';
    }

    HAL_UART_Receive_IT(&huart2, bt_buf, 1);     // 重挂接收
  }
  break;
}

A.4 统一命令执行函数

见 7.1 节完整代码。注意:

  • 返回类型从 void 改为 uint8_t(1=命中,0=未命中);
  • 未命中时不打印、不回显;
  • 命中后同步 g_fan_on / g_heat_on / g_curtain_open。

MQTT 侧调用不受影响(忽略返回值即可):

Remote_Cmd_Exec(esp_sub_data, 0);   // echo_back=0:不回显

A.5 运行时切波特率

见 3.3 节完整代码。调用约束:

约束原因
只能在主循环调用内部有等待,放中断会卡死
调用前拨码开关要到位软件读不到开关
调用后必须自己重挂接收函数内部已 AbortReceive_IT
调用前先定好 g_net避免切换瞬间的字节走错分支

A.6 KEY4 的分派

else if(GPIO_Pin == KEY4_Pin)
{
  if(g_mode == MODE_MANUAL)
  {
    g_heat_on = !g_heat_on;                                  // 手动:加热
    __HAL_TIM_SET_COMPARE(&htim3, TIM_CHANNEL_2, g_heat_on ? HER_PWM : 0);
    printf("[MANUAL] HEATER %s\r\n", g_heat_on ? "ON" : "OFF");
  }
  else
  {
    if(g_mode != MODE_REMOTE)
    {
      Switch_Mode(MODE_REMOTE);     // 进远程(默认蓝牙)
      g_net_req = NET_REQ_BT;
    }
    else if(g_net_busy)             // MQTT 初始化期间:丢弃
      printf("[KEY4] busy: MQTT init in progress, ignored\r\n");
    else if(g_net == NET_BT) g_net_req = NET_REQ_MQTT;
    else                     g_net_req = NET_REQ_BT;
  }
}

A.7 最小验证:三步确认蓝牙通

第一步:只验证"收到了字节"

在 uartlog = 2; 后面加一句临时打印:

printf("[BT] byte=%02X (%c)\r\n", bt_buf[0], bt_buf[0]);

手机发 F,串口助手应打印 [BT] byte=46 (F)。

  • 没有 → 拨码开关、接线、模式、波特率(回到 10.2 节);
  • 有 → 链路已通,问题一定在拼接/匹配。

第二步:验证拼接

把 5.4 节的修复版贴进去,手机发 FAN_ON,预期日志只有一行:

[CMD] FAN ON

并且 APP 收到回显 FAN_ON。

第三步:验证状态同步

发 FAN_ON → 按 KEY2 退到手模式 → 按 KEY1:

  • 风扇应该立刻停(因为 g_fan_on 已经是 1,翻转成 0);
  • 如果按第一下没反应、第二下才停,说明 Remote_Cmd_Exec() 里没同步 g_fan_on。

三步全过,蓝牙通道就算调通了,可以进入第 12 章的 ESP-12F。

评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

个

红包个数最小为10个

元

红包金额最低5元

当前余额3.43元 前往充值 >
需支付:10.00元
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付元
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值