2 * Copyright (c) 2010-2023 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.BluetoothDevice.ConnectionState;
31 import org.openhab.binding.bluetooth.BluetoothService;
32 import org.openhab.binding.bluetooth.ConnectedBluetoothHandler;
33 import org.openhab.binding.bluetooth.notification.BluetoothScanNotification;
34 import org.openhab.bluetooth.gattparser.BluetoothGattParser;
35 import org.openhab.bluetooth.gattparser.BluetoothGattParserFactory;
36 import org.openhab.bluetooth.gattparser.FieldHolder;
37 import org.openhab.bluetooth.gattparser.GattRequest;
38 import org.openhab.bluetooth.gattparser.GattResponse;
39 import org.openhab.bluetooth.gattparser.spec.Characteristic;
40 import org.openhab.bluetooth.gattparser.spec.Field;
41 import org.openhab.core.library.types.StringType;
42 import org.openhab.core.thing.Channel;
43 import org.openhab.core.thing.ChannelUID;
44 import org.openhab.core.thing.Thing;
45 import org.openhab.core.thing.ThingStatus;
46 import org.openhab.core.thing.ThingStatusDetail;
47 import org.openhab.core.thing.binding.builder.ChannelBuilder;
48 import org.openhab.core.thing.binding.builder.ThingBuilder;
49 import org.openhab.core.thing.type.ChannelTypeUID;
50 import org.openhab.core.types.Command;
51 import org.openhab.core.types.RefreshType;
52 import org.openhab.core.types.State;
53 import org.openhab.core.util.HexUtils;
54 import org.slf4j.Logger;
55 import org.slf4j.LoggerFactory;
58 * This is a handler for generic connected bluetooth devices that dynamically generates
59 * channels based off of a bluetooth device's GATT characteristics.
61 * @author Connor Petty - Initial contribution
62 * @author Peter Rosenberg - Use notifications, add support for ServiceData
66 public class GenericBluetoothHandler extends ConnectedBluetoothHandler {
68 private final Logger logger = LoggerFactory.getLogger(GenericBluetoothHandler.class);
69 private final Map<BluetoothCharacteristic, CharacteristicHandler> charHandlers = new ConcurrentHashMap<>();
70 private final Map<ChannelUID, CharacteristicHandler> channelHandlers = new ConcurrentHashMap<>();
71 private final BluetoothGattParser gattParser = BluetoothGattParserFactory.getDefault();
72 private final CharacteristicChannelTypeProvider channelTypeProvider;
73 private final Map<CharacteristicHandler, List<ChannelUID>> handlerToChannels = new ConcurrentHashMap<>();
75 private @Nullable ScheduledFuture<?> readCharacteristicJob = null;
77 public GenericBluetoothHandler(Thing thing, CharacteristicChannelTypeProvider channelTypeProvider) {
79 this.channelTypeProvider = channelTypeProvider;
83 public void initialize() {
86 GenericBindingConfiguration config = getConfigAs(GenericBindingConfiguration.class);
87 readCharacteristicJob = scheduler.scheduleWithFixedDelay(() -> {
88 if (device.getConnectionState() == ConnectionState.CONNECTED) {
89 if (device.isServicesDiscovered()) {
90 handlerToChannels.forEach((charHandler, channelUids) -> {
91 // Only read the value manually if notification is not on.
92 // Also read it the first time before we activate notifications below.
93 if (!device.isNotifying(charHandler.characteristic) && charHandler.canRead()) {
94 device.readCharacteristic(charHandler.characteristic);
96 // TODO the ideal solution would be to use locks/conditions and timeouts
97 // Kbetween this code and `onCharacteristicReadComplete` but
98 // that would overcomplicate the code a bit and I plan
99 // on implementing a better more generalized solution later
101 } catch (InterruptedException e) {
105 if (charHandler.characteristic.canNotify()) {
106 // Enabled/Disable notifications dependent on if the channel is linked.
107 // TODO check why isLinked() is true for not linked channels
108 if (channelUids.stream().anyMatch(this::isLinked)) {
109 if (!device.isNotifying(charHandler.characteristic)) {
110 device.enableNotifications(charHandler.characteristic);
113 if (device.isNotifying(charHandler.characteristic)) {
114 device.disableNotifications(charHandler.characteristic);
120 // if we are connected and still haven't been able to resolve the services, try disconnecting and
121 // then connecting again
125 }, 15, config.pollingInterval, TimeUnit.SECONDS);
129 public void dispose() {
130 ScheduledFuture<?> future = readCharacteristicJob;
131 if (future != null) {
136 charHandlers.clear();
137 channelHandlers.clear();
138 handlerToChannels.clear();
142 public void onServicesDiscovered() {
143 super.onServicesDiscovered();
144 logger.trace("Service discovery completed for '{}'", address);
145 updateThingChannels();
149 public void handleCommand(ChannelUID channelUID, Command command) {
150 super.handleCommand(channelUID, command);
152 CharacteristicHandler handler = channelHandlers.get(channelUID);
153 if (handler != null) {
154 handler.handleCommand(channelUID, command);
159 public void onCharacteristicUpdate(BluetoothCharacteristic characteristic, byte[] value) {
160 super.onCharacteristicUpdate(characteristic, value);
161 getCharacteristicHandler(characteristic).handleCharacteristicUpdate(value);
165 public void onScanRecordReceived(BluetoothScanNotification scanNotification) {
166 super.onScanRecordReceived(scanNotification);
168 handleServiceData(scanNotification);
172 * Service data is specified in the "Core Specification Supplement"
173 * https://www.bluetooth.com/specifications/specs/
176 * Broadcast configuration to configure what to advertise in service data
177 * is specified in "Core Specification 5.3"
178 * https://www.bluetooth.com/specifications/specs/
179 * Part G: GENERIC ATTRIBUTE PROFILE (GATT): 2.7 CONFIGURED BROADCAST
181 * This method extracts ServiceData, finds the Service and the Characteristic it belongs
182 * to and notifies a value change.
184 * @param scanNotification to get serviceData from
186 private void handleServiceData(BluetoothScanNotification scanNotification) {
187 Map<String, byte[]> serviceData = scanNotification.getServiceData();
188 if (serviceData != null) {
189 for (String uuidStr : serviceData.keySet()) {
191 BluetoothService service = device.getServices(UUID.fromString(uuidStr));
192 if (service == null) {
193 logger.warn("Service with UUID {} not found on {}, ignored.", uuidStr,
194 scanNotification.getAddress());
196 // The ServiceData contains the UUID of the Service but no identifier of the
197 // Characteristic the data belongs to.
198 // Check which Characteristic within this service has the `Broadcast` property set
199 // and select this one as the Characteristic to assign the data to.
200 List<BluetoothCharacteristic> broadcastCharacteristics = service.getCharacteristics().stream()
201 .filter((characteristic) -> characteristic
202 .hasPropertyEnabled(BluetoothCharacteristic.PROPERTY_BROADCAST))
203 .collect(Collectors.toUnmodifiableList());
205 if (broadcastCharacteristics.isEmpty()) {
207 "No Characteristic of service with UUID {} on {} has the broadcast property set, ignored.",
208 uuidStr, scanNotification.getAddress());
209 } else if (broadcastCharacteristics.size() > 1) {
211 "Multiple Characteristics of service with UUID {} on {} have the broadcast property set what is not supported, ignored.",
212 uuidStr, scanNotification.getAddress());
214 BluetoothCharacteristic broadcastCharacteristic = broadcastCharacteristics.get(0);
216 byte[] value = serviceData.get(uuidStr);
218 onCharacteristicUpdate(broadcastCharacteristic, value);
220 logger.warn("Service Data for Service with UUID {} on {} is null, ignored.", uuidStr,
221 scanNotification.getAddress());
229 private void updateThingChannels() {
230 List<Channel> channels = device.getServices().stream()//
231 .flatMap(service -> service.getCharacteristics().stream())//
232 .flatMap(characteristic -> {
233 logger.trace("{} processing characteristic {}", address, characteristic.getUuid());
234 CharacteristicHandler handler = getCharacteristicHandler(characteristic);
235 List<Channel> chans = handler.buildChannels();
236 List<ChannelUID> chanUids = chans.stream().map(Channel::getUID).collect(Collectors.toList());
237 for (ChannelUID channel : chanUids) {
238 channelHandlers.put(channel, handler);
240 handlerToChannels.put(handler, chanUids);
241 return chans.stream();
243 .collect(Collectors.toList());
245 ThingBuilder builder = editThing();
246 boolean changed = false;
247 for (Channel channel : channels) {
248 logger.trace("{} attempting to add channel {}", address, channel.getLabel());
249 // we only want to add each channel, not replace all of them
250 if (getThing().getChannel(channel.getUID()) == null) {
252 builder.withChannel(channel);
256 updateThing(builder.build());
260 private CharacteristicHandler getCharacteristicHandler(BluetoothCharacteristic characteristic) {
261 return Objects.requireNonNull(charHandlers.computeIfAbsent(characteristic, CharacteristicHandler::new));
264 private void readCharacteristic(BluetoothCharacteristic characteristic) {
265 readCharacteristic(characteristic.getService().getUuid(), characteristic.getUuid()).whenComplete((data, th) -> {
267 logger.warn("Could not read data from characteristic {} of device {}: {}", characteristic.getUuid(),
268 address, th.getMessage());
272 getCharacteristicHandler(characteristic).handleCharacteristicUpdate(data);
277 private void writeCharacteristic(BluetoothCharacteristic characteristic, byte[] data) {
278 writeCharacteristic(characteristic.getService().getUuid(), characteristic.getUuid(), data, false)
279 .whenComplete((r, th) -> {
281 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
282 "Could not write data to characteristic " + characteristic.getUuid() + ": "
288 private class CharacteristicHandler {
290 private BluetoothCharacteristic characteristic;
292 public CharacteristicHandler(BluetoothCharacteristic characteristic) {
293 this.characteristic = characteristic;
296 private String getCharacteristicUUID() {
297 return characteristic.getUuid().toString();
300 public void handleCommand(ChannelUID channelUID, Command command) {
302 if (command == RefreshType.REFRESH) {
304 readCharacteristic(characteristic);
310 if (command instanceof State) {
311 State state = (State) command;
312 String characteristicUUID = getCharacteristicUUID();
314 if (gattParser.isKnownCharacteristic(characteristicUUID)) {
315 String fieldName = getFieldName(channelUID);
316 if (fieldName != null) {
317 updateCharacteristic(fieldName, state);
319 logger.warn("Characteristic has no field name!");
321 } else if (state instanceof StringType) {
322 // unknown characteristic
323 byte[] data = HexUtils.hexToBytes(state.toString());
324 writeCharacteristic(characteristic, data);
326 } catch (RuntimeException ex) {
327 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
328 "Could not update bluetooth device. Error: " + ex.getMessage());
333 private void updateCharacteristic(String fieldName, State state) {
334 // TODO maybe we should check if the characteristic is authenticated?
335 String characteristicUUID = getCharacteristicUUID();
337 if (gattParser.isValidForWrite(characteristicUUID)) {
338 GattRequest request = gattParser.prepare(characteristicUUID);
340 BluetoothChannelUtils.updateHolder(gattParser, request, fieldName, state);
341 byte[] data = gattParser.serialize(request);
343 writeCharacteristic(characteristic, data);
344 } catch (NumberFormatException ex) {
345 logger.warn("Could not parse characteristic value: {} : {}", characteristicUUID, state, ex);
346 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
347 "Could not parse characteristic value: " + characteristicUUID + " : " + state);
352 public void handleCharacteristicUpdate(byte[] data) {
353 String characteristicUUID = getCharacteristicUUID();
354 if (gattParser.isKnownCharacteristic(characteristicUUID)) {
355 GattResponse response = gattParser.parse(characteristicUUID, data);
356 for (FieldHolder holder : response.getFieldHolders()) {
357 Field field = holder.getField();
358 ChannelUID channelUID = getChannelUID(field);
359 updateState(channelUID, BluetoothChannelUtils.convert(gattParser, holder));
362 // this is a raw channel
363 String hex = HexUtils.bytesToHex(data);
364 ChannelUID channelUID = getChannelUID(null);
365 updateState(channelUID, new StringType(hex));
369 public List<Channel> buildChannels() {
370 List<Channel> channels = new ArrayList<>();
371 String charUUID = getCharacteristicUUID();
372 Characteristic gattChar = gattParser.getCharacteristic(charUUID);
373 if (gattChar != null) {
374 List<Field> fields = gattParser.getFields(charUUID);
377 // check if the characteristic has only on field, if so use its name as label
378 if (fields.size() == 1) {
379 label = gattChar.getName();
382 Map<String, List<Field>> fieldsMapping = fields.stream().collect(Collectors.groupingBy(Field::getName));
384 for (List<Field> fieldList : fieldsMapping.values()) {
385 Field field = fieldList.get(0);
386 if (fieldList.size() > 1) {
387 if (field.isFlagField() || field.isOpCodesField()) {
388 logger.debug("Skipping flags/op codes field: {}.", charUUID);
390 logger.warn("Multiple fields with the same name found: {} / {}. Skipping these fields.",
391 charUUID, field.getName());
396 if (isFieldSupported(field)) {
397 Channel channel = buildFieldChannel(field, label, !gattChar.isValidForWrite());
398 if (channel != null) {
399 channels.add(channel);
401 logger.warn("Unable to build channel for field: {}", field.getName());
404 logger.warn("GATT field is not supported: {} / {} / {}", charUUID, field.getName(),
409 channels.add(buildUnknownChannel());
414 private Channel buildUnknownChannel() {
415 ChannelUID channelUID = getChannelUID(null);
416 ChannelTypeUID channelTypeUID = new ChannelTypeUID(BluetoothBindingConstants.BINDING_ID, "char-unknown");
417 return ChannelBuilder.create(channelUID).withType(channelTypeUID).withProperties(getChannelProperties(null))
421 public boolean canRead() {
422 String charUUID = getCharacteristicUUID();
423 if (gattParser.isKnownCharacteristic(charUUID)) {
424 return gattParser.isValidForRead(charUUID);
426 return characteristic.canRead();
429 public boolean canWrite() {
430 String charUUID = getCharacteristicUUID();
431 if (gattParser.isKnownCharacteristic(charUUID)) {
432 return gattParser.isValidForWrite(charUUID);
434 return characteristic.canWrite();
437 private boolean isAdvanced() {
438 return !gattParser.isKnownCharacteristic(getCharacteristicUUID());
441 private boolean isFieldSupported(Field field) {
442 return field.getFormat() != null;
445 private @Nullable Channel buildFieldChannel(Field field, @Nullable String charLabel, boolean readOnly) {
446 String label = charLabel != null ? charLabel : field.getName();
447 String acceptedType = BluetoothChannelUtils.getItemType(field);
448 if (acceptedType == null) {
449 // unknown field format
453 ChannelUID channelUID = getChannelUID(field);
455 logger.debug("Building a new channel for a field: {}", channelUID.getId());
457 ChannelTypeUID channelTypeUID = channelTypeProvider.registerChannelType(getCharacteristicUUID(),
458 isAdvanced(), readOnly, field);
460 return ChannelBuilder.create(channelUID, acceptedType).withType(channelTypeUID)
461 .withProperties(getChannelProperties(field.getName())).withLabel(label).build();
464 private ChannelUID getChannelUID(@Nullable Field field) {
465 StringBuilder builder = new StringBuilder();
466 builder.append("service-")//
467 .append(toBluetoothHandle(characteristic.getService().getUuid()))//
469 .append(toBluetoothHandle(characteristic.getUuid()));
471 builder.append("-").append(BluetoothChannelUtils.encodeFieldName(field.getName()));
473 return new ChannelUID(getThing().getUID(), builder.toString());
476 private String toBluetoothHandle(UUID uuid) {
477 long leastSig = uuid.getLeastSignificantBits();
478 long mostSig = uuid.getMostSignificantBits();
480 if (leastSig == BluetoothBindingConstants.BLUETOOTH_BASE_UUID) {
481 return "0x" + Long.toHexString(mostSig >> 32).toUpperCase();
483 return uuid.toString().toUpperCase();
486 private @Nullable String getFieldName(ChannelUID channelUID) {
487 String channelId = channelUID.getId();
488 int index = channelId.lastIndexOf("-");
490 throw new IllegalArgumentException(
491 "ChannelUID '" + channelUID + "' is not a valid GATT channel format");
493 String encodedFieldName = channelId.substring(index + 1);
494 if (encodedFieldName.isEmpty()) {
497 return BluetoothChannelUtils.decodeFieldName(encodedFieldName);
500 private Map<String, String> getChannelProperties(@Nullable String fieldName) {
501 Map<String, String> properties = new HashMap<>();
502 if (fieldName != null) {
503 properties.put(GenericBindingConstants.PROPERTY_FIELD_NAME, fieldName);
505 properties.put(GenericBindingConstants.PROPERTY_SERVICE_UUID,
506 characteristic.getService().getUuid().toString());
507 properties.put(GenericBindingConstants.PROPERTY_CHARACTERISTIC_UUID, getCharacteristicUUID());