432 lines
15 KiB
Swift
432 lines
15 KiB
Swift
//
|
|
// 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`.
|
|
}
|