基于低功耗蓝牙的传感器数据传输:从原理到ESP32实战
2026/8/20 3:17:00 网站建设 项目流程

1. 项目缘起:为什么选择蓝牙传输传感器数据?

最近在折腾一个智能花盆的项目,需要实时监测土壤的湿度、温度和光照强度,然后把数据传到手机上方便查看。一开始考虑过Wi-Fi,但家里路由器离阳台有点远,信号不稳定,而且花盆这种低功耗设备一直连着Wi-Fi也挺费电的。后来也想过用LoRa或者Zigbee,但模块成本和开发复杂度对个人项目来说有点高了。最后,我把目光投向了几乎每部手机都自带、功耗相对可控的蓝牙,特别是低功耗蓝牙(Bluetooth Low Energy, BLE)。

这个选择背后有几个很实际的考虑。首先,普适性是最大的优势。你几乎不需要担心用户的手机不支持蓝牙,这比让用户去配对一个专用的Wi-Fi网络要简单得多。其次,对于传感器这种间歇性发送少量数据的场景,BLE的功耗表现非常出色。它大部分时间处于深度睡眠状态,只在需要通信时快速唤醒,发送完数据又立刻休眠,一颗纽扣电池能撑好几个月。最后,开发门槛相对友好。现在Arduino、ESP32这类开发板对BLE的支持已经很成熟,手机端也有成熟的App框架甚至现成的调试工具,比如“Serial Bluetooth Terminal”这类串口蓝牙终端App,让你在开发初期就能快速验证数据通路是否畅通。

所以,如果你也在做一个需要把温湿度、气压、心率等传感器数据无线传输到手机或电脑的小项目,尤其是在室内、短距离(通常10米以内)、对实时性要求不是极端苛刻的场景下,蓝牙,特别是BLE,是一个非常务实且高效的选择。它绕开了复杂的网络配置,直连设备,让想法能更快地落地成可交互的原型。

2. 核心概念扫盲:经典蓝牙与低功耗蓝牙的关键差异

决定用蓝牙之后,第一个要搞清楚的问题就是:用经典蓝牙(Bluetooth Classic)还是低功耗蓝牙(BLE)?这直接决定了你的硬件选型、协议设计和功耗表现。很多人刚开始容易混淆,觉得蓝牙都一样,其实它俩从设计初衷到工作方式都大不相同。

简单来说,你可以把经典蓝牙想象成一条“持续流淌的小溪”。它设计用于需要连续、较高数据速率的流式传输,比如我们熟悉的蓝牙耳机听音乐(A2DP)、蓝牙音箱或者文件传输(FTP)。为了保持音频流连续不断,它需要维持一个稳定的、高带宽的连接通道,这就像小溪必须一直有水在流,自然就比较“费电”。

低功耗蓝牙(BLE)更像是一个“高效的邮差”。它专为间歇性、小批量数据传输而优化,比如心率带每隔一秒发送一次心率数据,或者智能门锁每天只同步几次时间。它的工作模式是“事件驱动”的:大部分时间在睡觉(广告间隔可以设置到几百毫秒甚至几秒),当有数据要发送或接收时迅速醒来,处理完立刻回去睡觉。这种“打盹”机制使得它的平均功耗可以做到极低。

对于我们传输传感器数据的场景,这个选择就非常清晰了。传感器数据(比如温度值、湿度百分比)通常都是几个字节到几十个字节的小数据包,而且我们不需要像音频那样每秒几万字节的连续流。我们需要的正是BLE这种“按需通信”的能力。发送一个温度读数后,设备就可以去休眠,等到下一个采集周期再醒来。这意味着你可以用更小的电池,或者让设备运行更长时间。

这里有一个技术细节需要注意:BLE的通信基于“服务器-客户端”模型。你的传感器设备通常作为GATT服务器,它定义了一系列服务和特征值。比如,你可以创建一个“环境监测服务”,里面包含“温度特征”、“湿度特征”、“光照特征”。每个特征值就像一个可以读写的数据寄存器。手机App则作为GATT客户端,去连接这个服务器,并订阅(Subscribe)或读取(Read)这些特征值。当传感器数据更新时,服务器可以通过“通知”(Notification)或“指示”(Indication)主动推送给已订阅的客户端,而无需客户端反复轮询,这进一步优化了效率和功耗。

