Files
aris-old/IrisCompanion/iris/Bluetooth/BlePeripheralManager.swift

533 lines
19 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//
// 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")
// Read/Notify: 1-byte value (0x00=OFF, 0x01=ON) that requests Glass to enable WiFi.
static let wifiRequestTxUUID = CBUUID(string: "A0B0C0D3-E0F0-4A0B-9C0D-0E0F1A2B3C4D")
private static let restoreIdentifier = "iris.ble.peripheral.v1"
@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
@Published private(set) var wifiRequested: Bool = false
private let queue = DispatchQueue(label: "iris.ble.peripheral.queue")
private lazy var peripheral = CBPeripheralManager(
delegate: self,
queue: queue,
options: [
CBPeripheralManagerOptionRestoreIdentifierKey: Self.restoreIdentifier,
]
)
private var service: CBMutableService?
private var feedTx: CBMutableCharacteristic?
private var controlRx: CBMutableCharacteristic?
private var wifiRequestTx: CBMutableCharacteristic?
private var subscribedCentralIds = Set<UUID>()
private var centralMaxUpdateLength: [UUID: Int] = [:]
private var lastReadValue: Data = Data()
private var wifiRequestValue: Data = Data([0x00])
private var wifiRequestNotifyPending: Bool = false
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 setWifiRequested(_ requested: Bool) {
queue.async { [weak self] in
guard let self else { return }
let newValue = Data([requested ? 0x01 : 0x00])
guard newValue != self.wifiRequestValue else { return }
self.wifiRequestValue = newValue
self.publish { self.wifiRequested = requested }
self.flushWifiRequestNotifyIfPossible()
}
}
func copyUUIDsToPasteboard() {
let text = """
SERVICE_UUID=\(Self.serviceUUID.uuidString)
FEED_TX_UUID=\(Self.feedTxUUID.uuidString)
CONTROL_RX_UUID=\(Self.controlRxUUID.uuidString)
WIFI_REQUEST_TX_UUID=\(Self.wifiRequestTxUUID.uuidString)
"""
#if canImport(UIKit)
DispatchQueue.main.async {
UIPasteboard.general.string = text
}
#endif
}
private func ensureService() {
guard peripheral.state == .poweredOn else { return }
if let existingService = service {
let hasWifiChar = (existingService.characteristics ?? []).contains { $0.uuid == Self.wifiRequestTxUUID }
if wifiRequestTx == nil || !hasWifiChar {
stopAdvertising()
peripheral.removeAllServices()
service = nil
feedTx = nil
controlRx = nil
wifiRequestTx = nil
}
}
guard service == nil else { return }
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 wifiRequestTx = CBMutableCharacteristic(
type: Self.wifiRequestTxUUID,
properties: [.notify, .read],
value: nil,
permissions: [.readable]
)
let service = CBMutableService(type: Self.serviceUUID, primary: true)
service.characteristics = [feedTx, controlRx, wifiRequestTx]
self.service = service
self.feedTx = feedTx
self.controlRx = controlRx
self.wifiRequestTx = wifiRequestTx
peripheral.add(service)
}
private func applyAdvertisingPolicy() {
ensureService()
guard peripheral.state == .poweredOn else {
stopAdvertising()
return
}
if advertisingEnabled {
startAdvertising()
} else {
stopAdvertising()
}
}
private func startAdvertising() {
guard !peripheral.isAdvertising else {
publish { self.isAdvertising = true }
return
}
peripheral.startAdvertising([
CBAdvertisementDataLocalNameKey: "Aris",
CBAdvertisementDataServiceUUIDsKey: [Self.serviceUUID],
])
}
private func stopAdvertising() {
guard peripheral.isAdvertising else {
publish { self.isAdvertising = false }
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 flushWifiRequestNotifyIfPossible() {
guard let wifiRequestTx else { return }
let hasSubscribers = !(wifiRequestTx.subscribedCentrals ?? []).isEmpty
guard hasSubscribers else {
wifiRequestNotifyPending = false
return
}
if peripheral.updateValue(wifiRequestValue, for: wifiRequestTx, onSubscribedCentrals: nil) {
wifiRequestNotifyPending = false
} else {
wifiRequestNotifyPending = true
}
}
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 peripheralManager(_ peripheral: CBPeripheralManager, willRestoreState dict: [String: Any]) {
queue.async { [weak self] in
guard let self else { return }
if let services = dict[CBPeripheralManagerRestoredStateServicesKey] as? [CBService],
let restoredService = services.first(where: { $0.uuid == Self.serviceUUID }),
let restoredMutableService = restoredService as? CBMutableService,
let characteristics = restoredService.characteristics {
self.service = restoredMutableService
self.feedTx = characteristics.first(where: { $0.uuid == Self.feedTxUUID }) as? CBMutableCharacteristic
self.controlRx = characteristics.first(where: { $0.uuid == Self.controlRxUUID }) as? CBMutableCharacteristic
self.wifiRequestTx = characteristics.first(where: { $0.uuid == Self.wifiRequestTxUUID }) as? CBMutableCharacteristic
}
self.publish {
self.isAdvertising = peripheral.isAdvertising
}
self.applyAdvertisingPolicy()
}
}
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
self.wifiRequestTx = 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) {
if characteristic.uuid == Self.wifiRequestTxUUID {
queue.async { [weak self] in
self?.flushWifiRequestNotifyIfPossible()
}
return
}
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) {
if characteristic.uuid == Self.wifiRequestTxUUID {
return
}
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()
if let self, self.wifiRequestNotifyPending {
self.flushWifiRequestNotifyIfPossible()
}
}
}
func peripheralManager(_ peripheral: CBPeripheralManager, didReceiveRead request: CBATTRequest) {
let maxLen = max(0, request.central.maximumUpdateValueLength)
if request.characteristic.uuid == Self.feedTxUUID {
request.value = maxLen > 0 ? lastReadValue.prefix(maxLen) : lastReadValue
peripheral.respond(to: request, withResult: .success)
return
}
if request.characteristic.uuid == Self.wifiRequestTxUUID {
request.value = maxLen > 0 ? wifiRequestValue.prefix(maxLen) : wifiRequestValue
peripheral.respond(to: request, withResult: .success)
return
}
peripheral.respond(to: request, withResult: .requestNotSupported)
}
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`.
}