master ohmu/fastfetch-openbsd / src / detection / bluetooth / bluetooth_apple.m
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}