2 * Copyright (c) 2010-2021 Contributors to the openHAB project
4 * See the NOTICE file(s) distributed with this work for additional
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
11 * SPDX-License-Identifier: EPL-2.0
13 package org.openhab.binding.bluetooth.generic.internal;
15 import java.util.ArrayList;
16 import java.util.HashMap;
17 import java.util.List;
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;
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;
57 * This is a handler for generic connected bluetooth devices that dynamically generates
58 * channels based off of a bluetooth device's GATT characteristics.
60 * @author Connor Petty - Initial contribution
61 * @author Peter Rosenberg - Use notifications
65 public class GenericBluetoothHandler extends ConnectedBluetoothHandler {
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<>();
74 private @Nullable ScheduledFuture<?> readCharacteristicJob = null;
76 public GenericBluetoothHandler(Thing thing, CharacteristicChannelTypeProvider channelTypeProvider) {
78 this.channelTypeProvider = channelTypeProvider;
82 public void initialize() {
85 GenericBindingConfiguration config = getConfigAs(GenericBindingConfiguration.class);
86 readCharacteristicJob = scheduler.scheduleWithFixedDelay(() -> {
87 if (device.getConnectionState() == ConnectionState.CONNECTED) {
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);
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
100 } catch (InterruptedException e) {
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);
112 if (device.isNotifying(charHandler.characteristic)) {
113 device.disableNotifications(charHandler.characteristic);
119 // if we are connected and still haven't been able to resolve the services, try disconnecting and
120 // then connecting again
124 }, 15, config.pollingInterval, TimeUnit.SECONDS);
128 public void dispose() {
129 ScheduledFuture<?> future = readCharacteristicJob;
130 if (future != null) {
135 charHandlers.clear();
136 channelHandlers.clear();
137 handlerToChannels.clear();
141 public void onServicesDiscovered() {
144 logger.trace("Service discovery completed for '{}'", address);
145 updateThingChannels();
150 public void handleCommand(ChannelUID channelUID, Command command) {
151 super.handleCommand(channelUID, command);
153 CharacteristicHandler handler = channelHandlers.get(channelUID);
154 if (handler != null) {
155 handler.handleCommand(channelUID, command);
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);
169 public void onCharacteristicUpdate(BluetoothCharacteristic characteristic) {
170 super.onCharacteristicUpdate(characteristic);
171 byte[] data = characteristic.getByteValue();
172 getCharacteristicHandler(characteristic).handleCharacteristicUpdate(data);
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);
186 handlerToChannels.put(handler, chanUids);
187 return chans.stream();
189 .collect(Collectors.toList());
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) {
198 builder.withChannel(channel);
202 updateThing(builder.build());
206 private CharacteristicHandler getCharacteristicHandler(BluetoothCharacteristic characteristic) {
207 return Objects.requireNonNull(charHandlers.computeIfAbsent(characteristic, CharacteristicHandler::new));
210 private boolean readCharacteristic(BluetoothCharacteristic characteristic) {
211 return device.readCharacteristic(characteristic);
214 private boolean writeCharacteristic(BluetoothCharacteristic characteristic, byte[] data) {
215 characteristic.setValue(data);
216 return device.writeCharacteristic(characteristic);
219 private class CharacteristicHandler {
221 private BluetoothCharacteristic characteristic;
223 public CharacteristicHandler(BluetoothCharacteristic characteristic) {
224 this.characteristic = characteristic;
227 private String getCharacteristicUUID() {
228 return characteristic.getUuid().toString();
231 public void handleCommand(ChannelUID channelUID, Command command) {
234 if (command == RefreshType.REFRESH) {
236 readCharacteristic(characteristic);
242 if (command instanceof State) {
243 State state = (State) command;
244 String characteristicUUID = getCharacteristicUUID();
246 if (gattParser.isKnownCharacteristic(characteristicUUID)) {
247 String fieldName = getFieldName(channelUID);
248 if (fieldName != null) {
249 updateCharacteristic(fieldName, state);
251 logger.warn("Characteristic has no field name!");
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);
261 } catch (RuntimeException ex) {
262 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
263 "Could not update bluetooth device. Error: " + ex.getMessage());
268 private void updateCharacteristic(String fieldName, State state) {
269 // TODO maybe we should check if the characteristic is authenticated?
270 String characteristicUUID = getCharacteristicUUID();
272 if (gattParser.isValidForWrite(characteristicUUID)) {
273 GattRequest request = gattParser.prepare(characteristicUUID);
275 BluetoothChannelUtils.updateHolder(gattParser, request, fieldName, state);
276 byte[] data = gattParser.serialize(request);
278 if (!writeCharacteristic(characteristic, data)) {
279 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
280 "Could not write data to characteristic: " + characteristicUUID);
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);
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));
300 // this is a raw channel
301 String hex = HexUtils.bytesToHex(data);
302 ChannelUID channelUID = getChannelUID(null);
303 updateState(channelUID, new StringType(hex));
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);
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();
320 Map<String, List<Field>> fieldsMapping = fields.stream().collect(Collectors.groupingBy(Field::getName));
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);
328 logger.warn("Multiple fields with the same name found: {} / {}. Skipping these fields.",
329 charUUID, field.getName());
334 if (isFieldSupported(field)) {
335 Channel channel = buildFieldChannel(field, label, !gattChar.isValidForWrite());
336 if (channel != null) {
337 channels.add(channel);
339 logger.warn("Unable to build channel for field: {}", field.getName());
342 logger.warn("GATT field is not supported: {} / {} / {}", charUUID, field.getName(),
347 channels.add(buildUnknownChannel());
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))
359 public boolean canRead() {
360 String charUUID = getCharacteristicUUID();
361 if (gattParser.isKnownCharacteristic(charUUID)) {
362 return gattParser.isValidForRead(charUUID);
364 return characteristic.canRead();
367 public boolean canWrite() {
368 String charUUID = getCharacteristicUUID();
369 if (gattParser.isKnownCharacteristic(charUUID)) {
370 return gattParser.isValidForWrite(charUUID);
372 return characteristic.canWrite();
375 private boolean isAdvanced() {
376 return !gattParser.isKnownCharacteristic(getCharacteristicUUID());
379 private boolean isFieldSupported(Field field) {
380 return field.getFormat() != null;
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
391 ChannelUID channelUID = getChannelUID(field);
393 logger.debug("Building a new channel for a field: {}", channelUID.getId());
395 ChannelTypeUID channelTypeUID = channelTypeProvider.registerChannelType(getCharacteristicUUID(),
396 isAdvanced(), readOnly, field);
398 return ChannelBuilder.create(channelUID, acceptedType).withType(channelTypeUID)
399 .withProperties(getChannelProperties(field.getName())).withLabel(label).build();
402 private ChannelUID getChannelUID(@Nullable Field field) {
403 StringBuilder builder = new StringBuilder();
404 builder.append("service-")//
405 .append(toBluetoothHandle(characteristic.getService().getUuid()))//
407 .append(toBluetoothHandle(characteristic.getUuid()));
409 builder.append("-").append(BluetoothChannelUtils.encodeFieldName(field.getName()));
411 return new ChannelUID(getThing().getUID(), builder.toString());
414 private String toBluetoothHandle(UUID uuid) {
415 long leastSig = uuid.getLeastSignificantBits();
416 long mostSig = uuid.getMostSignificantBits();
418 if (leastSig == BluetoothBindingConstants.BLUETOOTH_BASE_UUID) {
419 return "0x" + Long.toHexString(mostSig >> 32).toUpperCase();
421 return uuid.toString().toUpperCase();
424 private @Nullable String getFieldName(ChannelUID channelUID) {
425 String channelId = channelUID.getId();
426 int index = channelId.lastIndexOf("-");
428 throw new IllegalArgumentException(
429 "ChannelUID '" + channelUID + "' is not a valid GATT channel format");
431 String encodedFieldName = channelId.substring(index + 1);
432 if (encodedFieldName.isEmpty()) {
435 return BluetoothChannelUtils.decodeFieldName(encodedFieldName);
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);
443 properties.put(GenericBindingConstants.PROPERTY_SERVICE_UUID,
444 characteristic.getService().getUuid().toString());
445 properties.put(GenericBindingConstants.PROPERTY_CHARACTERISTIC_UUID, getCharacteristicUUID());