Bluetooth: adds option showType

This commit is contained in:
Carter Li
2026-09-23 15:11:06 +08:00
parent a78d2a7737
commit 4a97cc6825
10 changed files with 160 additions and 37 deletions
+1
View File
@@ -10,6 +10,7 @@ Changes:
Features:
* Added Bluetooth Low Energy detection on Windows and macOS, through WinRT and Core Bluetooth. (Bluetooth, Windows / macOS)
* Added the `showType` option to the `Bluetooth` module, which selects which stacks are looked for. (Bluetooth)
* 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.
+18
View File
@@ -1749,6 +1749,24 @@
"type": "boolean",
"default": false
},
"devType": {
"description": "Which Bluetooth stacks to look for",
"oneOf": [
{
"const": "classic",
"description": "Look for BR/EDR devices only"
},
{
"const": "le",
"description": "Look for Bluetooth Low Energy devices only"
},
{
"const": "both",
"description": "Look for devices on either stack"
}
],
"default": "both"
},
"percent": {
"$ref": "#/$defs/percent"
},
-8
View File
@@ -3,14 +3,6 @@
#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;
+45 -19
View File
@@ -100,6 +100,11 @@
// link is up, and the callbacks are waited for as a group, so a machine with no LE link pays
// nothing.
//
// * `showType` is honoured here rather than in the module, because the two stacks are two separate
// walks with two separate permission surfaces: a stack the user switched off is not asked about
// at all. The LE pass keeps working without the classic one, it only loses the address join and
// falls back to matching by name.
//
// 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
@@ -148,7 +153,7 @@
// 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*>* leServiceUuids(void) {
static NSArray<CBUUID*>* uuids;
static dispatch_once_t once;
dispatch_once(&once, ^{
@@ -166,7 +171,7 @@ static NSArray<CBUUID*>* ffBluetoothLeServiceUuids(void) {
// 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) {
static bool leIsConnected(CBPeripheral* peripheral) {
if ([peripheral respondsToSelector: @selector(isConnectedToSystem)]) {
return peripheral.isConnectedToSystem;
}
@@ -175,7 +180,7 @@ static bool ffBluetoothLeIsConnected(CBPeripheral* peripheral) {
// 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) {
static FFBluetoothResult* findByAddress(FFlist* devices, const char* address) {
FF_LIST_FOR_EACH (FFBluetoothResult, device, *devices) {
if (ffStrbufEqualS(&device->address, address)) {
return device;
@@ -187,7 +192,7 @@ static FFBluetoothResult* ffBluetoothFindByAddress(FFlist* devices, const char*
// 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) {
static void setClassOfDevice(FFstrbuf* type, IOBluetoothDevice* ioDevice) {
if(ioDevice.serviceClassMajor & kBluetoothServiceClassMajorLimitedDiscoverableMode)
ffStrbufAppendS(type, "Limited Discoverable Mode, ");
if(ioDevice.serviceClassMajor & kBluetoothServiceClassMajorReserved1)
@@ -263,7 +268,7 @@ static void ffBluetoothSetClassOfDevice(FFstrbuf* type, IOBluetoothDevice* ioDev
// 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) {
static FFBluetoothResult* findByName(FFlist* devices, const char* name) {
FFBluetoothResult* found = nullptr;
FF_LIST_FOR_EACH (FFBluetoothResult, device, *devices) {
if (!ffStrEqualsIgnCase(device->name.chars, name)) {
@@ -277,7 +282,7 @@ static FFBluetoothResult* ffBluetoothFindByName(FFlist* devices, const char* nam
return found;
}
static const char* ffBluetoothDetectLe(FFBluetoothOptions* options, FFlist* devices /* FFBluetoothResult */, NSDictionary<NSString*, NSString*>* classicAddressByIdentifier) {
static const char* detectLe(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.
@@ -297,13 +302,13 @@ static const char* ffBluetoothDetectLe(FFBluetoothOptions* options, FFlist* devi
return nullptr; // Powered off, unauthorized or no radio: the classic list stands on its own
}
NSArray<CBPeripheral*>* peripherals = [delegate.central retrieveConnectedPeripheralsWithServices: ffBluetoothLeServiceUuids()];
NSArray<CBPeripheral*>* peripherals = [delegate.central retrieveConnectedPeripheralsWithServices: leServiceUuids()];
if (peripherals.count == 0) {
return nullptr;
}
for (CBPeripheral* peripheral in peripherals) {
if (ffBluetoothLeIsConnected(peripheral)) {
if (leIsConnected(peripheral)) {
peripheral.delegate = delegate;
++delegate.signalStrengthAsked;
[peripheral readRSSI];
@@ -319,7 +324,7 @@ static const char* ffBluetoothDetectLe(FFBluetoothOptions* options, FFlist* devi
for (CBPeripheral* peripheral in peripherals) {
const char* name = peripheral.name.length > 0 ? peripheral.name.UTF8String : nullptr;
bool connected = ffBluetoothLeIsConnected(peripheral);
bool connected = leIsConnected(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
@@ -327,10 +332,10 @@ static const char* ffBluetoothDetectLe(FFBluetoothOptions* options, FFlist* devi
FFBluetoothResult* target = nullptr;
NSString* address = classicAddressByIdentifier[peripheral.identifier.UUIDString];
if (address) {
target = ffBluetoothFindByAddress(devices, address.UTF8String);
target = findByAddress(devices, address.UTF8String);
}
if (!target && name) {
target = ffBluetoothFindByName(devices, name);
target = findByName(devices, name);
}
if (!target && !connected && !options->showDisconnected) {
@@ -370,16 +375,15 @@ static const char* ffBluetoothDetectLe(FFBluetoothOptions* options, FFlist* devi
return nullptr;
}
const char* ffDetectBluetooth(FFBluetoothOptions* options, FFlist* devices /* FFBluetoothResult */)
// The classic (BR/EDR) half. `classicAddressByIdentifier` is an output rather than an input: the
// classic list is the only place a device address can be had from, and the LE pass needs those
// addresses to recognise a peripheral that has already been listed.
static const char* detectClassic(FFBluetoothOptions* options, FFlist* devices /* FFBluetoothResult */, NSMutableDictionary<NSString*, NSString*>* classicAddressByIdentifier)
{
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)
@@ -404,7 +408,7 @@ const char* ffDetectBluetooth(FFBluetoothOptions* options, FFlist* devices /* FF
}
FF_STRBUF_AUTO_DESTROY type = ffStrbufCreate();
ffBluetoothSetClassOfDevice(&type, ioDevice);
setClassOfDevice(&type, ioDevice);
uint8_t battery = 0;
if (ioDevice.batteryPercentSingle)
@@ -415,7 +419,7 @@ const char* ffDetectBluetooth(FFBluetoothOptions* options, FFlist* devices /* FF
battery = ioDevice.batteryPercentCase;
// 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;
FFBluetoothResult* device = address.length > 0 ? findByAddress(devices, address.chars) : nullptr;
if (!device)
{
device = FF_LIST_ADD(FFBluetoothResult, *devices);
@@ -442,9 +446,31 @@ const char* ffDetectBluetooth(FFBluetoothOptions* options, FFlist* devices /* FF
device->connected |= !!ioDevice.isConnected;
}
return nullptr;
}
const char* ffDetectBluetooth(FFBluetoothOptions* options, FFlist* devices /* FFBluetoothResult */)
{
// `showType` selects which stacks are walked, not merely which entries are printed: a stack the
// user switched off is not asked about at all. The classic pass is the one that produces the
// identifier map the LE pass joins on, so it is nil when that pass does not run -- the LE pass
// then has nothing to match against and reports every peripheral as a device of its own.
NSMutableDictionary<NSString*, NSString*>* classicAddressByIdentifier = nil;
if (options->showType & FF_BLUETOOTH_DEVICE_TYPE_CLASSIC_BIT) {
classicAddressByIdentifier = [NSMutableDictionary dictionary];
const char* error = detectClassic(options, devices, classicAddressByIdentifier);
if (error) {
return error;
}
}
// 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);
if (options->showType & FF_BLUETOOTH_DEVICE_TYPE_LE_BIT) {
detectLe(options, devices, classicAddressByIdentifier);
}
return nullptr;
}
+7 -1
View File
@@ -22,7 +22,13 @@ static int enumDev([[maybe_unused]] int sockfd, struct bt_devinfo const* dev, FF
return 0;
}
const char* ffDetectBluetooth([[maybe_unused]] FFBluetoothOptions* options, [[maybe_unused]] FFlist* devices /* FFBluetoothResult */) {
const char* ffDetectBluetooth(FFBluetoothOptions* options, FFlist* devices /* FFBluetoothResult */) {
// Netgraph carries the BR/EDR stack and nothing else, so there is no second function to dispatch
// to here: a Low Energy-only configuration simply has nothing to walk.
if (!(options->showType & FF_BLUETOOTH_DEVICE_TYPE_CLASSIC_BIT)) {
return nullptr;
}
// struct hostent* ent = bt_gethostent();
if (bt_devenum((void*) enumDev, devices) < 0) {
return "bt_devenum() failed";
+7 -1
View File
@@ -5,7 +5,13 @@ extern "C" {
#include <bluetooth/LocalDevice.h>
const char* ffDetectBluetooth([[maybe_unused]] FFBluetoothOptions* options, FFlist* devices /* FFBluetoothResult */) {
const char* ffDetectBluetooth(FFBluetoothOptions* options, FFlist* devices /* FFBluetoothResult */) {
// The local adapter's device class is a BR/EDR concept and the adapter is the only device this
// backend has, so a Low Energy-only configuration leaves the list empty.
if (!(options->showType & FF_BLUETOOTH_DEVICE_TYPE_CLASSIC_BIT)) {
return nullptr;
}
using namespace Bluetooth;
FF_SUPPRESS_IO();
+10 -1
View File
@@ -196,11 +196,20 @@ static void detectBluetoothRoot(FFBluetoothOptions* options, FFlist* devices, FF
FFBluetoothResult* device = detectBluetoothObject(devices, dbus, &arrayIter);
if (device) {
if (!options->showDisconnected && !device->connected) {
// `showType` selects which stacks are reported. Unlike the other backends there is no
// per-stack call to skip here: BlueZ publishes the class of device and the GATT appearance
// as two properties of the same `org.bluez.Device1` object, so both stacks arrive on this
// one walk and the filter lands on the result instead. A device whose stack could not be
// determined is kept -- BlueZ does not promise that a Device1 object carries either
// property, and dropping those would lose devices the default configuration reports.
bool deviceTypeWanted = device->deviceType == FF_BLUETOOTH_DEVICE_TYPE_NONE || (device->deviceType & options->showType);
if (!deviceTypeWanted || (!options->showDisconnected && !device->connected)) {
ffStrbufDestroy(&device->name);
ffStrbufDestroy(&device->address);
ffStrbufDestroy(&device->type);
--devices->length;
continue;
}
if (device->name.length == 0) {
+27 -6
View File
@@ -40,7 +40,7 @@ const char* ffBluetoothDetectLe(FFBluetoothOptions* options, FFlist* devices /*
// 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) {
static bool addressEquals(const wchar_t* plain, const FFstrbuf* address) {
if (address->length != 17) {
return false;
}
@@ -55,7 +55,7 @@ static bool ffBluetoothAddressEquals(const wchar_t* plain, const FFstrbuf* addre
return true;
}
static const char* ffBluetoothDetectBattery(FFlist* devices) {
static const char* detectBattery(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
@@ -107,7 +107,7 @@ static const char* ffBluetoothDetectBattery(FFlist* devices) {
}
FF_LIST_FOR_EACH (FFBluetoothResult, bt, *devices) {
if (ffBluetoothAddressEquals(deviceAddress, &bt->address)) {
if (addressEquals(deviceAddress, &bt->address)) {
bt->battery = battery;
break;
}
@@ -117,7 +117,10 @@ static const char* ffBluetoothDetectBattery(FFlist* devices) {
return nullptr;
}
const char* ffDetectBluetooth(FFBluetoothOptions* options, FFlist* devices /* FFBluetoothResult */) {
// The classic (BR/EDR) half. `BluetoothFindFirstDevice` only ever answers with BR/EDR devices; the
// Low Energy stack is a separate walk, in bluetooth_windows.cpp, which folds what it finds into the
// list this function builds.
static const char* detectClassic(FFBluetoothOptions* options, FFlist* devices /* FFBluetoothResult */) {
FF_LIBRARY_LOAD_MESSAGE(bluetoothapis, "bluetoothapis.dll", 1)
FF_LIBRARY_LOAD_SYMBOL_MESSAGE(bluetoothapis, BluetoothFindFirstDevice)
FF_LIBRARY_LOAD_SYMBOL_MESSAGE(bluetoothapis, BluetoothFindNextDevice)
@@ -239,10 +242,28 @@ const char* ffDetectBluetooth(FFBluetoothOptions* options, FFlist* devices /* FF
ffBluetoothFindDeviceClose(hFind);
}
ffBluetoothDetectLe(options, devices);
return nullptr;
}
const char* ffDetectBluetooth(FFBluetoothOptions* options, FFlist* devices /* FFBluetoothResult */) {
// `showType` selects which stacks are walked, not merely which entries are printed. The classic
// and the Low Energy stack are two different APIs here, so a stack the user switched off is not
// called at all.
if (options->showType & FF_BLUETOOTH_DEVICE_TYPE_CLASSIC_BIT) {
const char* error = detectClassic(options, devices);
if (error) {
return error;
}
}
if (options->showType & FF_BLUETOOTH_DEVICE_TYPE_LE_BIT) {
// A failure here is not fatal: the LE half only adds to the classic list, which stands on
// its own, and "no LE link on this machine" is the ordinary case rather than an error.
ffBluetoothDetectLe(options, devices);
}
if (devices->length > 0) {
ffBluetoothDetectBattery(devices);
detectBattery(devices);
}
return nullptr;
+33 -1
View File
@@ -1,7 +1,6 @@
#include "common/percent.h"
#include "common/printing.h"
#include "common/jsonconfig.h"
#include "common/strutil.h"
#include "detection/bluetooth/bluetooth.h"
#include "modules/bluetooth/bluetooth.h"
@@ -120,6 +119,22 @@ void ffParseBluetoothJsonObject(FFBluetoothOptions* options, yyjson_val* module)
continue;
}
if (unsafe_yyjson_equals_str(key, "showType")) {
int value;
const char* error = ffJsonConfigParseEnum(val, &value, (FFKeyValuePair[]) {
{ "classic", FF_BLUETOOTH_DEVICE_TYPE_CLASSIC_BIT },
{ "le", FF_BLUETOOTH_DEVICE_TYPE_LE_BIT },
{ "both", FF_BLUETOOTH_DEVICE_TYPE_BOTH },
{},
});
if (error) {
ffPrintError(FF_MODULE_GET_DISPLAY_NAME(Bluetooth), 0, &options->moduleArgs, FF_PRINT_TYPE_DEFAULT, "Invalid %s value: %s", unsafe_yyjson_get_str(key), error);
} else {
options->showType = (FFBluetoothDeviceType) value;
}
continue;
}
if (ffPercentParseJsonObject(key, val, &options->percent)) {
continue;
}
@@ -133,6 +148,21 @@ void ffGenerateBluetoothJsonConfig(FFBluetoothOptions* options, yyjson_mut_doc*
yyjson_mut_obj_add_bool(doc, module, "showDisconnected", options->showDisconnected);
switch (options->showType) {
case FF_BLUETOOTH_DEVICE_TYPE_CLASSIC_BIT:
yyjson_mut_obj_add_str(doc, module, "showType", "classic");
break;
case FF_BLUETOOTH_DEVICE_TYPE_LE_BIT:
yyjson_mut_obj_add_str(doc, module, "showType", "le");
break;
case FF_BLUETOOTH_DEVICE_TYPE_BOTH:
yyjson_mut_obj_add_str(doc, module, "showType", "both");
break;
case FF_BLUETOOTH_DEVICE_TYPE_NONE:
default:
break;
}
ffPercentGenerateJsonConfig(doc, module, options->percent);
}
@@ -181,6 +211,8 @@ bool ffGenerateBluetoothJsonResult(FFBluetoothOptions* options, yyjson_mut_doc*
void ffInitBluetoothOptions(FFBluetoothOptions* options) {
ffOptionInitModuleArg(&options->moduleArgs, "");
options->showDisconnected = false;
// Both stacks are walked by default; `showType` exists to switch one off, not to switch one on.
options->showType = FF_BLUETOOTH_DEVICE_TYPE_BOTH;
options->percent = (FFPercentageModuleConfig) { 50, 20, 0 };
}
+12
View File
@@ -3,10 +3,22 @@
#include "common/option.h"
#include "common/percent.h"
// Which radio a device answers on. A dual-mode device answers on both, and a backend that walks only
// one stack can only report the bit it knows about, so this is a bitfield rather than a value. It
// carries both meanings the module needs: which stacks a result was seen on, and which stacks
// `options->showType` asks the detection to walk at all.
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
FF_BLUETOOTH_DEVICE_TYPE_BOTH = FF_BLUETOOTH_DEVICE_TYPE_CLASSIC_BIT | FF_BLUETOOTH_DEVICE_TYPE_LE_BIT,
} FFBluetoothDeviceType;
typedef struct FFBluetoothOptions {
FFModuleArgs moduleArgs;
bool showDisconnected;
FFBluetoothDeviceType showType;
FFPercentageModuleConfig percent;
} FFBluetoothOptions;