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