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.discovery.internal;
15 import java.time.Duration;
16 import java.util.HashMap;
17 import java.util.HashSet;
19 import java.util.Optional;
21 import java.util.concurrent.CompletableFuture;
22 import java.util.concurrent.ConcurrentHashMap;
23 import java.util.concurrent.CopyOnWriteArraySet;
24 import java.util.function.BiConsumer;
26 import org.eclipse.jdt.annotation.NonNullByDefault;
27 import org.eclipse.jdt.annotation.Nullable;
28 import org.openhab.binding.bluetooth.BluetoothAdapter;
29 import org.openhab.binding.bluetooth.BluetoothAddress;
30 import org.openhab.binding.bluetooth.BluetoothBindingConstants;
31 import org.openhab.binding.bluetooth.BluetoothDevice;
32 import org.openhab.binding.bluetooth.BluetoothDiscoveryListener;
33 import org.openhab.binding.bluetooth.discovery.BluetoothDiscoveryParticipant;
34 import org.openhab.core.cache.ExpiringCache;
35 import org.openhab.core.config.discovery.AbstractDiscoveryService;
36 import org.openhab.core.config.discovery.DiscoveryResult;
37 import org.openhab.core.config.discovery.DiscoveryResultBuilder;
38 import org.openhab.core.config.discovery.DiscoveryService;
39 import org.openhab.core.thing.ThingTypeUID;
40 import org.openhab.core.thing.ThingUID;
41 import org.osgi.service.component.annotations.Activate;
42 import org.osgi.service.component.annotations.Component;
43 import org.osgi.service.component.annotations.Deactivate;
44 import org.osgi.service.component.annotations.Modified;
45 import org.osgi.service.component.annotations.Reference;
46 import org.osgi.service.component.annotations.ReferenceCardinality;
47 import org.osgi.service.component.annotations.ReferencePolicy;
48 import org.slf4j.Logger;
49 import org.slf4j.LoggerFactory;
52 * The {@link BluetoothDiscoveryService} handles searching for BLE devices.
54 * @author Chris Jackson - Initial Contribution
55 * @author Kai Kreuzer - Introduced BluetoothAdapters and BluetoothDiscoveryParticipants
56 * @author Connor Petty - Introduced connection based discovery and added roaming support
59 @Component(service = DiscoveryService.class, configurationPid = "discovery.bluetooth")
60 public class BluetoothDiscoveryService extends AbstractDiscoveryService implements BluetoothDiscoveryListener {
62 private final Logger logger = LoggerFactory.getLogger(BluetoothDiscoveryService.class);
64 private static final int SEARCH_TIME = 15;
66 private final Set<BluetoothAdapter> adapters = new CopyOnWriteArraySet<>();
67 private final Set<BluetoothDiscoveryParticipant> participants = new CopyOnWriteArraySet<>();
69 private final Map<BluetoothAddress, DiscoveryCache> discoveryCaches = new ConcurrentHashMap<>();
71 private final Set<ThingTypeUID> supportedThingTypes = new CopyOnWriteArraySet<>();
73 public BluetoothDiscoveryService() {
75 supportedThingTypes.add(BluetoothBindingConstants.THING_TYPE_BEACON);
80 protected void activate(@Nullable Map<String, Object> configProperties) {
81 logger.debug("Activating Bluetooth discovery service");
82 super.activate(configProperties);
87 protected void modified(@Nullable Map<String, Object> configProperties) {
88 super.modified(configProperties);
93 public void deactivate() {
94 logger.debug("Deactivating Bluetooth discovery service");
97 @Reference(cardinality = ReferenceCardinality.MULTIPLE, policy = ReferencePolicy.DYNAMIC)
98 protected void addBluetoothAdapter(BluetoothAdapter adapter) {
99 this.adapters.add(adapter);
100 adapter.addDiscoveryListener(this);
103 protected void removeBluetoothAdapter(BluetoothAdapter adapter) {
104 this.adapters.remove(adapter);
105 adapter.removeDiscoveryListener(this);
108 @Reference(cardinality = ReferenceCardinality.MULTIPLE, policy = ReferencePolicy.DYNAMIC)
109 protected void addBluetoothDiscoveryParticipant(BluetoothDiscoveryParticipant participant) {
110 this.participants.add(participant);
111 supportedThingTypes.addAll(participant.getSupportedThingTypeUIDs());
114 protected void removeBluetoothDiscoveryParticipant(BluetoothDiscoveryParticipant participant) {
115 supportedThingTypes.removeAll(participant.getSupportedThingTypeUIDs());
116 this.participants.remove(participant);
120 public Set<ThingTypeUID> getSupportedThingTypes() {
121 return supportedThingTypes;
125 public void startScan() {
126 for (BluetoothAdapter adapter : adapters) {
132 public void stopScan() {
133 for (BluetoothAdapter adapter : adapters) {
136 removeOlderResults(getTimestampOfLastScan());
140 public void deviceRemoved(BluetoothDevice device) {
141 discoveryCaches.computeIfPresent(device.getAddress(), (addr, cache) -> cache.removeDiscoveries(device));
145 public void deviceDiscovered(BluetoothDevice device) {
146 logger.debug("Discovered bluetooth device '{}': {}", device.getName(), device);
148 DiscoveryCache cache = discoveryCaches.computeIfAbsent(device.getAddress(), addr -> new DiscoveryCache());
149 cache.handleDiscovery(device);
152 private static ThingUID createThingUIDWithBridge(DiscoveryResult result, BluetoothAdapter adapter) {
153 return new ThingUID(result.getThingTypeUID(), adapter.getUID(), result.getThingUID().getId());
156 private static DiscoveryResult copyWithNewBridge(DiscoveryResult result, BluetoothAdapter adapter) {
157 String label = result.getLabel();
158 String adapterLabel = adapter.getLabel();
159 if (adapterLabel != null) {
160 label = adapterLabel + " - " + label;
163 return DiscoveryResultBuilder.create(createThingUIDWithBridge(result, adapter))//
164 .withBridge(adapter.getUID())//
165 .withProperties(result.getProperties())//
166 .withRepresentationProperty(result.getRepresentationProperty())//
167 .withTTL(result.getTimeToLive())//
172 private class DiscoveryCache {
174 private final Map<BluetoothAdapter, SnapshotFuture> discoveryFutures = new HashMap<>();
175 private final Map<BluetoothAdapter, Set<DiscoveryResult>> discoveryResults = new ConcurrentHashMap<>();
177 private ExpiringCache<BluetoothDeviceSnapshot> latestSnapshot = new ExpiringCache<>(Duration.ofMinutes(1),
181 * This is meant to be used as part of a Map.compute function
183 * @param device the device to remove from this cache
184 * @return this DiscoveryCache if there are still snapshots, null otherwise
186 public synchronized @Nullable DiscoveryCache removeDiscoveries(final BluetoothDevice device) {
187 // we remove any discoveries that have been published for this device
188 BluetoothAdapter adapter = device.getAdapter();
189 if (discoveryFutures.containsKey(adapter)) {
190 discoveryFutures.remove(adapter).future.thenAccept(result -> retractDiscoveryResult(adapter, result));
192 if (discoveryFutures.isEmpty()) {
198 public synchronized void handleDiscovery(BluetoothDevice device) {
199 if (!discoveryFutures.isEmpty()) {
201 // we have an ongoing futures so lets create our discovery after they all finish
202 .allOf(discoveryFutures.values().stream().map(sf -> sf.future)
203 .toArray(CompletableFuture[]::new))
204 .thenRun(() -> createDiscoveryFuture(device));
206 createDiscoveryFuture(device);
210 private synchronized void createDiscoveryFuture(BluetoothDevice device) {
211 BluetoothAdapter adapter = device.getAdapter();
212 CompletableFuture<DiscoveryResult> future = null;
214 BluetoothDeviceSnapshot snapshot = new BluetoothDeviceSnapshot(device);
215 BluetoothDeviceSnapshot latestSnapshot = this.latestSnapshot.getValue();
216 if (latestSnapshot != null) {
217 snapshot.merge(latestSnapshot);
219 if (snapshot.equals(latestSnapshot)) {
220 // this means that snapshot has no newer fields than the latest snapshot
221 if (discoveryFutures.containsKey(adapter)
222 && discoveryFutures.get(adapter).snapshot.equals(latestSnapshot)) {
223 // This adapter has already produced the most up-to-date result, so no further processing is
229 * This isn't a new snapshot, but an up-to-date result from this adapter has not been produced yet.
230 * Since a result must have been produced for this snapshot, we search the results of the other
231 * adapters to find the future for the latest snapshot, then we modify it to make it look like it
232 * came from this adapter. This way we don't need to recompute the DiscoveryResult.
234 Optional<CompletableFuture<DiscoveryResult>> otherFuture = discoveryFutures.values().stream()
235 // make sure that we only get futures for the current snapshot
236 .filter(sf -> sf.snapshot.equals(latestSnapshot)).findAny().map(sf -> sf.future);
237 if (otherFuture.isPresent()) {
238 future = otherFuture.get();
242 this.latestSnapshot.putValue(snapshot);
244 if (future == null) {
245 // we pass in the snapshot since it acts as a delegate for the device. It will also retain any new
246 // fields added to the device as part of the discovery process.
247 future = startDiscoveryProcess(snapshot);
250 if (discoveryFutures.containsKey(adapter)) {
251 // now we need to make sure that we remove the old discovered result if it is different from the new
253 SnapshotFuture oldSF = discoveryFutures.get(adapter);
254 future = oldSF.future.thenCombine(future, (oldResult, newResult) -> {
255 logger.trace("\n old: {}\n new: {}", oldResult, newResult);
256 if (!oldResult.getThingUID().equals(newResult.getThingUID())) {
257 retractDiscoveryResult(adapter, oldResult);
263 * this appends a post-process to any ongoing or completed discoveries with this device's address.
264 * If this discoveryFuture is ongoing then this post-process will run asynchronously upon the future's
266 * If this discoveryFuture is already completed then this post-process will run in the current thread.
267 * We need to make sure that this is part of the future chain so that the call to 'thingRemoved'
268 * in the 'removeDiscoveries' method above can be sure that it is running after the 'thingDiscovered'
270 future = future.thenApply(result -> {
271 publishDiscoveryResult(adapter, result);
273 }).whenComplete((r, t) -> {
275 logger.warn("Error occured during discovery of {}", device.getAddress(), t);
279 // now save this snapshot for later
280 discoveryFutures.put(adapter, new SnapshotFuture(snapshot, future));
283 private void publishDiscoveryResult(BluetoothAdapter adapter, DiscoveryResult result) {
284 Set<DiscoveryResult> results = new HashSet<>();
285 BiConsumer<BluetoothAdapter, DiscoveryResult> publisher = (a, r) -> {
286 results.add(copyWithNewBridge(r, a));
289 publisher.accept(adapter, result);
290 for (BluetoothDiscoveryParticipant participant : participants) {
291 participant.publishAdditionalResults(result, publisher);
293 results.forEach(BluetoothDiscoveryService.this::thingDiscovered);
294 discoveryResults.put(adapter, results);
297 private void retractDiscoveryResult(BluetoothAdapter adapter, DiscoveryResult result) {
298 Set<DiscoveryResult> results = discoveryResults.remove(adapter);
299 if (results != null) {
300 for (DiscoveryResult r : results) {
301 thingRemoved(r.getThingUID());
306 private CompletableFuture<DiscoveryResult> startDiscoveryProcess(BluetoothDeviceSnapshot device) {
307 return CompletableFuture.supplyAsync(new BluetoothDiscoveryProcess(device, participants, adapters),
312 private static class SnapshotFuture {
313 public final BluetoothDeviceSnapshot snapshot;
314 public final CompletableFuture<DiscoveryResult> future;
316 public SnapshotFuture(BluetoothDeviceSnapshot snapshot, CompletableFuture<DiscoveryResult> future) {
317 this.snapshot = snapshot;
318 this.future = future;