]> git.basschouten.com Git - openhab-addons.git/blob
95bcebeda36e7d9e0d1a6cce2425bc93368af30b
[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      * @param name the name of the device
81      */
82     public BlueZBluetoothDevice(BlueZBridgeHandler adapter, BluetoothAddress address) {
83         super(adapter, address);
84         logger.debug("Creating DBusBlueZ device with address '{}'", address);
85     }
86
87     @SuppressWarnings("PMD.CompareObjectsWithEquals")
88     public synchronized void updateBlueZDevice(@Nullable BluetoothDevice blueZDevice) {
89         if (this.device != null && this.device == blueZDevice) {
90             return;
91         }
92         logger.debug("updateBlueZDevice({})", blueZDevice);
93
94         this.device = blueZDevice;
95
96         if (blueZDevice == null) {
97             return;
98         }
99
100         Short rssi = blueZDevice.getRssi();
101         if (rssi != null) {
102             this.rssi = rssi.intValue();
103         }
104         this.name = blueZDevice.getName();
105         Map<UInt16, byte[]> manData = blueZDevice.getManufacturerData();
106         if (manData != null) {
107             manData.entrySet().stream().map(Map.Entry::getKey).filter(Objects::nonNull).findFirst()
108                     .ifPresent((UInt16 manufacturerId) ->
109                     // Convert to unsigned int to match the convention in BluetoothCompanyIdentifiers
110                     this.manufacturer = manufacturerId.intValue() & 0xFFFF);
111         }
112
113         if (Boolean.TRUE.equals(blueZDevice.isConnected())) {
114             setConnectionState(ConnectionState.CONNECTED);
115         }
116
117         discoverServices();
118     }
119
120     /**
121      * Clean up and release memory.
122      */
123     @Override
124     public void dispose() {
125         BluetoothDevice dev = device;
126         if (dev != null) {
127             if (Boolean.TRUE.equals(dev.isPaired())) {
128                 return;
129             }
130
131             try {
132                 dev.getAdapter().removeDevice(dev.getRawDevice());
133             } catch (DBusException ex) {
134                 String exceptionMessage = ex.getMessage();
135                 if (exceptionMessage == null || exceptionMessage.contains("Does Not Exist")) {
136                     logger.debug("Exception occurred when trying to remove inactive device '{}': {}", address,
137                             ex.getMessage());
138                 }
139                 // this codeblock will only be hit when the underlying device has already
140                 // been removed but we don't have a way to check if that is the case beforehand
141                 // so we will just eat the error here.
142             } catch (RuntimeException ex) {
143                 // try to catch any other exceptions
144                 logger.debug("Exception occurred when trying to remove inactive device '{}': {}", address,
145                         ex.getMessage());
146             }
147         }
148     }
149
150     private void setConnectionState(ConnectionState state) {
151         if (this.connectionState != state) {
152             this.connectionState = state;
153             notifyListeners(BluetoothEventType.CONNECTION_STATE, new BluetoothConnectionStatusNotification(state));
154         }
155     }
156
157     @Override
158     public boolean connect() {
159         logger.debug("Connect({})", device);
160
161         BluetoothDevice dev = device;
162         if (dev != null) {
163             if (Boolean.FALSE.equals(dev.isConnected())) {
164                 try {
165                     boolean ret = dev.connect();
166                     logger.debug("Connect result: {}", ret);
167                     return ret;
168                 } catch (NoReply e) {
169                     // Have to double check because sometimes, exception but still worked
170                     logger.debug("Got a timeout - but sometimes happen. Is Connected ? {}", dev.isConnected());
171                     if (Boolean.FALSE.equals(dev.isConnected())) {
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             } else {
185                 logger.debug("Device was already connected");
186                 // we might be stuck in another state atm so we need to trigger a connected in this case
187                 setConnectionState(ConnectionState.CONNECTED);
188                 return true;
189             }
190         }
191         return false;
192     }
193
194     @Override
195     public boolean disconnect() {
196         BluetoothDevice dev = device;
197         if (dev != null) {
198             logger.debug("Disconnecting '{}'", address);
199             return dev.disconnect();
200         }
201         return false;
202     }
203
204     private void ensureConnected() {
205         BluetoothDevice dev = device;
206         if (dev == null || !dev.isConnected()) {
207             throw new IllegalStateException("DBusBlueZ device is not set or not connected");
208         }
209     }
210
211     private @Nullable BluetoothGattCharacteristic getDBusBlueZCharacteristicByUUID(String uuid) {
212         BluetoothDevice dev = device;
213         if (dev == null) {
214             return null;
215         }
216         for (BluetoothGattService service : dev.getGattServices()) {
217             for (BluetoothGattCharacteristic c : service.getGattCharacteristics()) {
218                 if (c.getUuid().equalsIgnoreCase(uuid)) {
219                     return c;
220                 }
221             }
222         }
223         return null;
224     }
225
226     private @Nullable BluetoothGattCharacteristic getDBusBlueZCharacteristicByDBusPath(String dBusPath) {
227         BluetoothDevice dev = device;
228         if (dev == null) {
229             return null;
230         }
231         for (BluetoothGattService service : dev.getGattServices()) {
232             if (dBusPath.startsWith(service.getDbusPath())) {
233                 for (BluetoothGattCharacteristic characteristic : service.getGattCharacteristics()) {
234                     if (dBusPath.startsWith(characteristic.getDbusPath())) {
235                         return characteristic;
236                     }
237                 }
238             }
239         }
240         return null;
241     }
242
243     private @Nullable BluetoothGattDescriptor getDBusBlueZDescriptorByUUID(String uuid) {
244         BluetoothDevice dev = device;
245         if (dev == null) {
246             return null;
247         }
248         for (BluetoothGattService service : dev.getGattServices()) {
249             for (BluetoothGattCharacteristic c : service.getGattCharacteristics()) {
250                 for (BluetoothGattDescriptor d : c.getGattDescriptors()) {
251                     if (d.getUuid().equalsIgnoreCase(uuid)) {
252                         return d;
253                     }
254                 }
255             }
256         }
257         return null;
258     }
259
260     @Override
261     public CompletableFuture<@Nullable Void> enableNotifications(BluetoothCharacteristic characteristic) {
262         BluetoothDevice dev = device;
263         if (dev == null || !dev.isConnected()) {
264             return CompletableFuture
265                     .failedFuture(new IllegalStateException("DBusBlueZ device is not set or not connected"));
266         }
267
268         BluetoothGattCharacteristic c = getDBusBlueZCharacteristicByUUID(characteristic.getUuid().toString());
269         if (c == null) {
270             logger.warn("Characteristic '{}' is missing on device '{}'.", characteristic.getUuid(), address);
271             return CompletableFuture.failedFuture(
272                     new IllegalStateException("Characteristic " + characteristic.getUuid() + " is missing on device"));
273         }
274
275         return RetryFuture.callWithRetry(() -> {
276             try {
277                 c.startNotify();
278             } catch (DBusException e) {
279                 String exceptionMessage = e.getMessage();
280                 if (exceptionMessage != null && exceptionMessage.contains("Already notifying")) {
281                     return null;
282                 } else if (exceptionMessage != null && exceptionMessage.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 onServiceDataUpdate(ServiceDataEvent event) {
363         BluetoothScanNotification notification = new BluetoothScanNotification();
364         notification.setServiceData(event.getData());
365         notifyListeners(BluetoothEventType.SCAN_RECORD, notification);
366     }
367
368     @Override
369     public void onTxPowerUpdate(TXPowerEvent event) {
370         this.txPower = (int) event.getTxPower();
371     }
372
373     @Override
374     public void onCharacteristicNotify(CharacteristicUpdateEvent event) {
375         // Here it is a bit special - as the event is linked to the DBUS path, not characteristic UUID.
376         // So we need to find the characteristic by its DBUS path.
377         BluetoothGattCharacteristic characteristic = getDBusBlueZCharacteristicByDBusPath(event.getDbusPath());
378         if (characteristic == null) {
379             logger.debug("Received a notification for a characteristic not found on device.");
380             return;
381         }
382         BluetoothCharacteristic c = getCharacteristic(UUID.fromString(characteristic.getUuid()));
383         if (c != null) {
384             notifyListeners(BluetoothEventType.CHARACTERISTIC_UPDATED, c, event.getData());
385         }
386     }
387
388     @Override
389     public void onRssiUpdate(RssiEvent event) {
390         int rssiTmp = event.getRssi();
391         this.rssi = rssiTmp;
392         BluetoothScanNotification notification = new BluetoothScanNotification();
393         notification.setRssi(rssiTmp);
394         notifyListeners(BluetoothEventType.SCAN_RECORD, notification);
395     }
396
397     @Override
398     public void onConnectedStatusUpdate(ConnectedEvent event) {
399         this.connectionState = event.isConnected() ? ConnectionState.CONNECTED : ConnectionState.DISCONNECTED;
400         notifyListeners(BluetoothEventType.CONNECTION_STATE,
401                 new BluetoothConnectionStatusNotification(connectionState));
402     }
403
404     @Override
405     public boolean discoverServices() {
406         BluetoothDevice dev = device;
407         if (dev == null) {
408             return false;
409         }
410         if (dev.getGattServices().size() > getServices().size()) {
411             for (BluetoothGattService dBusBlueZService : dev.getGattServices()) {
412                 BluetoothService service = new BluetoothService(UUID.fromString(dBusBlueZService.getUuid()),
413                         dBusBlueZService.isPrimary());
414                 for (BluetoothGattCharacteristic dBusBlueZCharacteristic : dBusBlueZService.getGattCharacteristics()) {
415                     BluetoothCharacteristic characteristic = new BluetoothCharacteristic(
416                             UUID.fromString(dBusBlueZCharacteristic.getUuid()), 0);
417                     convertCharacteristicProperties(dBusBlueZCharacteristic, characteristic);
418
419                     for (BluetoothGattDescriptor dBusBlueZDescriptor : dBusBlueZCharacteristic.getGattDescriptors()) {
420                         BluetoothDescriptor descriptor = new BluetoothDescriptor(characteristic,
421                                 UUID.fromString(dBusBlueZDescriptor.getUuid()), 0);
422                         characteristic.addDescriptor(descriptor);
423                     }
424                     service.addCharacteristic(characteristic);
425                 }
426                 addService(service);
427             }
428             notifyListeners(BluetoothEventType.SERVICES_DISCOVERED);
429         }
430         return true;
431     }
432
433     /**
434      * Convert the flags of BluetoothGattCharacteristic to the int bitset used by BluetoothCharacteristic.
435      *
436      * @param dBusBlueZCharacteristic source characteristic to read the flags from
437      * @param characteristic destination characteristic to write to properties to
438      */
439     private void convertCharacteristicProperties(BluetoothGattCharacteristic dBusBlueZCharacteristic,
440             BluetoothCharacteristic characteristic) {
441         int properties = 0;
442
443         for (String property : dBusBlueZCharacteristic.getFlags()) {
444             switch (property) {
445                 case "broadcast":
446                     properties |= BluetoothCharacteristic.PROPERTY_BROADCAST;
447                     break;
448                 case "read":
449                     properties |= BluetoothCharacteristic.PROPERTY_READ;
450                     break;
451                 case "write-without-response":
452                     properties |= BluetoothCharacteristic.PROPERTY_WRITE_NO_RESPONSE;
453                     break;
454                 case "write":
455                     properties |= BluetoothCharacteristic.PROPERTY_WRITE;
456                     break;
457                 case "notify":
458                     properties |= BluetoothCharacteristic.PROPERTY_NOTIFY;
459                     break;
460                 case "indicate":
461                     properties |= BluetoothCharacteristic.PROPERTY_INDICATE;
462                     break;
463             }
464         }
465
466         characteristic.setProperties(properties);
467     }
468
469     @Override
470     public CompletableFuture<byte[]> readCharacteristic(BluetoothCharacteristic characteristic) {
471         BluetoothDevice dev = device;
472         if (dev == null || !dev.isConnected()) {
473             return CompletableFuture
474                     .failedFuture(new IllegalStateException("DBusBlueZ device is not set or not connected"));
475         }
476
477         BluetoothGattCharacteristic c = getDBusBlueZCharacteristicByUUID(characteristic.getUuid().toString());
478         if (c == null) {
479             logger.warn("Characteristic '{}' is missing on device '{}'.", characteristic.getUuid(), address);
480             return CompletableFuture.failedFuture(
481                     new IllegalStateException("Characteristic " + characteristic.getUuid() + " is missing on device"));
482         }
483
484         return RetryFuture.callWithRetry(() -> {
485             try {
486                 return c.readValue(null);
487             } catch (DBusException | DBusExecutionException e) {
488                 // DBusExecutionException is thrown if the value cannot be read
489                 logger.debug("Exception occurred when trying to read characteristic '{}': {}", characteristic.getUuid(),
490                         e.getMessage());
491                 throw e;
492             }
493         }, scheduler);
494     }
495
496     @Override
497     public boolean isNotifying(BluetoothCharacteristic characteristic) {
498         BluetoothGattCharacteristic c = getDBusBlueZCharacteristicByUUID(characteristic.getUuid().toString());
499         if (c != null) {
500             Boolean isNotifying = c.isNotifying();
501             return Objects.requireNonNullElse(isNotifying, false);
502         } else {
503             logger.warn("Characteristic '{}' is missing on device '{}'.", characteristic.getUuid(), address);
504             return false;
505         }
506     }
507
508     @Override
509     public CompletableFuture<@Nullable Void> disableNotifications(BluetoothCharacteristic characteristic) {
510         BluetoothDevice dev = device;
511         if (dev == null || !dev.isConnected()) {
512             return CompletableFuture
513                     .failedFuture(new IllegalStateException("DBusBlueZ device is not set or not connected"));
514         }
515         BluetoothGattCharacteristic c = getDBusBlueZCharacteristicByUUID(characteristic.getUuid().toString());
516         if (c == null) {
517             logger.warn("Characteristic '{}' is missing on device '{}'.", characteristic.getUuid(), address);
518             return CompletableFuture.failedFuture(
519                     new IllegalStateException("Characteristic " + characteristic.getUuid() + " is missing on device"));
520         }
521
522         return RetryFuture.callWithRetry(() -> {
523             try {
524                 c.stopNotify();
525             } catch (DBusException e) {
526                 String exceptionMessage = e.getMessage();
527                 if (exceptionMessage != null && exceptionMessage.contains("Already notifying")) {
528                     return null;
529                 } else if (exceptionMessage != null && exceptionMessage.contains("In Progress")) {
530                     // let's retry in half a second
531                     throw new RetryException(500, TimeUnit.MILLISECONDS);
532                 } else {
533                     logger.warn("Exception occurred while deactivating notifications on '{}'", address, e);
534                     throw e;
535                 }
536             }
537             return null;
538         }, scheduler);
539     }
540
541     @Override
542     public boolean enableNotifications(BluetoothDescriptor descriptor) {
543         // Not sure if it is possible to implement this
544         return false;
545     }
546
547     @Override
548     public boolean disableNotifications(BluetoothDescriptor descriptor) {
549         // Not sure if it is possible to implement this
550         return false;
551     }
552 }