mirror of
https://github.com/danieldemus/openhab-core.git
synced 2025-01-10 13:21:53 +01:00
Service to suggest addons via generic IP scan (#3920)
* Service to suggest addons via generic IP scan Signed-off-by: Holger Friedrich <mail@holger-friedrich.de>
This commit is contained in:
parent
2c9312e55c
commit
8bed621c8c
@ -310,6 +310,12 @@
|
||||
<version>${project.version}</version>
|
||||
<scope>compile</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.openhab.core.bundles</groupId>
|
||||
<artifactId>org.openhab.core.config.discovery.addon.ip</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>
|
||||
|
@ -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>
|
23
bundles/org.openhab.core.config.discovery.addon.ip/.project
Normal file
23
bundles/org.openhab.core.config.discovery.addon.ip/.project
Normal file
@ -0,0 +1,23 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<projectDescription>
|
||||
<name>org.openhab.core.config.discovery.addon.ip</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>
|
14
bundles/org.openhab.core.config.discovery.addon.ip/NOTICE
Normal file
14
bundles/org.openhab.core.config.discovery.addon.ip/NOTICE
Normal 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
|
||||
|
29
bundles/org.openhab.core.config.discovery.addon.ip/pom.xml
Normal file
29
bundles/org.openhab.core.config.discovery.addon.ip/pom.xml
Normal 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.ip</artifactId>
|
||||
|
||||
<name>openHAB Core :: Bundles :: IP-based 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>
|
@ -0,0 +1,278 @@
|
||||
/**
|
||||
* 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.ip;
|
||||
|
||||
import static org.openhab.core.config.discovery.addon.AddonFinderConstants.SERVICE_NAME_IP;
|
||||
import static org.openhab.core.config.discovery.addon.AddonFinderConstants.SERVICE_TYPE_IP;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.net.Inet4Address;
|
||||
import java.net.InetAddress;
|
||||
import java.net.InetSocketAddress;
|
||||
import java.net.SocketAddress;
|
||||
import java.net.StandardProtocolFamily;
|
||||
import java.net.StandardSocketOptions;
|
||||
import java.net.UnknownHostException;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.channels.DatagramChannel;
|
||||
import java.nio.channels.SelectionKey;
|
||||
import java.nio.channels.Selector;
|
||||
import java.text.ParseException;
|
||||
import java.util.HashSet;
|
||||
import java.util.HexFormat;
|
||||
import java.util.Iterator;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.StringTokenizer;
|
||||
import java.util.concurrent.Future;
|
||||
import java.util.concurrent.ScheduledExecutorService;
|
||||
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.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.net.NetUtil;
|
||||
import org.osgi.service.component.annotations.Component;
|
||||
import org.osgi.service.component.annotations.Deactivate;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
/**
|
||||
* This is a {@link IpAddonFinder} for finding suggested add-ons by sending IP packets to the
|
||||
* network and collecting responses.
|
||||
*
|
||||
* @implNote On activation, a thread is spawned which handles the detection. Scan runs once,
|
||||
* no continuous background scanning.
|
||||
*
|
||||
* @author Holger Friedrich - Initial contribution
|
||||
*/
|
||||
@NonNullByDefault
|
||||
@Component(service = AddonFinder.class, name = IpAddonFinder.SERVICE_NAME)
|
||||
public class IpAddonFinder extends BaseAddonFinder {
|
||||
|
||||
public static final String SERVICE_TYPE = SERVICE_TYPE_IP;
|
||||
public static final String SERVICE_NAME = SERVICE_NAME_IP;
|
||||
|
||||
private static final String TYPE_IP_MULTICAST = "ipMulticast";
|
||||
private static final String MATCH_PROPERTY_RESPONSE = "response";
|
||||
private static final String PARAMETER_DEST_IP = "destIp";
|
||||
private static final String PARAMETER_DEST_PORT = "destPort";
|
||||
private static final String PARAMETER_REQUEST = "request";
|
||||
private static final String PARAMETER_SRC_IP = "srcIp";
|
||||
private static final String PARAMETER_SRC_PORT = "srcPort";
|
||||
private static final String PARAMETER_TIMEOUT_MS = "timeoutMs";
|
||||
|
||||
private final Logger logger = LoggerFactory.getLogger(IpAddonFinder.class);
|
||||
private final ScheduledExecutorService scheduler = ThreadPoolManager.getScheduledPool(SERVICE_NAME);
|
||||
private @Nullable Future<?> scanJob = null;
|
||||
Set<AddonInfo> suggestions = new HashSet<>();
|
||||
|
||||
public IpAddonFinder() {
|
||||
logger.trace("IpAddonFinder::IpAddonFinder");
|
||||
// start of scan will be triggered by setAddonCandidates to ensure addonCandidates are available
|
||||
}
|
||||
|
||||
@Deactivate
|
||||
public void deactivate() {
|
||||
logger.trace("IpAddonFinder::deactivate");
|
||||
stopScan();
|
||||
}
|
||||
|
||||
public void setAddonCandidates(List<AddonInfo> candidates) {
|
||||
logger.debug("IpAddonFinder::setAddonCandidates({})", candidates.size());
|
||||
super.setAddonCandidates(candidates);
|
||||
startScan();
|
||||
}
|
||||
|
||||
synchronized void startScan() {
|
||||
if (scanJob == null) {
|
||||
scanJob = scheduler.schedule(this::scan, 1, TimeUnit.SECONDS);
|
||||
}
|
||||
}
|
||||
|
||||
void stopScan() {
|
||||
Future<?> tmpScanJob = scanJob;
|
||||
if (tmpScanJob != null) {
|
||||
if (!tmpScanJob.isDone()) {
|
||||
logger.trace("Trying to cancel IP scan");
|
||||
tmpScanJob.cancel(true);
|
||||
try {
|
||||
Thread.sleep(1000);
|
||||
} catch (InterruptedException ignore) {
|
||||
}
|
||||
}
|
||||
scanJob = null;
|
||||
}
|
||||
}
|
||||
|
||||
void scan() {
|
||||
logger.trace("IpAddonFinder::scan started");
|
||||
for (AddonInfo candidate : addonCandidates) {
|
||||
for (AddonDiscoveryMethod method : candidate.getDiscoveryMethods().stream()
|
||||
.filter(method -> SERVICE_TYPE.equals(method.getServiceType())).toList()) {
|
||||
|
||||
logger.trace("Checking candidate: {}", candidate.getUID());
|
||||
|
||||
Map<String, String> parameters = method.getParameters().stream()
|
||||
.collect(Collectors.toMap(property -> property.getName(), property -> property.getValue()));
|
||||
Map<String, String> matchProperties = method.getMatchProperties().stream()
|
||||
.collect(Collectors.toMap(property -> property.getName(), property -> property.getRegex()));
|
||||
|
||||
// parse standard set op parameters:
|
||||
String type = Objects.toString(parameters.get("type"), "");
|
||||
String request = Objects.toString(parameters.get(PARAMETER_REQUEST), "");
|
||||
String response = Objects.toString(matchProperties.get(MATCH_PROPERTY_RESPONSE), "");
|
||||
int timeoutMs = 0;
|
||||
try {
|
||||
timeoutMs = Integer.parseInt(Objects.toString(parameters.get(PARAMETER_TIMEOUT_MS)));
|
||||
} catch (NumberFormatException e) {
|
||||
logger.warn("{}: discovery-parameter '{}' cannot be parsed", candidate.getUID(),
|
||||
PARAMETER_TIMEOUT_MS);
|
||||
continue;
|
||||
}
|
||||
@Nullable
|
||||
InetAddress destIp = null;
|
||||
try {
|
||||
destIp = InetAddress.getByName(parameters.get(PARAMETER_DEST_IP));
|
||||
} catch (UnknownHostException e) {
|
||||
logger.warn("{}: discovery-parameter '{}' cannot be parsed", candidate.getUID(), PARAMETER_DEST_IP);
|
||||
continue;
|
||||
}
|
||||
int destPort = 0;
|
||||
try {
|
||||
destPort = Integer.parseInt(Objects.toString(parameters.get(PARAMETER_DEST_PORT)));
|
||||
} catch (NumberFormatException e) {
|
||||
logger.warn("{}: discovery-parameter '{}' cannot be parsed", candidate.getUID(),
|
||||
PARAMETER_DEST_PORT);
|
||||
continue;
|
||||
}
|
||||
|
||||
//
|
||||
// handle known types
|
||||
//
|
||||
try {
|
||||
switch (Objects.toString(type)) {
|
||||
case TYPE_IP_MULTICAST:
|
||||
List<String> ipAddresses = NetUtil.getAllInterfaceAddresses().stream()
|
||||
.filter(a -> a.getAddress() instanceof Inet4Address)
|
||||
.map(a -> a.getAddress().getHostAddress()).toList();
|
||||
|
||||
for (String localIp : ipAddresses) {
|
||||
try {
|
||||
DatagramChannel channel = (DatagramChannel) DatagramChannel
|
||||
.open(StandardProtocolFamily.INET)
|
||||
.setOption(StandardSocketOptions.SO_REUSEADDR, true)
|
||||
.bind(new InetSocketAddress(localIp, 0))
|
||||
.setOption(StandardSocketOptions.IP_MULTICAST_TTL, 64)
|
||||
.configureBlocking(false);
|
||||
|
||||
byte[] requestArray = buildRequestArray(channel, Objects.toString(request));
|
||||
logger.trace("{}: {}", candidate.getUID(),
|
||||
HexFormat.of().withDelimiter(" ").formatHex(requestArray));
|
||||
|
||||
channel.send(ByteBuffer.wrap(requestArray),
|
||||
new InetSocketAddress(destIp, destPort));
|
||||
|
||||
// listen to responses
|
||||
Selector selector = Selector.open();
|
||||
ByteBuffer buffer = ByteBuffer.wrap(new byte[50]);
|
||||
channel.register(selector, SelectionKey.OP_READ);
|
||||
selector.select(timeoutMs);
|
||||
Iterator<SelectionKey> it = selector.selectedKeys().iterator();
|
||||
|
||||
switch (Objects.toString(response)) {
|
||||
case ".*":
|
||||
if (it.hasNext()) {
|
||||
final SocketAddress source = ((DatagramChannel) it.next().channel())
|
||||
.receive(buffer);
|
||||
logger.debug("Received return frame from {}",
|
||||
((InetSocketAddress) source).getAddress().getHostAddress());
|
||||
suggestions.add(candidate);
|
||||
logger.debug("Suggested add-on found: {}", candidate.getUID());
|
||||
} else {
|
||||
logger.trace("{}: no response", candidate.getUID());
|
||||
}
|
||||
break;
|
||||
default:
|
||||
logger.warn("{}: match-property response \"{}\" is unknown",
|
||||
candidate.getUID(), type);
|
||||
break; // end loop
|
||||
}
|
||||
|
||||
} catch (IOException e) {
|
||||
logger.debug("{}: network error", candidate.getUID(), e);
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
logger.warn("{}: discovery-parameter type \"{}\" is unknown", candidate.getUID(), type);
|
||||
}
|
||||
} catch (ParseException | NumberFormatException none) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
logger.trace("IpAddonFinder::scan completed");
|
||||
}
|
||||
|
||||
byte[] buildRequestArray(DatagramChannel channel, String request) throws java.io.IOException, ParseException {
|
||||
InetSocketAddress sock = (InetSocketAddress) channel.getLocalAddress();
|
||||
|
||||
ByteArrayOutputStream requestFrame = new ByteArrayOutputStream();
|
||||
StringTokenizer parts = new StringTokenizer(request);
|
||||
|
||||
while (parts.hasMoreTokens()) {
|
||||
String token = parts.nextToken();
|
||||
if (token.startsWith("$")) {
|
||||
switch (token) {
|
||||
case "$" + PARAMETER_SRC_IP:
|
||||
byte[] adr = sock.getAddress().getAddress();
|
||||
requestFrame.write(adr);
|
||||
break;
|
||||
case "$" + PARAMETER_SRC_PORT:
|
||||
int dPort = sock.getPort();
|
||||
requestFrame.write((byte) ((dPort >> 8) & 0xff));
|
||||
requestFrame.write((byte) (dPort & 0xff));
|
||||
break;
|
||||
default:
|
||||
logger.warn("Unknown token in request frame \"{}\"", token);
|
||||
throw new ParseException(token, 0);
|
||||
}
|
||||
} else {
|
||||
int i = Integer.decode(token);
|
||||
requestFrame.write((byte) i);
|
||||
}
|
||||
}
|
||||
return requestFrame.toByteArray();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Set<AddonInfo> getSuggestedAddons() {
|
||||
logger.trace("IpAddonFinder::getSuggestedAddons {}/{}", suggestions.size(), addonCandidates.size());
|
||||
return suggestions;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getServiceName() {
|
||||
return SERVICE_NAME;
|
||||
}
|
||||
}
|
@ -28,6 +28,11 @@ public class AddonFinderConstants {
|
||||
public 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_IP = "ip";
|
||||
public static final String CFG_FINDER_IP = "suggestionFinderIp";
|
||||
public static final String SERVICE_NAME_IP = SERVICE_TYPE_IP + ADDON_SUGGESTION_FINDER;
|
||||
public static final String FEATURE_IP = ADDON_SUGGESTION_FINDER_FEATURE + SERVICE_TYPE_IP;
|
||||
|
||||
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;
|
||||
@ -38,9 +43,10 @@ public class AddonFinderConstants {
|
||||
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);
|
||||
public static final List<String> SUGGESTION_FINDERS = List.of(SERVICE_NAME_IP, SERVICE_NAME_MDNS,
|
||||
SERVICE_NAME_UPNP);
|
||||
public static final Map<String, String> SUGGESTION_FINDER_CONFIGS = Map.of(SERVICE_NAME_IP, CFG_FINDER_IP,
|
||||
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_IP, FEATURE_IP,
|
||||
SERVICE_NAME_MDNS, FEATURE_MDNS, SERVICE_NAME_UPNP, FEATURE_UPNP);
|
||||
}
|
||||
|
@ -29,6 +29,12 @@
|
||||
<description>Use mDNS network scan to suggest add-ons. Enabling/disabling may take up to 1 minute.</description>
|
||||
<default>true</default>
|
||||
</parameter>
|
||||
<parameter name="suggestionFinderIp" type="boolean">
|
||||
<advanced>true</advanced>
|
||||
<label>IP-based Suggestion Finder</label>
|
||||
<description>Use IP network discovery broadcasts to suggest add-ons. Enabling/disabling may take up to 1 minute.</description>
|
||||
<default>true</default>
|
||||
</parameter>
|
||||
</config-description>
|
||||
|
||||
</config-description:config-descriptions>
|
||||
|
@ -2,6 +2,8 @@ system.config.addons.includeIncompatible.label = Include (Potentially) Incompati
|
||||
system.config.addons.includeIncompatible.description = Some add-on services may provide add-ons where compatibility with the currently running system is not expected. Enabling this option will include these entries in the list of available add-ons.
|
||||
system.config.addons.remote.label = Access Remote Repository
|
||||
system.config.addons.remote.description = Defines whether openHAB should access the remote repository for add-on installation.
|
||||
system.config.addons.suggestionFinderIp.label = IP-based Suggestion Finder
|
||||
system.config.addons.suggestionFinderIp.description = Use IP network discovery broadcasts to suggest add-ons. Enabling/disabling may take up to 1 minute.
|
||||
system.config.addons.suggestionFinderMdns.label = mDNS Suggestion Finder
|
||||
system.config.addons.suggestionFinderMdns.description = Use mDNS network scan to suggest add-ons. Enabling/disabling may take up to 1 minute.
|
||||
system.config.addons.suggestionFinderUpnp.label = UPnP Suggestion Finder
|
||||
|
@ -31,6 +31,7 @@
|
||||
<module>org.openhab.core.config.core</module>
|
||||
<module>org.openhab.core.config.discovery</module>
|
||||
<module>org.openhab.core.config.discovery.addon</module>
|
||||
<module>org.openhab.core.config.discovery.addon.ip</module>
|
||||
<module>org.openhab.core.config.discovery.addon.mdns</module>
|
||||
<module>org.openhab.core.config.discovery.addon.process</module>
|
||||
<module>org.openhab.core.config.discovery.addon.upnp</module>
|
||||
|
@ -84,6 +84,12 @@
|
||||
<feature dependency="true">openhab.tp-jmdns</feature>
|
||||
</feature>
|
||||
|
||||
<feature name="openhab-core-config-discovery-addon-ip" 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.ip/${project.version}</bundle>
|
||||
</feature>
|
||||
|
||||
<feature name="openhab-core-config-discovery-addon-upnp" version="${project.version}">
|
||||
<feature>openhab-core-base</feature>
|
||||
<feature>openhab-core-config-discovery-addon</feature>
|
||||
|
Loading…
Reference in New Issue
Block a user