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