Files
aris-old/IrisCompanion/iris/Bluetooth/BlePeripheralManager.swift
2026-01-08 19:16:32 +00:00

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`.
}