diff --git a/CHANGELOG.md b/CHANGELOG.md index 67bd87f4f..fc1310793 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,10 @@ Changes: * Load average detection on GNU/Hurd now uses `getloadavg()` instead of reading `/proc/loadavg`, sharing the Solaris implementation. (Loadavg, GNU/Hurd) Features: +* Added Bluetooth Low Energy detection on Windows and macOS, through WinRT and Core Bluetooth. (Bluetooth, Windows / macOS) +* Added `{device-type}`, `{signal-quality}` and `{signal-quality-bar}` to the `Bluetooth` module, and `deviceType` and `signalQuality` to its JSON output. (Bluetooth) + * `deviceType` names the stacks a device answers on, `Classic` and/or `Low Energy`, and `signalQuality` is the signal strength of the LE link, converted from dBm on the same scale the `Wifi` module uses. + * Linux fills both from the BlueZ `Class`, `Appearance` and `RSSI` properties, and macOS from Core Bluetooth. The remaining platforms report `Classic` and no signal quality, which is what their backends can tell. * Added Bluetooth Core 6.0, 6.1, 6.2 and 6.3 to the `BluetoothRadio` version table, so an adapter reporting LMP version 14, 15, 16 or 17 now prints its version instead of the vendor name alone. (BluetoothRadio) * Added rum overlay package counting on RakuOS, exposed as `{rum}`. (Packages, Linux) * Improved image logo support diff --git a/CMakeLists.txt b/CMakeLists.txt index d10f6f397..4d1e9b9f2 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1076,6 +1076,7 @@ elseif(WIN32) src/detection/battery/battery_windows.c src/detection/bios/bios_windows.c src/detection/bluetooth/bluetooth_windows.c + src/detection/bluetooth/bluetooth_windows.cpp src/detection/bluetoothradio/bluetoothradio_windows.c src/detection/board/board_windows.c src/detection/bootmgr/bootmgr_windows.c @@ -1908,6 +1909,7 @@ elseif(APPLE) PRIVATE "-framework Cocoa" PRIVATE "-framework CoreFoundation" PRIVATE "-framework CoreAudio" + PRIVATE "-framework CoreBluetooth" PRIVATE "-framework CoreMedia" PRIVATE "-framework CoreVideo" PRIVATE "-framework ImageIO" diff --git a/doc/json_schema.json b/doc/json_schema.json index 2ed64ec2a..f8ac42894 100644 --- a/doc/json_schema.json +++ b/doc/json_schema.json @@ -285,7 +285,7 @@ "type": "string" }, "bluetoothFormat": { - "description": "Output format for the `Bluetooth` module. See Wiki for formatting syntax\n 1. {name}: Name\n 2. {address}: Address\n 3. {type}: Type\n 4. {battery-percentage}: Battery percentage number\n 5. {connected}: Is connected\n 6. {battery-percentage-bar}: Battery percentage bar", + "description": "Output format for the `Bluetooth` module. See Wiki for formatting syntax\n 1. {name}: Name\n 2. {address}: Address\n 3. {type}: Type\n 4. {device-type}: Bluetooth stacks the device answers on\n 5. {battery-percentage}: Battery percentage number\n 6. {battery-percentage-bar}: Battery percentage bar\n 7. {signal-quality}: Signal quality number\n 8. {signal-quality-bar}: Signal quality bar\n 9. {connected}: Is connected", "type": "string" }, "bluetoothradioFormat": { @@ -4043,10 +4043,9 @@ "const": "publicip" }, "url": { - "description": "URL of the public IP detection server to use. Only HTTP is supported", + "description": "URL of the public IP detection server to use. Only HTTP is supported\nIf empty, `http://ipinfo.io/json` is requested (`http://v6.ipinfo.io/json` when `ipv6` is set), with HTTP compression and TCP Fast Open enabled. A non-empty URL disables both and makes `ipv6` have no effect", "type": "string", - "format": "url", - "default": "http://ipinfo.io/ip" + "format": "url" }, "timeout": { "description": "Time in milliseconds to wait for the public IP server to respond\nSet to 0 to disable the timeout", diff --git a/src/detection/bluetooth/bluetooth.h b/src/detection/bluetooth/bluetooth.h index dde80853d..ac7379df3 100644 --- a/src/detection/bluetooth/bluetooth.h +++ b/src/detection/bluetooth/bluetooth.h @@ -3,11 +3,21 @@ #include "fastfetch.h" #include "modules/bluetooth/option.h" +// Which radio a device was seen over. A dual-mode device answers on both, and a platform that only +// walks one stack can only report the bit it knows about, so this is a bitfield rather than a value. +typedef enum FFBluetoothDeviceType: uint8_t { + FF_BLUETOOTH_DEVICE_TYPE_NONE = 0, + FF_BLUETOOTH_DEVICE_TYPE_CLASSIC_BIT = 1 << 0, // BR/EDR, the BTHENUM enumerator + FF_BLUETOOTH_DEVICE_TYPE_LE_BIT = 1 << 1, // Low Energy, the BTHLE enumerator +} FFBluetoothDeviceType; + typedef struct FFBluetoothResult { FFstrbuf name; FFstrbuf address; FFstrbuf type; - uint8_t battery; // 0-100% + FFBluetoothDeviceType deviceType; + uint8_t battery; // 0-100% + double signalQuality; // 0-100%, -DBL_MAX if unknown bool connected; } FFBluetoothResult; diff --git a/src/detection/bluetooth/bluetooth_apple.m b/src/detection/bluetooth/bluetooth_apple.m index 22ae236b5..2e966dff0 100644 --- a/src/detection/bluetooth/bluetooth_apple.m +++ b/src/detection/bluetooth/bluetooth_apple.m @@ -1,5 +1,8 @@ #include "bluetooth.h" +#include "common/percent.h" +#include "common/strutil.h" +#import #import @interface IOBluetoothDevice() @@ -8,105 +11,440 @@ @property (nonatomic) uint8_t batteryPercentLeft; @property (nonatomic) uint8_t batteryPercentRight; @property (nonatomic) uint8_t batteryPercentSingle; + // Not in the public header. The class is a wrapper around the private Core Bluetooth peer + // objects, and this is the one that carries the `NSUUID` the LE side hands out: `CBClassicPeer` + // and `CBPeripheral` both derive from `CBPeer`, which has one `identifier` and a separate + // `connectedTransport` byte -- one identity per device, not one per stack, which is the same idea + // as the container id the Windows backend joins on. The wrapper's own `identifier` below answers + // nil on macOS 27, so it is kept only as a second source. + @property (nonatomic) NSUUID* identifier; + @property (nonatomic) CBPeripheral* peripheral; @end +@interface CBPeripheral() + // Not in the public header. `state` and `isConnected` both answer *disconnected* for a + // peripheral the system has connected but this process has not -- which is every peripheral + // this module ever sees, because it reads the list and never connects to anything. Measured on + // macOS 27 across the seven peripherals `retrieveConnectedPeripheralsWithServices:` returned, + // two of them connected: `state` was 0 on all seven, `isConnected` 0 on all seven, and + // `isConnectedToSystem` 1 on exactly the two. + @property (readonly, nonatomic) BOOL isConnectedToSystem; +@end + +// --------------------------------------------------------------------------------------------- +// The LE stack +// --------------------------------------------------------------------------------------------- +// +// Core Bluetooth is the only public route to a peripheral that speaks Low Energy rather than BR/EDR. +// What it will and will not answer was measured on macOS 27 with `.workbuddy-ai/probes/ble/le_probe` +// (12 paired devices, one of them connected), because none of it follows from the headers: +// +// * `retrieveConnectedPeripheralsWithServices:@[]` answers with **nothing** -- 0 devices in 0.1 ms. +// The empty list is not "everything", so the assigned 16-bit UUIDs have to be spelled out. With +// them the same call answers in 0.5 ms, which is the reason this is the entry point that is used. +// +// * Scanning is not an option. `scanForPeripheralsWithServices:nil` turned up **57** peripherals in +// five seconds and 54 of them had no name at all: it reports every phone, watch and beacon in +// radio range, not the devices the machine knows. It is also the only way to see a peripheral that +// is not connected, so `showDisconnected` cannot be honoured on this platform -- there is no +// "paired LE peripherals" query the way `IOBluetoothDevice.pairedDevices` is one for classic. +// +// * The two stacks are joined on `NSUUID`, and the join is exact rather than a guess. The LE side +// hands one out as `CBPeripheral.identifier`, and the classic side has the same value on the +// `CBPeripheral` that `IOBluetoothDevice` wraps, the class being a wrapper around the private Core +// Bluetooth peer objects. A `CBPeer` carries a single `identifier` and a separate +// `connectedTransport` byte, so a dual-mode device is one identity rather than two -- the same +// shape as the container id the Windows backend joins on. It is not the device address: no LE +// identifier carried any of the twelve classic addresses. +// Two sources were measured on macOS 27 and only one of them is safe. `IOBluetoothDevice`'s own +// `identifier` answers nil for all twelve paired devices, and `peer.identifier` disagrees with +// `peripheral.identifier` on three of them (ERAZER G501: `23F3EDAC-...` against `18BDC152-...`), +// so `peer` is the classic peer and `peripheral` the LE one. Only `peripheral` is read here, and +// only where it is non-nil; the name is the fallback for the rest, trusted only when exactly one +// classic entry carries it. The machine this was written against has two devices called +// "ERAZER N500", and guessing between them would put one device's LE link on the other. +// +// * The object that would make this trivially exact -- the private `CBDevice`, which holds +// `bleAddressData` and `btAddressData` at once -- is out of reach: `+[CBDiscovery +// devicesWithDiscoveryFlags:error:]` answers `CBErrorDomain -71168` for every flag tried, which is +// the entitlement it needs and a plain command line tool does not have. +// +// * `pairedDevices` hands a dual-mode device out **twice** on macOS 27, once per stack, under the +// same name and the same address: the headset this was written against comes back as two entries, +// both carrying the same `peer` and `peripheral`. The second copy is therefore folded into the +// first as it is detected, rather than appended and removed again, both because printing one +// headset twice is wrong and because the duplicate makes the name ambiguous, which would leave the +// fallback above unusable. It matters that the fold happens while walking the list rather than +// afterwards: the identifier registered for the LE join comes from the *second* copy, which is the +// one holding the `CBPeripheral`. +// +// * The classic half cannot be folded into this one, and was measured rather than assumed. Core +// Bluetooth's public surface has no paired-device query at all: `CBCentralManager` enumerates +// *connected* peripherals only, which on this machine is 2 of the 12. The paired query does exist, +// as `CBClassicManager.retrievePairedPeersWithOptions:`, but it is private and it is what +// `IOBluetoothDevice.pairedDevices` already calls -- `IOBluetoothCoreBluetoothCoordinator`, which +// lives in IOBluetooth.framework, holds the manager and is the caller. Both Core-Bluetooth-only +// routes were measured and neither replaces it: a `CBClassicManager` this process creates never +// fills its peer map (0 entries after two seconds, `retrievePairedPeersWithOptions:` answers nil), +// and `CBCentralManager.sharedPairingAgent.retrievePairedPeers` answers with 7 of the 11 devices +// as bare `CBPeripheral` objects carrying no address, no battery and no class of device. +// `.workbuddy-ai/probes/ble/cb_only_probe.m` is the probe; the numbers are in `bug.md`. +// +// * `CBPeripheral.state` is not the connection state the caller means. It answers `Disconnected` for +// every peripheral the retrieval above returns, and so does `isConnected`; only the private +// `isConnectedToSystem` reflects that the system has the device. Reading `state` reported every LE +// peripheral as disconnected, which both suppressed the signal strength below and dropped a +// connected LE-only peripheral whenever `showDisconnected` was off. +// +// * The signal strength is a `readRSSI` round trip. It is asked for only where the system says the +// link is up, and the callbacks are waited for as a group, so a machine with no LE link pays +// nothing. +// +// Note on the permission model, which is what the first bullet above cannot show: any process that +// touches Bluetooth is subject to the `kTCCServiceBluetoothAlways` service, and the request is +// attributed to the *responsible* process. A host application that does not declare +// `NSBluetoothAlwaysUsageDescription` in its own Info.plist does not get a prompt -- the process is +// killed with SIGABRT instead. Terminal.app is an Apple platform binary and iTerm2, Ghostty and +// kitty all declare the key, so a normal terminal run prompts and remembers the answer. fastfetch +// deliberately does not embed a usage description of its own: that would give it a second TCC +// identity, which for an unsigned binary changes on every rebuild and would re-prompt each time. + +#define FF_BLUETOOTH_LE_STATE_WAIT_MS 500 // The state callback lands in 18.7 ms when powered on +// A cap, not a measured latency. `didReadRSSI` was never reached on macOS 27 until the +// `isConnectedToSystem` fix below, so what one round trip costs is still unmeasured; this is ten +// times the 18.7 ms the state callback was measured at, and it is only ever paid when the system +// reports a live LE link. +#define FF_BLUETOOTH_LE_RSSI_WAIT_MS 200 + +@interface FFBluetoothLeCentral : NSObject +@property(nonatomic, strong) CBCentralManager* central; +@property(nonatomic, strong) NSMutableDictionary* signalStrength; +@property(nonatomic) NSUInteger signalStrengthAsked; +@property(nonatomic) NSUInteger signalStrengthAnswered; +@property(nonatomic) BOOL stateKnown; +@end + +@implementation FFBluetoothLeCentral + +- (instancetype) init { + self = [super init]; + _signalStrength = [NSMutableDictionary dictionary]; + return self; +} + +- (void) centralManagerDidUpdateState:(CBCentralManager*)central { + self.stateKnown = YES; +} + +- (void) peripheral:(CBPeripheral*)peripheral didReadRSSI:(NSNumber*)RSSI error:(NSError*)error { + ++self.signalStrengthAnswered; + if (!error && RSSI) { + self.signalStrength[peripheral.identifier.UUIDString] = RSSI; + } +} + +@end + +// The assigned 16-bit UUIDs, plus the 0xFEE0-0xFEFF block, which is where the vendor-specific ones +// live. `retrieveConnectedPeripheralsWithServices:` filters on this list, so anything exposing only +// a 128-bit custom service is out of reach -- that is a limit of the API, not of this list. +static NSArray* ffBluetoothLeServiceUuids(void) { + static NSArray* uuids; + static dispatch_once_t once; + dispatch_once(&once, ^{ + NSMutableArray* list = [NSMutableArray array]; + for (uint32_t i = 0x1800; i <= 0x18FF; ++i) { + [list addObject: [CBUUID UUIDWithString: [NSString stringWithFormat: @"%04X", i]]]; + } + for (uint32_t i = 0xFEE0; i <= 0xFEFF; ++i) { + [list addObject: [CBUUID UUIDWithString: [NSString stringWithFormat: @"%04X", i]]]; + } + uuids = list; + }); + return uuids; +} + +// See the note on `CBPeripheral.isConnectedToSystem` at the top of the file. The fallback keeps the +// module working on a system that does not answer the private property. +static bool ffBluetoothLeIsConnected(CBPeripheral* peripheral) { + if ([peripheral respondsToSelector: @selector(isConnectedToSystem)]) { + return peripheral.isConnectedToSystem; + } + return peripheral.state == CBPeripheralStateConnected; +} + +// The address is what a device is looked up by, both while the classic list is walked and by the LE +// pass, and it is the only field the two copies of a dual-mode device are guaranteed to agree on. +static FFBluetoothResult* ffBluetoothFindByAddress(FFlist* devices, const char* address) { + FF_LIST_FOR_EACH (FFBluetoothResult, device, *devices) { + if (ffStrbufEqualS(&device->address, address)) { + return device; + } + } + return nullptr; +} + +// The class of device, as the classic stack reports it. It is built into a scratch buffer rather than +// straight into the entry because the entry may already exist: see the merge in `ffDetectBluetooth`, +// where the second copy of a dual-mode device only fills what the first copy left empty. +static void ffBluetoothSetClassOfDevice(FFstrbuf* type, IOBluetoothDevice* ioDevice) { + if(ioDevice.serviceClassMajor & kBluetoothServiceClassMajorLimitedDiscoverableMode) + ffStrbufAppendS(type, "Limited Discoverable Mode, "); + if(ioDevice.serviceClassMajor & kBluetoothServiceClassMajorReserved1) + ffStrbufAppendS(type, "LE audio, "); + if(ioDevice.serviceClassMajor & kBluetoothServiceClassMajorReserved2) + ffStrbufAppendS(type, "Reserved for future use, "); + if(ioDevice.serviceClassMajor & kBluetoothServiceClassMajorPositioning) + ffStrbufAppendS(type, "Positioning, "); + if(ioDevice.serviceClassMajor & kBluetoothServiceClassMajorNetworking) + ffStrbufAppendS(type, "Networking, "); + if(ioDevice.serviceClassMajor & kBluetoothServiceClassMajorRendering) + ffStrbufAppendS(type, "Rendering, "); + if(ioDevice.serviceClassMajor & kBluetoothServiceClassMajorCapturing) + ffStrbufAppendS(type, "Capturing, "); + if(ioDevice.serviceClassMajor & kBluetoothServiceClassMajorObjectTransfer) + ffStrbufAppendS(type, "Object Transfer, "); + if(ioDevice.serviceClassMajor & kBluetoothServiceClassMajorAudio) + ffStrbufAppendS(type, "Audio, "); + if(ioDevice.serviceClassMajor & kBluetoothServiceClassMajorTelephony) + ffStrbufAppendS(type, "Telephony, "); + if(ioDevice.serviceClassMajor & kBluetoothServiceClassMajorInformation) + ffStrbufAppendS(type, "Information, "); + + if(type->length == 0) + { + switch(ioDevice.deviceClassMajor) + { + case kBluetoothDeviceClassMajorMiscellaneous: + ffStrbufAppendS(type, "Miscellaneous"); + break; + case kBluetoothDeviceClassMajorComputer: + ffStrbufAppendS(type, "Computer"); + break; + case kBluetoothDeviceClassMajorPhone: + ffStrbufAppendS(type, "Phone"); + break; + case kBluetoothDeviceClassMajorLANAccessPoint: + ffStrbufAppendS(type, "LAN/Network Access point"); + break; + case kBluetoothDeviceClassMajorAudio: + ffStrbufAppendS(type, "Audio/Video"); + break; + case kBluetoothDeviceClassMajorPeripheral: + ffStrbufAppendS(type, "Peripheral"); + break; + case kBluetoothDeviceClassMajorImaging: + ffStrbufAppendS(type, "Imaging"); + break; + case kBluetoothDeviceClassMajorWearable: + ffStrbufAppendS(type, "Wearable"); + break; + case kBluetoothDeviceClassMajorToy: + ffStrbufAppendS(type, "Toy"); + break; + case kBluetoothDeviceClassMajorHealth: + ffStrbufAppendS(type, "Health"); + break; + case kBluetoothDeviceClassMajorUnclassified: + ffStrbufAppendS(type, "Uncategorized"); + break; + default: + ffStrbufAppendS(type, "Unknown"); + break; + } + } + else + { + ffStrbufTrimRight(type, ' '); + ffStrbufTrimRight(type, ','); + } +} + +// The fallback for a system whose `IOBluetoothDevice` does not answer `identifier`. A name that is +// carried by more than one classic entry cannot identify anything, so an ambiguous name joins +// nothing and the peripheral is reported as its own device instead of being merged. +static FFBluetoothResult* ffBluetoothFindByName(FFlist* devices, const char* name) { + FFBluetoothResult* found = nullptr; + FF_LIST_FOR_EACH (FFBluetoothResult, device, *devices) { + if (!ffStrEqualsIgnCase(device->name.chars, name)) { + continue; + } + if (found) { + return nullptr; + } + found = device; + } + return found; +} + +static const char* ffBluetoothDetectLe(FFBluetoothOptions* options, FFlist* devices /* FFBluetoothResult */, NSDictionary* classicAddressByIdentifier) { + CBManagerAuthorization authorization = CBCentralManager.authorization; + if (authorization == CBManagerAuthorizationDenied || authorization == CBManagerAuthorizationRestricted) { + return nullptr; // The user said no. `NotDetermined` is left to the manager below, which asks. + } + + FFBluetoothLeCentral* delegate = [FFBluetoothLeCentral new]; + delegate.central = [[CBCentralManager alloc] initWithDelegate: delegate queue: dispatch_get_main_queue()]; + + // Every CoreBluetooth entry point answers on a delegate callback, so the run loop has to be + // pumped until the state callback arrives. It is bounded because a module cannot wait forever. + NSDate* deadline = [NSDate dateWithTimeIntervalSinceNow: (double) FF_BLUETOOTH_LE_STATE_WAIT_MS / 1000]; + while (!delegate.stateKnown && [deadline timeIntervalSinceNow] > 0) { + [[NSRunLoop currentRunLoop] runMode: NSDefaultRunLoopMode beforeDate: [NSDate dateWithTimeIntervalSinceNow: 0.01]]; + } + + if (delegate.central.state != CBManagerStatePoweredOn) { + return nullptr; // Powered off, unauthorized or no radio: the classic list stands on its own + } + + NSArray* peripherals = [delegate.central retrieveConnectedPeripheralsWithServices: ffBluetoothLeServiceUuids()]; + if (peripherals.count == 0) { + return nullptr; + } + + for (CBPeripheral* peripheral in peripherals) { + if (ffBluetoothLeIsConnected(peripheral)) { + peripheral.delegate = delegate; + ++delegate.signalStrengthAsked; + [peripheral readRSSI]; + } + } + + if (delegate.signalStrengthAsked > 0) { + deadline = [NSDate dateWithTimeIntervalSinceNow: (double) FF_BLUETOOTH_LE_RSSI_WAIT_MS / 1000]; + while (delegate.signalStrengthAnswered < delegate.signalStrengthAsked && [deadline timeIntervalSinceNow] > 0) { + [[NSRunLoop currentRunLoop] runMode: NSDefaultRunLoopMode beforeDate: [NSDate dateWithTimeIntervalSinceNow: 0.01]]; + } + } + + for (CBPeripheral* peripheral in peripherals) { + const char* name = peripheral.name.length > 0 ? peripheral.name.UTF8String : nullptr; + bool connected = ffBluetoothLeIsConnected(peripheral); + + // The classic half wins wherever it has an answer: its name, address and type come from the + // class of device rather than from a name the two stacks may spell differently. The + // identifier is what makes the join exact; the name only stands in when it is missing. + FFBluetoothResult* target = nullptr; + NSString* address = classicAddressByIdentifier[peripheral.identifier.UUIDString]; + if (address) { + target = ffBluetoothFindByAddress(devices, address.UTF8String); + } + if (!target && name) { + target = ffBluetoothFindByName(devices, name); + } + + if (!target && !connected && !options->showDisconnected) { + continue; // An LE-only peripheral nobody asked to see + } + + if (!target) { + target = FF_LIST_ADD(FFBluetoothResult, *devices); + ffStrbufInit(&target->name); + ffStrbufInit(&target->address); + ffStrbufInit(&target->type); + target->deviceType = FF_BLUETOOTH_DEVICE_TYPE_NONE; + target->battery = 0; + target->signalQuality = -DBL_MAX; + target->connected = false; + + if (name) { + ffStrbufSetS(&target->name, name); + } else { + ffStrbufSetStatic(&target->name, "Unknown Device"); + } + // Core Bluetooth never hands out the device address, so the identifier is the only + // identity there is. It is stable on the host but is not an address, and it is not the + // 17-character form the other backends report. + ffStrbufSetS(&target->address, peripheral.identifier.UUIDString.UTF8String); + } + + target->deviceType = (FFBluetoothDeviceType) (target->deviceType | FF_BLUETOOTH_DEVICE_TYPE_LE_BIT); + target->connected |= connected; + + NSNumber* rssi = delegate.signalStrength[peripheral.identifier.UUIDString]; + if (rssi) { + target->signalQuality = ffRssiToSignalQuality(rssi.intValue); + } + } + + return nullptr; +} + const char* ffDetectBluetooth(FFBluetoothOptions* options, FFlist* devices /* FFBluetoothResult */) { NSArray* ioDevices = IOBluetoothDevice.pairedDevices; if(!ioDevices) return "IOBluetoothDevice.pairedDevices failed"; + // The identifier the LE pass matches on, mapped to the address rather than to a position in + // `devices`: the list is the caller's, and a position in it is not this module's to promise. + NSMutableDictionary* classicAddressByIdentifier = [NSMutableDictionary dictionary]; + for(IOBluetoothDevice* ioDevice in ioDevices) { if (!options->showDisconnected && !ioDevice.isConnected) continue; - FFBluetoothResult* device = FF_LIST_ADD(FFBluetoothResult, *devices); - ffStrbufInitS(&device->name, ioDevice.name.UTF8String); - ffStrbufInitS(&device->address, ioDevice.addressString.UTF8String); - ffStrbufReplaceAllC(&device->address, '-', ':'); - ffStrbufUpperCase(&device->address); - ffStrbufInit(&device->type); + // Normalised before the lookup rather than after the entry is created, because the address is + // what decides whether this is a device already seen: `pairedDevices` reports a dual-mode one + // once per stack, under one name and one address. + FF_STRBUF_AUTO_DESTROY address = ffStrbufCreateS(ioDevice.addressString.UTF8String); + ffStrbufReplaceAllC(&address, '-', ':'); + ffStrbufUpperCase(&address); + // Registered before the duplicate is folded away, because it is the *second* copy of a + // dual-mode device that carries the `CBPeripheral` -- the first one has `peripheral` nil. The + // wrapper's own `identifier` answers nil here, but that `CBPeripheral` carries the same + // `NSUUID` the LE side hands out. Both accesses are guarded because both are private: a system + // that answers neither falls back to matching by name. + if (address.length > 0 && [ioDevice respondsToSelector: @selector(peripheral)] && ioDevice.peripheral) { + NSUUID* identifier = ioDevice.peripheral.identifier; + if (identifier) + classicAddressByIdentifier[identifier.UUIDString] = [NSString stringWithUTF8String: address.chars]; + } + + FF_STRBUF_AUTO_DESTROY type = ffStrbufCreate(); + ffBluetoothSetClassOfDevice(&type, ioDevice); + + uint8_t battery = 0; if (ioDevice.batteryPercentSingle) - device->battery = ioDevice.batteryPercentSingle; + battery = ioDevice.batteryPercentSingle; else if (ioDevice.batteryPercentCombined) - device->battery = ioDevice.batteryPercentCombined; + battery = ioDevice.batteryPercentCombined; else if (ioDevice.batteryPercentCase) - device->battery = ioDevice.batteryPercentCase; + battery = ioDevice.batteryPercentCase; - device->connected = !!ioDevice.isConnected; - if(ioDevice.serviceClassMajor & kBluetoothServiceClassMajorLimitedDiscoverableMode) - ffStrbufAppendS(&device->type, "Limited Discoverable Mode, "); - if(ioDevice.serviceClassMajor & kBluetoothServiceClassMajorReserved1) - ffStrbufAppendS(&device->type, "LE audio, "); - if(ioDevice.serviceClassMajor & kBluetoothServiceClassMajorReserved2) - ffStrbufAppendS(&device->type, "Reserved for future use, "); - if(ioDevice.serviceClassMajor & kBluetoothServiceClassMajorPositioning) - ffStrbufAppendS(&device->type, "Positioning, "); - if(ioDevice.serviceClassMajor & kBluetoothServiceClassMajorNetworking) - ffStrbufAppendS(&device->type, "Networking, "); - if(ioDevice.serviceClassMajor & kBluetoothServiceClassMajorRendering) - ffStrbufAppendS(&device->type, "Rendering, "); - if(ioDevice.serviceClassMajor & kBluetoothServiceClassMajorCapturing) - ffStrbufAppendS(&device->type, "Capturing, "); - if(ioDevice.serviceClassMajor & kBluetoothServiceClassMajorObjectTransfer) - ffStrbufAppendS(&device->type, "Object Transfer, "); - if(ioDevice.serviceClassMajor & kBluetoothServiceClassMajorAudio) - ffStrbufAppendS(&device->type, "Audio, "); - if(ioDevice.serviceClassMajor & kBluetoothServiceClassMajorTelephony) - ffStrbufAppendS(&device->type, "Telephony, "); - if(ioDevice.serviceClassMajor & kBluetoothServiceClassMajorInformation) - ffStrbufAppendS(&device->type, "Information, "); + // A device with no address cannot be recognised as a repeat, so it is always its own entry. + FFBluetoothResult* device = address.length > 0 ? ffBluetoothFindByAddress(devices, address.chars) : nullptr; + if (!device) + { + device = FF_LIST_ADD(FFBluetoothResult, *devices); + ffStrbufInit(&device->name); + ffStrbufInit(&device->address); + ffStrbufInit(&device->type); + device->deviceType = FF_BLUETOOTH_DEVICE_TYPE_NONE; + device->battery = 0; + device->signalQuality = -DBL_MAX; + device->connected = false; + ffStrbufSetS(&device->name, ioDevice.name.UTF8String); + ffStrbufSet(&device->address, &address); + } - if(device->type.length == 0) - { - switch(ioDevice.deviceClassMajor) - { - case kBluetoothDeviceClassMajorMiscellaneous: - ffStrbufAppendS(&device->type, "Miscellaneous"); - break; - case kBluetoothDeviceClassMajorComputer: - ffStrbufAppendS(&device->type, "Computer"); - break; - case kBluetoothDeviceClassMajorPhone: - ffStrbufAppendS(&device->type, "Phone"); - break; - case kBluetoothDeviceClassMajorLANAccessPoint: - ffStrbufAppendS(&device->type, "LAN/Network Access point"); - break; - case kBluetoothDeviceClassMajorAudio: - ffStrbufAppendS(&device->type, "Audio/Video"); - break; - case kBluetoothDeviceClassMajorPeripheral: - ffStrbufAppendS(&device->type, "Peripheral"); - break; - case kBluetoothDeviceClassMajorImaging: - ffStrbufAppendS(&device->type, "Imaging"); - break; - case kBluetoothDeviceClassMajorWearable: - ffStrbufAppendS(&device->type, "Wearable"); - break; - case kBluetoothDeviceClassMajorToy: - ffStrbufAppendS(&device->type, "Toy"); - break; - case kBluetoothDeviceClassMajorHealth: - ffStrbufAppendS(&device->type, "Health"); - break; - case kBluetoothDeviceClassMajorUnclassified: - ffStrbufAppendS(&device->type, "Uncategorized"); - break; - default: - ffStrbufAppendS(&device->type, "Unknown"); - break; - } - } - else - { - ffStrbufTrimRight(&device->type, ' '); - ffStrbufTrimRight(&device->type, ','); - } + // The two copies of a dual-mode device are not identical -- one is the classic peer and the + // other the LE one -- so each field is taken wherever the entry already kept has nothing, + // rather than the second copy simply being dropped. The name is not merged: both copies spell + // it the same way, and the first one is the one the LE pass looks up by. + if (device->battery == 0) + device->battery = battery; + if (device->type.length == 0) + ffStrbufSet(&device->type, &type); + device->deviceType = (FFBluetoothDeviceType) (device->deviceType | FF_BLUETOOTH_DEVICE_TYPE_CLASSIC_BIT); // IOBluetooth is the BR/EDR stack + device->connected |= !!ioDevice.isConnected; } + // Last, so that a peripheral answering on both stacks is matched against the classic list and + // ends up as one entry rather than two. + ffBluetoothDetectLe(options, devices, classicAddressByIdentifier); + return nullptr; } diff --git a/src/detection/bluetooth/bluetooth_bsd.c b/src/detection/bluetooth/bluetooth_bsd.c index af1d6effd..0326d0fd6 100644 --- a/src/detection/bluetooth/bluetooth_bsd.c +++ b/src/detection/bluetooth/bluetooth_bsd.c @@ -15,7 +15,9 @@ static int enumDev([[maybe_unused]] int sockfd, struct bt_devinfo const* dev, FF ffStrbufInitS(&device->address, bt_ntoa(&dev->bdaddr, nullptr)); ffStrbufUpperCase(&device->address); ffStrbufInit(&device->type); + device->deviceType = FF_BLUETOOTH_DEVICE_TYPE_CLASSIC_BIT; // Netgraph only carries the BR/EDR stack device->battery = 0; + device->signalQuality = -DBL_MAX; device->connected = true; return 0; } diff --git a/src/detection/bluetooth/bluetooth_haiku.cpp b/src/detection/bluetooth/bluetooth_haiku.cpp index b4479d600..c0835e034 100644 --- a/src/detection/bluetooth/bluetooth_haiku.cpp +++ b/src/detection/bluetooth/bluetooth_haiku.cpp @@ -21,7 +21,9 @@ const char* ffDetectBluetooth([[maybe_unused]] FFBluetoothOptions* options, FFli ffStrbufInitS(&device->name, dev->GetFriendlyName()); ffStrbufInitS(&device->address, bdaddrUtils::ToString(dev->GetBluetoothAddress()).String()); ffStrbufInitS(&device->type, devClass.String()); + device->deviceType = FF_BLUETOOTH_DEVICE_TYPE_CLASSIC_BIT; // The local adapter's device class is a BR/EDR concept device->battery = 0; + device->signalQuality = -DBL_MAX; device->connected = true; // TODO: more devices? diff --git a/src/detection/bluetooth/bluetooth_linux.c b/src/detection/bluetooth/bluetooth_linux.c index f99d8289e..5794252d5 100644 --- a/src/detection/bluetooth/bluetooth_linux.c +++ b/src/detection/bluetooth/bluetooth_linux.c @@ -1,4 +1,5 @@ #include "bluetooth.h" +#include "common/percent.h" #include "common/strutil.h" #ifdef FF_HAVE_DBUS @@ -29,6 +30,10 @@ array [ //root string "Connected" variant boolean true ) + dict entry( //value + string "RSSI" + variant int16 -63 + ) ] ) dict entry( //property @@ -68,6 +73,12 @@ static bool detectBluetoothValue(FFDBusData* dbus, DBusMessageIter* iter, FFBlue ffDBusGetString(dbus, &dictIter, &device->name); } else if (ffStrEquals(deviceProperty, "Icon")) { ffDBusGetString(dbus, &dictIter, &device->type); + } else if (ffStrEquals(deviceProperty, "Class")) { + // Class of Device is a BR/EDR concept; BlueZ only publishes it for devices seen over classic. + device->deviceType |= FF_BLUETOOTH_DEVICE_TYPE_CLASSIC_BIT; + } else if (ffStrEquals(deviceProperty, "Appearance")) { + // The GATT Appearance characteristic is LE-only, and BlueZ mirrors it here for LE devices. + device->deviceType |= FF_BLUETOOTH_DEVICE_TYPE_LE_BIT; } else if (ffStrEquals(deviceProperty, "Percentage")) { uint64_t percentage; if (ffDBusGetUint(dbus, &dictIter, &percentage)) { @@ -75,6 +86,12 @@ static bool detectBluetoothValue(FFDBusData* dbus, DBusMessageIter* iter, FFBlue } } else if (ffStrEquals(deviceProperty, "Connected")) { ffDBusGetBool(dbus, &dictIter, &device->connected); + } else if (ffStrEquals(deviceProperty, "RSSI")) { + // `int16` on the wire, in dBm. Only present while the device is connected. + int64_t rssi; + if (ffDBusGetInt(dbus, &dictIter, &rssi)) { + device->signalQuality = ffRssiToSignalQuality((int) rssi); + } } else if (ffStrEquals(deviceProperty, "Paired")) { bool paired = true; ffDBusGetBool(dbus, &dictIter, &paired); @@ -155,7 +172,9 @@ static FFBluetoothResult* detectBluetoothObject(FFlist* devices, FFDBusData* dbu ffStrbufInit(&device->name); ffStrbufInit(&device->address); ffStrbufInit(&device->type); + device->deviceType = FF_BLUETOOTH_DEVICE_TYPE_NONE; device->battery = 0; + device->signalQuality = -DBL_MAX; device->connected = false; do { diff --git a/src/detection/bluetooth/bluetooth_windows.c b/src/detection/bluetooth/bluetooth_windows.c index 8f765c21a..6aec97983 100644 --- a/src/detection/bluetooth/bluetooth_windows.c +++ b/src/detection/bluetooth/bluetooth_windows.c @@ -23,12 +23,45 @@ DEFINE_DEVPROPKEY(DEVPKEY_Bluetooth_ServiceGUID, 0x2bd67d8b, 0x8beb, 0x48d5, 0x8 /* DEVPROP_TYPE_UINT8 */ DEFINE_DEVPROPKEY(DEVPKEY_Bluetooth_BatteryLevel, 0x104ea319, 0x6ee2, 0x4701, 0xbd, 0x47, 0x8d, 0xdb, 0xf4, 0x25, 0xbb, 0xe5, 2); -// TODO: use CM API to fetch bluetooth devices instead of BluetoothFindFirstDevice, if we find DEVPKEY_Bluetooth_IsConnected or similar +// Windows keeps the classic (BR/EDR) and the Low Energy stacks apart, and so does fastfetch: the +// device search below only ever returns BR/EDR devices, and the LE half lives in +// bluetooth_windows.cpp because reaching it means WinRT, which is C++ only. This is the bridge -- +// it walks the LE stack, then folds what it finds into the list the classic half already built. +const char* ffBluetoothDetectLe(FFBluetoothOptions* options, FFlist* devices /* FFBluetoothResult */); + +// The device tree cannot answer "is this connected". DEVPKEY_DeviceContainer_IsConnected and +// IsPaired are absent from every present devnode, and CM_Get_DevNode_PropertyW answers +// CR_NO_SUCH_VALUE for PKEY_Devices_Aep_IsConnected. The value does exist, but only in the +// Association Endpoint property store, which is reachable through WinRT alone -- see +// ffBluetoothDetectLe() and the note there about the cost of each way in. #define GUID_DEVCLASS_BLUETOOTH_STRING L"{e0cbf06c-cd8b-4647-bb8a-263b43f0f974}" // Found in #define GUID_DEVCLASS_MEDIA_STRING L"{4d36e96c-e325-11ce-bfc1-08002be10318}" // Found in +// The device tree spells an address without separators, while the list holds the printed form. The +// case has to be folded because it depends on the enumerator: the BTHENUM nodes are upper case and +// the BTHLE / BTHLEDevice ones lower case, and the list is always upper case. +static bool ffBluetoothAddressEquals(const wchar_t* plain, const FFstrbuf* address) { + if (address->length != 17) { + return false; + } + + for (uint32_t i = 0; i < 6; ++i) { + if (toupper((unsigned char) plain[i * 2]) != toupper((unsigned char) address->chars[i * 3]) || + toupper((unsigned char) plain[i * 2 + 1]) != toupper((unsigned char) address->chars[i * 3 + 1])) { + return false; + } + } + + return true; +} + static const char* ffBluetoothDetectBattery(FFlist* devices) { ULONG idListLength = 0; + // The class filter is a no-op here, and deliberately so: CM_Get_Device_ID_ListW() only honours the + // filter string together with CM_GETIDLIST_FILTER_CLASS, so this walks every present node of the + // tree rather than the media class. That is what finds the level -- the node carrying + // DEVPKEY_Bluetooth_BatteryLevel is a BTHENUM service node (the Hands-Free profile one), which is + // not in the media class at all, so narrowing the filter would lose it. CONFIGRET status = CM_Get_Device_ID_List_SizeW(&idListLength, GUID_DEVCLASS_MEDIA_STRING, CM_GETIDLIST_FILTER_PRESENT); if (status != CR_SUCCESS) { return "CM_Get_Device_ID_List_SizeW failed"; @@ -51,8 +84,6 @@ static const char* ffBluetoothDetectBattery(FFlist* devices) { for (const wchar_t* deviceId = idList; *deviceId; deviceId += wcslen(deviceId) + 1) { DEVINST devInst = 0; - // Hands-Free profile service; headsets often expose battery level through this media device node rather than the Bluetooth device node - // BthHFEnum if (CM_Locate_DevNodeW(&devInst, (DEVINSTID_W) deviceId, CM_LOCATE_DEVNODE_NORMAL) != CR_SUCCESS) { continue; } @@ -76,18 +107,7 @@ static const char* ffBluetoothDetectBattery(FFlist* devices) { } FF_LIST_FOR_EACH (FFBluetoothResult, bt, *devices) { - if (deviceAddress[0] == bt->address.chars[0] && - deviceAddress[1] == bt->address.chars[1] && - deviceAddress[2] == bt->address.chars[3] && - deviceAddress[3] == bt->address.chars[4] && - deviceAddress[4] == bt->address.chars[6] && - deviceAddress[5] == bt->address.chars[7] && - deviceAddress[6] == bt->address.chars[9] && - deviceAddress[7] == bt->address.chars[10] && - deviceAddress[8] == bt->address.chars[12] && - deviceAddress[9] == bt->address.chars[13] && - deviceAddress[10] == bt->address.chars[15] && - deviceAddress[11] == bt->address.chars[16]) { + if (ffBluetoothAddressEquals(deviceAddress, &bt->address)) { bt->battery = battery; break; } @@ -113,107 +133,113 @@ const char* ffDetectBluetooth(FFBluetoothOptions* options, FFlist* devices /* FF .dwSize = sizeof(BLUETOOTH_DEVICE_SEARCH_PARAMS) }, &btdi); if (!hFind) { - if (GetLastError() == ERROR_NO_MORE_ITEMS) { - return nullptr; + if (GetLastError() != ERROR_NO_MORE_ITEMS) { + return "BluetoothFindFirstDevice() failed"; } - return "BluetoothFindFirstDevice() failed"; + // "Nothing found" is not an error here: this search only ever sees BR/EDR devices, and a + // machine whose only peripheral is a Low Energy one reaches this branch with a working + // radio. The LE half below is what finds that device, so falling through is the point. + } else { + do { + if (!options->showDisconnected && !btdi.fConnected) { + continue; + } + + FFBluetoothResult* device = FF_LIST_ADD(FFBluetoothResult, *devices); + ffStrbufInitWS(&device->name, btdi.szName); + ffStrbufInitF(&device->address, "%02X:%02X:%02X:%02X:%02X:%02X", btdi.Address.rgBytes[5], btdi.Address.rgBytes[4], btdi.Address.rgBytes[3], btdi.Address.rgBytes[2], btdi.Address.rgBytes[1], btdi.Address.rgBytes[0]); + ffStrbufInit(&device->type); + device->deviceType = FF_BLUETOOTH_DEVICE_TYPE_CLASSIC_BIT; + device->battery = 0; + device->signalQuality = -DBL_MAX; // Only the LE stack can report a signal strength + device->connected = !!btdi.fConnected; + + // https://btprodspecificationrefs.blob.core.windows.net/assigned-numbers/Assigned%20Number%20Types/Assigned%20Numbers.pdf + + if (BitTest(&btdi.ulClassofDevice, 13)) { + ffStrbufAppendS(&device->type, "Limited Discoverable Mode, "); + } + if (BitTest(&btdi.ulClassofDevice, 14)) { + ffStrbufAppendS(&device->type, "LE audio, "); + } + if (BitTest(&btdi.ulClassofDevice, 15)) { + ffStrbufAppendS(&device->type, "Reserved for future use, "); + } + if (BitTest(&btdi.ulClassofDevice, 16)) { + ffStrbufAppendS(&device->type, "Positioning, "); + } + if (BitTest(&btdi.ulClassofDevice, 17)) { + ffStrbufAppendS(&device->type, "Networking, "); + } + if (BitTest(&btdi.ulClassofDevice, 18)) { + ffStrbufAppendS(&device->type, "Rendering, "); + } + if (BitTest(&btdi.ulClassofDevice, 19)) { + ffStrbufAppendS(&device->type, "Capturing, "); + } + if (BitTest(&btdi.ulClassofDevice, 20)) { + ffStrbufAppendS(&device->type, "Object Transfer, "); + } + if (BitTest(&btdi.ulClassofDevice, 21)) { + ffStrbufAppendS(&device->type, "Audio, "); + } + if (BitTest(&btdi.ulClassofDevice, 22)) { + ffStrbufAppendS(&device->type, "Telephony, "); + } + if (BitTest(&btdi.ulClassofDevice, 23)) { + ffStrbufAppendS(&device->type, "Information, "); + } + + if (device->type.length == 0) { + uint32_t majorDeviceClasses = (btdi.ulClassofDevice >> 8) & ~(UINT32_MAX << 5); + switch (majorDeviceClasses) { + case 0b00000: + ffStrbufAppendS(&device->type, "Miscellaneous"); + break; + case 0b00001: + ffStrbufAppendS(&device->type, "Computer"); + break; + case 0b00010: + ffStrbufAppendS(&device->type, "Phone"); + break; + case 0b00011: + ffStrbufAppendS(&device->type, "LAN/Network Access point"); + break; + case 0b00100: + ffStrbufAppendS(&device->type, "Audio/Video"); + break; + case 0b00101: + ffStrbufAppendS(&device->type, "Peripheral"); + break; + case 0b00110: + ffStrbufAppendS(&device->type, "Imaging"); + break; + case 0b00111: + ffStrbufAppendS(&device->type, "Wearable"); + break; + case 0b01000: + ffStrbufAppendS(&device->type, "Toy"); + break; + case 0b01001: + ffStrbufAppendS(&device->type, "Health"); + break; + case 0b11111: + ffStrbufAppendS(&device->type, "Uncategorized"); + break; + default: + ffStrbufAppendS(&device->type, "Unknown"); + break; + } + } else { + ffStrbufTrimRight(&device->type, ' '); + ffStrbufTrimRight(&device->type, ','); + } + } while (ffBluetoothFindNextDevice(hFind, &btdi)); + + ffBluetoothFindDeviceClose(hFind); } - do { - if (!options->showDisconnected && !btdi.fConnected) { - continue; - } - - FFBluetoothResult* device = FF_LIST_ADD(FFBluetoothResult, *devices); - ffStrbufInitWS(&device->name, btdi.szName); - ffStrbufInitF(&device->address, "%02X:%02X:%02X:%02X:%02X:%02X", btdi.Address.rgBytes[5], btdi.Address.rgBytes[4], btdi.Address.rgBytes[3], btdi.Address.rgBytes[2], btdi.Address.rgBytes[1], btdi.Address.rgBytes[0]); - ffStrbufInit(&device->type); - device->battery = 0; - device->connected = !!btdi.fConnected; - - // https://btprodspecificationrefs.blob.core.windows.net/assigned-numbers/Assigned%20Number%20Types/Assigned%20Numbers.pdf - - if (BitTest(&btdi.ulClassofDevice, 13)) { - ffStrbufAppendS(&device->type, "Limited Discoverable Mode, "); - } - if (BitTest(&btdi.ulClassofDevice, 14)) { - ffStrbufAppendS(&device->type, "LE audio, "); - } - if (BitTest(&btdi.ulClassofDevice, 15)) { - ffStrbufAppendS(&device->type, "Reserved for future use, "); - } - if (BitTest(&btdi.ulClassofDevice, 16)) { - ffStrbufAppendS(&device->type, "Positioning, "); - } - if (BitTest(&btdi.ulClassofDevice, 17)) { - ffStrbufAppendS(&device->type, "Networking, "); - } - if (BitTest(&btdi.ulClassofDevice, 18)) { - ffStrbufAppendS(&device->type, "Rendering, "); - } - if (BitTest(&btdi.ulClassofDevice, 19)) { - ffStrbufAppendS(&device->type, "Capturing, "); - } - if (BitTest(&btdi.ulClassofDevice, 20)) { - ffStrbufAppendS(&device->type, "Object Transfer, "); - } - if (BitTest(&btdi.ulClassofDevice, 21)) { - ffStrbufAppendS(&device->type, "Audio, "); - } - if (BitTest(&btdi.ulClassofDevice, 22)) { - ffStrbufAppendS(&device->type, "Telephony, "); - } - if (BitTest(&btdi.ulClassofDevice, 23)) { - ffStrbufAppendS(&device->type, "Information, "); - } - - if (device->type.length == 0) { - uint32_t majorDeviceClasses = (btdi.ulClassofDevice >> 8) & ~(UINT32_MAX << 5); - switch (majorDeviceClasses) { - case 0b00000: - ffStrbufAppendS(&device->type, "Miscellaneous"); - break; - case 0b00001: - ffStrbufAppendS(&device->type, "Computer"); - break; - case 0b00010: - ffStrbufAppendS(&device->type, "Phone"); - break; - case 0b00011: - ffStrbufAppendS(&device->type, "LAN/Network Access point"); - break; - case 0b00100: - ffStrbufAppendS(&device->type, "Audio/Video"); - break; - case 0b00101: - ffStrbufAppendS(&device->type, "Peripheral"); - break; - case 0b00110: - ffStrbufAppendS(&device->type, "Imaging"); - break; - case 0b00111: - ffStrbufAppendS(&device->type, "Wearable"); - break; - case 0b01000: - ffStrbufAppendS(&device->type, "Toy"); - break; - case 0b01001: - ffStrbufAppendS(&device->type, "Health"); - break; - case 0b11111: - ffStrbufAppendS(&device->type, "Uncategorized"); - break; - default: - ffStrbufAppendS(&device->type, "Unknown"); - break; - } - } else { - ffStrbufTrimRight(&device->type, ' '); - ffStrbufTrimRight(&device->type, ','); - } - } while (ffBluetoothFindNextDevice(hFind, &btdi)); - - ffBluetoothFindDeviceClose(hFind); + ffBluetoothDetectLe(options, devices); if (devices->length > 0) { ffBluetoothDetectBattery(devices); diff --git a/src/detection/bluetooth/bluetooth_windows.cpp b/src/detection/bluetooth/bluetooth_windows.cpp new file mode 100644 index 000000000..58863b56c --- /dev/null +++ b/src/detection/bluetooth/bluetooth_windows.cpp @@ -0,0 +1,425 @@ +extern "C" { +#include "bluetooth.h" +#include "common/mallocHelper.h" +#include "common/percent.h" +#include "common/strutil.h" +} + +#if FF_HAVE_WINRT + + #include "common/windows/winrt.hpp" + + #define INITGUID + #include + #include + #include + + #include + #include + +// Same definition as the one in bluetooth_windows.c; both are `selectany` and identical, so the +// linker keeps either. It is not in the SDK headers, hence the hand-written GUID. +/* DEVPROP_TYPE_STRING */ +DEFINE_DEVPROPKEY(DEVPKEY_Bluetooth_DeviceAddress, 0x2bd67d8b, 0x8beb, 0x48d5, 0x87, 0xe0, 0x6c, 0xda, 0x34, 0x28, 0x04, 0x0a, 1); + +#define GUID_DEVCLASS_BLUETOOTH_STRING L"{e0cbf06c-cd8b-4647-bb8a-263b43f0f974}" // Found in + +using FFBluetoothDeviceInformation = winrt::Windows::Devices::Enumeration::DeviceInformation; +using FFBluetoothDeviceInformationItf = abi_t; +using FFBluetoothDeviceList = abi_t>; +using FFBluetoothProperties = abi_t>; +using FFBluetoothPropertyValue = abi_t; + +// The AEP properties this file asks for. `FindAllAsync` rejects the whole call with +// `0x8002802B` (property key syntax error) if any name is not canonical, so every one of these was +// checked against the fast selector on a machine with paired LE devices; the ones that look +// plausible but do not exist (`...Bluetooth.Cod.Services`, `...Bluetooth.LastConnectedTime`, +// `...Bluetooth.Le.DeviceAddress`) are deliberately absent. +static const wchar_t* const ffBluetoothLeProperties[] = { + L"System.Devices.Aep.IsConnected", + L"System.Devices.Aep.SignalStrength", + L"System.Devices.Aep.DeviceAddress", + L"System.Devices.Aep.ContainerId", + L"System.Devices.Aep.Bluetooth.Le.Appearance", +}; + +// --------------------------------------------------------------------------------------------- +// Reading the AEP property store +// --------------------------------------------------------------------------------------------- + +// Every value in `DeviceInformation.Properties()` is boxed, and the one box that can be read back +// without knowing the concrete type is the property-value interface. So: ask the map whether the key +// is there, then ask the box for the type that key is documented to carry. +// +// "The device does not have it" is an ordinary answer rather than a failure -- an endpoint that has +// never been in range has no SignalStrength, and one whose firmware leaves the appearance at its +// default reports 0 instead of omitting the key. The caller owns the returned value. +static FFBluetoothPropertyValue* ffBluetoothLeLookup(FFBluetoothProperties* properties, const wchar_t* key) { + HSTRING_HEADER header; + HSTRING name; + if (FAILED(WindowsCreateStringReference(key, (UINT32) ::wcslen(key), &header, &name))) { + return nullptr; + } + + bool present = false; // `Lookup` on a missing key is an error, so ask first + if (FAILED(properties->HasKey(reinterpret_cast(name), &present)) || !present) { + return nullptr; + } + + FF_AUTO_RELEASE_COM_OBJECT abi_t* boxed = nullptr; + if (FAILED(properties->Lookup(reinterpret_cast(name), reinterpret_cast(&boxed))) || !boxed) { + return nullptr; + } + + FFBluetoothPropertyValue* value = nullptr; + if (FAILED(ffQueryInterface(boxed, &value))) { + return nullptr; + } + + return value; +} + +static bool ffBluetoothLeGetBool(FFBluetoothProperties* properties, const wchar_t* key, bool* result) { + FF_AUTO_RELEASE_COM_OBJECT FFBluetoothPropertyValue* value = ffBluetoothLeLookup(properties, key); + return value && SUCCEEDED(value->GetBoolean(result)); +} + +static bool ffBluetoothLeGetInt32(FFBluetoothProperties* properties, const wchar_t* key, int32_t* result) { + FF_AUTO_RELEASE_COM_OBJECT FFBluetoothPropertyValue* value = ffBluetoothLeLookup(properties, key); + return value && SUCCEEDED(value->GetInt32(result)); +} + +static bool ffBluetoothLeGetUInt16(FFBluetoothProperties* properties, const wchar_t* key, uint16_t* result) { + FF_AUTO_RELEASE_COM_OBJECT FFBluetoothPropertyValue* value = ffBluetoothLeLookup(properties, key); + return value && SUCCEEDED(value->GetUInt16(result)); +} + +static bool ffBluetoothLeGetGuid(FFBluetoothProperties* properties, const wchar_t* key, GUID* result) { + FF_AUTO_RELEASE_COM_OBJECT FFBluetoothPropertyValue* value = ffBluetoothLeLookup(properties, key); + // `winrt::guid` and `GUID` have the same four members in the same order, and C++/WinRT itself + // converts between them with exactly this cast. + return value && SUCCEEDED(value->GetGuid(reinterpret_cast(result))); +} + +// The caller owns `result`. +static bool ffBluetoothLeGetString(FFBluetoothProperties* properties, const wchar_t* key, HSTRING* result) { + FF_AUTO_RELEASE_COM_OBJECT FFBluetoothPropertyValue* value = ffBluetoothLeLookup(properties, key); + return value && SUCCEEDED(value->GetString(reinterpret_cast(result))); +} + +// --------------------------------------------------------------------------------------------- +// The device tree: which addresses belong to the same physical device +// --------------------------------------------------------------------------------------------- + +// A Bluetooth device is one container with one node per stack it speaks, and the two nodes carry +// *different* addresses: the phone is FC:AB:D0:72:40:90 over BR/EDR and 47:E1:D7:21:C7:FC over LE, +// both inside {B3F019F1-...}. The container is therefore the only identity the classic and the LE +// enumerations can be joined on, and the device tree is where the classic side gets it from. +typedef struct FFBluetoothNodeInfo { + char address[13]; // 12 hex digits + terminator, upper case, no separators + GUID container; +} FFBluetoothNodeInfo; + +static void ffBluetoothCollectNodes(FFlist* nodes /* FFBluetoothNodeInfo */) { + ULONG idListLength = 0; + if (CM_Get_Device_ID_List_SizeW(&idListLength, GUID_DEVCLASS_BLUETOOTH_STRING, CM_GETIDLIST_FILTER_CLASS | CM_GETIDLIST_FILTER_PRESENT) != CR_SUCCESS || idListLength == 0) { + return; + } + + FF_AUTO_FREE wchar_t* idList = (wchar_t*) malloc((size_t) idListLength * sizeof(wchar_t)); + if (!idList) { + return; + } + + if (CM_Get_Device_ID_ListW(GUID_DEVCLASS_BLUETOOTH_STRING, idList, idListLength, CM_GETIDLIST_FILTER_CLASS | CM_GETIDLIST_FILTER_PRESENT) != CR_SUCCESS) { + return; + } + + for (const wchar_t* deviceId = idList; *deviceId; deviceId += wcslen(deviceId) + 1) { + DEVINST devInst = 0; + if (CM_Locate_DevNodeW(&devInst, (DEVINSTID_W) deviceId, CM_LOCATE_DEVNODE_NORMAL) != CR_SUCCESS) { + continue; + } + + FFBluetoothNodeInfo node = {}; + + { + // Both enumerators carry this: BTHENUM nodes spell the address in upper case, BTHLE nodes + // in lower case, so it is folded here and the comparison later is plain byte equality. + wchar_t address[13]; + DEVPROPTYPE type = DEVPROP_TYPE_EMPTY; + ULONG size = sizeof(address); + if (CM_Get_DevNode_PropertyW(devInst, &DEVPKEY_Bluetooth_DeviceAddress, &type, (PBYTE) address, &size, 0) != CR_SUCCESS || type != DEVPROP_TYPE_STRING || size != sizeof(address)) { + continue; + } + + for (size_t i = 0; i < 12; ++i) { + node.address[i] = (char) toupper((unsigned char) address[i]); + } + } + + { + DEVPROPTYPE type = DEVPROP_TYPE_EMPTY; + ULONG size = sizeof(node.container); + if (CM_Get_DevNode_PropertyW(devInst, &DEVPKEY_Device_ContainerId, &type, (PBYTE) &node.container, &size, 0) != CR_SUCCESS || type != DEVPROP_TYPE_GUID || size != sizeof(node.container)) { + continue; + } + } + + *FF_LIST_ADD(FFBluetoothNodeInfo, *nodes) = node; + } +} + +// `address` is the printed form ("AA:BB:CC:DD:EE:FF"); the device tree stores it without separators. +static bool ffBluetoothFindContainer(const FFlist* nodes, const FFstrbuf* address, GUID* container) { + if (address->length != 17) { + return false; + } + + char plain[13]; + for (uint32_t i = 0; i < 6; ++i) { + plain[i * 2] = (char) toupper((unsigned char) address->chars[i * 3]); + plain[i * 2 + 1] = (char) toupper((unsigned char) address->chars[i * 3 + 1]); + } + plain[12] = '\0'; + + FF_LIST_FOR_EACH (FFBluetoothNodeInfo, node, *nodes) { + if (memcmp(node->address, plain, sizeof(plain)) == 0) { + *container = node->container; + return true; + } + } + + return false; +} + +// --------------------------------------------------------------------------------------------- +// The appearance, and the device it describes +// --------------------------------------------------------------------------------------------- + +// The GAP appearance is a 16-bit value whose upper 10 bits are the category. Windows names only the +// first nineteen of them -- `BluetoothLEAppearanceCategories` stops at Cycling (0x12) and has no +// AudioSink -- so these names are the Bluetooth SIG ones, checked against that class where it has an +// entry and against `BluetoothLEAppearance::FromRawValue()` for the rest: a pair of earbuds reports +// 0x0842, which the OS itself decodes as category 0x21, the value used below for Audio Sink. +static const char* ffBluetoothLeAppearanceName(uint16_t appearance) { + switch (appearance >> 6) { + case 0x00: return "Unknown"; + case 0x01: return "Phone"; + case 0x02: return "Computer"; + case 0x03: return "Watch"; + case 0x04: return "Clock"; + case 0x05: return "Display"; + case 0x06: return "Remote Control"; + case 0x07: return "Eye Glasses"; + case 0x08: return "Tag"; + case 0x09: return "Keyring"; + case 0x0A: return "Media Player"; + case 0x0B: return "Barcode Scanner"; + case 0x0C: return "Thermometer"; + case 0x0D: return "Heart Rate Sensor"; + case 0x0E: return "Blood Pressure"; + case 0x0F: return "Human Interface Device"; + case 0x10: return "Glucose Meter"; + case 0x11: return "Running Walking Sensor"; + case 0x12: return "Cycling"; + case 0x13: return "Control Device"; + case 0x14: return "Network Device"; + case 0x15: return "Sensor"; + case 0x16: return "Light Fixture"; + case 0x17: return "Fan"; + case 0x18: return "HVAC"; + case 0x19: return "Air Conditioning"; + case 0x1A: return "Humidifier"; + case 0x1B: return "Heating"; + case 0x1C: return "Access Control"; + case 0x1D: return "Motorized Device"; + case 0x1E: return "Power Device"; + case 0x1F: return "Light Source"; + case 0x20: return "Window Covering"; + case 0x21: return "Audio Sink"; + case 0x22: return "Audio Source"; + case 0x23: return "Motorized Vehicle"; + case 0x24: return "Domestic Appliance"; + case 0x25: return "Wearable Audio Device"; + case 0x26: return "Aircraft"; + case 0x27: return "AV Equipment"; + case 0x28: return "Display Equipment"; + case 0x29: return "Hearing Aid"; + case 0x2A: return "Gaming"; + case 0x2B: return "Signage"; + default: return "Unknown"; + } +} + +// Which entry of `devices` each classic device occupies, and which container it belongs to. The +// index is stored rather than a pointer because appending an LE-only device can move the list. +typedef struct FFBluetoothClassicEntry { + uint32_t index; + bool hasContainer; + GUID container; +} FFBluetoothClassicEntry; + +// --------------------------------------------------------------------------------------------- +// The LE stack +// --------------------------------------------------------------------------------------------- + +// `devices` already holds everything the classic (BR/EDR) stack knows. This adds what only the LE +// stack knows: devices that never speak BR/EDR at all, and -- for the ones that speak both -- the +// fact that they do, plus the signal strength the LE link is the only source of. +// +// The way in matters. `BluetoothLEDevice::GetDeviceSelectorFromPairingState(true)` as an AQS filter +// answers in 9-22 ms and brings the AEP properties along, whereas enumerating +// `DeviceInformationKind::AssociationEndpoint` returns the same data but costs about 61 seconds per +// call because it scans the radio. The 7.7-second trap is one layer further out: any `Uncached` GATT +// call, which the cache-backed service table hides by returning in a few milliseconds. +extern "C" const char* ffBluetoothDetectLe(FFBluetoothOptions* options, FFlist* devices /* FFBluetoothResult */) { + const char* error = ffInitCom(); + if (error) { + return error; + } + + FF_AUTO_RELEASE_COM_OBJECT abi_t* ledeviceStatics = nullptr; + if (FAILED(ffGetActivationFactory(L"Windows.Devices.Bluetooth.BluetoothLEDevice", &ledeviceStatics)) || !ledeviceStatics) { + return "winrt: RoGetActivationFactory(BluetoothLEDevice) failed"; + } + + [[gnu::cleanup(ffDeleteHstring)]] HSTRING selector = nullptr; + if (FAILED(ledeviceStatics->GetDeviceSelectorFromPairingState(true, reinterpret_cast(&selector))) || !selector) { + return "winrt: GetDeviceSelectorFromPairingState() failed"; + } + + // Without additional properties the map carries only the eight generic ones, and every AEP key + // is absent, so the list is not optional. + FF_AUTO_RELEASE_COM_OBJECT abi_t>* properties = nullptr; + if (FAILED(ffWinrtCreateHstringIterable(ffBluetoothLeProperties, (uint32_t) ARRAY_SIZE(ffBluetoothLeProperties), &properties)) || !properties) { + return "winrt: building the additional-properties list failed"; + } + + FF_AUTO_RELEASE_COM_OBJECT abi_t* enumerationStatics = nullptr; + if (FAILED(ffGetActivationFactory(L"Windows.Devices.Enumeration.DeviceInformation", &enumerationStatics)) || !enumerationStatics) { + return "winrt: RoGetActivationFactory(DeviceInformation) failed"; + } + + FF_AUTO_RELEASE_COM_OBJECT FFBluetoothDeviceList* found = nullptr; + HRESULT hr = ffRunAndWait>([&](void** result) { + return enumerationStatics->FindAllAsyncAqsFilterAndAdditionalProperties(reinterpret_cast(selector), reinterpret_cast(properties), result); + }, + &found); + if (FAILED(hr) || !found) { + return "winrt: DeviceInformation::FindAllAsync() failed"; + } + + uint32_t count = 0; + if (FAILED(found->get_Size(&count)) || count == 0) { + return nullptr; + } + + FF_LIST_AUTO_DESTROY nodes = ffListCreate(); + ffBluetoothCollectNodes(&nodes); + + FF_LIST_AUTO_DESTROY classic = ffListCreate(); + { + uint32_t index = 0; + FF_LIST_FOR_EACH (FFBluetoothResult, device, *devices) { + FFBluetoothClassicEntry* entry = FF_LIST_ADD(FFBluetoothClassicEntry, classic); + entry->index = index++; + entry->hasContainer = ffBluetoothFindContainer(&nodes, &device->address, &entry->container); + } + } + + for (uint32_t i = 0; i < count; ++i) { + FF_AUTO_RELEASE_COM_OBJECT FFBluetoothDeviceInformationItf* info = nullptr; + if (FAILED(found->GetAt(i, reinterpret_cast(&info))) || !info) { + continue; + } + + FF_AUTO_RELEASE_COM_OBJECT FFBluetoothProperties* map = nullptr; + if (FAILED(info->get_Properties(reinterpret_cast(&map))) || !map) { + continue; + } + + GUID container = {}; + if (!ffBluetoothLeGetGuid(map, L"System.Devices.Aep.ContainerId", &container)) { + continue; // Without a container there is no way to tell whether this is a device we know + } + + bool connected = false; + ffBluetoothLeGetBool(map, L"System.Devices.Aep.IsConnected", &connected); + + FFBluetoothResult* target = nullptr; + FF_LIST_FOR_EACH (FFBluetoothClassicEntry, entry, classic) { + if (entry->hasContainer && IsEqualGUID(entry->container, container)) { + target = FF_LIST_GET(FFBluetoothResult, *devices, entry->index); + break; + } + } + + if (!target && !connected && !options->showDisconnected) { + continue; // An LE-only device nobody asked to see. A known one is already in the list. + } + + if (!target) { + target = FF_LIST_ADD(FFBluetoothResult, *devices); + ffStrbufInit(&target->name); + ffStrbufInit(&target->address); + ffStrbufInit(&target->type); + target->deviceType = FF_BLUETOOTH_DEVICE_TYPE_NONE; + target->battery = 0; + target->signalQuality = -DBL_MAX; + target->connected = false; + } + + target->deviceType = (FFBluetoothDeviceType) (target->deviceType | FF_BLUETOOTH_DEVICE_TYPE_LE_BIT); + + // The classic half wins wherever it has an answer: its name and address are the ones the + // Settings app shows, and its type comes from the class of device rather than from an + // appearance value the firmware may never have set. + if (target->name.length == 0) { + [[gnu::cleanup(ffDeleteHstring)]] HSTRING name = nullptr; + if (SUCCEEDED(info->get_Name(reinterpret_cast(&name))) && name) { + ffStrbufSetHstring(&target->name, name); + } + ffStrbufTrimSpace(&target->name); // A device whose name is missing is reported as a space + } + if (target->name.length == 0) { + ffStrbufSetStatic(&target->name, "Unknown Device"); + } + + if (target->address.length == 0) { + [[gnu::cleanup(ffDeleteHstring)]] HSTRING address = nullptr; + if (ffBluetoothLeGetString(map, L"System.Devices.Aep.DeviceAddress", &address)) { + ffStrbufSetHstring(&target->address, address); + ffStrbufUpperCase(&target->address); + } + } + + if (target->type.length == 0) { + uint16_t appearance = 0; + if (ffBluetoothLeGetUInt16(map, L"System.Devices.Aep.Bluetooth.Le.Appearance", &appearance)) { + ffStrbufSetStatic(&target->type, ffBluetoothLeAppearanceName(appearance)); + } + } + + // The signal strength is what the LE stack last saw, and it stays readable while the link is + // down, so it is reported whenever the endpoint has one. Only the LE side can produce it. + int32_t signalStrength = 0; + if (ffBluetoothLeGetInt32(map, L"System.Devices.Aep.SignalStrength", &signalStrength)) { + target->signalQuality = ffRssiToSignalQuality(signalStrength); + } + + // A dual-mode device is connected if either of its two links is up, and the two can disagree. + target->connected |= connected; + } + + return nullptr; +} + +#else + +extern "C" const char* ffBluetoothDetectLe(FFBluetoothOptions* options, FFlist* devices /* FFBluetoothResult */) { + FF_UNUSED(options, devices); + return nullptr; +} + +#endif // FF_HAVE_WINRT diff --git a/src/modules/bluetooth/bluetooth.c b/src/modules/bluetooth/bluetooth.c index 8651dbb59..f9cac3971 100644 --- a/src/modules/bluetooth/bluetooth.c +++ b/src/modules/bluetooth/bluetooth.c @@ -35,22 +35,46 @@ static void printDevice(FFBluetoothOptions* options, const FFBluetoothResult* de ffStrbufPutTo(&buffer, stdout); } else { - FF_STRBUF_AUTO_DESTROY percentageNum = ffStrbufCreate(); + // The battery is `uint8_t` with 0 meaning "unknown", so the format string can only be filled + // unconditionally; the signal quality has a sentinel of its own and is left empty instead. + FF_STRBUF_AUTO_DESTROY batteryNum = ffStrbufCreate(); if (percentType & FF_PERCENTAGE_TYPE_NUM_BIT) { - ffPercentAppendNum(&percentageNum, device->battery, options->percent, false, &options->moduleArgs); + ffPercentAppendNum(&batteryNum, device->battery, options->percent, false, &options->moduleArgs); } - FF_STRBUF_AUTO_DESTROY percentageBar = ffStrbufCreate(); + FF_STRBUF_AUTO_DESTROY batteryBar = ffStrbufCreate(); if (percentType & FF_PERCENTAGE_TYPE_BAR_BIT) { - ffPercentAppendBar(&percentageBar, device->battery, options->percent, &options->moduleArgs); + ffPercentAppendBar(&batteryBar, device->battery, options->percent, &options->moduleArgs); + } + + FF_STRBUF_AUTO_DESTROY signalNum = ffStrbufCreate(); + FF_STRBUF_AUTO_DESTROY signalBar = ffStrbufCreate(); + if (device->signalQuality != -DBL_MAX) { + if (percentType & FF_PERCENTAGE_TYPE_NUM_BIT) { + ffPercentAppendNum(&signalNum, device->signalQuality, options->percent, false, &options->moduleArgs); + } + if (percentType & FF_PERCENTAGE_TYPE_BAR_BIT) { + ffPercentAppendBar(&signalBar, device->signalQuality, options->percent, &options->moduleArgs); + } + } + + FF_LIST_AUTO_DESTROY deviceTypes = ffListCreate(); + if (device->deviceType & FF_BLUETOOTH_DEVICE_TYPE_CLASSIC_BIT) { + ffStrbufInitStatic(FF_LIST_ADD(FFstrbuf, deviceTypes), "Classic"); + } + if (device->deviceType & FF_BLUETOOTH_DEVICE_TYPE_LE_BIT) { + ffStrbufInitStatic(FF_LIST_ADD(FFstrbuf, deviceTypes), "Low Energy"); } FF_PRINT_FORMAT_CHECKED(FF_MODULE_GET_DISPLAY_NAME(Bluetooth), index, &options->moduleArgs, FF_PRINT_TYPE_DEFAULT, ((FFformatarg[]) { FF_ARG(device->name, "name"), FF_ARG(device->address, "address"), FF_ARG(device->type, "type"), - FF_ARG(percentageNum, "battery-percentage"), + FF_ARG(deviceTypes, "device-type"), + FF_ARG(batteryNum, "battery-percentage"), + FF_ARG(batteryBar, "battery-percentage-bar"), + FF_ARG(signalNum, "signal-quality"), + FF_ARG(signalBar, "signal-quality-bar"), FF_ARG(device->connected, "connected"), - FF_ARG(percentageBar, "battery-percentage-bar"), })); } } @@ -128,7 +152,21 @@ bool ffGenerateBluetoothJsonResult(FFBluetoothOptions* options, yyjson_mut_doc* yyjson_mut_obj_add_strbuf(doc, obj, "address", &item->address); yyjson_mut_obj_add_uint(doc, obj, "battery", item->battery); yyjson_mut_obj_add_bool(doc, obj, "connected", item->connected); + + yyjson_mut_val* deviceTypes = yyjson_mut_obj_add_arr(doc, obj, "deviceType"); + if (item->deviceType & FF_BLUETOOTH_DEVICE_TYPE_CLASSIC_BIT) { + yyjson_mut_arr_add_str(doc, deviceTypes, "Classic"); + } + if (item->deviceType & FF_BLUETOOTH_DEVICE_TYPE_LE_BIT) { + yyjson_mut_arr_add_str(doc, deviceTypes, "Low Energy"); + } + yyjson_mut_obj_add_strbuf(doc, obj, "name", &item->name); + if (item->signalQuality != -DBL_MAX) { + yyjson_mut_obj_add_real(doc, obj, "signalQuality", item->signalQuality); + } else { + yyjson_mut_obj_add_null(doc, obj, "signalQuality"); + } yyjson_mut_obj_add_strbuf(doc, obj, "type", &item->type); } @@ -185,9 +223,12 @@ FFModuleBaseInfo ffBluetoothModuleInfo = { { "Name", "name" }, { "Address", "address" }, { "Type", "type" }, + { "Bluetooth stacks the device answers on", "device-type" }, { "Battery percentage number", "battery-percentage" }, - { "Is connected", "connected" }, { "Battery percentage bar", "battery-percentage-bar" }, + { "Signal quality number", "signal-quality" }, + { "Signal quality bar", "signal-quality-bar" }, + { "Is connected", "connected" }, })), .defaultOrder = 59, };