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.webthing.internal.discovery;
15 import static org.openhab.binding.webthing.internal.WebThingBindingConstants.*;
17 import java.io.IOException;
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;
26 import java.util.Optional;
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;
35 import javax.jmdns.ServiceEvent;
36 import javax.jmdns.ServiceInfo;
37 import javax.jmdns.ServiceListener;
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;
59 * WebThing discovery service based on mDNS. Refer https://iot.mozilla.org/wot/#web-thing-discovery
61 * @author Gregor Roth - Initial contribution
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<>();
78 * @param configProperties the config props
79 * @param mdnsClient the underlying mDNS client
82 public WebthingDiscoveryService(@Nullable Map<String, Object> configProperties, @Reference MDNSClient mdnsClient,
83 @Reference Scheduler executor, @Reference HttpClientFactory httpClientFactory) {
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);
94 public Set<ThingTypeUID> getSupportedThingTypes() {
95 return Set.of(THING_TYPE_UID);
100 protected void deactivate() {
102 mdnsClient.removeServiceListener(MDNS_SERVICE_TYPE, this);
106 public void serviceAdded(@NonNullByDefault({}) ServiceEvent serviceEvent) {
107 considerService(serviceEvent);
111 public void serviceResolved(@NonNullByDefault({}) ServiceEvent serviceEvent) {
112 considerService(serviceEvent);
116 public void serviceRemoved(@NonNullByDefault({}) ServiceEvent serviceEvent) {
117 for (var discoveryResult : discoverWebThing(serviceEvent.getInfo())) {
118 thingRemoved(discoveryResult.getThingUID());
123 protected void startBackgroundDiscovery() {
124 mdnsClient.addServiceListener(MDNS_SERVICE_TYPE, this);
129 protected void stopBackgroundDiscovery() {
130 mdnsClient.removeServiceListener(MDNS_SERVICE_TYPE, this);
133 private void startScan(boolean isBackground) {
134 scheduler.submit(() -> scan(isBackground));
138 protected void startScan() {
143 protected synchronized void stopScan() {
144 removeOlderResults(Instant.now().minus(Duration.ofMinutes(10)).toEpochMilli());
146 // stop running discovery tasks
147 for (var future : runningDiscoveryTasks) {
149 runningDiscoveryTasks.remove(future);
155 * scans the network via mDNS
157 * @param isBackground true, if is background task
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);
164 // create discovery task for each detected service and process these in parallel to increase total
166 for (var serviceInfo : serviceInfos) {
167 var future = scheduler.submit(new DiscoveryTask(serviceInfo));
168 runningDiscoveryTasks.add(future);
171 // wait until all tasks are completed
172 for (var future : runningDiscoveryTasks) {
174 future.get(5, TimeUnit.MINUTES);
175 } catch (InterruptedException | ExecutionException | TimeoutException e) {
176 logger.warn("discovering task {} terminated", future);
178 runningDiscoveryTasks.remove(future);
182 private class DiscoveryTask implements Callable<Set<DiscoveryResult>> {
183 private final ServiceInfo serviceInfo;
185 DiscoveryTask(ServiceInfo serviceInfo) {
186 this.serviceInfo = serviceInfo;
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));
203 public String toString() {
204 return "DiscoveryTask{" + "serviceInfo=" + serviceInfo + '}';
209 * convert the serviceInfo result of the mDNS scan to discovery results
211 * @param serviceInfo the service info
212 * @return the associated discovery result
214 private Set<DiscoveryResult> discoverWebThing(ServiceInfo serviceInfo) {
215 var discoveryResults = new HashSet<DiscoveryResult>();
217 if (serviceInfo.getHostAddresses().length > 0) {
218 var host = serviceInfo.getHostAddresses()[0];
219 var port = serviceInfo.getPort();
221 if (Collections.list(serviceInfo.getPropertyNames()).contains("path")) {
222 path = serviceInfo.getPropertyString("path");
223 if (!path.endsWith("/")) {
228 // There are two kinds of WebThing endpoints: Endpoints supporting a single WebThing as well as
229 // endpoints supporting multiple WebThings.
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.
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());
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());
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());
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());
271 return discoveryResults;
274 private Optional<DiscoveryResult> discoverWebThing(URI uri) {
276 var description = descriptionLoader.loadWebthingDescription(uri, Duration.ofSeconds(5));
278 var id = uri.getHost().replaceAll("\\W", "_") + "_" + uri.getPort();
279 if (uri.getPath().length() > 1) {
280 id = id + "_" + uri.getPath().replaceAll("\\W", "");
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();
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);
300 private void considerService(ServiceEvent serviceEvent) {
301 if (isBackgroundDiscoveryEnabled()) {
302 for (var discoveryResult : discoverWebThing(serviceEvent.getInfo())) {
303 thingDiscovered(discoveryResult);