Working battery percentage

This commit is contained in:
Hariharan Mudaliar 2025-10-26 22:56:57 +05:30
commit a75497bbbf
6 changed files with 1014 additions and 34 deletions

View file

@ -12,6 +12,7 @@ import AppKit
import Defaults
import SwiftUI
import IOBluetooth
import IOKit
/// Manages detection and monitoring of Bluetooth audio device connections
class BluetoothAudioManager: ObservableObject {
@ -27,6 +28,15 @@ class BluetoothAudioManager: ObservableObject {
private var cancellables = Set<AnyCancellable>()
private let coordinator = DynamicIslandViewCoordinator.shared
private var pollingTimer: Timer?
private let bluetoothPreferencesSuite = "/Library/Preferences/com.apple.Bluetooth"
@Published private(set) var batteryStatus: [String: String] = [:]
private var batteryStatusByAddress: [String: Int] = [:]
private var batteryStatusByName: [String: Int] = [:]
private var missingBatteryLog: Set<String> = []
private var lastBatteryStatusUpdate: Date?
private let batteryStatusUpdateInterval: TimeInterval = 20
// MARK: - Initialization
private init() {
@ -145,6 +155,8 @@ class BluetoothAudioManager: ObservableObject {
// Update connection state
isBluetoothAudioConnected = !connectedDevices.isEmpty
refreshBatteryLevelsForConnectedDevices()
if let lastDevice = connectedDevices.last {
lastConnectedDevice = lastDevice
print("🎧 [BluetoothAudioManager] ✅ Bluetooth audio connected: \(lastDevice.name)")
@ -201,9 +213,15 @@ class BluetoothAudioManager: ObservableObject {
connectedDevices.append(audioDevice)
lastConnectedDevice = audioDevice
isBluetoothAudioConnected = true
refreshBatteryLevelsForConnectedDevices()
// Show HUD for new connection
showDeviceConnectedHUD(audioDevice)
if let refreshedDevice = connectedDevices.last {
showDeviceConnectedHUD(refreshedDevice)
} else {
showDeviceConnectedHUD(audioDevice)
}
}
}
}
@ -229,6 +247,8 @@ class BluetoothAudioManager: ObservableObject {
}
isBluetoothAudioConnected = !connectedDevices.isEmpty
refreshBatteryLevelsForConnectedDevices()
}
/// Handles Bluetooth device connection event (legacy - kept for compatibility)
@ -326,11 +346,29 @@ class BluetoothAudioManager: ObservableObject {
/// Extracts battery level from Bluetooth device
private func getBatteryLevel(from device: IOBluetoothDevice) -> Int? {
// Try to read battery level from device
// Note: This may require additional permissions or may not be available for all devices
// Placeholder: Return nil if not available
// TODO: Implement actual battery level reading if device supports it
updateBatteryStatuses()
if let level = batteryLevelFromRegistry(forAddress: device.addressString) {
clearMissingBatteryInfo(for: device)
return level
}
if let name = device.name, let level = batteryLevelFromRegistry(forName: name) {
clearMissingBatteryInfo(for: device)
return level
}
if let level = batteryLevelFromDefaults(forAddress: device.addressString) {
clearMissingBatteryInfo(for: device)
return level
}
if let name = device.name, let level = batteryLevelFromDefaults(forName: name) {
clearMissingBatteryInfo(for: device)
return level
}
logMissingBatteryInfo(for: device)
return nil
}
@ -372,6 +410,590 @@ class BluetoothAudioManager: ObservableObject {
default: return .generic
}
}
private func refreshBatteryLevelsForConnectedDevices() {
updateBatteryStatuses(force: true)
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 updatedDevice = device.withBatteryLevel(refreshedLevel)
updatedDevices.append(updatedDevice)
if let refreshedLevel {
clearMissingBatteryInfo(forName: device.name, address: device.address)
} else {
logMissingBatteryInfo(forName: device.name, address: device.address)
}
}
connectedDevices = updatedDevices
if let last = connectedDevices.last {
lastConnectedDevice = last
}
}
private func batteryLevelFromDefaults(forAddress address: String?) -> Int? {
guard let address, !address.isEmpty else { return nil }
guard let preferences = UserDefaults(suiteName: bluetoothPreferencesSuite) else { return nil }
guard let deviceCache = preferences.object(forKey: "DeviceCache") as? [String: Any] else { return nil }
let normalizedTarget = normalizeBluetoothIdentifier(address)
var bestMatch: Int?
for (key, value) in deviceCache {
guard let payload = value as? [String: Any] else { continue }
if matchesBluetoothIdentifier(normalizedTarget, key: key, payload: payload) {
if let level = extractBatteryPercentage(from: payload) {
let clamped = clampBatteryPercentage(level)
bestMatch = max(bestMatch ?? clamped, clamped)
}
}
}
return bestMatch
}
private func batteryLevelFromDefaults(forName name: String) -> Int? {
guard !name.isEmpty else { return nil }
guard let preferences = UserDefaults(suiteName: bluetoothPreferencesSuite) else { return nil }
guard let deviceCache = preferences.object(forKey: "DeviceCache") as? [String: Any] else { return nil }
var bestMatch: Int?
for value in deviceCache.values {
guard let payload = value as? [String: Any] else { continue }
let candidateName = (payload["Name"] as? String) ?? (payload["DeviceName"] as? String)
if let candidateName, candidateName.caseInsensitiveCompare(name) == .orderedSame {
if let level = extractBatteryPercentage(from: payload) {
let clamped = clampBatteryPercentage(level)
bestMatch = max(bestMatch ?? clamped, clamped)
}
}
}
return bestMatch
}
private func batteryLevelFromRegistry(forName name: String) -> Int? {
let trimmed = name.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return nil }
let normalized = normalizeProductName(trimmed)
guard !normalized.isEmpty else { return nil }
if let value = batteryStatusByName[normalized] {
return clampBatteryPercentage(value)
}
return nil
}
private func updateBatteryStatuses(force: Bool = false) {
let now = Date()
if !force, let lastBatteryStatusUpdate,
now.timeIntervalSince(lastBatteryStatusUpdate) < batteryStatusUpdateInterval {
return
}
var combinedAddressPercentages: [String: Int] = [:]
var combinedNamePercentages: [String: Int] = [:]
let registry = collectRegistryBatteryLevels()
mergeBatteryLevels(into: &combinedAddressPercentages, from: registry.addresses)
mergeBatteryLevels(into: &combinedNamePercentages, from: registry.names)
let defaults = collectDefaultsBatteryLevels()
mergeBatteryLevels(into: &combinedAddressPercentages, from: defaults.addresses)
mergeBatteryLevels(into: &combinedNamePercentages, from: defaults.names)
let profiler = collectSystemProfilerBatteryLevels()
mergeBatteryLevels(into: &combinedAddressPercentages, from: profiler.addresses)
mergeBatteryLevels(into: &combinedNamePercentages, from: profiler.names)
var statuses: [String: String] = [:]
for (key, value) in combinedAddressPercentages {
statuses[key] = String(clampBatteryPercentage(value))
}
let applyUpdates = {
self.batteryStatus = statuses
self.batteryStatusByAddress = combinedAddressPercentages
self.batteryStatusByName = combinedNamePercentages
self.lastBatteryStatusUpdate = now
}
if Thread.isMainThread {
applyUpdates()
} else {
DispatchQueue.main.sync(execute: applyUpdates)
}
}
private func mergeBatteryLevels(into target: inout [String: Int], from source: [String: Int]) {
guard !source.isEmpty else { return }
for (key, value) in source {
guard !key.isEmpty else { continue }
if let existing = target[key] {
target[key] = max(existing, value)
} else {
target[key] = value
}
}
}
private func collectRegistryBatteryLevels() -> (addresses: [String: Int], names: [String: Int]) {
var addressPercentages: [String: Int] = [:]
var namePercentages: [String: Int] = [:]
var iterator = io_iterator_t()
let matchingDict: CFDictionary = IOServiceMatching("AppleDeviceManagementHIDEventService")
let servicePort: mach_port_t
if #available(macOS 12.0, *) {
servicePort = kIOMainPortDefault
} else {
servicePort = kIOMasterPortDefault
}
let kernResult = IOServiceGetMatchingServices(servicePort, matchingDict, &iterator)
if kernResult == KERN_SUCCESS {
var entry: io_object_t = IOIteratorNext(iterator)
while entry != 0 {
if let percent = IORegistryEntryCreateCFProperty(entry, "BatteryPercent" as CFString, kCFAllocatorDefault, 0)?.takeRetainedValue() as? Int {
let normalizedPercent = clampBatteryPercentage(percent)
let identifierKeys = ["DeviceAddress", "SerialNumber", "BD_ADDR"]
for key in identifierKeys {
if let identifier = stringValue(forKey: key, entry: entry) {
let normalizedIdentifier = normalizeBluetoothIdentifier(identifier)
if !normalizedIdentifier.isEmpty {
if let existing = addressPercentages[normalizedIdentifier] {
addressPercentages[normalizedIdentifier] = max(existing, normalizedPercent)
} else {
addressPercentages[normalizedIdentifier] = normalizedPercent
}
}
}
}
let nameKeys = [
"Product",
"ProductName",
"DeviceName",
"Name",
"USB Product Name",
"Bluetooth Product Name"
]
for key in nameKeys {
if let product = stringValue(forKey: key, entry: entry) {
let normalizedName = normalizeProductName(product)
if !normalizedName.isEmpty {
if let existing = namePercentages[normalizedName] {
namePercentages[normalizedName] = max(existing, normalizedPercent)
} else {
namePercentages[normalizedName] = normalizedPercent
}
}
}
}
}
IOObjectRelease(entry)
entry = IOIteratorNext(iterator)
}
}
IOObjectRelease(iterator)
return (addressPercentages, namePercentages)
}
private func collectDefaultsBatteryLevels() -> (addresses: [String: Int], names: [String: Int]) {
guard let preferences = UserDefaults(suiteName: bluetoothPreferencesSuite),
let deviceCache = preferences.object(forKey: "DeviceCache") as? [String: Any] else {
return ([:], [:])
}
var addressPercentages: [String: Int] = [:]
var namePercentages: [String: Int] = [:]
for (key, value) in deviceCache {
guard let payload = value as? [String: Any] else { continue }
guard let level = extractBatteryPercentage(from: payload) else { continue }
let clamped = clampBatteryPercentage(level)
let normalizedKey = normalizeBluetoothIdentifier(key)
if !normalizedKey.isEmpty {
addressPercentages[normalizedKey] = max(addressPercentages[normalizedKey] ?? clamped, clamped)
}
for identifier in identifiersFromDeviceCachePayload(payload) {
addressPercentages[identifier] = max(addressPercentages[identifier] ?? clamped, clamped)
}
if let name = (payload["Name"] as? String) ?? (payload["DeviceName"] as? String) {
let normalizedName = normalizeProductName(name)
if !normalizedName.isEmpty {
namePercentages[normalizedName] = max(namePercentages[normalizedName] ?? clamped, clamped)
}
}
}
return (addressPercentages, namePercentages)
}
private func collectSystemProfilerBatteryLevels() -> (addresses: [String: Int], names: [String: Int]) {
guard let root = systemProfilerBluetoothDictionary() else {
return ([:], [:])
}
var addressPercentages: [String: Int] = [:]
var namePercentages: [String: Int] = [:]
if let connectedList = root["device_connected"] as? [[String: [String: Any]]] {
for deviceGroup in connectedList {
for (rawName, payload) in deviceGroup {
guard let percent = extractSystemProfilerBatteryPercentage(from: payload) else { continue }
let clamped = clampBatteryPercentage(percent)
let normalizedName = normalizeProductName(rawName)
if !normalizedName.isEmpty {
namePercentages[normalizedName] = max(namePercentages[normalizedName] ?? clamped, clamped)
}
for address in profilerAddressCandidates(from: payload) {
let normalizedAddress = normalizeBluetoothIdentifier(address)
if !normalizedAddress.isEmpty {
addressPercentages[normalizedAddress] = max(addressPercentages[normalizedAddress] ?? clamped, clamped)
}
}
}
}
}
return (addressPercentages, namePercentages)
}
private func identifiersFromDeviceCachePayload(_ payload: [String: Any]) -> [String] {
var identifiers: Set<String> = []
let candidateKeys = ["DeviceAddress", "Address", "BD_ADDR", "SerialNumber"]
for key in candidateKeys {
if let value = payload[key] as? String {
let normalized = normalizeBluetoothIdentifier(value)
if !normalized.isEmpty {
identifiers.insert(normalized)
}
} else if let data = payload[key] as? Data,
let ascii = String(data: data, encoding: .utf8) {
let normalized = normalizeBluetoothIdentifier(ascii)
if !normalized.isEmpty {
identifiers.insert(normalized)
}
}
}
return Array(identifiers)
}
private func systemProfilerBluetoothDictionary() -> [String: Any]? {
let process = Process()
process.launchPath = "/usr/sbin/system_profiler"
process.arguments = ["SPBluetoothDataType", "-json"]
let outputPipe = Pipe()
process.standardOutput = outputPipe
let errorPipe = Pipe()
process.standardError = errorPipe
do {
try process.run()
} catch {
return nil
}
let data = outputPipe.fileHandleForReading.readDataToEndOfFile()
process.waitUntilExit()
guard process.terminationStatus == 0 else {
return nil
}
guard !data.isEmpty else { return nil }
guard let jsonObject = try? JSONSerialization.jsonObject(with: data, options: []) as? [String: Any],
let entries = jsonObject["SPBluetoothDataType"] as? [[String: Any]],
let root = entries.first else {
return nil
}
return root
}
private func extractSystemProfilerBatteryPercentage(from payload: [String: Any]) -> Int? {
let preferredKeys = [
"device_batteryLevelCase",
"device_batteryLevelLeft",
"device_batteryLevelRight",
"device_batteryLevelMain",
"device_batteryLevel",
"device_batteryLevelCombined",
"device_batteryPercentCombined",
"Left Battery Level",
"Right Battery Level",
"Battery Level",
"BatteryPercent"
]
var values: [Int] = []
for key in preferredKeys {
if let raw = payload[key], let converted = convertToBatteryPercentage(raw) {
values.append(converted)
}
}
if values.isEmpty {
for (key, raw) in payload where key.lowercased().contains("battery") {
if let converted = convertToBatteryPercentage(raw) {
values.append(converted)
}
}
}
let validValues = values.filter { $0 >= 0 }
return validValues.max()
}
private func profilerAddressCandidates(from payload: [String: Any]) -> [String] {
var addresses: Set<String> = []
let keys = [
"device_address",
"device_mac_address",
"device_serial_num",
"device_serialNumber",
"device_serial_number"
]
for key in keys {
if let value = payload[key] as? String, !value.isEmpty {
addresses.insert(value)
} else if let data = payload[key] as? Data,
let ascii = String(data: data, encoding: .utf8), !ascii.isEmpty {
addresses.insert(ascii)
}
}
return Array(addresses)
}
private func batteryLevelFromRegistry(forAddress address: String?) -> Int? {
guard let address, !address.isEmpty else { return nil }
let normalized = normalizeBluetoothIdentifier(address)
if let value = batteryStatusByAddress[normalized] {
return clampBatteryPercentage(value)
}
if let storedValue = batteryStatus[normalized], let value = Int(storedValue) {
return clampBatteryPercentage(value)
}
return nil
}
private func extractBatteryPercentage(from payload: [String: Any]) -> Int? {
let keys = [
"BatteryPercent",
"BatteryPercentCase",
"BatteryPercentLeft",
"BatteryPercentRight",
"BatteryPercentSingle",
"BatteryPercentCombined",
"BatteryPercentMain",
"device_batteryLevelLeft",
"device_batteryLevelRight",
"device_batteryLevelMain",
"Left Battery Level",
"Right Battery Level"
]
var values: [Int] = []
for key in keys {
guard let raw = payload[key] else { continue }
if let converted = convertToBatteryPercentage(raw) {
values.append(converted)
}
}
if values.isEmpty,
let services = payload["Services"] as? [[String: Any]] {
for service in services {
if let serviceValues = service["BatteryPercentages"] as? [String: Any] {
for value in serviceValues.values {
if let converted = convertToBatteryPercentage(value) {
values.append(converted)
}
}
}
}
}
return values.max()
}
private func convertToBatteryPercentage(_ value: Any) -> Int? {
if let number = value as? Int {
if number == 1 {
return 100
}
return number
}
if let number = value as? Double {
if number <= 1.0 {
return Int(number * 100)
}
return Int(number)
}
if let string = value as? String {
let trimmed = string.replacingOccurrences(of: "%", with: "")
if let doubleValue = Double(trimmed) {
if doubleValue <= 1.0 {
return Int(doubleValue * 100)
}
return Int(doubleValue)
}
}
return nil
}
private func clampBatteryPercentage(_ value: Int) -> Int {
min(max(value, 0), 100)
}
private func matchesBluetoothIdentifier(_ normalizedTarget: String, key: String, payload: [String: Any]) -> Bool {
if normalizeBluetoothIdentifier(key) == normalizedTarget {
return true
}
let candidateFields: [String?] = [
payload["DeviceAddress"] as? String,
payload["Address"] as? String,
payload["BD_ADDR"] as? String,
payload["SerialNumber"] as? String
]
for field in candidateFields {
if let field, normalizeBluetoothIdentifier(field) == normalizedTarget {
return true
}
}
if let deviceAddressData = payload["DeviceAddress"] as? Data,
let ascii = String(data: deviceAddressData, encoding: .utf8),
normalizeBluetoothIdentifier(ascii) == normalizedTarget {
return true
}
if let addressData = payload["BD_ADDR"] as? Data,
let ascii = String(data: addressData, encoding: .utf8),
normalizeBluetoothIdentifier(ascii) == normalizedTarget {
return true
}
if let serialData = payload["SerialNumber"] as? Data,
let ascii = String(data: serialData, encoding: .utf8),
normalizeBluetoothIdentifier(ascii) == normalizedTarget {
return true
}
return false
}
private func logMissingBatteryInfo(for device: IOBluetoothDevice) {
let name = device.name ?? ""
let address = device.addressString ?? ""
logMissingBatteryInfo(forName: name, address: address)
}
private func clearMissingBatteryInfo(for device: IOBluetoothDevice) {
let name = device.name ?? ""
let address = device.addressString ?? ""
clearMissingBatteryInfo(forName: name, address: address)
}
private func logMissingBatteryInfo(forName name: String, address: String) {
let key = missingBatteryKey(name: name, address: address)
guard !missingBatteryLog.contains(key) else { return }
missingBatteryLog.insert(key)
let trimmedName = name.trimmingCharacters(in: .whitespacesAndNewlines)
let trimmedAddress = address.trimmingCharacters(in: .whitespacesAndNewlines)
let displayName = trimmedName.isEmpty ? "unknown device" : trimmedName
let isUnknownAddress = trimmedAddress.caseInsensitiveCompare("unknown") == .orderedSame
let displayAddress = (trimmedAddress.isEmpty || isUnknownAddress) ? "N/A" : trimmedAddress
print("🎧 [BluetoothAudioManager] ⚠️ Battery percentage unavailable for \(displayName) (\(displayAddress))")
}
private func clearMissingBatteryInfo(forName name: String, address: String) {
let key = missingBatteryKey(name: name, address: address)
missingBatteryLog.remove(key)
}
private func missingBatteryKey(name: String, address: String) -> String {
let trimmedName = name.trimmingCharacters(in: .whitespacesAndNewlines)
let trimmedAddress = address.trimmingCharacters(in: .whitespacesAndNewlines)
let normalizedName = normalizeProductName(trimmedName)
let isUnknownAddress = trimmedAddress.caseInsensitiveCompare("unknown") == .orderedSame
let normalizedAddress = (trimmedAddress.isEmpty || isUnknownAddress) ? "" : normalizeBluetoothIdentifier(trimmedAddress)
if normalizedName.isEmpty && normalizedAddress.isEmpty {
return "unknown"
}
return normalizedName + "#" + normalizedAddress
}
private func stringValue(forKey key: String, entry: io_object_t) -> String? {
guard let unmanaged = IORegistryEntryCreateCFProperty(entry, key as CFString, kCFAllocatorDefault, 0) else {
return nil
}
let value = unmanaged.takeRetainedValue()
if let string = value as? String, !string.isEmpty {
return string
}
if let data = value as? Data, let ascii = String(data: data, encoding: .utf8), !ascii.isEmpty {
return ascii
}
return nil
}
private func normalizeBluetoothIdentifier(_ value: String) -> String {
return value
.lowercased()
.replacingOccurrences(of: ":", with: "")
.replacingOccurrences(of: "-", with: "")
.replacingOccurrences(of: " ", with: "")
}
private func normalizeProductName(_ name: String) -> String {
let components = name
.lowercased()
.components(separatedBy: CharacterSet.alphanumerics.inverted)
.filter { !$0.isEmpty }
return components.joined()
}
// MARK: - HUD Display
@ -382,8 +1004,14 @@ class BluetoothAudioManager: ObservableObject {
print("🎧 [BluetoothAudioManager] 📱 Showing device connected HUD")
// Convert battery percentage to 0.0-1.0 value
let batteryValue: CGFloat = if let battery = device.batteryLevel {
CGFloat(battery) / 100.0
let reportedBattery = device.batteryLevel
?? batteryLevelFromRegistry(forAddress: device.address)
?? batteryLevelFromRegistry(forName: device.name)
?? batteryLevelFromDefaults(forAddress: device.address)
?? batteryLevelFromDefaults(forName: device.name)
let batteryValue: CGFloat = if let battery = reportedBattery {
CGFloat(clampBatteryPercentage(battery)) / 100.0
} else {
0.0 // 0 means battery info not available
}
@ -411,16 +1039,47 @@ class BluetoothAudioManager: ObservableObject {
observers.removeAll()
cancellables.removeAll()
}
@MainActor
func refreshConnectedDeviceBatteries() {
refreshBatteryLevelsForConnectedDevices()
}
}
// MARK: - Models
struct BluetoothAudioDevice: Identifiable {
let id = UUID()
let id: UUID
let name: String
let address: String
let batteryLevel: Int? // 0-100, nil if not available
let deviceType: BluetoothAudioDeviceType
init(
id: UUID = UUID(),
name: String,
address: String,
batteryLevel: Int?,
deviceType: BluetoothAudioDeviceType
) {
self.id = id
self.name = name
self.address = address
self.batteryLevel = batteryLevel
self.deviceType = deviceType
}
}
extension BluetoothAudioDevice {
func withBatteryLevel(_ batteryLevel: Int?) -> BluetoothAudioDevice {
BluetoothAudioDevice(
id: id,
name: name,
address: address,
batteryLevel: batteryLevel,
deviceType: deviceType
)
}
}
enum BluetoothAudioDeviceType {