]> git.basschouten.com Git - openhab-addons.git/blob
ccdf31874b725ee0dd5946f7af758ab468eb30fb
[openhab-addons.git] /
1 /**
2  * Copyright (c) 2010-2023 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.discovery.internal;
14
15 import java.time.Duration;
16 import java.util.HashMap;
17 import java.util.HashSet;
18 import java.util.Map;
19 import java.util.Objects;
20 import java.util.Optional;
21 import java.util.Set;
22 import java.util.concurrent.CompletableFuture;
23 import java.util.concurrent.ConcurrentHashMap;
24 import java.util.concurrent.CopyOnWriteArraySet;
25 import java.util.function.BiConsumer;
26
27 import org.eclipse.jdt.annotation.NonNullByDefault;
28 import org.eclipse.jdt.annotation.Nullable;
29 import org.openhab.binding.bluetooth.BluetoothAdapter;
30 import org.openhab.binding.bluetooth.BluetoothAddress;
31 import org.openhab.binding.bluetooth.BluetoothBindingConstants;
32 import org.openhab.binding.bluetooth.BluetoothDevice;
33 import org.openhab.binding.bluetooth.BluetoothDiscoveryListener;
34 import org.openhab.binding.bluetooth.discovery.BluetoothDiscoveryParticipant;
35 import org.openhab.core.cache.ExpiringCache;
36 import org.openhab.core.config.discovery.AbstractDiscoveryService;
37 import org.openhab.core.config.discovery.DiscoveryResult;
38 import org.openhab.core.config.discovery.DiscoveryResultBuilder;
39 import org.openhab.core.config.discovery.DiscoveryService;
40 import org.openhab.core.thing.ThingTypeUID;
41 import org.openhab.core.thing.ThingUID;
42 import org.osgi.service.component.annotations.Activate;
43 import org.osgi.service.component.annotations.Component;
44 import org.osgi.service.component.annotations.Deactivate;
45 import org.osgi.service.component.annotations.Modified;
46 import org.osgi.service.component.annotations.Reference;
47 import org.osgi.service.component.annotations.ReferenceCardinality;
48 import org.osgi.service.component.annotations.ReferencePolicy;
49 import org.slf4j.Logger;
50 import org.slf4j.LoggerFactory;
51
52 /**
53  * The {@link BluetoothDiscoveryService} handles searching for BLE devices.
54  *
55  * @author Chris Jackson - Initial Contribution
56  * @author Kai Kreuzer - Introduced BluetoothAdapters and BluetoothDiscoveryParticipants
57  * @author Connor Petty - Introduced connection based discovery and added roaming support
58  */
59 @NonNullByDefault
60 @Component(service = DiscoveryService.class, configurationPid = "discovery.bluetooth")
61 public class BluetoothDiscoveryService extends AbstractDiscoveryService implements BluetoothDiscoveryListener {
62
63     private final Logger logger = LoggerFactory.getLogger(BluetoothDiscoveryService.class);
64
65     private static final int SEARCH_TIME = 15;
66
67     private final Set<BluetoothAdapter> adapters = new CopyOnWriteArraySet<>();
68     private final Set<BluetoothDiscoveryParticipant> participants = new CopyOnWriteArraySet<>();
69     @NonNullByDefault({})
70     private final Map<BluetoothAddress, DiscoveryCache> discoveryCaches = new ConcurrentHashMap<>();
71
72     private final Set<ThingTypeUID> supportedThingTypes = new CopyOnWriteArraySet<>();
73
74     public BluetoothDiscoveryService() {
75         super(SEARCH_TIME);
76         supportedThingTypes.add(BluetoothBindingConstants.THING_TYPE_BEACON);
77     }
78
79     @Override
80     @Activate
81     protected void activate(@Nullable Map<String, Object> configProperties) {
82         logger.debug("Activating Bluetooth discovery service");
83         super.activate(configProperties);
84     }
85
86     @Override
87     @Modified
88     protected void modified(@Nullable Map<String, Object> configProperties) {
89         super.modified(configProperties);
90     }
91
92     @Override
93     @Deactivate
94     public void deactivate() {
95         logger.debug("Deactivating Bluetooth discovery service");
96     }
97
98     @Reference(cardinality = ReferenceCardinality.MULTIPLE, policy = ReferencePolicy.DYNAMIC)
99     protected void addBluetoothAdapter(BluetoothAdapter adapter) {
100         this.adapters.add(adapter);
101         adapter.addDiscoveryListener(this);
102     }
103
104     protected void removeBluetoothAdapter(BluetoothAdapter adapter) {
105         this.adapters.remove(adapter);
106         adapter.removeDiscoveryListener(this);
107     }
108
109     @Reference(cardinality = ReferenceCardinality.MULTIPLE, policy = ReferencePolicy.DYNAMIC)
110     protected void addBluetoothDiscoveryParticipant(BluetoothDiscoveryParticipant participant) {
111         this.participants.add(participant);
112         supportedThingTypes.addAll(participant.getSupportedThingTypeUIDs());
113     }
114
115     protected void removeBluetoothDiscoveryParticipant(BluetoothDiscoveryParticipant participant) {
116         supportedThingTypes.removeAll(participant.getSupportedThingTypeUIDs());
117         this.participants.remove(participant);
118     }
119
120     @Override
121     public Set<ThingTypeUID> getSupportedThingTypes() {
122         return supportedThingTypes;
123     }
124
125     @Override
126     public void startScan() {
127         for (BluetoothAdapter adapter : adapters) {
128             adapter.scanStart();
129         }
130     }
131
132     @Override
133     public void stopScan() {
134         for (BluetoothAdapter adapter : adapters) {
135             adapter.scanStop();
136         }
137
138         // The method `removeOlderResults()` removes the Things from listeners like `Inbox`.
139         // We therefore need to reset `latestSnapshot` so that the Things are notified again next time.
140         // Results newer than `getTimestampOfLastScan()` will also be notified again but do not lead to duplicates.
141         discoveryCaches.values().forEach(discoveryCache -> {
142             discoveryCache.latestSnapshot.putValue(null);
143         });
144         removeOlderResults(getTimestampOfLastScan());
145     }
146
147     @Override
148     public void deviceRemoved(BluetoothDevice device) {
149         discoveryCaches.computeIfPresent(device.getAddress(), (addr, cache) -> cache.removeDiscoveries(device));
150     }
151
152     @Override
153     public void deviceDiscovered(BluetoothDevice device) {
154         logger.debug("Discovered bluetooth device '{}': {}", device.getName(), device);
155
156         DiscoveryCache cache = Objects
157                 .requireNonNull(discoveryCaches.computeIfAbsent(device.getAddress(), addr -> new DiscoveryCache()));
158         cache.handleDiscovery(device);
159     }
160
161     private static ThingUID createThingUIDWithBridge(DiscoveryResult result, BluetoothAdapter adapter) {
162         return new ThingUID(result.getThingTypeUID(), adapter.getUID(), result.getThingUID().getId());
163     }
164
165     private static DiscoveryResult copyWithNewBridge(DiscoveryResult result, BluetoothAdapter adapter) {
166         String label = result.getLabel();
167
168         return DiscoveryResultBuilder.create(createThingUIDWithBridge(result, adapter))//
169                 .withBridge(adapter.getUID())//
170                 .withProperties(result.getProperties())//
171                 .withRepresentationProperty(result.getRepresentationProperty())//
172                 .withTTL(result.getTimeToLive())//
173                 .withLabel(label)//
174                 .build();
175     }
176
177     private class DiscoveryCache {
178
179         private final Map<BluetoothAdapter, SnapshotFuture> discoveryFutures = new HashMap<>();
180         private final Map<BluetoothAdapter, Set<DiscoveryResult>> discoveryResults = new ConcurrentHashMap<>();
181
182         private ExpiringCache<BluetoothDeviceSnapshot> latestSnapshot = new ExpiringCache<>(Duration.ofMinutes(1),
183                 () -> null);
184
185         /**
186          * This is meant to be used as part of a Map.compute function
187          *
188          * @param device the device to remove from this cache
189          * @return this DiscoveryCache if there are still snapshots, null otherwise
190          */
191         public synchronized @Nullable DiscoveryCache removeDiscoveries(final BluetoothDevice device) {
192             // we remove any discoveries that have been published for this device
193             BluetoothAdapter adapter = device.getAdapter();
194             if (discoveryFutures.containsKey(adapter)) {
195                 @Nullable
196                 SnapshotFuture ssFuture = discoveryFutures.remove(adapter);
197                 if (ssFuture != null) {
198                     ssFuture.future.thenAccept(result -> retractDiscoveryResult(adapter, result));
199                 }
200             }
201             if (discoveryFutures.isEmpty()) {
202                 return null;
203             }
204             return this;
205         }
206
207         public synchronized void handleDiscovery(BluetoothDevice device) {
208             if (!discoveryFutures.isEmpty()) {
209                 CompletableFuture
210                         // we have an ongoing futures so lets create our discovery after they all finish
211                         .allOf(discoveryFutures.values().stream().map(sf -> sf.future)
212                                 .toArray(CompletableFuture[]::new))
213                         .thenRun(() -> createDiscoveryFuture(device));
214             } else {
215                 createDiscoveryFuture(device);
216             }
217         }
218
219         private synchronized void createDiscoveryFuture(BluetoothDevice device) {
220             BluetoothAdapter adapter = device.getAdapter();
221             CompletableFuture<DiscoveryResult> future = null;
222
223             BluetoothDeviceSnapshot snapshot = new BluetoothDeviceSnapshot(device);
224             BluetoothDeviceSnapshot latestSnapshot = this.latestSnapshot.getValue();
225             if (latestSnapshot != null) {
226                 snapshot.merge(latestSnapshot);
227
228                 if (snapshot.equals(latestSnapshot)) {
229                     // this means that snapshot has no newer fields than the latest snapshot
230                     if (discoveryFutures.containsKey(adapter)
231                             && discoveryFutures.get(adapter).snapshot.equals(latestSnapshot)) {
232                         // This adapter has already produced the most up-to-date result, so no further processing is
233                         // necessary
234                         return;
235                     }
236
237                     /*
238                      * This isn't a new snapshot, but an up-to-date result from this adapter has not been produced yet.
239                      * Since a result must have been produced for this snapshot, we search the results of the other
240                      * adapters to find the future for the latest snapshot, then we modify it to make it look like it
241                      * came from this adapter. This way we don't need to recompute the DiscoveryResult.
242                      */
243                     Optional<CompletableFuture<DiscoveryResult>> otherFuture = discoveryFutures.values().stream()
244                             // make sure that we only get futures for the current snapshot
245                             .filter(sf -> sf.snapshot.equals(latestSnapshot)).findAny().map(sf -> sf.future);
246                     if (otherFuture.isPresent()) {
247                         future = otherFuture.get();
248                     }
249                 }
250             }
251             this.latestSnapshot.putValue(snapshot);
252
253             if (future == null) {
254                 // we pass in the snapshot since it acts as a delegate for the device. It will also retain any new
255                 // fields added to the device as part of the discovery process.
256                 future = startDiscoveryProcess(snapshot);
257             }
258
259             if (discoveryFutures.containsKey(adapter)) {
260                 // now we need to make sure that we remove the old discovered result if it is different from the new
261                 // one.
262
263                 @Nullable
264                 SnapshotFuture oldSF = discoveryFutures.get(adapter);
265                 future = oldSF.future.thenCombine(future, (oldResult, newResult) -> {
266                     logger.trace("\n old: {}\n new: {}", oldResult, newResult);
267                     if (!oldResult.getThingUID().equals(newResult.getThingUID())) {
268                         retractDiscoveryResult(adapter, oldResult);
269                     }
270                     return newResult;
271                 });
272             }
273             /*
274              * this appends a post-process to any ongoing or completed discoveries with this device's address.
275              * If this discoveryFuture is ongoing then this post-process will run asynchronously upon the future's
276              * completion.
277              * If this discoveryFuture is already completed then this post-process will run in the current thread.
278              * We need to make sure that this is part of the future chain so that the call to 'thingRemoved'
279              * in the 'removeDiscoveries' method above can be sure that it is running after the 'thingDiscovered'
280              */
281             future = future.thenApply(result -> {
282                 publishDiscoveryResult(adapter, result);
283                 return result;
284             }).whenComplete((r, t) -> {
285                 if (t != null) {
286                     logger.warn("Error occured during discovery of {}", device.getAddress(), t);
287                 }
288             });
289
290             // now save this snapshot for later
291             discoveryFutures.put(adapter, new SnapshotFuture(snapshot, future));
292         }
293
294         private void publishDiscoveryResult(BluetoothAdapter adapter, DiscoveryResult result) {
295             Set<DiscoveryResult> results = new HashSet<>();
296             BiConsumer<BluetoothAdapter, DiscoveryResult> publisher = (a, r) -> {
297                 results.add(copyWithNewBridge(r, a));
298             };
299
300             publisher.accept(adapter, result);
301             for (BluetoothDiscoveryParticipant participant : participants) {
302                 participant.publishAdditionalResults(result, publisher);
303             }
304             results.forEach(BluetoothDiscoveryService.this::thingDiscovered);
305             discoveryResults.put(adapter, results);
306         }
307
308         /**
309          * Called when a new discovery is published and thus requires the old discovery to be removed first.
310          *
311          * @param adapter to get the results to be removed
312          * @param result unused
313          */
314         private void retractDiscoveryResult(BluetoothAdapter adapter, DiscoveryResult result) {
315             Set<DiscoveryResult> results = discoveryResults.remove(adapter);
316             if (results != null) {
317                 for (DiscoveryResult r : results) {
318                     thingRemoved(r.getThingUID());
319                 }
320             }
321         }
322
323         private CompletableFuture<DiscoveryResult> startDiscoveryProcess(BluetoothDeviceSnapshot device) {
324             return CompletableFuture.supplyAsync(new BluetoothDiscoveryProcess(device, participants, adapters),
325                     scheduler);
326         }
327     }
328
329     private static class SnapshotFuture {
330         public final BluetoothDeviceSnapshot snapshot;
331         public final CompletableFuture<DiscoveryResult> future;
332
333         public SnapshotFuture(BluetoothDeviceSnapshot snapshot, CompletableFuture<DiscoveryResult> future) {
334             this.snapshot = snapshot;
335             this.future = future;
336         }
337     }
338 }