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