initial commit
This commit is contained in:
431
IrisCompanion/iris/Bluetooth/BlePeripheralManager.swift
Normal file
431
IrisCompanion/iris/Bluetooth/BlePeripheralManager.swift
Normal file
@@ -0,0 +1,431 @@
|
||||
//
|
||||
// BlePeripheralManager.swift
|
||||
// iris
|
||||
//
|
||||
// Created by Codex.
|
||||
//
|
||||
|
||||
import CoreBluetooth
|
||||
import Foundation
|
||||
|
||||
#if canImport(UIKit)
|
||||
import UIKit
|
||||
#endif
|
||||
|
||||
final class BlePeripheralManager: NSObject, ObservableObject {
|
||||
static let serviceUUID = CBUUID(string: "A0B0C0D0-E0F0-4A0B-9C0D-0E0F1A2B3C4D")
|
||||
static let feedTxUUID = CBUUID(string: "A0B0C0D1-E0F0-4A0B-9C0D-0E0F1A2B3C4D")
|
||||
static let controlRxUUID = CBUUID(string: "A0B0C0D2-E0F0-4A0B-9C0D-0E0F1A2B3C4D")
|
||||
|
||||
@Published private(set) var bluetoothState: CBManagerState = .unknown
|
||||
@Published var advertisingEnabled: Bool = true
|
||||
@Published private(set) var isAdvertising: Bool = false
|
||||
@Published private(set) var isSubscribed: Bool = false
|
||||
@Published private(set) var subscribedCount: Int = 0
|
||||
@Published private(set) var lastMsgIdSent: UInt32 = 0
|
||||
@Published private(set) var lastPingAt: Date? = nil
|
||||
@Published private(set) var lastCommand: String? = nil
|
||||
@Published private(set) var notifyQueueDepth: Int = 0
|
||||
@Published private(set) var droppedNotifyPackets: Int = 0
|
||||
@Published private(set) var lastNotifyAt: Date? = nil
|
||||
@Published private(set) var lastDataAt: Date? = nil
|
||||
|
||||
private let queue = DispatchQueue(label: "iris.ble.peripheral.queue")
|
||||
private lazy var peripheral = CBPeripheralManager(delegate: self, queue: queue)
|
||||
|
||||
private var service: CBMutableService?
|
||||
private var feedTx: CBMutableCharacteristic?
|
||||
private var controlRx: CBMutableCharacteristic?
|
||||
|
||||
private var subscribedCentralIds = Set<UUID>()
|
||||
private var centralMaxUpdateLength: [UUID: Int] = [:]
|
||||
private var lastReadValue: Data = Data()
|
||||
|
||||
private struct PendingMessage {
|
||||
let msgId: UInt32
|
||||
let msgType: UInt8
|
||||
let payload: Data
|
||||
let maxPayloadLen: Int
|
||||
var nextChunkIndex: Int
|
||||
let chunkCount: Int
|
||||
|
||||
var remainingChunks: Int { max(0, chunkCount - nextChunkIndex) }
|
||||
|
||||
func remainingBytesApprox(headerLen: Int) -> Int {
|
||||
guard maxPayloadLen > 0 else { return remainingChunks * headerLen }
|
||||
let remainingPayload = max(0, payload.count - (nextChunkIndex * maxPayloadLen))
|
||||
return remainingPayload + (remainingChunks * headerLen)
|
||||
}
|
||||
}
|
||||
|
||||
private var pendingMessages: [PendingMessage] = []
|
||||
private var pingTimer: DispatchSourceTimer?
|
||||
|
||||
private var msgId: UInt32 = 0
|
||||
var onFirstSubscribe: (() -> Void)? = nil
|
||||
var onControlCommand: ((String) -> Void)? = nil
|
||||
|
||||
private let packetHeaderLen = 9
|
||||
private let maxQueuedNotifyPackets = 20_000
|
||||
private let maxQueuedNotifyBytesApprox = 8_000_000
|
||||
|
||||
override init() {
|
||||
super.init()
|
||||
_ = peripheral
|
||||
}
|
||||
|
||||
func start() {
|
||||
queue.async { [weak self] in
|
||||
self?.applyAdvertisingPolicy()
|
||||
}
|
||||
}
|
||||
|
||||
func stop() {
|
||||
queue.async { [weak self] in
|
||||
self?.publish { self?.advertisingEnabled = false }
|
||||
self?.stopAdvertising()
|
||||
self?.stopPing()
|
||||
}
|
||||
}
|
||||
|
||||
func sendOpaque(_ payload: Data, msgType: UInt8 = 1) {
|
||||
queue.async { [weak self] in
|
||||
self?.sendMessage(msgType: msgType, payload: payload)
|
||||
}
|
||||
}
|
||||
|
||||
func copyUUIDsToPasteboard() {
|
||||
let text = """
|
||||
SERVICE_UUID=\(Self.serviceUUID.uuidString)
|
||||
FEED_TX_UUID=\(Self.feedTxUUID.uuidString)
|
||||
CONTROL_RX_UUID=\(Self.controlRxUUID.uuidString)
|
||||
"""
|
||||
#if canImport(UIKit)
|
||||
DispatchQueue.main.async {
|
||||
UIPasteboard.general.string = text
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
private func ensureService() {
|
||||
guard peripheral.state == .poweredOn else { return }
|
||||
guard service == nil else { return }
|
||||
|
||||
peripheral.removeAllServices()
|
||||
|
||||
let feedTx = CBMutableCharacteristic(
|
||||
type: Self.feedTxUUID,
|
||||
properties: [.notify, .read],
|
||||
value: nil,
|
||||
permissions: [.readable]
|
||||
)
|
||||
let controlRx = CBMutableCharacteristic(
|
||||
type: Self.controlRxUUID,
|
||||
properties: [.writeWithoutResponse, .write],
|
||||
value: nil,
|
||||
permissions: [.writeable]
|
||||
)
|
||||
let service = CBMutableService(type: Self.serviceUUID, primary: true)
|
||||
service.characteristics = [feedTx, controlRx]
|
||||
self.service = service
|
||||
self.feedTx = feedTx
|
||||
self.controlRx = controlRx
|
||||
peripheral.add(service)
|
||||
}
|
||||
|
||||
private func applyAdvertisingPolicy() {
|
||||
ensureService()
|
||||
guard peripheral.state == .poweredOn else {
|
||||
stopAdvertising()
|
||||
return
|
||||
}
|
||||
if advertisingEnabled, subscribedCentralIds.isEmpty {
|
||||
startAdvertising()
|
||||
} else {
|
||||
stopAdvertising()
|
||||
}
|
||||
}
|
||||
|
||||
private func startAdvertising() {
|
||||
guard !isAdvertising else { return }
|
||||
peripheral.startAdvertising([
|
||||
CBAdvertisementDataLocalNameKey: "GlassNow",
|
||||
CBAdvertisementDataServiceUUIDsKey: [Self.serviceUUID],
|
||||
])
|
||||
publish { self.isAdvertising = true }
|
||||
}
|
||||
|
||||
private func stopAdvertising() {
|
||||
guard isAdvertising else { return }
|
||||
peripheral.stopAdvertising()
|
||||
publish { self.isAdvertising = false }
|
||||
}
|
||||
|
||||
private func startPing() {
|
||||
guard pingTimer == nil else { return }
|
||||
let timer = DispatchSource.makeTimerSource(queue: queue)
|
||||
timer.schedule(deadline: .now() + 15, repeating: 15)
|
||||
timer.setEventHandler { [weak self] in
|
||||
self?.sendPing()
|
||||
}
|
||||
timer.resume()
|
||||
pingTimer = timer
|
||||
}
|
||||
|
||||
private func stopPing() {
|
||||
pingTimer?.cancel()
|
||||
pingTimer = nil
|
||||
}
|
||||
|
||||
private func sendPing() {
|
||||
sendMessage(msgType: 2, payload: Data())
|
||||
publish { self.lastPingAt = Date() }
|
||||
}
|
||||
|
||||
private func sendMessage(msgType: UInt8, payload: Data) {
|
||||
guard let feedTx = feedTx else { return }
|
||||
guard !subscribedCentralIds.isEmpty else { return }
|
||||
|
||||
lastReadValue = payload.trimmedTrailingWhitespace()
|
||||
|
||||
msgId &+= 1
|
||||
publish { self.lastMsgIdSent = self.msgId }
|
||||
|
||||
let maxLen = currentMaxUpdateValueLength()
|
||||
guard maxLen > packetHeaderLen else { return }
|
||||
let maxPayloadLen = max(1, maxLen - packetHeaderLen)
|
||||
let totalPayloadLen = lastReadValue.count
|
||||
let chunkCount = max(1, Int(ceil(Double(totalPayloadLen) / Double(maxPayloadLen))))
|
||||
guard chunkCount <= Int(UInt16.max) else { return }
|
||||
|
||||
flushPendingMessages()
|
||||
|
||||
var message = PendingMessage(
|
||||
msgId: msgId,
|
||||
msgType: msgType,
|
||||
payload: lastReadValue,
|
||||
maxPayloadLen: maxPayloadLen,
|
||||
nextChunkIndex: 0,
|
||||
chunkCount: chunkCount
|
||||
)
|
||||
|
||||
if !pendingMessages.isEmpty {
|
||||
enqueuePendingMessage(message)
|
||||
return
|
||||
}
|
||||
|
||||
if !sendPendingMessage(&message, characteristic: feedTx) {
|
||||
pendingMessages.append(message)
|
||||
enforceNotifyQueueLimits()
|
||||
}
|
||||
if msgType != 2 {
|
||||
publish { self.lastDataAt = Date() }
|
||||
}
|
||||
publishNotifyQueueDepth()
|
||||
}
|
||||
|
||||
private func buildPacket(msgId: UInt32, msgType: UInt8, chunkIndex: UInt16, chunkCount: UInt16, payloadSlice: Data.SubSequence) -> Data {
|
||||
var data = Data()
|
||||
data.reserveCapacity(packetHeaderLen + payloadSlice.count)
|
||||
data.appendLE(msgId)
|
||||
data.append(msgType)
|
||||
data.appendLE(chunkIndex)
|
||||
data.appendLE(chunkCount)
|
||||
data.append(contentsOf: payloadSlice)
|
||||
return data
|
||||
}
|
||||
|
||||
private func sendPendingMessage(_ message: inout PendingMessage, characteristic: CBMutableCharacteristic) -> Bool {
|
||||
while message.nextChunkIndex < message.chunkCount {
|
||||
let start = message.nextChunkIndex * message.maxPayloadLen
|
||||
let end = min(start + message.maxPayloadLen, message.payload.count)
|
||||
let packet = buildPacket(
|
||||
msgId: message.msgId,
|
||||
msgType: message.msgType,
|
||||
chunkIndex: UInt16(message.nextChunkIndex),
|
||||
chunkCount: UInt16(message.chunkCount),
|
||||
payloadSlice: message.payload[start..<end]
|
||||
)
|
||||
if peripheral.updateValue(packet, for: characteristic, onSubscribedCentrals: nil) {
|
||||
publish { self.lastNotifyAt = Date() }
|
||||
message.nextChunkIndex += 1
|
||||
continue
|
||||
}
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private func flushPendingMessages() {
|
||||
guard let feedTx = feedTx else { return }
|
||||
while !pendingMessages.isEmpty {
|
||||
if sendPendingMessage(&pendingMessages[0], characteristic: feedTx) {
|
||||
pendingMessages.removeFirst()
|
||||
continue
|
||||
}
|
||||
break
|
||||
}
|
||||
publishNotifyQueueDepth()
|
||||
}
|
||||
|
||||
private func enqueuePendingMessage(_ message: PendingMessage) {
|
||||
pendingMessages.append(message)
|
||||
enforceNotifyQueueLimits()
|
||||
publishNotifyQueueDepth()
|
||||
}
|
||||
|
||||
private func publishNotifyQueueDepth() {
|
||||
let depth = pendingMessages.reduce(0) { $0 + $1.remainingChunks }
|
||||
publish { self.notifyQueueDepth = depth }
|
||||
}
|
||||
|
||||
private func queuedNotifyBytesApprox() -> Int {
|
||||
pendingMessages.reduce(0) { $0 + $1.remainingBytesApprox(headerLen: packetHeaderLen) }
|
||||
}
|
||||
|
||||
private func enforceNotifyQueueLimits() {
|
||||
var dropped = 0
|
||||
while !pendingMessages.isEmpty {
|
||||
let packetCount = pendingMessages.reduce(0) { $0 + $1.remainingChunks }
|
||||
let bytesApprox = queuedNotifyBytesApprox()
|
||||
if packetCount <= maxQueuedNotifyPackets, bytesApprox <= maxQueuedNotifyBytesApprox {
|
||||
break
|
||||
}
|
||||
let removed = pendingMessages.removeLast()
|
||||
dropped += removed.remainingChunks
|
||||
}
|
||||
if dropped > 0 {
|
||||
publish { self.droppedNotifyPackets += dropped }
|
||||
}
|
||||
}
|
||||
|
||||
private func publish(_ block: @escaping () -> Void) {
|
||||
DispatchQueue.main.async(execute: block)
|
||||
}
|
||||
|
||||
private func currentMaxUpdateValueLength() -> Int {
|
||||
let lengths = subscribedCentralIds.compactMap { centralMaxUpdateLength[$0] }.filter { $0 > 0 }
|
||||
if let minLen = lengths.min() {
|
||||
return minLen
|
||||
}
|
||||
return 180
|
||||
}
|
||||
}
|
||||
|
||||
extension BlePeripheralManager: CBPeripheralManagerDelegate {
|
||||
func peripheralManagerDidUpdateState(_ peripheral: CBPeripheralManager) {
|
||||
queue.async { [weak self] in
|
||||
guard let self = self else { return }
|
||||
self.publish { self.bluetoothState = peripheral.state }
|
||||
if peripheral.state != .poweredOn {
|
||||
self.subscribedCentralIds.removeAll()
|
||||
self.centralMaxUpdateLength.removeAll()
|
||||
self.pendingMessages.removeAll()
|
||||
self.stopPing()
|
||||
self.stopAdvertising()
|
||||
self.publish {
|
||||
self.isSubscribed = false
|
||||
self.subscribedCount = 0
|
||||
self.notifyQueueDepth = 0
|
||||
}
|
||||
self.service = nil
|
||||
self.feedTx = nil
|
||||
self.controlRx = nil
|
||||
return
|
||||
}
|
||||
self.ensureService()
|
||||
self.applyAdvertisingPolicy()
|
||||
}
|
||||
}
|
||||
|
||||
func peripheralManager(_ peripheral: CBPeripheralManager, didAdd service: CBService, error: Error?) {
|
||||
queue.async { [weak self] in
|
||||
self?.applyAdvertisingPolicy()
|
||||
}
|
||||
}
|
||||
|
||||
func peripheralManagerDidStartAdvertising(_ peripheral: CBPeripheralManager, error: Error?) {
|
||||
publish { self.isAdvertising = (error == nil) }
|
||||
}
|
||||
|
||||
func peripheralManager(_ peripheral: CBPeripheralManager, central: CBCentral, didSubscribeTo characteristic: CBCharacteristic) {
|
||||
guard characteristic.uuid == Self.feedTxUUID else { return }
|
||||
queue.async { [weak self] in
|
||||
guard let self = self else { return }
|
||||
let wasEmpty = self.subscribedCentralIds.isEmpty
|
||||
self.subscribedCentralIds.insert(central.identifier)
|
||||
self.centralMaxUpdateLength[central.identifier] = central.maximumUpdateValueLength
|
||||
self.publishNotifyQueueDepth()
|
||||
self.publish {
|
||||
self.isSubscribed = true
|
||||
self.subscribedCount = self.subscribedCentralIds.count
|
||||
}
|
||||
self.applyAdvertisingPolicy()
|
||||
self.startPing()
|
||||
if wasEmpty {
|
||||
self.onFirstSubscribe?()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func peripheralManager(_ peripheral: CBPeripheralManager, central: CBCentral, didUnsubscribeFrom characteristic: CBCharacteristic) {
|
||||
guard characteristic.uuid == Self.feedTxUUID else { return }
|
||||
queue.async { [weak self] in
|
||||
guard let self = self else { return }
|
||||
self.subscribedCentralIds.remove(central.identifier)
|
||||
self.centralMaxUpdateLength[central.identifier] = nil
|
||||
self.publishNotifyQueueDepth()
|
||||
self.publish {
|
||||
self.subscribedCount = self.subscribedCentralIds.count
|
||||
self.isSubscribed = !self.subscribedCentralIds.isEmpty
|
||||
}
|
||||
if self.subscribedCentralIds.isEmpty {
|
||||
self.stopPing()
|
||||
}
|
||||
self.applyAdvertisingPolicy()
|
||||
}
|
||||
}
|
||||
|
||||
func peripheralManagerIsReady(toUpdateSubscribers peripheral: CBPeripheralManager) {
|
||||
queue.async { [weak self] in
|
||||
self?.flushPendingMessages()
|
||||
}
|
||||
}
|
||||
|
||||
func peripheralManager(_ peripheral: CBPeripheralManager, didReceiveRead request: CBATTRequest) {
|
||||
guard request.characteristic.uuid == Self.feedTxUUID else {
|
||||
peripheral.respond(to: request, withResult: .requestNotSupported)
|
||||
return
|
||||
}
|
||||
let maxLen = max(0, request.central.maximumUpdateValueLength)
|
||||
request.value = maxLen > 0 ? lastReadValue.prefix(maxLen) : lastReadValue
|
||||
peripheral.respond(to: request, withResult: .success)
|
||||
}
|
||||
|
||||
func peripheralManager(_ peripheral: CBPeripheralManager, didReceiveWrite requests: [CBATTRequest]) {
|
||||
for request in requests {
|
||||
guard request.characteristic.uuid == Self.controlRxUUID else {
|
||||
continue
|
||||
}
|
||||
let command = String(data: request.value ?? Data(), encoding: .utf8) ?? ""
|
||||
publish { self.lastCommand = command }
|
||||
let trimmed = command.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
self.onControlCommand?(trimmed)
|
||||
peripheral.respond(to: request, withResult: .success)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extension Data {
|
||||
mutating func appendLE(_ value: UInt32) {
|
||||
var v = value.littleEndian
|
||||
Swift.withUnsafeBytes(of: &v) { append(contentsOf: $0) }
|
||||
}
|
||||
|
||||
mutating func appendLE(_ value: UInt16) {
|
||||
var v = value.littleEndian
|
||||
Swift.withUnsafeBytes(of: &v) { append(contentsOf: $0) }
|
||||
}
|
||||
|
||||
// `trimmedTrailingWhitespace()` lives in `iris/Utils/DataTrimming.swift`.
|
||||
}
|
||||
Reference in New Issue
Block a user