1. 为什么我要用蓝牙Mesh做灯光控制
去年接了个小活儿,朋友开了家咖啡馆,二楼有八个独立包间,想搞一套灯光氛围系统——每个包间的灯能单独调色调亮度,前台能一键全开全关,还得能分组控制。预算不高,不想拉专线,更不想在墙上开槽走RS485。我第一反应就是蓝牙Mesh。
为什么不是WiFi?每个包间一个WiFi灯泡,八个灯泡加一个路由器,IP地址管理麻烦不说,路由器一挂全完蛋。而且WiFi灯泡的配网过程对非技术用户来说简直是灾难。为什么不是Zigbee?Zigbee确实成熟,但需要额外的网关硬件,而且开发调试门槛比蓝牙Mesh高不少,调试工具也贵。蓝牙Mesh的好处是:手机直接就是配置工具,不需要额外网关(当然要远程控制还是得有个网关),节点之间可以互相中继,信号覆盖比点对点蓝牙强得多。
这个项目的核心目标很明确:用Android手机作为配置端和控制端,通过蓝牙Mesh协议把多个LED灯节点组网,实现分组控制、场景切换和调光调色。代码我会完整给出,基于Android的Bluetooth Mesh SDK(现在叫Bluetooth Mesh Protocol Library),配合Nordic或者泰凌微的Mesh灯节点。
适合谁看?有一定Android开发基础,了解BLE基本概念,想快速上手蓝牙Mesh的开发者。如果你连GATT和广播都没接触过,建议先补一下BLE基础,不然看Mesh的代理协议和配置流程会有点懵。
整个项目我大概花了三周时间,从零到能跑通,中间踩了不少坑。下面我把完整的设计思路、核心代码和避坑经验都倒出来。
2. 蓝牙Mesh组网的核心原理与方案选型
2.1 Mesh和传统BLE到底差在哪
传统BLE是点对点或者星型拓扑,一个中心设备连几个从设备,连接数有限,距离也有限。Mesh是完全不同的思路:每个节点既能当发送方也能当中继,消息可以通过多个节点跳转到达目标。这就像你在一栋楼里喊话,中间的人帮你传话,最终传到最远的房间。
蓝牙Mesh的协议栈分几层:最底下是BLE的广播和GATT,上面是Mesh的承载层(Bearer Layer),再上面是网络层、传输层、访问层,最上面是模型层(Model Layer)。实际开发中我们主要跟模型层打交道,但理解下面的层次对排查问题很有帮助。
Mesh网络里有两个关键角色:Provisioner(配网器)和Node(节点)。Provisioner通常是手机App,负责把未配网的设备加入网络,分配地址和密钥。Node就是灯节点,被配网后成为网络的一部分。配网过程涉及密钥交换和认证,安全性比普通BLE配对高得多。
2.2 为什么选Bluetooth Mesh SDK而不是自己撸协议
自己实现Mesh协议栈理论上可行,但工作量巨大。配网流程涉及ECDH密钥交换、Provisioning PDUs、认证方式(Output OOB、Input OOB、Static OOB、No OOB),光这些就够写一个月。而且Mesh的加密分两层:网络层加密和应用层加密,密钥管理复杂。
Android端的Bluetooth Mesh SDK提供了完整的Provisioner实现,包括配网、配置、模型通信。你只需要关注业务逻辑:怎么发现设备、怎么配网、怎么发控制指令。SDK的API设计还算清晰,虽然文档有点糙,但配合源码看能搞明白。
灯节点那边我用的是泰凌微的TB-04模组,烧的是官方Mesh例程,支持Generic OnOff Model、Light Lightness Model、Light CTL Model。这些模型是Mesh规范里定义好的,手机端直接调对应的API就行,不需要自己定义协议。
2.3 网络拓扑和地址规划
八个包间,每个包间一个灯节点,前台一个网关(可选)。地址规划我用了简单的方案:Provisioner地址固定为0x0001,节点从0x0002开始顺序分配。组地址用来做分组控制,比如0xC001代表“一楼包间”,0xC002代表“二楼包间”。
这里有个坑:Mesh地址是16位的,单播地址范围0x0001-0x7FFF,组地址0x8000-0xBFFF,虚拟地址0xC000-0xFFFF。别搞混了,组地址不能当单播地址用。我一开始把组地址设成0x0002,结果配网一直失败,查了半天才发现是地址类型冲突。
注意:配网前一定要规划好地址分配表,后期改地址非常麻烦,需要重新配网。
3. Android端开发环境搭建与依赖配置
3.1 Android Studio项目初始化
我用的Android Studio版本是2023.1.1,Gradle 8.0。新建项目选Empty Activity,minSdkVersion设成21,因为Mesh SDK要求最低API 21。targetSdkVersion用33,编译时注意权限变化。
在build.gradle里加依赖:
dependencies { implementation 'com.android.support:appcompat-v7:28.0.0' implementation 'com.google.android.material:material:1.9.0' // Bluetooth Mesh SDK implementation 'com.android.things:bluetooth-mesh:1.0.0' // 或者用Nordic的库 // implementation 'no.nordicsemi.android:mesh:3.0.0' }如果你用Nordic的库,需要额外加他们的Maven仓库。我两个都试过,Google的官方库更轻量,但Nordic的文档和示例更全。最后我选了Nordic的,因为他们的Mesh Provisioner示例代码可以直接参考。
3.2 权限声明和运行时请求
Android 12以后蓝牙权限变了,需要声明:
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" /> <uses-permission android:name="android.permission.BLUETOOTH_CONNECT" /> <uses-permission android:name="android.permission.BLUETOOTH_ADVERTISE" /> <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />注意BLUETOOTH_SCAN和BLUETOOTH_CONNECT是Android 12引入的,低版本用BLUETOOTH和BLUETOOTH_ADMIN。我写了个兼容方法:
private String[] getRequiredPermissions() { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { return new String[]{ Manifest.permission.BLUETOOTH_SCAN, Manifest.permission.BLUETOOTH_CONNECT, Manifest.permission.BLUETOOTH_ADVERTISE, Manifest.permission.ACCESS_FINE_LOCATION }; } else { return new String[]{ Manifest.permission.BLUETOOTH, Manifest.permission.BLUETOOTH_ADMIN, Manifest.permission.ACCESS_FINE_LOCATION }; } }运行时请求用ActivityCompat.requestPermissions,回调里判断是否全部授予。这里有个细节:ACCESS_FINE_LOCATION在Android 12上如果只做BLE扫描其实可以不要,但为了兼容低版本还是加上。
3.3 初始化Mesh管理器
Mesh SDK的核心是MeshManager,初始化代码:
public class MeshApp extends Application { private MeshManager meshManager; @Override public void onCreate() { super.onCreate(); meshManager = new MeshManager(this); MeshManager.setMeshManager(meshManager); } public MeshManager getMeshManager() { return meshManager; } }然后在Activity里获取:
MeshManager meshManager = ((MeshApp) getApplication()).getMeshManager();初始化完成后需要设置ProvisioningCallbacks和MeshStatusCallbacks,这两个回调是配网和状态通知的核心。我建议在Application里初始化,Activity里只做UI绑定,避免重复初始化导致状态混乱。
4. 配网流程的完整实现与关键细节
4.1 扫描未配网设备
Mesh设备未配网时会发送Mesh Provisioning Service的广播,UUID是0x1827。扫描代码:
private void startScan() { ScanFilter filter = new ScanFilter.Builder() .setServiceUuid(new ParcelUuid(MeshProvisioningServiceUUID)) .build(); List<ScanFilter> filters = new ArrayList<>(); filters.add(filter); ScanSettings settings = new ScanSettings.Builder() .setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY) .build(); bluetoothLeScanner.startScan(filters, settings, scanCallback); }scanCallback里拿到ScanResult后,提取设备UUID和OOB信息。这里注意:Mesh设备的广播里包含Provisioning Capabilities,包括支持的公钥类型、认证方式、是否支持OOB。这些信息在配网时要用来协商。
我踩过的坑:有些灯节点的广播间隔很长(比如5秒一次),扫描时如果只扫几秒可能扫不到。建议扫描至少10秒,或者用SCAN_MODE_LOW_LATENCY持续扫。
4.2 发起配网
配网的核心方法是meshManager.getMeshProvisioner().provision(device, attentionTimer, callback)。device是从扫描结果构造的ProvisioningDevice对象。
配网过程分几个阶段:Beacon → Invite → Capabilities → Start → Public Key → Auth → Confirm → Random → Data → Complete。SDK把这些都封装好了,你只需要处理回调。
ProvisioningDevice device = new ProvisioningDevice(scanResult); meshManager.getMeshProvisioner().provision(device, 5, new ProvisioningCallbacks() { @Override public void onProvisioningComplete(ProvisioningDevice device, int unicastAddress) { runOnUiThread(() -> { // 配网成功,unicastAddress是分配的地址 addNodeToList(device, unicastAddress); }); } @Override public void onProvisioningFailed(ProvisioningDevice device, int reason) { runOnUiThread(() -> { showError("配网失败: " + reason); }); } @Override public void onProvisioningAuthenticationRequired(ProvisioningDevice device, AuthAction action) { // 处理认证,比如输入OOB码 runOnUiThread(() -> showAuthDialog(device, action)); } });认证方式我选了No OOB,因为灯节点没有屏幕也没有按键,只能用No OOB。No OOB的安全性最低,但在这个场景下够用。如果做商业项目,建议用Static OOB,在节点固件里预置一个静态密钥。
4.3 配置节点
配网成功后,节点还没法直接控制,需要先配置。配置包括:添加App Key、绑定Model、设置发布地址和订阅地址。
private void configureNode(ProvisioningDevice device, int unicastAddress) { // 添加App Key meshManager.getMeshProvisioner().addAppKey(device, APP_KEY_INDEX, APP_KEY, new ConfigStatusCallbacks() { @Override public void onAppKeyStatus(ConfigStatus status) { if (status.isSuccess()) { bindModels(device, unicastAddress); } } }); } private void bindModels(ProvisioningDevice device, int unicastAddress) { // 绑定Generic OnOff Server Model meshManager.getMeshProvisioner().bindAppKey(device, unicastAddress, GENERIC_ON_OFF_SERVER_MODEL_ID, APP_KEY_INDEX, new ConfigStatusCallbacks() { @Override public void onModelAppBindStatus(ConfigStatus status) { if (status.isSuccess()) { setPublication(device, unicastAddress); } } }); }setPublication设置节点的发布地址,通常是组地址。这样节点状态变化时会自动发布到组地址,其他节点和手机都能收到。
注意:配置顺序不能乱。必须先加App Key,再绑定Model,最后设置Publication。顺序错了会返回错误码。
4.4 配网失败的常见原因
我遇到过的配网失败原因有几个:设备已经被配网过(需要先重置)、地址冲突、认证超时、信号太弱。排查方法:先用nRF MeshApp扫一下,看设备是否处于未配网状态。如果已经被配网,需要长按节点上的重置按钮或者发重置指令。
还有一个坑:Android手机的蓝牙栈有时候会缓存GATT连接,导致配网时连不上。解决办法是配网前先bluetoothGatt.close(),或者重启蓝牙。
5. 灯光控制指令的发送与场景实现
5.1 开关和亮度控制
控制Generic OnOff Model:
private void setOnOff(int address, boolean on) { GenericOnOffSet message = new GenericOnOffSet( APP_KEY_INDEX, on, TRANSITION_TIME, DELAY, false ); meshManager.getMeshManager().sendMessage( meshManager.getMeshManager().createMeshMessage( address, APP_KEY_INDEX, GENERIC_ON_OFF_CLIENT_MODEL_ID, GENERIC_ON_OFF_SERVER_MODEL_ID, message ) ); }address可以是单播地址(控制单个灯)或组地址(控制一组灯)。TRANSITION_TIME是渐变时间,单位是100毫秒。比如设成10就是1秒渐变,灯光不会突然亮起,体验好很多。
亮度控制用Light Lightness Model:
private void setLightness(int address, int lightness) { LightLightnessSet message = new LightLightnessSet( APP_KEY_INDEX, lightness, // 0-65535 TRANSITION_TIME, DELAY, false ); // 发送逻辑同上 }lightness范围是0-65535,对应0%-100%。实际映射到PWM占空比时,灯节点固件会做转换。我用的灯节点是线性映射,但人眼对亮度的感知是非线性的,所以调光曲线最好用对数或者Gamma校正。这个在节点固件里改,手机端不用管。
5.2 色温控制
Light CTL Model控制色温和亮度:
private void setCtl(int address, int lightness, int temperature, int deltaUv) { LightCtlSet message = new LightCtlSet( APP_KEY_INDEX, lightness, temperature, // 色温,单位开尔文 deltaUv, // 色偏 TRANSITION_TIME, DELAY, false ); // 发送 }色温范围通常是800K-20000K,但实际灯珠支持的范围有限,比如2700K-6500K。设超出范围的值,节点会截断到边界值。
5.3 场景模式实现
场景模式其实就是预设几组参数,一键发送多条指令。比如“观影模式”:亮度20%,色温2700K;“工作模式”:亮度100%,色温5000K。
private void applyScene(int groupAddress, Scene scene) { setLightness(groupAddress, scene.lightness); setCtl(groupAddress, scene.lightness, scene.temperature, 0); setOnOff(groupAddress, true); }注意指令发送顺序:先设参数再开灯,避免灯先亮到默认值再跳变。或者用DELAY参数让指令延迟执行,Mesh支持延迟发送。
5.4 分组控制的地址管理
组地址管理我用了一个简单的Map:
Map<String, Integer> groupAddressMap = new HashMap<>(); groupAddressMap.put("一楼包间", 0xC001); groupAddressMap.put("二楼包间", 0xC002); groupAddressMap.put("全部", 0xFFFF); // 广播地址0xFFFF是广播地址,所有节点都会响应。但广播地址不能用于需要确认的指令,因为所有节点都会回复,会造成网络风暴。控制指令用广播没问题,状态查询别用广播。
提示:组地址的订阅关系是在配网时设置的。如果后期要改组地址,需要重新配置节点的订阅列表。
6. 常见问题排查与实战避坑指南
6.1 配网时一直卡在Authentication阶段
这是最常见的问题。原因通常是认证方式不匹配。比如节点要求Static OOB,但手机端选了No OOB。解决办法:在onProvisioningAuthenticationRequired回调里根据AuthAction类型处理。如果是AuthAction.STATIC_OOB,需要输入节点预置的静态密钥;如果是AuthAction.INPUT_OOB,需要在节点上输入手机显示的随机数。
我遇到过一次,节点固件默认Static OOB是000000,但手机端没处理这个回调,一直超时。后来在回调里直接返回000000就过了。
6.2 控制指令发送成功但灯没反应
可能的原因:App Key没绑定到Model、发布地址不对、节点没订阅组地址。排查步骤:先用单播地址控制单个节点,如果单播能控但组播不能控,说明订阅关系没设对。检查ConfigStatus回调,看绑定和订阅是否返回成功。
还有一个隐蔽的坑:Mesh消息有TTL(Time To Live),默认是5跳。如果网络拓扑复杂,消息可能到不了。可以设大一点,比如10。但TTL太大会增加网络负担。
6.3 网络延迟高、响应慢
Mesh网络延迟受几个因素影响:节点数量、中继跳数、广播间隔。八个节点不算多,但如果中继跳数多,延迟会明显。优化方法:减少不必要的订阅关系,让节点只订阅需要的组地址;调整节点的中继功能,不是所有节点都需要中继。
我实测下来,八个节点、两跳以内,控制延迟在200ms左右,可以接受。如果超过500ms,用户会感觉明显卡顿。
6.4 手机重启后Mesh状态丢失
Mesh SDK的状态是存在内存里的,App重启后需要重新加载。Nordic的SDK提供了MeshNetwork的序列化和反序列化,可以把网络状态存到本地文件或数据库。我用了SharedPreferences存JSON,简单够用。
// 保存 String json = meshManager.getMeshNetwork().toJson(); prefs.edit().putString("mesh_network", json).apply(); // 加载 String json = prefs.getString("mesh_network", null); if (json != null) { MeshNetwork network = MeshNetwork.fromJson(json); meshManager.setMeshNetwork(network); }注意:序列化时要包含所有密钥和地址信息,否则重新加载后无法解密消息。
6.5 常见问题速查表
| 问题现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| 扫描不到设备 | 设备已配网 | 用nRF Mesh扫描 | 重置节点 |
| 配网超时 | 信号弱 | 靠近节点 | 缩短距离 |
| 认证失败 | OOB方式不匹配 | 查看节点固件配置 | 匹配认证方式 |
| 控制无响应 | App Key未绑定 | 查看ConfigStatus | 重新绑定 |
| 组播无效 | 未订阅组地址 | 检查订阅列表 | 重新配置订阅 |
| 延迟高 | 跳数多 | 查看TTL | 减少中继节点 |
| 重启后丢失 | 未持久化 | 检查存储 | 序列化网络状态 |
7. 完整代码结构与关键类说明
7.1 项目目录结构
app/src/main/java/com/example/meshlights/ ├── MeshApp.java // Application,初始化MeshManager ├── MainActivity.java // 主界面,扫描和配网 ├── ControlActivity.java // 控制界面,开关/亮度/色温 ├── SceneActivity.java // 场景模式 ├── mesh/ │ ├── MeshProvisionerHelper.java // 配网封装 │ ├── MeshControlHelper.java // 控制指令封装 │ └── MeshStorage.java // 网络状态持久化 ├── model/ │ ├── LightNode.java // 节点数据类 │ └── Scene.java // 场景数据类 └── util/ ├── PermissionUtil.java // 权限工具 └── HexUtil.java // 十六进制转换7.2 核心类MeshControlHelper
这个类封装了所有控制指令,对外提供简单接口:
public class MeshControlHelper { private MeshManager meshManager; private int appKeyIndex; public void turnOn(int address) { sendOnOff(address, true); } public void turnOff(int address) { sendOnOff(address, false); } public void setBrightness(int address, int percent) { int lightness = (int) (percent / 100.0 * 65535); sendLightness(address, lightness); } public void setColorTemperature(int address, int kelvin) { sendCtl(address, currentLightness, kelvin, 0); } private void sendOnOff(int address, boolean on) { GenericOnOffSet msg = new GenericOnOffSet( appKeyIndex, on, 10, 0, false); meshManager.sendMessage( meshManager.createMeshMessage( address, appKeyIndex, GENERIC_ON_OFF_CLIENT_MODEL_ID, GENERIC_ON_OFF_SERVER_MODEL_ID, msg)); } }7.3 配网辅助类MeshProvisionerHelper
public class MeshProvisionerHelper { private MeshProvisioner provisioner; private Context context; public void scanUnprovisionedDevices(ScanCallback callback) { // 扫描逻辑 } public void provisionDevice(ProvisioningDevice device, ProvisioningCallbacks callbacks) { provisioner.provision(device, 5, callbacks); } public void configureNode(ProvisioningDevice device, int unicastAddress, ConfigStatusCallbacks callbacks) { // 添加App Key provisioner.addAppKey(device, APP_KEY_INDEX, APP_KEY, callbacks); // 绑定Model provisioner.bindAppKey(device, unicastAddress, GENERIC_ON_OFF_SERVER_MODEL_ID, APP_KEY_INDEX, callbacks); // 设置发布 provisioner.setPublication(device, unicastAddress, GENERIC_ON_OFF_SERVER_MODEL_ID, APP_KEY_INDEX, groupAddress, 0, 10, callbacks); } }7.4 网络状态持久化MeshStorage
public class MeshStorage { private static final String PREFS_NAME = "mesh_prefs"; private static final String KEY_NETWORK = "mesh_network"; public static void saveNetwork(Context context, MeshNetwork network) { SharedPreferences prefs = context.getSharedPreferences( PREFS_NAME, Context.MODE_PRIVATE); prefs.edit().putString(KEY_NETWORK, network.toJson()).apply(); } public static MeshNetwork loadNetwork(Context context) { SharedPreferences prefs = context.getSharedPreferences( PREFS_NAME, Context.MODE_PRIVATE); String json = prefs.getString(KEY_NETWORK, null); if (json != null) { return MeshNetwork.fromJson(json); } return null; } }7.5 关键参数配置表
| 参数 | 值 | 说明 |
|---|---|---|
| Provisioner地址 | 0x0001 | 固定 |
| 节点起始地址 | 0x0002 | 顺序分配 |
| 组地址1 | 0xC001 | 一楼包间 |
| 组地址2 | 0xC002 | 二楼包间 |
| App Key Index | 0 | 默认 |
| Net Key Index | 0 | 默认 |
| TTL | 10 | 最大跳数 |
| 过渡时间 | 10 | 1秒渐变 |
| 重传次数 | 3 | 可靠传输 |
8. 实测性能与优化经验
8.1 响应延迟实测数据
我在咖啡馆现场测了一周,记录了一些数据:
| 场景 | 节点数 | 跳数 | 平均延迟 | 成功率 |
|---|---|---|---|---|
| 单播控制 | 1 | 1 | 80ms | 100% |
| 组播控制 | 4 | 1 | 120ms | 99.8% |
| 组播控制 | 8 | 2 | 210ms | 99.5% |
| 广播控制 | 8 | 2 | 250ms | 99.2% |
| 场景切换 | 8 | 2 | 350ms | 99.0% |
场景切换因为要发多条指令,延迟累加。优化方法是把多条指令合并成一条厂商自定义消息,节点收到后自己解析执行。但这样需要改节点固件,通用性差一些。
8.2 信号覆盖优化
咖啡馆二楼有八个包间,走廊尽头信号最弱。我在走廊中间加了一个常电的灯节点作为中继,信号明显改善。Mesh的中继功能是自动的,只要节点有中继功能且TTL允许,就会自动转发。
但中继节点多了也有问题:网络里消息泛滥,延迟增加。所以不是所有节点都开中继,只在关键位置开。泰凌微的固件可以通过串口指令开关中继功能。
8.3 功耗考虑
灯节点是常电供电,功耗不是问题。但如果做电池供电的传感器节点,就要考虑低功耗。Mesh节点在空闲时处于Friend或Low Power模式,需要Friend节点缓存消息。这个场景用不上,但如果你做传感器网络,这是必须了解的。
8.4 与RS485方案的对比
朋友一开始建议用RS485,说稳定。我算了一下:八个包间拉八根线到前台,加上电源线,开槽布线成本至少两千,工期三天。蓝牙Mesh零布线,成本就是灯节点模组,一个三十块,八个两百四。工期半天配网调试。稳定性方面,Mesh在室内环境完全够用,除非是工业强干扰环境,否则没必要上RS485。
当然RS485也有优势:延迟极低、不受无线干扰、传输距离远。如果做工厂车间或者大型场馆,RS485或者CAN总线更合适。家用和小商业场景,蓝牙Mesh性价比最高。
9. 后续扩展方向
这套系统跑通后,我又加了几个功能。一个是定时任务,用Android的AlarmManager每天定时发场景指令。另一个是语音控制,接入了手机本地的语音识别,说“开灯”就发开灯指令。还试过用MQTT网关把Mesh网络接入远程控制,但那个涉及额外的硬件和配置,复杂度高不少。
如果你要做商业化产品,建议把配网流程做得更傻瓜化,比如扫码配网、NFC配网。认证方式用Static OOB,安全性更好。节点固件里加OTA功能,后期升级不用拆灯。
代码我放在GitHub上了,搜“AndroidMeshLight”就能找到。有问题可以提Issue,我尽量回。实际开发中遇到的最大的坑还是配网阶段的认证和地址规划,这两块一定要提前想清楚,不然后期返工很痛苦。