mirror of
https://github.com/fastfetch-cli/fastfetch.git
synced 2026-10-28 07:44:06 +01:00
Bluetooth (Windows / macOS): adds Bluetooth LE device detection
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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?
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user