diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index c44fdb1..798e02e 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -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 diff --git a/Packages/XStatsKit/Sources/Metrics/BluetoothBattery.swift b/Packages/XStatsKit/Sources/Metrics/BluetoothBattery.swift index 3f8645b..acb56bf 100644 --- a/Packages/XStatsKit/Sources/Metrics/BluetoothBattery.swift +++ b/Packages/XStatsKit/Sources/Metrics/BluetoothBattery.swift @@ -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).. [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).. BluetoothDevice.Kind { let text = ((minorType ?? "") + " " + name).lowercased() if text.contains("keyboard") { return .keyboard } diff --git a/Packages/XStatsKit/Sources/XStatsUI/Popover/BatteryPopover.swift b/Packages/XStatsKit/Sources/XStatsUI/Popover/BatteryPopover.swift index 82e9aef..afe4450 100644 --- a/Packages/XStatsKit/Sources/XStatsUI/Popover/BatteryPopover.swift +++ b/Packages/XStatsKit/Sources/XStatsUI/Popover/BatteryPopover.swift @@ -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) diff --git a/Packages/XStatsKit/Sources/XStatsUI/State/BluetoothController.swift b/Packages/XStatsKit/Sources/XStatsUI/State/BluetoothController.swift index e489451..d54e584 100644 --- a/Packages/XStatsKit/Sources/XStatsUI/State/BluetoothController.swift +++ b/Packages/XStatsKit/Sources/XStatsUI/State/BluetoothController.swift @@ -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) } diff --git a/Packages/XStatsKit/Tests/MetricsTests/HistoryDatabaseTests.swift b/Packages/XStatsKit/Tests/MetricsTests/HistoryDatabaseTests.swift index 759949b..2d96c22 100644 --- a/Packages/XStatsKit/Tests/MetricsTests/HistoryDatabaseTests.swift +++ b/Packages/XStatsKit/Tests/MetricsTests/HistoryDatabaseTests.swift @@ -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() { diff --git a/Packages/XStatsKit/Tests/XStatsUITests/XStatsUITests.swift b/Packages/XStatsKit/Tests/XStatsUITests/XStatsUITests.swift index e07819e..c9c9a85 100644 --- a/Packages/XStatsKit/Tests/XStatsUITests/XStatsUITests.swift +++ b/Packages/XStatsKit/Tests/XStatsUITests/XStatsUITests.swift @@ -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)