这是我们这阶段最后的成果展示。从一块裸的FRDM-MCXW72开发板开始,一步步把它做成了一个"打开手机/浏览器就能看温湿度"的蓝牙小设备。先说最终的样子:
一块FRDM-MCXW72板子,接了Grove-TH Sensor V2.0(DHT20)
板子通过BLE广播,名字叫Zephyr TH Sensor
一台手机/电脑上的浏览器打开配套的HTML页面
点一下"扫描连接",浏览器和板子蓝牙握手
之后每隔2秒,板子把"温度 24.32°C、湿度 58.06%RH"以一行文本的形式推过去
页面顶部两张大卡片显示当前温湿度,底部黑窗口是滚动日志
形态上,就是把以前USB串口接到电脑上用printk打印日志的方式,搬到了无线链路上。逻辑上,板子干的事和之前一模一样——读传感器、格式化、往外吐数据——只是"往外吐"这一步从UART换成了 BLE NUS。
为什么选 NUS
蓝牙BLE上能跑的东西很多(心率、电池、按键……),我们要做"无线串口",最直接的方案是 NUS(Nordic UART Service)。
NUS是Nordic半导体的私有GATT服务,它在GATT之上模拟了一个UART通道:
TX characteristic:设备往客户端发(用 notify 主动推)
RX characteristic:客户端往设备发(用 write)
128-bit UUID:6E400001-... (Service) / 6E400002 (RX) / 6E400003 (TX)
为什么选它:
零成本:Zephyr官方就内置了,注册几个回调就能用
协议简单:和串口完全一样,吐纯文本、收纯文本,没有二进制编解码
Web Bluetooth 友好:Chrome / Edge 的Web Bluetooth API允许访问NUS(虽然128-bit私有UUID 普遍被禁,但NUS在白名单里)
UUID 这点特别提一下:绝大多数128-bit自定义UUID浏览器都会拒绝。NUS是为数不多被 Web Bluetooth显式允许的私有服务之一。如果我们自己造一个UUID出来,前端连不上。
Zephyr固件侧用NUS多简单
#include <zephyr/bluetooth/services/nus.h> static void notif_enabled(bool enabled, void *ctx) { /* 客户端订阅/取消订阅 */ } static void nus_received(struct bt_conn *conn, const void *data, uint16_t len, void *ctx) { /* 客户端发过来的数据回这里 */ } struct bt_nus_cb nus_listener = { .notif_enabled = notif_enabled, .received = nus_received, }; /* 主流程 */ bt_nus_cb_register(&nus_listener, NULL); bt_enable(NULL); bt_le_adv_start(BT_LE_ADV_CONN_FAST_1, ad, ARRAY_SIZE(ad), sd, ARRAY_SIZE(sd)); /* 要发数据时 */ char line[64]; int len = snprintk(line, sizeof(line), "DHT20: 24.32 degC, 58.06 %RH\n"); bt_nus_send(NULL, line, len); /* NULL = 广播给所有订阅了通知的连接 */整个GATT服务声明、UUID注册、notify subscription管理都让Zephyr做掉了,我们只关心 send/receive 两个动作。
把 hello_world 升级成 peripheral_th
工程在 g:/peripheral_th。基线是Zephyr自带的 samples/bluetooth/peripheral_nus,相当于它把 NUS的样板代码已经写好,我们把DHT20那部分加进去。
设备树overlay(沿用上一阶段)
&lpi2c1 { dht20: dht20@38 { compatible = "aosong,dht20"; reg = <0x38>; status = "okay"; }; };lpi2c1是板子上Arduino接口里 SDA(D14)/SCL(D15) 对应的那条I2C总线,已经在板级DTS里使能了,所以这里只挂设备。注意DHT20默认I2C地址是0x38,和板上加速度计0x19互不冲突,可以共享总线。
prj.conf关键开关
# 蓝牙 CONFIG_BT=y CONFIG_BT_PERIPHERAL=y CONFIG_BT_DEVICE_NAME="Zephyr TH Sensor" # 关键:把 NUS 打开(Zephyr 自带的服务) CONFIG_BT_ZEPHYR_NUS=y CONFIG_BT_ZEPHYR_NUS_DEFAULT_INSTANCE=y # MTU 拉满,一行 DHT20 数据不会分片 CONFIG_BT_L2CAP_TX_MTU=247 # 传感器 CONFIG_I2C=y CONFIG_SENSOR=y CONFIG_DHT20=y
main.c 的核心循环
整个固件的核心其实就一个周期任务:每2秒读一次DHT20,把结果同时printk到串口 + 通过 NUS 发出去。
static volatile bool nus_notif_on; /* 是否有人在订阅通知 */ static void sample_work_handler(struct k_work *work) { if (!nus_notif_on) { schedule_next_sample(); return; } char line[64]; int len = read_dht20(line, sizeof(line)); /* "DHT20: 24.32 degC, 58.06 %RH\n" */ if (len < 0) { /* I2C 错误处理 */ return; } printk("%s", line); /* 串口也打一份,方便调试 */ bt_nus_send(NULL, line, len); /* 蓝牙推一份 */ schedule_next_sample(); }两个值得展开的设计点:
(1) 用 K_WORK_DELAYABLE_DEFINE + k_work_schedule 做周期任务
不用 k_timer,因为 handler 里要做 printk(很慢,不能在 ISR 上下文)
handler 自己递归 schedule 自己,实现周期
周期 2s 由 SAMPLE_PERIOD = K_SECONDS(2) 控制
(2) nus_notif_on 闸门
只在客户端订阅了 NUS notify 的时候才采样 + 发送
没人连接时整个链路静态,省电、省 I2C 总线
这个标志位由 notif_enabled 回调更新(客户端 subscribe/unsubscribe CCC 时触发)
烧录跑起来后,串口长这样(手机没连时只显示广播开始):

手机/浏览器连上、订阅 NUS 之后变成:

网页侧:纯前端 + Web Bluetooth API
测试代码:// 1) 扫描并让用户选设备 const device = await navigator.bluetooth.requestDevice({ filters: [{ services: ['6e400001-...'] }] // 只列 NUS 设备 }); // 2) 连接 + 发现服务 + 拿 TX characteristic const server = await device.gatt.connect(); const service = await server.getPrimaryService('6e400001-...'); const tx = await service.getCharacteristic('6e400003-...'); // 3) 订阅通知 await tx.startNotifications(); tx.addEventListener('characteristicvaluechanged', (event) => { const text = new TextDecoder('utf-8').decode(event.target.value); // 拼行、解析、显示 });打开就是这样的布局:

简洁够用,没花哨。数据每 2 秒循环一次:
k_work 周期触发
sensor_sample_fetch 读 DHT20(I2C)
sensor_channel_get 取温度/湿度
snprintk 格式化成字符串
串口 printk 打印
bt_nus_send 走 BLE notify
浏览器 characteristicvaluechanged 收到
TextDecoder 解码 + 解析 + 刷新 UI
阶段成果:
固件:g:/peripheral_th,FLASH 97KB / RAM 35KB
Web 页面:g:/peripheral_th/web/index.html
一行命令 west flash(或 JLink 脚本)烧录即用
到这一步,串口能打的内容,浏览器也能看了——这一句话就是这阶段全部的成果。
我要赚赏金
