diff --git a/bom/openhab-core/pom.xml b/bom/openhab-core/pom.xml
index e8f72da93..4a96fcf9a 100644
--- a/bom/openhab-core/pom.xml
+++ b/bom/openhab-core/pom.xml
@@ -304,6 +304,24 @@
${project.version}
compile
+
+ org.openhab.core.bundles
+ org.openhab.core.config.discovery.addon
+ ${project.version}
+ compile
+
+
+ org.openhab.core.bundles
+ org.openhab.core.config.discovery.addon.mdns
+ ${project.version}
+ compile
+
+
+ org.openhab.core.bundles
+ org.openhab.core.config.discovery.addon.upnp
+ ${project.version}
+ compile
+
org.openhab.core.bundles
org.openhab.core.config.discovery.mdns
diff --git a/bundles/org.openhab.core.config.discovery.addon.mdns/.classpath b/bundles/org.openhab.core.config.discovery.addon.mdns/.classpath
new file mode 100644
index 000000000..d3d6b3c11
--- /dev/null
+++ b/bundles/org.openhab.core.config.discovery.addon.mdns/.classpath
@@ -0,0 +1,29 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/bundles/org.openhab.core.config.discovery.addon.mdns/.project b/bundles/org.openhab.core.config.discovery.addon.mdns/.project
new file mode 100644
index 000000000..00932f9e0
--- /dev/null
+++ b/bundles/org.openhab.core.config.discovery.addon.mdns/.project
@@ -0,0 +1,23 @@
+
+
+ org.openhab.core.config.discovery.addon.mdns
+
+
+
+
+
+ org.eclipse.jdt.core.javabuilder
+
+
+
+
+ org.eclipse.m2e.core.maven2Builder
+
+
+
+
+
+ org.eclipse.m2e.core.maven2Nature
+ org.eclipse.jdt.core.javanature
+
+
diff --git a/bundles/org.openhab.core.config.discovery.addon.mdns/NOTICE b/bundles/org.openhab.core.config.discovery.addon.mdns/NOTICE
new file mode 100644
index 000000000..6c17d0d8a
--- /dev/null
+++ b/bundles/org.openhab.core.config.discovery.addon.mdns/NOTICE
@@ -0,0 +1,14 @@
+This content is produced and maintained by the openHAB project.
+
+* Project home: https://www.openhab.org
+
+== Declared Project Licenses
+
+This program and the accompanying materials are made available under the terms
+of the Eclipse Public License 2.0 which is available at
+https://www.eclipse.org/legal/epl-2.0/.
+
+== Source Code
+
+https://github.com/openhab/openhab-core
+
diff --git a/bundles/org.openhab.core.config.discovery.addon.mdns/pom.xml b/bundles/org.openhab.core.config.discovery.addon.mdns/pom.xml
new file mode 100644
index 000000000..3afd5faef
--- /dev/null
+++ b/bundles/org.openhab.core.config.discovery.addon.mdns/pom.xml
@@ -0,0 +1,34 @@
+
+
+
+ 4.0.0
+
+
+ org.openhab.core.bundles
+ org.openhab.core.reactor.bundles
+ 4.1.0-SNAPSHOT
+
+
+ org.openhab.core.config.discovery.addon.mdns
+
+ openHAB Core :: Bundles :: mDNS Suggested Add-on Finder
+
+
+
+ org.openhab.core.bundles
+ org.openhab.core.config.discovery.mdns
+ ${project.version}
+
+
+ org.openhab.core.bundles
+ org.openhab.core.config.discovery.addon
+ ${project.version}
+
+
+ org.openhab.core.bundles
+ org.openhab.core.addon
+ ${project.version}
+
+
+
diff --git a/bundles/org.openhab.core.config.discovery.addon.mdns/src/main/java/org/openhab/core/config/discovery/addon/mdns/MDNSAddonFinder.java b/bundles/org.openhab.core.config.discovery.addon.mdns/src/main/java/org/openhab/core/config/discovery/addon/mdns/MDNSAddonFinder.java
new file mode 100644
index 000000000..6af2d6466
--- /dev/null
+++ b/bundles/org.openhab.core.config.discovery.addon.mdns/src/main/java/org/openhab/core/config/discovery/addon/mdns/MDNSAddonFinder.java
@@ -0,0 +1,183 @@
+/**
+ * Copyright (c) 2010-2023 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.core.config.discovery.addon.mdns;
+
+import static org.openhab.core.config.discovery.addon.AddonFinderConstants.SERVICE_NAME_MDNS;
+import static org.openhab.core.config.discovery.addon.AddonFinderConstants.SERVICE_TYPE_MDNS;
+
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.regex.Pattern;
+import java.util.stream.Collectors;
+
+import javax.jmdns.ServiceEvent;
+import javax.jmdns.ServiceInfo;
+import javax.jmdns.ServiceListener;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.openhab.core.addon.AddonDiscoveryMethod;
+import org.openhab.core.addon.AddonInfo;
+import org.openhab.core.common.ThreadPoolManager;
+import org.openhab.core.config.discovery.addon.AddonFinder;
+import org.openhab.core.config.discovery.addon.BaseAddonFinder;
+import org.openhab.core.io.transport.mdns.MDNSClient;
+import org.osgi.service.component.annotations.Activate;
+import org.osgi.service.component.annotations.Component;
+import org.osgi.service.component.annotations.Deactivate;
+import org.osgi.service.component.annotations.Reference;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * This is a {@link MDNSAddonFinder} for finding suggested add-ons via mDNS.
+ *
+ * @author Andrew Fiddian-Green - Initial contribution
+ * @author Mark Herwege - refactor to allow uninstall
+ */
+@NonNullByDefault
+@Component(service = AddonFinder.class, name = MDNSAddonFinder.SERVICE_NAME)
+public class MDNSAddonFinder extends BaseAddonFinder implements ServiceListener {
+
+ public static final String SERVICE_TYPE = SERVICE_TYPE_MDNS;
+ public static final String SERVICE_NAME = SERVICE_NAME_MDNS;
+
+ private static final String NAME = "name";
+ private static final String APPLICATION = "application";
+
+ private final Logger logger = LoggerFactory.getLogger(MDNSAddonFinder.class);
+ private final ScheduledExecutorService scheduler = ThreadPoolManager.getScheduledPool(SERVICE_NAME);
+ private final Map services = new ConcurrentHashMap<>();
+ private MDNSClient mdnsClient;
+
+ @Activate
+ public MDNSAddonFinder(@Reference MDNSClient mdnsClient) {
+ this.mdnsClient = mdnsClient;
+ }
+
+ /**
+ * Adds the given mDNS service to the set of discovered services.
+ *
+ * @param device the mDNS service to be added.
+ */
+ public void addService(ServiceInfo service, boolean isResolved) {
+ String qualifiedName = service.getQualifiedName();
+ if (isResolved || !services.containsKey(qualifiedName)) {
+ if (services.put(qualifiedName, service) == null) {
+ logger.trace("Added service: {}", qualifiedName);
+ }
+ }
+ }
+
+ @Deactivate
+ public void deactivate() {
+ services.clear();
+ unsetAddonCandidates();
+ }
+
+ @Override
+ public void setAddonCandidates(List candidates) {
+ // Remove listeners for all service types that are no longer in candidates
+ addonCandidates.stream().filter(c -> !candidates.contains(c))
+ .forEach(c -> c.getDiscoveryMethods().stream().filter(m -> SERVICE_TYPE.equals(m.getServiceType()))
+ .filter(m -> !m.getMdnsServiceType().isEmpty())
+ .forEach(m -> mdnsClient.removeServiceListener(m.getMdnsServiceType(), this)));
+
+ // Add listeners for all service types in candidates
+ super.setAddonCandidates(candidates);
+ addonCandidates
+ .forEach(c -> c.getDiscoveryMethods().stream().filter(m -> SERVICE_TYPE.equals(m.getServiceType()))
+ .filter(m -> !m.getMdnsServiceType().isEmpty()).forEach(m -> {
+ String serviceType = m.getMdnsServiceType();
+ mdnsClient.addServiceListener(serviceType, this);
+ scheduler.submit(() -> mdnsClient.list(serviceType));
+ }));
+ }
+
+ @Override
+ public void unsetAddonCandidates() {
+ addonCandidates.forEach(c -> c.getDiscoveryMethods().stream()
+ .filter(m -> SERVICE_TYPE.equals(m.getServiceType())).filter(m -> !m.getMdnsServiceType().isEmpty())
+ .forEach(m -> mdnsClient.removeServiceListener(m.getMdnsServiceType(), this)));
+ super.unsetAddonCandidates();
+ }
+
+ @Override
+ public Set getSuggestedAddons() {
+ Set result = new HashSet<>();
+ for (AddonInfo candidate : addonCandidates) {
+ for (AddonDiscoveryMethod method : candidate.getDiscoveryMethods().stream()
+ .filter(method -> SERVICE_TYPE.equals(method.getServiceType())).toList()) {
+ Map matchProperties = method.getMatchProperties().stream()
+ .collect(Collectors.toMap(property -> property.getName(), property -> property.getPattern()));
+
+ Set matchPropertyKeys = matchProperties.keySet().stream()
+ .filter(property -> (!NAME.equals(property) && !APPLICATION.equals(property)))
+ .collect(Collectors.toSet());
+
+ logger.trace("Checking candidate: {}", candidate.getUID());
+ for (ServiceInfo service : services.values()) {
+
+ logger.trace("Checking service: {}/{}", service.getQualifiedName(), service.getNiceTextString());
+ if (method.getMdnsServiceType().equals(service.getType())
+ && propertyMatches(matchProperties, NAME, service.getName())
+ && propertyMatches(matchProperties, APPLICATION, service.getApplication())
+ && matchPropertyKeys.stream().allMatch(
+ name -> propertyMatches(matchProperties, name, service.getPropertyString(name)))) {
+ result.add(candidate);
+ logger.debug("Suggested add-on found: {}", candidate.getUID());
+ break;
+ }
+ }
+ }
+ }
+ return result;
+ }
+
+ @Override
+ public String getServiceName() {
+ return SERVICE_NAME;
+ }
+
+ /*
+ * ************ MDNSClient call-back methods ************
+ */
+
+ @Override
+ public void serviceAdded(@Nullable ServiceEvent event) {
+ if (event != null) {
+ ServiceInfo service = event.getInfo();
+ if (service != null) {
+ addService(service, false);
+ }
+ }
+ }
+
+ @Override
+ public void serviceRemoved(@Nullable ServiceEvent event) {
+ }
+
+ @Override
+ public void serviceResolved(@Nullable ServiceEvent event) {
+ if (event != null) {
+ ServiceInfo service = event.getInfo();
+ if (service != null) {
+ addService(service, true);
+ }
+ }
+ }
+}
diff --git a/bundles/org.openhab.core.config.discovery.addon.mdns/src/test/java/org/openhab/core/config/discovery/addon/mdns/tests/MDNSAddonFinderTests.java b/bundles/org.openhab.core.config.discovery.addon.mdns/src/test/java/org/openhab/core/config/discovery/addon/mdns/tests/MDNSAddonFinderTests.java
new file mode 100644
index 000000000..d202dea4d
--- /dev/null
+++ b/bundles/org.openhab.core.config.discovery.addon.mdns/src/test/java/org/openhab/core/config/discovery/addon/mdns/tests/MDNSAddonFinderTests.java
@@ -0,0 +1,125 @@
+/**
+ * Copyright (c) 2010-2023 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.core.config.discovery.addon.mdns.tests;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.*;
+import static org.mockito.Mockito.*;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+import javax.jmdns.ServiceInfo;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.TestInstance;
+import org.junit.jupiter.api.TestInstance.Lifecycle;
+import org.mockito.Mockito;
+import org.openhab.core.addon.AddonDiscoveryMethod;
+import org.openhab.core.addon.AddonInfo;
+import org.openhab.core.addon.AddonMatchProperty;
+import org.openhab.core.config.discovery.addon.AddonFinder;
+import org.openhab.core.config.discovery.addon.AddonFinderConstants;
+import org.openhab.core.config.discovery.addon.AddonSuggestionService;
+import org.openhab.core.config.discovery.addon.mdns.MDNSAddonFinder;
+import org.openhab.core.io.transport.mdns.MDNSClient;
+
+/**
+ * JUnit tests for the {@link AddonSuggestionService}.
+ *
+ * @author Andrew Fiddian-Green - Initial contribution
+ * @author Mark Herwege - Adapted to finders in separate packages
+ */
+@NonNullByDefault
+@TestInstance(Lifecycle.PER_CLASS)
+public class MDNSAddonFinderTests {
+
+ private @NonNullByDefault({}) MDNSClient mdnsClient;
+ private @NonNullByDefault({}) AddonFinder addonFinder;
+ private List addonInfos = new ArrayList<>();
+
+ @BeforeAll
+ public void setup() {
+ setupMockMdnsClient();
+ setupAddonInfos();
+ createAddonFinder();
+ }
+
+ private void createAddonFinder() {
+ MDNSAddonFinder mdnsAddonFinder = new MDNSAddonFinder(mdnsClient);
+ assertNotNull(mdnsAddonFinder);
+
+ for (ServiceInfo service : mdnsClient.list("_hue._tcp.local.")) {
+ mdnsAddonFinder.addService(service, true);
+ }
+ for (ServiceInfo service : mdnsClient.list("_printer._tcp.local.")) {
+ mdnsAddonFinder.addService(service, true);
+ }
+
+ addonFinder = mdnsAddonFinder;
+ }
+
+ private void setupMockMdnsClient() {
+ // create the mock
+ mdnsClient = mock(MDNSClient.class, Mockito.RETURNS_DEEP_STUBS);
+ when(mdnsClient.list(anyString())).thenReturn(new ServiceInfo[] {});
+ ServiceInfo hueService = ServiceInfo.create("hue", "hue", 0, 0, 0, false, "hue service");
+ when(mdnsClient.list(eq("_hue._tcp.local."))).thenReturn(new ServiceInfo[] { hueService });
+ ServiceInfo hpService = ServiceInfo.create("printer", "hpprinter", 0, 0, 0, false, "hp printer service");
+ hpService.setText(Map.of("ty", "hp printer", "rp", "anything"));
+ when(mdnsClient.list(eq("_printer._tcp.local."))).thenReturn(new ServiceInfo[] { hpService });
+
+ // check that it works
+ assertNotNull(mdnsClient);
+ ServiceInfo[] result;
+ result = mdnsClient.list("_printer._tcp.local.");
+ assertEquals(1, result.length);
+ assertEquals("hpprinter", result[0].getName());
+ assertEquals(2, Collections.list(result[0].getPropertyNames()).size());
+ assertEquals("hp printer", result[0].getPropertyString("ty"));
+ result = mdnsClient.list("_hue._tcp.local.");
+ assertEquals(1, result.length);
+ assertEquals("hue", result[0].getName());
+ result = mdnsClient.list("aardvark");
+ assertEquals(0, result.length);
+ }
+
+ private void setupAddonInfos() {
+ AddonDiscoveryMethod hp = new AddonDiscoveryMethod().setServiceType(AddonFinderConstants.SERVICE_TYPE_MDNS)
+ .setMatchProperties(
+ List.of(new AddonMatchProperty("rp", ".*"), new AddonMatchProperty("ty", "hp (.*)")))
+ .setMdnsServiceType("_printer._tcp.local.");
+ addonInfos.add(AddonInfo.builder("hpprinter", "binding").withName("HP").withDescription("HP Printer")
+ .withDiscoveryMethods(List.of(hp)).build());
+
+ AddonDiscoveryMethod hue = new AddonDiscoveryMethod().setServiceType(AddonFinderConstants.SERVICE_TYPE_MDNS)
+ .setMdnsServiceType("_hue._tcp.local.");
+ addonInfos.add(AddonInfo.builder("hue", "binding").withName("Hue").withDescription("Hue Bridge")
+ .withDiscoveryMethods(List.of(hue)).build());
+ }
+
+ @Test
+ public void testGetSuggestedAddons() {
+ addonFinder.setAddonCandidates(addonInfos);
+ Set addons = addonFinder.getSuggestedAddons();
+ assertEquals(2, addons.size());
+ assertFalse(addons.stream().anyMatch(a -> "aardvark".equals(a.getUID())));
+ assertTrue(addons.stream().anyMatch(a -> "binding-hue".equals(a.getUID())));
+ assertTrue(addons.stream().anyMatch(a -> "binding-hpprinter".equals(a.getUID())));
+ }
+}
diff --git a/bundles/org.openhab.core.config.discovery.addon.upnp/.classpath b/bundles/org.openhab.core.config.discovery.addon.upnp/.classpath
new file mode 100644
index 000000000..d3d6b3c11
--- /dev/null
+++ b/bundles/org.openhab.core.config.discovery.addon.upnp/.classpath
@@ -0,0 +1,29 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/bundles/org.openhab.core.config.discovery.addon.upnp/.project b/bundles/org.openhab.core.config.discovery.addon.upnp/.project
new file mode 100644
index 000000000..f86bba378
--- /dev/null
+++ b/bundles/org.openhab.core.config.discovery.addon.upnp/.project
@@ -0,0 +1,23 @@
+
+
+ org.openhab.core.config.discovery.addon.upnp
+
+
+
+
+
+ org.eclipse.jdt.core.javabuilder
+
+
+
+
+ org.eclipse.m2e.core.maven2Builder
+
+
+
+
+
+ org.eclipse.m2e.core.maven2Nature
+ org.eclipse.jdt.core.javanature
+
+
diff --git a/bundles/org.openhab.core.config.discovery.addon.upnp/NOTICE b/bundles/org.openhab.core.config.discovery.addon.upnp/NOTICE
new file mode 100644
index 000000000..6c17d0d8a
--- /dev/null
+++ b/bundles/org.openhab.core.config.discovery.addon.upnp/NOTICE
@@ -0,0 +1,14 @@
+This content is produced and maintained by the openHAB project.
+
+* Project home: https://www.openhab.org
+
+== Declared Project Licenses
+
+This program and the accompanying materials are made available under the terms
+of the Eclipse Public License 2.0 which is available at
+https://www.eclipse.org/legal/epl-2.0/.
+
+== Source Code
+
+https://github.com/openhab/openhab-core
+
diff --git a/bundles/org.openhab.core.config.discovery.addon.upnp/pom.xml b/bundles/org.openhab.core.config.discovery.addon.upnp/pom.xml
new file mode 100644
index 000000000..47f50aa76
--- /dev/null
+++ b/bundles/org.openhab.core.config.discovery.addon.upnp/pom.xml
@@ -0,0 +1,29 @@
+
+
+
+ 4.0.0
+
+
+ org.openhab.core.bundles
+ org.openhab.core.reactor.bundles
+ 4.1.0-SNAPSHOT
+
+
+ org.openhab.core.config.discovery.addon.upnp
+
+ openHAB Core :: Bundles :: uPnP Suggested Add-on Finder
+
+
+
+ org.openhab.core.bundles
+ org.openhab.core.config.discovery.addon
+ ${project.version}
+
+
+ org.openhab.core.bundles
+ org.openhab.core.addon
+ ${project.version}
+
+
+
diff --git a/bundles/org.openhab.core.config.discovery.addon.upnp/src/main/java/org/openhab/core/config/discovery/addon/upnp/UpnpAddonFinder.java b/bundles/org.openhab.core.config.discovery.addon.upnp/src/main/java/org/openhab/core/config/discovery/addon/upnp/UpnpAddonFinder.java
new file mode 100644
index 000000000..034ac431c
--- /dev/null
+++ b/bundles/org.openhab.core.config.discovery.addon.upnp/src/main/java/org/openhab/core/config/discovery/addon/upnp/UpnpAddonFinder.java
@@ -0,0 +1,247 @@
+/**
+ * Copyright (c) 2010-2023 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.core.config.discovery.addon.upnp;
+
+import static org.openhab.core.config.discovery.addon.AddonFinderConstants.*;
+
+import java.net.URI;
+import java.util.HashSet;
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.regex.Pattern;
+import java.util.stream.Collectors;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.jupnp.UpnpService;
+import org.jupnp.model.meta.DeviceDetails;
+import org.jupnp.model.meta.LocalDevice;
+import org.jupnp.model.meta.ManufacturerDetails;
+import org.jupnp.model.meta.ModelDetails;
+import org.jupnp.model.meta.RemoteDevice;
+import org.jupnp.model.meta.RemoteDeviceIdentity;
+import org.jupnp.model.types.DeviceType;
+import org.jupnp.model.types.UDN;
+import org.jupnp.registry.Registry;
+import org.jupnp.registry.RegistryListener;
+import org.openhab.core.addon.AddonDiscoveryMethod;
+import org.openhab.core.addon.AddonInfo;
+import org.openhab.core.config.discovery.addon.AddonFinder;
+import org.openhab.core.config.discovery.addon.BaseAddonFinder;
+import org.osgi.service.component.annotations.Activate;
+import org.osgi.service.component.annotations.Component;
+import org.osgi.service.component.annotations.Deactivate;
+import org.osgi.service.component.annotations.Reference;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * This is a {@link UpnpAddonFinder} for finding suggested Addons via UPnP.
+ *
+ * @author Andrew Fiddian-Green - Initial contribution
+ * @author Mark Herwege - refactor to allow uninstall
+ */
+@NonNullByDefault
+@Component(service = AddonFinder.class, name = UpnpAddonFinder.SERVICE_NAME)
+public class UpnpAddonFinder extends BaseAddonFinder implements RegistryListener {
+
+ public static final String SERVICE_TYPE = SERVICE_TYPE_UPNP;
+ public static final String SERVICE_NAME = SERVICE_NAME_UPNP;
+
+ private static final String DEVICE_TYPE = "deviceType";
+ private static final String MANUFACTURER = "manufacturer";
+ private static final String MANUFACTURER_URI = "manufacturerURI";
+ private static final String MODEL_NAME = "modelName";
+ private static final String MODEL_NUMBER = "modelNumber";
+ private static final String MODEL_DESCRIPTION = "modelDescription";
+ private static final String MODEL_URI = "modelURI";
+ private static final String SERIAL_NUMBER = "serialNumber";
+ private static final String FRIENDLY_NAME = "friendlyName";
+
+ private static final Set SUPPORTED_PROPERTIES = Set.of(DEVICE_TYPE, MANUFACTURER, MANUFACTURER_URI,
+ MODEL_NAME, MODEL_NUMBER, MODEL_DESCRIPTION, MODEL_URI, SERIAL_NUMBER, FRIENDLY_NAME);
+
+ private final Logger logger = LoggerFactory.getLogger(UpnpAddonFinder.class);
+ private final Map devices = new ConcurrentHashMap<>();
+ private UpnpService upnpService;
+
+ @Activate
+ public UpnpAddonFinder(@Reference UpnpService upnpService) {
+ this.upnpService = upnpService;
+
+ Registry registry = upnpService.getRegistry();
+ for (RemoteDevice device : registry.getRemoteDevices()) {
+ remoteDeviceAdded(registry, device);
+ }
+ registry.addListener(this);
+ }
+
+ @Deactivate
+ public void deactivate() {
+ unsetAddonCandidates();
+
+ UpnpService upnpService = this.upnpService;
+ upnpService.getRegistry().removeListener(this);
+
+ devices.clear();
+ }
+
+ /**
+ * Adds the given UPnP remote device to the set of discovered devices.
+ *
+ * @param device the UPnP remote device to be added.
+ */
+ private void addDevice(RemoteDevice device) {
+ RemoteDeviceIdentity identity = device.getIdentity();
+ if (identity != null) {
+ UDN udn = identity.getUdn();
+ if (udn != null) {
+ String udnString = udn.getIdentifierString();
+ if (devices.put(udnString, device) == null) {
+ logger.trace("Added device: {}", device.getDisplayString());
+ }
+ }
+ }
+ }
+
+ @Override
+ public Set getSuggestedAddons() {
+ Set result = new HashSet<>();
+ for (AddonInfo candidate : addonCandidates) {
+ for (AddonDiscoveryMethod method : candidate.getDiscoveryMethods().stream()
+ .filter(method -> SERVICE_TYPE.equals(method.getServiceType())).toList()) {
+ Map matchProperties = method.getMatchProperties().stream()
+ .collect(Collectors.toMap(property -> property.getName(), property -> property.getPattern()));
+
+ Set propertyNames = new HashSet<>(matchProperties.keySet());
+ propertyNames.removeAll(SUPPORTED_PROPERTIES);
+
+ if (!propertyNames.isEmpty()) {
+ logger.warn("Add-on '{}' addon.xml file contains unsupported 'match-property' [{}]",
+ candidate.getUID(), String.join(",", propertyNames));
+ break;
+ }
+
+ logger.trace("Checking candidate: {}", candidate.getUID());
+ for (RemoteDevice device : devices.values()) {
+
+ String deviceType = null;
+ String serialNumber = null;
+ String friendlyName = null;
+ String manufacturer = null;
+ String manufacturerURI = null;
+ String modelName = null;
+ String modelNumber = null;
+ String modelDescription = null;
+ String modelURI = null;
+
+ DeviceType devType = device.getType();
+ if (devType != null) {
+ deviceType = devType.getType();
+ }
+
+ DeviceDetails devDetails = device.getDetails();
+ if (devDetails != null) {
+ friendlyName = devDetails.getFriendlyName();
+ serialNumber = devDetails.getSerialNumber();
+
+ ManufacturerDetails mfrDetails = devDetails.getManufacturerDetails();
+ if (mfrDetails != null) {
+ URI mfrUri = mfrDetails.getManufacturerURI();
+ manufacturer = mfrDetails.getManufacturer();
+ manufacturerURI = mfrUri != null ? mfrUri.toString() : null;
+ }
+
+ ModelDetails modDetails = devDetails.getModelDetails();
+ if (modDetails != null) {
+ URI modUri = modDetails.getModelURI();
+ modelName = modDetails.getModelName();
+ modelDescription = modDetails.getModelDescription();
+ modelNumber = modDetails.getModelNumber();
+ modelURI = modUri != null ? modUri.toString() : null;
+ }
+ }
+
+ logger.trace("Checking device: {}", device.getDisplayString());
+ if (propertyMatches(matchProperties, DEVICE_TYPE, deviceType)
+ && propertyMatches(matchProperties, MANUFACTURER, manufacturer)
+ && propertyMatches(matchProperties, MANUFACTURER_URI, manufacturerURI)
+ && propertyMatches(matchProperties, MODEL_NAME, modelName)
+ && propertyMatches(matchProperties, MODEL_NUMBER, modelNumber)
+ && propertyMatches(matchProperties, MODEL_DESCRIPTION, modelDescription)
+ && propertyMatches(matchProperties, MODEL_URI, modelURI)
+ && propertyMatches(matchProperties, SERIAL_NUMBER, serialNumber)
+ && propertyMatches(matchProperties, FRIENDLY_NAME, friendlyName)) {
+ result.add(candidate);
+ logger.debug("Suggested addon found: {}", candidate.getUID());
+ break;
+ }
+ }
+ }
+ }
+ return result;
+ }
+
+ @Override
+ public String getServiceName() {
+ return SERVICE_NAME;
+ }
+
+ /*
+ * ************ UpnpService call-back methods ************
+ */
+
+ @Override
+ public void afterShutdown() {
+ }
+
+ @Override
+ public void beforeShutdown(@Nullable Registry registry) {
+ }
+
+ @Override
+ public void localDeviceAdded(@Nullable Registry registry, @Nullable LocalDevice localDevice) {
+ }
+
+ @Override
+ public void localDeviceRemoved(@Nullable Registry registry, @Nullable LocalDevice localDevice) {
+ }
+
+ @Override
+ public void remoteDeviceAdded(@Nullable Registry registry, @Nullable RemoteDevice remoteDevice) {
+ if (remoteDevice != null) {
+ addDevice(remoteDevice);
+ }
+ }
+
+ @Override
+ public void remoteDeviceDiscoveryFailed(@Nullable Registry registry, @Nullable RemoteDevice remoteDevice,
+ @Nullable Exception exception) {
+ }
+
+ @Override
+ public void remoteDeviceDiscoveryStarted(@Nullable Registry registry, @Nullable RemoteDevice remoteDevice) {
+ }
+
+ @Override
+ public void remoteDeviceRemoved(@Nullable Registry registry, @Nullable RemoteDevice remoteDevice) {
+ }
+
+ @Override
+ public void remoteDeviceUpdated(@Nullable Registry registry, @Nullable RemoteDevice remoteDevice) {
+ if (remoteDevice != null) {
+ addDevice(remoteDevice);
+ }
+ }
+}
diff --git a/bundles/org.openhab.core.config.discovery.addon.upnp/src/test/java/org/openhab/core/config/discovery/addon/upnp/tests/UpnpAddonFinderTests.java b/bundles/org.openhab.core.config.discovery.addon.upnp/src/test/java/org/openhab/core/config/discovery/addon/upnp/tests/UpnpAddonFinderTests.java
new file mode 100644
index 000000000..8c5bd09a6
--- /dev/null
+++ b/bundles/org.openhab.core.config.discovery.addon.upnp/src/test/java/org/openhab/core/config/discovery/addon/upnp/tests/UpnpAddonFinderTests.java
@@ -0,0 +1,149 @@
+/**
+ * Copyright (c) 2010-2023 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.core.config.discovery.addon.upnp.tests;
+
+/**
+ * Copyright (c) 2010-2023 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.junit.jupiter.api.Assertions.fail;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+import java.net.InetAddress;
+import java.net.MalformedURLException;
+import java.net.URL;
+import java.net.UnknownHostException;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Set;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.TestInstance;
+import org.junit.jupiter.api.TestInstance.Lifecycle;
+import org.jupnp.UpnpService;
+import org.jupnp.model.ValidationException;
+import org.jupnp.model.meta.DeviceDetails;
+import org.jupnp.model.meta.ManufacturerDetails;
+import org.jupnp.model.meta.ModelDetails;
+import org.jupnp.model.meta.RemoteDevice;
+import org.jupnp.model.meta.RemoteDeviceIdentity;
+import org.jupnp.model.meta.RemoteService;
+import org.jupnp.model.types.DeviceType;
+import org.jupnp.model.types.UDN;
+import org.mockito.Mockito;
+import org.openhab.core.addon.AddonDiscoveryMethod;
+import org.openhab.core.addon.AddonInfo;
+import org.openhab.core.addon.AddonMatchProperty;
+import org.openhab.core.config.discovery.addon.AddonFinder;
+import org.openhab.core.config.discovery.addon.AddonFinderConstants;
+import org.openhab.core.config.discovery.addon.upnp.UpnpAddonFinder;
+
+/**
+ * JUnit tests for the {@link UpnpAddonFinder}.
+ *
+ * @author Andrew Fiddian-Green - Initial contribution
+ * @author Mark Herwege - Adapted to finders in separate packages
+ */
+@NonNullByDefault
+@TestInstance(Lifecycle.PER_CLASS)
+public class UpnpAddonFinderTests {
+
+ private @NonNullByDefault({}) UpnpService upnpService;
+ private @NonNullByDefault({}) AddonFinder addonFinder;
+ private List addonInfos = new ArrayList<>();
+
+ @BeforeAll
+ public void setup() {
+ setupMockUpnpService();
+ setupAddonInfos();
+ createAddonFinder();
+ }
+
+ private void createAddonFinder() {
+ UpnpAddonFinder upnpAddonFinder = new UpnpAddonFinder(upnpService);
+ assertNotNull(upnpAddonFinder);
+
+ addonFinder = upnpAddonFinder;
+ }
+
+ private void setupMockUpnpService() {
+ // create the mock
+ upnpService = mock(UpnpService.class, Mockito.RETURNS_DEEP_STUBS);
+ URL url = null;
+ try {
+ url = new URL("http://www.openhab.org/");
+ } catch (MalformedURLException e) {
+ fail("MalformedURLException");
+ }
+ UDN udn = new UDN("udn");
+ InetAddress address = null;
+ try {
+ address = InetAddress.getByName("127.0.0.1");
+ } catch (UnknownHostException e) {
+ fail("UnknownHostException");
+ }
+ RemoteDeviceIdentity identity = new RemoteDeviceIdentity(udn, 0, url, new byte[] {}, address);
+ DeviceType type = new DeviceType("nameSpace", "type");
+ ManufacturerDetails manDetails = new ManufacturerDetails("manufacturer", "manufacturerURI");
+ ModelDetails modDetails = new ModelDetails("Philips hue bridge", "modelDescription", "modelNumber", "modelURI");
+ DeviceDetails devDetails = new DeviceDetails("friendlyName", manDetails, modDetails, "serialNumber",
+ "000123456789");
+ List<@Nullable RemoteDevice> remoteDevices = new ArrayList<>();
+ try {
+ remoteDevices.add(new RemoteDevice(identity, type, devDetails, (RemoteService) null));
+ } catch (ValidationException e1) {
+ fail("ValidationException");
+ }
+ when(upnpService.getRegistry().getRemoteDevices()).thenReturn(remoteDevices);
+
+ // check that it works
+ assertNotNull(upnpService);
+ List result = new ArrayList<>(upnpService.getRegistry().getRemoteDevices());
+ assertEquals(1, result.size());
+ RemoteDevice device = result.get(0);
+ assertEquals("manufacturer", device.getDetails().getManufacturerDetails().getManufacturer());
+ assertEquals("serialNumber", device.getDetails().getSerialNumber());
+ }
+
+ private void setupAddonInfos() {
+ AddonDiscoveryMethod hue = new AddonDiscoveryMethod().setServiceType(AddonFinderConstants.SERVICE_TYPE_UPNP)
+ .setMatchProperties(List.of(new AddonMatchProperty("modelName", "Philips hue bridge")));
+ addonInfos.add(AddonInfo.builder("hue", "binding").withName("Hue").withDescription("Hue Bridge")
+ .withDiscoveryMethods(List.of(hue)).build());
+ }
+
+ @Test
+ public void testGetSuggestedAddons() {
+ addonFinder.setAddonCandidates(addonInfos);
+ Set addons = addonFinder.getSuggestedAddons();
+ assertEquals(1, addons.size());
+ assertFalse(addons.stream().anyMatch(a -> "aardvark".equals(a.getUID())));
+ assertTrue(addons.stream().anyMatch(a -> "binding-hue".equals(a.getUID())));
+ }
+}
diff --git a/bundles/org.openhab.core.config.discovery.addon/.classpath b/bundles/org.openhab.core.config.discovery.addon/.classpath
new file mode 100644
index 000000000..e9d63b05a
--- /dev/null
+++ b/bundles/org.openhab.core.config.discovery.addon/.classpath
@@ -0,0 +1,29 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/bundles/org.openhab.core.config.discovery.addon/.project b/bundles/org.openhab.core.config.discovery.addon/.project
new file mode 100644
index 000000000..ea7024024
--- /dev/null
+++ b/bundles/org.openhab.core.config.discovery.addon/.project
@@ -0,0 +1,23 @@
+
+
+ org.openhab.core.config.discovery.addon
+
+
+
+
+
+ org.eclipse.jdt.core.javabuilder
+
+
+
+
+ org.eclipse.m2e.core.maven2Builder
+
+
+
+
+
+ org.eclipse.jdt.core.javanature
+ org.eclipse.m2e.core.maven2Nature
+
+
diff --git a/bundles/org.openhab.core.config.discovery.addon/NOTICE b/bundles/org.openhab.core.config.discovery.addon/NOTICE
new file mode 100644
index 000000000..6c17d0d8a
--- /dev/null
+++ b/bundles/org.openhab.core.config.discovery.addon/NOTICE
@@ -0,0 +1,14 @@
+This content is produced and maintained by the openHAB project.
+
+* Project home: https://www.openhab.org
+
+== Declared Project Licenses
+
+This program and the accompanying materials are made available under the terms
+of the Eclipse Public License 2.0 which is available at
+https://www.eclipse.org/legal/epl-2.0/.
+
+== Source Code
+
+https://github.com/openhab/openhab-core
+
diff --git a/bundles/org.openhab.core.config.discovery.addon/pom.xml b/bundles/org.openhab.core.config.discovery.addon/pom.xml
new file mode 100644
index 000000000..9b33fcd43
--- /dev/null
+++ b/bundles/org.openhab.core.config.discovery.addon/pom.xml
@@ -0,0 +1,24 @@
+
+
+
+ 4.0.0
+
+
+ org.openhab.core.bundles
+ org.openhab.core.reactor.bundles
+ 4.1.0-SNAPSHOT
+
+
+ org.openhab.core.config.discovery.addon
+
+ openHAB Core :: Bundles :: Add-on Suggestion Service
+
+
+
+ org.openhab.core.bundles
+ org.openhab.core.addon
+ ${project.version}
+
+
+
diff --git a/bundles/org.openhab.core.config.discovery.addon/src/main/java/org/openhab/core/config/discovery/addon/AddonFinder.java b/bundles/org.openhab.core.config.discovery.addon/src/main/java/org/openhab/core/config/discovery/addon/AddonFinder.java
new file mode 100644
index 000000000..26eec778a
--- /dev/null
+++ b/bundles/org.openhab.core.config.discovery.addon/src/main/java/org/openhab/core/config/discovery/addon/AddonFinder.java
@@ -0,0 +1,48 @@
+/**
+ * Copyright (c) 2010-2023 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.core.config.discovery.addon;
+
+import java.util.List;
+import java.util.Set;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.openhab.core.addon.AddonInfo;
+
+/**
+ * This is a {@link AddonFinder} interface for classes that find add-ons that are suggested to be installed.
+ *
+ * @author Andrew Fiddian-Green - Initial contribution
+ */
+@NonNullByDefault
+public interface AddonFinder {
+
+ /**
+ * The framework calls this method to scan through the candidate list of {@link AddonInfo} and return a subset of
+ * those that it suggests to be installed.
+ */
+ public Set getSuggestedAddons();
+
+ /**
+ * The framework calls this method to provide a list of {@link AddonInfo} elements which contain potential
+ * candidates that this finder can iterate over in order to detect which ones to return via the
+ * {@code getSuggestedAddons()} method.
+ *
+ * @param candidates a list of AddonInfo candidates.
+ */
+ public void setAddonCandidates(List candidates);
+
+ /**
+ * This method should be called from the framework to allow a finder to stop searching for addons and do cleanup.
+ */
+ public void unsetAddonCandidates();
+}
diff --git a/bundles/org.openhab.core.config.discovery.addon/src/main/java/org/openhab/core/config/discovery/addon/AddonFinderConstants.java b/bundles/org.openhab.core.config.discovery.addon/src/main/java/org/openhab/core/config/discovery/addon/AddonFinderConstants.java
new file mode 100644
index 000000000..596498898
--- /dev/null
+++ b/bundles/org.openhab.core.config.discovery.addon/src/main/java/org/openhab/core/config/discovery/addon/AddonFinderConstants.java
@@ -0,0 +1,46 @@
+/**
+ * Copyright (c) 2010-2023 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.core.config.discovery.addon;
+
+import java.util.List;
+import java.util.Map;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+
+/**
+ * This {@link AddonFinderConstants} contains constants describing addon finders available in core.
+ *
+ * @author Mark Herwege - Initial contribution
+ */
+@NonNullByDefault
+public class AddonFinderConstants {
+
+ private static final String ADDON_SUGGESTION_FINDER = "-addon-suggestion-finder";
+ private static final String ADDON_SUGGESTION_FINDER_FEATURE = "openhab-core-config-discovery-addon-";
+
+ public static final String SERVICE_TYPE_MDNS = "mdns";
+ public static final String CFG_FINDER_MDNS = "suggestionFinderMdns";
+ public static final String SERVICE_NAME_MDNS = SERVICE_TYPE_MDNS + ADDON_SUGGESTION_FINDER;
+ public static final String FEATURE_MDNS = ADDON_SUGGESTION_FINDER_FEATURE + SERVICE_TYPE_MDNS;
+
+ public static final String SERVICE_TYPE_UPNP = "upnp";
+ public static final String CFG_FINDER_UPNP = "suggestionFinderUpnp";
+ public static final String SERVICE_NAME_UPNP = SERVICE_TYPE_UPNP + ADDON_SUGGESTION_FINDER;
+ public static final String FEATURE_UPNP = ADDON_SUGGESTION_FINDER_FEATURE + SERVICE_TYPE_UPNP;
+
+ public static final List SUGGESTION_FINDERS = List.of(SERVICE_NAME_MDNS, SERVICE_NAME_UPNP);
+ public static final Map SUGGESTION_FINDER_CONFIGS = Map.of(SERVICE_NAME_MDNS, CFG_FINDER_MDNS,
+ SERVICE_NAME_UPNP, CFG_FINDER_UPNP);
+ public static final Map SUGGESTION_FINDER_FEATURES = Map.of(SERVICE_NAME_MDNS, FEATURE_MDNS,
+ SERVICE_NAME_UPNP, FEATURE_UPNP);
+}
diff --git a/bundles/org.openhab.core.config.discovery.addon/src/main/java/org/openhab/core/config/discovery/addon/AddonFinderService.java b/bundles/org.openhab.core.config.discovery.addon/src/main/java/org/openhab/core/config/discovery/addon/AddonFinderService.java
new file mode 100644
index 000000000..276e27fdc
--- /dev/null
+++ b/bundles/org.openhab.core.config.discovery.addon/src/main/java/org/openhab/core/config/discovery/addon/AddonFinderService.java
@@ -0,0 +1,43 @@
+/**
+ * Copyright (c) 2010-2023 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.core.config.discovery.addon;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+
+/**
+ * Classes implementing this interface can be registered as an OSGi service in order to provide functionality for
+ * managing add-on suggestion finders, such as installing and uninstalling them.
+ *
+ * @author Mark Herwege - Initial contribution
+ */
+@NonNullByDefault
+public interface AddonFinderService {
+
+ /**
+ * Installs the given add-on suggestion finder.
+ *
+ * This can be a long running process. The framework makes sure that this is called within a separate thread.
+ *
+ * @param id the id of the add-on suggestion finder to install
+ */
+ void install(String id);
+
+ /**
+ * Uninstalls the given add-on suggestion finder.
+ *
+ * This can be a long running process. The framework makes sure that this is called within a separate thread.
+ *
+ * @param id the id of the add-on suggestion finder to uninstall
+ */
+ void uninstall(String id);
+}
diff --git a/bundles/org.openhab.core.config.discovery.addon/src/main/java/org/openhab/core/config/discovery/addon/AddonSuggestionService.java b/bundles/org.openhab.core.config.discovery.addon/src/main/java/org/openhab/core/config/discovery/addon/AddonSuggestionService.java
new file mode 100644
index 000000000..5ee86cf1e
--- /dev/null
+++ b/bundles/org.openhab.core.config.discovery.addon/src/main/java/org/openhab/core/config/discovery/addon/AddonSuggestionService.java
@@ -0,0 +1,200 @@
+/**
+ * Copyright (c) 2010-2023 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.core.config.discovery.addon;
+
+import static org.openhab.core.config.discovery.addon.AddonFinderConstants.*;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.Dictionary;
+import java.util.Enumeration;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.ScheduledFuture;
+import java.util.concurrent.TimeUnit;
+import java.util.stream.Collectors;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.openhab.core.addon.AddonInfo;
+import org.openhab.core.addon.AddonInfoProvider;
+import org.openhab.core.common.ThreadPoolManager;
+import org.openhab.core.config.core.ConfigParser;
+import org.openhab.core.i18n.LocaleProvider;
+import org.osgi.service.cm.ConfigurationAdmin;
+import org.osgi.service.component.annotations.Activate;
+import org.osgi.service.component.annotations.Component;
+import org.osgi.service.component.annotations.Deactivate;
+import org.osgi.service.component.annotations.Modified;
+import org.osgi.service.component.annotations.Reference;
+import org.osgi.service.component.annotations.ReferenceCardinality;
+import org.osgi.service.component.annotations.ReferencePolicy;
+import org.osgi.service.component.annotations.ReferencePolicyOption;
+
+/**
+ * This is a {@link AddonSuggestionService} which discovers suggested add-ons for the user to install.
+ *
+ * @author Andrew Fiddian-Green - Initial contribution
+ * @author Mark Herwege - Install/remove finders
+ */
+@NonNullByDefault
+@Component(immediate = true, service = AddonSuggestionService.class, name = AddonSuggestionService.SERVICE_NAME, configurationPid = AddonSuggestionService.CONFIG_PID)
+public class AddonSuggestionService implements AutoCloseable {
+
+ public static final String SERVICE_NAME = "addon-suggestion-service";
+ public static final String CONFIG_PID = "org.openhab.addons";
+
+ private final Set addonInfoProviders = ConcurrentHashMap.newKeySet();
+ private final List addonFinders = Collections.synchronizedList(new ArrayList<>());
+ private final ConfigurationAdmin configurationAdmin;
+ private final LocaleProvider localeProvider;
+ private @Nullable AddonFinderService addonFinderService;
+ private @Nullable Map config;
+ private final ScheduledExecutorService scheduler;
+ private final Map baseFinderConfig = new ConcurrentHashMap<>();
+ private final ScheduledFuture> syncConfigurationTask;
+
+ @Activate
+ public AddonSuggestionService(final @Reference ConfigurationAdmin configurationAdmin,
+ @Reference LocaleProvider localeProvider, @Nullable Map config) {
+ this.configurationAdmin = configurationAdmin;
+ this.localeProvider = localeProvider;
+
+ SUGGESTION_FINDERS.forEach(f -> baseFinderConfig.put(f, true));
+ modified(config);
+ changed();
+
+ // Changes to the configuration are expected to call the {@link modified} method. This works well when running
+ // in Eclipse. Running in Karaf, the method was not consistently called. Therefore regularly check for changes
+ // in configuration.
+ // This pattern and code was re-used from {@link org.openhab.core.karaf.internal.FeatureInstaller}
+ scheduler = ThreadPoolManager.getScheduledPool(ThreadPoolManager.THREAD_POOL_NAME_COMMON);
+ syncConfigurationTask = scheduler.scheduleWithFixedDelay(this::syncConfiguration, 1, 1, TimeUnit.MINUTES);
+ }
+
+ @Deactivate
+ protected void deactivate() {
+ syncConfigurationTask.cancel(true);
+ }
+
+ @Reference(cardinality = ReferenceCardinality.OPTIONAL, policy = ReferencePolicy.DYNAMIC, policyOption = ReferencePolicyOption.GREEDY)
+ protected void addAddonFinderService(AddonFinderService addonFinderService) {
+ this.addonFinderService = addonFinderService;
+ modified(config);
+ }
+
+ protected void removeAddonFinderService(AddonFinderService addonFinderService) {
+ AddonFinderService finderService = this.addonFinderService;
+ if ((finderService != null) && addonFinderService.getClass().isAssignableFrom(finderService.getClass())) {
+ this.addonFinderService = null;
+ }
+ }
+
+ @Modified
+ public void modified(@Nullable final Map config) {
+ baseFinderConfig.forEach((finder, cfg) -> {
+ String cfgParam = SUGGESTION_FINDER_CONFIGS.get(finder);
+ if (cfgParam != null) {
+ boolean enabled = (config != null)
+ ? ConfigParser.valueAsOrElse(config.get(cfgParam), Boolean.class, cfg)
+ : cfg;
+ baseFinderConfig.put(finder, enabled);
+ String feature = SUGGESTION_FINDER_FEATURES.get(finder);
+ AddonFinderService finderService = addonFinderService;
+ if (feature != null && finderService != null) {
+ if (enabled) {
+ scheduler.execute(() -> finderService.install(feature));
+ } else {
+ scheduler.execute(() -> finderService.uninstall(feature));
+ }
+ }
+ }
+ });
+ this.config = config;
+ }
+
+ private void syncConfiguration() {
+ try {
+ Dictionary cfg = configurationAdmin.getConfiguration(CONFIG_PID).getProperties();
+ if (cfg == null) {
+ return;
+ }
+ final Map cfgMap = new HashMap<>();
+ final Enumeration enumeration = cfg.keys();
+ while (enumeration.hasMoreElements()) {
+ final String key = enumeration.nextElement();
+ cfgMap.put(key, cfg.get(key));
+ }
+ if (!cfgMap.equals(config)) {
+ modified(cfgMap);
+ }
+ } catch (IOException e) {
+ }
+ }
+
+ private boolean isFinderEnabled(AddonFinder finder) {
+ if (finder instanceof BaseAddonFinder baseFinder) {
+ return baseFinderConfig.getOrDefault(baseFinder.getServiceName(), true);
+ }
+ return true;
+ }
+
+ @Reference(cardinality = ReferenceCardinality.MULTIPLE, policy = ReferencePolicy.DYNAMIC)
+ public void addAddonInfoProvider(AddonInfoProvider addonInfoProvider) {
+ addonInfoProviders.add(addonInfoProvider);
+ changed();
+ }
+
+ public void removeAddonInfoProvider(AddonInfoProvider addonInfoProvider) {
+ if (addonInfoProviders.remove(addonInfoProvider)) {
+ changed();
+ }
+ }
+
+ @Reference(cardinality = ReferenceCardinality.MULTIPLE, policy = ReferencePolicy.DYNAMIC)
+ public void addAddonFinder(AddonFinder addonFinder) {
+ addonFinders.add(addonFinder);
+ changed();
+ }
+
+ public void removeAddonFinder(AddonFinder addonFinder) {
+ if (addonFinders.remove(addonFinder)) {
+ changed();
+ }
+ }
+
+ private void changed() {
+ List candidates = addonInfoProviders.stream().map(p -> p.getAddonInfos(localeProvider.getLocale()))
+ .flatMap(Collection::stream).toList();
+ addonFinders.stream().filter(this::isFinderEnabled).forEach(f -> f.setAddonCandidates(candidates));
+ }
+
+ @Deactivate
+ @Override
+ public void close() throws Exception {
+ addonFinders.clear();
+ addonInfoProviders.clear();
+ }
+
+ public Set getSuggestedAddons(@Nullable Locale locale) {
+ return addonFinders.stream().filter(this::isFinderEnabled).map(f -> f.getSuggestedAddons())
+ .flatMap(Collection::stream).collect(Collectors.toSet());
+ }
+}
diff --git a/bundles/org.openhab.core.config.discovery.addon/src/main/java/org/openhab/core/config/discovery/addon/BaseAddonFinder.java b/bundles/org.openhab.core.config.discovery.addon/src/main/java/org/openhab/core/config/discovery/addon/BaseAddonFinder.java
new file mode 100644
index 000000000..cb62e5351
--- /dev/null
+++ b/bundles/org.openhab.core.config.discovery.addon/src/main/java/org/openhab/core/config/discovery/addon/BaseAddonFinder.java
@@ -0,0 +1,60 @@
+/**
+ * Copyright (c) 2010-2023 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.core.config.discovery.addon;
+
+import java.util.List;
+import java.util.Map;
+import java.util.regex.Pattern;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.openhab.core.addon.AddonInfo;
+
+/**
+ * This is a {@link BaseAddonFinder} abstract class for finding suggested add-ons.
+ *
+ * @author Andrew Fiddian-Green - Initial contribution
+ */
+@NonNullByDefault
+public abstract class BaseAddonFinder implements AddonFinder {
+
+ /**
+ * Helper method to check if the given {@code propertyName} is in the {@code propertyPatternMap} and if so, the
+ * given {@code propertyValue} matches the respective regular expression {@code Pattern}.
+ *
+ * @param propertyPatternMap map of property names and regex patterns for value matching
+ * @param propertyName
+ * @param propertyValue
+ * @return true a) if the property name exists and the property value is not null and matches the regular
+ * expression, or b) the property name does not exist.
+ */
+ protected static boolean propertyMatches(Map propertyPatternMap, String propertyName,
+ @Nullable String propertyValue) {
+ Pattern pattern = propertyPatternMap.get(propertyName);
+ return pattern == null ? true : propertyValue == null ? false : pattern.matcher(propertyValue).matches();
+ }
+
+ protected volatile List addonCandidates = List.of();
+
+ @Override
+ public void setAddonCandidates(List candidates) {
+ addonCandidates = candidates;
+ }
+
+ @Override
+ public void unsetAddonCandidates() {
+ addonCandidates = List.of();
+ }
+
+ public abstract String getServiceName();
+}
diff --git a/bundles/org.openhab.core.config.discovery.addon/src/test/java/org/openhab/core/config/discovery/addon/tests/AddonSuggestionServiceTests.java b/bundles/org.openhab.core.config.discovery.addon/src/test/java/org/openhab/core/config/discovery/addon/tests/AddonSuggestionServiceTests.java
new file mode 100644
index 000000000..0d72ac4d5
--- /dev/null
+++ b/bundles/org.openhab.core.config.discovery.addon/src/test/java/org/openhab/core/config/discovery/addon/tests/AddonSuggestionServiceTests.java
@@ -0,0 +1,196 @@
+/**
+ * Copyright (c) 2010-2023 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.core.config.discovery.addon.tests;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.*;
+import static org.openhab.core.config.discovery.addon.AddonFinderConstants.*;
+
+import java.io.IOException;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.TestInstance;
+import org.junit.jupiter.api.TestInstance.Lifecycle;
+import org.openhab.core.addon.AddonDiscoveryMethod;
+import org.openhab.core.addon.AddonInfo;
+import org.openhab.core.addon.AddonInfoProvider;
+import org.openhab.core.addon.AddonMatchProperty;
+import org.openhab.core.config.discovery.addon.AddonFinder;
+import org.openhab.core.config.discovery.addon.AddonFinderConstants;
+import org.openhab.core.config.discovery.addon.AddonSuggestionService;
+import org.openhab.core.i18n.LocaleProvider;
+import org.osgi.service.cm.Configuration;
+import org.osgi.service.cm.ConfigurationAdmin;
+
+/**
+ * JUnit tests for the {@link AddonSuggestionService}.
+ *
+ * @author Andrew Fiddian-Green - Initial contribution
+ * @author Mark Herwege - Adapted to finders in separate packages
+ */
+@NonNullByDefault
+@TestInstance(Lifecycle.PER_CLASS)
+public class AddonSuggestionServiceTests {
+
+ private @NonNullByDefault({}) ConfigurationAdmin configurationAdmin;
+ private @NonNullByDefault({}) LocaleProvider localeProvider;
+ private @NonNullByDefault({}) AddonInfoProvider addonInfoProvider;
+ private @NonNullByDefault({}) AddonFinder mdnsAddonFinder;
+ private @NonNullByDefault({}) AddonFinder upnpAddonFinder;
+ private @NonNullByDefault({}) AddonSuggestionService addonSuggestionService;
+
+ private final Map config = Map.of(AddonFinderConstants.CFG_FINDER_MDNS, true,
+ AddonFinderConstants.CFG_FINDER_UPNP, true);
+
+ @AfterAll
+ public void cleanUp() {
+ assertNotNull(addonSuggestionService);
+ try {
+ addonSuggestionService.close();
+ } catch (Exception e) {
+ fail(e);
+ }
+ }
+
+ @BeforeAll
+ public void setup() {
+ setupMockConfigurationAdmin();
+ setupMockLocaleProvider();
+ setupMockAddonInfoProvider();
+ setupMockMdnsAddonFinder();
+ setupMockUpnpAddonFinder();
+ addonSuggestionService = createAddonSuggestionService();
+ }
+
+ private AddonSuggestionService createAddonSuggestionService() {
+ AddonSuggestionService addonSuggestionService = new AddonSuggestionService(configurationAdmin, localeProvider,
+ config);
+ assertNotNull(addonSuggestionService);
+
+ addonSuggestionService.addAddonFinder(mdnsAddonFinder);
+ addonSuggestionService.addAddonFinder(upnpAddonFinder);
+
+ return addonSuggestionService;
+ }
+
+ private void setupMockConfigurationAdmin() {
+ // create the mock
+ configurationAdmin = mock(ConfigurationAdmin.class);
+ Configuration configuration = mock(Configuration.class);
+ try {
+ when(configurationAdmin.getConfiguration(any())).thenReturn(configuration);
+ } catch (IOException e) {
+ }
+ when(configuration.getProperties()).thenReturn(null);
+
+ // check that it works
+ assertNotNull(configurationAdmin);
+ try {
+ assertNull(configurationAdmin.getConfiguration(AddonSuggestionService.CONFIG_PID).getProperties());
+ } catch (IOException e) {
+ }
+ }
+
+ private void setupMockLocaleProvider() {
+ // create the mock
+ localeProvider = mock(LocaleProvider.class);
+ when(localeProvider.getLocale()).thenReturn(Locale.US);
+
+ // check that it works
+ assertNotNull(localeProvider);
+ assertEquals(Locale.US, localeProvider.getLocale());
+ }
+
+ private void setupMockAddonInfoProvider() {
+ AddonDiscoveryMethod hp = new AddonDiscoveryMethod().setServiceType(AddonFinderConstants.SERVICE_TYPE_MDNS)
+ .setMatchProperties(
+ List.of(new AddonMatchProperty("rp", ".*"), new AddonMatchProperty("ty", "hp (.*)")))
+ .setMdnsServiceType("_printer._tcp.local.");
+
+ AddonDiscoveryMethod hue1 = new AddonDiscoveryMethod().setServiceType(AddonFinderConstants.SERVICE_TYPE_UPNP)
+ .setMatchProperties(List.of(new AddonMatchProperty("modelName", "Philips hue bridge")));
+
+ AddonDiscoveryMethod hue2 = new AddonDiscoveryMethod().setServiceType(AddonFinderConstants.SERVICE_TYPE_MDNS)
+ .setMdnsServiceType("_hue._tcp.local.");
+
+ // create the mock
+ addonInfoProvider = mock(AddonInfoProvider.class);
+ Set addonInfos = new HashSet<>();
+ addonInfos.add(AddonInfo.builder("hue", "binding").withName("Hue").withDescription("Hue Bridge")
+ .withDiscoveryMethods(List.of(hue1, hue2)).build());
+
+ addonInfos.add(AddonInfo.builder("hpprinter", "binding").withName("HP").withDescription("HP Printer")
+ .withDiscoveryMethods(List.of(hp)).build());
+ when(addonInfoProvider.getAddonInfos(any(Locale.class))).thenReturn(addonInfos);
+
+ // check that it works
+ assertNotNull(addonInfoProvider);
+ Set addonInfos2 = addonInfoProvider.getAddonInfos(Locale.US);
+ assertEquals(2, addonInfos2.size());
+ assertTrue(addonInfos2.stream().anyMatch(a -> "binding-hue".equals(a.getUID())));
+ assertTrue(addonInfos2.stream().anyMatch(a -> "binding-hpprinter".equals(a.getUID())));
+ }
+
+ private void setupMockMdnsAddonFinder() {
+ // create the mock
+ mdnsAddonFinder = mock(AddonFinder.class);
+
+ Set addonInfos = addonInfoProvider.getAddonInfos(Locale.US).stream().filter(
+ c -> c.getDiscoveryMethods().stream().anyMatch(m -> SERVICE_TYPE_MDNS.equals(m.getServiceType())))
+ .collect(Collectors.toSet());
+ when(mdnsAddonFinder.getSuggestedAddons()).thenReturn(addonInfos);
+
+ // check that it works
+ assertNotNull(mdnsAddonFinder);
+ Set addonInfos2 = mdnsAddonFinder.getSuggestedAddons();
+ assertEquals(2, addonInfos2.size());
+ assertTrue(addonInfos2.stream().anyMatch(a -> "binding-hue".equals(a.getUID())));
+ assertTrue(addonInfos2.stream().anyMatch(a -> "binding-hpprinter".equals(a.getUID())));
+ }
+
+ private void setupMockUpnpAddonFinder() {
+ // create the mock
+ upnpAddonFinder = mock(AddonFinder.class);
+
+ Set addonInfos = addonInfoProvider.getAddonInfos(Locale.US).stream().filter(
+ c -> c.getDiscoveryMethods().stream().anyMatch(m -> SERVICE_TYPE_UPNP.equals(m.getServiceType())))
+ .collect(Collectors.toSet());
+ when(upnpAddonFinder.getSuggestedAddons()).thenReturn(addonInfos);
+
+ // check that it works
+ assertNotNull(upnpAddonFinder);
+ Set addonInfos2 = upnpAddonFinder.getSuggestedAddons();
+ assertEquals(1, addonInfos2.size());
+ assertTrue(addonInfos2.stream().anyMatch(a -> "binding-hue".equals(a.getUID())));
+ }
+
+ @Test
+ public void testGetSuggestedAddons() {
+ addonSuggestionService.addAddonInfoProvider(addonInfoProvider);
+ Set addons = addonSuggestionService.getSuggestedAddons(localeProvider.getLocale());
+ assertEquals(2, addons.size());
+ assertFalse(addons.stream().anyMatch(a -> "aardvark".equals(a.getUID())));
+ assertTrue(addons.stream().anyMatch(a -> "binding-hue".equals(a.getUID())));
+ assertTrue(addons.stream().anyMatch(a -> "binding-hpprinter".equals(a.getUID())));
+ }
+}
diff --git a/bundles/org.openhab.core.io.rest.core/pom.xml b/bundles/org.openhab.core.io.rest.core/pom.xml
index e829c11cd..20f2604bb 100644
--- a/bundles/org.openhab.core.io.rest.core/pom.xml
+++ b/bundles/org.openhab.core.io.rest.core/pom.xml
@@ -35,6 +35,11 @@
org.openhab.core.config.discovery
${project.version}
+
+ org.openhab.core.bundles
+ org.openhab.core.config.discovery.addon
+ ${project.version}
+
org.openhab.core.bundles
org.openhab.core.io.rest
diff --git a/bundles/org.openhab.core.io.rest.core/src/main/java/org/openhab/core/io/rest/core/internal/addons/AddonResource.java b/bundles/org.openhab.core.io.rest.core/src/main/java/org/openhab/core/io/rest/core/internal/addons/AddonResource.java
index b2b4f8bee..fff95f130 100644
--- a/bundles/org.openhab.core.io.rest.core/src/main/java/org/openhab/core/io/rest/core/internal/addons/AddonResource.java
+++ b/bundles/org.openhab.core.io.rest.core/src/main/java/org/openhab/core/io/rest/core/internal/addons/AddonResource.java
@@ -56,6 +56,7 @@ import org.openhab.core.config.core.ConfigDescription;
import org.openhab.core.config.core.ConfigDescriptionRegistry;
import org.openhab.core.config.core.ConfigUtil;
import org.openhab.core.config.core.Configuration;
+import org.openhab.core.config.discovery.addon.AddonSuggestionService;
import org.openhab.core.events.Event;
import org.openhab.core.events.EventPublisher;
import org.openhab.core.io.rest.JSONResponse;
@@ -120,6 +121,7 @@ public class AddonResource implements RESTResource {
private final ConfigurationService configurationService;
private final AddonInfoRegistry addonInfoRegistry;
private final ConfigDescriptionRegistry configDescriptionRegistry;
+ private final AddonSuggestionService addonSuggestionService;
private @Context @NonNullByDefault({}) UriInfo uriInfo;
@@ -127,12 +129,14 @@ public class AddonResource implements RESTResource {
public AddonResource(final @Reference EventPublisher eventPublisher, final @Reference LocaleService localeService,
final @Reference ConfigurationService configurationService,
final @Reference AddonInfoRegistry addonInfoRegistry,
- final @Reference ConfigDescriptionRegistry configDescriptionRegistry) {
+ final @Reference ConfigDescriptionRegistry configDescriptionRegistry,
+ final @Reference AddonSuggestionService addonSuggestionService) {
this.eventPublisher = eventPublisher;
this.localeService = localeService;
this.configurationService = configurationService;
this.addonInfoRegistry = addonInfoRegistry;
this.configDescriptionRegistry = configDescriptionRegistry;
+ this.addonSuggestionService = addonSuggestionService;
}
@Reference(cardinality = ReferenceCardinality.MULTIPLE, policy = ReferencePolicy.DYNAMIC)
@@ -178,6 +182,19 @@ public class AddonResource implements RESTResource {
return Response.ok(new Stream2JSONInputStream(addonTypeStream)).build();
}
+ @GET
+ @Path("/suggestions")
+ @Produces(MediaType.APPLICATION_JSON)
+ @Operation(operationId = "getSuggestedAddons", summary = "Get suggested add-ons to be installed.", responses = {
+ @ApiResponse(responseCode = "200", description = "OK", content = @Content(array = @ArraySchema(schema = @Schema(implementation = Addon.class)))), })
+ public Response getSuggestions(
+ @HeaderParam("Accept-Language") @Parameter(description = "language") @Nullable String language) {
+ logger.debug("Received HTTP GET request at '{}'", uriInfo.getPath());
+ Locale locale = localeService.getLocale(language);
+ return Response.ok(new Stream2JSONInputStream(addonSuggestionService.getSuggestedAddons(locale).stream()))
+ .build();
+ }
+
@GET
@Path("/types")
@Produces(MediaType.APPLICATION_JSON)
diff --git a/bundles/org.openhab.core.karaf/pom.xml b/bundles/org.openhab.core.karaf/pom.xml
index 97267744d..aa2d40cd0 100644
--- a/bundles/org.openhab.core.karaf/pom.xml
+++ b/bundles/org.openhab.core.karaf/pom.xml
@@ -30,6 +30,11 @@
org.openhab.core.config.core
${project.version}
+
+ org.openhab.core.bundles
+ org.openhab.core.config.discovery.addon
+ ${project.version}
+
org.apache.karaf.features
org.apache.karaf.features.core
diff --git a/bundles/org.openhab.core.karaf/src/main/java/org/openhab/core/karaf/internal/KarafAddonFinderService.java b/bundles/org.openhab.core.karaf/src/main/java/org/openhab/core/karaf/internal/KarafAddonFinderService.java
new file mode 100644
index 000000000..815d6eaed
--- /dev/null
+++ b/bundles/org.openhab.core.karaf/src/main/java/org/openhab/core/karaf/internal/KarafAddonFinderService.java
@@ -0,0 +1,63 @@
+/**
+ * Copyright (c) 2010-2023 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.core.karaf.internal;
+
+import org.apache.karaf.features.FeaturesService;
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.openhab.core.config.discovery.addon.AddonFinderService;
+import org.osgi.service.component.annotations.Activate;
+import org.osgi.service.component.annotations.Component;
+import org.osgi.service.component.annotations.Reference;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * This service is an implementation of an openHAB {@link AddonSuggestionFinderService} using the Karaf features
+ * service. This service allows dynamic installation/removal of add-on suggestion finders.
+ *
+ * @author Mark Herwege - Initial contribution
+ */
+@Component(name = "org.openhab.core.karafaddonfinders", immediate = true)
+@NonNullByDefault
+public class KarafAddonFinderService implements AddonFinderService {
+ private final Logger logger = LoggerFactory.getLogger(KarafAddonFinderService.class);
+
+ private final FeaturesService featuresService;
+
+ @Activate
+ public KarafAddonFinderService(final @Reference FeaturesService featuresService) {
+ this.featuresService = featuresService;
+ }
+
+ @Override
+ public void install(String id) {
+ try {
+ if (!featuresService.isInstalled(featuresService.getFeature(id))) {
+ featuresService.installFeature(id);
+ }
+ } catch (Exception e) {
+ logger.error("Failed to install add-on suggestion finder {}", id, e);
+ }
+ }
+
+ @Override
+ public void uninstall(String id) {
+ try {
+ if (featuresService.isInstalled(featuresService.getFeature(id))) {
+ featuresService.uninstallFeature(id);
+ }
+ } catch (Exception e) {
+ logger.error("Failed to uninstall add-on suggestion finder {}", id, e);
+ }
+ }
+}
diff --git a/bundles/org.openhab.core.karaf/src/main/java/org/openhab/core/karaf/internal/KarafAddonService.java b/bundles/org.openhab.core.karaf/src/main/java/org/openhab/core/karaf/internal/KarafAddonService.java
index 8e4227155..e3b246557 100644
--- a/bundles/org.openhab.core.karaf/src/main/java/org/openhab/core/karaf/internal/KarafAddonService.java
+++ b/bundles/org.openhab.core.karaf/src/main/java/org/openhab/core/karaf/internal/KarafAddonService.java
@@ -133,8 +133,7 @@ public class KarafAddonService implements AddonService {
AddonInfo addonInfo = addonInfoRegistry.getAddonInfo(uid, locale);
- if (isInstalled && addonInfo != null) {
- // only enrich if this add-on is installed, otherwise wrong data might be added
+ if (addonInfo != null) {
addon = addon.withLabel(addonInfo.getName()).withDescription(addonInfo.getDescription())
.withConnection(addonInfo.getConnection()).withCountries(addonInfo.getCountries())
.withLink(getDefaultDocumentationLink(type, name))
diff --git a/bundles/org.openhab.core/src/main/resources/OH-INF/config/addons.xml b/bundles/org.openhab.core/src/main/resources/OH-INF/config/addons.xml
index d036ea139..2c143c391 100644
--- a/bundles/org.openhab.core/src/main/resources/OH-INF/config/addons.xml
+++ b/bundles/org.openhab.core/src/main/resources/OH-INF/config/addons.xml
@@ -17,6 +17,18 @@
expected. Enabling this option will include these entries in the list of available add-ons.
false
+
+ true
+
+ Use UPnP network scan to suggest add-ons.
+ true
+
+
+ true
+
+ Use mDNS network scan to suggest add-ons.
+ true
+
diff --git a/bundles/pom.xml b/bundles/pom.xml
index 70000056d..ad7441432 100644
--- a/bundles/pom.xml
+++ b/bundles/pom.xml
@@ -30,6 +30,9 @@
org.openhab.core.automation.rest
org.openhab.core.config.core
org.openhab.core.config.discovery
+ org.openhab.core.config.discovery.addon
+ org.openhab.core.config.discovery.addon.mdns
+ org.openhab.core.config.discovery.addon.upnp
org.openhab.core.config.discovery.mdns
org.openhab.core.config.discovery.usbserial
org.openhab.core.config.discovery.usbserial.linuxsysfs
diff --git a/features/karaf/openhab-core/src/main/feature/feature.xml b/features/karaf/openhab-core/src/main/feature/feature.xml
index 5cdcf866a..2f919eb97 100644
--- a/features/karaf/openhab-core/src/main/feature/feature.xml
+++ b/features/karaf/openhab-core/src/main/feature/feature.xml
@@ -66,6 +66,29 @@
mvn:org.openhab.core.bundles/org.openhab.core.io.rest/${project.version}
mvn:org.openhab.core.bundles/org.openhab.core.io.rest.core/${project.version}
mvn:org.openhab.core.bundles/org.openhab.core.io.rest.sse/${project.version}
+ openhab-core-config-discovery-addon
+
+
+
+ openhab-core-base
+ mvn:org.openhab.core.bundles/org.openhab.core.config.discovery.addon/${project.version}
+
+
+
+ openhab-core-base
+ openhab-core-config-discovery-addon
+ mvn:org.openhab.core.bundles/org.openhab.core.io.transport.mdns/${project.version}
+ mvn:org.openhab.core.bundles/org.openhab.core.config.discovery.addon.mdns/${project.version}
+ openhab.tp;filter:="(feature=jmdns)"
+ openhab.tp-jmdns
+
+
+
+ openhab-core-base
+ openhab-core-config-discovery-addon
+ mvn:org.openhab.core.bundles/org.openhab.core.config.discovery.addon.upnp/${project.version}
+ openhab.tp;filter:="(feature=jupnp)"
+ openhab.tp-jupnp