164 lines
6.1 KiB
Swift
164 lines
6.1 KiB
Swift
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.sorted(by: { $0.isConnected && !$1.isConnected }) {
|
|
var key = Self.key(for: device)
|
|
var migratedEntry: (device: BluetoothDevice, lastSeen: Date)?
|
|
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 {
|
|
migratedEntry = previous.value
|
|
entries[previous.key] = nil
|
|
if device.address.isEmpty { device.address = previous.value.device.address }
|
|
key = Self.key(for: device)
|
|
}
|
|
}
|
|
currentKeys.insert(key)
|
|
if device.isConnected {
|
|
device.lastSeen = nil
|
|
entries[key] = (device, now)
|
|
result.append(device)
|
|
} else if let cached = entries[key] ?? migratedEntry {
|
|
var recent = cached.device
|
|
recent.name = device.name
|
|
recent.address = device.address
|
|
recent.kind = device.kind
|
|
recent.isConnected = false
|
|
recent.isCharging = nil
|
|
recent.lastSeen = cached.lastSeen
|
|
result.append(recent)
|
|
} else {
|
|
device.lastSeen = nil
|
|
result.append(device)
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|
|
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
|
|
public final class BluetoothController {
|
|
public enum Demand: Equatable, Sendable {
|
|
/// 不轮询
|
|
case off
|
|
/// 菜单栏需要(电量低提示、没有电池的 Mac 显示设备电量):每 5 分钟
|
|
case background
|
|
/// 电池弹窗 / 页面、本机信息页打开着:每分钟
|
|
case foreground
|
|
|
|
var interval: Duration? {
|
|
switch self {
|
|
case .off: nil
|
|
case .background: .seconds(300)
|
|
case .foreground: .seconds(60)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// nil 表示还没读过
|
|
public private(set) var devices: [BluetoothDevice]?
|
|
public private(set) var updatedAt: Date?
|
|
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 where device.isConnected {
|
|
for battery in device.batteries {
|
|
if let current = best, battery.percent >= current.percent { continue }
|
|
best = (device, battery.label, battery.percent)
|
|
}
|
|
}
|
|
return best
|
|
}
|
|
|
|
func setDemand(_ demand: Demand) {
|
|
guard demand != self.demand else { return }
|
|
self.demand = demand
|
|
task?.cancel()
|
|
task = nil
|
|
guard let interval = demand.interval else { return }
|
|
task = Task { [weak self] in
|
|
while !Task.isCancelled {
|
|
await self?.read()
|
|
try? await Task.sleep(for: interval)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// 弹窗里的刷新按钮
|
|
func refresh() {
|
|
Task { await read() }
|
|
}
|
|
|
|
private func read() async {
|
|
guard !isReading else { return }
|
|
isReading = true
|
|
let current = await Task.detached(priority: .utility) { BluetoothBatteryReader.read() }.value
|
|
let now = Date()
|
|
devices = deviceCache.merge(current: current, now: now)
|
|
updatedAt = now
|
|
isReading = false
|
|
}
|
|
}
|
|
|
|
extension BluetoothDevice.Kind {
|
|
/// 列表与菜单栏里代表设备类型的图标
|
|
var symbol: String {
|
|
switch self {
|
|
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"
|
|
}
|
|
}
|
|
}
|