ohmu
·
2026-10-15
1#include "bluetooth.h"
2#include "common/debug.h"
3#include "common/percent.h"
4#include "common/strutil.h"
5
6#ifdef FF_HAVE_DBUS
7 #include "common/dbus.h"
8 #include "common/io.h"
9
10/* Example dbus reply, striped to only the relevant parts:
11array [ //root
12 dict entry( //object
13 object path "/org/bluez/hci0/dev_03_21_8B_91_16_4D"
14 array [
15 dict entry( //property
16 string "org.bluez.Device1"
17 array [
18 dict entry( //value
19 string "Address"
20 variant string "03:21:8B:91:16:4D"
21 )
22 dict entry( //value
23 string "Name"
24 variant string "JBL TUNE160BT"
25 )
26 dict entry( //value
27 string "Icon"
28 variant string "audio-headset"
29 )
30 dict entry( //value
31 string "Connected"
32 variant boolean true
33 )
34 dict entry( //value
35 string "RSSI"
36 variant int16 -63
37 )
38 ]
39 )
40 dict entry( //property
41 string "org.bluez.Battery1"
42 array [
43 dict entry( //value
44 string "Percentage"
45 variant byte 100
46 )
47 ]
48 )
49 ]
50 )
51]
52*/
53
54/* How the two stack bits are inferred here, and why the LE one is mostly unattainable.
55 *
56 * `org.bluez.Device1` has no "does this device support LE" property, so both bits are read off
57 * the only two stack-specific ones it has:
58 *
59 * Class - BR/EDR Class of Device, learned from BR/EDR EIR / inquiry data
60 * (BlueZ src/eir.c, EIR_CLASS_OF_DEV) and persisted in the device file.
61 * Appearance - GATT appearance, learned from LE advertising data only
62 * (BlueZ src/adapter.c -> src/eir.c, EIR_GAP_APPEARANCE, AD type 0x19).
63 *
64 * For a dual-mode device only the first one ever shows up, for two independent reasons:
65 *
66 * 1. BlueZ never publishes `Appearance` for a device that has a `Class`. Its getter short
67 * circuits on it, and the property table registers that getter as the `exists` callback,
68 * so the key is absent from `GetManagedObjects` altogether rather than present with a
69 * default value:
70 *
71 * static gboolean get_appearance(...)
72 * {
73 * if (dev_property_exists_class(property, data)) // device->class != 0
74 * return FALSE;
75 * if (device->appearance) { ... return TRUE; }
76 * return FALSE;
77 * }
78 *
79 * `btd_device_get_icon()` prefers `Class` the same way, which is why `Icon` describes a
80 * classic-seen device by its CoD. BlueZ treats the two as mutually exclusive descriptions
81 * of the device's *category*, not as a record of which radios it speaks. Checked against
82 * BlueZ 5.87, src/device.c.
83 *
84 * 2. Even without (1) the value is usually unknown: it is only learned if the device
85 * advertised AD type 0x19 while we were scanning (phones rarely do), a zero is never
86 * persisted (`/var/lib/bluetooth/<adapter>/<addr>/info` then has no `Appearance=` line),
87 * and that directory is mode 0700 so it could not be read here anyway.
88 *
89 * Consequence: a device ever seen over BR/EDR -- every paired phone, since all of them have a
90 * CoD -- can only ever be reported as Classic, however LE capable it really is, and no amount
91 * of extra data collected on this path can change that. Windows gets it right because it
92 * enumerates the two stacks separately (bluetooth_windows.cpp walks the WinRT
93 * BluetoothLEDevice endpoints and ORs in the LE bit); BlueZ has no equivalent enumeration --
94 * one Device1 object per address, no per-stack capability -- and its own storage only records
95 * `SupportedTechnologies=BR/EDR;LE` once a real LE connection or an LE sighting has happened.
96 *
97 * Signals that look like LE proof but are not, and are deliberately not used here:
98 * - GAP / GATT in `UUIDs` (0x1800 / 0x1801): BlueZ mirrors BR/EDR SDP records advertising
99 * the ATT bearer (L2CAP PSM 0x001F) into the same `attributes` cache it loads GATT
100 * primaries from, so those UUIDs can come from a purely classic SDP browse.
101 * - `AddressType`: a BR/EDR-only record reports "public" too -- `device->bdaddr_type` is
102 * forced to `BDADDR_BREDR` when the record is loaded.
103 * - `ManufacturerData` / `ServiceData`: BlueZ parses them out of BR/EDR EIR as well.
104 *
105 * The one durable LE-only signal BlueZ does publish, if this is ever revisited:
106 * - `AdvertisingFlags` exists once the device has been seen in an LE advertising report.
107 * It is written only from the `bdaddr_type != BDADDR_BREDR` branch of BlueZ's device-found
108 * handler, starts out invalid (0xff) and is never cleared, so it survives the end of a
109 * discovery session -- but it is not persisted, so a bluetoothd restart loses it again.
110 * `PreferredBearer` would be even better (it exists exactly when both bearers are known),
111 * yet it is flagged experimental and needs `Experimental=true` to be exported at all.
112 */
113
114static bool detectBluetoothValue(FFDBusData* dbus, DBusMessageIter* iter, FFBluetoothResult* device) {
115 if (dbus->lib->ffdbus_message_iter_get_arg_type(iter) != DBUS_TYPE_DICT_ENTRY) {
116 return true;
117 }
118
119 DBusMessageIter dictIter;
120 dbus->lib->ffdbus_message_iter_recurse(iter, &dictIter);
121
122 if (dbus->lib->ffdbus_message_iter_get_arg_type(&dictIter) != DBUS_TYPE_STRING) {
123 return true;
124 }
125
126 const char* deviceProperty;
127 dbus->lib->ffdbus_message_iter_get_basic(&dictIter, &deviceProperty);
128
129 dbus->lib->ffdbus_message_iter_next(&dictIter);
130
131 if (ffStrEquals(deviceProperty, "Address")) {
132 ffDBusGetString(dbus, &dictIter, &device->address);
133 } else if (ffStrEquals(deviceProperty, "Name")) {
134 ffDBusGetString(dbus, &dictIter, &device->name);
135 } else if (ffStrEquals(deviceProperty, "Icon")) {
136 // BlueZ derives this from the Class of Device whenever there is one and only falls back
137 // to the LE appearance otherwise, so `type` describes a classic-seen device by its CoD.
138 ffDBusGetString(dbus, &dictIter, &device->type);
139 } else if (ffStrEquals(deviceProperty, "Class")) {
140 // The BR/EDR Class of Device. Published iff BlueZ ever learned a CoD, which comes from
141 // BR/EDR EIR / inquiry data, so this means "seen over BR/EDR" -- not "classic only".
142 // Its mere presence is also what hides `Appearance` in the branch below.
143 device->deviceType |= FF_BLUETOOTH_DEVICE_TYPE_CLASSIC_BIT;
144 } else if (ffStrEquals(deviceProperty, "Appearance")) {
145 // The LE-only GATT appearance. Absence does *not* mean "not LE": BlueZ omits this
146 // property entirely for every device that has a `Class` (see the block comment above),
147 // and knows the value only if the device advertised AD type 0x19 while we were scanning.
148 // The two properties are mutually exclusive in BlueZ's view, so a dual-mode phone that
149 // was ever seen classically reports Classic only, whatever it is capable of.
150 device->deviceType |= FF_BLUETOOTH_DEVICE_TYPE_LE_BIT;
151 } else if (ffStrEquals(deviceProperty, "Percentage")) {
152 uint64_t percentage;
153 if (ffDBusGetUint(dbus, &dictIter, &percentage)) {
154 device->battery = (uint8_t) percentage;
155 }
156 } else if (ffStrEquals(deviceProperty, "Connected")) {
157 ffDBusGetBool(dbus, &dictIter, &device->connected);
158 } else if (ffStrEquals(deviceProperty, "RSSI")) {
159 // `int16` on the wire, in dBm. Written only from discovery reports and invalidated when
160 // the discovery session ends, so it says nothing about the connection state: a connected
161 // device that has not been scanned lately reports no RSSI at all.
162 int64_t rssi;
163 if (ffDBusGetInt(dbus, &dictIter, &rssi)) {
164 device->signalQuality = ffRssiToSignalQuality((int) rssi);
165 }
166 } else if (ffStrEquals(deviceProperty, "Paired")) {
167 bool paired = true;
168 ffDBusGetBool(dbus, &dictIter, &paired);
169 if (!paired) {
170 return false;
171 }
172 }
173 return true;
174}
175
176static void detectBluetoothProperty(FFDBusData* dbus, DBusMessageIter* iter, FFBluetoothResult* device) {
177 if (dbus->lib->ffdbus_message_iter_get_arg_type(iter) != DBUS_TYPE_DICT_ENTRY) {
178 return;
179 }
180
181 DBusMessageIter dictIter;
182 dbus->lib->ffdbus_message_iter_recurse(iter, &dictIter);
183
184 if (dbus->lib->ffdbus_message_iter_get_arg_type(&dictIter) != DBUS_TYPE_STRING) {
185 return;
186 }
187
188 const char* propertyType;
189 dbus->lib->ffdbus_message_iter_get_basic(&dictIter, &propertyType);
190
191 if (!ffStrContains(propertyType, ".Device") && !ffStrContains(propertyType, ".Battery")) {
192 return; // We don't care about other properties
193 }
194
195 dbus->lib->ffdbus_message_iter_next(&dictIter);
196
197 if (dbus->lib->ffdbus_message_iter_get_arg_type(&dictIter) != DBUS_TYPE_ARRAY) {
198 return;
199 }
200
201 DBusMessageIter arrayIter;
202 dbus->lib->ffdbus_message_iter_recurse(&dictIter, &arrayIter);
203
204 do {
205 bool shouldContinue = detectBluetoothValue(dbus, &arrayIter, device);
206 if (!shouldContinue) {
207 ffStrbufClear(&device->name);
208 break;
209 }
210 } while (dbus->lib->ffdbus_message_iter_next(&arrayIter));
211}
212
213static FFBluetoothResult* detectBluetoothObject(FFlist* devices, FFDBusData* dbus, DBusMessageIter* iter) {
214 if (dbus->lib->ffdbus_message_iter_get_arg_type(iter) != DBUS_TYPE_DICT_ENTRY) {
215 return nullptr;
216 }
217
218 DBusMessageIter dictIter;
219 dbus->lib->ffdbus_message_iter_recurse(iter, &dictIter);
220
221 if (dbus->lib->ffdbus_message_iter_get_arg_type(&dictIter) != DBUS_TYPE_OBJECT_PATH) {
222 return nullptr;
223 }
224
225 const char* objectPath;
226 dbus->lib->ffdbus_message_iter_get_basic(&dictIter, &objectPath);
227
228 // We don't want adapter objects
229 if (!ffStrContains(objectPath, "/dev_")) {
230 return nullptr;
231 }
232
233 dbus->lib->ffdbus_message_iter_next(&dictIter);
234
235 if (dbus->lib->ffdbus_message_iter_get_arg_type(&dictIter) != DBUS_TYPE_ARRAY) {
236 return nullptr;
237 }
238
239 DBusMessageIter arrayIter;
240 dbus->lib->ffdbus_message_iter_recurse(&dictIter, &arrayIter);
241
242 FFBluetoothResult* device = FF_LIST_ADD(FFBluetoothResult, *devices);
243 ffStrbufInit(&device->name);
244 ffStrbufInit(&device->address);
245 ffStrbufInit(&device->type);
246 device->deviceType = FF_BLUETOOTH_DEVICE_TYPE_NONE;
247 device->battery = 0;
248 device->signalQuality = -DBL_MAX;
249 device->connected = false;
250
251 do {
252 detectBluetoothProperty(dbus, &arrayIter, device);
253 } while (dbus->lib->ffdbus_message_iter_next(&arrayIter));
254
255 return device;
256}
257
258static void detectBluetoothRoot(FFBluetoothOptions* options, FFlist* devices, FFDBusData* dbus, DBusMessageIter* iter, int32_t connectedCount) {
259 if (dbus->lib->ffdbus_message_iter_get_arg_type(iter) != DBUS_TYPE_ARRAY) {
260 return;
261 }
262
263 DBusMessageIter arrayIter;
264 dbus->lib->ffdbus_message_iter_recurse(iter, &arrayIter);
265
266 do {
267 FFBluetoothResult* device = detectBluetoothObject(devices, dbus, &arrayIter);
268
269 if (device) {
270 // `showType` selects which stacks are reported. Unlike the other backends there is no
271 // per-stack call to skip here: both bits are read off this one walk of Device1, so the
272 // filter lands on the result instead. That filter is necessarily one sided -- see the
273 // block comment above detectBluetoothValue(): `Class` and `Appearance` are mutually
274 // exclusive in BlueZ, so a dual-mode device that was ever seen classically carries only
275 // the classic bit, and `showType: le` drops it even though it does speak LE. A device
276 // whose stack could not be determined at all is kept -- BlueZ promises neither property,
277 // and dropping those would lose devices the default configuration reports.
278 bool deviceTypeWanted = device->deviceType == FF_BLUETOOTH_DEVICE_TYPE_NONE || (device->deviceType & options->showType);
279
280 if (!deviceTypeWanted || (!options->showDisconnected && !device->connected)) {
281 ffStrbufDestroy(&device->name);
282 ffStrbufDestroy(&device->address);
283 ffStrbufDestroy(&device->type);
284 --devices->length;
285 continue;
286 }
287
288 if (device->name.length == 0) {
289 ffStrbufSetStatic(&device->name, "Unknown Device");
290 }
291
292 if (device->connected && --connectedCount == 0) {
293 break;
294 }
295 }
296 } while (dbus->lib->ffdbus_message_iter_next(&arrayIter));
297}
298
299static const char* detectBluetooth(FFBluetoothOptions* options, FFlist* devices, int32_t connectedCount) {
300 FF_DBUS_AUTO_DESTROY_DATA FFDBusData dbus = {};
301 const char* error = ffDBusLoadData(DBUS_BUS_SYSTEM, &dbus);
302 if (error) {
303 return error;
304 }
305
306 DBusMessage* managedObjects = ffDBusGetMethodReply(&dbus, "org.bluez", "/", "org.freedesktop.DBus.ObjectManager", "GetManagedObjects", nullptr, nullptr);
307 if (!managedObjects) {
308 FF_DEBUG("Failed to call GetManagedObjects");
309 return "Failed to call GetManagedObjects";
310 }
311
312 DBusMessageIter rootIter;
313 if (!dbus.lib->ffdbus_message_iter_init(managedObjects, &rootIter)) {
314 FF_DEBUG("Failed to get root iterator of GetManagedObjects");
315 dbus.lib->ffdbus_message_unref(managedObjects);
316 return "Failed to get root iterator of GetManagedObjects";
317 }
318
319 detectBluetoothRoot(options, devices, &dbus, &rootIter, connectedCount);
320
321 dbus.lib->ffdbus_message_unref(managedObjects);
322 return nullptr;
323}
324
325static uint32_t connectedDevices(void) {
326 FF_AUTO_CLOSE_DIR DIR* dirp = opendir("/sys/class/bluetooth");
327 if (dirp == nullptr) {
328 return 0;
329 }
330
331 uint32_t result = 0;
332 struct dirent* entry;
333 while ((entry = readdir(dirp)) != nullptr) {
334 if (strchr(entry->d_name, ':') != nullptr) {
335 ++result;
336 }
337 }
338
339 return result;
340}
341
342#endif
343
344const char* ffDetectBluetooth(FFBluetoothOptions* options, FFlist* devices /* FFBluetoothResult */) {
345#ifdef FF_HAVE_DBUS
346 int32_t connectedCount = -1;
347 if (!options->showDisconnected) {
348 connectedCount = (int32_t) connectedDevices();
349 if (connectedCount == 0) {
350 return nullptr;
351 }
352 }
353
354 return detectBluetooth(options, devices, connectedCount);
355#else
356 FF_UNUSED(options, devices);
357 FF_DEBUG("Fastfetch was compiled without DBus support");
358 return "Fastfetch was compiled without DBus support";
359#endif
360}