Service to find suggested addons to install (#3806)

Signed-off-by: Andrew Fiddian-Green <software@whitebear.ch>
Co-authored-by: Mark Herwege <mark.herwege@telenet.be>
This commit is contained in:
Andrew Fiddian-Green 2023-12-07 16:32:33 +00:00 committed by GitHub
parent de9912d06b
commit 62a50a409a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
31 changed files with 1730 additions and 3 deletions

View File

@ -304,6 +304,24 @@
<version>${project.version}</version> <version>${project.version}</version>
<scope>compile</scope> <scope>compile</scope>
</dependency> </dependency>
<dependency>
<groupId>org.openhab.core.bundles</groupId>
<artifactId>org.openhab.core.config.discovery.addon</artifactId>
<version>${project.version}</version>
<scope>compile</scope>
</dependency>
<dependency>
<groupId>org.openhab.core.bundles</groupId>
<artifactId>org.openhab.core.config.discovery.addon.mdns</artifactId>
<version>${project.version}</version>
<scope>compile</scope>
</dependency>
<dependency>
<groupId>org.openhab.core.bundles</groupId>
<artifactId>org.openhab.core.config.discovery.addon.upnp</artifactId>
<version>${project.version}</version>
<scope>compile</scope>
</dependency>
<dependency> <dependency>
<groupId>org.openhab.core.bundles</groupId> <groupId>org.openhab.core.bundles</groupId>
<artifactId>org.openhab.core.config.discovery.mdns</artifactId> <artifactId>org.openhab.core.config.discovery.mdns</artifactId>

View File

@ -0,0 +1,29 @@
<?xml version="1.0" encoding="UTF-8"?>
<classpath>
<classpathentry kind="src" output="target/classes" path="src/main/java">
<attributes>
<attribute name="optional" value="true"/>
<attribute name="maven.pomderived" value="true"/>
</attributes>
</classpathentry>
<classpathentry kind="src" output="target/test-classes" path="src/test/java">
<attributes>
<attribute name="test" value="true"/>
<attribute name="optional" value="true"/>
<attribute name="maven.pomderived" value="true"/>
</attributes>
</classpathentry>
<classpathentry kind="con" path="org.eclipse.jdt.launching.JRE_CONTAINER/org.eclipse.jdt.internal.debug.ui.launcher.StandardVMType/JavaSE-17">
<attributes>
<attribute name="maven.pomderived" value="true"/>
<attribute name="annotationpath" value="target/dependency"/>
</attributes>
</classpathentry>
<classpathentry kind="con" path="org.eclipse.m2e.MAVEN2_CLASSPATH_CONTAINER">
<attributes>
<attribute name="maven.pomderived" value="true"/>
<attribute name="annotationpath" value="target/dependency"/>
</attributes>
</classpathentry>
<classpathentry kind="output" path="target/classes"/>
</classpath>

View File

@ -0,0 +1,23 @@
<?xml version="1.0" encoding="UTF-8"?>
<projectDescription>
<name>org.openhab.core.config.discovery.addon.mdns</name>
<comment></comment>
<projects>
</projects>
<buildSpec>
<buildCommand>
<name>org.eclipse.jdt.core.javabuilder</name>
<arguments>
</arguments>
</buildCommand>
<buildCommand>
<name>org.eclipse.m2e.core.maven2Builder</name>
<arguments>
</arguments>
</buildCommand>
</buildSpec>
<natures>
<nature>org.eclipse.m2e.core.maven2Nature</nature>
<nature>org.eclipse.jdt.core.javanature</nature>
</natures>
</projectDescription>

View File

@ -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

View File

@ -0,0 +1,34 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.openhab.core.bundles</groupId>
<artifactId>org.openhab.core.reactor.bundles</artifactId>
<version>4.1.0-SNAPSHOT</version>
</parent>
<artifactId>org.openhab.core.config.discovery.addon.mdns</artifactId>
<name>openHAB Core :: Bundles :: mDNS Suggested Add-on Finder</name>
<dependencies>
<dependency>
<groupId>org.openhab.core.bundles</groupId>
<artifactId>org.openhab.core.config.discovery.mdns</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>org.openhab.core.bundles</groupId>
<artifactId>org.openhab.core.config.discovery.addon</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>org.openhab.core.bundles</groupId>
<artifactId>org.openhab.core.addon</artifactId>
<version>${project.version}</version>
</dependency>
</dependencies>
</project>

View File

@ -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<String, ServiceInfo> 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<AddonInfo> 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<AddonInfo> getSuggestedAddons() {
Set<AddonInfo> result = new HashSet<>();
for (AddonInfo candidate : addonCandidates) {
for (AddonDiscoveryMethod method : candidate.getDiscoveryMethods().stream()
.filter(method -> SERVICE_TYPE.equals(method.getServiceType())).toList()) {
Map<String, Pattern> matchProperties = method.getMatchProperties().stream()
.collect(Collectors.toMap(property -> property.getName(), property -> property.getPattern()));
Set<String> 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);
}
}
}
}

View File

@ -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<AddonInfo> 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<AddonInfo> 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())));
}
}

View File

@ -0,0 +1,29 @@
<?xml version="1.0" encoding="UTF-8"?>
<classpath>
<classpathentry kind="src" output="target/classes" path="src/main/java">
<attributes>
<attribute name="optional" value="true"/>
<attribute name="maven.pomderived" value="true"/>
</attributes>
</classpathentry>
<classpathentry kind="src" output="target/test-classes" path="src/test/java">
<attributes>
<attribute name="test" value="true"/>
<attribute name="optional" value="true"/>
<attribute name="maven.pomderived" value="true"/>
</attributes>
</classpathentry>
<classpathentry kind="con" path="org.eclipse.jdt.launching.JRE_CONTAINER/org.eclipse.jdt.internal.debug.ui.launcher.StandardVMType/JavaSE-17">
<attributes>
<attribute name="maven.pomderived" value="true"/>
<attribute name="annotationpath" value="target/dependency"/>
</attributes>
</classpathentry>
<classpathentry kind="con" path="org.eclipse.m2e.MAVEN2_CLASSPATH_CONTAINER">
<attributes>
<attribute name="maven.pomderived" value="true"/>
<attribute name="annotationpath" value="target/dependency"/>
</attributes>
</classpathentry>
<classpathentry kind="output" path="target/classes"/>
</classpath>

View File

@ -0,0 +1,23 @@
<?xml version="1.0" encoding="UTF-8"?>
<projectDescription>
<name>org.openhab.core.config.discovery.addon.upnp</name>
<comment></comment>
<projects>
</projects>
<buildSpec>
<buildCommand>
<name>org.eclipse.jdt.core.javabuilder</name>
<arguments>
</arguments>
</buildCommand>
<buildCommand>
<name>org.eclipse.m2e.core.maven2Builder</name>
<arguments>
</arguments>
</buildCommand>
</buildSpec>
<natures>
<nature>org.eclipse.m2e.core.maven2Nature</nature>
<nature>org.eclipse.jdt.core.javanature</nature>
</natures>
</projectDescription>

View File

@ -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

View File

@ -0,0 +1,29 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.openhab.core.bundles</groupId>
<artifactId>org.openhab.core.reactor.bundles</artifactId>
<version>4.1.0-SNAPSHOT</version>
</parent>
<artifactId>org.openhab.core.config.discovery.addon.upnp</artifactId>
<name>openHAB Core :: Bundles :: uPnP Suggested Add-on Finder</name>
<dependencies>
<dependency>
<groupId>org.openhab.core.bundles</groupId>
<artifactId>org.openhab.core.config.discovery.addon</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>org.openhab.core.bundles</groupId>
<artifactId>org.openhab.core.addon</artifactId>
<version>${project.version}</version>
</dependency>
</dependencies>
</project>

View File

@ -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<String> 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<String, RemoteDevice> 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<AddonInfo> getSuggestedAddons() {
Set<AddonInfo> result = new HashSet<>();
for (AddonInfo candidate : addonCandidates) {
for (AddonDiscoveryMethod method : candidate.getDiscoveryMethods().stream()
.filter(method -> SERVICE_TYPE.equals(method.getServiceType())).toList()) {
Map<String, Pattern> matchProperties = method.getMatchProperties().stream()
.collect(Collectors.toMap(property -> property.getName(), property -> property.getPattern()));
Set<String> 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);
}
}
}

View File

@ -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<AddonInfo> 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<RemoteDevice> 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<AddonInfo> 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())));
}
}

View File

@ -0,0 +1,29 @@
<?xml version="1.0" encoding="UTF-8"?>
<classpath>
<classpathentry kind="src" output="target/classes" path="src/main/java">
<attributes>
<attribute name="optional" value="true"/>
<attribute name="maven.pomderived" value="true"/>
</attributes>
</classpathentry>
<classpathentry kind="con" path="org.eclipse.jdt.launching.JRE_CONTAINER/org.eclipse.jdt.internal.debug.ui.launcher.StandardVMType/JavaSE-17">
<attributes>
<attribute name="maven.pomderived" value="true"/>
<attribute name="annotationpath" value="target/dependency"/>
</attributes>
</classpathentry>
<classpathentry kind="con" path="org.eclipse.m2e.MAVEN2_CLASSPATH_CONTAINER">
<attributes>
<attribute name="maven.pomderived" value="true"/>
<attribute name="annotationpath" value="target/dependency"/>
</attributes>
</classpathentry>
<classpathentry kind="src" output="target/test-classes" path="src/test/java">
<attributes>
<attribute name="test" value="true"/>
<attribute name="optional" value="true"/>
<attribute name="maven.pomderived" value="true"/>
</attributes>
</classpathentry>
<classpathentry kind="output" path="target/classes"/>
</classpath>

View File

@ -0,0 +1,23 @@
<?xml version="1.0" encoding="UTF-8"?>
<projectDescription>
<name>org.openhab.core.config.discovery.addon</name>
<comment></comment>
<projects>
</projects>
<buildSpec>
<buildCommand>
<name>org.eclipse.jdt.core.javabuilder</name>
<arguments>
</arguments>
</buildCommand>
<buildCommand>
<name>org.eclipse.m2e.core.maven2Builder</name>
<arguments>
</arguments>
</buildCommand>
</buildSpec>
<natures>
<nature>org.eclipse.jdt.core.javanature</nature>
<nature>org.eclipse.m2e.core.maven2Nature</nature>
</natures>
</projectDescription>

View File

@ -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

View File

@ -0,0 +1,24 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.openhab.core.bundles</groupId>
<artifactId>org.openhab.core.reactor.bundles</artifactId>
<version>4.1.0-SNAPSHOT</version>
</parent>
<artifactId>org.openhab.core.config.discovery.addon</artifactId>
<name>openHAB Core :: Bundles :: Add-on Suggestion Service</name>
<dependencies>
<dependency>
<groupId>org.openhab.core.bundles</groupId>
<artifactId>org.openhab.core.addon</artifactId>
<version>${project.version}</version>
</dependency>
</dependencies>
</project>

View File

@ -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<AddonInfo> 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<AddonInfo> candidates);
/**
* This method should be called from the framework to allow a finder to stop searching for addons and do cleanup.
*/
public void unsetAddonCandidates();
}

View File

@ -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<String> SUGGESTION_FINDERS = List.of(SERVICE_NAME_MDNS, SERVICE_NAME_UPNP);
public static final Map<String, String> SUGGESTION_FINDER_CONFIGS = Map.of(SERVICE_NAME_MDNS, CFG_FINDER_MDNS,
SERVICE_NAME_UPNP, CFG_FINDER_UPNP);
public static final Map<String, String> SUGGESTION_FINDER_FEATURES = Map.of(SERVICE_NAME_MDNS, FEATURE_MDNS,
SERVICE_NAME_UPNP, FEATURE_UPNP);
}

View File

@ -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);
}

View File

@ -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<AddonInfoProvider> addonInfoProviders = ConcurrentHashMap.newKeySet();
private final List<AddonFinder> addonFinders = Collections.synchronizedList(new ArrayList<>());
private final ConfigurationAdmin configurationAdmin;
private final LocaleProvider localeProvider;
private @Nullable AddonFinderService addonFinderService;
private @Nullable Map<String, Object> config;
private final ScheduledExecutorService scheduler;
private final Map<String, Boolean> baseFinderConfig = new ConcurrentHashMap<>();
private final ScheduledFuture<?> syncConfigurationTask;
@Activate
public AddonSuggestionService(final @Reference ConfigurationAdmin configurationAdmin,
@Reference LocaleProvider localeProvider, @Nullable Map<String, Object> 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<String, Object> 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<String, Object> cfg = configurationAdmin.getConfiguration(CONFIG_PID).getProperties();
if (cfg == null) {
return;
}
final Map<String, Object> cfgMap = new HashMap<>();
final Enumeration<String> 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<AddonInfo> 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<AddonInfo> getSuggestedAddons(@Nullable Locale locale) {
return addonFinders.stream().filter(this::isFinderEnabled).map(f -> f.getSuggestedAddons())
.flatMap(Collection::stream).collect(Collectors.toSet());
}
}

View File

@ -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<String, Pattern> 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<AddonInfo> addonCandidates = List.of();
@Override
public void setAddonCandidates(List<AddonInfo> candidates) {
addonCandidates = candidates;
}
@Override
public void unsetAddonCandidates() {
addonCandidates = List.of();
}
public abstract String getServiceName();
}

View File

@ -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<String, Object> 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<AddonInfo> 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<AddonInfo> 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<AddonInfo> 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<AddonInfo> 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<AddonInfo> 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<AddonInfo> 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<AddonInfo> 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())));
}
}

View File

@ -35,6 +35,11 @@
<artifactId>org.openhab.core.config.discovery</artifactId> <artifactId>org.openhab.core.config.discovery</artifactId>
<version>${project.version}</version> <version>${project.version}</version>
</dependency> </dependency>
<dependency>
<groupId>org.openhab.core.bundles</groupId>
<artifactId>org.openhab.core.config.discovery.addon</artifactId>
<version>${project.version}</version>
</dependency>
<dependency> <dependency>
<groupId>org.openhab.core.bundles</groupId> <groupId>org.openhab.core.bundles</groupId>
<artifactId>org.openhab.core.io.rest</artifactId> <artifactId>org.openhab.core.io.rest</artifactId>

View File

@ -56,6 +56,7 @@ import org.openhab.core.config.core.ConfigDescription;
import org.openhab.core.config.core.ConfigDescriptionRegistry; import org.openhab.core.config.core.ConfigDescriptionRegistry;
import org.openhab.core.config.core.ConfigUtil; import org.openhab.core.config.core.ConfigUtil;
import org.openhab.core.config.core.Configuration; 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.Event;
import org.openhab.core.events.EventPublisher; import org.openhab.core.events.EventPublisher;
import org.openhab.core.io.rest.JSONResponse; import org.openhab.core.io.rest.JSONResponse;
@ -120,6 +121,7 @@ public class AddonResource implements RESTResource {
private final ConfigurationService configurationService; private final ConfigurationService configurationService;
private final AddonInfoRegistry addonInfoRegistry; private final AddonInfoRegistry addonInfoRegistry;
private final ConfigDescriptionRegistry configDescriptionRegistry; private final ConfigDescriptionRegistry configDescriptionRegistry;
private final AddonSuggestionService addonSuggestionService;
private @Context @NonNullByDefault({}) UriInfo uriInfo; private @Context @NonNullByDefault({}) UriInfo uriInfo;
@ -127,12 +129,14 @@ public class AddonResource implements RESTResource {
public AddonResource(final @Reference EventPublisher eventPublisher, final @Reference LocaleService localeService, public AddonResource(final @Reference EventPublisher eventPublisher, final @Reference LocaleService localeService,
final @Reference ConfigurationService configurationService, final @Reference ConfigurationService configurationService,
final @Reference AddonInfoRegistry addonInfoRegistry, final @Reference AddonInfoRegistry addonInfoRegistry,
final @Reference ConfigDescriptionRegistry configDescriptionRegistry) { final @Reference ConfigDescriptionRegistry configDescriptionRegistry,
final @Reference AddonSuggestionService addonSuggestionService) {
this.eventPublisher = eventPublisher; this.eventPublisher = eventPublisher;
this.localeService = localeService; this.localeService = localeService;
this.configurationService = configurationService; this.configurationService = configurationService;
this.addonInfoRegistry = addonInfoRegistry; this.addonInfoRegistry = addonInfoRegistry;
this.configDescriptionRegistry = configDescriptionRegistry; this.configDescriptionRegistry = configDescriptionRegistry;
this.addonSuggestionService = addonSuggestionService;
} }
@Reference(cardinality = ReferenceCardinality.MULTIPLE, policy = ReferencePolicy.DYNAMIC) @Reference(cardinality = ReferenceCardinality.MULTIPLE, policy = ReferencePolicy.DYNAMIC)
@ -178,6 +182,19 @@ public class AddonResource implements RESTResource {
return Response.ok(new Stream2JSONInputStream(addonTypeStream)).build(); 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 @GET
@Path("/types") @Path("/types")
@Produces(MediaType.APPLICATION_JSON) @Produces(MediaType.APPLICATION_JSON)

View File

@ -30,6 +30,11 @@
<artifactId>org.openhab.core.config.core</artifactId> <artifactId>org.openhab.core.config.core</artifactId>
<version>${project.version}</version> <version>${project.version}</version>
</dependency> </dependency>
<dependency>
<groupId>org.openhab.core.bundles</groupId>
<artifactId>org.openhab.core.config.discovery.addon</artifactId>
<version>${project.version}</version>
</dependency>
<dependency> <dependency>
<groupId>org.apache.karaf.features</groupId> <groupId>org.apache.karaf.features</groupId>
<artifactId>org.apache.karaf.features.core</artifactId> <artifactId>org.apache.karaf.features.core</artifactId>

View File

@ -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);
}
}
}

View File

