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