蓝牙灯控实战:Lighting Control

好,咱们今天聊点真正能上手玩的东西。蓝牙灯控,说白了就是让你的手机能控制灯泡的亮度、色温,甚至让几个灯一起联动。我在好几个智能家居项目里都做过类似的功能,踩过不少坑,今天把这些经验都抖出来。

1. 灯控协议的设计思路

先想一个问题:灯泡和手机怎么沟通?BLE 通信说白了就是读写特征值。我们需要定义一套协议,让手机发指令,灯泡执行。

我个人习惯用自定义 Service UUID 和 Characteristic UUID。比如:

// 灯控服务 UUID
val LIGHT_SERVICE_UUID = "0000FFE0-0000-1000-8000-00805F9B34FB"

// 亮度特征值 UUID
val BRIGHTNESS_CHAR_UUID = "0000FFE1-0000-1000-8000-00805F9B34FB"

// 色温特征值 UUID
val COLOR_TEMP_CHAR_UUID = "0000FFE2-0000-1000-8000-00805F9B34FB"

// 场景联动特征值 UUID
val SCENE_CHAR_UUID = "0000FFE3-0000-1000-8000-00805F9B34FB"

为什么这么设计?因为标准服务有时候不够灵活。我在项目中遇到过,有些灯泡厂商用 0xFFE0 这个私有服务,兼容性反而更好。

2. 亮度调节的实现

亮度调节,说白了就是写一个 0-100 的数值到特征值里。但这里有个细节:很多灯泡用的是 0-255 的 PWM 值。你想想看,手机端显示 0-100%,实际发出去要映射到 0-255。

核心逻辑:手机端用百分比,蓝牙传输用原始值,灯泡端再映射到 PWM。

fun setBrightness(device: BluetoothDevice, brightness: Int) {
    // brightness: 0-100
    val mappedValue = (brightness * 255 / 100).coerceIn(0, 255)
    val data = byteArrayOf(mappedValue.toByte())
    
    val gatt = device.connectGatt(context, false, gattCallback)
    // ... 连接成功后写入特征值
    gatt.writeCharacteristic(
        gatt.getService(LIGHT_SERVICE_UUID)
            .getCharacteristic(BRIGHTNESS_CHAR_UUID)
            .apply { value = data }
    )
}

嗯,这里要注意:coerceIn 是 Kotlin 的语法糖,防止数值越界。我曾经因为没做边界检查,传了个 101 进去,灯泡直接闪了一下然后灭了……后来查了半天才发现是数值溢出。

3. 色温控制

色温控制比亮度复杂一点。色温通常用开尔文(K)表示,范围从 2700K(暖黄)到 6500K(冷白)。但蓝牙传输时,一般用两个字节表示。

我建议这样设计:

fun setColorTemperature(device: BluetoothDevice, kelvin: Int) {
    // kelvin: 2700-6500
    val highByte = (kelvin shr 8).toByte()
    val lowByte = (kelvin and 0xFF).toByte()
    val data = byteArrayOf(highByte, lowByte)
    
    // 写入色温特征值
    writeCharacteristic(device, COLOR_TEMP_CHAR_UUID, data)
}

为什么用两个字节?因为 6500 大于 255,一个字节装不下。你想想看,如果用单字节,最大只能表示 255K,那灯泡就只能发出一种颜色了。

小技巧:有些灯泡支持色温和亮度一起发送,用一个特征值包含两个字段。比如第一个字节是亮度,第二、三个字节是色温。这样一次写入就能同时调节两个参数,减少蓝牙通信次数。

4. 场景联动

场景联动,说白了就是让多个灯泡同时执行一个预设方案。比如「阅读模式」:主灯亮度 80%,色温 4000K;台灯亮度 60%,色温 5000K。

实现方式有两种:

  1. 手机端联动:手机逐个连接每个灯泡,发送指令。优点是灵活,缺点是慢。
  2. 灯泡端联动:手机只发一个场景编号,灯泡自己内部协调。优点是快,缺点是灯泡需要支持组播。

我在项目中更倾向于第一种,因为实现简单,而且兼容性好。第二种需要灯泡固件支持,不是所有厂商都开放。

data class Scene(
    val name: String,
    val brightness: Int,  // 0-100
    val colorTemp: Int    // 2700-6500
)

fun applyScene(devices: List<BluetoothDevice>, scene: Scene) {
    devices.forEach { device ->
        setBrightness(device, scene.brightness)
        setColorTemperature(device, scene.colorTemp)
    }
}

嗯,这里有个坑:如果灯泡数量多,比如 10 个,逐个连接发送指令会很慢。我曾经试过,10 个灯泡全部设置完需要 5-6 秒。用户体验很差。

避坑指南:我曾经在项目中遇到一个问题——同时连接多个 BLE 设备,Android 系统会限制并发连接数(通常是 6-7 个)。超过这个数,后面的连接会失败。解决方案是:用队列管理连接,一个一个来,或者断开不用的设备。

5. 实战中的性能优化

灯控场景对实时性要求很高。你滑动亮度条,灯泡应该立刻响应。但 BLE 通信有延迟,怎么优化?

我总结了几点:

  • 减少通信次数:把亮度和色温合并到一个特征值里,一次写入搞定。
  • 使用 Write Without Response:如果不需要确认,就用这个模式,速度更快。
  • 预连接:在用户打开控制界面时,提前连接好灯泡,不要等用户滑动条了才去连。
// 使用 Write Without Response
fun writeFast(device: BluetoothDevice, uuid: UUID, data: ByteArray) {
    val gatt = device.connectGatt(context, false, gattCallback)
    val char = gatt.getService(LIGHT_SERVICE_UUID).getCharacteristic(uuid)
    gatt.beginReliableWrite() // 可选,用于批量写入
    char.writeType = BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE
    char.value = data
    gatt.writeCharacteristic(char)
}

你想想看,如果每次滑动都等确认,那延迟至少 50ms,用户会感觉卡顿。用无响应写入,延迟能降到 10ms 以内。

6. 知识体系总览

下面这张图总结了蓝牙灯控的核心流程:

蓝牙灯控知识体系 手机端(GATT Client) 灯泡端(GATT Server) BLE 连接 手机端功能 • 亮度调节(0-100%) • 色温控制(2700-6500K) • 场景联动(预设方案) • 设备发现与连接 • 数据映射(百分比→原始值) • 队列管理(多设备并发) • 性能优化(无响应写入) 灯泡端功能 • 接收亮度指令(0-255) • 接收色温指令(2字节) • 场景编号解析 • PWM 映射输出 • 组播支持(可选) • 状态反馈(通知) • 固件升级(OTA) BLE 协议层 Service: 0xFFE0 Characteristic: 0xFFE1-0xFFE3

7. 总结与经验

蓝牙灯控看起来简单,但要做好其实不容易。我做了几个项目后,最大的感受是:协议设计决定上限,性能优化决定下限

如果你刚开始做,建议先实现单灯控制,再扩展到多灯联动。不要一上来就想搞复杂的场景联动,容易把自己绕进去。

嗯,最后说一句:调试的时候,买个便宜的蓝牙抓包工具,能省你很多时间。我曾经为了查一个指令没响应的 bug,折腾了两天,最后发现是特征值 UUID 写错了……

核心要点回顾:

  • 亮度用 0-100% 映射到 0-255
  • 色温用两个字节传输,范围 2700-6500K
  • 场景联动用手机端逐个控制,简单可靠
  • 性能优化用无响应写入和预连接
  • 注意 Android 并发连接数限制

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