Bluetooth (Windows / macOS): adds Bluetooth LE device detection

This commit is contained in:
Carter Li
2026-09-23 14:02:08 +08:00
parent 3930759007
commit a78d2a7737
11 changed files with 1072 additions and 204 deletions
+4
View File
@@ -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
+2
View File
@@ -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"
+3 -4
View File
@@ -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",
+11 -1
View File
@@ -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;
+417 -79
View File
@@ -1,5 +1,8 @@
#include "bluetooth.h"
#include "common/percent.h"
#include "common/strutil.h"
#import <CoreBluetooth/CoreBluetooth.h>
#import <IOBluetooth/IOBluetooth.h>
@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 <CBCentralManagerDelegate, CBPeripheralDelegate>
@property(nonatomic, strong) CBCentralManager* central;
@property(nonatomic, strong) NSMutableDictionary<NSString*, NSNumber*>* 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<CBUUID*>* ffBluetoothLeServiceUuids(void) {
static NSArray<CBUUID*>* uuids;
static dispatch_once_t once;
dispatch_once(&once, ^{
NSMutableArray<CBUUID*>* 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<NSString*, NSString*>* 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<CBPeripheral*>* 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<IOBluetoothDevice*>* 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<NSString*, NSString*>* 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;
}
+2
View File
@@ -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;
}
@@ -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?
+19
View File
@@ -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 {
+139 -113
View File
@@ -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 <devguid.h>
#define GUID_DEVCLASS_MEDIA_STRING L"{4d36e96c-e325-11ce-bfc1-08002be10318}" // Found in <devguid.h>
// 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);
@@ -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 <windows.h>
#include <cfgmgr32.h>
#include <devpkey.h>
#include <winrt/Windows.Devices.Bluetooth.h>
#include <winrt/Windows.Devices.Enumeration.h>
// 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 <devguid.h>
using FFBluetoothDeviceInformation = winrt::Windows::Devices::Enumeration::DeviceInformation;
using FFBluetoothDeviceInformationItf = abi_t<winrt::Windows::Devices::Enumeration::IDeviceInformation>;
using FFBluetoothDeviceList = abi_t<winrt::Windows::Foundation::Collections::IVectorView<FFBluetoothDeviceInformation>>;
using FFBluetoothProperties = abi_t<winrt::Windows::Foundation::Collections::IMapView<winrt::hstring, winrt::Windows::Foundation::IInspectable>>;
using FFBluetoothPropertyValue = abi_t<winrt::Windows::Foundation::IPropertyValue>;
// 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<void*>(name), &present)) || !present) {
return nullptr;
}
FF_AUTO_RELEASE_COM_OBJECT abi_t<winrt::Windows::Foundation::IInspectable>* boxed = nullptr;
if (FAILED(properties->Lookup(reinterpret_cast<void*>(name), reinterpret_cast<void**>(&boxed))) || !boxed) {
return nullptr;
}
FFBluetoothPropertyValue* value = nullptr;
if (FAILED(ffQueryInterface<winrt::Windows::Foundation::IPropertyValue>(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<winrt::guid*>(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<void**>(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<winrt::Windows::Devices::Bluetooth::IBluetoothLEDeviceStatics2>* ledeviceStatics = nullptr;
if (FAILED(ffGetActivationFactory<winrt::Windows::Devices::Bluetooth::IBluetoothLEDeviceStatics2>(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<void**>(&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<winrt::Windows::Foundation::Collections::IIterable<winrt::hstring>>* 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<winrt::Windows::Devices::Enumeration::IDeviceInformationStatics>* enumerationStatics = nullptr;
if (FAILED(ffGetActivationFactory<winrt::Windows::Devices::Enumeration::IDeviceInformationStatics>(L"Windows.Devices.Enumeration.DeviceInformation", &enumerationStatics)) || !enumerationStatics) {
return "winrt: RoGetActivationFactory(DeviceInformation) failed";
}
FF_AUTO_RELEASE_COM_OBJECT FFBluetoothDeviceList* found = nullptr;
HRESULT hr = ffRunAndWait<winrt::Windows::Foundation::Collections::IVectorView<FFBluetoothDeviceInformation>>([&](void** result) {
return enumerationStatics->FindAllAsyncAqsFilterAndAdditionalProperties(reinterpret_cast<void*>(selector), reinterpret_cast<void*>(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<void**>(&info))) || !info) {
continue;
}
FF_AUTO_RELEASE_COM_OBJECT FFBluetoothProperties* map = nullptr;
if (FAILED(info->get_Properties(reinterpret_cast<void**>(&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<void**>(&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
+48 -7
View File
@@ -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,
};