蓝牙智能井盖的通信协议栈并不是简单地把传感器数据塞进一个特征值就能搞定的事。实际项目中,井盖通常安装在道路两侧,需要长期低功耗运行,同时又要保证异常告警能及时上报,远程开锁指令能被可靠执行。Core Bluetooth框架提供了中心角色和外设角色两套API,但真正要落地一个可维护、可扩展的协议栈,必须把物理层、链路层、网络层、应用层分开设计。下面按照这四层逐一展开,所有代码均基于Swift和iOS 15以上版本。

物理层:BLE连接参数与广播策略
物理层在Core Bluetooth中的直接体现就是广播数据和连接参数。井盖设备作为外设(Peripheral)通常使用BLE 5.0模块,广播间隔建议设置为300ms到1s之间。广播间隔越短,中心设备发现越快,但功耗会明显上升;井盖不需要频繁被发现,所以一般选择1s左右的广播间隔。在iOS端,中心设备通过scanForPeripherals(withServices:options:)扫描时,可以通过CBCentralManagerScanOptionAllowDuplicatesKey决定是否允许重复上报同一个广播包。对于已经绑定过的井盖,建议采用定向广播或者直接使用连接恢复机制,避免每次都需要扫描。
连接参数方面,iOS不能直接指定连接间隔,只能在外设端请求更新。井盖端固件通常会向中心设备发起连接参数更新请求,将连接间隔调整到30ms到50ms,从设备延迟(Slave Latency)设为0,这样保证指令下发的实时性。iOS系统会根据外设请求和自身省电策略自动协商,中心端无需也无法干预。需要注意的一点是,如果井盖在持续传输数据,连接事件必须足够密集,否则链路层缓冲区会溢出。物理层的另一个重要约束是最大传输单元(MTU),iOS 10以后支持最大185字节的ATT_MTU,实际可用载荷为182字节。这个数字直接决定后面网络层分片的大小。
链路层:GATT服务设计与特征划分
链路层在BLE中对应GATT(Generic Attribute Profile),核心是把数据交换抽象为服务(Service)、特征(Characteristic)和描述符(Descriptor)。井盖协议栈建议定义两个服务:设备信息服务(0x180A)和井盖业务服务(自定义128位UUID)。设备信息服务中包含固件版本、硬件版本、厂商名称等只读特征,方便后续远程升级和资产管理。业务服务至少需要三个特征:状态上报特征(Notify属性)、指令下发特征(Write属性)、配置特征(Read/Write属性)。状态上报特征用于井盖主动推送倾角、水位、电池电压等数据,指令下发特征用于手机端发送开锁、参数设置等命令,配置特征用于读写井盖的心跳周期、告警阈值等。
特征值的UUID建议使用128位随机UUID,在iOS端用CBUUID(string:)创建。一个常见的错误是把所有数据都放在同一个特征里,既用Notify又用Write,这样很容易出现并发读写冲突。正确做法是物理上分离:井盖到手机的走Notify特征,手机到井盖的走Write特征。特征属性必须精确,例如状态上报特征只需要.notify,指令下发特征需要.write(不要求响应,因为应用层会做确认),配置特征需要.read和.write。如果井盖端期望指令写完后收到ATT层确认,可以加上.writeWithoutResponse的对照属性,但一般不建议,因为网络层已经有确认机制。
下面给出在iOS端扫描并连接井盖、发现服务与特征的Swift代码片段。该代码假设井盖的广播名称前缀为“Manhole”,业务服务UUID和特征UUID已用常量定义。
import CoreBluetooth
class ManholeCentral: NSObject, CBCentralManagerDelegate, CBPeripheralDelegate {
private var central: CBCentralManager!
private var targetPeripheral: CBPeripheral?
private let serviceUUID = CBUUID(string: "6E400001-B5A3-F393-E0A9-E50E24DCCA9E")
private let notifyCharUUID = CBUUID(string: "6E400002-B5A3-F393-E0A9-E50E24DCCA9E")
private let writeCharUUID = CBUUID(string: "6E400003-B5A3-F393-E0A9-E50E24DCCA9E")
private var notifyChar: CBCharacteristic?
override init() {
super.init()
central = CBCentralManager(delegate: self, queue: .main)
}
func centralManagerDidUpdateState(_ central: CBCentralManager) {
if central.state == .poweredOn {
central.scanForPeripherals(withServices: [serviceUUID], options: [CBCentralManagerScanOptionAllowDuplicatesKey: false])
}
}
func centralManager(_ central: CBCentralManager, didDiscover peripheral: CBPeripheral, advertisementData: [String : Any], rssi RSSI: NSNumber) {
if let name = advertisementData[CBAdvertisementDataLocalNameKey] as? String, name.hasPrefix("Manhole") {
targetPeripheral = peripheral
central.stopScan()
central.connect(peripheral, options: nil)
}
}
func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) {
peripheral.delegate = self
peripheral.discoverServices([serviceUUID])
}
func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: Error?) {
guard let service = peripheral.services?.first else { return }
peripheral.discoverCharacteristics([notifyCharUUID, writeCharUUID], for: service)
}
func peripheral(_ peripheral: CBPeripheral, didDiscoverCharacteristicsFor service: CBService, error: Error?) {
guard let characteristics = service.characteristics else { return }
for char in characteristics {
if char.uuid == notifyCharUUID {
notifyChar = char
peripheral.setNotifyValue(true, for: char)
}
}
}
func peripheral(_ peripheral: CBPeripheral, didUpdateValueFor characteristic: CBCharacteristic, error: Error?) {
if characteristic.uuid == notifyCharUUID, let data = characteristic.value {
// 交给网络层处理数据帧
receiveLinkLayerFrame(data)
}
}
}
网络层:分片重组与可靠传输机制
网络层的核心任务是把应用层的数据包切成适合ATT_MTU的片段,并在接收端重新组装。由于iOS端可用的ATT_MTU通常为182字节,去掉链路层帧头后,每个分片建议携带不超过160字节的有效载荷。井盖端固件也需要实现相同的分片协议,否则无法互通。网络层帧格式建议采用固定头部:1字节帧类型、2字节总长度、2字节序号、1字节分片索引、1字节分片总数,然后接数据区。帧类型包括数据帧(0x01)、确认帧(0x02)、心跳帧(0x03)。序号用来标识一次完整的应用层消息,分片索引和总数用于重组。
可靠传输采用简单的停止等待ARQ:手机端发送一个数据包后,井盖端必须回复对应的确认帧,如果手机在500ms内没有收到确认,则重传,最多重传3次。停止等待协议虽然吞吐量不高,但对井盖这种低数据率场景完全够用,而且实现简单、占用内存小。注意确认帧只需要包含序号,不需要重复数据区,这样即使链路质量较差,确认帧也能很快传回。网络层的重传超时需要考虑BLE连接间隔,建议设为连接间隔的10倍以上,但不要超过2秒,否则用户体验会明显下降。
另一项容易忽略的工作是断线重连后的状态恢复。如果井盖在发送一个多分片数据包的过程中断开连接,手机端必须能够丢弃不完整的分片,重新发起请求。井盖端也应缓存最近一次未完成发送的数据包,以便重连后继续。实际实现中,可以用一个Data数组按序号暂存接收到的分片,当所有分片到齐后再拼接并交给应用层。下面展示一个简单的重组逻辑,代码中的frameType和sequence从原始Data中解析得到。
struct NetworkFrame {
static let headerSize = 6
static let maxPayloadPerFragment = 160
let type: UInt8
let sequence: UInt16
let fragmentIndex: UInt8
let fragmentTotal: UInt8
let payload: Data
}
var pendingFrames: [UInt16: [UInt8: Data]] = [:]
func receiveLinkLayerFrame(_ data: Data) {
guard data.count >= NetworkFrame.headerSize else { return }
let type = data[0]
let sequence = UInt16(data[1]) << 8 | UInt16(data[2])
let fragIndex = data[3]
let fragTotal = data[4]
let payload = data.subdata(in: 5..<data.count)
if type == 0x02 { // 确认帧
// 从待确认字典中移除对应序号,停止重传定时器
return
}
if fragTotal == 1 {
// 单分片直接提交应用层
deliverToApplication(payload)
return
}
if pendingFrames[sequence] == nil {
pendingFrames[sequence] = [:]
}
pendingFrames[sequence]?[fragIndex] = payload
if pendingFrames[sequence]?.count == Int(fragTotal) {
var fullData = Data()
for index in 0..<fragTotal {
if let fragment = pendingFrames[sequence]?[index] {
fullData.append(fragment)
}
}
pendingFrames.removeValue(forKey: sequence)
deliverToApplication(fullData)
}
}
应用层:井盖状态报文与指令交互流程
应用层定义具体的业务数据格式。井盖状态上报报文建议使用JSON或者精简的二进制TLV格式。JSON可读性好,便于调试,但会显著增加数据量;对于电池供电的井盖,二进制格式更合适。本文采用二进制TLV(Tag-Length-Value)编码,每个字段1字节标签、1字节长度、N字节值。状态字段包括:倾角(0x01)、水位(0x02)、电池电压(0x03)、温度(0x04)、告警标志(0x05)。例如一个倾角值12.5度可以编码为0x01 0x02 0x00 0x7D,其中0x007D是125的十六进制,表示12.5度(缩放10倍)。
指令下发的报文格式类似,但方向相反。远程开锁指令可以定义为标签0x10、长度1字节、值0x01表示开锁,0x00表示关锁。参数设置指令如修改心跳周期,标签0x11、长度2字节、值以秒为单位。手机端发送指令后,应用层等待网络层的确认帧,只有在确认帧到达后才认为指令已被井盖接收。注意这里存在两层确认:网络层的确认帧只代表链路层收到了完整数据包,不代表应用层执行成功。井盖固件在执行完开锁动作后,应该通过状态上报特征主动推一条执行结果报文,手机端收到后更新界面。如果没有这个应用层回执,用户可能看到“已发送”但井盖实际没动作。
完整的一次井盖倾角告警上报与远程开锁交互流程如下:井盖检测到倾角超过阈值,立即通过Notify特征发送一条状态上报报文(可能分多个网络层帧);手机端网络层重组后交给应用层解析,发现倾角异常并触发告警;操作人员在App上点击“远程开锁”,App构建开锁指令并交给网络层发送;井盖收到指令后执行开锁,同时通过Notify特征发送一条包含执行结果的状态报文。整个过程涉及物理层的连接参数、链路层的特征选择、网络层的分片确认、应用层的字段编码,任何一层设计不合理都会导致通信失败或功耗失控。
还需要注意后台运行限制。iOS应用进入后台后,Core Bluetooth的中心角色仍然可以维持连接并接收Notify数据,但系统可能随时挂起应用。如果井盖在夜间频繁上报数据,App需要合理处理后台唤醒,避免被系统终止。对于关键告警,可以考虑使用Core Bluetooth的状态恢复特性,结合CBCentralManagerOptionRestoreIdentifierKey在应用被系统杀死后重新恢复连接。井盖端固件也应具备超时重连机制,长时间收不到中心设备的确认后主动断开并重新广播。