3. 硬件选型与电路连接实战

理论清楚了,接下来就是动手。硬件是项目的骨架,选对了,后面就顺风顺水。

3.1 核心控制器:带BLE功能的微控制器

对于个人项目和小批量原型,我强烈推荐使用集成了BLE射频和微处理器于一体的SoC方案,这比“MCU + 外置BLE模块”的组合要简单、稳定且成本更低。目前市面上主流的选择有:

  • ESP32系列:这是绝对的“网红”选手,性价比之王。一颗芯片同时集成了Wi-Fi和蓝牙(包括经典和BLE),双核处理器,性能强劲,社区资源极其丰富。对于只需要BLE的项目,像ESP32-C3(单核RISC-V)或ESP32-S3(双核Xtensa)都是非常好的选择。它的Arduino核心支持完善,用BLEDevice库几行代码就能建立起一个BLE服务器,对新手极其友好。
  • nRF52系列(如nRF52832/52840):来自Nordic Semiconductor,可以说是BLE领域的“原住民”和标杆。它的射频性能、功耗控制以及对BLE协议栈的支持都非常专业。如果你对功耗有极致要求,或者项目后期考虑认证和量产,nRF52是更工业级的选择。开发可以使用Nordic自家的nRF Connect SDK(基于Zephyr RTOS)或Arduino核心。
  • Arduino Nano 33 BLE:如果你钟情于Arduino生态,这款板子搭载了nRF52840,提供了完美的Arduino兼容性和强大的BLE能力,开箱即用。

以我手头的ESP32-C3开发板为例,它成本不到20元,但BLE功能完整,足以胜任绝大多数传感器数据转发任务。

3.2 传感器选型与连接

传感器根据你的需求来定。常见的有:

  • 温湿度:DHT11/DHT22(单总线), SHT30/SHT40(I2C,精度更高)。
  • 光照强度:BH1750(I2C)。
  • 大气压强:BMP280/BME280(I2C/SPI,BME280还集成温湿度)。

连接方式上,I2C总线是连接多个传感器的首选,因为它只需要两根数据线(SDA, SCL)加上电源和地,就可以挂载多个设备,每个设备有唯一的地址,非常节省GPIO口。以连接SHT30(温湿度)和BH1750(光照)为例:

  1. 硬件连接

    • 将ESP32-C3的3.3V引脚连接到传感器的VCC
    • 将ESP32-C3的GND引脚连接到传感器的GND
    • 将ESP32-C3的GPIO4定义为SDA,连接到所有传感器的SDA引脚。
    • 将ESP32-C3的GPIO5定义为SCL,连接到所有传感器的SCL引脚。
    • 注意:I2C总线需要上拉电阻。通常开发板内部已启用上拉,但如果连接线较长或设备较多,数据不稳定,可以在SDASCL线上各接一个4.7kΩ的电阻到3.3V
  2. 地址确认:每个I2C设备有固定或可配置的地址。SHT30通常为0x440x45,BH1750为0x23。你可以先写一个简单的I2C扫描程序,确认设备地址是否正确被识别,这是排查连接问题的第一步。

实操心得:焊接或使用杜邦线连接时,务必确保电源稳定。传感器对电源噪声比较敏感,不稳定的电源会导致读数跳动甚至无法初始化。如果遇到数据异常,第一个要检查的就是电源电压和地线连接是否牢固。

4. 软件架构:从数据采集到BLE广播

硬件连好了,接下来是让它们“活”起来的软件部分。整个流程可以分解为:初始化 -> 循环采集 -> 数据处理 -> BLE更新 -> 休眠。我们以ESP32的Arduino框架为例来拆解。

4.1 初始化阶段

