diff --git a/bom/openhab-core/pom.xml b/bom/openhab-core/pom.xml
index b13d80de934..0c9d4a359da 100644
--- a/bom/openhab-core/pom.xml
+++ b/bom/openhab-core/pom.xml
@@ -310,6 +310,12 @@
${project.version}
compile
+
+ org.openhab.core.bundles
+ org.openhab.core.config.discovery.addon.ip
+ ${project.version}
+ compile
+
org.openhab.core.bundles
org.openhab.core.config.discovery.addon.mdns
diff --git a/bundles/org.openhab.core.config.discovery.addon.ip/.classpath b/bundles/org.openhab.core.config.discovery.addon.ip/.classpath
new file mode 100644
index 00000000000..d3d6b3c11b6
--- /dev/null
+++ b/bundles/org.openhab.core.config.discovery.addon.ip/.classpath
@@ -0,0 +1,29 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/bundles/org.openhab.core.config.discovery.addon.ip/.project b/bundles/org.openhab.core.config.discovery.addon.ip/.project
new file mode 100644
index 00000000000..f2cee8bcbb9
--- /dev/null
+++ b/bundles/org.openhab.core.config.discovery.addon.ip/.project
@@ -0,0 +1,23 @@
+
+
+ org.openhab.core.config.discovery.addon.ip
+
+
+
+
+
+ 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.ip/NOTICE b/bundles/org.openhab.core.config.discovery.addon.ip/NOTICE
new file mode 100644
index 00000000000..6c17d0d8a45
--- /dev/null
+++ b/bundles/org.openhab.core.config.discovery.addon.ip/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.ip/pom.xml b/bundles/org.openhab.core.config.discovery.addon.ip/pom.xml
new file mode 100644
index 00000000000..d70ff9728c8
--- /dev/null
+++ b/bundles/org.openhab.core.config.discovery.addon.ip/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.ip
+
+ openHAB Core :: Bundles :: IP-based 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.ip/src/main/java/org/openhab/core/config/discovery/addon/ip/IpAddonFinder.java b/bundles/org.openhab.core.config.discovery.addon.ip/src/main/java/org/openhab/core/config/discovery/addon/ip/IpAddonFinder.java
new file mode 100644
index 00000000000..5864b19fc83
--- /dev/null
+++ b/bundles/org.openhab.core.config.discovery.addon.ip/src/main/java/org/openhab/core/config/discovery/addon/ip/IpAddonFinder.java
@@ -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 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 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 parameters = method.getParameters().stream()
+ .collect(Collectors.toMap(property -> property.getName(), property -> property.getValue()));
+ Map 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 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 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 getSuggestedAddons() {
+ logger.trace("IpAddonFinder::getSuggestedAddons {}/{}", suggestions.size(), addonCandidates.size());
+ return suggestions;
+ }
+
+ @Override
+ public String getServiceName() {
+ return SERVICE_NAME;
+ }
+}
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
index 76bef9d57a3..f3ce2c9112f 100644
--- 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
@@ -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 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);
+ public static final List SUGGESTION_FINDERS = List.of(SERVICE_NAME_IP, SERVICE_NAME_MDNS,
+ SERVICE_NAME_UPNP);
+ public static final Map 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 SUGGESTION_FINDER_FEATURES = Map.of(SERVICE_NAME_IP, FEATURE_IP,
+ SERVICE_NAME_MDNS, FEATURE_MDNS, SERVICE_NAME_UPNP, FEATURE_UPNP);
}
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 8859ef7814f..cbd0802400e 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
@@ -29,6 +29,12 @@
Use mDNS network scan to suggest add-ons. Enabling/disabling may take up to 1 minute.
true
+
+ true
+
+ Use IP network discovery broadcasts to suggest add-ons. Enabling/disabling may take up to 1 minute.
+ true
+
diff --git a/bundles/org.openhab.core/src/main/resources/OH-INF/i18n/addons.properties b/bundles/org.openhab.core/src/main/resources/OH-INF/i18n/addons.properties
index 46f8519531d..d63c1ac73a5 100644
--- a/bundles/org.openhab.core/src/main/resources/OH-INF/i18n/addons.properties
+++ b/bundles/org.openhab.core/src/main/resources/OH-INF/i18n/addons.properties
@@ -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
diff --git a/bundles/pom.xml b/bundles/pom.xml
index ad85d6f1b41..ca86361706e 100644
--- a/bundles/pom.xml
+++ b/bundles/pom.xml
@@ -31,6 +31,7 @@
org.openhab.core.config.core
org.openhab.core.config.discovery
org.openhab.core.config.discovery.addon
+ org.openhab.core.config.discovery.addon.ip
org.openhab.core.config.discovery.addon.mdns
org.openhab.core.config.discovery.addon.process
org.openhab.core.config.discovery.addon.upnp
diff --git a/features/karaf/openhab-core/src/main/feature/feature.xml b/features/karaf/openhab-core/src/main/feature/feature.xml
index 9ef66a80f73..44e3eb37a79 100644
--- a/features/karaf/openhab-core/src/main/feature/feature.xml
+++ b/features/karaf/openhab-core/src/main/feature/feature.xml
@@ -84,6 +84,12 @@
openhab.tp-jmdns
+
+ openhab-core-base
+ openhab-core-config-discovery-addon
+ mvn:org.openhab.core.bundles/org.openhab.core.config.discovery.addon.ip/${project.version}
+
+
openhab-core-base
openhab-core-config-discovery-addon