Files

116 lines
6.0 KiB
Swift

import Foundation
import IOKit
import Localization
/// 已连接蓝牙设备的电量
public struct BluetoothDevice: Sendable, Equatable, Identifiable {
public enum Kind: String, Sendable {
case keyboard, mouse, trackpad, headphones, phone, other
}
public var id: String { address.isEmpty ? name : address }
public var name: String
public var address: String
public var kind: Kind
/// 电量(0...100);耳机分左耳、右耳、充电盒
public var batteries: [(label: String, percent: Int)]
public static func == (lhs: BluetoothDevice, rhs: BluetoothDevice) -> Bool {
lhs.id == rhs.id && lhs.name == rhs.name && lhs.kind == rhs.kind
&& lhs.batteries.map(\.label) == rhs.batteries.map(\.label) && lhs.batteries.map(\.percent) == rhs.batteries.map(\.percent)
}
}
/// 电量来自两处:system_profiler 的蓝牙信息(AirPods 等耳机的左右耳与充电盒)和 IOKit 里 Apple 键盘、鼠标、触控板的 BatteryPercent
public enum BluetoothBatteryReader {
public static func read() -> [BluetoothDevice] {
var devices = parseSystemProfiler(runSystemProfiler())
for (product, percent) in hidBatteries() {
if let index = devices.firstIndex(where: { $0.name == product }) {
if devices[index].batteries.isEmpty { devices[index].batteries = [(tr("电量"), percent)] }
} else {
devices.append(BluetoothDevice(name: product, address: "", kind: kind(forName: product, minorType: nil), batteries: [(tr("电量"), percent)]))
}
}
return devices.sorted { $0.name.localizedStandardCompare($1.name) == .orderedAscending }
}
/// `system_profiler SPBluetoothDataType -json` 里 device_connected 分组的设备
static func parseSystemProfiler(_ data: Data) -> [BluetoothDevice] {
guard let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let sections = root["SPBluetoothDataType"] as? [[String: Any]] else { return [] }
var devices: [BluetoothDevice] = []
for section in sections {
for entry in section["device_connected"] as? [[String: Any]] ?? [] {
for (name, value) in entry {
guard let info = value as? [String: Any] else { continue }
let labels: [(String, String)] = [("device_batteryLevelMain", tr("电量")), ("device_batteryLevel", tr("电量")),
("device_batteryLevelLeft", tr("左耳")), ("device_batteryLevelRight", tr("右耳")),
("device_batteryLevelCase", tr("充电盒"))]
var batteries: [(String, Int)] = []
for (key, label) in labels {
if let percent = percentValue(info[key]), !batteries.contains(where: { $0.0 == label }) {
batteries.append((label, percent))
}
}
devices.append(BluetoothDevice(name: name, address: info["device_address"] as? String ?? "",
kind: kind(forName: name, minorType: info["device_minorType"] as? String),
batteries: batteries))
}
}
}
return devices
}
static func percentValue(_ value: Any?) -> Int? {
if let number = value as? Int { return (0...100).contains(number) ? number : nil }
guard let text = value as? String, let number = Int(text.trimmingCharacters(in: CharacterSet(charactersIn: "% "))) else { return nil }
return (0...100).contains(number) ? number : nil
}
static func kind(forName name: String, minorType: String?) -> BluetoothDevice.Kind {
let text = ((minorType ?? "") + " " + name).lowercased()
if text.contains("keyboard") { return .keyboard }
if text.contains("trackpad") { return .trackpad }
if text.contains("mouse") { return .mouse }
if text.contains("headphone") || text.contains("headset") || text.contains("airpods") || text.contains("beats") { return .headphones }
if text.contains("phone") { return .phone }
return .other
}
private static func runSystemProfiler() -> Data {
let process = Process()
process.executableURL = URL(fileURLWithPath: "/usr/sbin/system_profiler")
process.arguments = ["SPBluetoothDataType", "-json", "-detailLevel", "basic"]
let pipe = Pipe()
process.standardOutput = pipe
process.standardError = FileHandle.nullDevice
do { try process.run() } catch { return Data() }
// 最多等 10 秒,蓝牙服务卡住时不拖住采样
let deadline = DispatchTime.now() + 10
DispatchQueue.global().asyncAfter(deadline: deadline) { if process.isRunning { process.terminate() } }
let data = pipe.fileHandleForReading.readDataToEndOfFile()
process.waitUntilExit()
return data
}
private static func hidBatteries() -> [(String, Int)] {
var iterator: io_iterator_t = 0
guard IOServiceGetMatchingServices(kIOMainPortDefault, IOServiceMatching("AppleDeviceManagementHIDEventService"), &iterator) == KERN_SUCCESS else {
return []
}
defer { IOObjectRelease(iterator) }
var results: [(String, Int)] = []
while case let service = IOIteratorNext(iterator), service != 0 {
defer { IOObjectRelease(service) }
guard let percent = IORegistryEntryCreateCFProperty(service, "BatteryPercent" as CFString, kCFAllocatorDefault, 0)?
.takeRetainedValue() as? Int,
let product = IORegistryEntryCreateCFProperty(service, "Product" as CFString, kCFAllocatorDefault, 0)?
.takeRetainedValue() as? String,
(0...100).contains(percent), !results.contains(where: { $0.0 == product }) else { continue }
results.append((product, percent))
}
return results
}
}