mirror of
https://github.com/FBukovina/Atoll.git
synced 2026-10-27 15:14:33 +00:00
BugFix: Bluetooth Battery Percentage Report
This commit is contained in:
parent
1d61cf514e
commit
c555aa40a6
1 changed files with 163 additions and 5 deletions
|
|
@ -40,6 +40,10 @@ class BluetoothAudioManager: ObservableObject {
|
||||||
private var missingBatteryLog: Set<String> = []
|
private var missingBatteryLog: Set<String> = []
|
||||||
private var lastBatteryStatusUpdate: Date?
|
private var lastBatteryStatusUpdate: Date?
|
||||||
private let batteryStatusUpdateInterval: TimeInterval = 20
|
private let batteryStatusUpdateInterval: TimeInterval = 20
|
||||||
|
private let pmsetFetchQueue = DispatchQueue(label: "com.dynamicisland.bluetooth.pmset", qos: .utility)
|
||||||
|
private var isPmsetRefreshInFlight = false
|
||||||
|
private var lastPmsetRefreshDate: Date?
|
||||||
|
private let pmsetRefreshCooldown: TimeInterval = 5
|
||||||
|
|
||||||
// MARK: - Initialization
|
// MARK: - Initialization
|
||||||
private init() {
|
private init() {
|
||||||
|
|
@ -423,7 +427,7 @@ class BluetoothAudioManager: ObservableObject {
|
||||||
triggerLiveBatteryRefreshIfNeeded()
|
triggerLiveBatteryRefreshIfNeeded()
|
||||||
}
|
}
|
||||||
|
|
||||||
private func applyConnectedDeviceBatteryLevels() {
|
private func applyConnectedDeviceBatteryLevels(triggerPmsetFallback: Bool = true) {
|
||||||
guard !connectedDevices.isEmpty else {
|
guard !connectedDevices.isEmpty else {
|
||||||
lastConnectedDevice = nil
|
lastConnectedDevice = nil
|
||||||
return
|
return
|
||||||
|
|
@ -446,6 +450,11 @@ class BluetoothAudioManager: ObservableObject {
|
||||||
if let last = updatedDevices.last {
|
if let last = updatedDevices.last {
|
||||||
lastConnectedDevice = last
|
lastConnectedDevice = last
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if triggerPmsetFallback,
|
||||||
|
updatedDevices.contains(where: { $0.batteryLevel == nil }) {
|
||||||
|
requestPmsetFallback(reason: "missing battery after refresh")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func bestBatteryLevel(for device: BluetoothAudioDevice) -> Int? {
|
private func bestBatteryLevel(for device: BluetoothAudioDevice) -> Int? {
|
||||||
|
|
@ -456,6 +465,44 @@ class BluetoothAudioManager: ObservableObject {
|
||||||
?? device.batteryLevel
|
?? device.batteryLevel
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func requestPmsetFallback(reason: String) {
|
||||||
|
guard connectedDevices.contains(where: { $0.batteryLevel == nil }) else { return }
|
||||||
|
guard !isPmsetRefreshInFlight else { return }
|
||||||
|
|
||||||
|
let now = Date()
|
||||||
|
if let lastPmsetRefreshDate,
|
||||||
|
now.timeIntervalSince(lastPmsetRefreshDate) < pmsetRefreshCooldown {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
isPmsetRefreshInFlight = true
|
||||||
|
print("🎧 [BluetoothAudioManager] 🔄 Triggering pmset fallback (\(reason))")
|
||||||
|
pmsetFetchQueue.async { [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
|
let entries = self.collectPmsetAccessoryBatteryEntries()
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
self.handlePmsetFallbackResults(entries)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func handlePmsetFallbackResults(_ entries: [PmsetAccessoryBatteryEntry]) {
|
||||||
|
isPmsetRefreshInFlight = false
|
||||||
|
lastPmsetRefreshDate = Date()
|
||||||
|
guard !entries.isEmpty else { return }
|
||||||
|
|
||||||
|
var updatedNames = batteryStatusByName
|
||||||
|
let newlyFilled = mergePmsetEntries(entries, into: &updatedNames, logNewEntries: true)
|
||||||
|
guard !newlyFilled.isEmpty else { return }
|
||||||
|
|
||||||
|
batteryStatusByName = updatedNames
|
||||||
|
applyConnectedDeviceBatteryLevels(triggerPmsetFallback: false)
|
||||||
|
|
||||||
|
if let level = hudBatteryLevelCandidate() {
|
||||||
|
updateActiveBluetoothHUDBattery(with: level)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private func triggerLiveBatteryRefreshIfNeeded() {
|
private func triggerLiveBatteryRefreshIfNeeded() {
|
||||||
guard !connectedDevices.isEmpty else { return }
|
guard !connectedDevices.isEmpty else { return }
|
||||||
guard connectedDevices.contains(where: { $0.batteryLevel == nil }) else { return }
|
guard connectedDevices.contains(where: { $0.batteryLevel == nil }) else { return }
|
||||||
|
|
@ -534,10 +581,8 @@ class BluetoothAudioManager: ObservableObject {
|
||||||
guard didUpdate else { return }
|
guard didUpdate else { return }
|
||||||
|
|
||||||
applyConnectedDeviceBatteryLevels()
|
applyConnectedDeviceBatteryLevels()
|
||||||
if let lastLevel = lastConnectedDevice?.batteryLevel {
|
if let level = hudBatteryLevelCandidate() {
|
||||||
updateActiveBluetoothHUDBattery(with: lastLevel)
|
updateActiveBluetoothHUDBattery(with: level)
|
||||||
} else if let fallbackLevel = connectedDevices.last(where: { $0.batteryLevel != nil })?.batteryLevel {
|
|
||||||
updateActiveBluetoothHUDBattery(with: fallbackLevel)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -550,6 +595,11 @@ class BluetoothAudioManager: ObservableObject {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func hudBatteryLevelCandidate() -> Int? {
|
||||||
|
lastConnectedDevice?.batteryLevel
|
||||||
|
?? connectedDevices.last(where: { $0.batteryLevel != nil })?.batteryLevel
|
||||||
|
}
|
||||||
|
|
||||||
private func coreBluetoothCacheSnapshot() -> CoreBluetoothCacheSnapshot {
|
private func coreBluetoothCacheSnapshot() -> CoreBluetoothCacheSnapshot {
|
||||||
guard let preferences = UserDefaults(suiteName: bluetoothPreferencesSuite),
|
guard let preferences = UserDefaults(suiteName: bluetoothPreferencesSuite),
|
||||||
let coreCache = preferences.object(forKey: "CoreBluetoothCache") as? [String: [String: Any]] else {
|
let coreCache = preferences.object(forKey: "CoreBluetoothCache") as? [String: [String: Any]] else {
|
||||||
|
|
@ -626,6 +676,46 @@ class BluetoothAudioManager: ObservableObject {
|
||||||
static let empty = CoreBluetoothCacheSnapshot(byAddress: [:], byName: [:], namesByUUID: [:])
|
static let empty = CoreBluetoothCacheSnapshot(byAddress: [:], byName: [:], namesByUUID: [:])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private struct PmsetAccessoryBatteryEntry {
|
||||||
|
let displayName: String
|
||||||
|
let normalizedName: String
|
||||||
|
let level: Int
|
||||||
|
}
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
private func mergePmsetEntries(
|
||||||
|
_ entries: [PmsetAccessoryBatteryEntry],
|
||||||
|
into names: inout [String: Int],
|
||||||
|
logNewEntries: Bool
|
||||||
|
) -> [PmsetAccessoryBatteryEntry] {
|
||||||
|
guard !entries.isEmpty else { return [] }
|
||||||
|
|
||||||
|
var newlyFilled: [PmsetAccessoryBatteryEntry] = []
|
||||||
|
|
||||||
|
for entry in entries {
|
||||||
|
let clamped = clampBatteryPercentage(entry.level)
|
||||||
|
let previous = names[entry.normalizedName]
|
||||||
|
|
||||||
|
if previous == nil {
|
||||||
|
newlyFilled.append(entry)
|
||||||
|
names[entry.normalizedName] = clamped
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if let previous, clamped > previous {
|
||||||
|
names[entry.normalizedName] = clamped
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if logNewEntries {
|
||||||
|
for entry in newlyFilled {
|
||||||
|
print("🎧 [BluetoothAudioManager] ℹ️ pmset reported \(entry.level)% for \(entry.displayName)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return newlyFilled
|
||||||
|
}
|
||||||
|
|
||||||
private func batteryLevelFromDefaults(forAddress address: String?) -> Int? {
|
private func batteryLevelFromDefaults(forAddress address: String?) -> Int? {
|
||||||
guard let address, !address.isEmpty else { return nil }
|
guard let address, !address.isEmpty else { return nil }
|
||||||
guard let preferences = UserDefaults(suiteName: bluetoothPreferencesSuite) else { return nil }
|
guard let preferences = UserDefaults(suiteName: bluetoothPreferencesSuite) else { return nil }
|
||||||
|
|
@ -701,6 +791,9 @@ class BluetoothAudioManager: ObservableObject {
|
||||||
mergeBatteryLevels(into: &combinedAddressPercentages, from: profiler.addresses)
|
mergeBatteryLevels(into: &combinedAddressPercentages, from: profiler.addresses)
|
||||||
mergeBatteryLevels(into: &combinedNamePercentages, from: profiler.names)
|
mergeBatteryLevels(into: &combinedNamePercentages, from: profiler.names)
|
||||||
|
|
||||||
|
let pmsetEntries = collectPmsetAccessoryBatteryEntries()
|
||||||
|
mergePmsetEntries(pmsetEntries, into: &combinedNamePercentages, logNewEntries: true)
|
||||||
|
|
||||||
var statuses: [String: String] = [:]
|
var statuses: [String: String] = [:]
|
||||||
for (key, value) in combinedAddressPercentages {
|
for (key, value) in combinedAddressPercentages {
|
||||||
statuses[key] = String(clampBatteryPercentage(value))
|
statuses[key] = String(clampBatteryPercentage(value))
|
||||||
|
|
@ -867,6 +960,71 @@ class BluetoothAudioManager: ObservableObject {
|
||||||
return (addressPercentages, namePercentages)
|
return (addressPercentages, namePercentages)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func collectPmsetAccessoryBatteryEntries() -> [PmsetAccessoryBatteryEntry] {
|
||||||
|
let process = Process()
|
||||||
|
process.launchPath = "/usr/bin/pmset"
|
||||||
|
process.arguments = ["-g", "accps"]
|
||||||
|
|
||||||
|
let outputPipe = Pipe()
|
||||||
|
process.standardOutput = outputPipe
|
||||||
|
process.standardError = Pipe()
|
||||||
|
|
||||||
|
do {
|
||||||
|
try process.run()
|
||||||
|
} catch {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
let data = outputPipe.fileHandleForReading.readDataToEndOfFile()
|
||||||
|
process.waitUntilExit()
|
||||||
|
|
||||||
|
guard process.terminationStatus == 0 else {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
guard !data.isEmpty, let output = String(data: data, encoding: .utf8) else {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let regex = try? NSRegularExpression(
|
||||||
|
pattern: #"^\s*-\s*(.+?)\s*(?:\(.+?\))?\s+(\d+)\s*%"#,
|
||||||
|
options: [.anchorsMatchLines]
|
||||||
|
) else {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
var entries: [PmsetAccessoryBatteryEntry] = []
|
||||||
|
let nsOutput = output as NSString
|
||||||
|
let range = NSRange(location: 0, length: nsOutput.length)
|
||||||
|
|
||||||
|
regex.enumerateMatches(in: output, options: [], range: range) { match, _, _ in
|
||||||
|
guard let match, match.numberOfRanges >= 3 else { return }
|
||||||
|
|
||||||
|
let rawName = nsOutput
|
||||||
|
.substring(with: match.range(at: 1))
|
||||||
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
let percentString = nsOutput.substring(with: match.range(at: 2))
|
||||||
|
|
||||||
|
guard !rawName.isEmpty, let level = Int(percentString) else { return }
|
||||||
|
|
||||||
|
let normalizedName = normalizeProductName(rawName)
|
||||||
|
guard !normalizedName.isEmpty else { return }
|
||||||
|
if normalizedName.hasPrefix("internalbattery") {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
entries.append(
|
||||||
|
PmsetAccessoryBatteryEntry(
|
||||||
|
displayName: rawName,
|
||||||
|
normalizedName: normalizedName,
|
||||||
|
level: level
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return entries
|
||||||
|
}
|
||||||
|
|
||||||
private func identifiersFromDeviceCachePayload(_ payload: [String: Any]) -> [String] {
|
private func identifiersFromDeviceCachePayload(_ payload: [String: Any]) -> [String] {
|
||||||
var identifiers: Set<String> = []
|
var identifiers: Set<String> = []
|
||||||
let candidateKeys = ["DeviceAddress", "Address", "BD_ADDR", "SerialNumber"]
|
let candidateKeys = ["DeviceAddress", "Address", "BD_ADDR", "SerialNumber"]
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue