蓝牙温湿度计实战:Environmental Sensing Service、定期上报、阈值告警

好,咱们今天来搞点实在的。前面讲了那么多 BLE 的基础概念、Service 和 Characteristic 的读写套路,是时候拿一个真实设备练练手了。我个人觉得,温湿度计是入门 BLE 开发最好的玩具——逻辑简单、数据直观、反馈即时。你写完代码,手机一贴,温度数字跳出来,那种成就感是很直接的。

Environmental Sensing Service 是什么?

蓝牙联盟定义了一套标准服务,叫 Environmental Sensing Service,简称 ESS。它的 UUID 是 0x181A。说白了,就是专门给环境传感器用的——温度、湿度、气压、光照,统统归它管。

我刚开始接触 BLE 时,看到一堆标准 Service 列表,头都大了。后来发现,其实你只需要记住几个常用的就行。ESS 就是其中之一,而且它的结构非常清晰。

Characteristic UUID 说明
Temperature 0x2A6E 当前温度值,单位摄氏度
Humidity 0x2A6F 当前湿度值,单位百分比
Pressure 0x2A6D 大气压,单位帕斯卡

嗯,这里要注意:温度 Characteristic 的数据格式是 INT16,单位是 0.01 摄氏度。也就是说,设备发过来的原始值是 2536,实际温度是 25.36°C。这个换算关系,我在项目里吃过亏——有一次直接拿原始值显示,结果温度飙到 2536 度,差点以为实验室着火了。

连接与发现 Service

连接设备后,第一步就是发现 Service。这个过程其实很简单,调用 BluetoothGatt.discoverServices() 就行。但有个坑:这个方法是异步的,结果会通过回调返回。

override fun onServicesDiscovered(gatt: BluetoothGatt, status: Int) {
    if (status != BluetoothGatt.GATT_SUCCESS) {
        Log.e(TAG, "Service discovery failed")
        return
    }

    val essService = gatt.getService(UUID.fromString("0000181a-0000-1000-8000-00805f9b34fb"))
    if (essService == null) {
        Log.e(TAG, "ESS not found")
        return
    }

    tempCharacteristic = essService.getCharacteristic(UUID.fromString("00002a6e-0000-1000-8000-00805f9b34fb"))
    humCharacteristic = essService.getCharacteristic(UUID.fromString("00002a6f-0000-1000-8000-00805f9b34fb"))
}

你想想看,如果设备不支持 ESS,你硬要去读,那肯定报错。所以每次都要判空。我曾经在代码里漏了判空,结果上线后用户反馈 App 闪退,查了半天才发现是某款廉价温湿度计压根没实现标准 Service。

定期上报:Notification 的正确打开方式

温湿度计这种设备,你不会想一直轮询去读数据。那样太费电,也太费流量。正确的做法是让设备主动上报——也就是开启 Notification。

开启 Notification 需要两步:

  1. 写入 Client Characteristic Configuration Descriptor(CCCD),值为 0x0001 表示开启通知。
  2. onCharacteristicChanged 回调里接收数据。
fun enableNotification(gatt: BluetoothGatt, characteristic: BluetoothGattCharacteristic) {
    val success = gatt.setCharacteristicNotification(characteristic, true)
    if (!success) {
        Log.w(TAG, "setCharacteristicNotification failed")
        return
    }

    val descriptor = characteristic.getDescriptor(UUID.fromString("00002902-0000-1000-8000-00805f9b34fb"))
    descriptor.value = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE
    gatt.writeDescriptor(descriptor)
}

这里有个细节:setCharacteristicNotification 只是本地注册,真正让设备开始发数据的是写 CCCD。两个步骤缺一不可。我见过有人只调了第一个方法,然后一直等不到回调,急得团团转。

收到数据后,解析也很直接:

override fun onCharacteristicChanged(gatt: BluetoothGatt, characteristic: BluetoothGattCharacteristic) {
    val rawValue = characteristic.value
    if (rawValue == null) return

    when (characteristic.uuid.toString()) {
        "00002a6e-0000-1000-8000-00805f9b34fb" -> {
            val tempC = rawValue.toShort() / 100.0f
            Log.d(TAG, "Temperature: $tempC °C")
        }
        "00002a6f-0000-1000-8000-00805f9b34fb" -> {
            val humidity = rawValue.toShort() / 100.0f
            Log.d(TAG, "Humidity: $humidity %")
        }
    }
}
小提示:有些设备上报频率很高,比如每秒一次。如果你在 UI 线程直接更新 TextView,界面会卡成 PPT。建议用 Handler 或者 LiveData 做节流,比如每 500ms 刷新一次 UI。

