]> git.basschouten.com Git - openhab-addons.git/blob
afe6ff502b6f8d5abe709e891a71453b0c14a3df
[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.webthing.internal.discovery;
14
15 import static org.openhab.binding.webthing.internal.WebThingBindingConstants.*;
16
17 import java.io.IOException;
18 import java.net.URI;
19 import java.time.Duration;
20 import java.time.Instant;
21 import java.util.Collections;
22 import java.util.HashMap;
23 import java.util.HashSet;
24 import java.util.List;
25 import java.util.Map;
26 import java.util.Optional;
27 import java.util.Set;
28 import java.util.concurrent.Callable;
29 import java.util.concurrent.CopyOnWriteArrayList;
30 import java.util.concurrent.ExecutionException;
31 import java.util.concurrent.Future;
32 import java.util.concurrent.TimeUnit;
33 import java.util.concurrent.TimeoutException;
34
35 import javax.jmdns.ServiceEvent;
36 import javax.jmdns.ServiceInfo;
37 import javax.jmdns.ServiceListener;
38
39 import org.eclipse.jdt.annotation.NonNullByDefault;
40 import org.eclipse.jdt.annotation.Nullable;
41 import org.openhab.binding.webthing.internal.client.DescriptionLoader;
42 import org.openhab.core.config.discovery.AbstractDiscoveryService;
43 import org.openhab.core.config.discovery.DiscoveryResult;
44 import org.openhab.core.config.discovery.DiscoveryResultBuilder;
45 import org.openhab.core.config.discovery.DiscoveryService;
46 import org.openhab.core.io.net.http.HttpClientFactory;
47 import org.openhab.core.io.transport.mdns.MDNSClient;
48 import org.openhab.core.scheduler.Scheduler;
49 import org.openhab.core.thing.ThingTypeUID;
50 import org.openhab.core.thing.ThingUID;
51 import org.osgi.service.component.annotations.Activate;
52 import org.osgi.service.component.annotations.Component;
53 import org.osgi.service.component.annotations.Deactivate;
54 import org.osgi.service.component.annotations.Reference;
55 import org.slf4j.Logger;
56 import org.slf4j.LoggerFactory;
57
58 /**
59  * WebThing discovery service based on mDNS. Refer https://iot.mozilla.org/wot/#web-thing-discovery
60  *
61  * @author Gregor Roth - Initial contribution
62  */
63 @NonNullByDefault
64 @Component(service = DiscoveryService.class, configurationPid = "webthingdiscovery.mdns")
65 public class WebthingDiscoveryService extends AbstractDiscoveryService implements ServiceListener {
66     private static final Duration FOREGROUND_SCAN_TIMEOUT = Duration.ofMillis(200);
67     public static final String ID = "id";
68     public static final String SCHEMAS = "schemas";
69     public static final String WEB_THING_URI = "webThingURI";
70     private final Logger logger = LoggerFactory.getLogger(WebthingDiscoveryService.class);
71     private final DescriptionLoader descriptionLoader;
72     private final MDNSClient mdnsClient;
73     private final List<Future<Set<DiscoveryResult>>> runningDiscoveryTasks = new CopyOnWriteArrayList<>();
74
75     /**
76      * constructor
77      *
78      * @param configProperties the config props
79      * @param mdnsClient the underlying mDNS client
80      */
81     @Activate
82     public WebthingDiscoveryService(@Nullable Map<String, Object> configProperties, @Reference MDNSClient mdnsClient,
83             @Reference Scheduler executor, @Reference HttpClientFactory httpClientFactory) {
84         super(30);
85         this.mdnsClient = mdnsClient;
86         this.descriptionLoader = new DescriptionLoader(httpClientFactory.getCommonHttpClient());
87         super.activate(configProperties);
88         if (isBackgroundDiscoveryEnabled()) {
89             mdnsClient.addServiceListener(MDNS_SERVICE_TYPE, this);
90         }
91     }
92
93     @Override
94     public Set<ThingTypeUID> getSupportedThingTypes() {
95         return Set.of(THING_TYPE_UID);
96     }
97
98     @Deactivate
99     @Override
100     protected void deactivate() {
101         super.deactivate();
102         mdnsClient.removeServiceListener(MDNS_SERVICE_TYPE, this);
103     }
104
105     @Override
106     public void serviceAdded(@NonNullByDefault({}) ServiceEvent serviceEvent) {
107         considerService(serviceEvent);
108     }
109
110     @Override
111     public void serviceResolved(@NonNullByDefault({}) ServiceEvent serviceEvent) {
112         considerService(serviceEvent);
113     }
114
115     @Override
116     public void serviceRemoved(@NonNullByDefault({}) ServiceEvent serviceEvent) {
117         for (var discoveryResult : discoverWebThing(serviceEvent.getInfo())) {
118             thingRemoved(discoveryResult.getThingUID());
119         }
120     }
121
122     @Override
123     protected void startBackgroundDiscovery() {
124         mdnsClient.addServiceListener(MDNS_SERVICE_TYPE, this);
125         startScan(true);
126     }
127
128     @Override
129     protected void stopBackgroundDiscovery() {
130         mdnsClient.removeServiceListener(MDNS_SERVICE_TYPE, this);
131     }
132
133     private void startScan(boolean isBackground) {
134         scheduler.submit(() -> scan(isBackground));
135     }
136
137     @Override
138     protected void startScan() {
139         startScan(false);
140     }
141
142     @Override
143     protected synchronized void stopScan() {
144         removeOlderResults(Instant.now().minus(Duration.ofMinutes(10)).toEpochMilli());
145
146         // stop running discovery tasks
147         for (var future : runningDiscoveryTasks) {
148             future.cancel(true);
149             runningDiscoveryTasks.remove(future);
150         }
151         super.stopScan();
152     }
153
154     /**
155      * scans the network via mDNS
156      *
157      * @param isBackground true, if is background task
158      */
159     private void scan(boolean isBackground) {
160         var serviceInfos = isBackground ? mdnsClient.list(MDNS_SERVICE_TYPE)
161                 : mdnsClient.list(MDNS_SERVICE_TYPE, FOREGROUND_SCAN_TIMEOUT);
162         logger.debug("got {} mDNS entries", serviceInfos.length);
163
164         // create discovery task for each detected service and process these in parallel to increase total
165         // discovery speed
166         for (var serviceInfo : serviceInfos) {
167             var future = scheduler.submit(new DiscoveryTask(serviceInfo));
168             runningDiscoveryTasks.add(future);
169         }
170
171         // wait until all tasks are completed
172         for (var future : runningDiscoveryTasks) {
173             try {
174                 future.get(5, TimeUnit.MINUTES);
175             } catch (InterruptedException | ExecutionException | TimeoutException e) {
176                 logger.warn("discovering task {} terminated", future);
177             }
178             runningDiscoveryTasks.remove(future);
179         }
180     }
181
182     private class DiscoveryTask implements Callable<Set<DiscoveryResult>> {
183         private final ServiceInfo serviceInfo;
184
185         DiscoveryTask(ServiceInfo serviceInfo) {
186             this.serviceInfo = serviceInfo;
187         }
188
189         @Override
190         public Set<DiscoveryResult> call() {
191             var results = new HashSet<DiscoveryResult>();
192             for (var discoveryResult : discoverWebThing(serviceInfo)) {
193                 results.add(discoveryResult);
194                 thingDiscovered(discoveryResult);
195                 logger.debug("WebThing '{}' (uri: {}, id: {}, schemas: {}) discovered", discoveryResult.getLabel(),
196                         discoveryResult.getProperties().get(WEB_THING_URI), discoveryResult.getProperties().get(ID),
197                         discoveryResult.getProperties().get(SCHEMAS));
198             }
199             return results;
200         }
201
202         @Override
203         public String toString() {
204             return "DiscoveryTask{" + "serviceInfo=" + serviceInfo + '}';
205         }
206     }
207
208     /**
209      * convert the serviceInfo result of the mDNS scan to discovery results
210      *
211      * @param serviceInfo the service info
212      * @return the associated discovery result
213      */
214     private Set<DiscoveryResult> discoverWebThing(ServiceInfo serviceInfo) {
215         var discoveryResults = new HashSet<DiscoveryResult>();
216
217         if (serviceInfo.getHostAddresses().length > 0) {
218             var host = serviceInfo.getHostAddresses()[0];
219             var port = serviceInfo.getPort();
220             var path = "/";
221             if (Collections.list(serviceInfo.getPropertyNames()).contains("path")) {
222                 path = serviceInfo.getPropertyString("path");
223                 if (!path.endsWith("/")) {
224                     path = path + "/";
225                 }
226             }
227
228             // There are two kinds of WebThing endpoints: Endpoints supporting a single WebThing as well as
229             // endpoints supporting multiple WebThings.
230             //
231             // In the routine below the enpoint will be checked for single WebThings first, than for multiple
232             // WebThings if a single WebThing has not been found.
233             // Furthermore, first it will be tried to connect the endpoint using https. If this fails, as fallback
234             // plain http is used.
235
236             // check single WebThing path via https (e.g. https://192.168.0.23:8433/)
237             var optionalDiscoveryResult = discoverWebThing(toURI(host, port, path, true));
238             if (optionalDiscoveryResult.isPresent()) {
239                 discoveryResults.add(optionalDiscoveryResult.get());
240             } else {
241                 // check single WebThing path via plain http (e.g. http://192.168.0.23:8433/)
242                 optionalDiscoveryResult = discoverWebThing(toURI(host, port, path, false));
243                 if (optionalDiscoveryResult.isPresent()) {
244                     discoveryResults.add(optionalDiscoveryResult.get());
245                 } else {
246                     // check multiple WebThing path via https (e.g. https://192.168.0.23:8433/0,
247                     // https://192.168.0.23:8433/1,...)
248                     outer: for (int i = 0; i < 50; i++) { // search 50 entries at maximum
249                         optionalDiscoveryResult = discoverWebThing(toURI(host, port, path + i + "/", true));
250                         if (optionalDiscoveryResult.isPresent()) {
251                             discoveryResults.add(optionalDiscoveryResult.get());
252                         } else if (i == 0) {
253                             // check multiple WebThing path via plain http (e.g. http://192.168.0.23:8433/0,
254                             // http://192.168.0.23:8433/1,...)
255                             for (int j = 0; j < 50; j++) { // search 50 entries at maximum
256                                 optionalDiscoveryResult = discoverWebThing(toURI(host, port, path + j + "/", false));
257                                 if (optionalDiscoveryResult.isPresent()) {
258                                     discoveryResults.add(optionalDiscoveryResult.get());
259                                 } else {
260                                     break outer;
261                                 }
262                             }
263                         } else {
264                             break;
265                         }
266                     }
267                 }
268             }
269         }
270
271         return discoveryResults;
272     }
273
274     private Optional<DiscoveryResult> discoverWebThing(URI uri) {
275         try {
276             var description = descriptionLoader.loadWebthingDescription(uri, Duration.ofSeconds(5));
277
278             var id = uri.getHost().replaceAll("\\W", "_") + "_" + uri.getPort();
279             if (uri.getPath().length() > 1) {
280                 id = id + "_" + uri.getPath().replaceAll("\\W", "");
281             }
282
283             var thingUID = new ThingUID(THING_TYPE_UID, id);
284             Map<String, Object> properties = new HashMap<>(2);
285             properties.put(ID, id);
286             properties.put(SCHEMAS, description.contextKeyword);
287             return Optional.of(DiscoveryResultBuilder.create(thingUID).withThingType(THING_TYPE_UID)
288                     .withProperty(WEB_THING_URI, uri).withLabel(description.title).withProperties(properties)
289                     .withRepresentationProperty(ID).build());
290         } catch (IOException ioe) {
291             return Optional.empty();
292         }
293     }
294
295     private URI toURI(String host, int port, String path, boolean isHttps) {
296         return isHttps ? URI.create("https://" + host + ":" + port + path)
297                 : URI.create("http://" + host + ":" + port + path);
298     }
299
300     private void considerService(ServiceEvent serviceEvent) {
301         if (isBackgroundDiscoveryEnabled()) {
302             for (var discoveryResult : discoverWebThing(serviceEvent.getInfo())) {
303                 thingDiscovered(discoveryResult);
304             }
305         }
306     }
307 }