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