Skip to content

Commit d8dc3e4

Browse files
[bluetooth.bluez] Improve stability of BlueZ adapter and align with interface
Wrong method implementation/assumption -------------------------------------- `BluetoothDevice#connect` is documented as: "[...] Connects to a device. This is an asynchronous method. [...]" and there is a companion method `BluetoothDevice#awaitConnection`. While documentation on the latter method is missing, it only makes sense if `connect` is indeed implemented asynchronously and `awaitConnection` allows to issue a bounded wait for the connection to be established. There are two issues: - ConnectedBluetoothHandler assumes that BluetoothDevice#connect indeed connects and returns `true` on successful connection in the `reconnectJob`. The problem is that `true` only means, that the connection process started. This is rectified by issuing a `connect` called followd by an `awaitConnection` call. The latter assumes that connection is established after not more than 30s. - `BlueZBluetoothDevice#connect` directly calls into the bluez adapter `Connect` method which is blocking. To work around this the call is dispatched into an asynchronous call and parallel invocation is prevented using an AtomicBoolean as lock/indicator. Too early BlueZ GATT characteristic interaction ----------------------------------------------- The BlueZ API does not make the characteristics available to clients until service discovery is done. Service discovery is automatically started after a connection is established. The methods interacting with bluetooth GATT characteristics have to wait until discovery is done and can only then attempt to read/write the characteristics. getDBusBlueZCharacteristicByUUID is thus modified to wait for discovery to finish. Most code paths are already prepared for an asynchronous invocation and this discovery becomes part of that chain. Timeouts -------- While working with Govee Bluetooth LE devices it was noted, that these devices take a long time to connect. The observed numbers were: | Scanning | Discovery | | -------- | --------- | | 12,5s | 0,3s | | 16,2s | 0,8s | | 14,9s | 0,3s | | 15,1s | 0,3s | | 8,0s | 0,8s | | 11,1s | 0,3s | | 14,6s | 0,3s | | 28,0s | 0,3s | | 4,6s | 0,3s | | 17,3s | 0,3s | | 19,5s | 0,3s | | 15,9s | 0,3s | | 4,0s | 0,8s | | 13,6s | 0,3s | | 19,2s | 0,3s | This causes problems as that might exceed the default limit of 20s dbus-java sets for replies for dbus calls. The timeouts for connection and discovery were thus bumped to 30s to account for slow devices. The call timeout for dbus-java was bumped to 60s. It is assumed, that the dbus connection can be assumed to be stable in general and thus calls that take long are not broken at the connection level, but are indeed just slow. Signed-off-by: Matthias Bläsing <mblaesing@doppel-helix.eu>
1 parent d2d93c8 commit d8dc3e4

3 files changed

Lines changed: 143 additions & 131 deletions

File tree

bundles/org.openhab.binding.bluetooth.bluez/src/main/java/org/openhab/binding/bluetooth/bluez/internal/BlueZBluetoothDevice.java

Lines changed: 134 additions & 128 deletions
Original file line numberDiff line numberDiff line change
@@ -17,12 +17,14 @@
1717
import java.util.Objects;
1818
import java.util.UUID;
1919
import java.util.concurrent.CompletableFuture;
20+
import java.util.concurrent.ExecutionException;
2021
import java.util.concurrent.ScheduledExecutorService;
2122
import java.util.concurrent.TimeUnit;
23+
import java.util.concurrent.atomic.AtomicBoolean;
24+
import java.util.concurrent.atomic.AtomicInteger;
2225

2326
import org.eclipse.jdt.annotation.NonNullByDefault;
2427
import org.eclipse.jdt.annotation.Nullable;
25-
import org.freedesktop.dbus.errors.NoReply;
2628
import org.freedesktop.dbus.errors.UnknownObject;
2729
import org.freedesktop.dbus.exceptions.DBusException;
2830
import org.freedesktop.dbus.exceptions.DBusExecutionException;
@@ -72,6 +74,8 @@ public class BlueZBluetoothDevice extends BaseBluetoothDevice implements BlueZEv
7274

7375
private final ScheduledExecutorService scheduler = ThreadPoolManager.getScheduledPool("bluetooth");
7476

77+
private final AtomicBoolean connectionStartRunning = new AtomicBoolean();
78+
7579
// Device from native lib
7680
private @Nullable BluetoothDevice device = null;
7781

