ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

Android蓝牙Mesh灯光控制实战:从配网到分组调光调色

2026/9/19 10:09:04 拓冰建站 浏览量
Android蓝牙Mesh灯光控制实战:从配网到分组调光调色 1. 为什么我要用蓝牙Mesh做灯光控制去年接了个小活儿朋友开了家咖啡馆二楼有八个独立包间想搞一套灯光氛围系统——每个包间的灯能单独调色调亮度前台能一键全开全关还得能分组控制。预算不高不想拉专线更不想在墙上开槽走RS485。我第一反应就是蓝牙Mesh。为什么不是WiFi每个包间一个WiFi灯泡八个灯泡加一个路由器IP地址管理麻烦不说路由器一挂全完蛋。而且WiFi灯泡的配网过程对非技术用户来说简直是灾难。为什么不是ZigbeeZigbee确实成熟但需要额外的网关硬件而且开发调试门槛比蓝牙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.1Gradle 8.0。新建项目选Empty ActivityminSdkVersion设成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:nameandroid.permission.BLUETOOTH_SCAN / uses-permission android:nameandroid.permission.BLUETOOTH_CONNECT / uses-permission android:nameandroid.permission.BLUETOOTH_ADVERTISE / uses-permission android:nameandroid.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(); ListScanFilter 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 Modelprivate 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 Modelprivate 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 分组控制的地址管理组地址管理我用了一个简单的MapMapString, 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消息有TTLTime 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 配网辅助类MeshProvisionerHelperpublic 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 网络状态持久化MeshStoragepublic 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顺序分配组地址10xC001一楼包间组地址20xC002二楼包间App Key Index0默认Net Key Index0默认TTL10最大跳数过渡时间101秒渐变重传次数3可靠传输8. 实测性能与优化经验8.1 响应延迟实测数据我在咖啡馆现场测了一周记录了一些数据场景节点数跳数平均延迟成功率单播控制1180ms100%组播控制41120ms99.8%组播控制82210ms99.5%广播控制82250ms99.2%场景切换82350ms99.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我尽量回。实际开发中遇到的最大的坑还是配网阶段的认证和地址规划这两块一定要提前想清楚不然后期返工很痛苦。