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