// // 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() 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.. 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`. }