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