fix(battery): 补全蓝牙设备电量显示
This commit is contained in:
1 parent
b462420046
commit
ff049336f2
14 files changed
+305
-34
No files matched your search
@@ -158,6 +158,7 @@ npm 缓存 ذاكرة npm المؤقتة
|
||||
上传并覆盖 رفع واستبدال
|
||||
上传本机设置 رفع إعدادات هذا الـ Mac
|
||||
上次同步:{} آخر مزامنة: {}
|
||||
上次更新:{} آخر تحديث: {}
|
||||
上次检查 {},最新 {} آخر فحص {}، أحدث إصدار {}
|
||||
上次检查发现问题 وجد الفحص الأخير مشكلات
|
||||
上次检查正常 الفحص الأخير سليم
|
||||
@@ -1111,7 +1112,7 @@ npm 缓存 ذاكرة npm المؤقتة
|
||||
这段时间没有记录 لا توجد سجلات لهذه الفترة
|
||||
这段时间还没有记录。XStats 运行时每分钟记录一次,睡眠与锁屏期间不记录。 لا توجد سجلات لهذه الفترة بعد. يسجل XStats كل دقيقة أثناء التشغيل باستثناء الإسبات وقفل الشاشة.
|
||||
这里列出资源库里的 LaunchAgents 与 LaunchDaemons。停用只写入系统的停用记录并卸载,不删除文件,随时可以重新启用;登录时打开的应用与后台权限在系统设置里管理。 يعرض LaunchAgents وLaunchDaemons من مجلدات المكتبة. يسجل التعطيل حالتها ويوقف تحميلها دون حذف الملفات، ويمكن إعادة تفعيلها متى شئت. تُدار تطبيقات الدخول وأذونات الخلفية في إعدادات النظام.
|
||||
这里只显示已连接蓝牙设备的电量 تُعرض أجهزة Bluetooth المتصلة فقط
|
||||
这里显示已连接设备,以及最近 30 分钟内读到的设备电量 يعرض الأجهزة المتصلة وقراءات البطارية خلال آخر 30 دقيقة
|
||||
进程 العمليات
|
||||
进程 {} · 线程 {} العمليات {} · الخيوط {}
|
||||
进程名:{} اسم العملية: {}
|
||||
|
||||
|
@@ -158,6 +158,7 @@ npm 缓存 npm-Cache
|
||||
上传并覆盖 Hochladen und überschreiben
|
||||
上传本机设置 Einstellungen dieses Mac hochladen
|
||||
上次同步:{} Zuletzt synchronisiert: {}
|
||||
上次更新:{} Zuletzt aktualisiert: {}
|
||||
上次检查 {},最新 {} Zuletzt geprüft {}, neueste Version {}
|
||||
上次检查发现问题 Letzte Prüfung ergab Probleme
|
||||
上次检查正常 Letzte Prüfung ohne Befund
|
||||
@@ -1111,7 +1112,7 @@ npm 缓存 npm-Cache
|
||||
这段时间没有记录 Keine Aufzeichnungen für diesen Zeitraum
|
||||
这段时间还没有记录。XStats 运行时每分钟记录一次,睡眠与锁屏期间不记录。 Noch keine Aufzeichnungen für diesen Zeitraum. XStats zeichnet während des Betriebs jede Minute auf, außer im Ruhezustand oder bei gesperrtem Bildschirm.
|
||||
这里列出资源库里的 LaunchAgents 与 LaunchDaemons。停用只写入系统的停用记录并卸载,不删除文件,随时可以重新启用;登录时打开的应用与后台权限在系统设置里管理。 Zeigt LaunchAgents und LaunchDaemons aus den Library-Ordnern. Deaktivieren trägt sie nur als deaktiviert ein und entlädt sie, ohne Dateien zu löschen; eine erneute Aktivierung ist jederzeit möglich. Anmelde-Apps und Hintergrundrechte verwalten Sie in den Systemeinstellungen.
|
||||
这里只显示已连接蓝牙设备的电量 Hier wird nur der Ladestand verbundener Bluetooth-Geräte angezeigt
|
||||
这里显示已连接设备,以及最近 30 分钟内读到的设备电量 Zeigt verbundene Geräte und in den letzten 30 Minuten gelesene Akkustände
|
||||
进程 Prozesse
|
||||
进程 {} · 线程 {} Prozesse {} · Threads {}
|
||||
进程名:{} Prozessname: {}
|
||||
|
||||
|
@@ -158,6 +158,7 @@ npm 缓存 Caché de npm
|
||||
上传并覆盖 Subir y sobrescribir
|
||||
上传本机设置 Subir ajustes de este Mac
|
||||
上次同步:{} Última sincronización: {}
|
||||
上次更新:{} Última actualización: {}
|
||||
上次检查 {},最新 {} Última comprobación {}, versión más reciente {}
|
||||
上次检查发现问题 Se encontraron problemas en la última comprobación
|
||||
上次检查正常 Última comprobación correcta
|
||||
@@ -1111,7 +1112,7 @@ npm 缓存 Caché de npm
|
||||
这段时间没有记录 Sin registros en este periodo
|
||||
这段时间还没有记录。XStats 运行时每分钟记录一次,睡眠与锁屏期间不记录。 Aún no hay registros. XStats registra cada minuto mientras funciona, excepto durante el reposo y el bloqueo.
|
||||
这里列出资源库里的 LaunchAgents 与 LaunchDaemons。停用只写入系统的停用记录并卸载,不删除文件,随时可以重新启用;登录时打开的应用与后台权限在系统设置里管理。 Muestra LaunchAgents y LaunchDaemons de tus bibliotecas. Desactivarlos registra el estado y los descarga sin borrar archivos, por lo que puedes reactivarlos. Las apps de inicio y permisos en segundo plano se gestionan en Ajustes del Sistema.
|
||||
这里只显示已连接蓝牙设备的电量 Solo se muestran los dispositivos Bluetooth conectados
|
||||
这里显示已连接设备,以及最近 30 分钟内读到的设备电量 Muestra dispositivos conectados y baterías leídas en los últimos 30 minutos
|
||||
进程 Procesos
|
||||
进程 {} · 线程 {} Procesos {} · Hilos {}
|
||||
进程名:{} Nombre del proceso: {}
|
||||
|
||||
|
@@ -158,6 +158,7 @@ npm 缓存 Cache npm
|
||||
上传并覆盖 Envoyer et remplacer
|
||||
上传本机设置 Envoyer les réglages de ce Mac
|
||||
上次同步:{} Dernière synchronisation : {}
|
||||
上次更新:{} Dernière mise à jour : {}
|
||||
上次检查 {},最新 {} Dernière vérification {}, dernière version {}
|
||||
上次检查发现问题 La dernière vérification a détecté des problèmes
|
||||
上次检查正常 Dernière vérification sans problème
|
||||
@@ -1111,7 +1112,7 @@ npm 缓存 Cache npm
|
||||
这段时间没有记录 Aucune donnée pour cette période
|
||||
这段时间还没有记录。XStats 运行时每分钟记录一次,睡眠与锁屏期间不记录。 Aucune donnée pour cette période. XStats enregistre chaque minute pendant son exécution, sauf en veille ou lorsque l’écran est verrouillé.
|
||||
这里列出资源库里的 LaunchAgents 与 LaunchDaemons。停用只写入系统的停用记录并卸载,不删除文件,随时可以重新启用;登录时打开的应用与后台权限在系统设置里管理。 Affiche les LaunchAgents et LaunchDaemons des bibliothèques. La désactivation les marque comme désactivés et les décharge sans supprimer de fichiers ; ils peuvent être réactivés à tout moment. Les apps d’ouverture de session et les autorisations d’arrière-plan se gèrent dans Réglages Système.
|
||||
这里只显示已连接蓝牙设备的电量 Seules les batteries des appareils Bluetooth connectés sont affichées ici
|
||||
这里显示已连接设备,以及最近 30 分钟内读到的设备电量 Affiche les appareils connectés et les niveaux lus au cours des 30 dernières minutes
|
||||
进程 Processus
|
||||
进程 {} · 线程 {} Processus {} · Threads {}
|
||||
进程名:{} Nom du processus : {}
|
||||
|
||||
|
@@ -158,6 +158,7 @@ npm 缓存 npmキャッシュ
|
||||
上传并覆盖 アップロードして上書き
|
||||
上传本机设置 このMacの設定をアップロード
|
||||
上次同步:{} 最終同期:{}
|
||||
上次更新:{} 最終更新:{}
|
||||
上次检查 {},最新 {} 最終確認 {}、最新 {}
|
||||
上次检查发现问题 前回の確認で問題あり
|
||||
上次检查正常 前回の確認は正常
|
||||
@@ -1111,7 +1112,7 @@ npm 缓存 npmキャッシュ
|
||||
这段时间没有记录 この期間の記録はありません
|
||||
这段时间还没有记录。XStats 运行时每分钟记录一次,睡眠与锁屏期间不记录。 この期間の記録はまだありません。XStatsは実行中、毎分記録します。スリープ・画面ロック中は記録しません。
|
||||
这里列出资源库里的 LaunchAgents 与 LaunchDaemons。停用只写入系统的停用记录并卸载,不删除文件,随时可以重新启用;登录时打开的应用与后台权限在系统设置里管理。 ライブラリのLaunchAgentsとLaunchDaemonsを表示します。無効化はシステムへの無効記録とアンロードのみで、ファイルを削除しないためいつでも再度有効にできます。ログイン時に開くアプリとバックグラウンド権限はシステム設定で管理します。
|
||||
这里只显示已连接蓝牙设备的电量 接続中のBluetoothデバイスの残量のみ表示します
|
||||
这里显示已连接设备,以及最近 30 分钟内读到的设备电量 接続中のデバイスと、過去30分以内に取得したデバイスのバッテリー残量を表示します
|
||||
进程 プロセス
|
||||
进程 {} · 线程 {} プロセス {} · スレッド {}
|
||||
进程名:{} プロセス名:{}
|
||||
|
||||
|
@@ -158,6 +158,7 @@ npm 缓存 npm 캐시
|
||||
上传并覆盖 업로드 및 덮어쓰기
|
||||
上传本机设置 이 Mac의 설정 업로드
|
||||
上次同步:{} 마지막 동기화: {}
|
||||
上次更新:{} 마지막 업데이트: {}
|
||||
上次检查 {},最新 {} 마지막 확인 {}, 최신 {}
|
||||
上次检查发现问题 지난 확인에서 문제 발견
|
||||
上次检查正常 지난 확인 정상
|
||||
@@ -1111,7 +1112,7 @@ npm 缓存 npm 캐시
|
||||
这段时间没有记录 이 기간의 기록 없음
|
||||
这段时间还没有记录。XStats 运行时每分钟记录一次,睡眠与锁屏期间不记录。 이 기간의 기록이 아직 없습니다. XStats 실행 중 매분 기록하며 잠자기 및 화면 잠금 중에는 기록하지 않습니다.
|
||||
这里列出资源库里的 LaunchAgents 与 LaunchDaemons。停用只写入系统的停用记录并卸载,不删除文件,随时可以重新启用;登录时打开的应用与后台权限在系统设置里管理。 라이브러리의 LaunchAgents와 LaunchDaemons를 표시합니다. 끄면 비활성 상태로 기록하고 언로드하며 파일을 삭제하지 않으므로 언제든 다시 켤 수 있습니다. 로그인 앱과 백그라운드 권한은 시스템 설정에서 관리합니다.
|
||||
这里只显示已连接蓝牙设备的电量 연결된 Bluetooth 기기의 배터리만 표시합니다
|
||||
这里显示已连接设备,以及最近 30 分钟内读到的设备电量 연결된 기기와 최근 30분 이내에 읽은 기기의 배터리를 표시합니다
|
||||
进程 프로세스
|
||||
进程 {} · 线程 {} 프로세스 {} · 스레드 {}
|
||||
进程名:{} 프로세스 이름: {}
|
||||
|
||||
|
@@ -159,6 +159,7 @@ npm 缓存 npm 緩存
|
||||
上传并覆盖 上傳並覆蓋
|
||||
上传本机设置 上傳本機設置
|
||||
上次同步:{} 上次同步:{}
|
||||
上次更新:{} 上次更新:{}
|
||||
上次检查 {},最新 {} 上次檢查 {},最新 {}
|
||||
上次检查发现问题 上次檢查發現問題
|
||||
上次检查正常 上次檢查正常
|
||||
@@ -1112,7 +1113,7 @@ npm 缓存 npm 緩存
|
||||
这段时间没有记录 這段時間沒有記錄
|
||||
这段时间还没有记录。XStats 运行时每分钟记录一次,睡眠与锁屏期间不记录。 這段時間還沒有記錄。XStats 運行時每分鐘記錄一次,睡眠與鎖屏期間不記錄。
|
||||
这里列出资源库里的 LaunchAgents 与 LaunchDaemons。停用只写入系统的停用记录并卸载,不删除文件,随时可以重新启用;登录时打开的应用与后台权限在系统设置里管理。 這裡列出資源庫里的 LaunchAgents 與 LaunchDaemons。停用只寫入系統的停用記錄並卸載,不刪除文件,隨時可以重新啓用;登錄時打開的應用與後台權限在系統設置里管理。
|
||||
这里只显示已连接蓝牙设备的电量 這裡只顯示已連接藍牙設備的電量
|
||||
这里显示已连接设备,以及最近 30 分钟内读到的设备电量 顯示已連接設備,以及最近 30 分鐘內讀取到的設備電量
|
||||
进程 進程
|
||||
进程 {} · 线程 {} 進程 {} · 線程 {}
|
||||
进程名:{} 進程名:{}
|
||||
|
||||
|
@@ -162,6 +162,7 @@ npm 缓存 npm Cache
|
||||
上传与下载速度、IP 地址、DNS Upload and download speed, IP addresses, DNS
|
||||
上传本机设置 Upload this Mac's settings
|
||||
上次同步:{} Last synced: {}
|
||||
上次更新:{} Last updated: {}
|
||||
上次检查 {},最新 {} Last checked {}, latest {}
|
||||
上次检查发现问题 Last check found problems
|
||||
上次检查正常 Last check was fine
|
||||
@@ -1087,7 +1088,7 @@ npm 缓存 npm Cache
|
||||
这段时间没有记录 No records for this period
|
||||
这段时间还没有记录。XStats 运行时每分钟记录一次,睡眠与锁屏期间不记录。 No records for this period yet. XStats records once a minute while running, except during sleep and lock.
|
||||
这里列出资源库里的 LaunchAgents 与 LaunchDaemons。停用只写入系统的停用记录并卸载,不删除文件,随时可以重新启用;登录时打开的应用与后台权限在系统设置里管理。 Lists LaunchAgents and LaunchDaemons from your Library folders. Turning one off records it as disabled and unloads it without deleting files, so you can turn it back on anytime. Login apps and background permissions are managed in System Settings.
|
||||
这里只显示已连接蓝牙设备的电量 Only connected Bluetooth devices are shown here
|
||||
这里显示已连接设备,以及最近 30 分钟内读到的设备电量 Shows connected devices and battery readings seen in the last 30 minutes
|
||||
进程 Processes
|
||||
进程 {} · 线程 {} Processes {} · Threads {}
|
||||
进程名:{} Process name: {}
|
||||
|
||||
@@ -2,37 +2,69 @@ 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 id: String {
|
||||
let normalizedAddress = address.lowercased().filter(\.isHexDigit)
|
||||
if !normalizedAddress.isEmpty { return "address:\(normalizedAddress)" }
|
||||
let normalizedName = name.folding(options: [.caseInsensitive, .diacriticInsensitive], locale: Locale(identifier: "en_US_POSIX"))
|
||||
return "name:\(normalizedName)"
|
||||
}
|
||||
public var name: String
|
||||
public var address: String
|
||||
public var kind: Kind
|
||||
/// 电量(0...100);耳机分左耳、右耳、充电盒
|
||||
public var batteries: [(label: String, percent: Int)]
|
||||
/// false 表示设备最近出现过,但当前读取不到;详情页会以“上次电量”展示
|
||||
public var isConnected = true
|
||||
public var lastSeen: Date? = nil
|
||||
|
||||
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)
|
||||
&& lhs.isConnected == rhs.isConnected && lhs.lastSeen == rhs.lastSeen
|
||||
}
|
||||
}
|
||||
|
||||
/// 电量来自两处:system_profiler 的蓝牙信息(AirPods 等耳机的左右耳与充电盒)和 IOKit 里 Apple 键盘、鼠标、触控板的 BatteryPercent
|
||||
/// 电量来自三处:system_profiler 的蓝牙信息、pmset 的附件电源,以及 IOKit 的 HID 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)] }
|
||||
devices = merge(devices, with: parsePMSet(runPMSet()))
|
||||
devices = merge(devices, with: hidBatteries())
|
||||
return devices.sorted { $0.name.localizedStandardCompare($1.name) == .orderedAscending }
|
||||
}
|
||||
|
||||
/// system_profiler、pmset 与 IORegistry 对同一设备提供的信息并不完整,按名称合并并保留已有地址。
|
||||
static func merge(_ devices: [BluetoothDevice], with additions: [BluetoothDevice]) -> [BluetoothDevice] {
|
||||
var result = devices
|
||||
for addition in additions {
|
||||
let normalizedName = addition.name.folding(options: [.caseInsensitive, .diacriticInsensitive], locale: Locale(identifier: "en_US_POSIX"))
|
||||
let nameMatches = result.indices.filter {
|
||||
result[$0].name.folding(options: [.caseInsensitive, .diacriticInsensitive], locale: Locale(identifier: "en_US_POSIX")) == normalizedName
|
||||
}
|
||||
let index: Int? = if !addition.address.isEmpty {
|
||||
result.firstIndex(where: { !$0.address.isEmpty && $0.id == addition.id })
|
||||
?? nameMatches.first(where: { result[$0].address.isEmpty })
|
||||
} else if !addition.name.isEmpty, nameMatches.count == 1 {
|
||||
nameMatches[0]
|
||||
} else {
|
||||
devices.append(BluetoothDevice(name: product, address: "", kind: kind(forName: product, minorType: nil), batteries: [(tr("电量"), percent)]))
|
||||
nil
|
||||
}
|
||||
|
||||
if let index {
|
||||
if result[index].address.isEmpty { result[index].address = addition.address }
|
||||
if result[index].batteries.isEmpty { result[index].batteries = addition.batteries }
|
||||
if result[index].kind == .other { result[index].kind = addition.kind }
|
||||
} else if !addition.name.isEmpty, !addition.address.isEmpty || nameMatches.count <= 1 {
|
||||
result.append(addition)
|
||||
}
|
||||
}
|
||||
return devices.sorted { $0.name.localizedStandardCompare($1.name) == .orderedAscending }
|
||||
return result
|
||||
}
|
||||
|
||||
/// `system_profiler SPBluetoothDataType -json` 里 device_connected 分组的设备
|
||||
@@ -64,10 +96,29 @@ public enum BluetoothBatteryReader {
|
||||
|
||||
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 }
|
||||
guard let text = value as? String else { return nil }
|
||||
let separators = CharacterSet.whitespacesAndNewlines.union(CharacterSet(charactersIn: "%"))
|
||||
let normalized = text.trimmingCharacters(in: separators)
|
||||
guard let number = Int(normalized) else { return nil }
|
||||
return (0...100).contains(number) ? number : nil
|
||||
}
|
||||
|
||||
/// `pmset -g accps` 能补充部分 system_profiler / IORegistry 看不到的耳机和第三方 HID 电量。
|
||||
/// 无名称的电源项由 IORegistry 提供产品名,这里跳过以免显示成匿名设备。
|
||||
static func parsePMSet(_ output: String) -> [BluetoothDevice] {
|
||||
output.split(whereSeparator: \.isNewline).compactMap { rawLine in
|
||||
let line = rawLine.trimmingCharacters(in: .whitespaces)
|
||||
guard line.hasPrefix("-"), let idRange = line.range(of: "(id=") else { return nil }
|
||||
let name = line[line.index(after: line.startIndex)..<idRange.lowerBound].trimmingCharacters(in: .whitespaces)
|
||||
guard !name.isEmpty, let close = line[idRange.upperBound...].firstIndex(of: ")") else { return nil }
|
||||
let remainder = line[line.index(after: close)...]
|
||||
guard let percentText = remainder.split(separator: ";", maxSplits: 1).first,
|
||||
let percent = percentValue(String(percentText)) else { return nil }
|
||||
return BluetoothDevice(name: name, address: "", kind: kind(forName: name, minorType: nil),
|
||||
batteries: [(tr("电量"), percent)])
|
||||
}
|
||||
}
|
||||
|
||||
static func kind(forName name: String, minorType: String?) -> BluetoothDevice.Kind {
|
||||
let text = ((minorType ?? "") + " " + name).lowercased()
|
||||
if text.contains("keyboard") { return .keyboard }
|
||||
@@ -94,21 +145,42 @@ public enum BluetoothBatteryReader {
|
||||
return data
|
||||
}
|
||||
|
||||
private static func hidBatteries() -> [(String, Int)] {
|
||||
private static func runPMSet() -> String {
|
||||
let process = Process()
|
||||
process.executableURL = URL(fileURLWithPath: "/usr/bin/pmset")
|
||||
process.arguments = ["-g", "accps"]
|
||||
let pipe = Pipe()
|
||||
process.standardOutput = pipe
|
||||
process.standardError = FileHandle.nullDevice
|
||||
do { try process.run() } catch { return "" }
|
||||
let data = pipe.fileHandleForReading.readDataToEndOfFile()
|
||||
process.waitUntilExit()
|
||||
return process.terminationStatus == 0 ? String(decoding: data, as: UTF8.self) : ""
|
||||
}
|
||||
|
||||
private static func hidBatteries() -> [BluetoothDevice] {
|
||||
var iterator: io_iterator_t = 0
|
||||
guard IOServiceGetMatchingServices(kIOMainPortDefault, IOServiceMatching("AppleDeviceManagementHIDEventService"), &iterator) == KERN_SUCCESS else {
|
||||
return []
|
||||
}
|
||||
defer { IOObjectRelease(iterator) }
|
||||
var results: [(String, Int)] = []
|
||||
var results: [BluetoothDevice] = []
|
||||
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))
|
||||
(0...100).contains(percent) else { continue }
|
||||
let product = IORegistryEntryCreateCFProperty(service, "Product" as CFString, kCFAllocatorDefault, 0)?
|
||||
.takeRetainedValue() as? String ?? ""
|
||||
let address = IORegistryEntryCreateCFProperty(service, "DeviceAddress" as CFString, kCFAllocatorDefault, 0)?
|
||||
.takeRetainedValue() as? String ?? ""
|
||||
let category = IORegistryEntryCreateCFProperty(service, "Accessory Category" as CFString, kCFAllocatorDefault, 0)?
|
||||
.takeRetainedValue() as? String
|
||||
let name = product.isEmpty ? (category ?? "") : product
|
||||
let device = BluetoothDevice(name: name, address: address, kind: kind(forName: name, minorType: category),
|
||||
batteries: [(tr("电量"), percent)])
|
||||
guard (!device.name.isEmpty || !device.address.isEmpty), !results.contains(where: { $0.id == device.id }) else { continue }
|
||||
results.append(device)
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
@@ -223,7 +223,7 @@ struct DisplayInfo {
|
||||
}
|
||||
}
|
||||
|
||||
/// 已连接蓝牙设备的电量;页面打开期间由 BluetoothController 每分钟刷新
|
||||
/// 蓝牙设备的当前或最近电量;页面打开期间由 BluetoothController 每分钟刷新
|
||||
private struct BluetoothCard: View {
|
||||
@Environment(AppModel.self) private var model
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import Localization
|
||||
import Metrics
|
||||
import SwiftUI
|
||||
|
||||
/// 电池详情:电量与充电状态、最近 24 小时电量曲线、功耗、健康度,以及已连接蓝牙设备的电量。
|
||||
/// 电池详情:电量与充电状态、最近 24 小时电量曲线、功耗、健康度,以及蓝牙设备的当前或最近电量。
|
||||
/// 没有电池的 Mac(mini、Studio、iMac)只显示蓝牙设备
|
||||
struct BatteryPopover: View {
|
||||
@Environment(AppModel.self) private var model
|
||||
@@ -86,7 +86,8 @@ private struct BatteryHero: View {
|
||||
.foregroundStyle(DS.Palette.textSecondary)
|
||||
VStack(alignment: .leading, spacing: DS.Space.s1 / 2) {
|
||||
Text(tr("这台 Mac 没有电池")).dsFont(.sm, weight: .semibold).foregroundStyle(DS.Palette.textPrimary)
|
||||
Text(tr("这里只显示已连接蓝牙设备的电量")).dsFont(.xs).foregroundStyle(DS.Palette.textSecondary)
|
||||
Text(tr("这里显示已连接设备,以及最近 30 分钟内读到的设备电量"))
|
||||
.dsFont(.xs).foregroundStyle(DS.Palette.textSecondary)
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
@@ -171,7 +172,7 @@ private struct BatteryHealthSection: View {
|
||||
|
||||
// MARK: - 蓝牙设备
|
||||
|
||||
/// 已连接蓝牙设备的电量;弹窗打开期间每分钟刷新,也可以手动刷新
|
||||
/// 蓝牙设备的当前或最近电量;弹窗打开期间每分钟刷新,也可以手动刷新
|
||||
private struct BluetoothSection: View {
|
||||
@Environment(AppModel.self) private var model
|
||||
|
||||
@@ -188,7 +189,7 @@ private struct BluetoothSection: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// 蓝牙设备电量列表:本机信息页与电池弹窗共用
|
||||
/// 蓝牙设备电量列表:本机信息页与电池弹窗共用;暂时断开的设备以灰色显示最近读数
|
||||
struct BluetoothDeviceList: View {
|
||||
let devices: [BluetoothDevice]?
|
||||
|
||||
@@ -205,7 +206,7 @@ struct BluetoothDeviceList: View {
|
||||
.frame(width: DS.Size.iconStandalone)
|
||||
Text(verbatim: device.name)
|
||||
.dsFont(.xs, weight: .medium)
|
||||
.foregroundStyle(DS.Palette.textPrimary)
|
||||
.foregroundStyle(device.isConnected ? DS.Palette.textPrimary : DS.Palette.textTertiary)
|
||||
.lineLimit(1)
|
||||
Spacer(minLength: DS.Space.s2)
|
||||
if device.batteries.isEmpty {
|
||||
@@ -217,15 +218,22 @@ struct BluetoothDeviceList: View {
|
||||
Text(verbatim: battery.label).dsFont(.xs).foregroundStyle(DS.Palette.textTertiary)
|
||||
}
|
||||
ProgressTrack(fraction: Double(battery.percent) / 100,
|
||||
color: battery.percent <= 20 ? DS.Palette.error : DS.Palette.success,
|
||||
color: device.isConnected
|
||||
? (battery.percent <= 20 ? DS.Palette.error : DS.Palette.success)
|
||||
: DS.Palette.textTertiary,
|
||||
height: DS.Space.s1 + DS.Space.s1 / 2)
|
||||
.frame(width: DS.Space.s8)
|
||||
Text(verbatim: "\(battery.percent)%")
|
||||
.dsFont(.xs, weight: .medium)
|
||||
.foregroundStyle(DS.Palette.textPrimary)
|
||||
.foregroundStyle(device.isConnected ? DS.Palette.textPrimary : DS.Palette.textTertiary)
|
||||
.monospacedDigit()
|
||||
}
|
||||
}
|
||||
if !device.isConnected, let lastSeen = device.lastSeen {
|
||||
Text(tr("上次更新:\(lastSeen.formatted(Date.FormatStyle(date: .omitted, time: .shortened, locale: L10n.locale)))"))
|
||||
.dsFont(.xs)
|
||||
.foregroundStyle(DS.Palette.textTertiary)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
|
||||
@@ -2,6 +2,61 @@ import Foundation
|
||||
import Metrics
|
||||
import Observation
|
||||
|
||||
/// 短暂休眠或切换到其他设备时保留最近电量;过期设备不会继续伪装成实时读数。
|
||||
struct BluetoothDeviceCache {
|
||||
let retention: TimeInterval
|
||||
private var entries: [String: (device: BluetoothDevice, lastSeen: Date)] = [:]
|
||||
|
||||
init(retention: TimeInterval = 30 * 60) {
|
||||
self.retention = retention
|
||||
}
|
||||
|
||||
mutating func merge(current: [BluetoothDevice], now: Date) -> [BluetoothDevice] {
|
||||
entries = entries.filter { now.timeIntervalSince($0.value.lastSeen) <= retention }
|
||||
let currentNameCounts = Dictionary(grouping: current, by: Self.normalizedName).mapValues(\.count)
|
||||
var currentKeys = Set<String>()
|
||||
var result: [BluetoothDevice] = []
|
||||
for var device in current {
|
||||
var key = Self.key(for: device)
|
||||
if entries[key] == nil, currentNameCounts[Self.normalizedName(device)] == 1 {
|
||||
let candidates = entries.filter {
|
||||
Self.normalizedName($0.value.device) == Self.normalizedName(device)
|
||||
&& (device.address.isEmpty || $0.value.device.address.isEmpty)
|
||||
}
|
||||
if candidates.count == 1, let previous = candidates.first {
|
||||
entries[previous.key] = nil
|
||||
if device.address.isEmpty { device.address = previous.value.device.address }
|
||||
key = Self.key(for: device)
|
||||
}
|
||||
}
|
||||
currentKeys.insert(key)
|
||||
device.isConnected = true
|
||||
device.lastSeen = nil
|
||||
entries[key] = (device, now)
|
||||
result.append(device)
|
||||
}
|
||||
|
||||
for (key, entry) in entries where !currentKeys.contains(key) {
|
||||
var device = entry.device
|
||||
device.isConnected = false
|
||||
device.lastSeen = entry.lastSeen
|
||||
result.append(device)
|
||||
}
|
||||
return result.sorted {
|
||||
if $0.isConnected != $1.isConnected { return $0.isConnected }
|
||||
return $0.name.localizedStandardCompare($1.name) == .orderedAscending
|
||||
}
|
||||
}
|
||||
|
||||
private static func key(for device: BluetoothDevice) -> String {
|
||||
device.id
|
||||
}
|
||||
|
||||
private static func normalizedName(_ device: BluetoothDevice) -> String {
|
||||
device.name.folding(options: [.caseInsensitive, .diacriticInsensitive], locale: Locale(identifier: "en_US_POSIX"))
|
||||
}
|
||||
}
|
||||
|
||||
/// 已连接蓝牙设备的电量。读取要起 system_profiler、一次一两秒,所以只在有人看或菜单栏需要时轮询
|
||||
@MainActor
|
||||
@Observable
|
||||
@@ -29,11 +84,16 @@ public final class BluetoothController {
|
||||
public private(set) var isReading = false
|
||||
@ObservationIgnored private var demand: Demand = .off
|
||||
@ObservationIgnored private var task: Task<Void, Never>?
|
||||
@ObservationIgnored private var deviceCache = BluetoothDeviceCache()
|
||||
|
||||
/// 电量最低的一块电池(耳机的左右耳、充电盒分别算)
|
||||
public var lowest: (device: BluetoothDevice, label: String, percent: Int)? {
|
||||
Self.lowest(in: devices ?? [])
|
||||
}
|
||||
|
||||
static func lowest(in devices: [BluetoothDevice]) -> (device: BluetoothDevice, label: String, percent: Int)? {
|
||||
var best: (device: BluetoothDevice, label: String, percent: Int)?
|
||||
for device in devices ?? [] {
|
||||
for device in devices where device.isConnected {
|
||||
for battery in device.batteries {
|
||||
if let current = best, battery.percent >= current.percent { continue }
|
||||
best = (device, battery.label, battery.percent)
|
||||
@@ -64,8 +124,10 @@ public final class BluetoothController {
|
||||
private func read() async {
|
||||
guard !isReading else { return }
|
||||
isReading = true
|
||||
devices = await Task.detached(priority: .utility) { BluetoothBatteryReader.read() }.value
|
||||
updatedAt = Date()
|
||||
let current = await Task.detached(priority: .utility) { BluetoothBatteryReader.read() }.value
|
||||
let now = Date()
|
||||
devices = deviceCache.merge(current: current, now: now)
|
||||
updatedAt = now
|
||||
isReading = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,4 +66,55 @@ import Testing
|
||||
#expect(BluetoothBatteryReader.percentValue("120%") == nil)
|
||||
#expect(BluetoothBatteryReader.parseSystemProfiler(Data("oops".utf8)).isEmpty)
|
||||
}
|
||||
|
||||
@Test func parsesNamedAccessoryPowerSources() {
|
||||
let output = """
|
||||
Now drawing from 'AC Power'
|
||||
-Magic Keyboard (id=1234)\t98%; discharging present: true
|
||||
-大唐西域进贡上等白玉耳坠 (id=5678)\t76%;
|
||||
- (id=9999)\t24%; discharging present: true
|
||||
"""
|
||||
|
||||
let devices = BluetoothBatteryReader.parsePMSet(output)
|
||||
|
||||
#expect(devices.map(\.name) == ["Magic Keyboard", "大唐西域进贡上等白玉耳坠"])
|
||||
#expect(devices.map { $0.batteries.first?.percent } == [98, 76])
|
||||
#expect(devices.map(\.kind) == [.keyboard, .other])
|
||||
}
|
||||
|
||||
@Test func mergesBatterySourcesByDeviceName() {
|
||||
let profiler = BluetoothDevice(name: "Magic Keyboard", address: "AA:BB", kind: .keyboard, batteries: [])
|
||||
let powerSource = BluetoothDevice(name: "Magic Keyboard", address: "", kind: .keyboard, batteries: [("电量", 98)])
|
||||
let headphones = BluetoothDevice(name: "Headphones", address: "", kind: .headphones, batteries: [("电量", 76)])
|
||||
|
||||
let devices = BluetoothBatteryReader.merge([profiler], with: [powerSource, headphones])
|
||||
|
||||
#expect(devices.count == 2)
|
||||
#expect(devices.first { $0.name == "Magic Keyboard" }?.address == "AA:BB")
|
||||
#expect(devices.first { $0.name == "Magic Keyboard" }?.batteries.first?.percent == 98)
|
||||
}
|
||||
|
||||
@Test func mergesUnnamedHIDBatteryByNormalizedAddress() {
|
||||
let profiler = BluetoothDevice(name: "Magic Trackpad", address: "3C:A6:F6:BF:79:DC", kind: .trackpad, batteries: [])
|
||||
let hid = BluetoothDevice(name: "", address: "3c-a6-f6-bf-79-dc", kind: .trackpad, batteries: [("电量", 23)])
|
||||
|
||||
let devices = BluetoothBatteryReader.merge([profiler], with: [hid])
|
||||
|
||||
#expect(devices.count == 1)
|
||||
#expect(devices.first?.name == "Magic Trackpad")
|
||||
#expect(devices.first?.batteries.first?.percent == 23)
|
||||
}
|
||||
|
||||
@Test func keepsSameNameDevicesSeparateWhenAddressesDiffer() {
|
||||
let first = BluetoothDevice(name: "Gamepad", address: "AA:AA", kind: .other, batteries: [])
|
||||
let second = BluetoothDevice(name: "Gamepad", address: "BB:BB", kind: .other, batteries: [])
|
||||
let firstBattery = BluetoothDevice(name: "Gamepad", address: "aa-aa", kind: .other, batteries: [("电量", 80)])
|
||||
let secondBattery = BluetoothDevice(name: "Gamepad", address: "bb-bb", kind: .other, batteries: [("电量", 60)])
|
||||
|
||||
let devices = BluetoothBatteryReader.merge([first, second], with: [firstBattery, secondBattery])
|
||||
|
||||
#expect(devices.count == 2)
|
||||
#expect(devices.first { $0.address == "AA:AA" }?.batteries.first?.percent == 80)
|
||||
#expect(devices.first { $0.address == "BB:BB" }?.batteries.first?.percent == 60)
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,76 @@ private func isolatedDefaults() -> UserDefaults {
|
||||
return defaults
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Suite struct BluetoothDeviceCacheTests {
|
||||
private func device(_ name: String, percent: Int) -> BluetoothDevice {
|
||||
BluetoothDevice(name: name, address: "", kind: .other, batteries: [("电量", percent)])
|
||||
}
|
||||
|
||||
@Test func retainsDisconnectedDevicesForThirtyMinutes() throws {
|
||||
let start = Date(timeIntervalSince1970: 1_000)
|
||||
var cache = BluetoothDeviceCache(retention: 30 * 60)
|
||||
_ = cache.merge(current: [device("耳机", percent: 76), device("触控板", percent: 24)], now: start)
|
||||
|
||||
let recent = cache.merge(current: [device("触控板", percent: 23)], now: start.addingTimeInterval(60))
|
||||
|
||||
let headphones = try #require(recent.first { $0.name == "耳机" })
|
||||
#expect(!headphones.isConnected)
|
||||
#expect(headphones.lastSeen == start)
|
||||
#expect(BluetoothController.lowest(in: recent)?.device.name == "触控板")
|
||||
|
||||
let expired = cache.merge(current: [device("触控板", percent: 22)], now: start.addingTimeInterval(30 * 60 + 1))
|
||||
#expect(!expired.contains { $0.name == "耳机" })
|
||||
}
|
||||
|
||||
@Test func keepsSameNameDevicesSeparateByAddress() {
|
||||
let start = Date(timeIntervalSince1970: 2_000)
|
||||
var cache = BluetoothDeviceCache(retention: 30 * 60)
|
||||
var first = device("Gamepad", percent: 80)
|
||||
first.address = "AA:AA"
|
||||
var second = device("Gamepad", percent: 60)
|
||||
second.address = "BB:BB"
|
||||
|
||||
_ = cache.merge(current: [first, second], now: start)
|
||||
let recent = cache.merge(current: [first], now: start.addingTimeInterval(60))
|
||||
|
||||
#expect(recent.count == 2)
|
||||
#expect(recent.first { $0.address == "AA:AA" }?.isConnected == true)
|
||||
#expect(recent.first { $0.address == "BB:BB" }?.isConnected == false)
|
||||
}
|
||||
|
||||
@Test func upgradesNameIdentityWhenAddressAppears() {
|
||||
let start = Date(timeIntervalSince1970: 3_000)
|
||||
var cache = BluetoothDeviceCache(retention: 30 * 60)
|
||||
let nameOnly = device("Headphones", percent: 76)
|
||||
var withAddress = device("Headphones", percent: 75)
|
||||
withAddress.address = "CC:CC"
|
||||
|
||||
_ = cache.merge(current: [nameOnly], now: start)
|
||||
let current = cache.merge(current: [withAddress], now: start.addingTimeInterval(60))
|
||||
|
||||
#expect(current.count == 1)
|
||||
#expect(current.first?.address == "CC:CC")
|
||||
#expect(current.first?.isConnected == true)
|
||||
#expect(current.first?.batteries.first?.percent == 75)
|
||||
}
|
||||
|
||||
@Test func expiredIdentityDoesNotMigrateToNewNameOnlyDevice() {
|
||||
let start = Date(timeIntervalSince1970: 4_000)
|
||||
var cache = BluetoothDeviceCache(retention: 30 * 60)
|
||||
var old = device("Headphones", percent: 76)
|
||||
old.address = "DD:DD"
|
||||
|
||||
_ = cache.merge(current: [old], now: start)
|
||||
let current = cache.merge(current: [device("Headphones", percent: 90)],
|
||||
now: start.addingTimeInterval(30 * 60 + 1))
|
||||
|
||||
#expect(current.count == 1)
|
||||
#expect(current.first?.address.isEmpty == true)
|
||||
#expect(current.first?.batteries.first?.percent == 90)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Suite struct SettingsTests {
|
||||
@Test func defaultsAndInvalidValues() {
|
||||
|
||||
Reference in new issue
Block a user