]> git.basschouten.com Git - openhab-addons.git/blob
6bd553448f6c0d34348a27bc12ca55e7c7330ff3
[openhab-addons.git] /
1 /**
2  * Copyright (c) 2010-2023 Contributors to the openHAB project
3  *
4  * See the NOTICE file(s) distributed with this work for additional
5  * information.
6  *
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
10  *
11  * SPDX-License-Identifier: EPL-2.0
12  */
13 package org.openhab.binding.bluetooth;
14
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;
25
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;
38
39 /**
40  * This is a base implementation for more specific thing handlers that require constant connection to bluetooth devices.
41  *
42  * @author Kai Kreuzer - Initial contribution and API
43  */
44 @NonNullByDefault
45 public class ConnectedBluetoothHandler extends BeaconBluetoothHandler {
46
47     private final Logger logger = LoggerFactory.getLogger(ConnectedBluetoothHandler.class);
48     private @Nullable Future<?> reconnectJob;
49     private @Nullable Future<?> pendingDisconnect;
50
51     private boolean alwaysConnected;
52     private int idleDisconnectDelay = 1000;
53
54     // we initially set the to scheduler so that we can keep this field non-null
55     private ScheduledExecutorService connectionTaskExecutor = scheduler;
56
57     public ConnectedBluetoothHandler(Thing thing) {
58         super(thing);
59     }
60
61     @Override
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;
70
71         super.initialize();
72
73         if (thing.getStatus() == ThingStatus.OFFLINE) {
74             // something went wrong in super.initialize() so we shouldn't initialize further here either
75             return;
76         }
77
78         Object alwaysConnectRaw = getConfig().get(BluetoothBindingConstants.CONFIGURATION_ALWAYS_CONNECTED);
79         alwaysConnected = !Boolean.FALSE.equals(alwaysConnectRaw);
80
81         Object idleDisconnectDelayRaw = getConfig().get(BluetoothBindingConstants.CONFIGURATION_IDLE_DISCONNECT_DELAY);
82         idleDisconnectDelay = 1000;
83         if (idleDisconnectDelayRaw instanceof Number numberCommand) {
84             idleDisconnectDelay = numberCommand.intValue();
85         }
86
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(() -> {
94                 try {
95                     if (device.getConnectionState() != ConnectionState.CONNECTED) {
96                         if (device.connect()) {
97                             if (!alwaysConnected) {
98                                 cancel(reconnectJob, false);
99                                 reconnectJob = null;
100                             }
101                         } else {
102                             logger.debug("Failed to connect to {}", address);
103                         }
104                         // we do not set the Thing status here, because we will anyhow receive a call to
105                         // onConnectionStateChange
106                     } else {
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");
111                         }
112                     }
113                 } catch (RuntimeException ex) {
114                     logger.warn("Unexpected error occurred", ex);
115                 }
116             }, 0, 30, TimeUnit.SECONDS);
117         }
118     }
119
120     @Override
121     @SuppressWarnings("PMD.CompareObjectsWithEquals")
122     public void dispose() {
123         cancel(reconnectJob, true);
124         reconnectJob = null;
125         cancel(pendingDisconnect, true);
126         pendingDisconnect = null;
127
128         super.dispose();
129
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();
133         }
134     }
135
136     private static void cancel(@Nullable Future<?> future, boolean interrupt) {
137         if (future != null) {
138             future.cancel(interrupt);
139         }
140     }
141
142     public void connect() {
143         connectionTaskExecutor.execute(() -> {
144             if (!device.connect()) {
145                 logger.debug("Failed to connect to {}", address);
146             }
147         });
148     }
149
150     public void disconnect() {
151         connectionTaskExecutor.execute(device::disconnect);
152     }
153
154     private void scheduleDisconnect() {
155         cancel(pendingDisconnect, false);
156         pendingDisconnect = connectionTaskExecutor.schedule(device::disconnect, idleDisconnectDelay,
157                 TimeUnit.MILLISECONDS);
158     }
159
160     private void connectAndWait() throws ConnectionException, TimeoutException, InterruptedException {
161         if (device.getConnectionState() == ConnectionState.CONNECTED) {
162             return;
163         }
164         if (device.getConnectionState() != ConnectionState.CONNECTING) {
165             if (!device.connect()) {
166                 throw new ConnectionException("Failed to start connecting");
167             }
168         }
169         if (!device.awaitConnection(1, TimeUnit.SECONDS)) {
170             throw new TimeoutException("Connection attempt timeout.");
171         }
172         if (!device.isServicesDiscovered()) {
173             device.discoverServices();
174             if (!device.awaitServiceDiscovery(10, TimeUnit.SECONDS)) {
175                 throw new TimeoutException("Service discovery timeout");
176             }
177         }
178     }
179
180     private BluetoothCharacteristic connectAndGetCharacteristic(UUID serviceUUID, UUID characteristicUUID)
181             throws BluetoothException, TimeoutException, InterruptedException {
182         connectAndWait();
183         BluetoothService service = device.getServices(serviceUUID);
184         if (service == null) {
185             throw new BluetoothException("Service with uuid " + serviceUUID + " could not be found");
186         }
187         BluetoothCharacteristic characteristic = service.getCharacteristic(characteristicUUID);
188         if (characteristic == null) {
189             throw new BluetoothException("Characteristic with uuid " + characteristicUUID + " could not be found");
190         }
191         return characteristic;
192     }
193
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"));
200         }
201         if (connectionTaskExecutor.isShutdown()) {
202             return CompletableFuture.failedFuture(new IllegalStateException("connectionTaskExecutor is shut down"));
203         }
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         callable.apply(connectAndGetCharacteristic(serviceUUID, characteristicUUID)).get(), connectionTaskExecutor)
208                 // 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) {
213                         th = th.getCause();
214                     }
215                     if (th instanceof InterruptedException) {
216                         // we don't want to schedule anything if we receive an interrupt
217                         return;
218                     }
219                     if (th instanceof TimeoutException) {
220                         updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, th.getMessage());
221                     }
222                     if (!alwaysConnected) {
223                         scheduleDisconnect();
224                     }
225                 }, scheduler);
226     }
227
228     public CompletableFuture<@Nullable Void> enableNotifications(UUID serviceUUID, UUID characteristicUUID) {
229         return executeWithConnection(serviceUUID, characteristicUUID, device::enableNotifications);
230     }
231
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));
238             } else {
239                 return device.writeCharacteristic(characteristic, data);
240             }
241         });
242         if (logger.isDebugEnabled()) {
243             future = future.whenComplete((v, t) -> {
244                 if (t == null) {
245                     logger.debug("Characteristic {} from {} has written value {}", characteristicUUID, address,
246                             HexUtils.bytesToHex(data));
247                 }
248             });
249         }
250         return future;
251     }
252
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) -> {
257                 if (t == null) {
258                     if (logger.isDebugEnabled()) {
259                         logger.debug("Characteristic {} from {} has been read - value {}", characteristicUUID, address,
260                                 HexUtils.bytesToHex(data));
261                     }
262                 }
263             });
264         }
265         return future;
266     }
267
268     @Override
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
271         // we are connected.
272         if (receivedSignal) {
273             if (alwaysConnected) {
274                 if (device.getConnectionState() == ConnectionState.CONNECTED) {
275                     updateStatus(ThingStatus.ONLINE);
276                 } else {
277                     updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.NONE, "Device is not connected.");
278                 }
279             }
280         } else {
281             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR);
282         }
283     }
284
285     @Override
286     public void onConnectionStateChange(BluetoothConnectionStatusNotification connectionNotification) {
287         super.onConnectionStateChange(connectionNotification);
288         switch (connectionNotification.getConnectionState()) {
289             case DISCOVERED:
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.");
296                             }
297                         }
298                     });
299                 }
300                 break;
301             case CONNECTED:
302                 if (alwaysConnected) {
303                     connectionTaskExecutor.submit(() -> {
304                         if (!device.isServicesDiscovered() && !device.discoverServices()) {
305                             logger.debug("Error while discovering services");
306                         }
307                     });
308                 }
309                 break;
310             case DISCONNECTED:
311                 cancel(pendingDisconnect, false);
312                 if (alwaysConnected) {
313                     updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR);
314                 }
315                 break;
316             default:
317                 break;
318         }
319     }
320
321     @Override
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);
327         }
328     }
329
330     @Override
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);
336         }
337     }
338
339     @Override
340     public void onServicesDiscovered() {
341         super.onServicesDiscovered();
342
343         if (!alwaysConnected && device.getConnectionState() == ConnectionState.CONNECTED) {
344             // disconnect when the device was only connected to discover the Services.
345             disconnect();
346         }
347     }
348 }