From 592ef68d60e5a9d15556dcfd4f3c16b5fe1c1d19 Mon Sep 17 00:00:00 2001 From: Carter Li Date: Wed, 23 Sep 2026 18:04:46 +0800 Subject: [PATCH] Bluetooth (Linux): adds comments about LE device support of BlueZ --- src/detection/bluetooth/bluetooth_linux.c | 88 ++++++++++++++++++++--- 1 file changed, 80 insertions(+), 8 deletions(-) diff --git a/src/detection/bluetooth/bluetooth_linux.c b/src/detection/bluetooth/bluetooth_linux.c index e335acb2b..78187822a 100644 --- a/src/detection/bluetooth/bluetooth_linux.c +++ b/src/detection/bluetooth/bluetooth_linux.c @@ -50,6 +50,66 @@ array [ //root ] */ +/* How the two stack bits are inferred here, and why the LE one is mostly unattainable. + * + * `org.bluez.Device1` has no "does this device support LE" property, so both bits are read off + * the only two stack-specific ones it has: + * + * Class - BR/EDR Class of Device, learned from BR/EDR EIR / inquiry data + * (BlueZ src/eir.c, EIR_CLASS_OF_DEV) and persisted in the device file. + * Appearance - GATT appearance, learned from LE advertising data only + * (BlueZ src/adapter.c -> src/eir.c, EIR_GAP_APPEARANCE, AD type 0x19). + * + * For a dual-mode device only the first one ever shows up, for two independent reasons: + * + * 1. BlueZ never publishes `Appearance` for a device that has a `Class`. Its getter short + * circuits on it, and the property table registers that getter as the `exists` callback, + * so the key is absent from `GetManagedObjects` altogether rather than present with a + * default value: + * + * static gboolean get_appearance(...) + * { + * if (dev_property_exists_class(property, data)) // device->class != 0 + * return FALSE; + * if (device->appearance) { ... return TRUE; } + * return FALSE; + * } + * + * `btd_device_get_icon()` prefers `Class` the same way, which is why `Icon` describes a + * classic-seen device by its CoD. BlueZ treats the two as mutually exclusive descriptions + * of the device's *category*, not as a record of which radios it speaks. Checked against + * BlueZ 5.87, src/device.c. + * + * 2. Even without (1) the value is usually unknown: it is only learned if the device + * advertised AD type 0x19 while we were scanning (phones rarely do), a zero is never + * persisted (`/var/lib/bluetooth///info` then has no `Appearance=` line), + * and that directory is mode 0700 so it could not be read here anyway. + * + * Consequence: a device ever seen over BR/EDR -- every paired phone, since all of them have a + * CoD -- can only ever be reported as Classic, however LE capable it really is, and no amount + * of extra data collected on this path can change that. Windows gets it right because it + * enumerates the two stacks separately (bluetooth_windows.cpp walks the WinRT + * BluetoothLEDevice endpoints and ORs in the LE bit); BlueZ has no equivalent enumeration -- + * one Device1 object per address, no per-stack capability -- and its own storage only records + * `SupportedTechnologies=BR/EDR;LE` once a real LE connection or an LE sighting has happened. + * + * Signals that look like LE proof but are not, and are deliberately not used here: + * - GAP / GATT in `UUIDs` (0x1800 / 0x1801): BlueZ mirrors BR/EDR SDP records advertising + * the ATT bearer (L2CAP PSM 0x001F) into the same `attributes` cache it loads GATT + * primaries from, so those UUIDs can come from a purely classic SDP browse. + * - `AddressType`: a BR/EDR-only record reports "public" too -- `device->bdaddr_type` is + * forced to `BDADDR_BREDR` when the record is loaded. + * - `ManufacturerData` / `ServiceData`: BlueZ parses them out of BR/EDR EIR as well. + * + * The one durable LE-only signal BlueZ does publish, if this is ever revisited: + * - `AdvertisingFlags` exists once the device has been seen in an LE advertising report. + * It is written only from the `bdaddr_type != BDADDR_BREDR` branch of BlueZ's device-found + * handler, starts out invalid (0xff) and is never cleared, so it survives the end of a + * discovery session -- but it is not persisted, so a bluetoothd restart loses it again. + * `PreferredBearer` would be even better (it exists exactly when both bearers are known), + * yet it is flagged experimental and needs `Experimental=true` to be exported at all. + */ + static bool detectBluetoothValue(FFDBusData* dbus, DBusMessageIter* iter, FFBluetoothResult* device) { if (dbus->lib->ffdbus_message_iter_get_arg_type(iter) != DBUS_TYPE_DICT_ENTRY) { return true; @@ -72,12 +132,20 @@ static bool detectBluetoothValue(FFDBusData* dbus, DBusMessageIter* iter, FFBlue } else if (ffStrEquals(deviceProperty, "Name")) { ffDBusGetString(dbus, &dictIter, &device->name); } else if (ffStrEquals(deviceProperty, "Icon")) { + // BlueZ derives this from the Class of Device whenever there is one and only falls back + // to the LE appearance otherwise, so `type` describes a classic-seen device by its CoD. 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. + // The BR/EDR Class of Device. Published iff BlueZ ever learned a CoD, which comes from + // BR/EDR EIR / inquiry data, so this means "seen over BR/EDR" -- not "classic only". + // Its mere presence is also what hides `Appearance` in the branch below. 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. + // The LE-only GATT appearance. Absence does *not* mean "not LE": BlueZ omits this + // property entirely for every device that has a `Class` (see the block comment above), + // and knows the value only if the device advertised AD type 0x19 while we were scanning. + // The two properties are mutually exclusive in BlueZ's view, so a dual-mode phone that + // was ever seen classically reports Classic only, whatever it is capable of. device->deviceType |= FF_BLUETOOTH_DEVICE_TYPE_LE_BIT; } else if (ffStrEquals(deviceProperty, "Percentage")) { uint64_t percentage; @@ -87,7 +155,9 @@ 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. + // `int16` on the wire, in dBm. Written only from discovery reports and invalidated when + // the discovery session ends, so it says nothing about the connection state: a connected + // device that has not been scanned lately reports no RSSI at all. int64_t rssi; if (ffDBusGetInt(dbus, &dictIter, &rssi)) { device->signalQuality = ffRssiToSignalQuality((int) rssi); @@ -197,11 +267,13 @@ static void detectBluetoothRoot(FFBluetoothOptions* options, FFlist* devices, FF if (device) { // `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. + // per-stack call to skip here: both bits are read off this one walk of Device1, so the + // filter lands on the result instead. That filter is necessarily one sided -- see the + // block comment above detectBluetoothValue(): `Class` and `Appearance` are mutually + // exclusive in BlueZ, so a dual-mode device that was ever seen classically carries only + // the classic bit, and `showType: le` drops it even though it does speak LE. A device + // whose stack could not be determined at all is kept -- BlueZ promises neither 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)) {