From 4f4644649401930b09fadc86497fdeed4f0ddce7 Mon Sep 17 00:00:00 2001 From: gentpan Date: Sun, 13 Sep 2026 08:48:40 +0500 Subject: [PATCH] =?UTF-8?q?=E8=93=9D=E7=89=99=E8=AE=BE=E5=A4=87=E7=94=B5?= =?UTF-8?q?=E9=87=8F=E4=B8=8E=E4=BD=8E=E7=94=B5=E9=87=8F=E9=80=9A=E7=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 1 + .../Sources/Metrics/BluetoothBattery.swift | 114 ++++++++++++++++++ .../OpenStatsUI/Panel/SystemInfoPage.swift | 67 ++++++++++ .../OpenStatsUI/State/AlertController.swift | 31 ++++- .../MetricsTests/HistoryDatabaseTests.swift | 22 ++++ 5 files changed, 230 insertions(+), 5 deletions(-) create mode 100644 Packages/OpenStatsKit/Sources/Metrics/BluetoothBattery.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 12fdc1a..944a426 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ - 卸载应用(主窗口工具分组):列出“应用程序”里的第三方应用及其体积,选中或把应用拖进来后找出它留下的应用数据、缓存、偏好设置、沙盒容器、窗口状态、日志、网页数据和登录启动项,可逐项取消勾选;确认后连同应用一起移到废纸篓(可放回),并可同时从程序坞移除图标。只匹配应用包名与同名目录,系统自带和 Apple 的应用不列出,正在运行时提示先退出。 - 启动项(主窗口工具分组):列出当前用户、所有用户的 LaunchAgents 和系统 LaunchDaemons,显示所属应用、可执行文件、是否登录时运行 / 保持运行,以及运行中(PID)、已加载、已停用状态;当前用户的启动项可以直接停用或重新启用(写入 launchd 停用记录并卸载,不删除文件),其余只读并提供登录项设置入口。 - 历史(主窗口监控分组):每分钟把 CPU 平均与峰值、内存与内存压力、网络上下行、GPU、CPU 最高温度和整机功耗写入本机 SQLite 数据库,保留 7 天;可回看最近 1 小时 / 24 小时 / 7 天,睡眠等没有记录的时段线条断开,内存压力严重的时段标红,鼠标移到图上查看那一刻的数值;可关闭记录或清除历史,数据不上传。 +- 蓝牙设备电量:本机信息页列出已连接的键盘、鼠标、触控板和耳机的电量(AirPods 分左耳、右耳、充电盒),低于 20% 标红;通知里新增“蓝牙设备电量低”(低于 15%)。 #### 调整 diff --git a/Packages/OpenStatsKit/Sources/Metrics/BluetoothBattery.swift b/Packages/OpenStatsKit/Sources/Metrics/BluetoothBattery.swift new file mode 100644 index 0000000..7b33258 --- /dev/null +++ b/Packages/OpenStatsKit/Sources/Metrics/BluetoothBattery.swift @@ -0,0 +1,114 @@ +import Foundation +import IOKit + +/// 已连接蓝牙设备的电量 +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 = [("电量", percent)] } + } else { + devices.append(BluetoothDevice(name: product, address: "", kind: kind(forName: product, minorType: nil), batteries: [("电量", 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", "电量"), ("device_batteryLevel", "电量"), + ("device_batteryLevelLeft", "左耳"), ("device_batteryLevelRight", "右耳"), + ("device_batteryLevelCase", "充电盒")] + 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 + } +} diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Panel/SystemInfoPage.swift b/Packages/OpenStatsKit/Sources/OpenStatsUI/Panel/SystemInfoPage.swift index ecafc36..16851ae 100644 --- a/Packages/OpenStatsKit/Sources/OpenStatsUI/Panel/SystemInfoPage.swift +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Panel/SystemInfoPage.swift @@ -99,6 +99,8 @@ struct SystemInfoPage: View { } } + BluetoothCard() + InfoCard(icon: "display.2", title: "显示器") { let displays = DisplayInfo.all() if displays.isEmpty { @@ -218,3 +220,68 @@ struct DisplayInfo { return (best.pixelWidth, best.pixelHeight) } } + +/// 已连接蓝牙设备的电量,页面打开期间每分钟刷新 +private struct BluetoothCard: View { + @State private var devices: [BluetoothDevice]? + + var body: some View { + InfoCard(icon: "dot.radiowaves.left.and.right", title: "蓝牙设备") { + if let devices { + if devices.isEmpty { + Text("没有已连接的蓝牙设备").dsFont(.xs).foregroundStyle(DS.Palette.textTertiary) + } + ForEach(devices) { device in + HStack(spacing: DS.Space.s2) { + Image(systemName: symbol(device.kind)) + .font(.system(size: DS.TextSize.sm.rawValue)) + .foregroundStyle(DS.Palette.textSecondary) + .frame(width: DS.Size.iconStandalone) + Text(verbatim: device.name) + .dsFont(.xs, weight: .medium) + .foregroundStyle(DS.Palette.textPrimary) + .lineLimit(1) + Spacer(minLength: DS.Space.s2) + if device.batteries.isEmpty { + Text("不提供电量").dsFont(.xs).foregroundStyle(DS.Palette.textTertiary) + } + ForEach(device.batteries, id: \.label) { battery in + HStack(spacing: DS.Space.s1) { + if device.batteries.count > 1 { + 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, + 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) + .monospacedDigit() + } + } + } + } + } else { + Text("正在读取…").dsFont(.xs).foregroundStyle(DS.Palette.textTertiary) + } + } + .task { + while !Task.isCancelled { + devices = await Task.detached { BluetoothBatteryReader.read() }.value + try? await Task.sleep(for: .seconds(60)) + } + } + } + + private func symbol(_ kind: BluetoothDevice.Kind) -> String { + switch kind { + case .keyboard: "keyboard" + case .mouse: "computermouse" + case .trackpad: "rectangle.and.hand.point.up.left" + case .headphones: "headphones" + case .phone: "iphone" + case .other: "dot.radiowaves.left.and.right" + } + } +} diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/State/AlertController.swift b/Packages/OpenStatsKit/Sources/OpenStatsUI/State/AlertController.swift index ea74dca..9ab99e3 100644 --- a/Packages/OpenStatsKit/Sources/OpenStatsUI/State/AlertController.swift +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/State/AlertController.swift @@ -7,7 +7,7 @@ import UserNotifications /// 可以发系统通知的状况 public enum AlertKind: String, CaseIterable, Identifiable, Sendable { - case cpuTemperature, memoryPressure, diskSpace, networkDown, batteryHealth + case cpuTemperature, memoryPressure, diskSpace, networkDown, batteryHealth, bluetoothBattery public var id: String { rawValue } @@ -18,6 +18,7 @@ public enum AlertKind: String, CaseIterable, Identifiable, Sendable { case .diskSpace: "磁盘空间不足" case .networkDown: "网络断开" case .batteryHealth: "电池健康度下降" + case .bluetoothBattery: "蓝牙设备电量低" } } @@ -28,6 +29,7 @@ public enum AlertKind: String, CaseIterable, Identifiable, Sendable { case .diskSpace: "启动磁盘可用空间低于 10% 或 10 GB,每 10 分钟检查一次" case .networkDown: "网络连接中断超过 20 秒,恢复后再提示一次" case .batteryHealth: "电池最大容量低于 80%,每 30 天最多提醒一次" + case .bluetoothBattery: "已连接的键盘、鼠标、耳机等电量低于 15%,每 10 分钟检查一次" } } @@ -38,6 +40,7 @@ public enum AlertKind: String, CaseIterable, Identifiable, Sendable { case .diskSpace: "internaldrive" case .networkDown: "wifi.slash" case .batteryHealth: "battery.25percent" + case .bluetoothBattery: "dot.radiowaves.left.and.right" } } @@ -48,7 +51,7 @@ public enum AlertKind: String, CaseIterable, Identifiable, Sendable { case .memoryPressure: .memory case .diskSpace: .disk case .networkDown: .network - case .batteryHealth: .system + case .batteryHealth, .bluetoothBattery: .system } } @@ -58,7 +61,7 @@ public enum AlertKind: String, CaseIterable, Identifiable, Sendable { case .cpuTemperature: 60 case .memoryPressure: 30 case .networkDown: 20 - case .diskSpace, .batteryHealth: 0 + case .diskSpace, .batteryHealth, .bluetoothBattery: 0 } } @@ -66,7 +69,7 @@ public enum AlertKind: String, CaseIterable, Identifiable, Sendable { var cooldown: TimeInterval { switch self { case .cpuTemperature, .memoryPressure, .networkDown: 30 * 60 - case .diskSpace: 6 * 60 * 60 + case .diskSpace, .bluetoothBattery: 6 * 60 * 60 case .batteryHealth: 30 * 24 * 60 * 60 } } @@ -113,6 +116,8 @@ public final class AlertController: NSObject { @ObservationIgnored private var networkNotified = false @ObservationIgnored private var networkDownTask: Task? @ObservationIgnored private var periodicTask: Task? + /// 每个蓝牙设备上次提醒的时间 + @ObservationIgnored private var bluetoothNotified: [String: Date] = [:] /// 单元测试与截图进程没有应用包标识,不能使用通知中心 private var center: UNUserNotificationCenter? { @@ -145,11 +150,12 @@ public final class AlertController: NSObject { networkNotified = false } - if enabled.contains(.diskSpace) || enabled.contains(.batteryHealth) { + if enabled.contains(.diskSpace) || enabled.contains(.batteryHealth) || enabled.contains(.bluetoothBattery) { if periodicTask == nil { periodicTask = Task { [weak self] in while !Task.isCancelled { self?.checkPeriodic() + await self?.checkBluetooth() try? await Task.sleep(for: .seconds(10 * 60), tolerance: .seconds(60)) } } @@ -195,6 +201,21 @@ public final class AlertController: NSObject { } } + private func checkBluetooth(now: Date = Date()) async { + guard settings.enabledAlerts.contains(.bluetoothBattery) else { return } + let devices = await Task.detached { BluetoothBatteryReader.read() }.value + for device in devices { + guard let lowest = device.batteries.min(by: { $0.percent < $1.percent }), lowest.percent <= 15 else { + bluetoothNotified[device.id] = nil + continue + } + if let last = bluetoothNotified[device.id], now.timeIntervalSince(last) < AlertKind.bluetoothBattery.cooldown { continue } + bluetoothNotified[device.id] = now + let part = device.batteries.count > 1 ? "\(lowest.label)" : "" + send(.bluetoothBattery, title: "\(device.name)电量低", body: "\(device.name)\(part)只剩 \(lowest.percent)%,记得充电。") + } + } + private func fireIfNeeded(_ kind: AlertKind, isActive: Bool, now: Date, body: @autoclosure () -> String) { var tracker = trackers[kind] ?? AlertTracker() let fire = tracker.update(isActive: isActive, now: now, sustain: kind.sustain, cooldown: kind.cooldown) diff --git a/Packages/OpenStatsKit/Tests/MetricsTests/HistoryDatabaseTests.swift b/Packages/OpenStatsKit/Tests/MetricsTests/HistoryDatabaseTests.swift index c00c475..b9cf4b0 100644 --- a/Packages/OpenStatsKit/Tests/MetricsTests/HistoryDatabaseTests.swift +++ b/Packages/OpenStatsKit/Tests/MetricsTests/HistoryDatabaseTests.swift @@ -45,3 +45,25 @@ import Testing #expect(await database.count() == 0) } } + +@Suite struct BluetoothBatteryTests { + @Test func parsesConnectedDevices() { + let json = """ + {"SPBluetoothDataType":[{"controller_properties":{}, + "device_connected":[ + {"AirPods Pro":{"device_address":"AA:BB","device_minorType":"Headphones","device_batteryLevelLeft":"80%","device_batteryLevelRight":"75 %","device_batteryLevelCase":"40%"}}, + {"Magic Keyboard":{"device_address":"CC:DD","device_minorType":"Keyboard","device_batteryLevelMain":"55%"}} + ], + "device_not_connected":[{"Old Mouse":{"device_address":"EE:FF","device_minorType":"Mouse"}}]}]} + """ + let devices = BluetoothBatteryReader.parseSystemProfiler(Data(json.utf8)) + #expect(devices.count == 2) + let airpods = devices.first { $0.name == "AirPods Pro" } + #expect(airpods?.kind == .headphones) + #expect(airpods?.batteries.map(\.percent) == [80, 75, 40]) + #expect(airpods?.batteries.map(\.label) == ["左耳", "右耳", "充电盒"]) + #expect(devices.first { $0.name == "Magic Keyboard" }?.batteries.first?.percent == 55) + #expect(BluetoothBatteryReader.percentValue("120%") == nil) + #expect(BluetoothBatteryReader.parseSystemProfiler(Data("oops".utf8)).isEmpty) + } +}