Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Service to suggest addons via generic IP scan #3920

Merged
merged 16 commits into from
Dec 17, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions bom/openhab-core/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -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>
Expand Down
29 changes: 29 additions & 0 deletions bundles/org.openhab.core.config.discovery.addon.ip/.classpath
Original file line number Diff line number Diff line change
@@ -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 changes: 23 additions & 0 deletions bundles/org.openhab.core.config.discovery.addon.ip/.project
Original file line number Diff line number Diff line change
@@ -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 changes: 14 additions & 0 deletions bundles/org.openhab.core.config.discovery.addon.ip/NOTICE
Original file line number Diff line number Diff line change
@@ -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 changes: 29 additions & 0 deletions bundles/org.openhab.core.config.discovery.addon.ip/pom.xml
Original file line number Diff line number Diff line change
@@ -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>
Original file line number Diff line number Diff line change
@@ -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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would expect an @activate method as well and a regular (scheduled) poll. Remember this should be not just for KNX. The whole finder can be switched off. If it is not switched off, it should poll for all defined ip broadcast finders on a schedule.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should also be addressed in a 4.2 PR. I understand the code to only do a scan on start of the finder or added AddonInfo. I would expect a regular scan. I think this is fine now, but I would like to see it improved in the future.

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;
holgerfriedrich marked this conversation as resolved.
Show resolved Hide resolved
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 ".*":
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think this can be generalized. Can't you read the buffer, convert to String and do a regex on it?
We could leave this improvement for later when we make more add-ons discoverable through this finder. But it should be improved.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yes, in principle....

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The long answer to this is.....
Yes of course we can hex dump the received frame and do a regex match. Maybe the regex needs to be dynamic (using $srcIp etc before doing the match).
I would need to change the receive loop, as just taking the first frame and return is no longer possible. I.e. a receive loop, which takes care for the overall timeout and then checking every frame within the time period.

Not sure if this is required for the first implementation, I would probably leave a shortcut for ".*" in anyway as it avoids the regex matching etc.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am OK to improve this for 4.2. I hope you will do some work to look at all bindings that use this type of discovery and try to cover more of these. Start by defining criteria, and then see where this core matching needs to ba adapted.

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;
}
}
Loading