@@ -170,6 +174,7 @@ public void dispose() {
170174
}
171175

172176
private void setConnectionState(ConnectionState state) {
177+
logger.debug("Update connection state for {} to {}", address, state);
173178
if (this.connectionState != state) {
174179
this.connectionState = state;
175180
notifyListeners(BluetoothEventType.CONNECTION_STATE, new BluetoothConnectionStatusNotification(state));
@@ -207,42 +212,47 @@ synchronized void resetForRemoval() {
207212

208213
@Override
209214
public boolean connect() {
210-
logger.debug("Connect({})", device);
211-
212215
BluetoothDevice dev = device;
213-
if (dev != null) {
214-
if (Boolean.FALSE.equals(dev.isConnected())) {
215-
// BlueZ Device.Connect() is unreliable while the adapter is actively discovering
216-
// (the call blocks / does not complete). Pause discovery first; the bridge's periodic
217-
// refresh job resumes it shortly after.
218-
bridgeHandler.stopDiscovery();
219-
try {
216+
217+
if (dev == null) {
218+
return false;
219+
}
220+
221+
logger.debug("Connect({})", dev);
222+
223+
if (Boolean.FALSE.equals(dev.isConnected())) {
224+
if (connectionStartRunning.compareAndSet(false, true)) {
225+
CompletableFuture.runAsync(() -> {
226+
setConnectionState(ConnectionState.CONNECTING);
227+
// BlueZ Device.Connect() is unreliable while the adapter is actively discovering
228+
// (the call blocks / does not complete). Pause discovery first; the bridge's periodic
229+
// refresh job resumes it shortly after.
230+
bridgeHandler.stopDiscovery();
231+
// This method does not block at most until the dbus-java
232+
// method timeout is reached.
220233
boolean ret = dev.connect();
221234
logger.debug("Connect result: {}", ret);
222-
return ret;
223-
} catch (NoReply e) {
224-
// Have to double check because sometimes, exception but still worked
225-
logger.debug("Got a timeout - but sometimes happen. Is Connected ? {}", dev.isConnected());
226-
if (Boolean.FALSE.equals(dev.isConnected())) {
227-
notifyListeners(BluetoothEventType.CONNECTION_STATE,
228-
new BluetoothConnectionStatusNotification(ConnectionState.DISCONNECTED));
229-
return false;
230-
} else {
231-
return true;
235+
ConnectionState cs1 = ret ? ConnectionState.CONNECTED : ConnectionState.DISCONNECTED;
236+
logger.debug("Updating connection state after connect: {}", cs1);
237+
setConnectionState(cs1);
238+
}, scheduler).handle((voidResult, th) -> {
239+
if (th != null) {
240+
logger.debug("Failed to connect", th);
241+
setConnectionState(ConnectionState.DISCONNECTED);
232242
}
233-
} catch (DBusExecutionException e) {
234-
// Catch "software caused connection abort"
235-
return false;
236-
} catch (Exception e) {
237-
logger.warn("error occurred while trying to connect", e);
238-
}
243+
connectionStartRunning.set(false);
244+
return null;
245+
});
239246
} else {
240-
logger.debug("Device was already connected");
241-
// we might be stuck in another state atm so we need to trigger a connected in this case
242-
setConnectionState(ConnectionState.CONNECTED);
243-
return true;
247+
logger.debug("Connection already in progress for {}", address);
244248
}
249+
return true;
250+
} else {
251+
logger.debug("Device was already connected");
252+
// we might be stuck in another state atm so we need to trigger a connected in this case
253+
setConnectionState(ConnectionState.CONNECTED);
245254
}
255+
246256
return false;
247257
}
248258

@@ -283,19 +293,29 @@ private List<BluetoothGattService> getGattServicesRefreshed(BluetoothDevice dev)
283293
return services;
284294
}
285295

286-
private @Nullable BluetoothGattCharacteristic getDBusBlueZCharacteristicByUUID(String uuid) {
296+
private @Nullable CompletableFuture<BluetoothGattCharacteristic> getDBusBlueZCharacteristicByUUID(String uuid) {
287297
BluetoothDevice dev = device;
288298
if (dev == null) {
289299
return null;
290300
}
291-
for (BluetoothGattService service : getGattServicesRefreshed(dev)) {
292-
for (BluetoothGattCharacteristic characteristic : service.getGattCharacteristics()) {
293-
if (characteristic != null && uuid.equalsIgnoreCase(characteristic.getUuid())) {
294-
return characteristic;
301+
AtomicInteger atomicInteger = new AtomicInteger();
302+
return RetryFuture.callWithRetry(() -> {
303+
if (Boolean.TRUE.equals(dev.isServicesResolved())) {
304+
for (BluetoothGattService service : getGattServicesRefreshed(dev)) {
305+
for (BluetoothGattCharacteristic characteristic : service.getGattCharacteristics()) {
306+
if (characteristic != null && uuid.equalsIgnoreCase(characteristic.getUuid())) {
307+
return characteristic;
308+
}
309+
}
295310
}
296311
}
297-
}
298-
return null;
312+
313+
if (atomicInteger.incrementAndGet() < 100) { // 100 iterations 50ms each => 5s
314+
throw new RetryException(50, TimeUnit.MILLISECONDS);
315+
}
316+
317+
throw new IllegalStateException("Characteristic " + uuid + " is missing on device");
318+
}, scheduler);
299319
}
300320

301321
private @Nullable BluetoothGattCharacteristic getDBusBlueZCharacteristicByDBusPath(String dBusPath) {
@@ -323,30 +343,25 @@ private List<BluetoothGattService> getGattServicesRefreshed(BluetoothDevice dev)
323343
.failedFuture(new IllegalStateException("DBusBlueZ device is not set or not connected"));
324344
}
325345

326-
BluetoothGattCharacteristic c = getDBusBlueZCharacteristicByUUID(characteristic.getUuid().toString());
327-
if (c == null) {
328-
logger.warn("Characteristic '{}' is missing on device '{}'.", characteristic.getUuid(), address);
329-
return CompletableFuture.failedFuture(
330-
new IllegalStateException("Characteristic " + characteristic.getUuid() + " is missing on device"));
331-
}
332-
333-
return RetryFuture.callWithRetry(() -> {
334-
try {
335-
c.startNotify();
336-
} catch (DBusException e) {
337-
String exceptionMessage = e.getMessage();
338-
if (exceptionMessage != null && exceptionMessage.contains("Already notifying")) {
346+
return getDBusBlueZCharacteristicByUUID(characteristic.getUuid().toString())
347+
.thenCompose(c -> RetryFuture.callWithRetry(() -> {
348+
try {
349+
c.startNotify();
350+
} catch (DBusExecutionException e) {
351+
String exceptionMessage = e.getMessage();
352+
if (exceptionMessage != null && exceptionMessage.contains("Already notifying")) {
353+
return null;
354+
} else if (exceptionMessage != null && exceptionMessage.contains("In Progress")) {
355+
// let's retry in half a second
356+
throw new RetryException(500, TimeUnit.MILLISECONDS);
357+
} else {
358+
logger.warn("Exception occurred while activating notifications on '{}'", address, e);
359+
throw e;
360+
}
361+
}
362+
;
339363
return null;
340-
} else if (exceptionMessage != null && exceptionMessage.contains("In Progress")) {
341-
// let's retry in half a second
342-
throw new RetryException(500, TimeUnit.MILLISECONDS);
343-
} else {
344-
logger.warn("Exception occurred while activating notifications on '{}'", address, e);
345-
throw e;
346-
}
347-
}
348-
return null;
349-
}, scheduler);
364+
}, scheduler));
350365
}
351366

352367
@Override
@@ -359,23 +374,17 @@ private List<BluetoothGattService> getGattServicesRefreshed(BluetoothDevice dev)
359374
.failedFuture(new IllegalStateException("DBusBlueZ device is not set or not connected"));
360375
}
361376

362-
BluetoothGattCharacteristic c = getDBusBlueZCharacteristicByUUID(characteristic.getUuid().toString());
363-
if (c == null) {
364-
logger.warn("Characteristic '{}' is missing on device '{}'.", characteristic.getUuid(), address);
365-
return CompletableFuture.failedFuture(
366-
new IllegalStateException("Characteristic " + characteristic.getUuid() + " is missing on device"));
367-
}
368-
369-
return RetryFuture.callWithRetry(() -> {
370-
try {
371-
c.writeValue(value, null);
372-
return null;
373-
} catch (DBusException e) {
374-
logger.debug("Exception occurred when trying to write characteristic '{}': {}",
375-
characteristic.getUuid(), e.getMessage());
376-
throw e;
377-
}
378-
}, scheduler);
377+
return getDBusBlueZCharacteristicByUUID(characteristic.getUuid().toString())
378+
.thenCompose(c -> RetryFuture.callWithRetry(() -> {
379+
try {
380+
c.writeValue(value, null);
381+
return null;
382+
} catch (DBusException e) {
383+
logger.debug("Exception occurred when trying to write characteristic '{}': {}",
384+
characteristic.getUuid(), e.getMessage());
385+
throw e;
386+
}
387+
}, scheduler));
379388
}
380389

381390
@Override
@@ -385,6 +394,7 @@ public void onDBusBlueZEvent(BlueZEvent event) {
385394

386395
@Override
387396
public void onServicesResolved(ServicesResolvedEvent event) {
397+
logger.debug("onServicesResolved: {}", event.isResolved());
388398
if (event.isResolved()) {
389399
// Populate our service/characteristic list from the now-resolved GATT and notify
390400
// listeners. BlueZ can deliver ServicesResolved=true while our own supportedServices list
@@ -461,9 +471,7 @@ public void onRssiUpdate(RssiEvent event) {
461471

462472
@Override
463473
public void onConnectedStatusUpdate(ConnectedEvent event) {
464-
this.connectionState = event.isConnected() ? ConnectionState.CONNECTED : ConnectionState.DISCONNECTED;
465-
notifyListeners(BluetoothEventType.CONNECTION_STATE,
466-
new BluetoothConnectionStatusNotification(connectionState));
474+
setConnectionState(event.isConnected() ? ConnectionState.CONNECTED : ConnectionState.DISCONNECTED);
467475
}
468476

469477
@Override
@@ -562,33 +570,32 @@ public CompletableFuture<byte[]> readCharacteristic(BluetoothCharacteristic char
562570
.failedFuture(new IllegalStateException("DBusBlueZ device is not set or not connected"));
563571
}
564572

565-
BluetoothGattCharacteristic c = getDBusBlueZCharacteristicByUUID(characteristic.getUuid().toString());
566-
if (c == null) {
567-
logger.warn("Characteristic '{}' is missing on device '{}'.", characteristic.getUuid(), address);
568-
return CompletableFuture.failedFuture(
569-
new IllegalStateException("Characteristic " + characteristic.getUuid() + " is missing on device"));
570-
}
571-
572-
return RetryFuture.callWithRetry(() -> {
573-
try {
574-
return c.readValue(null);
575-
} catch (DBusException | DBusExecutionException e) {
576-
// DBusExecutionException is thrown if the value cannot be read
577-
logger.debug("Exception occurred when trying to read characteristic '{}': {}", characteristic.getUuid(),
578-
e.getMessage());
579-
throw e;
580-
}
581-
}, scheduler);
573+
return getDBusBlueZCharacteristicByUUID(characteristic.getUuid().toString())
574+
.thenCompose(c -> RetryFuture.callWithRetry(() -> {
575+
try {
576+
return c.readValue(null);
577+
} catch (DBusException | DBusExecutionException e) {
578+
// DBusExecutionException is thrown if the value cannot be read
579+
logger.debug("Exception occurred when trying to read characteristic '{}': {}",
580+
characteristic.getUuid(), e.getMessage());
581+
throw e;
582+
}
583+
}, scheduler));
582584
}
583585

584586
@Override
585587
public boolean isNotifying(BluetoothCharacteristic characteristic) {
586-
BluetoothGattCharacteristic c = getDBusBlueZCharacteristicByUUID(characteristic.getUuid().toString());
587-
if (c != null) {
588-
Boolean isNotifying = c.isNotifying();
589-
return Objects.requireNonNullElse(isNotifying, false);
590-
} else {
591-
logger.warn("Characteristic '{}' is missing on device '{}'.", characteristic.getUuid(), address);
588+
try {
589+
BluetoothGattCharacteristic c = getDBusBlueZCharacteristicByUUID(characteristic.getUuid().toString()).get();
590+
if (c != null) {
591+
Boolean isNotifying = c.isNotifying();
592+
return Objects.requireNonNullElse(isNotifying, false);
593+
} else {
594+
logger.warn("Characteristic '{}' is missing on device '{}'.", characteristic.getUuid(), address);
595+
return false;
596+
}
597+
} catch (InterruptedException | ExecutionException ex) {
598+
logger.warn("Fetchig characteristic '{}' for device '{}'.", characteristic.getUuid(), address, ex);
592599
return false;
593600
}
594601
}
@@ -597,33 +604,32 @@ public boolean isNotifying(BluetoothCharacteristic characteristic) {
597604
public CompletableFuture<@Nullable Void> disableNotifications(BluetoothCharacteristic characteristic) {
598605
BluetoothDevice dev = device;
599606
if (dev == null || Boolean.FALSE.equals(dev.isConnected())) {
600-
return CompletableFuture
601-
.failedFuture(new IllegalStateException("DBusBlueZ device is not set or not connected"));
602-
}
603-
BluetoothGattCharacteristic c = getDBusBlueZCharacteristicByUUID(characteristic.getUuid().toString());
604-
if (c == null) {
605-
logger.warn("Characteristic '{}' is missing on device '{}'.", characteristic.getUuid(), address);
606-
return CompletableFuture.failedFuture(
607-
new IllegalStateException("Characteristic " + characteristic.getUuid() + " is missing on device"));
607+
String message;
608+
if (dev == null) {
609+
message = "DBusBlueZ device is not set";
610+
} else {
611+
message = "DBusBlueZ device is not not connected";
612+
}
613+
return CompletableFuture.failedFuture(new IllegalStateException(message));
608614
}
609-
610-
return RetryFuture.callWithRetry(() -> {
611-
try {
612-
c.stopNotify();
613-
} catch (DBusException e) {
614-
String exceptionMessage = e.getMessage();
615-
if (exceptionMessage != null && exceptionMessage.contains("Already notifying")) {
615+
return getDBusBlueZCharacteristicByUUID(characteristic.getUuid().toString())
616+
.thenCompose(c -> RetryFuture.callWithRetry(() -> {
617+
try {
618+
c.stopNotify();
619+
} catch (DBusExecutionException e) {
620+
String exceptionMessage = e.getMessage();
621+
if (exceptionMessage != null && exceptionMessage.contains("Already notifying")) {
622+
return null;
623+
} else if (exceptionMessage != null && exceptionMessage.contains("In Progress")) {
624+
// let's retry in half a second
625+
throw new RetryException(500, TimeUnit.MILLISECONDS);
626+
} else {
627+
logger.warn("Exception occurred while deactivating notifications on '{}'", address, e);
628+
throw e;
629+
}
630+
}
616631
return null;
617-
} else if (exceptionMessage != null && exceptionMessage.contains("In Progress")) {
618-
// let's retry in half a second
619-
throw new RetryException(500, TimeUnit.MILLISECONDS);
620-
} else {
621-
logger.warn("Exception occurred while deactivating notifications on '{}'", address, e);
622-
throw e;
623-
}
624-
}
625-
return null;
626-
}, scheduler);
632+
}, scheduler));
627633
}
628634

629635
@Override

bundles/org.openhab.binding.bluetooth.bluez/src/main/java/org/openhab/binding/bluetooth/bluez/internal/BlueZBridgeHandler.java

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
import org.eclipse.jdt.annotation.NonNullByDefault;
2828
import org.eclipse.jdt.annotation.Nullable;
2929
import org.freedesktop.dbus.exceptions.DBusExecutionException;
30+
import org.freedesktop.dbus.messages.MethodCall;
3031
import org.freedesktop.dbus.types.Variant;
3132
import org.openhab.binding.bluetooth.AbstractBluetoothBridgeHandler;
3233
import org.openhab.binding.bluetooth.BluetoothAddress;
@@ -61,6 +62,10 @@
6162
public class BlueZBridgeHandler extends AbstractBluetoothBridgeHandler<BlueZBluetoothDevice>
6263
implements BlueZEventListener {
6364

65+
static {
66+
MethodCall.setDefaultTimeout(60_000);
67+
}
68+
6469
private final Logger logger = LoggerFactory.getLogger(BlueZBridgeHandler.class);
6570

6671
// ADAPTER from BlueZ-DBus Library

0 commit comments

Comments
 (0)