-
-
Notifications
You must be signed in to change notification settings - Fork 428
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
Changes from all commits
83c97ae
6e4f471
ded7540
7f94a12
99bdd55
e4b5a9b
16b7032
73dc742
acd6195
cfcbb79
179132e
468e01b
5deb85b
73b166f
fc38fdb
933c9e6
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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> |
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> |
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 | ||
|
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 | ||
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 ".*": | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. yes, in principle.... There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The long answer to this is..... 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. There was a problem hiding this comment. Choose a reason for hiding this commentThe 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; | ||
} | ||
} |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.