]> git.basschouten.com Git - openhab-addons.git/blob
137aafbae250ae24ffabb6378f722a82a28fda97
[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.bluez.internal;
14
15 import java.util.Map;
16 import java.util.Objects;
17 import java.util.UUID;
18 import java.util.concurrent.CompletableFuture;
19 import java.util.concurrent.ScheduledExecutorService;
20 import java.util.concurrent.TimeUnit;
21
22 import org.eclipse.jdt.annotation.NonNullByDefault;
23 import org.eclipse.jdt.annotation.Nullable;
24 import org.freedesktop.dbus.errors.NoReply;
25 import org.freedesktop.dbus.exceptions.DBusException;
26 import org.freedesktop.dbus.exceptions.DBusExecutionException;
27 import org.freedesktop.dbus.types.UInt16;
28 import org.openhab.binding.bluetooth.BaseBluetoothDevice;
29 import org.openhab.binding.bluetooth.BluetoothAddress;
30 import org.openhab.binding.bluetooth.BluetoothCharacteristic;
31 import org.openhab.binding.bluetooth.BluetoothDescriptor;
32 import org.openhab.binding.bluetooth.BluetoothService;
33 import org.openhab.binding.bluetooth.bluez.internal.events.BlueZEvent;
34 import org.openhab.binding.bluetooth.bluez.internal.events.BlueZEventListener;
35 import org.openhab.binding.bluetooth.bluez.internal.events.CharacteristicUpdateEvent;
36 import org.openhab.binding.bluetooth.bluez.internal.events.ConnectedEvent;
37 import org.openhab.binding.bluetooth.bluez.internal.events.ManufacturerDataEvent;
38 import org.openhab.binding.bluetooth.bluez.internal.events.NameEvent;
39 import org.openhab.binding.bluetooth.bluez.internal.events.RssiEvent;
40 import org.openhab.binding.bluetooth.bluez.internal.events.ServicesResolvedEvent;
41 import org.openhab.binding.bluetooth.bluez.internal.events.TXPowerEvent;
42 import org.openhab.binding.bluetooth.notification.BluetoothConnectionStatusNotification;
43 import org.openhab.binding.bluetooth.notification.BluetoothScanNotification;
44 import org.openhab.binding.bluetooth.util.RetryException;
45 import org.openhab.binding.bluetooth.util.RetryFuture;
46 import org.openhab.core.common.ThreadPoolManager;
47 import org.openhab.core.util.HexUtils;
48 import org.slf4j.Logger;
49 import org.slf4j.LoggerFactory;
50
51 import com.github.hypfvieh.bluetooth.wrapper.BluetoothDevice;
52 import com.github.hypfvieh.bluetooth.wrapper.BluetoothGattCharacteristic;
53 import com.github.hypfvieh.bluetooth.wrapper.BluetoothGattDescriptor;
54 import com.github.hypfvieh.bluetooth.wrapper.BluetoothGattService;
55
56 /**
57  * Implementation of BluetoothDevice for BlueZ via DBus-BlueZ API
58  *
59  * @author Kai Kreuzer - Initial contribution and API
60  * @author Benjamin Lafois - Replaced tinyB with bluezDbus
61  * @author Peter Rosenberg - Improve notifications and properties support
62  *
63  */
64 @NonNullByDefault
65 public class BlueZBluetoothDevice extends BaseBluetoothDevice implements BlueZEventListener {
66
67     private final Logger logger = LoggerFactory.getLogger(BlueZBluetoothDevice.class);
68
69     private final ScheduledExecutorService scheduler = ThreadPoolManager.getScheduledPool("bluetooth");
70
71     // Device from native lib
72     private @Nullable BluetoothDevice device = null;
73
74     /**
75      * Constructor
76      *
77      * @param adapter the bridge handler through which this device is connected
78      * @param address the Bluetooth address of the device
79      * @param name the name of the device
80      */
81     public BlueZBluetoothDevice(BlueZBridgeHandler adapter, BluetoothAddress address) {
82         super(adapter, address);
83         logger.debug("Creating DBusBlueZ device with address '{}'", address);
84     }
85
86     @SuppressWarnings("PMD.CompareObjectsWithEquals")
87     public synchronized void updateBlueZDevice(@Nullable BluetoothDevice blueZDevice) {
88         if (this.device != null && this.device == blueZDevice) {
89             return;
90         }
91         logger.debug("updateBlueZDevice({})", blueZDevice);
92
93         this.device = blueZDevice;
94
95         if (blueZDevice == null) {
96             return;
97         }
98
99         Short rssi = blueZDevice.getRssi();
100         if (rssi != null) {
101             this.rssi = rssi.intValue();
102         }
103         this.name = blueZDevice.getName();
104         Map<UInt16, byte[]> manData = blueZDevice.getManufacturerData();
105         if (manData != null) {
106             manData.entrySet().stream().map(Map.Entry::getKey).filter(Objects::nonNull).findFirst()
107                     .ifPresent((UInt16 manufacturerId) ->
108                     // Convert to unsigned int to match the convention in BluetoothCompanyIdentifiers
109                     this.manufacturer = manufacturerId.intValue() & 0xFFFF);
110         }
111
112         if (Boolean.TRUE.equals(blueZDevice.isConnected())) {
113             setConnectionState(ConnectionState.CONNECTED);
114         }
115
116         discoverServices();
117     }
118
119     /**
120      * Clean up and release memory.
121      */
122     @Override
123     public void dispose() {
124         BluetoothDevice dev = device;
125         if (dev != null) {
126             if (Boolean.TRUE.equals(dev.isPaired())) {
127                 return;
128             }
129
130             try {
131                 dev.getAdapter().removeDevice(dev.getRawDevice());
132             } catch (DBusException ex) {
133                 if (ex.getMessage().contains("Does Not Exist")) {
134                     // this happens when the underlying device has already been removed
135                     // but we don't have a way to check if that is the case beforehand so
136                     // we will just eat the error here.
137                 } else {
138                     logger.debug("Exception occurred when trying to remove inactive device '{}': {}", address,
139                             ex.getMessage());
140                 }
141             } catch (RuntimeException ex) {
142                 // try to catch any other exceptions
143                 logger.debug("Exception occurred when trying to remove inactive device '{}': {}", address,
144                         ex.getMessage());
145             }
146         }
147     }
148
149     private void setConnectionState(ConnectionState state) {
150         if (this.connectionState != state) {
151             this.connectionState = state;
152             notifyListeners(BluetoothEventType.CONNECTION_STATE, new BluetoothConnectionStatusNotification(state));
153         }
154     }
155
156     @Override
157     public boolean connect() {
158         logger.debug("Connect({})", device);
159
160         BluetoothDevice dev = device;
161         if (dev != null) {
162             if (Boolean.FALSE.equals(dev.isConnected())) {
163                 try {
164                     boolean ret = dev.connect();
165                     logger.debug("Connect result: {}", ret);
166                     return ret;
167                 } catch (NoReply e) {
168                     // Have to double check because sometimes, exception but still worked
169                     logger.debug("Got a timeout - but sometimes happen. Is Connected ? {}", dev.isConnected());
170                     if (Boolean.FALSE.equals(dev.isConnected())) {
171
172                         notifyListeners(BluetoothEventType.CONNECTION_STATE,
173                                 new BluetoothConnectionStatusNotification(ConnectionState.DISCONNECTED));
174                         return false;
175                     } else {
176                         return true;
177                     }
178                 } catch (DBusExecutionException e) {
179                     // Catch "software caused connection abort"
180                     return false;
181                 } catch (Exception e) {
182                     logger.warn("error occured while trying to connect", e);
183                 }
184
185             } else {
186                 logger.debug("Device was already connected");
187                 // we might be stuck in another state atm so we need to trigger a connected in this case
188                 setConnectionState(ConnectionState.CONNECTED);
189                 return true;
190             }
191         }
192         return false;
193     }
194
195     @Override
196     public boolean disconnect() {
197         BluetoothDevice dev = device;
198         if (dev != null) {
199             logger.debug("Disconnecting '{}'", address);
200             return dev.disconnect();
201         }
202         return false;
203     }
204
205     private void ensureConnected() {
206         BluetoothDevice dev = device;
207         if (dev == null || !dev.isConnected()) {
208             throw new IllegalStateException("DBusBlueZ device is not set or not connected");
209         }
210     }
211
212     private @Nullable BluetoothGattCharacteristic getDBusBlueZCharacteristicByUUID(String uuid) {
213         BluetoothDevice dev = device;
214         if (dev == null) {
215             return null;
216         }
217         for (BluetoothGattService service : dev.getGattServices()) {
218             for (BluetoothGattCharacteristic c : service.getGattCharacteristics()) {
219                 if (c.getUuid().equalsIgnoreCase(uuid)) {
220                     return c;
221                 }
222             }
223         }
224         return null;
225     }
226
227     private @Nullable BluetoothGattCharacteristic getDBusBlueZCharacteristicByDBusPath(String dBusPath) {
228         BluetoothDevice dev = device;
229         if (dev == null) {
230             return null;
231         }
232         for (BluetoothGattService service : dev.getGattServices()) {
233             if (dBusPath.startsWith(service.getDbusPath())) {
234                 for (BluetoothGattCharacteristic characteristic : service.getGattCharacteristics()) {
235                     if (dBusPath.startsWith(characteristic.getDbusPath())) {
236                         return characteristic;
237                     }
238                 }
239             }
240         }
241         return null;
242     }
243
244     private @Nullable BluetoothGattDescriptor getDBusBlueZDescriptorByUUID(String uuid) {
245         BluetoothDevice dev = device;
246         if (dev == null) {
247             return null;
248         }
249         for (BluetoothGattService service : dev.getGattServices()) {
250             for (BluetoothGattCharacteristic c : service.getGattCharacteristics()) {
251                 for (BluetoothGattDescriptor d : c.getGattDescriptors()) {
252                     if (d.getUuid().equalsIgnoreCase(uuid)) {
253                         return d;
254                     }
255                 }
256             }
257         }
258         return null;
259     }
260
261     @Override
262     public CompletableFuture<@Nullable Void> enableNotifications(BluetoothCharacteristic characteristic) {
263         BluetoothDevice dev = device;
264         if (dev == null || !dev.isConnected()) {
265             return CompletableFuture
266                     .failedFuture(new IllegalStateException("DBusBlueZ device is not set or not connected"));
267         }
268
269         BluetoothGattCharacteristic c = getDBusBlueZCharacteristicByUUID(characteristic.getUuid().toString());
270         if (c == null) {
271             logger.warn("Characteristic '{}' is missing on device '{}'.", characteristic.getUuid(), address);
272             return CompletableFuture.failedFuture(
273                     new IllegalStateException("Characteristic " + characteristic.getUuid() + " is missing on device"));
274         }
275
276         return RetryFuture.callWithRetry(() -> {
277             try {
278                 c.startNotify();
279             } catch (DBusException e) {
280                 if (e.getMessage().contains("Already notifying")) {
281                     return null;
282                 } else if (e.getMessage().contains("In Progress")) {
283                     // let's retry in half a second
284                     throw new RetryException(500, TimeUnit.MILLISECONDS);
285                 } else {
286                     logger.warn("Exception occurred while activating notifications on '{}'", address, e);
287                     throw e;
288                 }
289             }
290             return null;
291         }, scheduler);
292     }
293
294     @Override
295     public CompletableFuture<@Nullable Void> writeCharacteristic(BluetoothCharacteristic characteristic, byte[] value) {
296         logger.debug("writeCharacteristic()");
297
298         BluetoothDevice dev = device;
299         if (dev == null || !dev.isConnected()) {
300             return CompletableFuture
301                     .failedFuture(new IllegalStateException("DBusBlueZ device is not set or not connected"));
302         }
303
304         BluetoothGattCharacteristic c = getDBusBlueZCharacteristicByUUID(characteristic.getUuid().toString());
305         if (c == null) {
306             logger.warn("Characteristic '{}' is missing on device '{}'.", characteristic.getUuid(), address);
307             return CompletableFuture.failedFuture(
308                     new IllegalStateException("Characteristic " + characteristic.getUuid() + " is missing on device"));
309         }
310
311         return RetryFuture.callWithRetry(() -> {
312             try {
313                 c.writeValue(value, null);
314                 return null;
315             } catch (DBusException e) {
316                 logger.debug("Exception occurred when trying to write characteristic '{}': {}",
317                         characteristic.getUuid(), e.getMessage());
318                 throw e;
319             }
320         }, scheduler);
321     }
322
323     @Override
324     public void onDBusBlueZEvent(BlueZEvent event) {
325         logger.debug("Unsupported event: {}", event);
326     }
327
328     @Override
329     public void onServicesResolved(ServicesResolvedEvent event) {
330         if (event.isResolved()) {
331             notifyListeners(BluetoothEventType.SERVICES_DISCOVERED);
332         }
333     }
334
335     @Override
336     public void onNameUpdate(NameEvent event) {
337         BluetoothScanNotification notification = new BluetoothScanNotification();
338         notification.setDeviceName(event.getName());
339         notifyListeners(BluetoothEventType.SCAN_RECORD, notification);
340     }
341
342     @Override
343     public void onManufacturerDataUpdate(ManufacturerDataEvent event) {
344         for (Map.Entry<Short, byte[]> entry : event.getData().entrySet()) {
345             BluetoothScanNotification notification = new BluetoothScanNotification();
346             byte[] data = new byte[entry.getValue().length + 2];
347             data[0] = (byte) (entry.getKey() & 0xFF);
348             data[1] = (byte) (entry.getKey() >>> 8);
349
350             System.arraycopy(entry.getValue(), 0, data, 2, entry.getValue().length);
351
352             if (logger.isDebugEnabled()) {
353                 logger.debug("Received manufacturer data for '{}': {}", address, HexUtils.bytesToHex(data, " "));
354             }
355
356             notification.setManufacturerData(data);
357             notifyListeners(BluetoothEventType.SCAN_RECORD, notification);
358         }
359     }
360
361     @Override
362     public void onTxPowerUpdate(TXPowerEvent event) {
363         this.txPower = (int) event.getTxPower();
364     }
365
366     @Override
367     public void onCharacteristicNotify(CharacteristicUpdateEvent event) {
368         // Here it is a bit special - as the event is linked to the DBUS path, not characteristic UUID.
369         // So we need to find the characteristic by its DBUS path.
370         BluetoothGattCharacteristic characteristic = getDBusBlueZCharacteristicByDBusPath(event.getDbusPath());
371         if (characteristic == null) {
372             logger.debug("Received a notification for a characteristic not found on device.");
373             return;
374         }
375         BluetoothCharacteristic c = getCharacteristic(UUID.fromString(characteristic.getUuid()));
376         if (c != null) {
377             notifyListeners(BluetoothEventType.CHARACTERISTIC_UPDATED, c, event.getData());
378         }
379     }
380
381     @Override
382     public void onRssiUpdate(RssiEvent event) {
383         int rssiTmp = event.getRssi();
384         this.rssi = rssiTmp;
385         BluetoothScanNotification notification = new BluetoothScanNotification();
386         notification.setRssi(rssiTmp);
387         notifyListeners(BluetoothEventType.SCAN_RECORD, notification);
388     }
389
390     @Override
391     public void onConnectedStatusUpdate(ConnectedEvent event) {
392         this.connectionState = event.isConnected() ? ConnectionState.CONNECTED : ConnectionState.DISCONNECTED;
393         notifyListeners(BluetoothEventType.CONNECTION_STATE,
394                 new BluetoothConnectionStatusNotification(connectionState));
395     }
396
397     @Override
398     public boolean discoverServices() {
399         BluetoothDevice dev = device;
400         if (dev == null) {
401             return false;
402         }
403         if (dev.getGattServices().size() > getServices().size()) {
404             for (BluetoothGattService dBusBlueZService : dev.getGattServices()) {
405                 BluetoothService service = new BluetoothService(UUID.fromString(dBusBlueZService.getUuid()),
406                         dBusBlueZService.isPrimary());
407                 for (BluetoothGattCharacteristic dBusBlueZCharacteristic : dBusBlueZService.getGattCharacteristics()) {
408                     BluetoothCharacteristic characteristic = new BluetoothCharacteristic(
409                             UUID.fromString(dBusBlueZCharacteristic.getUuid()), 0);
410                     convertCharacteristicProperties(dBusBlueZCharacteristic, characteristic);
411
412                     for (BluetoothGattDescriptor dBusBlueZDescriptor : dBusBlueZCharacteristic.getGattDescriptors()) {
413                         BluetoothDescriptor descriptor = new BluetoothDescriptor(characteristic,
414                                 UUID.fromString(dBusBlueZDescriptor.getUuid()), 0);
415                         characteristic.addDescriptor(descriptor);
416                     }
417                     service.addCharacteristic(characteristic);
418                 }
419                 addService(service);
420             }
421             notifyListeners(BluetoothEventType.SERVICES_DISCOVERED);
422         }
423         return true;
424     }
425
426     /**
427      * Convert the flags of BluetoothGattCharacteristic to the int bitset used by BluetoothCharacteristic.
428      *
429      * @param dBusBlueZCharacteristic source characteristic to read the flags from
430      * @param characteristic destination characteristic to write to properties to
431      */
432     private void convertCharacteristicProperties(BluetoothGattCharacteristic dBusBlueZCharacteristic,
433             BluetoothCharacteristic characteristic) {
434         int properties = 0;
435
436         for (String property : dBusBlueZCharacteristic.getFlags()) {
437             switch (property) {
438                 case "broadcast":
439                     properties |= BluetoothCharacteristic.PROPERTY_BROADCAST;
440                     break;
441                 case "read":
442                     properties |= BluetoothCharacteristic.PROPERTY_READ;
443                     break;
444                 case "write-without-response":
445                     properties |= BluetoothCharacteristic.PROPERTY_WRITE_NO_RESPONSE;
446                     break;
447                 case "write":
448                     properties |= BluetoothCharacteristic.PROPERTY_WRITE;
449                     break;
450                 case "notify":
451                     properties |= BluetoothCharacteristic.PROPERTY_NOTIFY;
452                     break;
453                 case "indicate":
454                     properties |= BluetoothCharacteristic.PROPERTY_INDICATE;
455                     break;
456             }
457         }
458
459         characteristic.setProperties(properties);
460     }
461
462     @Override
463     public CompletableFuture<byte[]> readCharacteristic(BluetoothCharacteristic characteristic) {
464         BluetoothDevice dev = device;
465         if (dev == null || !dev.isConnected()) {
466             return CompletableFuture
467                     .failedFuture(new IllegalStateException("DBusBlueZ device is not set or not connected"));
468         }
469
470         BluetoothGattCharacteristic c = getDBusBlueZCharacteristicByUUID(characteristic.getUuid().toString());
471         if (c == null) {
472             logger.warn("Characteristic '{}' is missing on device '{}'.", characteristic.getUuid(), address);
473             return CompletableFuture.failedFuture(
474                     new IllegalStateException("Characteristic " + characteristic.getUuid() + " is missing on device"));
475         }
476
477         return RetryFuture.callWithRetry(() -> {
478             try {
479                 return c.readValue(null);
480             } catch (DBusException | DBusExecutionException e) {
481                 // DBusExecutionException is thrown if the value cannot be read
482                 logger.debug("Exception occurred when trying to read characteristic '{}': {}", characteristic.getUuid(),
483                         e.getMessage());
484                 throw e;
485             }
486         }, scheduler);
487     }
488
489     @Override
490     public boolean isNotifying(BluetoothCharacteristic characteristic) {
491         BluetoothGattCharacteristic c = getDBusBlueZCharacteristicByUUID(characteristic.getUuid().toString());
492         if (c != null) {
493             Boolean isNotifying = c.isNotifying();
494             return Objects.requireNonNullElse(isNotifying, false);
495         } else {
496             logger.warn("Characteristic '{}' is missing on device '{}'.", characteristic.getUuid(), address);
497             return false;
498         }
499     }
500
501     @Override
502     public CompletableFuture<@Nullable Void> disableNotifications(BluetoothCharacteristic characteristic) {
503         BluetoothDevice dev = device;
504         if (dev == null || !dev.isConnected()) {
505             return CompletableFuture
506                     .failedFuture(new IllegalStateException("DBusBlueZ device is not set or not connected"));
507         }
508         BluetoothGattCharacteristic c = getDBusBlueZCharacteristicByUUID(characteristic.getUuid().toString());
509         if (c == null) {
510             logger.warn("Characteristic '{}' is missing on device '{}'.", characteristic.getUuid(), address);
511             return CompletableFuture.failedFuture(
512                     new IllegalStateException("Characteristic " + characteristic.getUuid() + " is missing on device"));
513         }
514
515         return RetryFuture.callWithRetry(() -> {
516             try {
517                 c.stopNotify();
518             } catch (DBusException e) {
519                 if (e.getMessage().contains("Already notifying")) {
520                     return null;
521                 } else if (e.getMessage().contains("In Progress")) {
522                     // let's retry in half a second
523                     throw new RetryException(500, TimeUnit.MILLISECONDS);
524                 } else {
525                     logger.warn("Exception occurred while deactivating notifications on '{}'", address, e);
526                     throw e;
527                 }
528             }
529             return null;
530         }, scheduler);
531     }
532
533     @Override
534     public boolean enableNotifications(BluetoothDescriptor descriptor) {
535         // Not sure if it is possible to implement this
536         return false;
537     }
538
539     @Override
540     public boolean disableNotifications(BluetoothDescriptor descriptor) {
541         // Not sure if it is possible to implement this
542         return false;
543     }
544 }