#include <Wire.h> #include <BLEDevice.h> #include <BLEUtils.h> #include <BLEServer.h> #include <BLE2902.h> // 定义BLE服务和特征值的UUID // 可以使用在线UUID生成器生成唯一的UUID,避免与标准服务冲突 #define SERVICE_UUID "4fafc201-1fb5-459e-8fcc-c5c9c331914b" #define TEMPERATURE_CHAR_UUID "beb5483e-36e1-4688-b7f5-ea07361b26a8" #define HUMIDITY_CHAR_UUID "beb5483e-36e1-4688-b7f5-ea07361b26a9" #define LUX_CHAR_UUID "beb5483e-36e1-4688-b7f5-ea07361b26aa" // 全局变量定义 BLEServer *pServer; BLECharacteristic *pTemperatureChar; BLECharacteristic *pHumidityChar; BLECharacteristic *pLuxChar; void setup() { Serial.begin(115200); // 1. 初始化I2C总线 Wire.begin(4, 5); // SDA=GPIO4, SCL=GPIO5 // 2. 初始化传感器(这里需要调用具体传感器的初始化函数,例如sht30.begin()等) initSensors(); // 3. 创建BLE设备并设置名称 BLEDevice::init("SmartFlowerPot"); // 4. 创建BLE服务器 pServer = BLEDevice::createServer(); // 5. 创建BLE服务 BLEService *pService = pServer->createService(SERVICE_UUID); // 6. 为服务创建特征值 // 特征值属性:READ表示客户端可读,NOTIFY表示服务器可主动通知 pTemperatureChar = pService->createCharacteristic( TEMPERATURE_CHAR_UUID, BLECharacteristic::PROPERTY_READ | BLECharacteristic::PROPERTY_NOTIFY ); pHumidityChar = pService->createCharacteristic( HUMIDITY_CHAR_UUID, BLECharacteristic::PROPERTY_READ | BLECharacteristic::PROPERTY_NOTIFY ); pLuxChar = pService->createCharacteristic( LUX_CHAR_UUID, BLECharacteristic::PROPERTY_READ | BLECharacteristic::PROPERTY_NOTIFY ); // 7. 为每个特征值添加一个描述符(Client Characteristic Configuration Descriptor, CCCD) // 客户端通过这个描述符来订阅(Subscribe)通知 pTemperatureChar->addDescriptor(new BLE2902()); pHumidityChar->addDescriptor(new BLE2902()); pLuxChar->addDescriptor(new BLE2902()); // 8. 启动服务 pService->start(); // 9. 开始广播,让周围的设备能发现我们 BLEAdvertising *pAdvertising = BLEDevice::getAdvertising(); pAdvertising->addServiceUUID(SERVICE_UUID); pAdvertising->setScanResponse(true); pAdvertising->setMinPreferred(0x06); // 有助于提高某些手机的连接速度 pAdvertising->setMinPreferred(0x12); BLEDevice::startAdvertising(); Serial.println("BLE Server started and advertising..."); }

4.2 主循环与数据更新

loop()函数中,我们周期性地读取传感器数据,并更新BLE特征值。关键点在于,只有更新特征值并调用notify(),已连接的客户端才会收到新数据。

