mirror of
https://github.com/morgan9e/macos-stats
synced 2026-04-14 00:04:15 +09:00
feat: added a new way to obtain Bluetooth device battery levels using pmset (#2990)
This commit is contained in:
@@ -15,6 +15,8 @@ import Carbon
|
|||||||
extension String: @retroactive LocalizedError {
|
extension String: @retroactive LocalizedError {
|
||||||
public var errorDescription: String? { return self }
|
public var errorDescription: String? { return self }
|
||||||
|
|
||||||
|
public var nilIfEmpty: String? { self.isEmpty ? nil : self }
|
||||||
|
|
||||||
public var digits: String {
|
public var digits: String {
|
||||||
return components(separatedBy: CharacterSet.decimalDigits.inverted).joined()
|
return components(separatedBy: CharacterSet.decimalDigits.inverted).joined()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -113,6 +113,11 @@ internal class BLEView: NSStackView {
|
|||||||
batteryLevel.forEach { (pair: KeyValue_t) in
|
batteryLevel.forEach { (pair: KeyValue_t) in
|
||||||
if let view = self.levels.first(where: { $0.identifier?.rawValue == pair.key }) {
|
if let view = self.levels.first(where: { $0.identifier?.rawValue == pair.key }) {
|
||||||
view.stringValue = "\(pair.value)%"
|
view.stringValue = "\(pair.value)%"
|
||||||
|
if let additional = pair.additional as? String {
|
||||||
|
view.toolTip = "\(pair.key) - \(additional)"
|
||||||
|
} else {
|
||||||
|
view.toolTip = pair.key
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
self.addLevel(pair)
|
self.addLevel(pair)
|
||||||
}
|
}
|
||||||
@@ -124,7 +129,11 @@ internal class BLEView: NSStackView {
|
|||||||
valueView.identifier = NSUserInterfaceItemIdentifier(rawValue: pair.key)
|
valueView.identifier = NSUserInterfaceItemIdentifier(rawValue: pair.key)
|
||||||
valueView.font = NSFont.systemFont(ofSize: 12, weight: .regular)
|
valueView.font = NSFont.systemFont(ofSize: 12, weight: .regular)
|
||||||
valueView.stringValue = "\(pair.value)%"
|
valueView.stringValue = "\(pair.value)%"
|
||||||
|
if let additional = pair.additional as? String {
|
||||||
|
valueView.toolTip = "\(pair.key) - \(additional)"
|
||||||
|
} else {
|
||||||
valueView.toolTip = pair.key
|
valueView.toolTip = pair.key
|
||||||
|
}
|
||||||
self.addArrangedSubview(valueView)
|
self.addArrangedSubview(valueView)
|
||||||
self.levels.append(valueView)
|
self.levels.append(valueView)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ internal class DevicesReader: Reader<[BLEDevice]>, CBCentralManagerDelegate, CBP
|
|||||||
let hid = self.HIDDevices()
|
let hid = self.HIDDevices()
|
||||||
let SPB = self.profilerDevices()
|
let SPB = self.profilerDevices()
|
||||||
var list = self.cacheDevices()
|
var list = self.cacheDevices()
|
||||||
|
let pmsetLevels = self.pmsetAccessoryLevels()
|
||||||
|
|
||||||
hid.forEach { v in
|
hid.forEach { v in
|
||||||
if !list.contains(where: {$0.address == v.address}) {
|
if !list.contains(where: {$0.address == v.address}) {
|
||||||
@@ -142,6 +143,43 @@ internal class DevicesReader: Reader<[BLEDevice]>, CBCentralManagerDelegate, CBP
|
|||||||
self.devices = self.devices.filter({ !SPB.1.contains($0.address) })
|
self.devices = self.devices.filter({ !SPB.1.contains($0.address) })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pmsetLevels.forEach { p in
|
||||||
|
let pmsetName = (p.name ?? "")
|
||||||
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
.lowercased()
|
||||||
|
|
||||||
|
if !pmsetName.isEmpty,
|
||||||
|
let idx = self.devices.firstIndex(where: {
|
||||||
|
$0.name.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() == pmsetName
|
||||||
|
}) {
|
||||||
|
if !p.batteryLevel.isEmpty {
|
||||||
|
self.devices[idx].batteryLevel = p.batteryLevel
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if !p.address.isEmpty,
|
||||||
|
let idx = self.devices.firstIndex(where: {
|
||||||
|
!$0.address.isEmpty &&
|
||||||
|
$0.address.caseInsensitiveCompare(p.address) == .orderedSame
|
||||||
|
}) {
|
||||||
|
if !p.batteryLevel.isEmpty {
|
||||||
|
self.devices[idx].batteryLevel = p.batteryLevel
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
self.devices.append(BLEDevice(
|
||||||
|
address: p.address,
|
||||||
|
name: p.name ?? "",
|
||||||
|
uuid: p.uuid,
|
||||||
|
RSSI: 100,
|
||||||
|
batteryLevel: p.batteryLevel,
|
||||||
|
isConnected: true,
|
||||||
|
isPaired: false
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
self.callback(self.devices.filter({ $0.RSSI != nil }))
|
self.callback(self.devices.filter({ $0.RSSI != nil }))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -330,4 +368,125 @@ internal class DevicesReader: Reader<[BLEDevice]>, CBCentralManagerDelegate, CBP
|
|||||||
self.bleLevels[peripheral.identifier] = KeyValue_t(key: "battery", value: "\(batteryLevel)")
|
self.bleLevels[peripheral.identifier] = KeyValue_t(key: "battery", value: "\(batteryLevel)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - PMSET data
|
||||||
|
private func pmsetAccessoryLevels() -> [bleDevice] {
|
||||||
|
guard let res = process(path: "/usr/bin/pmset", arguments: ["-g", "accps"]) else { return [] }
|
||||||
|
|
||||||
|
struct Entry {
|
||||||
|
let originalName: String
|
||||||
|
let normalizedName: String
|
||||||
|
let percent: Int
|
||||||
|
let id: String
|
||||||
|
let isCase: Bool
|
||||||
|
let state: String? // "charging" | "discharging"
|
||||||
|
}
|
||||||
|
|
||||||
|
var grouped: [String: [Entry]] = [:]
|
||||||
|
var displayNameForGroup: [String: String] = [:]
|
||||||
|
|
||||||
|
for raw in res.components(separatedBy: .newlines) {
|
||||||
|
let line = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard line.hasPrefix("-"), let tabIdx = line.firstIndex(of: "\t") else { continue }
|
||||||
|
|
||||||
|
var namePart = String(line[line.index(after: line.startIndex)..<tabIdx]).trimmingCharacters(in: .whitespaces)
|
||||||
|
|
||||||
|
var parsedID = ""
|
||||||
|
if let idMatch = namePart.range(of: #"(?<=\(id=)\d+(?=\))"#, options: .regularExpression) {
|
||||||
|
parsedID = String(namePart[idMatch])
|
||||||
|
}
|
||||||
|
if let idRange = namePart.range(of: #"\s*\(id=\d+\)$"#, options: .regularExpression) {
|
||||||
|
namePart.removeSubrange(idRange)
|
||||||
|
}
|
||||||
|
guard !namePart.isEmpty else { continue }
|
||||||
|
|
||||||
|
let details = String(line[line.index(after: tabIdx)...]).trimmingCharacters(in: .whitespaces)
|
||||||
|
guard let first = details.split(separator: ";").first else { continue }
|
||||||
|
|
||||||
|
let pctString = first.replacingOccurrences(of: "%", with: "").trimmingCharacters(in: .whitespaces)
|
||||||
|
guard let pct = Int(pctString) else { continue }
|
||||||
|
|
||||||
|
let normalized = namePart.lowercased()
|
||||||
|
let isCase = normalized.contains("etui") || normalized.contains("case")
|
||||||
|
|
||||||
|
if !isCase && details.range(of: #"\bremaining\b"#, options: .regularExpression) != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
let groupKey = normalized
|
||||||
|
.replacingOccurrences(of: #"^\s*(etui|case)\s+"#, with: "", options: .regularExpression)
|
||||||
|
.trimmingCharacters(in: .whitespaces)
|
||||||
|
|
||||||
|
let state: String?
|
||||||
|
if details.range(of: #"\bcharging\b"#, options: .regularExpression) != nil {
|
||||||
|
state = "charging"
|
||||||
|
} else if details.range(of: #"\bdischarging\b"#, options: .regularExpression) != nil {
|
||||||
|
state = "discharging"
|
||||||
|
} else {
|
||||||
|
state = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
grouped[groupKey, default: []].append(Entry(
|
||||||
|
originalName: namePart,
|
||||||
|
normalizedName: normalized,
|
||||||
|
percent: pct,
|
||||||
|
id: parsedID,
|
||||||
|
isCase: isCase,
|
||||||
|
state: state
|
||||||
|
))
|
||||||
|
|
||||||
|
if displayNameForGroup[groupKey] == nil {
|
||||||
|
let display = namePart
|
||||||
|
.replacingOccurrences(of: #"^\s*(?i:etui|case)\s+"#, with: "", options: .regularExpression)
|
||||||
|
.trimmingCharacters(in: .whitespaces)
|
||||||
|
displayNameForGroup[groupKey] = display
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var out: [bleDevice] = []
|
||||||
|
|
||||||
|
for (groupKey, entries) in grouped {
|
||||||
|
let displayName = displayNameForGroup[groupKey] ?? entries.first?.originalName ?? groupKey
|
||||||
|
var kv: [KeyValue_t] = []
|
||||||
|
|
||||||
|
if entries.count == 1, let e = entries.first {
|
||||||
|
kv = [KeyValue_t(key: "battery", value: "\(e.percent)", additional: e.state)]
|
||||||
|
} else {
|
||||||
|
if let c = entries.first(where: { $0.isCase }) {
|
||||||
|
kv.append(KeyValue_t(key: "case", value: "\(c.percent)", additional: c.state))
|
||||||
|
}
|
||||||
|
|
||||||
|
let buds = entries
|
||||||
|
.filter { !$0.isCase }
|
||||||
|
.sorted { lhs, rhs in
|
||||||
|
let li = Int(lhs.id) ?? Int.max
|
||||||
|
let ri = Int(rhs.id) ?? Int.max
|
||||||
|
if li != ri { return li < ri }
|
||||||
|
return lhs.id < rhs.id
|
||||||
|
}
|
||||||
|
|
||||||
|
if buds.count >= 1 {
|
||||||
|
kv.append(KeyValue_t(key: "first", value: "\(buds[0].percent)", additional: buds[0].state))
|
||||||
|
}
|
||||||
|
if buds.count >= 2 {
|
||||||
|
kv.append(KeyValue_t(key: "second", value: "\(buds[1].percent)", additional: buds[1].state))
|
||||||
|
}
|
||||||
|
|
||||||
|
if kv.isEmpty, let first = entries.first {
|
||||||
|
kv = [KeyValue_t(key: "battery", value: "\(first.percent)", additional: first.state)]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mergedAddress = entries.map { $0.id }.sorted().joined(separator: "x")
|
||||||
|
|
||||||
|
out.append(bleDevice(
|
||||||
|
name: displayName,
|
||||||
|
address: mergedAddress,
|
||||||
|
uuid: nil,
|
||||||
|
batteryLevel: kv
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
return out
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user