]> git.basschouten.com Git - openhab-addons.git/blob
644e23bf898f80dd1932a27f76c839eb85580508
[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.generic.internal;
14
15 import java.util.ArrayList;
16 import java.util.HashMap;
17 import java.util.List;
18 import java.util.Map;
19 import java.util.Objects;
20 import java.util.UUID;
21 import java.util.concurrent.ConcurrentHashMap;
22 import java.util.concurrent.ScheduledFuture;
23 import java.util.concurrent.TimeUnit;
24 import java.util.stream.Collectors;
25
26 import org.eclipse.jdt.annotation.NonNullByDefault;
27 import org.eclipse.jdt.annotation.Nullable;
28 import org.openhab.binding.bluetooth.BluetoothBindingConstants;
29 import org.openhab.binding.bluetooth.BluetoothCharacteristic;
30 import org.openhab.binding.bluetooth.BluetoothDevice.ConnectionState;
31 import org.openhab.binding.bluetooth.BluetoothService;
32 import org.openhab.binding.bluetooth.ConnectedBluetoothHandler;
33 import org.openhab.binding.bluetooth.notification.BluetoothScanNotification;
34 import org.openhab.bluetooth.gattparser.BluetoothGattParser;
35 import org.openhab.bluetooth.gattparser.BluetoothGattParserFactory;
36 import org.openhab.bluetooth.gattparser.FieldHolder;
37 import org.openhab.bluetooth.gattparser.GattRequest;
38 import org.openhab.bluetooth.gattparser.GattResponse;
39 import org.openhab.bluetooth.gattparser.spec.Characteristic;
40 import org.openhab.bluetooth.gattparser.spec.Field;
41 import org.openhab.core.library.types.StringType;
42 import org.openhab.core.thing.Channel;
43 import org.openhab.core.thing.ChannelUID;
44 import org.openhab.core.thing.Thing;
45 import org.openhab.core.thing.ThingStatus;
46 import org.openhab.core.thing.ThingStatusDetail;
47 import org.openhab.core.thing.binding.builder.ChannelBuilder;
48 import org.openhab.core.thing.binding.builder.ThingBuilder;
49 import org.openhab.core.thing.type.ChannelTypeUID;
50 import org.openhab.core.types.Command;
51 import org.openhab.core.types.RefreshType;
52 import org.openhab.core.types.State;
53 import org.openhab.core.util.HexUtils;
54 import org.slf4j.Logger;
55 import org.slf4j.LoggerFactory;
56
57 /**
58  * This is a handler for generic connected bluetooth devices that dynamically generates
59  * channels based off of a bluetooth device's GATT characteristics.
60  *
61  * @author Connor Petty - Initial contribution
62  * @author Peter Rosenberg - Use notifications, add support for ServiceData
63  *
64  */
65 @NonNullByDefault
66 public class GenericBluetoothHandler extends ConnectedBluetoothHandler {
67
68     private final Logger logger = LoggerFactory.getLogger(GenericBluetoothHandler.class);
69     private final Map<BluetoothCharacteristic, CharacteristicHandler> charHandlers = new ConcurrentHashMap<>();
70     private final Map<ChannelUID, CharacteristicHandler> channelHandlers = new ConcurrentHashMap<>();
71     private final BluetoothGattParser gattParser = BluetoothGattParserFactory.getDefault();
72     private final CharacteristicChannelTypeProvider channelTypeProvider;
73     private final Map<CharacteristicHandler, List<ChannelUID>> handlerToChannels = new ConcurrentHashMap<>();
74
75     private @Nullable ScheduledFuture<?> readCharacteristicJob = null;
76
77     public GenericBluetoothHandler(Thing thing, CharacteristicChannelTypeProvider channelTypeProvider) {
78         super(thing);
79         this.channelTypeProvider = channelTypeProvider;
80     }
81
82     @Override
83     public void initialize() {
84         super.initialize();
85
86         GenericBindingConfiguration config = getConfigAs(GenericBindingConfiguration.class);
87         readCharacteristicJob = scheduler.scheduleWithFixedDelay(() -> {
88             if (device.getConnectionState() == ConnectionState.CONNECTED) {
89                 if (device.isServicesDiscovered()) {
90                     handlerToChannels.forEach((charHandler, channelUids) -> {
91                         // Only read the value manually if notification is not on.
92                         // Also read it the first time before we activate notifications below.
93                         if (!device.isNotifying(charHandler.characteristic) && charHandler.canRead()) {
94                             device.readCharacteristic(charHandler.characteristic);
95                             try {
96                                 // TODO the ideal solution would be to use locks/conditions and timeouts
97                                 // Kbetween this code and `onCharacteristicReadComplete` but
98                                 // that would overcomplicate the code a bit and I plan
99                                 // on implementing a better more generalized solution later
100                                 Thread.sleep(50);
101                             } catch (InterruptedException e) {
102                                 return;
103                             }
104                         }
105                         if (charHandler.characteristic.canNotify()) {
106                             // Enabled/Disable notifications dependent on if the channel is linked.
107                             // TODO check why isLinked() is true for not linked channels
108                             if (channelUids.stream().anyMatch(this::isLinked)) {
109                                 if (!device.isNotifying(charHandler.characteristic)) {
110                                     device.enableNotifications(charHandler.characteristic);
111                                 }
112                             } else {
113                                 if (device.isNotifying(charHandler.characteristic)) {
114                                     device.disableNotifications(charHandler.characteristic);
115                                 }
116                             }
117                         }
118                     });
119                 } else {
120                     // if we are connected and still haven't been able to resolve the services, try disconnecting and
121                     // then connecting again
122                     device.disconnect();
123                 }
124             }
125         }, 15, config.pollingInterval, TimeUnit.SECONDS);
126     }
127
128     @Override
129     public void dispose() {
130         ScheduledFuture<?> future = readCharacteristicJob;
131         if (future != null) {
132             future.cancel(true);
133         }
134         super.dispose();
135
136         charHandlers.clear();
137         channelHandlers.clear();
138         handlerToChannels.clear();
139     }
140
141     @Override
142     public void onServicesDiscovered() {
143         super.onServicesDiscovered();
144         logger.trace("Service discovery completed for '{}'", address);
145         updateThingChannels();
146     }
147
148     @Override
149     public void handleCommand(ChannelUID channelUID, Command command) {
150         super.handleCommand(channelUID, command);
151
152         CharacteristicHandler handler = channelHandlers.get(channelUID);
153         if (handler != null) {
154             handler.handleCommand(channelUID, command);
155         }
156     }
157
158     @Override
159     public void onCharacteristicUpdate(BluetoothCharacteristic characteristic, byte[] value) {
160         super.onCharacteristicUpdate(characteristic, value);
161         getCharacteristicHandler(characteristic).handleCharacteristicUpdate(value);
162     }
163
164     @Override
165     public void onScanRecordReceived(BluetoothScanNotification scanNotification) {
166         super.onScanRecordReceived(scanNotification);
167
168         handleServiceData(scanNotification);
169     }
170
171     /**
172      * Service data is specified in the "Core Specification Supplement"
173      * https://www.bluetooth.com/specifications/specs/
174      * 1.11 SERVICE DATA
175      * <p>
176      * Broadcast configuration to configure what to advertise in service data
177      * is specified in "Core Specification 5.3"
178      * https://www.bluetooth.com/specifications/specs/
179      * Part G: GENERIC ATTRIBUTE PROFILE (GATT): 2.7 CONFIGURED BROADCAST
180      *
181      * This method extracts ServiceData, finds the Service and the Characteristic it belongs
182      * to and notifies a value change.
183      *
184      * @param scanNotification to get serviceData from
185      */
186     private void handleServiceData(BluetoothScanNotification scanNotification) {
187         Map<String, byte[]> serviceData = scanNotification.getServiceData();
188         if (serviceData != null) {
189             for (String uuidStr : serviceData.keySet()) {
190                 @Nullable
191                 BluetoothService service = device.getServices(UUID.fromString(uuidStr));
192                 if (service == null) {
193                     logger.warn("Service with UUID {} not found on {}, ignored.", uuidStr,
194                             scanNotification.getAddress());
195                 } else {
196                     // The ServiceData contains the UUID of the Service but no identifier of the
197                     // Characteristic the data belongs to.
198                     // Check which Characteristic within this service has the `Broadcast` property set
199                     // and select this one as the Characteristic to assign the data to.
200                     List<BluetoothCharacteristic> broadcastCharacteristics = service.getCharacteristics().stream()
201                             .filter((characteristic) -> characteristic
202                                     .hasPropertyEnabled(BluetoothCharacteristic.PROPERTY_BROADCAST))
203                             .collect(Collectors.toUnmodifiableList());
204
205                     if (broadcastCharacteristics.size() == 0) {
206                         logger.info(
207                                 "No Characteristic of service with UUID {} on {} has the broadcast property set, ignored.",
208                                 uuidStr, scanNotification.getAddress());
209                     } else if (broadcastCharacteristics.size() > 1) {
210                         logger.warn(
211                                 "Multiple Characteristics of service with UUID {} on {} have the broadcast property set what is not supported, ignored.",
212                                 uuidStr, scanNotification.getAddress());
213                     } else {
214                         BluetoothCharacteristic broadcastCharacteristic = broadcastCharacteristics.get(0);
215
216                         byte[] value = serviceData.get(uuidStr);
217                         if (value != null) {
218                             onCharacteristicUpdate(broadcastCharacteristic, value);
219                         } else {
220                             logger.warn("Service Data for Service with UUID {} on {} is null, ignored.", uuidStr,
221                                     scanNotification.getAddress());
222                         }
223                     }
224                 }
225             }
226         }
227     }
228
229     private void updateThingChannels() {
230         List<Channel> channels = device.getServices().stream()//
231                 .flatMap(service -> service.getCharacteristics().stream())//
232                 .flatMap(characteristic -> {
233                     logger.trace("{} processing characteristic {}", address, characteristic.getUuid());
234                     CharacteristicHandler handler = getCharacteristicHandler(characteristic);
235                     List<Channel> chans = handler.buildChannels();
236                     List<ChannelUID> chanUids = chans.stream().map(Channel::getUID).collect(Collectors.toList());
237                     for (ChannelUID channel : chanUids) {
238                         channelHandlers.put(channel, handler);
239                     }
240                     handlerToChannels.put(handler, chanUids);
241                     return chans.stream();
242                 })//
243                 .collect(Collectors.toList());
244
245         ThingBuilder builder = editThing();
246         boolean changed = false;
247         for (Channel channel : channels) {
248             logger.trace("{} attempting to add channel {}", address, channel.getLabel());
249             // we only want to add each channel, not replace all of them
250             if (getThing().getChannel(channel.getUID()) == null) {
251                 changed = true;
252                 builder.withChannel(channel);
253             }
254         }
255         if (changed) {
256             updateThing(builder.build());
257         }
258     }
259
260     private CharacteristicHandler getCharacteristicHandler(BluetoothCharacteristic characteristic) {
261         return Objects.requireNonNull(charHandlers.computeIfAbsent(characteristic, CharacteristicHandler::new));
262     }
263
264     private void readCharacteristic(BluetoothCharacteristic characteristic) {
265         readCharacteristic(characteristic.getService().getUuid(), characteristic.getUuid()).whenComplete((data, th) -> {
266             if (th != null) {
267                 logger.warn("Could not read data from characteristic {} of device {}: {}", characteristic.getUuid(),
268                         address, th.getMessage());
269                 return;
270             }
271             if (data != null) {
272                 getCharacteristicHandler(characteristic).handleCharacteristicUpdate(data);
273             }
274         });
275     }
276
277     private void writeCharacteristic(BluetoothCharacteristic characteristic, byte[] data) {
278         writeCharacteristic(characteristic.getService().getUuid(), characteristic.getUuid(), data, false)
279                 .whenComplete((r, th) -> {
280                     if (th != null) {
281                         updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
282                                 "Could not write data to characteristic " + characteristic.getUuid() + ": "
283                                         + th.getMessage());
284                     }
285                 });
286     }
287
288     private class CharacteristicHandler {
289
290         private BluetoothCharacteristic characteristic;
291
292         public CharacteristicHandler(BluetoothCharacteristic characteristic) {
293             this.characteristic = characteristic;
294         }
295
296         private String getCharacteristicUUID() {
297             return characteristic.getUuid().toString();
298         }
299
300         public void handleCommand(ChannelUID channelUID, Command command) {
301
302             // Handle REFRESH
303             if (command == RefreshType.REFRESH) {
304                 if (canRead()) {
305                     readCharacteristic(characteristic);
306                 }
307                 return;
308             }
309
310             // handle write
311             if (command instanceof State) {
312                 State state = (State) command;
313                 String characteristicUUID = getCharacteristicUUID();
314                 try {
315                     if (gattParser.isKnownCharacteristic(characteristicUUID)) {
316                         String fieldName = getFieldName(channelUID);
317                         if (fieldName != null) {
318                             updateCharacteristic(fieldName, state);
319                         } else {
320                             logger.warn("Characteristic has no field name!");
321                         }
322                     } else if (state instanceof StringType) {
323                         // unknown characteristic
324                         byte[] data = HexUtils.hexToBytes(state.toString());
325                         writeCharacteristic(characteristic, data);
326                     }
327                 } catch (RuntimeException ex) {
328                     updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
329                             "Could not update bluetooth device. Error: " + ex.getMessage());
330                 }
331             }
332         }
333
334         private void updateCharacteristic(String fieldName, State state) {
335             // TODO maybe we should check if the characteristic is authenticated?
336             String characteristicUUID = getCharacteristicUUID();
337
338             if (gattParser.isValidForWrite(characteristicUUID)) {
339                 GattRequest request = gattParser.prepare(characteristicUUID);
340                 try {
341                     BluetoothChannelUtils.updateHolder(gattParser, request, fieldName, state);
342                     byte[] data = gattParser.serialize(request);
343
344                     writeCharacteristic(characteristic, data);
345                 } catch (NumberFormatException ex) {
346                     logger.warn("Could not parse characteristic value: {} : {}", characteristicUUID, state, ex);
347                     updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
348                             "Could not parse characteristic value: " + characteristicUUID + " : " + state);
349                 }
350             }
351         }
352
353         public void handleCharacteristicUpdate(byte[] data) {
354             String characteristicUUID = getCharacteristicUUID();
355             if (gattParser.isKnownCharacteristic(characteristicUUID)) {
356                 GattResponse response = gattParser.parse(characteristicUUID, data);
357                 for (FieldHolder holder : response.getFieldHolders()) {
358                     Field field = holder.getField();
359                     ChannelUID channelUID = getChannelUID(field);
360                     updateState(channelUID, BluetoothChannelUtils.convert(gattParser, holder));
361                 }
362             } else {
363                 // this is a raw channel
364                 String hex = HexUtils.bytesToHex(data);
365                 ChannelUID channelUID = getChannelUID(null);
366                 updateState(channelUID, new StringType(hex));
367             }
368         }
369
370         public List<Channel> buildChannels() {
371             List<Channel> channels = new ArrayList<>();
372             String charUUID = getCharacteristicUUID();
373             Characteristic gattChar = gattParser.getCharacteristic(charUUID);
374             if (gattChar != null) {
375                 List<Field> fields = gattParser.getFields(charUUID);
376
377                 String label = null;
378                 // check if the characteristic has only on field, if so use its name as label
379                 if (fields.size() == 1) {
380                     label = gattChar.getName();
381                 }
382
383                 Map<String, List<Field>> fieldsMapping = fields.stream().collect(Collectors.groupingBy(Field::getName));
384
385                 for (List<Field> fieldList : fieldsMapping.values()) {
386                     Field field = fieldList.get(0);
387                     if (fieldList.size() > 1) {
388                         if (field.isFlagField() || field.isOpCodesField()) {
389                             logger.debug("Skipping flags/op codes field: {}.", charUUID);
390                         } else {
391                             logger.warn("Multiple fields with the same name found: {} / {}. Skipping these fields.",
392                                     charUUID, field.getName());
393                         }
394                         continue;
395                     }
396
397                     if (isFieldSupported(field)) {
398                         Channel channel = buildFieldChannel(field, label, !gattChar.isValidForWrite());
399                         if (channel != null) {
400                             channels.add(channel);
401                         } else {
402                             logger.warn("Unable to build channel for field: {}", field.getName());
403                         }
404                     } else {
405                         logger.warn("GATT field is not supported: {} / {} / {}", charUUID, field.getName(),
406                                 field.getFormat());
407                     }
408                 }
409             } else {
410                 channels.add(buildUnknownChannel());
411             }
412             return channels;
413         }
414
415         private Channel buildUnknownChannel() {
416             ChannelUID channelUID = getChannelUID(null);
417             ChannelTypeUID channelTypeUID = new ChannelTypeUID(BluetoothBindingConstants.BINDING_ID, "char-unknown");
418             return ChannelBuilder.create(channelUID).withType(channelTypeUID).withProperties(getChannelProperties(null))
419                     .build();
420         }
421
422         public boolean canRead() {
423             String charUUID = getCharacteristicUUID();
424             if (gattParser.isKnownCharacteristic(charUUID)) {
425                 return gattParser.isValidForRead(charUUID);
426             }
427             return characteristic.canRead();
428         }
429
430         public boolean canWrite() {
431             String charUUID = getCharacteristicUUID();
432             if (gattParser.isKnownCharacteristic(charUUID)) {
433                 return gattParser.isValidForWrite(charUUID);
434             }
435             return characteristic.canWrite();
436         }
437
438         private boolean isAdvanced() {
439             return !gattParser.isKnownCharacteristic(getCharacteristicUUID());
440         }
441
442         private boolean isFieldSupported(Field field) {
443             return field.getFormat() != null;
444         }
445
446         private @Nullable Channel buildFieldChannel(Field field, @Nullable String charLabel, boolean readOnly) {
447             String label = charLabel != null ? charLabel : field.getName();
448             String acceptedType = BluetoothChannelUtils.getItemType(field);
449             if (acceptedType == null) {
450                 // unknown field format
451                 return null;
452             }
453
454             ChannelUID channelUID = getChannelUID(field);
455
456             logger.debug("Building a new channel for a field: {}", channelUID.getId());
457
458             ChannelTypeUID channelTypeUID = channelTypeProvider.registerChannelType(getCharacteristicUUID(),
459                     isAdvanced(), readOnly, field);
460
461             return ChannelBuilder.create(channelUID, acceptedType).withType(channelTypeUID)
462                     .withProperties(getChannelProperties(field.getName())).withLabel(label).build();
463         }
464
465         private ChannelUID getChannelUID(@Nullable Field field) {
466             StringBuilder builder = new StringBuilder();
467             builder.append("service-")//
468                     .append(toBluetoothHandle(characteristic.getService().getUuid()))//
469                     .append("-char-")//
470                     .append(toBluetoothHandle(characteristic.getUuid()));
471             if (field != null) {
472                 builder.append("-").append(BluetoothChannelUtils.encodeFieldName(field.getName()));
473             }
474             return new ChannelUID(getThing().getUID(), builder.toString());
475         }
476
477         private String toBluetoothHandle(UUID uuid) {
478             long leastSig = uuid.getLeastSignificantBits();
479             long mostSig = uuid.getMostSignificantBits();
480
481             if (leastSig == BluetoothBindingConstants.BLUETOOTH_BASE_UUID) {
482                 return "0x" + Long.toHexString(mostSig >> 32).toUpperCase();
483             }
484             return uuid.toString().toUpperCase();
485         }
486
487         private @Nullable String getFieldName(ChannelUID channelUID) {
488             String channelId = channelUID.getId();
489             int index = channelId.lastIndexOf("-");
490             if (index == -1) {
491                 throw new IllegalArgumentException(
492                         "ChannelUID '" + channelUID + "' is not a valid GATT channel format");
493             }
494             String encodedFieldName = channelId.substring(index + 1);
495             if (encodedFieldName.isEmpty()) {
496                 return null;
497             }
498             return BluetoothChannelUtils.decodeFieldName(encodedFieldName);
499         }
500
501         private Map<String, String> getChannelProperties(@Nullable String fieldName) {
502             Map<String, String> properties = new HashMap<>();
503             if (fieldName != null) {
504                 properties.put(GenericBindingConstants.PROPERTY_FIELD_NAME, fieldName);
505             }
506             properties.put(GenericBindingConstants.PROPERTY_SERVICE_UUID,
507                     characteristic.getService().getUuid().toString());
508             properties.put(GenericBindingConstants.PROPERTY_CHARACTERISTIC_UUID, getCharacteristicUUID());
509             return properties;
510         }
511     }
512 }