阈值告警:让 App 主动提醒你

光显示数据还不够,咱们得加点智能。比如温度超过 30°C 就弹个通知,湿度低于 20% 就提醒加湿。这个逻辑不复杂,但要注意几点:

  • 阈值要可配置,别写死在代码里
  • 告警不要重复触发,否则用户会被烦死
  • 断开连接后要重置状态
data class ThresholdConfig(
    val tempMin: Float = 18.0f,
    val tempMax: Float = 28.0f,
    val humMin: Float = 30.0f,
    val humMax: Float = 70.0f
)

class AlertManager(private val context: Context) {
    private var lastAlertTime = mutableMapOf<String, Long>()
    private val cooldownMs = 60_000L // 同一告警一分钟内不重复

    fun checkAndAlert(temp: Float, hum: Float, config: ThresholdConfig) {
        if (temp < config.tempMin) {
            triggerAlert("温度过低", "$temp °C 低于阈值 ${config.tempMin} °C")
        } else if (temp > config.tempMax) {
            triggerAlert("温度过高", "$temp °C 超过阈值 ${config.tempMax} °C")
        }

        if (hum < config.humMin) {
            triggerAlert("湿度过低", "$hum % 低于阈值 ${config.humMin} %")
        } else if (hum > config.humMax) {
            triggerAlert("湿度过高", "$hum % 超过阈值 ${config.humMax} %")
        }
    }

    private fun triggerAlert(title: String, message: String) {
        val now = System.currentTimeMillis()
        val key = "$title:$message"
        if (now - (lastAlertTime[key] ?: 0L) < cooldownMs) return

        lastAlertTime[key] = now
        // 发送系统通知
        NotificationCompat.Builder(context, CHANNEL_ID)
            .setSmallIcon(R.drawable.ic_alert)
            .setContentTitle(title)
            .setContentText(message)
            .setPriority(NotificationCompat.PRIORITY_HIGH)
            .build()
            .also { notificationManager.notify(key.hashCode(), it) }
    }
}
注意:阈值告警的冷却时间别设太短。我曾经设了 5 秒,结果用户把温湿度计放在空调出风口测试,手机疯狂弹通知,差点被用户投诉。建议至少 1 分钟。

整体架构图

下面这张图展示了温湿度计 App 的核心数据流。从设备连接、Service 发现,到 Notification 接收、数据解析,再到阈值判断和告警触发,一气呵成。

温湿度计 BLE 数据流架构 BLE 温湿度计 ESS Service (0x181A) 连接 & 发现 BluetoothGatt 连接 / 断开 / 重连 开启 Notification 数据回调 onCharacteristicChanged 数据解析 INT16 → 浮点数 阈值判断 温度 / 湿度 超限? 超限 告警通知 系统通知 / 弹窗 正常 忽略

避坑指南

最后,分享几个我踩过的坑,希望能帮你省点时间:

  • 设备断开后要清理状态:Notification 的注册、阈值告警的冷却时间,都要在 onConnectionStateChange 里重置。否则重连后可能收不到数据,或者告警逻辑混乱。
  • 不要在主线程做数据解析:虽然 INT16 转浮点数很快,但万一设备上报频率高,或者你后面加了更复杂的计算,UI 线程会卡。建议用 HandlerThread 或者 Kotlin 协程。
  • 测试不同品牌的设备:有些国产温湿度计虽然宣称支持 ESS,但实现并不标准。比如温度 Characteristic 的 UUID 可能不是 0x2A6E,而是厂商自定义的。遇到这种情况,只能通过抓包或者厂商文档来确认。

嗯,温湿度计这块基本就这些了。代码量不大,但涉及的知识点很典型——Service 发现、Notification 开启、数据解析、业务逻辑判断。把这些搞透了,后面做更复杂的 BLE 设备(比如心率计、体脂秤)就会顺手很多。

公众号:蓝海资料掘金营,微信deep3321