]> git.basschouten.com Git - openhab-addons.git/blob
36bbf6fc0f27f0cff32e4d66a3515280a0e25dc0
[openhab-addons.git] /
1 /**
2  * Copyright (c) 2010-2022 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
64         // super.initialize adds callbacks that might require the connectionTaskExecutor to be present, so we initialize
65         // the connectionTaskExecutor first
66         ScheduledThreadPoolExecutor executor = new ScheduledThreadPoolExecutor(1,
67                 new NamedThreadFactory("bluetooth-connection" + thing.getThingTypeUID(), true));
68         executor.setExecuteExistingDelayedTasksAfterShutdownPolicy(false);
69         executor.setRemoveOnCancelPolicy(true);
70         connectionTaskExecutor = executor;
71
72         super.initialize();
73
74         if (thing.getStatus() == ThingStatus.OFFLINE) {
75             // something went wrong in super.initialize() so we shouldn't initialize further here either
76             return;
77         }
78
79         Object alwaysConnectRaw = getConfig().get(BluetoothBindingConstants.CONFIGURATION_ALWAYS_CONNECTED);
80         alwaysConnected = !Boolean.FALSE.equals(alwaysConnectRaw);
81
82         Object idleDisconnectDelayRaw = getConfig().get(BluetoothBindingConstants.CONFIGURATION_IDLE_DISCONNECT_DELAY);
83         idleDisconnectDelay = 1000;
84         if (idleDisconnectDelayRaw instanceof Number) {
85             idleDisconnectDelay = ((Number) idleDisconnectDelayRaw).intValue();
86         }
87
88         // Start the recurrent job if the device is always connected
89         // or if the Services where not yet discovered.
90         // If the device is not always connected, the job will be terminated
91         // after successful connection and the device disconnected after Service
92         // discovery in `onServicesDiscovered()`.
93         if (alwaysConnected || !device.isServicesDiscovered()) {
94             reconnectJob = connectionTaskExecutor.scheduleWithFixedDelay(() -> {
95                 try {
96                     if (device.getConnectionState() != ConnectionState.CONNECTED) {
97                         if (device.connect()) {
98                             if (!alwaysConnected) {
99                                 cancel(reconnectJob, false);
100                                 reconnectJob = null;
101                             }
102                         } else {
103                             logger.debug("Failed to connect to {}", address);
104                         }
105                         // we do not set the Thing status here, because we will anyhow receive a call to
106                         // onConnectionStateChange
107                     } else {
108                         // just in case it was already connected to begin with
109                         updateStatus(ThingStatus.ONLINE);
110                         if (!device.isServicesDiscovered() && !device.discoverServices()) {
111                             logger.debug("Error while discovering services");
112                         }
113                     }
114                 } catch (RuntimeException ex) {
115                     logger.warn("Unexpected error occurred", ex);
116                 }
117             }, 0, 30, TimeUnit.SECONDS);
118         }
119     }
120
121     @Override
122     @SuppressWarnings("PMD.CompareObjectsWithEquals")
123     public void dispose() {
124         cancel(reconnectJob, true);
125         reconnectJob = null;
126         cancel(pendingDisconnect, true);
127         pendingDisconnect = null;
128
129         super.dispose();
130
131         // just in case something goes really wrong in the core and it tries to dispose a handler before initializing it
132         if (scheduler != connectionTaskExecutor) {
133             connectionTaskExecutor.shutdownNow();
134         }
135     }
136
137     private static void cancel(@Nullable Future<?> future, boolean interrupt) {
138         if (future != null) {
139             future.cancel(interrupt);
140         }
141     }
142
143     public void connect() {
144         connectionTaskExecutor.execute(() -> {
145             if (!device.connect()) {
146                 logger.debug("Failed to connect to {}", address);
147             }
148         });
149     }
150
151     public void disconnect() {
152         connectionTaskExecutor.execute(device::disconnect);
153     }
154
155     private void scheduleDisconnect() {
156         cancel(pendingDisconnect, false);
157         pendingDisconnect = connectionTaskExecutor.schedule(device::disconnect, idleDisconnectDelay,
158                 TimeUnit.MILLISECONDS);
159     }
160
161     private void connectAndWait() throws ConnectionException, TimeoutException, InterruptedException {
162         if (device.getConnectionState() == ConnectionState.CONNECTED) {
163             return;
164         }
165         if (device.getConnectionState() != ConnectionState.CONNECTING) {
166             if (!device.connect()) {
167                 throw new ConnectionException("Failed to start connecting");
168             }
169         }
170         if (!device.awaitConnection(1, TimeUnit.SECONDS)) {
171             throw new TimeoutException("Connection attempt timeout.");
172         }
173         if (!device.isServicesDiscovered()) {
174             device.discoverServices();
175             if (!device.awaitServiceDiscovery(10, TimeUnit.SECONDS)) {
176                 throw new TimeoutException("Service discovery timeout");
177             }
178         }
179     }
180
181     private BluetoothCharacteristic connectAndGetCharacteristic(UUID serviceUUID, UUID characteristicUUID)
182             throws BluetoothException, TimeoutException, InterruptedException {
183         connectAndWait();
184         BluetoothService service = device.getServices(serviceUUID);
185         if (service == null) {
186             throw new BluetoothException("Service with uuid " + serviceUUID + " could not be found");
187         }
188         BluetoothCharacteristic characteristic = service.getCharacteristic(characteristicUUID);
189         if (characteristic == null) {
190             throw new BluetoothException("Characteristic with uuid " + characteristicUUID + " could not be found");
191         }
192         return characteristic;
193     }
194
195     @SuppressWarnings("PMD.CompareObjectsWithEquals")
196     private <T> CompletableFuture<T> executeWithConnection(UUID serviceUUID, UUID characteristicUUID,
197             Function<BluetoothCharacteristic, CompletableFuture<T>> callable) {
198         if (connectionTaskExecutor == scheduler) {
199             return CompletableFuture
200                     .failedFuture(new IllegalStateException("connectionTaskExecutor has not been initialized"));
201         }
202         if (connectionTaskExecutor.isShutdown()) {
203             return CompletableFuture.failedFuture(new IllegalStateException("connectionTaskExecutor is shut down"));
204         }
205         // we use a RetryFuture because it supports running Callable instances
206         return RetryFuture.callWithRetry(() -> {
207             // we block for completion here so that we keep the lock on the connectionTaskExecutor active.
208             return callable.apply(connectAndGetCharacteristic(serviceUUID, characteristicUUID)).get();
209         }, connectionTaskExecutor)// we make this completion async so that operations chained off the returned future
210                                   // will not run on the connectionTaskExecutor
211                 .whenCompleteAsync((r, th) -> {
212                     // we us a while loop here in case the exceptions get nested
213                     while (th instanceof CompletionException || th instanceof ExecutionException) {
214                         th = th.getCause();
215                     }
216                     if (th instanceof InterruptedException) {
217                         // we don't want to schedule anything if we receive an interrupt
218                         return;
219                     }
220                     if (th instanceof TimeoutException) {
221                         updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, th.getMessage());
222                     }
223                     if (!alwaysConnected) {
224                         scheduleDisconnect();
225                     }
226                 }, scheduler);
227     }
228
229     public CompletableFuture<@Nullable Void> enableNotifications(UUID serviceUUID, UUID characteristicUUID) {
230         return executeWithConnection(serviceUUID, characteristicUUID, device::enableNotifications);
231     }
232
233     public CompletableFuture<@Nullable Void> writeCharacteristic(UUID serviceUUID, UUID characteristicUUID, byte[] data,
234             boolean enableNotification) {
235         var future = executeWithConnection(serviceUUID, characteristicUUID, characteristic -> {
236             if (enableNotification) {
237                 return device.enableNotifications(characteristic)
238                         .thenCompose((v) -> device.writeCharacteristic(characteristic, data));
239             } else {
240                 return device.writeCharacteristic(characteristic, data);
241             }
242         });
243         if (logger.isDebugEnabled()) {
244             future = future.whenComplete((v, t) -> {
245                 if (t == null) {
246                     logger.debug("Characteristic {} from {} has written value {}", characteristicUUID, address,
247                             HexUtils.bytesToHex(data));
248                 }
249             });
250         }
251         return future;
252     }
253
254     public CompletableFuture<byte[]> readCharacteristic(UUID serviceUUID, UUID characteristicUUID) {
255         var future = executeWithConnection(serviceUUID, characteristicUUID, device::readCharacteristic);
256         if (logger.isDebugEnabled()) {
257             future = future.whenComplete((data, t) -> {
258                 if (t == null) {
259                     if (logger.isDebugEnabled()) {
260                         logger.debug("Characteristic {} from {} has been read - value {}", characteristicUUID, address,
261                                 HexUtils.bytesToHex(data));
262                     }
263                 }
264             });
265         }
266         return future;
267     }
268
269     @Override
270     protected void updateStatusBasedOnRssi(boolean receivedSignal) {
271         // 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         // we are connected.
273         if (receivedSignal) {
274             if (alwaysConnected) {
275                 if (device.getConnectionState() == ConnectionState.CONNECTED) {
276                     updateStatus(ThingStatus.ONLINE);
277                 } else {
278                     updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.NONE, "Device is not connected.");
279                 }
280             }
281         } else {
282             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR);
283         }
284     }
285
286     @Override
287     public void onConnectionStateChange(BluetoothConnectionStatusNotification connectionNotification) {
288         super.onConnectionStateChange(connectionNotification);
289         switch (connectionNotification.getConnectionState()) {
290             case DISCOVERED:
291                 // The device is now known on the Bluetooth network, so we can do something...
292                 if (alwaysConnected) {
293                     connectionTaskExecutor.submit(() -> {
294                         if (device.getConnectionState() != ConnectionState.CONNECTED) {
295                             if (!device.connect()) {
296                                 logger.debug("Error connecting to device after discovery.");
297                             }
298                         }
299                     });
300                 }
301                 break;
302             case CONNECTED:
303                 if (alwaysConnected) {
304                     connectionTaskExecutor.submit(() -> {
305                         if (!device.isServicesDiscovered() && !device.discoverServices()) {
306                             logger.debug("Error while discovering services");
307                         }
308                     });
309                 }
310                 break;
311             case DISCONNECTED:
312                 cancel(pendingDisconnect, false);
313                 if (alwaysConnected) {
314                     updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR);
315                 }
316                 break;
317             default:
318                 break;
319         }
320     }
321
322     @Override
323     public void onCharacteristicUpdate(BluetoothCharacteristic characteristic, byte[] value) {
324         super.onCharacteristicUpdate(characteristic, value);
325         if (logger.isDebugEnabled()) {
326             logger.debug("Recieved update {} to characteristic {} of device {}", HexUtils.bytesToHex(value),
327                     characteristic.getUuid(), address);
328         }
329     }
330
331     @Override
332     public void onDescriptorUpdate(BluetoothDescriptor descriptor, byte[] value) {
333         super.onDescriptorUpdate(descriptor, value);
334         if (logger.isDebugEnabled()) {
335             logger.debug("Received update {} to descriptor {} of device {}", HexUtils.bytesToHex(value),
336                     descriptor.getUuid(), address);
337         }
338     }
339
340     @Override
341     public void onServicesDiscovered() {
342         super.onServicesDiscovered();
343
344         if (!alwaysConnected && device.getConnectionState() == ConnectionState.CONNECTED) {
345             // disconnect when the device was only connected to discover the Services.
346             disconnect();
347         }
348     }
349 }