fix(battery): 显示蓝牙配件充电状态与分级电量

This commit is contained in:
ysicing committed 2026-09-27 16:19:55 +08:00
1 parent 7dc19b3f70
commit 2bde9d96c7
6 files changed
+132 -28

No files matched your search

+3
View File
@@ -310,6 +310,9 @@ falls back to the Bluetooth device with the lowest battery.
popover / page or the System page is open, every five minutes when the menu bar needs it (the
low-battery hint or a Mac without a battery), otherwise not at all. `AppModel.bluetoothDemand` derives
that from the same visibility state as `demand`.
Charging state comes from named `pmset -g accps` accessory entries. For anonymous entries, it is
attached only when the reported percentage matches exactly one connected Bluetooth battery device;
ambiguous matches remain unknown. Disconnected cached devices never retain a charging label.
## Online updates
@@ -19,6 +19,8 @@ public struct BluetoothDevice: Sendable, Equatable, Identifiable {
public var kind: Kind
/// 电量(0...100);耳机分左耳、右耳、充电盒
public var batteries: [(label: String, percent: Int)]
/// nil 表示系统未提供充电状态,不据电量百分比推断。
public var isCharging: Bool? = nil
/// false 表示设备最近出现过,但当前读取不到;详情页会以“上次电量”展示
public var isConnected = true
public var lastSeen: Date? = nil
@@ -26,6 +28,7 @@ public struct BluetoothDevice: Sendable, Equatable, Identifiable {
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.isCharging == rhs.isCharging
&& lhs.isConnected == rhs.isConnected && lhs.lastSeen == rhs.lastSeen
}
}
@@ -33,10 +36,12 @@ public struct BluetoothDevice: Sendable, Equatable, Identifiable {
/// 电量来自三处:system_profiler 的蓝牙信息、pmset 的附件电源,以及 IOKit 的 HID BatteryPercent。
public enum BluetoothBatteryReader {
public static func read() -> [BluetoothDevice] {
let powerOutput = runPMSet()
var devices = parseSystemProfiler(runSystemProfiler())
devices = merge(devices, with: parsePMSet(runPMSet()))
devices = merge(devices, with: parsePMSet(powerOutput))
devices = merge(devices, with: hidBatteries())
return devices.sorted { $0.name.localizedStandardCompare($1.name) == .orderedAscending }
return mergeChargingSources(devices, powerOutput: powerOutput)
.sorted { $0.name.localizedStandardCompare($1.name) == .orderedAscending }
}
/// system_profiler、pmset 与 IORegistry 对同一设备提供的信息并不完整,按名称合并并保留已有地址。
@@ -59,6 +64,7 @@ public enum BluetoothBatteryReader {
if let index {
if result[index].address.isEmpty { result[index].address = addition.address }
if result[index].batteries.isEmpty { result[index].batteries = addition.batteries }
if let charging = addition.isCharging { result[index].isCharging = charging }
if result[index].kind == .other { result[index].kind = addition.kind }
result[index].isConnected = result[index].isConnected || addition.isConnected
} else if !addition.name.isEmpty, !addition.address.isEmpty || nameMatches.count <= 1 {
@@ -114,18 +120,43 @@ public enum BluetoothBatteryReader {
/// 无名称的电源项由 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)])
guard let source = parsePowerSourceLine(rawLine), !source.name.isEmpty else { return nil }
return BluetoothDevice(name: source.name, address: "", kind: kind(forName: source.name, minorType: nil),
batteries: [(tr("电量"), source.percent)], isCharging: source.isCharging)
}
}
/// pmset 有时只给附件 ID,没有名称;仅在电量百分比唯一匹配到已连接设备时补充充电状态。
static func mergeChargingSources(_ devices: [BluetoothDevice], powerOutput: String) -> [BluetoothDevice] {
var result = devices
for line in powerOutput.split(whereSeparator: \.isNewline) {
guard let source = parsePowerSourceLine(line), source.name.isEmpty, source.isCharging == true else { continue }
let matches = result.indices.filter { index in
result[index].isConnected && result[index].batteries.contains { $0.percent == source.percent }
}
if matches.count == 1, result[matches[0]].isCharging == nil {
result[matches[0]].isCharging = true
}
}
return result
}
private static func parsePowerSourceLine(_ rawLine: Substring) -> (name: String, percent: Int, isCharging: Bool?)? {
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 let close = line[idRange.upperBound...].firstIndex(of: ")") else { return nil }
let fields = line[line.index(after: close)...].split(separator: ";")
guard let first = fields.first, let percent = percentValue(String(first)) else { return nil }
let charging: Bool? = fields.dropFirst().compactMap { field in
let value = field.trimmingCharacters(in: .whitespaces).lowercased()
if value.hasPrefix("charging present:") { return value.hasSuffix("true") }
if value.hasPrefix("discharging present:") && value.hasSuffix("true") { return false }
return nil
}.first
return (name, percent, charging)
}
static func kind(forName name: String, minorType: String?) -> BluetoothDevice.Kind {
let text = ((minorType ?? "") + " " + name).lowercased()
if text.contains("keyboard") { return .keyboard }
@@ -199,6 +199,16 @@ enum BluetoothDeviceRowLayout: Equatable {
}
}
/// 电量色只用于图标与进度条;数字保持正文颜色,便于在浅色背景上阅读。
private func bluetoothBatteryColor(percent: Int, connected: Bool) -> Color {
guard connected else { return DS.Palette.textTertiary }
switch percent {
case ...20: return DS.Palette.error
case ...50: return DS.Palette.warning
default: return DS.Palette.success
}
}
struct BluetoothDeviceList: View {
let devices: [BluetoothDevice]?
@@ -232,10 +242,15 @@ private struct BluetoothDeviceRow: View {
.frame(width: DS.Size.iconStandalone)
.accessibilityHidden(true)
VStack(alignment: .leading, spacing: 0) {
Text(verbatim: device.name)
.dsFont(.xs, weight: .medium)
.foregroundStyle(device.isConnected ? DS.Palette.textPrimary : DS.Palette.textTertiary)
.lineLimit(1)
HStack(spacing: DS.Space.s1) {
Text(verbatim: device.name)
.dsFont(.xs, weight: .medium)
.foregroundStyle(device.isConnected ? DS.Palette.textPrimary : DS.Palette.textTertiary)
.lineLimit(1)
if layout == .parts && device.isConnected && device.isCharging == true {
chargingSymbol
}
}
if layout == .parts, let lastSeenText {
Text(lastSeenText)
.dsFont(.xs)
@@ -281,10 +296,23 @@ private struct BluetoothDeviceRow: View {
@ViewBuilder
private var compactValue: some View {
if let battery = device.batteries.first {
let charging = device.isConnected && device.isCharging == true
HStack(spacing: DS.Space.s1) {
batteryTrack(battery)
.frame(width: DS.Space.s8)
batteryPercent(battery)
Image(systemName: batterySymbol(for: battery.percent))
.font(.system(size: DS.TextSize.sm.rawValue))
.foregroundStyle(bluetoothBatteryColor(percent: battery.percent, connected: device.isConnected))
.overlay {
if charging {
Image(systemName: "bolt.fill")
.font(.system(size: 7, weight: .black))
.foregroundStyle(DS.Palette.textPrimary)
.offset(x: -1)
}
}
.help(charging ? tr("充电中") : tr("电量"))
.accessibilityElement(children: .ignore)
.accessibilityLabel(charging ? tr("充电中") : tr("电量"))
}
} else {
Text(device.isConnected ? tr("不提供电量") : tr("未连接"))
@@ -294,13 +322,22 @@ private struct BluetoothDeviceRow: View {
}
}
private func batteryTrack(_ battery: (label: String, percent: Int)) -> some View {
ProgressTrack(fraction: Double(battery.percent) / 100,
color: device.isConnected
? (battery.percent <= 20 ? DS.Palette.error : DS.Palette.success)
: DS.Palette.textTertiary,
height: DS.Space.s1 + DS.Space.s1 / 2)
.accessibilityHidden(true)
private var chargingSymbol: some View {
Image(systemName: "bolt.fill")
.font(.system(size: DS.TextSize.xs.rawValue, weight: .semibold))
.foregroundStyle(DS.Palette.primary)
.help(tr("充电中"))
.accessibilityLabel(tr("充电中"))
}
private func batterySymbol(for percent: Int) -> String {
switch percent {
case ..<13: "battery.0"
case ..<38: "battery.25"
case ..<63: "battery.50"
case ..<88: "battery.75"
default: "battery.100"
}
}
private func batteryPercent(_ battery: (label: String, percent: Int)) -> some View {
@@ -325,9 +362,7 @@ private struct BluetoothBatteryPart: View {
.lineLimit(1)
HStack(spacing: DS.Space.s1) {
ProgressTrack(fraction: Double(battery.percent) / 100,
color: device.isConnected
? (battery.percent <= 20 ? DS.Palette.error : DS.Palette.success)
: DS.Palette.textTertiary,
color: bluetoothBatteryColor(percent: battery.percent, connected: device.isConnected),
height: DS.Space.s1 + DS.Space.s1 / 2)
.frame(minWidth: DS.Space.s4, maxWidth: .infinity)
.accessibilityHidden(true)
@@ -42,6 +42,7 @@ struct BluetoothDeviceCache {
recent.address = device.address
recent.kind = device.kind
recent.isConnected = false
recent.isCharging = nil
recent.lastSeen = cached.lastSeen
result.append(recent)
} else {
@@ -53,6 +54,7 @@ struct BluetoothDeviceCache {
for (key, entry) in entries where !currentKeys.contains(key) {
var device = entry.device
device.isConnected = false
device.isCharging = nil
device.lastSeen = entry.lastSeen
result.append(device)
}
@@ -79,7 +79,7 @@ import Testing
let output = """
Now drawing from 'AC Power'
-Magic Keyboard (id=1234)\t98%; discharging present: true
-大唐西域进贡上等白玉耳坠 (id=5678)\t76%;
-大唐西域进贡上等白玉耳坠 (id=5678)\t76%; charging present: true
- (id=9999)\t24%; discharging present: true
"""
@@ -88,6 +88,27 @@ import Testing
#expect(devices.map(\.name) == ["Magic Keyboard", "大唐西域进贡上等白玉耳坠"])
#expect(devices.map { $0.batteries.first?.percent } == [98, 76])
#expect(devices.map(\.kind) == [.keyboard, .other])
#expect(devices.map(\.isCharging) == [false, true])
}
@Test func anonymousChargingSourceMatchesOnlyOneConnectedBattery() {
let output = """
Now drawing from 'AC Power'
- (id=53280768)\t69%; charging present: true
"""
let trackpad = BluetoothDevice(name: "Magic Trackpad", address: "AA:BB", kind: .trackpad,
batteries: [("电量", 69)])
let connected = BluetoothBatteryReader.mergeChargingSources([trackpad], powerOutput: output)
#expect(connected.first?.isCharging == true)
let other = BluetoothDevice(name: "Headphones", address: "CC:DD", kind: .headphones,
batteries: [("电量", 69)])
let ambiguous = BluetoothBatteryReader.mergeChargingSources([trackpad, other], powerOutput: output)
#expect(ambiguous.allSatisfy { $0.isCharging == nil })
var knownNotCharging = other
knownNotCharging.isCharging = false
let mixed = BluetoothBatteryReader.mergeChargingSources([trackpad, knownNotCharging], powerOutput: output)
#expect(mixed.first?.isCharging == nil)
}
@Test func mergesBatterySourcesByDeviceName() {
@@ -82,6 +82,18 @@ private func isolatedDefaults() -> UserDefaults {
#expect(!expired.contains { $0.name == "耳机" })
}
@Test func disconnectedDeviceDoesNotKeepChargingStatus() throws {
let start = Date(timeIntervalSince1970: 1_000)
var cache = BluetoothDeviceCache()
var trackpad = device("Magic Trackpad", percent: 69)
trackpad.isCharging = true
_ = cache.merge(current: [trackpad], now: start)
let disconnected = try #require(cache.merge(current: [], now: start.addingTimeInterval(60)).first)
#expect(!disconnected.isConnected)
#expect(disconnected.isCharging == nil)
}
@Test func keepsSameNameDevicesSeparateByAddress() {
let start = Date(timeIntervalSince1970: 2_000)
var cache = BluetoothDeviceCache(retention: 30 * 60)