116 lines
6.0 KiB
Swift
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
|
|
}
|
|
}
|