Skip to content

Commit 5e2aec3

Browse files
committed
fix: try to omit duplicates of the Bluetooth devices using vendor and product ID (#3025)
1 parent 9f20153 commit 5e2aec3

File tree

2 files changed

+124
-102
lines changed

2 files changed

+124
-102
lines changed

Modules/Bluetooth/main.swift

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,9 @@ public struct BLEDevice: Codable {
2424
var isConnected: Bool = false
2525
var isPaired: Bool = false
2626

27+
var vendorId: Int? = nil
28+
var productId: Int? = nil
29+
2730
var peripheral: CBPeripheral? = nil
2831
var isPeripheralInitialized: Bool = false
2932

@@ -39,17 +42,19 @@ public struct BLEDevice: Codable {
3942
}
4043

4144
private enum CodingKeys: String, CodingKey {
42-
case address, name, uuid, RSSI, batteryLevel, isConnected, isPaired
45+
case address, name, uuid, RSSI, batteryLevel, isConnected, isPaired, vendorId, productId
4346
}
4447

45-
init(address: String, name: String, uuid: UUID?, RSSI: Int?, batteryLevel: [KeyValue_t], isConnected: Bool, isPaired: Bool) {
48+
init(address: String, name: String, uuid: UUID?, RSSI: Int?, batteryLevel: [KeyValue_t], isConnected: Bool, isPaired: Bool, vendorId: Int? = nil, productId: Int? = nil) {
4649
self.address = address
4750
self.name = name
4851
self.uuid = uuid
4952
self.RSSI = RSSI
5053
self.batteryLevel = batteryLevel
5154
self.isConnected = isConnected
5255
self.isPaired = isPaired
56+
self.vendorId = vendorId
57+
self.productId = productId
5358
}
5459

5560
public init(from decoder: Decoder) throws {
@@ -61,6 +66,8 @@ public struct BLEDevice: Codable {
6166
self.batteryLevel = try container.decode(Array<KeyValue_t>.self, forKey: .batteryLevel)
6267
self.isConnected = try container.decode(Bool.self, forKey: .isConnected)
6368
self.isPaired = try container.decode(Bool.self, forKey: .isPaired)
69+
self.vendorId = try? container.decode(Int.self, forKey: .vendorId)
70+
self.productId = try? container.decode(Int.self, forKey: .productId)
6471
}
6572

6673
public func encode(to encoder: Encoder) throws {
@@ -72,6 +79,8 @@ public struct BLEDevice: Codable {
7279
try container.encode(batteryLevel, forKey: .batteryLevel)
7380
try container.encode(isConnected, forKey: .isConnected)
7481
try container.encode(isPaired, forKey: .isPaired)
82+
try container.encode(vendorId, forKey: .vendorId)
83+
try container.encode(productId, forKey: .productId)
7584
}
7685
}
7786

@@ -117,7 +126,7 @@ public class Bluetooth: Module {
117126
active.forEach { (d: BLEDevice) in
118127
if d.state {
119128
d.batteryLevel.forEach { (p: KeyValue_t) in
120-
list.append(Stack_t(key: "\(d.address)-\(p.key)", value: "\(p.value)%"))
129+
list.append(Stack_t(key: "\(d.address)-\(p.key)", value: "\(p.value)%", label: "\(d.name) - \(p.key)"))
121130
}
122131
}
123132
}

Modules/Bluetooth/readers.swift

Lines changed: 112 additions & 99 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ private struct bleDevice {
1919
var address: String
2020
var uuid: UUID?
2121
var batteryLevel: [KeyValue_t]
22+
var vendorId: Int? = nil
23+
var productId: Int? = nil
2224
}
2325

2426
private struct ioDevice {
@@ -86,7 +88,9 @@ internal class DevicesReader: Reader<[BLEDevice]>, CBCentralManagerDelegate, CBP
8688
self.devices[idx].batteryLevel = data.batteryLevel
8789
self.devices[idx].isPaired = device.isPaired
8890
self.devices[idx].isConnected = device.isConnected
89-
91+
if self.devices[idx].vendorId == nil { self.devices[idx].vendorId = data.vendorId }
92+
if self.devices[idx].productId == nil { self.devices[idx].productId = data.productId }
93+
9094
return
9195
}
9296

@@ -97,7 +101,9 @@ internal class DevicesReader: Reader<[BLEDevice]>, CBCentralManagerDelegate, CBP
97101
RSSI: rssi,
98102
batteryLevel: data.batteryLevel,
99103
isConnected: device.isConnected,
100-
isPaired: device.isPaired
104+
isPaired: device.isPaired,
105+
vendorId: data.vendorId,
106+
productId: data.productId
101107
))
102108
}
103109

@@ -150,18 +156,18 @@ internal class DevicesReader: Reader<[BLEDevice]>, CBCentralManagerDelegate, CBP
150156

151157
if !pmsetName.isEmpty,
152158
let idx = self.devices.firstIndex(where: {
153-
$0.name.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() == pmsetName
159+
let deviceName = $0.name.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
160+
return deviceName == pmsetName || deviceName.contains(pmsetName) || pmsetName.contains(deviceName)
154161
}) {
155162
if !p.batteryLevel.isEmpty {
156163
self.devices[idx].batteryLevel = p.batteryLevel
157164
}
158165
return
159166
}
160167

161-
if !p.address.isEmpty,
168+
if let pVendor = p.vendorId, let pProduct = p.productId,
162169
let idx = self.devices.firstIndex(where: {
163-
!$0.address.isEmpty &&
164-
$0.address.caseInsensitiveCompare(p.address) == .orderedSame
170+
$0.vendorId == pVendor && $0.productId == pProduct
165171
}) {
166172
if !p.batteryLevel.isEmpty {
167173
self.devices[idx].batteryLevel = p.batteryLevel
@@ -176,7 +182,9 @@ internal class DevicesReader: Reader<[BLEDevice]>, CBCentralManagerDelegate, CBP
176182
RSSI: 100,
177183
batteryLevel: p.batteryLevel,
178184
isConnected: true,
179-
isPaired: false
185+
isPaired: false,
186+
vendorId: p.vendorId,
187+
productId: p.productId
180188
))
181189
}
182190

@@ -205,7 +213,9 @@ internal class DevicesReader: Reader<[BLEDevice]>, CBCentralManagerDelegate, CBP
205213
address = addr
206214
}
207215

