533 lines
19 KiB
Swift
533 lines
19 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")
|
||
// Read/Notify: 1-byte value (0x00=OFF, 0x01=ON) that requests Glass to enable Wi‑Fi.
|
||
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`.
|
||
}
|