mirror of
https://github.com/FBukovina/Atoll.git
synced 2026-10-27 15:14:33 +00:00
BugFix: Out of bounds of headphone hud
This commit is contained in:
parent
bc2051cfbe
commit
2b6faff96d
3 changed files with 499 additions and 32 deletions
|
|
@ -24,6 +24,7 @@ struct InlineHUD: View {
|
|||
@Default(.useCircularBluetoothBatteryIndicator) var useCircularBluetoothBatteryIndicator
|
||||
@Default(.showBluetoothBatteryPercentageText) var showBluetoothBatteryPercentageText
|
||||
@Default(.showBluetoothDeviceNameMarquee) var showBluetoothDeviceNameMarquee
|
||||
@Default(.enableMinimalisticUI) var enableMinimalisticUI
|
||||
@ObservedObject var bluetoothManager = BluetoothAudioManager.shared
|
||||
|
||||
@State private var displayName: String = ""
|
||||
|
|
@ -33,35 +34,54 @@ struct InlineHUD: View {
|
|||
let hasBatteryLevel = value > 0
|
||||
|
||||
let baseInfoWidth: CGFloat = {
|
||||
if type == .bluetoothAudio {
|
||||
return showBluetoothDeviceNameMarquee ? 140 : 88
|
||||
guard type == .bluetoothAudio else { return 100 }
|
||||
if showBluetoothDeviceNameMarquee {
|
||||
return enableMinimalisticUI ? 128 : 140
|
||||
}
|
||||
return 100
|
||||
return enableMinimalisticUI ? 76 : 88
|
||||
}()
|
||||
|
||||
let infoWidth: CGFloat = {
|
||||
var width = baseInfoWidth + gestureProgress / 2
|
||||
if !hoverAnimation { width -= 8 }
|
||||
let minimum: CGFloat = type == .bluetoothAudio ? (showBluetoothDeviceNameMarquee ? 120 : 82) : 88
|
||||
let minimum: CGFloat = {
|
||||
guard type == .bluetoothAudio else { return 88 }
|
||||
if showBluetoothDeviceNameMarquee {
|
||||
return enableMinimalisticUI ? 112 : 120
|
||||
}
|
||||
return enableMinimalisticUI ? 70 : 82
|
||||
}()
|
||||
return max(width, minimum)
|
||||
}()
|
||||
let baseTrailingWidth: CGFloat = {
|
||||
if type == .bluetoothAudio {
|
||||
if !hasBatteryLevel {
|
||||
return showBluetoothDeviceNameMarquee ? 118 : 88
|
||||
}
|
||||
|
||||
if useCircularIndicator {
|
||||
return showBluetoothBatteryPercentageText ? 120 : 96
|
||||
} else {
|
||||
return showBluetoothBatteryPercentageText ? 136 : 108
|
||||
}
|
||||
let baseTrailingWidth: CGFloat = {
|
||||
guard type == .bluetoothAudio else { return 100 }
|
||||
if !hasBatteryLevel {
|
||||
return showBluetoothDeviceNameMarquee ? (enableMinimalisticUI ? 104 : 118) : (enableMinimalisticUI ? 74 : 88)
|
||||
}
|
||||
return 100
|
||||
|
||||
if useCircularIndicator {
|
||||
return showBluetoothBatteryPercentageText ? (enableMinimalisticUI ? 108 : 120) : (enableMinimalisticUI ? 84 : 96)
|
||||
}
|
||||
|
||||
return showBluetoothBatteryPercentageText ? (enableMinimalisticUI ? 118 : 136) : (enableMinimalisticUI ? 92 : 108)
|
||||
}()
|
||||
|
||||
let trailingWidth: CGFloat = {
|
||||
var width = baseTrailingWidth + gestureProgress / 2
|
||||
if !hoverAnimation { width -= 8 }
|
||||
let minimum: CGFloat = type == .bluetoothAudio ? (showBluetoothBatteryPercentageText ? 110 : 92) : 90
|
||||
let minimum: CGFloat = {
|
||||
guard type == .bluetoothAudio else { return 90 }
|
||||
if !hasBatteryLevel {
|
||||
return showBluetoothDeviceNameMarquee ? (enableMinimalisticUI ? 96 : 110) : (enableMinimalisticUI ? 70 : 92)
|
||||
}
|
||||
|
||||
if useCircularIndicator {
|
||||
return showBluetoothBatteryPercentageText ? (enableMinimalisticUI ? 92 : 110) : (enableMinimalisticUI ? 68 : 80)
|
||||
}
|
||||
|
||||
return showBluetoothBatteryPercentageText ? (enableMinimalisticUI ? 104 : 120) : (enableMinimalisticUI ? 82 : 98)
|
||||
}()
|
||||
return max(width, minimum)
|
||||
}()
|
||||
|
||||
|
|
@ -161,7 +181,14 @@ struct InlineHUD: View {
|
|||
.contentTransition(.interpolate)
|
||||
} else if (type == .bluetoothAudio) {
|
||||
if hasBatteryLevel {
|
||||
HStack(spacing: useCircularIndicator ? 8 : 6) {
|
||||
let indicatorSpacing: CGFloat = {
|
||||
if useCircularIndicator {
|
||||
return showBluetoothBatteryPercentageText ? 8 : 2
|
||||
}
|
||||
return showBluetoothBatteryPercentageText ? 6 : 4
|
||||
}()
|
||||
|
||||
HStack(spacing: indicatorSpacing) {
|
||||
if useCircularIndicator {
|
||||
CircularBatteryIndicator(
|
||||
value: value,
|
||||
|
|
@ -221,7 +248,7 @@ struct InlineHUD: View {
|
|||
.frame(maxWidth: .infinity, alignment: .trailing)
|
||||
}
|
||||
}
|
||||
.padding(.trailing, 4)
|
||||
.padding(.trailing, trailingWidth > 0 ? 4 : 0)
|
||||
.frame(width: trailingWidth, height: vm.closedNotchSize.height - (hoverAnimation ? 0 : 12), alignment: .center)
|
||||
}
|
||||
.frame(height: vm.closedNotchSize.height + (hoverAnimation ? 8 : 0), alignment: .center)
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import Defaults
|
|||
import SwiftUI
|
||||
import IOBluetooth
|
||||
import IOKit
|
||||
import CoreBluetooth
|
||||
|
||||
/// Manages detection and monitoring of Bluetooth audio device connections
|
||||
class BluetoothAudioManager: ObservableObject {
|
||||
|
|
@ -29,6 +30,8 @@ class BluetoothAudioManager: ObservableObject {
|
|||
private let coordinator = DynamicIslandViewCoordinator.shared
|
||||
private var pollingTimer: Timer?
|
||||
private let bluetoothPreferencesSuite = "/Library/Preferences/com.apple.Bluetooth"
|
||||
private let batteryReader = BluetoothLEBatteryReader()
|
||||
private var isLiveBatteryRefreshInFlight = false
|
||||
|
||||
@Published private(set) var batteryStatus: [String: String] = [:]
|
||||
|
||||
|
|
@ -411,17 +414,24 @@ class BluetoothAudioManager: ObservableObject {
|
|||
}
|
||||
}
|
||||
|
||||
private func refreshBatteryLevelsForConnectedDevices() {
|
||||
updateBatteryStatuses(force: true)
|
||||
private func refreshBatteryLevelsForConnectedDevices(forceCacheRefresh: Bool = true) {
|
||||
if forceCacheRefresh {
|
||||
updateBatteryStatuses(force: true)
|
||||
}
|
||||
|
||||
applyConnectedDeviceBatteryLevels()
|
||||
triggerLiveBatteryRefreshIfNeeded()
|
||||
}
|
||||
|
||||
private func applyConnectedDeviceBatteryLevels() {
|
||||
guard !connectedDevices.isEmpty else {
|
||||
lastConnectedDevice = nil
|
||||
return
|
||||
}
|
||||
|
||||
var updatedDevices: [BluetoothAudioDevice] = []
|
||||
for device in connectedDevices {
|
||||
let refreshedLevel =
|
||||
batteryLevelFromRegistry(forAddress: device.address)
|
||||
?? batteryLevelFromRegistry(forName: device.name)
|
||||
?? batteryLevelFromDefaults(forAddress: device.address)
|
||||
?? batteryLevelFromDefaults(forName: device.name)
|
||||
?? device.batteryLevel
|
||||
let refreshedLevel = bestBatteryLevel(for: device)
|
||||
let updatedDevice = device.withBatteryLevel(refreshedLevel)
|
||||
updatedDevices.append(updatedDevice)
|
||||
|
||||
|
|
@ -433,11 +443,189 @@ class BluetoothAudioManager: ObservableObject {
|
|||
}
|
||||
|
||||
connectedDevices = updatedDevices
|
||||
if let last = connectedDevices.last {
|
||||
if let last = updatedDevices.last {
|
||||
lastConnectedDevice = last
|
||||
}
|
||||
}
|
||||
|
||||
private func bestBatteryLevel(for device: BluetoothAudioDevice) -> Int? {
|
||||
batteryLevelFromRegistry(forAddress: device.address)
|
||||
?? batteryLevelFromRegistry(forName: device.name)
|
||||
?? batteryLevelFromDefaults(forAddress: device.address)
|
||||
?? batteryLevelFromDefaults(forName: device.name)
|
||||
?? device.batteryLevel
|
||||
}
|
||||
|
||||
private func triggerLiveBatteryRefreshIfNeeded() {
|
||||
guard !connectedDevices.isEmpty else { return }
|
||||
guard connectedDevices.contains(where: { $0.batteryLevel == nil }) else { return }
|
||||
guard !isLiveBatteryRefreshInFlight else { return }
|
||||
|
||||
let lookups = coreBluetoothLookups(for: connectedDevices)
|
||||
guard !lookups.isEmpty else { return }
|
||||
|
||||
isLiveBatteryRefreshInFlight = true
|
||||
batteryReader.fetchBatteryLevels(for: lookups) { [weak self] results in
|
||||
guard let self else { return }
|
||||
DispatchQueue.main.async {
|
||||
self.isLiveBatteryRefreshInFlight = false
|
||||
self.handleLiveBatteryResults(results)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func coreBluetoothLookups(for devices: [BluetoothAudioDevice]) -> [BluetoothLEBatteryReader.Lookup] {
|
||||
let snapshot = coreBluetoothCacheSnapshot()
|
||||
guard snapshot.hasEntries else { return [] }
|
||||
|
||||
var lookups: [BluetoothLEBatteryReader.Lookup] = []
|
||||
var seenUUIDs: Set<UUID> = []
|
||||
|
||||
for device in devices {
|
||||
let normalizedAddress = normalizeBluetoothIdentifier(device.address)
|
||||
let normalizedName = normalizeProductName(device.name)
|
||||
guard !normalizedAddress.isEmpty || !normalizedName.isEmpty else { continue }
|
||||
|
||||
let uuid = snapshot.byAddress[normalizedAddress]
|
||||
?? snapshot.byName[normalizedName]
|
||||
|
||||
guard let uuid, !seenUUIDs.contains(uuid) else { continue }
|
||||
seenUUIDs.insert(uuid)
|
||||
|
||||
let canonicalName = snapshot.namesByUUID[uuid] ?? normalizedName
|
||||
lookups.append(
|
||||
.init(
|
||||
uuid: uuid,
|
||||
addressKey: normalizedAddress.isEmpty ? nil : normalizedAddress,
|
||||
nameKey: canonicalName.isEmpty ? nil : canonicalName
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
return lookups
|
||||
}
|
||||
|
||||
private func handleLiveBatteryResults(_ results: [BluetoothLEBatteryReader.Result]) {
|
||||
guard !results.isEmpty else { return }
|
||||
|
||||
var didUpdate = false
|
||||
|
||||
for result in results {
|
||||
let level = clampBatteryPercentage(result.level)
|
||||
|
||||
if let addressKey = result.addressKey, !addressKey.isEmpty {
|
||||
let previous = batteryStatusByAddress[addressKey] ?? -1
|
||||
if level > previous {
|
||||
batteryStatusByAddress[addressKey] = level
|
||||
batteryStatus[addressKey] = String(level)
|
||||
didUpdate = true
|
||||
}
|
||||
}
|
||||
|
||||
if let nameKey = result.nameKey, !nameKey.isEmpty {
|
||||
let previous = batteryStatusByName[nameKey] ?? -1
|
||||
if level > previous {
|
||||
batteryStatusByName[nameKey] = level
|
||||
didUpdate = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
guard didUpdate else { return }
|
||||
|
||||
applyConnectedDeviceBatteryLevels()
|
||||
if let lastLevel = lastConnectedDevice?.batteryLevel {
|
||||
updateActiveBluetoothHUDBattery(with: lastLevel)
|
||||
} else if let fallbackLevel = connectedDevices.last(where: { $0.batteryLevel != nil })?.batteryLevel {
|
||||
updateActiveBluetoothHUDBattery(with: fallbackLevel)
|
||||
}
|
||||
}
|
||||
|
||||
private func updateActiveBluetoothHUDBattery(with level: Int?) {
|
||||
guard let level else { return }
|
||||
DispatchQueue.main.async {
|
||||
guard self.coordinator.sneakPeek.show,
|
||||
self.coordinator.sneakPeek.type == .bluetoothAudio else { return }
|
||||
self.coordinator.sneakPeek.value = CGFloat(level) / 100.0
|
||||
}
|
||||
}
|
||||
|
||||
private func coreBluetoothCacheSnapshot() -> CoreBluetoothCacheSnapshot {
|
||||
guard let preferences = UserDefaults(suiteName: bluetoothPreferencesSuite),
|
||||
let coreCache = preferences.object(forKey: "CoreBluetoothCache") as? [String: [String: Any]] else {
|
||||
return .empty
|
||||
}
|
||||
|
||||
var byAddress: [String: UUID] = [:]
|
||||
var byName: [String: UUID] = [:]
|
||||
var namesByUUID: [UUID: String] = [:]
|
||||
|
||||
for (uuidString, payload) in coreCache {
|
||||
guard let uuid = UUID(uuidString: uuidString) else { continue }
|
||||
|
||||
let addressKeys = ["DeviceAddress", "Address", "BD_ADDR", "device_address"]
|
||||
for key in addressKeys {
|
||||
if let value = payload[key], let normalized = normalizeBluetoothIdentifier(from: value) {
|
||||
byAddress[normalized] = uuid
|
||||
}
|
||||
}
|
||||
|
||||
if let serialValue = payload["SerialNumber"], let normalizedSerial = normalizeBluetoothIdentifier(from: serialValue) {
|
||||
byAddress[normalizedSerial] = uuid
|
||||
}
|
||||
|
||||
let nameKeys = ["Name", "DeviceName", "ProductName", "Product", "device_name"]
|
||||
for key in nameKeys {
|
||||
if let value = payload[key], let normalizedName = normalizeProductName(from: value) {
|
||||
byName[normalizedName] = uuid
|
||||
namesByUUID[uuid] = normalizedName
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return CoreBluetoothCacheSnapshot(byAddress: byAddress, byName: byName, namesByUUID: namesByUUID)
|
||||
}
|
||||
|
||||
private func normalizeBluetoothIdentifier(from value: Any) -> String? {
|
||||
if let string = value as? String {
|
||||
let normalized = normalizeBluetoothIdentifier(string)
|
||||
return normalized.isEmpty ? nil : normalized
|
||||
}
|
||||
|
||||
if let data = value as? Data,
|
||||
let ascii = String(data: data, encoding: .utf8) {
|
||||
let normalized = normalizeBluetoothIdentifier(ascii)
|
||||
return normalized.isEmpty ? nil : normalized
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
private func normalizeProductName(from value: Any) -> String? {
|
||||
if let string = value as? String {
|
||||
let normalized = normalizeProductName(string)
|
||||
return normalized.isEmpty ? nil : normalized
|
||||
}
|
||||
if let data = value as? Data,
|
||||
let ascii = String(data: data, encoding: .utf8) {
|
||||
let normalized = normalizeProductName(ascii)
|
||||
return normalized.isEmpty ? nil : normalized
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private struct CoreBluetoothCacheSnapshot {
|
||||
let byAddress: [String: UUID]
|
||||
let byName: [String: UUID]
|
||||
let namesByUUID: [UUID: String]
|
||||
|
||||
var hasEntries: Bool {
|
||||
!byAddress.isEmpty || !byName.isEmpty
|
||||
}
|
||||
|
||||
static let empty = CoreBluetoothCacheSnapshot(byAddress: [:], byName: [:], namesByUUID: [:])
|
||||
}
|
||||
|
||||
private func batteryLevelFromDefaults(forAddress address: String?) -> Int? {
|
||||
guard let address, !address.isEmpty else { return nil }
|
||||
guard let preferences = UserDefaults(suiteName: bluetoothPreferencesSuite) else { return nil }
|
||||
|
|
@ -1004,11 +1192,7 @@ class BluetoothAudioManager: ObservableObject {
|
|||
print("🎧 [BluetoothAudioManager] 📱 Showing device connected HUD")
|
||||
|
||||
// Convert battery percentage to 0.0-1.0 value
|
||||
let reportedBattery = device.batteryLevel
|
||||
?? batteryLevelFromRegistry(forAddress: device.address)
|
||||
?? batteryLevelFromRegistry(forName: device.name)
|
||||
?? batteryLevelFromDefaults(forAddress: device.address)
|
||||
?? batteryLevelFromDefaults(forName: device.name)
|
||||
let reportedBattery = bestBatteryLevel(for: device)
|
||||
|
||||
let batteryValue: CGFloat = if let battery = reportedBattery {
|
||||
CGFloat(clampBatteryPercentage(battery)) / 100.0
|
||||
|
|
@ -1048,6 +1232,262 @@ class BluetoothAudioManager: ObservableObject {
|
|||
}
|
||||
}
|
||||
|
||||
// MARK: - CoreBluetooth Battery Reader
|
||||
|
||||
private final class BluetoothLEBatteryReader: NSObject, CBCentralManagerDelegate, CBPeripheralDelegate {
|
||||
struct Lookup {
|
||||
let uuid: UUID
|
||||
let addressKey: String?
|
||||
let nameKey: String?
|
||||
}
|
||||
|
||||
struct Result {
|
||||
let uuid: UUID
|
||||
let level: Int
|
||||
let addressKey: String?
|
||||
let nameKey: String?
|
||||
}
|
||||
|
||||
private enum State {
|
||||
case idle
|
||||
case requesting
|
||||
}
|
||||
|
||||
private static let batteryServiceUUID = CBUUID(string: "180F")
|
||||
private static let batteryCharacteristicUUID = CBUUID(string: "2A19")
|
||||
|
||||
private let timeoutInterval: TimeInterval = 6.0
|
||||
|
||||
private var central: CBCentralManager!
|
||||
private var state: State = .idle
|
||||
private var pendingLookups: [Lookup] = []
|
||||
private var lookupByUUID: [UUID: Lookup] = [:]
|
||||
private var completion: (([Result]) -> Void)?
|
||||
private var pendingPeripherals: [UUID: CBPeripheral] = [:]
|
||||
private var results: [UUID: Result] = [:]
|
||||
private var missingUUIDs: Set<UUID> = []
|
||||
private var timeoutWorkItem: DispatchWorkItem?
|
||||
|
||||
override init() {
|
||||
super.init()
|
||||
central = CBCentralManager(delegate: self, queue: nil)
|
||||
}
|
||||
|
||||
func fetchBatteryLevels(for lookups: [Lookup], completion: @escaping ([Result]) -> Void) {
|
||||
guard !lookups.isEmpty else {
|
||||
completion([])
|
||||
return
|
||||
}
|
||||
|
||||
guard state == .idle else {
|
||||
completion([])
|
||||
return
|
||||
}
|
||||
|
||||
state = .requesting
|
||||
pendingLookups = lookups
|
||||
lookupByUUID = Dictionary(uniqueKeysWithValues: lookups.map { ($0.uuid, $0) })
|
||||
self.completion = completion
|
||||
results.removeAll()
|
||||
pendingPeripherals.removeAll()
|
||||
missingUUIDs = Set(lookups.map { $0.uuid })
|
||||
|
||||
switch central.state {
|
||||
case .poweredOn:
|
||||
startRequest()
|
||||
case .unauthorized, .unsupported, .poweredOff:
|
||||
complete(with: [])
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - CBCentralManagerDelegate
|
||||
|
||||
func centralManagerDidUpdateState(_ central: CBCentralManager) {
|
||||
guard state == .requesting else { return }
|
||||
|
||||
switch central.state {
|
||||
case .poweredOn:
|
||||
startRequest()
|
||||
case .unauthorized, .unsupported, .poweredOff:
|
||||
complete(with: [])
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) {
|
||||
peripheral.discoverServices([Self.batteryServiceUUID])
|
||||
}
|
||||
|
||||
func centralManager(_ central: CBCentralManager, didFailToConnect peripheral: CBPeripheral, error: Error?) {
|
||||
markPeripheralFinished(peripheral.identifier)
|
||||
}
|
||||
|
||||
func centralManager(_ central: CBCentralManager, didDisconnectPeripheral peripheral: CBPeripheral, error: Error?) {
|
||||
guard state == .requesting else { return }
|
||||
markPeripheralFinished(peripheral.identifier)
|
||||
}
|
||||
|
||||
func centralManager(_ central: CBCentralManager, didDiscover peripheral: CBPeripheral, advertisementData: [String: Any], rssi RSSI: NSNumber) {
|
||||
guard state == .requesting else { return }
|
||||
guard missingUUIDs.contains(peripheral.identifier) else { return }
|
||||
|
||||
missingUUIDs.remove(peripheral.identifier)
|
||||
configurePeripheral(peripheral)
|
||||
}
|
||||
|
||||
// MARK: - CBPeripheralDelegate
|
||||
|
||||
func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: Error?) {
|
||||
guard state == .requesting else { return }
|
||||
|
||||
if let error {
|
||||
print("🎧 [BluetoothLEBatteryReader] Service discovery failed: \(error.localizedDescription)")
|
||||
markPeripheralFinished(peripheral.identifier)
|
||||
return
|
||||
}
|
||||
|
||||
guard let service = peripheral.services?.first(where: { $0.uuid == Self.batteryServiceUUID }) else {
|
||||
markPeripheralFinished(peripheral.identifier)
|
||||
return
|
||||
}
|
||||
|
||||
peripheral.discoverCharacteristics([Self.batteryCharacteristicUUID], for: service)
|
||||
}
|
||||
|
||||
func peripheral(_ peripheral: CBPeripheral, didDiscoverCharacteristicsFor service: CBService, error: Error?) {
|
||||
guard state == .requesting else { return }
|
||||
|
||||
if let error {
|
||||
print("🎧 [BluetoothLEBatteryReader] Characteristic discovery failed: \(error.localizedDescription)")
|
||||
markPeripheralFinished(peripheral.identifier)
|
||||
return
|
||||
}
|
||||
|
||||
guard let characteristic = service.characteristics?.first(where: { $0.uuid == Self.batteryCharacteristicUUID }) else {
|
||||
markPeripheralFinished(peripheral.identifier)
|
||||
return
|
||||
}
|
||||
|
||||
peripheral.readValue(for: characteristic)
|
||||
}
|
||||
|
||||
func peripheral(_ peripheral: CBPeripheral, didUpdateValueFor characteristic: CBCharacteristic, error: Error?) {
|
||||
guard state == .requesting else { return }
|
||||
|
||||
defer { markPeripheralFinished(peripheral.identifier) }
|
||||
|
||||
if let error {
|
||||
print("🎧 [BluetoothLEBatteryReader] Battery read failed: \(error.localizedDescription)")
|
||||
return
|
||||
}
|
||||
|
||||
guard let data = characteristic.value, let byte = data.first, let lookup = lookupByUUID[peripheral.identifier] else {
|
||||
return
|
||||
}
|
||||
|
||||
let level = Int(byte)
|
||||
results[peripheral.identifier] = Result(
|
||||
uuid: peripheral.identifier,
|
||||
level: level,
|
||||
addressKey: lookup.addressKey,
|
||||
nameKey: lookup.nameKey
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private func startRequest() {
|
||||
central.stopScan()
|
||||
|
||||
let identifiers = Array(missingUUIDs)
|
||||
if !identifiers.isEmpty {
|
||||
let peripherals = central.retrievePeripherals(withIdentifiers: identifiers)
|
||||
for peripheral in peripherals {
|
||||
missingUUIDs.remove(peripheral.identifier)
|
||||
configurePeripheral(peripheral)
|
||||
}
|
||||
}
|
||||
|
||||
let connectedPeripherals = central.retrieveConnectedPeripherals(withServices: [Self.batteryServiceUUID])
|
||||
for peripheral in connectedPeripherals where missingUUIDs.contains(peripheral.identifier) {
|
||||
missingUUIDs.remove(peripheral.identifier)
|
||||
configurePeripheral(peripheral)
|
||||
}
|
||||
|
||||
if !missingUUIDs.isEmpty {
|
||||
central.scanForPeripherals(withServices: [Self.batteryServiceUUID], options: [CBCentralManagerScanOptionAllowDuplicatesKey: false])
|
||||
}
|
||||
|
||||
if pendingPeripherals.isEmpty && missingUUIDs.isEmpty {
|
||||
complete(with: Array(results.values))
|
||||
return
|
||||
}
|
||||
|
||||
scheduleTimeout()
|
||||
}
|
||||
|
||||
private func configurePeripheral(_ peripheral: CBPeripheral) {
|
||||
pendingPeripherals[peripheral.identifier] = peripheral
|
||||
peripheral.delegate = self
|
||||
|
||||
switch peripheral.state {
|
||||
case .connected:
|
||||
peripheral.discoverServices([Self.batteryServiceUUID])
|
||||
default:
|
||||
central.connect(peripheral, options: nil)
|
||||
}
|
||||
}
|
||||
|
||||
private func markPeripheralFinished(_ identifier: UUID) {
|
||||
pendingPeripherals.removeValue(forKey: identifier)
|
||||
missingUUIDs.remove(identifier)
|
||||
|
||||
if missingUUIDs.isEmpty {
|
||||
central.stopScan()
|
||||
}
|
||||
|
||||
if pendingPeripherals.isEmpty && missingUUIDs.isEmpty {
|
||||
complete(with: Array(results.values))
|
||||
}
|
||||
}
|
||||
|
||||
private func scheduleTimeout() {
|
||||
cancelTimeout()
|
||||
let workItem = DispatchWorkItem { [weak self] in
|
||||
guard let self else { return }
|
||||
self.complete(with: Array(self.results.values))
|
||||
}
|
||||
timeoutWorkItem = workItem
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + timeoutInterval, execute: workItem)
|
||||
}
|
||||
|
||||
private func cancelTimeout() {
|
||||
timeoutWorkItem?.cancel()
|
||||
timeoutWorkItem = nil
|
||||
}
|
||||
|
||||
private func complete(with results: [Result]) {
|
||||
guard state == .requesting else { return }
|
||||
cancelTimeout()
|
||||
central.stopScan()
|
||||
state = .idle
|
||||
|
||||
pendingPeripherals.removeAll()
|
||||
missingUUIDs.removeAll()
|
||||
pendingLookups.removeAll()
|
||||
lookupByUUID.removeAll()
|
||||
|
||||
let completion = self.completion
|
||||
self.completion = nil
|
||||
self.results.removeAll()
|
||||
|
||||
completion?(results)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Models
|
||||
|
||||
struct BluetoothAudioDevice: Identifiable {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue