2 * Copyright (c) 2010-2023 Contributors to the openHAB project
4 * See the NOTICE file(s) distributed with this work for additional
7 * This program and the accompanying materials are made available under the
8 * terms of the Eclipse Public License 2.0 which is available at
9 * http://www.eclipse.org/legal/epl-2.0
11 * SPDX-License-Identifier: EPL-2.0
13 package org.openhab.binding.bluetooth;
15 import java.util.UUID;
16 import java.util.concurrent.CompletableFuture;
17 import java.util.concurrent.CompletionException;
18 import java.util.concurrent.ExecutionException;
19 import java.util.concurrent.Future;
20 import java.util.concurrent.ScheduledExecutorService;
21 import java.util.concurrent.ScheduledThreadPoolExecutor;
22 import java.util.concurrent.TimeUnit;
23 import java.util.concurrent.TimeoutException;
24 import java.util.function.Function;
26 import org.eclipse.jdt.annotation.NonNullByDefault;
27 import org.eclipse.jdt.annotation.Nullable;
28 import org.openhab.binding.bluetooth.BluetoothDevice.ConnectionState;
29 import org.openhab.binding.bluetooth.notification.BluetoothConnectionStatusNotification;
30 import org.openhab.binding.bluetooth.util.RetryFuture;
31 import org.openhab.core.common.NamedThreadFactory;
32 import org.openhab.core.thing.Thing;
33 import org.openhab.core.thing.ThingStatus;
34 import org.openhab.core.thing.ThingStatusDetail;
35 import org.openhab.core.util.HexUtils;
36 import org.slf4j.Logger;
37 import org.slf4j.LoggerFactory;
40 * This is a base implementation for more specific thing handlers that require constant connection to bluetooth devices.
42 * @author Kai Kreuzer - Initial contribution and API
45 public class ConnectedBluetoothHandler extends BeaconBluetoothHandler {
47 private final Logger logger = LoggerFactory.getLogger(ConnectedBluetoothHandler.class);
48 private @Nullable Future<?> reconnectJob;
49 private @Nullable Future<?> pendingDisconnect;
51 private boolean alwaysConnected;
52 private int idleDisconnectDelay = 1000;
54 // we initially set the to scheduler so that we can keep this field non-null
55 private ScheduledExecutorService connectionTaskExecutor = scheduler;
57 public ConnectedBluetoothHandler(Thing thing) {
62 public void initialize() {
63 // super.initialize adds callbacks that might require the connectionTaskExecutor to be present, so we initialize
64 // the connectionTaskExecutor first
65 ScheduledThreadPoolExecutor executor = new ScheduledThreadPoolExecutor(1,
66 new NamedThreadFactory("bluetooth-connection" + thing.getThingTypeUID(), true));
67 executor.setExecuteExistingDelayedTasksAfterShutdownPolicy(false);
68 executor.setRemoveOnCancelPolicy(true);
69 connectionTaskExecutor = executor;
73 if (thing.getStatus() == ThingStatus.OFFLINE) {
74 // something went wrong in super.initialize() so we shouldn't initialize further here either
78 Object alwaysConnectRaw = getConfig().get(BluetoothBindingConstants.CONFIGURATION_ALWAYS_CONNECTED);
79 alwaysConnected = !Boolean.FALSE.equals(alwaysConnectRaw);
81 Object idleDisconnectDelayRaw = getConfig().get(BluetoothBindingConstants.CONFIGURATION_IDLE_DISCONNECT_DELAY);
82 idleDisconnectDelay = 1000;
83 if (idleDisconnectDelayRaw instanceof Number) {
84 idleDisconnectDelay = ((Number) idleDisconnectDelayRaw).intValue();
87 // Start the recurrent job if the device is always connected
88 // or if the Services where not yet discovered.
89 // If the device is not always connected, the job will be terminated
90 // after successful connection and the device disconnected after Service
91 // discovery in `onServicesDiscovered()`.
92 if (alwaysConnected || !device.isServicesDiscovered()) {
93 reconnectJob = connectionTaskExecutor.scheduleWithFixedDelay(() -> {
95 if (device.getConnectionState() != ConnectionState.CONNECTED) {
96 if (device.connect()) {
97 if (!alwaysConnected) {
98 cancel(reconnectJob, false);
102 logger.debug("Failed to connect to {}", address);
104 // we do not set the Thing status here, because we will anyhow receive a call to
105 // onConnectionStateChange
107 // just in case it was already connected to begin with
108 updateStatus(ThingStatus.ONLINE);
109 if (!device.isServicesDiscovered() && !device.discoverServices()) {
110 logger.debug("Error while discovering services");
113 } catch (RuntimeException ex) {
114 logger.warn("Unexpected error occurred", ex);
116 }, 0, 30, TimeUnit.SECONDS);
121 @SuppressWarnings("PMD.CompareObjectsWithEquals")
122 public void dispose() {
123 cancel(reconnectJob, true);
125 cancel(pendingDisconnect, true);
126 pendingDisconnect = null;
130 // just in case something goes really wrong in the core and it tries to dispose a handler before initializing it
131 if (scheduler != connectionTaskExecutor) {
132 connectionTaskExecutor.shutdownNow();
136 private static void cancel(@Nullable Future<?> future, boolean interrupt) {
137 if (future != null) {
138 future.cancel(interrupt);
142 public void connect() {
143 connectionTaskExecutor.execute(() -> {
144 if (!device.connect()) {
145 logger.debug("Failed to connect to {}", address);
150 public void disconnect() {
151 connectionTaskExecutor.execute(device::disconnect);
154 private void scheduleDisconnect() {
155 cancel(pendingDisconnect, false);
156 pendingDisconnect = connectionTaskExecutor.schedule(device::disconnect, idleDisconnectDelay,
157 TimeUnit.MILLISECONDS);
160 private void connectAndWait() throws ConnectionException, TimeoutException, InterruptedException {
161 if (device.getConnectionState() == ConnectionState.CONNECTED) {
164 if (device.getConnectionState() != ConnectionState.CONNECTING) {
165 if (!device.connect()) {
166 throw new ConnectionException("Failed to start connecting");
169 if (!device.awaitConnection(1, TimeUnit.SECONDS)) {
170 throw new TimeoutException("Connection attempt timeout.");
172 if (!device.isServicesDiscovered()) {
173 device.discoverServices();
174 if (!device.awaitServiceDiscovery(10, TimeUnit.SECONDS)) {
175 throw new TimeoutException("Service discovery timeout");
180 private BluetoothCharacteristic connectAndGetCharacteristic(UUID serviceUUID, UUID characteristicUUID)
181 throws BluetoothException, TimeoutException, InterruptedException {
183 BluetoothService service = device.getServices(serviceUUID);
184 if (service == null) {
185 throw new BluetoothException("Service with uuid " + serviceUUID + " could not be found");
187 BluetoothCharacteristic characteristic = service.getCharacteristic(characteristicUUID);
188 if (characteristic == null) {
189 throw new BluetoothException("Characteristic with uuid " + characteristicUUID + " could not be found");
191 return characteristic;
194 @SuppressWarnings("PMD.CompareObjectsWithEquals")
195 private <T> CompletableFuture<T> executeWithConnection(UUID serviceUUID, UUID characteristicUUID,
196 Function<BluetoothCharacteristic, CompletableFuture<T>> callable) {
197 if (connectionTaskExecutor == scheduler) {
198 return CompletableFuture
199 .failedFuture(new IllegalStateException("connectionTaskExecutor has not been initialized"));
201 if (connectionTaskExecutor.isShutdown()) {
202 return CompletableFuture.failedFuture(new IllegalStateException("connectionTaskExecutor is shut down"));
204 // we use a RetryFuture because it supports running Callable instances
205 return RetryFuture.callWithRetry(() -> {
206 // we block for completion here so that we keep the lock on the connectionTaskExecutor active.
207 return callable.apply(connectAndGetCharacteristic(serviceUUID, characteristicUUID)).get();
208 }, connectionTaskExecutor)// we make this completion async so that operations chained off the returned future
209 // will not run on the connectionTaskExecutor
210 .whenCompleteAsync((r, th) -> {
211 // we us a while loop here in case the exceptions get nested
212 while (th instanceof CompletionException || th instanceof ExecutionException) {
215 if (th instanceof InterruptedException) {
216 // we don't want to schedule anything if we receive an interrupt
219 if (th instanceof TimeoutException) {
220 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, th.getMessage());
222 if (!alwaysConnected) {
223 scheduleDisconnect();
228 public CompletableFuture<@Nullable Void> enableNotifications(UUID serviceUUID, UUID characteristicUUID) {
229 return executeWithConnection(serviceUUID, characteristicUUID, device::enableNotifications);
232 public CompletableFuture<@Nullable Void> writeCharacteristic(UUID serviceUUID, UUID characteristicUUID, byte[] data,
233 boolean enableNotification) {
234 var future = executeWithConnection(serviceUUID, characteristicUUID, characteristic -> {
235 if (enableNotification) {
236 return device.enableNotifications(characteristic)
237 .thenCompose((v) -> device.writeCharacteristic(characteristic, data));
239 return device.writeCharacteristic(characteristic, data);
242 if (logger.isDebugEnabled()) {
243 future = future.whenComplete((v, t) -> {
245 logger.debug("Characteristic {} from {} has written value {}", characteristicUUID, address,
246 HexUtils.bytesToHex(data));
253 public CompletableFuture<byte[]> readCharacteristic(UUID serviceUUID, UUID characteristicUUID) {
254 var future = executeWithConnection(serviceUUID, characteristicUUID, device::readCharacteristic);
255 if (logger.isDebugEnabled()) {
256 future = future.whenComplete((data, t) -> {
258 if (logger.isDebugEnabled()) {
259 logger.debug("Characteristic {} from {} has been read - value {}", characteristicUUID, address,
260 HexUtils.bytesToHex(data));
269 protected void updateStatusBasedOnRssi(boolean receivedSignal) {
270 // if there is no signal, we can be sure we are OFFLINE, but if there is a signal, we also have to check whether
272 if (receivedSignal) {
273 if (alwaysConnected) {
274 if (device.getConnectionState() == ConnectionState.CONNECTED) {
275 updateStatus(ThingStatus.ONLINE);
277 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.NONE, "Device is not connected.");
281 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR);
286 public void onConnectionStateChange(BluetoothConnectionStatusNotification connectionNotification) {
287 super.onConnectionStateChange(connectionNotification);
288 switch (connectionNotification.getConnectionState()) {
290 // The device is now known on the Bluetooth network, so we can do something...
291 if (alwaysConnected) {
292 connectionTaskExecutor.submit(() -> {
293 if (device.getConnectionState() != ConnectionState.CONNECTED) {
294 if (!device.connect()) {
295 logger.debug("Error connecting to device after discovery.");
302 if (alwaysConnected) {
303 connectionTaskExecutor.submit(() -> {
304 if (!device.isServicesDiscovered() && !device.discoverServices()) {
305 logger.debug("Error while discovering services");
311 cancel(pendingDisconnect, false);
312 if (alwaysConnected) {
313 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR);
322 public void onCharacteristicUpdate(BluetoothCharacteristic characteristic, byte[] value) {
323 super.onCharacteristicUpdate(characteristic, value);
324 if (logger.isDebugEnabled()) {
325 logger.debug("Recieved update {} to characteristic {} of device {}", HexUtils.bytesToHex(value),
326 characteristic.getUuid(), address);
331 public void onDescriptorUpdate(BluetoothDescriptor descriptor, byte[] value) {
332 super.onDescriptorUpdate(descriptor, value);
333 if (logger.isDebugEnabled()) {
334 logger.debug("Received update {} to descriptor {} of device {}", HexUtils.bytesToHex(value),
335 descriptor.getUuid(), address);
340 public void onServicesDiscovered() {
341 super.onServicesDiscovered();
343 if (!alwaysConnected && device.getConnectionState() == ConnectionState.CONNECTED) {
344 // disconnect when the device was only connected to discover the Services.