@ -133,8 +133,7 @@ public class KarafAddonService implements AddonService {
AddonInfo addonInfo = addonInfoRegistry.getAddonInfo(uid, locale); AddonInfo addonInfo = addonInfoRegistry.getAddonInfo(uid, locale);
if (isInstalled && addonInfo != null) { if (addonInfo != null) {
// only enrich if this add-on is installed, otherwise wrong data might be added
addon = addon.withLabel(addonInfo.getName()).withDescription(addonInfo.getDescription()) addon = addon.withLabel(addonInfo.getName()).withDescription(addonInfo.getDescription())
.withConnection(addonInfo.getConnection()).withCountries(addonInfo.getCountries()) .withConnection(addonInfo.getConnection()).withCountries(addonInfo.getCountries())
.withLink(getDefaultDocumentationLink(type, name)) .withLink(getDefaultDocumentationLink(type, name))

View File

@ -17,6 +17,18 @@
expected. Enabling this option will include these entries in the list of available add-ons.</description> expected. Enabling this option will include these entries in the list of available add-ons.</description>
<default>false</default> <default>false</default>
</parameter> </parameter>
<parameter name="suggestionFinderUpnp" type="boolean">
<advanced>true</advanced>
<label>UPnP Suggestion Finder</label>
<description>Use UPnP network scan to suggest add-ons.</description>
<default>true</default>
</parameter>
<parameter name="suggestionFinderMdns" type="boolean">
<advanced>true</advanced>
<label>mDNS Suggestion Finder</label>
<description>Use mDNS network scan to suggest add-ons.</description>
<default>true</default>
</parameter>
</config-description> </config-description>
</config-description:config-descriptions> </config-description:config-descriptions>

View File

@ -30,6 +30,9 @@
<module>org.openhab.core.automation.rest</module> <module>org.openhab.core.automation.rest</module>
<module>org.openhab.core.config.core</module> <module>org.openhab.core.config.core</module>
<module>org.openhab.core.config.discovery</module> <module>org.openhab.core.config.discovery</module>
<module>org.openhab.core.config.discovery.addon</module>
<module>org.openhab.core.config.discovery.addon.mdns</module>
<module>org.openhab.core.config.discovery.addon.upnp</module>
<module>org.openhab.core.config.discovery.mdns</module> <module>org.openhab.core.config.discovery.mdns</module>
<module>org.openhab.core.config.discovery.usbserial</module> <module>org.openhab.core.config.discovery.usbserial</module>
<module>org.openhab.core.config.discovery.usbserial.linuxsysfs</module> <module>org.openhab.core.config.discovery.usbserial.linuxsysfs</module>

View File

@ -66,6 +66,29 @@
<bundle>mvn:org.openhab.core.bundles/org.openhab.core.io.rest/${project.version}</bundle> <bundle>mvn:org.openhab.core.bundles/org.openhab.core.io.rest/${project.version}</bundle>
<bundle>mvn:org.openhab.core.bundles/org.openhab.core.io.rest.core/${project.version}</bundle> <bundle>mvn:org.openhab.core.bundles/org.openhab.core.io.rest.core/${project.version}</bundle>
<bundle>mvn:org.openhab.core.bundles/org.openhab.core.io.rest.sse/${project.version}</bundle> <bundle>mvn:org.openhab.core.bundles/org.openhab.core.io.rest.sse/${project.version}</bundle>
<feature>openhab-core-config-discovery-addon</feature>
</feature>
<feature name="openhab-core-config-discovery-addon" version="${project.version}">
<feature>openhab-core-base</feature>
<bundle>mvn:org.openhab.core.bundles/org.openhab.core.config.discovery.addon/${project.version}</bundle>
</feature>
<feature name="openhab-core-config-discovery-addon-mdns" version="${project.version}">
<feature>openhab-core-base</feature>
<feature>openhab-core-config-discovery-addon</feature>
<bundle dependency="true">mvn:org.openhab.core.bundles/org.openhab.core.io.transport.mdns/${project.version}</bundle>
<bundle>mvn:org.openhab.core.bundles/org.openhab.core.config.discovery.addon.mdns/${project.version}</bundle>
<requirement>openhab.tp;filter:="(feature=jmdns)"</requirement>
<feature dependency="true">openhab.tp-jmdns</feature>
</feature>
<feature name="openhab-core-config-discovery-addon-upnp" version="${project.version}">
<feature>openhab-core-base</feature>
<feature>openhab-core-config-discovery-addon</feature>
<bundle>mvn:org.openhab.core.bundles/org.openhab.core.config.discovery.addon.upnp/${project.version}</bundle>
<requirement>openhab.tp;filter:="(feature=jupnp)"</requirement>
<feature dependency="true">openhab.tp-jupnp</feature>
</feature> </feature>
<feature name="openhab-core-addon-marketplace" version="${project.version}"> <feature name="openhab-core-addon-marketplace" version="${project.version}">