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