蓝牙设备电量与低电量通知

This commit is contained in:
gentpan committed 2026-09-13 08:48:40 +05:00
1 parent 9e236670e5
commit 4f46446494
5 files changed
+230 -5

No files matched your search

+1
View File
@@ -18,6 +18,7 @@
- 卸载应用(主窗口工具分组):列出“应用程序”里的第三方应用及其体积,选中或把应用拖进来后找出它留下的应用数据、缓存、偏好设置、沙盒容器、窗口状态、日志、网页数据和登录启动项,可逐项取消勾选;确认后连同应用一起移到废纸篓(可放回),并可同时从程序坞移除图标。只匹配应用包名与同名目录,系统自带和 Apple 的应用不列出,正在运行时提示先退出。
- 启动项(主窗口工具分组):列出当前用户、所有用户的 LaunchAgents 和系统 LaunchDaemons,显示所属应用、可执行文件、是否登录时运行 / 保持运行,以及运行中(PID)、已加载、已停用状态;当前用户的启动项可以直接停用或重新启用(写入 launchd 停用记录并卸载,不删除文件),其余只读并提供登录项设置入口。
- 历史(主窗口监控分组):每分钟把 CPU 平均与峰值、内存与内存压力、网络上下行、GPU、CPU 最高温度和整机功耗写入本机 SQLite 数据库,保留 7 天;可回看最近 1 小时 / 24 小时 / 7 天,睡眠等没有记录的时段线条断开,内存压力严重的时段标红,鼠标移到图上查看那一刻的数值;可关闭记录或清除历史,数据不上传。
- 蓝牙设备电量:本机信息页列出已连接的键盘、鼠标、触控板和耳机的电量(AirPods 分左耳、右耳、充电盒),低于 20% 标红;通知里新增“蓝牙设备电量低”(低于 15%)。
#### 调整
@@ -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
}
}
@@ -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"
}
}
}
@@ -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<Void, Never>?
@ObservationIgnored private var periodicTask: Task<Void, Never>?
/// 每个蓝牙设备上次提醒的时间
@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)
@@ -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)
}
}