208-
list.append(bleDevice(name: name, address: address, uuid: nil, batteryLevel: [KeyValue_t(key: "battery", value: "\(batteryPercent)")]))
216+
let vendorId = d.object(forKey: "VendorID") as? Int
217+
let productId = d.object(forKey: "ProductID") as? Int
218+
list.append(bleDevice(name: name, address: address, uuid: nil, batteryLevel: [KeyValue_t(key: "battery", value: "\(batteryPercent)")], vendorId: vendorId, productId: productId))
209219
}
210220

211221
return list
@@ -371,119 +381,122 @@ internal class DevicesReader: Reader<[BLEDevice]>, CBCentralManagerDelegate, CBP
371381

372382
// MARK: - PMSET data
373383
private func pmsetAccessoryLevels() -> [bleDevice] {
374-
guard let res = process(path: "/usr/bin/pmset", arguments: ["-g", "accps"]) else { return [] }
384+
guard let res = process(path: "/usr/bin/pmset", arguments: ["-g", "accps", "-xml"]) else { return [] }
375385

376-
struct Entry {
377-
let originalName: String
378-
let normalizedName: String
379-
let percent: Int
380-
let id: String
381-
let isCase: Bool
382-
let state: String? // "charging" | "discharging"
383-
}
386+
let plists = res.components(separatedBy: "<?xml")
387+
.filter { !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }
388+
.compactMap { chunk -> [String: Any]? in
389+
let xml = "<?xml" + chunk
390+
guard let data = xml.data(using: .utf8),
391+
let plist = try? PropertyListSerialization.propertyList(from: data, format: nil) as? [String: Any] else {
392+
return nil
393+
}
394+
return plist
395+
}
384396

385-
var grouped: [String: [Entry]] = [:]
386-
var displayNameForGroup: [String: String] = [:]
397+
struct PmsetEntry {
398+
let name: String
399+
let capacity: Int
400+
let accessoryIdentifier: String
401+
let partIdentifier: String?
402+
let groupIdentifier: String?
403+
let category: String?
404+
let isCharging: Bool
405+
let vendorId: Int?
406+
let productId: Int?
407+
let combinedParts: [[String: Any]]?
408+
}
387409

388-
for raw in res.components(separatedBy: .newlines) {
389-
let line = raw.trimmingCharacters(in: .whitespacesAndNewlines)
390-
guard line.hasPrefix("-"), let tabIdx = line.firstIndex(of: "\t") else { continue }
391-
392-
var namePart = String(line[line.index(after: line.startIndex)..<tabIdx]).trimmingCharacters(in: .whitespaces)
393-
394-
var parsedID = ""
395-
if let idMatch = namePart.range(of: #"(?<=\(id=)\d+(?=\))"#, options: .regularExpression) {
396-
parsedID = String(namePart[idMatch])
397-
}
398-
if let idRange = namePart.range(of: #"\s*\(id=\d+\)$"#, options: .regularExpression) {
399-
namePart.removeSubrange(idRange)
400-
}
401-
guard !namePart.isEmpty else { continue }
410+
var entries: [PmsetEntry] = []
411+
for dict in plists {
412+
guard let name = dict["Name"] as? String,
413+
let capacity = dict["Current Capacity"] as? Int,
414+
let accessoryId = dict["Accessory Identifier"] as? String else { continue }
402415

403-
let details = String(line[line.index(after: tabIdx)...]).trimmingCharacters(in: .whitespaces)
404-
guard let first = details.split(separator: ";").first else { continue }
405-
406-
let pctString = first.replacingOccurrences(of: "%", with: "").trimmingCharacters(in: .whitespaces)
407-
guard let pct = Int(pctString) else { continue }
408-
409-
let normalized = namePart.lowercased()
410-
let isCase = normalized.contains("etui") || normalized.contains("case")
411-
412-
if !isCase && details.range(of: #"\bremaining\b"#, options: .regularExpression) != nil {
413-
continue
414-
}
415-
416-
let groupKey = normalized
417-
.replacingOccurrences(of: #"^\s*(etui|case)\s+"#, with: "", options: .regularExpression)
418-
.trimmingCharacters(in: .whitespaces)
419-
420-
let state: String?
421-
if details.range(of: #"\bcharging\b"#, options: .regularExpression) != nil {
422-
state = "charging"
423-
} else if details.range(of: #"\bdischarging\b"#, options: .regularExpression) != nil {
424-
state = "discharging"
416+
let isCharging: Bool
417+
if let charging = dict["Is Charging"] as? Bool {
418+
isCharging = charging
419+
} else if let state = dict["Power Source State"] as? String {
420+
isCharging = state == "AC Power"
425421
} else {
426-
state = nil
422+
isCharging = false
427423
}
428424

429-
grouped[groupKey, default: []].append(Entry(
430-
originalName: namePart,
431-
normalizedName: normalized,
432-
percent: pct,
433-
id: parsedID,
434-
isCase: isCase,
435-
state: state
425+
entries.append(PmsetEntry(
426+
name: name,
427+
capacity: capacity,
428+
accessoryIdentifier: accessoryId,
429+
partIdentifier: dict["Part Identifier"] as? String,
430+
groupIdentifier: dict["Group Identifier"] as? String,
431+
category: dict["Accessory Category"] as? String,
432+
isCharging: isCharging,
433+
vendorId: dict["Vendor ID"] as? Int,
434+
productId: dict["Product ID"] as? Int,
435+
combinedParts: dict["Combined Parts"] as? [[String: Any]]
436436
))
437-
438-
if displayNameForGroup[groupKey] == nil {
439-
let display = namePart
440-
.replacingOccurrences(of: #"^\s*(?i:etui|case)\s+"#, with: "", options: .regularExpression)
441-
.trimmingCharacters(in: .whitespaces)
442-
displayNameForGroup[groupKey] = display
437+
}
438+
439+
var grouped: [String: [PmsetEntry]] = [:]
440+
var standalone: [PmsetEntry] = []
441+
for entry in entries {
442+
if let groupId = entry.groupIdentifier {
443+
grouped[groupId, default: []].append(entry)
444+
} else {
445+
standalone.append(entry)
443446
}
444447
}
445448

446449
var out: [bleDevice] = []
447450

448-
for (groupKey, entries) in grouped {
449-
let displayName = displayNameForGroup[groupKey] ?? entries.first?.originalName ?? groupKey
451+
for entry in standalone {
452+
let state = entry.isCharging ? "charging" : "discharging"
453+
out.append(bleDevice(
454+
name: entry.name,
455+
address: entry.accessoryIdentifier,
456+
uuid: nil,
457+
batteryLevel: [KeyValue_t(key: "battery", value: "\(entry.capacity)", additional: state)],
458+
vendorId: entry.vendorId,
459+
productId: entry.productId
460+
))
461+
}
462+
463+
for (_, group) in grouped {
464+
let combinedEntry = group.first(where: { $0.partIdentifier == "Combined" })
465+
let caseEntry = group.first(where: { $0.partIdentifier == "Case" || $0.category == "Audio Battery Case" })
466+
let displayName = combinedEntry?.name ?? group.first(where: { !($0.category ?? "").contains("Case") })?.name ?? group.first?.name ?? ""
467+
let accessoryId = combinedEntry?.accessoryIdentifier ?? group.first?.accessoryIdentifier ?? ""
468+
450469
var kv: [KeyValue_t] = []
451470

452-
if entries.count == 1, let e = entries.first {
453-
kv = [KeyValue_t(key: "battery", value: "\(e.percent)", additional: e.state)]
454-
} else {
455-
if let c = entries.first(where: { $0.isCase }) {
456-
kv.append(KeyValue_t(key: "case", value: "\(c.percent)", additional: c.state))
457-
}
458-
459-
let buds = entries
460-
.filter { !$0.isCase }
461-
.sorted { lhs, rhs in
462-
let li = Int(lhs.id) ?? Int.max
463-
let ri = Int(rhs.id) ?? Int.max
464-
if li != ri { return li < ri }
465-
return lhs.id < rhs.id
466-
}
467-
468-
if buds.count >= 1 {
469-
kv.append(KeyValue_t(key: "first", value: "\(buds[0].percent)", additional: buds[0].state))
470-
}
471-
if buds.count >= 2 {
472-
kv.append(KeyValue_t(key: "second", value: "\(buds[1].percent)", additional: buds[1].state))
473-
}
474-
475-
if kv.isEmpty, let first = entries.first {
476-
kv = [KeyValue_t(key: "battery", value: "\(first.percent)", additional: first.state)]
471+
if let c = caseEntry {
472+
let state = c.isCharging ? "charging" : "discharging"
473+
kv.append(KeyValue_t(key: "case", value: "\(c.capacity)", additional: state))
474+
}
475+
476+
if let parts = combinedEntry?.combinedParts {
477+
for part in parts {
478+
guard let partId = part["Part Identifier"] as? String,
479+
let cap = part["Current Capacity"] as? Int else { continue }
480+
let charging = (part["Is Charging"] as? Bool) ?? false
481+
let state = charging ? "charging" : "discharging"
482+
kv.append(KeyValue_t(key: partId.lowercased(), value: "\(cap)", additional: state))
477483
}
478484
}
479485

480-
let mergedAddress = entries.map { $0.id }.sorted().joined(separator: "x")
486+
if kv.isEmpty, let e = combinedEntry ?? group.first {
487+
let state = e.isCharging ? "charging" : "discharging"
488+
kv.append(KeyValue_t(key: "battery", value: "\(e.capacity)", additional: state))
489+
}
481490

491+
let vendorId = combinedEntry?.vendorId ?? group.first?.vendorId
492+
let productId = combinedEntry?.productId ?? group.first?.productId
482493
out.append(bleDevice(
483494
name: displayName,
484-
address: mergedAddress,
495+
address: accessoryId,
485496
uuid: nil,
486-
batteryLevel: kv
497+
batteryLevel: kv,
498+
vendorId: vendorId,
499+
productId: productId
487500
))
488501
}
489502

0 commit comments

Comments
 (0)