ohmu
·
2026-10-15
1#include "bluetooth.h"
2#include "common/percent.h"
3#include "common/strutil.h"
4
5#import <CoreBluetooth/CoreBluetooth.h>
6#import <IOBluetooth/IOBluetooth.h>
7
8@interface IOBluetoothDevice()
9 @property (nonatomic) uint8_t batteryPercentCase;
10 @property (nonatomic) uint8_t batteryPercentCombined;
11 @property (nonatomic) uint8_t batteryPercentLeft;
12 @property (nonatomic) uint8_t batteryPercentRight;
13 @property (nonatomic) uint8_t batteryPercentSingle;
14 // Not in the public header. The class is a wrapper around the private Core Bluetooth peer
15 // objects, and this is the one that carries the `NSUUID` the LE side hands out: `CBClassicPeer`
16 // and `CBPeripheral` both derive from `CBPeer`, which has one `identifier` and a separate
17 // `connectedTransport` byte -- one identity per device, not one per stack, which is the same idea
18 // as the container id the Windows backend joins on. The wrapper's own `identifier` below answers
19 // nil on macOS 27, so it is kept only as a second source.
20 @property (nonatomic) NSUUID* identifier;
21 @property (nonatomic) CBPeripheral* peripheral;
22@end
23
24@interface CBPeripheral()
25 // Not in the public header. `state` and `isConnected` both answer *disconnected* for a
26 // peripheral the system has connected but this process has not -- which is every peripheral
27 // this module ever sees, because it reads the list and never connects to anything. Measured on
28 // macOS 27 across the seven peripherals `retrieveConnectedPeripheralsWithServices:` returned,
29 // two of them connected: `state` was 0 on all seven, `isConnected` 0 on all seven, and
30 // `isConnectedToSystem` 1 on exactly the two.
31 @property (readonly, nonatomic) BOOL isConnectedToSystem;
32@end
33
34// ---------------------------------------------------------------------------------------------
35// The LE stack
36// ---------------------------------------------------------------------------------------------
37//
38// Core Bluetooth is the only public route to a peripheral that speaks Low Energy rather than BR/EDR.
39// What it will and will not answer was measured on macOS 27 with a standalone probe program
40// (12 paired devices, one of them connected), because none of it follows from the headers:
41//
42// * `retrieveConnectedPeripheralsWithServices:@[]` answers with **nothing** -- 0 devices in 0.1 ms.
43// The empty list is not "everything", so the assigned 16-bit UUIDs have to be spelled out. With
44// them the same call answers in 0.5 ms, which is the reason this is the entry point that is used.
45//
46// * Scanning is not an option. `scanForPeripheralsWithServices:nil` turned up **57** peripherals in
47// five seconds and 54 of them had no name at all: it reports every phone, watch and beacon in
48// radio range, not the devices the machine knows. It is also the only way to see a peripheral that
49// is not connected, so `showDisconnected` cannot be honoured on this platform -- there is no
50// "paired LE peripherals" query the way `IOBluetoothDevice.pairedDevices` is one for classic.
51//
52// * The two stacks are joined on `NSUUID`, and the join is exact rather than a guess. The LE side
53// hands one out as `CBPeripheral.identifier`, and the classic side has the same value on the
54// `CBPeripheral` that `IOBluetoothDevice` wraps, the class being a wrapper around the private Core
55// Bluetooth peer objects. A `CBPeer` carries a single `identifier` and a separate
56// `connectedTransport` byte, so a dual-mode device is one identity rather than two -- the same
57// shape as the container id the Windows backend joins on. It is not the device address: no LE
58// identifier carried any of the twelve classic addresses.
59// Two sources were measured on macOS 27 and only one of them is safe. `IOBluetoothDevice`'s own
60// `identifier` answers nil for all twelve paired devices, and `peer.identifier` disagrees with
61// `peripheral.identifier` on three of them (ERAZER G501: `23F3EDAC-...` against `18BDC152-...`),
62// so `peer` is the classic peer and `peripheral` the LE one. Only `peripheral` is read here, and
63// only where it is non-nil; the name is the fallback for the rest, trusted only when exactly one
64// classic entry carries it. The machine this was written against has two devices called
65// "ERAZER N500", and guessing between them would put one device's LE link on the other.
66//
67// * The object that would make this trivially exact -- the private `CBDevice`, which holds
68// `bleAddressData` and `btAddressData` at once -- is out of reach: `+[CBDiscovery
69// devicesWithDiscoveryFlags:error:]` answers `CBErrorDomain -71168` for every flag tried, which is
70// the entitlement it needs and a plain command line tool does not have.
71//
72// * `pairedDevices` hands a dual-mode device out **twice** on macOS 27, once per stack, under the
73// same name and the same address: the headset this was written against comes back as two entries,
74// both carrying the same `peer` and `peripheral`. The second copy is therefore folded into the
75// first as it is detected, rather than appended and removed again, both because printing one
76// headset twice is wrong and because the duplicate makes the name ambiguous, which would leave the
77// fallback above unusable. It matters that the fold happens while walking the list rather than
78// afterwards: the identifier registered for the LE join comes from the *second* copy, which is the
79// one holding the `CBPeripheral`.
80//
81// * The classic half cannot be folded into this one, and was measured rather than assumed. Core
82// Bluetooth's public surface has no paired-device query at all: `CBCentralManager` enumerates
83// *connected* peripherals only, which on this machine is 2 of the 12. The paired query does exist,
84// as `CBClassicManager.retrievePairedPeersWithOptions:`, but it is private and it is what
85// `IOBluetoothDevice.pairedDevices` already calls -- `IOBluetoothCoreBluetoothCoordinator`, which
86// lives in IOBluetooth.framework, holds the manager and is the caller. Both Core-Bluetooth-only
87// routes were measured and neither replaces it: a `CBClassicManager` this process creates never
88// fills its peer map (0 entries after two seconds, `retrievePairedPeersWithOptions:` answers nil),
89// and `CBCentralManager.sharedPairingAgent.retrievePairedPeers` answers with 7 of the 11 devices
90// as bare `CBPeripheral` objects carrying no address, no battery and no class of device.
91//
92// * `CBPeripheral.state` is not the connection state the caller means. It answers `Disconnected` for
93// every peripheral the retrieval above returns, and so does `isConnected`; only the private
94// `isConnectedToSystem` reflects that the system has the device. Reading `state` reported every LE
95// peripheral as disconnected, which both suppressed the signal strength below and dropped a
96// connected LE-only peripheral whenever `showDisconnected` was off.
97//
98// * The signal strength is a `readRSSI` round trip. It is asked for only where the system says the
99// link is up, and the callbacks are waited for as a group, so a machine with no LE link pays
100// nothing.
101//
102// * `showType` is honoured here rather than in the module, because the two stacks are two separate
103// walks with two separate permission surfaces: a stack the user switched off is not asked about
104// at all. The LE pass keeps working without the classic one, it only loses the address join and
105// falls back to matching by name.
106//
107// Note on the permission model, which is what the first bullet above cannot show: any process that
108// touches Bluetooth is subject to the `kTCCServiceBluetoothAlways` service, and the request is
109// attributed to the *responsible* process. A host application that does not declare
110// `NSBluetoothAlwaysUsageDescription` in its own Info.plist does not get a prompt -- the process is
111// killed with SIGABRT instead. Terminal.app is an Apple platform binary and iTerm2, Ghostty and
112// kitty all declare the key, so a normal terminal run prompts and remembers the answer. fastfetch
113// deliberately does not embed a usage description of its own: that would give it a second TCC
114// identity, which for an unsigned binary changes on every rebuild and would re-prompt each time.
115
116#define FF_BLUETOOTH_LE_STATE_WAIT_MS 500 // The state callback lands in 18.7 ms when powered on
117// A cap, not a measured latency. `didReadRSSI` was never reached on macOS 27 until the
118// `isConnectedToSystem` fix below, so what one round trip costs is still unmeasured; this is ten
119// times the 18.7 ms the state callback was measured at, and it is only ever paid when the system
120// reports a live LE link.
121#define FF_BLUETOOTH_LE_RSSI_WAIT_MS 200
122
123@interface FFBluetoothLeCentral : NSObject <CBCentralManagerDelegate, CBPeripheralDelegate>
124@property(nonatomic, strong) CBCentralManager* central;
125@property(nonatomic, strong) NSMutableDictionary<NSString*, NSNumber*>* signalStrength;
126@property(nonatomic) NSUInteger signalStrengthAsked;
127@property(nonatomic) NSUInteger signalStrengthAnswered;
128@property(nonatomic) BOOL stateKnown;
129@end
130
131@implementation FFBluetoothLeCentral
132
133- (instancetype) init {
134 self = [super init];
135 _signalStrength = [NSMutableDictionary dictionary];
136 return self;
137}
138
139- (void) centralManagerDidUpdateState:(CBCentralManager*)central {
140 self.stateKnown = YES;
141}
142
143- (void) peripheral:(CBPeripheral*)peripheral didReadRSSI:(NSNumber*)RSSI error:(NSError*)error {
144 ++self.signalStrengthAnswered;
145 if (!error && RSSI) {
146 self.signalStrength[peripheral.identifier.UUIDString] = RSSI;
147 }
148}
149
150@end
151
152// The assigned 16-bit UUIDs, plus the 0xFEE0-0xFEFF block, which is where the vendor-specific ones
153// live. `retrieveConnectedPeripheralsWithServices:` filters on this list, so anything exposing only
154// a 128-bit custom service is out of reach -- that is a limit of the API, not of this list.
155static NSArray<CBUUID*>* leServiceUuids(void) {
156 static NSArray<CBUUID*>* uuids;
157 static dispatch_once_t once;
158 dispatch_once(&once, ^{
159 NSMutableArray<CBUUID*>* list = [NSMutableArray array];
160 for (uint32_t i = 0x1800; i <= 0x18FF; ++i) {
161 [list addObject: [CBUUID UUIDWithString: [NSString stringWithFormat: @"%04X", i]]];
162 }
163 for (uint32_t i = 0xFEE0; i <= 0xFEFF; ++i) {
164 [list addObject: [CBUUID UUIDWithString: [NSString stringWithFormat: @"%04X", i]]];
165 }
166 uuids = list;
167 });
168 return uuids;
169}
170
171// See the note on `CBPeripheral.isConnectedToSystem` at the top of the file. The fallback keeps the
172// module working on a system that does not answer the private property.
173static bool leIsConnected(CBPeripheral* peripheral) {
174 if ([peripheral respondsToSelector: @selector(isConnectedToSystem)]) {
175 return peripheral.isConnectedToSystem;
176 }
177 return peripheral.state == CBPeripheralStateConnected;
178}
179
180// The address is what a device is looked up by, both while the classic list is walked and by the LE
181// pass, and it is the only field the two copies of a dual-mode device are guaranteed to agree on.
182static FFBluetoothResult* findByAddress(FFlist* devices, const char* address) {
183 FF_LIST_FOR_EACH (FFBluetoothResult, device, *devices) {
184 if (ffStrbufEqualS(&device->address, address)) {
185 return device;
186 }
187 }
188 return nullptr;
189}
190
191// The class of device, as the classic stack reports it. It is built into a scratch buffer rather than
192// straight into the entry because the entry may already exist: see the merge in `ffDetectBluetooth`,
193// where the second copy of a dual-mode device only fills what the first copy left empty.
194static void setClassOfDevice(FFstrbuf* type, IOBluetoothDevice* ioDevice) {
195 if(ioDevice.serviceClassMajor & kBluetoothServiceClassMajorLimitedDiscoverableMode)
196 ffStrbufAppendS(type, "Limited Discoverable Mode, ");
197 if(ioDevice.serviceClassMajor & kBluetoothServiceClassMajorReserved1)
198 ffStrbufAppendS(type, "LE audio, ");
199 if(ioDevice.serviceClassMajor & kBluetoothServiceClassMajorReserved2)
200 ffStrbufAppendS(type, "Reserved for future use, ");
201 if(ioDevice.serviceClassMajor & kBluetoothServiceClassMajorPositioning)
202 ffStrbufAppendS(type, "Positioning, ");
203 if(ioDevice.serviceClassMajor & kBluetoothServiceClassMajorNetworking)
204 ffStrbufAppendS(type, "Networking, ");
205 if(ioDevice.serviceClassMajor & kBluetoothServiceClassMajorRendering)
206 ffStrbufAppendS(type, "Rendering, ");
207 if(ioDevice.serviceClassMajor & kBluetoothServiceClassMajorCapturing)
208 ffStrbufAppendS(type, "Capturing, ");
209 if(ioDevice.serviceClassMajor & kBluetoothServiceClassMajorObjectTransfer)
210 ffStrbufAppendS(type, "Object Transfer, ");
211 if(ioDevice.serviceClassMajor & kBluetoothServiceClassMajorAudio)
212 ffStrbufAppendS(type, "Audio, ");
213 if(ioDevice.serviceClassMajor & kBluetoothServiceClassMajorTelephony)
214 ffStrbufAppendS(type, "Telephony, ");
215 if(ioDevice.serviceClassMajor & kBluetoothServiceClassMajorInformation)
216 ffStrbufAppendS(type, "Information, ");
217
218 if(type->length == 0)
219 {
220 switch(ioDevice.deviceClassMajor)
221 {
222 case kBluetoothDeviceClassMajorMiscellaneous:
223 ffStrbufAppendS(type, "Miscellaneous");
224 break;
225 case kBluetoothDeviceClassMajorComputer:
226 ffStrbufAppendS(type, "Computer");
227 break;
228 case kBluetoothDeviceClassMajorPhone:
229 ffStrbufAppendS(type, "Phone");
230 break;
231 case kBluetoothDeviceClassMajorLANAccessPoint:
232 ffStrbufAppendS(type, "LAN/Network Access point");
233 break;
234 case kBluetoothDeviceClassMajorAudio:
235 ffStrbufAppendS(type, "Audio/Video");
236 break;
237 case kBluetoothDeviceClassMajorPeripheral:
238 ffStrbufAppendS(type, "Peripheral");
239 break;
240 case kBluetoothDeviceClassMajorImaging:
241 ffStrbufAppendS(type, "Imaging");
242 break;
243 case kBluetoothDeviceClassMajorWearable:
244 ffStrbufAppendS(type, "Wearable");
245 break;
246 case kBluetoothDeviceClassMajorToy:
247 ffStrbufAppendS(type, "Toy");
248 break;
249 case kBluetoothDeviceClassMajorHealth:
250 ffStrbufAppendS(type, "Health");
251 break;
252 case kBluetoothDeviceClassMajorUnclassified:
253 ffStrbufAppendS(type, "Uncategorized");
254 break;
255 default:
256 ffStrbufAppendS(type, "Unknown");
257 break;
258 }
259 }
260 else
261 {
262 ffStrbufTrimRight(type, ' ');
263 ffStrbufTrimRight(type, ',');
264 }
265}
266
267// The fallback for a system whose `IOBluetoothDevice` does not answer `identifier`. A name that is
268// carried by more than one classic entry cannot identify anything, so an ambiguous name joins
269// nothing and the peripheral is reported as its own device instead of being merged.
270static FFBluetoothResult* findByName(FFlist* devices, const char* name) {
271 FFBluetoothResult* found = nullptr;
272 FF_LIST_FOR_EACH (FFBluetoothResult, device, *devices) {
273 // Only the classic half counts. The LE pass runs after the classic one and appends its
274 // entries to the same list, so the peripheral an earlier iteration of this very loop created
275 // would otherwise make its own name look ambiguous -- and the rule is there to keep one name
276 // from standing for two classic devices, not to count the LE copy of one of them.
277 if (!(device->deviceType & FF_BLUETOOTH_DEVICE_TYPE_CLASSIC_BIT)) {
278 continue;
279 }
280 if (!ffStrEqualsIgnCase(device->name.chars, name)) {
281 continue;
282 }
283 if (found) {
284 return nullptr;
285 }
286 found = device;
287 }
288 return found;
289}
290
291static const char* detectLe(FFBluetoothOptions* options, FFlist* devices /* FFBluetoothResult */, NSDictionary<NSString*, NSString*>* classicAddressByIdentifier) {
292 CBManagerAuthorization authorization = CBCentralManager.authorization;
293 if (authorization == CBManagerAuthorizationDenied || authorization == CBManagerAuthorizationRestricted) {
294 return nullptr; // The user said no. `NotDetermined` is left to the manager below, which asks.
295 }
296
297 FFBluetoothLeCentral* delegate = [FFBluetoothLeCentral new];
298 delegate.central = [[CBCentralManager alloc] initWithDelegate: delegate queue: dispatch_get_main_queue()];
299
300 // Every CoreBluetooth entry point answers on a delegate callback, so the run loop has to be
301 // pumped until the state callback arrives. It is bounded because a module cannot wait forever.
302 NSDate* deadline = [NSDate dateWithTimeIntervalSinceNow: (double) FF_BLUETOOTH_LE_STATE_WAIT_MS / 1000];
303 while (!delegate.stateKnown && [deadline timeIntervalSinceNow] > 0) {
304 [[NSRunLoop currentRunLoop] runMode: NSDefaultRunLoopMode beforeDate: [NSDate dateWithTimeIntervalSinceNow: 0.01]];
305 }
306
307 if (delegate.central.state != CBManagerStatePoweredOn) {
308 return nullptr; // Powered off, unauthorized or no radio: the classic list stands on its own
309 }
310
311 NSArray<CBPeripheral*>* peripherals = [delegate.central retrieveConnectedPeripheralsWithServices: leServiceUuids()];
312 if (peripherals.count == 0) {
313 return nullptr;
314 }
315
316 for (CBPeripheral* peripheral in peripherals) {
317 if (leIsConnected(peripheral)) {
318 peripheral.delegate = delegate;
319 ++delegate.signalStrengthAsked;
320 [peripheral readRSSI];
321 }
322 }
323
324 if (delegate.signalStrengthAsked > 0) {
325 deadline = [NSDate dateWithTimeIntervalSinceNow: (double) FF_BLUETOOTH_LE_RSSI_WAIT_MS / 1000];
326 while (delegate.signalStrengthAnswered < delegate.signalStrengthAsked && [deadline timeIntervalSinceNow] > 0) {
327 [[NSRunLoop currentRunLoop] runMode: NSDefaultRunLoopMode beforeDate: [NSDate dateWithTimeIntervalSinceNow: 0.01]];
328 }
329 }
330
331 for (CBPeripheral* peripheral in peripherals) {
332 const char* name = peripheral.name.length > 0 ? peripheral.name.UTF8String : nullptr;
333 bool connected = leIsConnected(peripheral);
334
335 // The classic half wins wherever it has an answer: its name, address and type come from the
336 // class of device rather than from a name the two stacks may spell differently. The
337 // identifier is what makes the join exact; the name only stands in when it is missing.
338 FFBluetoothResult* target = nullptr;
339 NSString* address = classicAddressByIdentifier[peripheral.identifier.UUIDString];
340 if (address) {
341 target = findByAddress(devices, address.UTF8String);
342 }
343 if (!target && name) {
344 target = findByName(devices, name);
345 }
346
347 if (!target && !connected && !options->showDisconnected) {
348 continue; // An LE-only peripheral nobody asked to see
349 }
350
351 if (!target) {
352 target = FF_LIST_ADD(FFBluetoothResult, *devices);
353 ffStrbufInit(&target->name);
354 ffStrbufInit(&target->address);
355 ffStrbufInit(&target->type);
356 target->deviceType = FF_BLUETOOTH_DEVICE_TYPE_NONE;
357 target->battery = 0;
358 target->signalQuality = -DBL_MAX;
359 target->connected = false;
360
361 if (name) {
362 ffStrbufSetS(&target->name, name);
363 } else {
364 ffStrbufSetStatic(&target->name, "Unknown Device");
365 }
366 // Core Bluetooth never hands out the device address, so the identifier is the only
367 // identity there is. It is stable on the host but is not an address, and it is not the
368 // 17-character form the other backends report.
369 ffStrbufSetS(&target->address, peripheral.identifier.UUIDString.UTF8String);
370 }
371
372 target->deviceType = (FFBluetoothDeviceType) (target->deviceType | FF_BLUETOOTH_DEVICE_TYPE_LE_BIT);
373 target->connected |= connected;
374
375 NSNumber* rssi = delegate.signalStrength[peripheral.identifier.UUIDString];
376 if (rssi) {
377 target->signalQuality = ffRssiToSignalQuality(rssi.intValue);
378 }
379 }
380
381 return nullptr;
382}
383
384// The classic (BR/EDR) half. `classicAddressByIdentifier` is an output rather than an input: the
385// classic list is the only place a device address can be had from, and the LE pass needs those
386// addresses to recognise a peripheral that has already been listed.
387static const char* detectClassic(FFBluetoothOptions* options, FFlist* devices /* FFBluetoothResult */, NSMutableDictionary<NSString*, NSString*>* classicAddressByIdentifier)
388{
389 NSArray<IOBluetoothDevice*>* ioDevices = IOBluetoothDevice.pairedDevices;
390 if(!ioDevices)
391 return "IOBluetoothDevice.pairedDevices failed";
392
393 for(IOBluetoothDevice* ioDevice in ioDevices)
394 {
395 if (!options->showDisconnected && !ioDevice.isConnected)
396 continue;
397
398 // Normalised before the lookup rather than after the entry is created, because the address is
399 // what decides whether this is a device already seen: `pairedDevices` reports a dual-mode one
400 // once per stack, under one name and one address.
401 FF_STRBUF_AUTO_DESTROY address = ffStrbufCreateS(ioDevice.addressString.UTF8String);
402 ffStrbufReplaceAllC(&address, '-', ':');
403 ffStrbufUpperCase(&address);
404
405 // Registered before the duplicate is folded away, because it is the *second* copy of a
406 // dual-mode device that carries the `CBPeripheral` -- the first one has `peripheral` nil. The
407 // wrapper's own `identifier` answers nil here, but that `CBPeripheral` carries the same
408 // `NSUUID` the LE side hands out. Both accesses are guarded because both are private: a system
409 // that answers neither falls back to matching by name.
410 if (address.length > 0 && [ioDevice respondsToSelector: @selector(peripheral)] && ioDevice.peripheral) {
411 NSUUID* identifier = ioDevice.peripheral.identifier;
412 if (identifier)
413 classicAddressByIdentifier[identifier.UUIDString] = [NSString stringWithUTF8String: address.chars];
414 }
415
416 FF_STRBUF_AUTO_DESTROY type = ffStrbufCreate();
417 setClassOfDevice(&type, ioDevice);
418
419 uint8_t battery = 0;
420 if (ioDevice.batteryPercentSingle)
421 battery = ioDevice.batteryPercentSingle;
422 else if (ioDevice.batteryPercentCombined)
423 battery = ioDevice.batteryPercentCombined;
424 else if (ioDevice.batteryPercentCase)
425 battery = ioDevice.batteryPercentCase;
426
427 // A device with no address cannot be recognised as a repeat, so it is always its own entry.
428 FFBluetoothResult* device = address.length > 0 ? findByAddress(devices, address.chars) : nullptr;
429 if (!device)
430 {
431 device = FF_LIST_ADD(FFBluetoothResult, *devices);
432 ffStrbufInit(&device->name);
433 ffStrbufInit(&device->address);
434 ffStrbufInit(&device->type);
435 device->deviceType = FF_BLUETOOTH_DEVICE_TYPE_NONE;
436 device->battery = 0;
437 device->signalQuality = -DBL_MAX;
438 device->connected = false;
439 ffStrbufSetS(&device->name, ioDevice.name.UTF8String);
440 ffStrbufSet(&device->address, &address);
441 }
442
443 // The two copies of a dual-mode device are not identical -- one is the classic peer and the
444 // other the LE one -- so each field is taken wherever the entry already kept has nothing,
445 // rather than the second copy simply being dropped. The name is not merged: both copies spell
446 // it the same way, and the first one is the one the LE pass looks up by.
447 if (device->battery == 0)
448 device->battery = battery;
449 if (device->type.length == 0)
450 ffStrbufSet(&device->type, &type);
451 device->deviceType = (FFBluetoothDeviceType) (device->deviceType | FF_BLUETOOTH_DEVICE_TYPE_CLASSIC_BIT); // IOBluetooth is the BR/EDR stack
452 device->connected |= !!ioDevice.isConnected;
453 }
454
455 return nullptr;
456}
457
458const char* ffDetectBluetooth(FFBluetoothOptions* options, FFlist* devices /* FFBluetoothResult */)
459{
460 // `showType` selects which stacks are walked, not merely which entries are printed: a stack the
461 // user switched off is not asked about at all. The classic pass is the one that produces the
462 // identifier map the LE pass joins on, so it is nil when that pass does not run -- the LE pass
463 // then has nothing to match against and reports every peripheral as a device of its own.
464 NSMutableDictionary<NSString*, NSString*>* classicAddressByIdentifier = nil;
465
466 if (options->showType & FF_BLUETOOTH_DEVICE_TYPE_CLASSIC_BIT) {
467 classicAddressByIdentifier = [NSMutableDictionary dictionary];
468
469 const char* error = detectClassic(options, devices, classicAddressByIdentifier);
470 if (error) {
471 return error;
472 }
473 }
474
475 // Last, so that a peripheral answering on both stacks is matched against the classic list and
476 // ends up as one entry rather than two.
477 if (options->showType & FF_BLUETOOTH_DEVICE_TYPE_LE_BIT) {
478 detectLe(options, devices, classicAddressByIdentifier);
479 }
480
481 return nullptr;
482}