void loop() { // 1. 读取传感器数据 float temperature = readTemperature(); // 假设的函数 float humidity = readHumidity(); float lux = readLux(); // 2. 将浮点数转换为字节数组,以便通过BLE传输 // BLE特征值的数据是字节数组(uint8_t array) uint8_t tempBytes[4]; memcpy(tempBytes, &temperature, 4); // 将float的4个字节拷贝到数组 uint8_t humiBytes[4]; memcpy(humiBytes, &humidity, 4); uint8_t luxBytes[4]; memcpy(luxBytes, &lux, 4); // 3. 更新BLE特征值 pTemperatureChar->setValue(tempBytes, 4); pTemperatureChar->notify(); // 发送通知给已订阅的客户端 pHumidityChar->setValue(humiBytes, 4); pHumidityChar->notify(); pLuxChar->setValue(luxBytes, 4); pLuxChar->notify(); // 4. 打印到串口方便调试 Serial.printf("Temp: %.2f°C, Humi: %.2f%%, Lux: %.2f lx\n", temperature, humidity, lux); // 5. 进入深度睡眠以省电(根据需求) // esp_deep_sleep(1000000 * 10); // 睡眠10秒 // 如果不睡眠,则用delay控制采集间隔 delay(5000); // 每5秒采集并发送一次 }

避坑指南notify()indicate()的区别。两者都用于服务器主动推送数据。notify是“发完即忘”,不保证客户端收到;indicate则需要客户端回复确认,保证了可靠性但增加了延迟和功耗。对于传感器数据这种允许偶尔丢失的非关键数据,用notify就够了。另外,频繁调用notify(比如毫秒级)可能会让某些手机端的BLE栈处理不过来,导致连接不稳定,建议根据数据变化率合理设置间隔。

5. 手机端数据接收:从调试App到自定义开发

设备端代码跑通了,数据在广播了,我们怎么在手机上看呢?这里有两条路径:快速验证和深度定制。

5.1 利用现成App快速验证

在开发初期,强烈建议使用现成的BLE调试App来验证你的设备是否正常工作。这能帮你快速排除是硬件问题、BLE服务配置问题还是手机端问题。我常用的有:

  • nRF Connect(by Nordic Semiconductor):功能最强大、最专业的BLE调试工具之一。它可以扫描、连接、浏览设备的GATT表(所有服务和特征值一目了然),手动读写特征值,以及订阅通知。当你手机连接上“SmartFlowerPot”后,就能在nRF Connect里看到我们定义的“环境监测服务”,点开温度特征,你不仅能读到当前值(一堆十六进制数),还能点击“启用通知”的图标。一旦启用,每次设备调用notify(),手机这边就会实时显示新的数据。你可以手动把收到的4个字节的十六进制数转换成浮点数来验证。
  • Serial Bluetooth Terminal:正如网络热词所示,这款App非常流行。它更侧重于串口透传模式,但对于标准的BLE通知,只要它支持作为GATT客户端并订阅特征值,也能接收数据。它的界面可能更像一个简单的串口监视器,适合喜欢简洁风格的用户。

用这些App验证,可以确保你的BLE服务器代码、UUID设置、数据打包格式都是正确的。这是迈向成功的关键一步,避免了在自定义开发时面对“没数据”的问题,却不知道是设备端没发还是手机端没收到。

5.2 开发自定义Android App (Kotlin示例)

当你想做一个专属的、界面友好的花盆监测App时,就需要自己动手了。下面是一个使用Kotlin在Android上接收BLE通知的核心流程概览:

  1. 权限申请:在AndroidManifest.xml中添加蓝牙和位置权限(Android 6.0+需要位置权限来扫描BLE设备)。
  2. 扫描设备:使用BluetoothLeScanner开始扫描,通过ScanCallback过滤设备名为“SmartFlowerPot”的设备。
  3. 连接设备:找到设备后,使用BluetoothGatt进行连接。连接是异步的,状态回调在BluetoothGattCallback中。
  4. 发现服务:连接成功后,调用gatt.discoverServices()。服务发现完成后,会在回调的onServicesDiscovered方法中收到通知。
  5. 订阅通知:在onServicesDiscovered中,通过服务的UUID和特征值的UUID找到我们定义的“温度特征”。然后,执行以下关键操作:
    val characteristic = service.getCharacteristic(temperatureCharUuid) characteristic?.let { char -> // 启用客户端特征配置描述符(CCCD)的通知 gatt.setCharacteristicNotification(char, true) val descriptor = char.getDescriptor(CCCD_UUID) // CCCD_UUID通常是0x2902 descriptor?.value = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE gatt.writeDescriptor(descriptor) // 写入描述符以订阅通知 }
  6. 接收数据:订阅成功后,新的传感器数据会通过onCharacteristicChanged回调送达。
    override fun onCharacteristicChanged( gatt: BluetoothGatt, characteristic: BluetoothGattCharacteristic ) { when(characteristic.uuid) { temperatureCharUuid -> { val bytes = characteristic.value val temperature = ByteBuffer.wrap(bytes).order(ByteOrder.LITTLE_ENDIAN).float runOnUiThread { updateTemperatureUI(temperature) } } // ... 处理其他特征值 } }
    注意字节序:在ESP32(小端架构)上,float以小端字节序存储。在Android(通常是大端)上解析时,需要明确指定字节序,否则会得到错误的数据。这是跨平台数据传输的一个经典坑。

开发经验:Android上的BLE开发,生命周期管理回调处理是两大难点。务必在App的onPauseonDestroy中及时关闭GATT连接、停止扫描,并置空回调引用,防止内存泄漏。此外,所有BLE操作(连接、发现服务、读写)都是异步的,且不能并行,必须在一个队列里顺序执行,否则很容易导致操作失败或连接断开。社区有一些优秀的BLE封装库(如RxAndroidBle)可以帮助管理这些复杂性。

6. 功耗优化与连接稳定性实战

项目基本跑通后,我们往往会发现两个现实问题:电池耗得比预期快,以及蓝牙连接偶尔会莫名其妙断开。这就需要进入优化阶段。

6.1 深度睡眠与采集周期调优

功耗的大头在MCU和射频部分。对于ESP32这类芯片,优化策略是:尽可能让它们睡觉

  • 使用深度睡眠:在前面的loop()示例中,我用了delay(5000)。这意味着MCU和射频(在广播或连接状态下)在这5秒内仍然是活跃的,只是没干活。更优的做法是使用深度睡眠。在数据发送完毕后,调用esp_deep_sleep()函数,让芯片彻底关闭CPU和大部分外设,仅保留RTC计时器在运行。到达设定的睡眠时间后,RTC计时器会触发复位,芯片重启,从头执行setup()loop(),再次采集数据、广播/连接、发送,然后继续睡。

    // 在loop()末尾,替换delay Serial.println("Entering deep sleep for 10 seconds..."); esp_deep_sleep(1000000 * 10); // 单位是微秒,这里是10秒

    注意:深度睡眠时,RAM中的数据会丢失。如果你的BLE连接需要保持,就不能用深度睡眠,而应该用轻度睡眠调制解调器睡眠(在已连接状态下自动启用)。对于传感器数据记录仪这类通常由手机主动连接来读取数据的设备,深度睡眠是完美选择。对于需要持续向手机推送数据的设备,则需要在连接状态下优化广播间隔和连接参数。

  • 延长广播间隔:在startAdvertising()之前,可以配置广播参数。更长的广播间隔意味着更少的射频活动,功耗更低,但设备被手机发现的速度会变慢。

    BLEAdvertising *pAdvertising = BLEDevice::getAdvertising(); pAdvertising->setMinInterval(0x400); // 单位是0.625ms, 0x400 = 1024*0.625ms = 640ms pAdvertising->setMaxInterval(0x800); // 0x800 = 2048*0.625ms = 1280ms

6.2 BLE连接参数协商

连接稳定性很大程度上取决于连接参数:连接间隔、从机延迟和监控超时。这些参数在连接时由主机(手机)和从机(我们的设备)协商决定,但设备端可以发出更新请求。

  • 连接间隔:主机和从机之间进行数据交换的周期。间隔越短,实时性越好,但功耗越高。对于传感器数据,1秒(1600 * 0.625ms)甚至更长的间隔通常足够。你可以在设备端代码中,在连接建立后的回调里,尝试向主机发送连接参数更新请求。
  • 从机延迟:允许从机跳过多少个连接事件而不唤醒监听。如果设为n,从机可以每n+1个连接事件醒来一次检查是否有数据,这能大幅降低功耗。对于主要靠通知推送数据的设备,可以设置一个较大的从机延迟。
  • 监控超时:在多久没有成功通信后判定连接丢失。这个值必须是连接间隔的10倍以上。

很多连接不稳定(尤其是Android设备)的问题,都源于手机系统蓝牙栈使用了不合适的连接参数。在设备端主动请求一个更合理的参数集,有时能显著改善体验。ESP32的Arduino BLE库可能没有直接提供简单的API来设置这些,但在底层是可以配置的,或者你可以尝试使用更底层的IDF API。

稳定性心得:除了参数,还要注意射频环境。Wi-Fi(特别是2.4GHz)和蓝牙共用频段,相互干扰是常事。如果你的ESP32同时开启了Wi-Fi和BLE,尝试关闭Wi-Fi,或者将Wi-Fi信道固定在1、6、11之外的信道。另外,确保设备供电充足,电压跌落会导致蓝牙射频工作异常,从而断连。

7. 数据格式与协议设计考量

当你的传感器不止一个,或者数据量稍大时,如何组织通过BLE发送的数据包,就成了一门学问。直接把几个浮点数的字节数组依次notify出去虽然简单,但不够优雅,也缺乏扩展性。

7.1 自定义简单协议

一个更好的做法是定义一个轻量级的应用层协议。例如,我们可以设计一个包含数据头、传感器类型、数据长度、数据体和校验和的小数据包。

// 假设我们定义一种数据帧格式: // [帧头0xAA][帧头0x55][传感器ID][数据长度N][数据...][校验和] // 校验和可以是前面所有字节的简单求和取低8位 struct SensorPacket { uint8_t header[2] = {0xAA, 0x55}; uint8_t sensorId; // 0x01:温度,0x02:湿度,0x03:光照 uint8_t dataLen; // 后续数据长度,例如float是4 uint8_t data[4]; // 实际数据 uint8_t checksum; }; void sendSensorDataOverBLE(uint8_t sensorId, float value) { SensorPacket packet; packet.sensorId = sensorId; packet.dataLen = 4; memcpy(packet.data, &value, 4); // 计算校验和(简单示例) packet.checksum = 0; uint8_t* p = (uint8_t*)&packet; for(int i=0; i<sizeof(packet)-1; i++) { packet.checksum += p[i]; } // 通过一个统一的“数据通道”特征值发送整个包 pDataChannelChar->setValue((uint8_t*)&packet, sizeof(packet)); pDataChannelChar->notify(); }

这样,手机端只需要订阅一个特征值,就能收到所有传感器的数据。通过解析sensorId来区分数据类型,扩展性大大增强。如果想同时发送多个传感器读数,也可以设计成复合数据包。

7.2 JSON格式传输

对于更复杂的数据结构,或者需要与云端服务对接,发送JSON字符串是一个通用性极强的选择。ESP32上可以使用ArduinoJson库来序列化数据。

#include <ArduinoJson.h> void sendSensorDataAsJSON(float temp, float humi, float lux) { StaticJsonDocument<200> doc; doc["device"] = "FlowerPot_01"; doc["timestamp"] = millis(); doc["data"]["temperature"] = temp; doc["data"]["humidity"] = humi; doc["data"]["illuminance"] = lux; String output; serializeJson(doc, output); // 注意:BLE特征值有长度限制(通常是20字节, ATT_MTU默认23字节减3字节开销) // 长数据需要分段。或者,可以协商更大的MTU(如247字节)来传输更长的数据包。 pJsonDataChar->setValue(output.c_str()); pJsonDataChar->notify(); }

使用JSON的优点是手机端解析极其方便(直接用JSONObject),而且易于阅读和调试。缺点是数据冗余多,传输效率低,且需要处理可能的数据分包。对于简单的传感器数据,自定义二进制协议更高效;对于需要强可读性和扩展性的场景,JSON是更好的选择。

协议选择建议:在项目初期,为了快速验证,可以直接发送原始字节。当功能稳定、需要添加更多传感器或数据项时,再引入简单的自定义协议。如果未来确定要与手机App深度交互或对接云平台,那么从一开始就采用JSON格式可能会减少后期重构的工作量。记住,BLE的传输速率和包大小有限,设计协议时务必以简洁为首要原则

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询