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

Add IP broadcast add-on finder for suggestions #4036

Merged
merged 6 commits into from
May 12, 2024
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,13 @@

import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.net.DatagramPacket;
import java.net.DatagramSocket;
import java.net.Inet4Address;
import java.net.InetAddress;
import java.net.InetSocketAddress;
import java.net.SocketAddress;
import java.net.SocketTimeoutException;
import java.net.StandardProtocolFamily;
import java.net.StandardSocketOptions;
import java.net.UnknownHostException;
Expand All @@ -28,6 +31,7 @@
import java.nio.channels.SelectionKey;
import java.nio.channels.Selector;
import java.text.ParseException;
import java.util.Arrays;
import java.util.HashSet;
import java.util.HexFormat;
import java.util.Iterator;
Expand All @@ -54,8 +58,12 @@
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.CidrAddress;
import org.openhab.core.net.NetUtil;
import org.openhab.core.net.NetworkAddressChangeListener;
import org.openhab.core.net.NetworkAddressService;
import org.openhab.core.util.StringUtils;
import org.osgi.service.component.annotations.Activate;
import org.osgi.service.component.annotations.Component;
import org.osgi.service.component.annotations.Deactivate;
import org.osgi.service.component.annotations.Reference;
Expand Down Expand Up @@ -173,14 +181,16 @@
* no continuous background scanning.
*
* @author Holger Friedrich - Initial contribution
* @author Jacob Laursen - Added support for broadcast-based scanning
*/
@NonNullByDefault
@Component(service = AddonFinder.class, name = IpAddonFinder.SERVICE_NAME)
public class IpAddonFinder extends BaseAddonFinder {
public class IpAddonFinder extends BaseAddonFinder implements NetworkAddressChangeListener {

public static final String SERVICE_TYPE = SERVICE_TYPE_IP;
public static final String SERVICE_NAME = SERVICE_NAME_IP;

private static final String TYPE_IP_BROADCAST = "ipBroadcast";
private static final String TYPE_IP_MULTICAST = "ipMulticast";
private static final String MATCH_PROPERTY_RESPONSE = "response";
private static final String PARAMETER_DEST_IP = "destIp";
Expand All @@ -194,28 +204,33 @@ public class IpAddonFinder extends BaseAddonFinder {
private static final String REPLACEMENT_UUID = "uuid";

private final Logger logger = LoggerFactory.getLogger(IpAddonFinder.class);
private final NetworkAddressService networkAddressService;
private final ScheduledExecutorService scheduler = ThreadPoolManager
.getScheduledPool(ThreadPoolManager.THREAD_POOL_NAME_COMMON);
private final Set<AddonService> addonServices = new CopyOnWriteArraySet<>();
private @Nullable Future<?> scanJob = null;
Set<AddonInfo> suggestions = new HashSet<>();

public IpAddonFinder() {
@Activate
public IpAddonFinder(final @Reference NetworkAddressService networkAddressService) {
logger.trace("IpAddonFinder::IpAddonFinder");
// start of scan will be triggered by setAddonCandidates to ensure addonCandidates are available
this.networkAddressService = networkAddressService;
this.networkAddressService.addNetworkAddressChangeListener(this);
}

@Deactivate
public void deactivate() {
logger.trace("IpAddonFinder::deactivate");
networkAddressService.removeNetworkAddressChangeListener(this);
stopScan();
}

@Override
public void setAddonCandidates(List<AddonInfo> candidates) {
logger.debug("IpAddonFinder::setAddonCandidates({})", candidates.size());
super.setAddonCandidates(candidates);
startScan();
startScan(20);
}

@Reference(cardinality = ReferenceCardinality.MULTIPLE, policy = ReferencePolicy.DYNAMIC)
Expand All @@ -227,14 +242,24 @@ protected void removeAddonService(AddonService featureService) {
this.addonServices.remove(featureService);
}

private void startScan() {
@Override
public void onChanged(List<CidrAddress> added, List<CidrAddress> removed) {
// Nothing to do
}

@Override
public void onPrimaryAddressChanged(@Nullable String oldPrimaryAddress, @Nullable String newPrimaryAddress) {
startScan(0);
}

private void startScan(long delayInSeconds) {
// The setAddonCandidates() method is called for each info provider.
// In order to do the scan only once, but on the full set of candidates, we have to delay the execution.
// At the same time we must make sure that a scheduled scan is rescheduled - or (after more than our delay) is
// executed once more.
stopScan();
logger.trace("Scheduling new IP scan");
scanJob = scheduler.schedule(this::scan, 20, TimeUnit.SECONDS);
scanJob = scheduler.schedule(this::scan, delayInSeconds, TimeUnit.SECONDS);
}

private void stopScan() {
Expand Down Expand Up @@ -269,15 +294,17 @@ private void scan() {

// parse standard set of parameters
String type = Objects.toString(parameters.get("type"), "");
String request = Objects.toString(parameters.get(PARAMETER_REQUEST), "");
String requestPlain = Objects.toString(parameters.get(PARAMETER_REQUEST_PLAIN), "");
String request = Objects.requireNonNull(Objects.toString(parameters.get(PARAMETER_REQUEST), ""));
String requestPlain = Objects
.requireNonNull(Objects.toString(parameters.get(PARAMETER_REQUEST_PLAIN), ""));
// xor
if (!("".equals(request) ^ "".equals(requestPlain))) {
logger.warn("{}: discovery-parameter '{}' or '{}' required", candidate.getUID(), PARAMETER_REQUEST,
PARAMETER_REQUEST_PLAIN);
continue;
}
String response = Objects.toString(matchProperties.get(MATCH_PROPERTY_RESPONSE), "");
String response = Objects
.requireNonNull(Objects.toString(matchProperties.get(MATCH_PROPERTY_RESPONSE), ""));
int timeoutMs;
try {
timeoutMs = Integer.parseInt(Objects.toString(parameters.get(PARAMETER_TIMEOUT_MS)));
Expand Down Expand Up @@ -322,69 +349,13 @@ private void scan() {
// handle known types
try {
switch (Objects.toString(type)) {
case TYPE_IP_BROADCAST:
scanBroadcast(candidate, request, requestPlain, response, timeoutMs, destPort);
break;
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, listenPort))
.setOption(StandardSocketOptions.IP_MULTICAST_TTL, 64).configureBlocking(false);
Selector selector = Selector.open()) {
byte[] requestArray = "".equals(requestPlain)
? buildRequestArray(channel, Objects.toString(request))
: buildRequestArrayPlain(channel, Objects.toString(requestPlain));
if (logger.isTraceEnabled()) {
InetSocketAddress sock = (InetSocketAddress) channel.getLocalAddress();
String id = candidate.getUID();
logger.trace("{}: probing {} -> {}:{}", id, localIp,
destIp != null ? destIp.getHostAddress() : "", destPort);
if (!"".equals(requestPlain)) {
logger.trace("{}: \'{}\'", id, new String(requestArray));
}
logger.trace("{}: {}", id,
HexFormat.of().withDelimiter(" ").formatHex(requestArray));
logger.trace("{}: listening on {}:{} for {} ms", id,
sock.getAddress().getHostAddress(), sock.getPort(), timeoutMs);
}

channel.send(ByteBuffer.wrap(requestArray),
new InetSocketAddress(destIp, destPort));

// listen to responses
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 received on {}", candidate.getUID(),
localIp);
}
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);
}
}
scanMulticast(candidate, request, requestPlain, response, timeoutMs, listenPort, destIp,
destPort);
break;

default:
logger.warn("{}: discovery-parameter type \"{}\" is unknown", candidate.getUID(), type);
}
Expand All @@ -396,10 +367,130 @@ private void scan() {
logger.trace("IpAddonFinder::scan completed");
}

private void scanBroadcast(AddonInfo candidate, String request, String requestPlain, String response, int timeoutMs,
int destPort) throws ParseException {
if (request.isEmpty() && requestPlain.isEmpty()) {
logger.warn("{}: match-property request and requestPlain \"{}\" is unknown", candidate.getUID(),
TYPE_IP_BROADCAST);
return;
}
if (!request.isEmpty() && !requestPlain.isEmpty()) {
logger.warn("{}: match-properties request and requestPlain \"{}\" are both present", candidate.getUID(),
TYPE_IP_BROADCAST);
return;
}
if (response.isEmpty()) {
logger.warn("{}: match-property response \"{}\" is unknown", candidate.getUID(), TYPE_IP_BROADCAST);
return;
}
String broadcastAddress = networkAddressService.getConfiguredBroadcastAddress();
logger.debug("Starting broadcast scan with address {}", broadcastAddress);

try (DatagramSocket socket = new DatagramSocket()) {
socket.setBroadcast(true);
socket.setSoTimeout(timeoutMs);
byte[] sendBuffer = requestPlain.isEmpty() ? buildRequestArray(socket.getLocalSocketAddress(), request)
: buildRequestArrayPlain(socket.getLocalSocketAddress(), requestPlain);
DatagramPacket sendPacket = new DatagramPacket(sendBuffer, sendBuffer.length,
InetAddress.getByName(broadcastAddress), destPort);
socket.send(sendPacket);

// wait for responses
while (!Thread.currentThread().isInterrupted()) {
byte[] discoverReceive = buildByteArray(response);
byte[] receiveBuffer = new byte[discoverReceive.length];
DatagramPacket receivePacket = new DatagramPacket(receiveBuffer, receiveBuffer.length);
try {
socket.receive(receivePacket);
} catch (SocketTimeoutException e) {
break; // leave the endless loop
}

byte[] data = receivePacket.getData();
if (Arrays.equals(data, discoverReceive)) {
suggestions.add(candidate);
logger.debug("Suggested add-on found: {}", candidate.getUID());
}
}
} catch (IOException e) {
logger.debug("{}: network error", candidate.getUID(), e);
}
}

private byte[] buildByteArray(String input) {
ByteArrayOutputStream requestFrame = new ByteArrayOutputStream();
StringTokenizer parts = new StringTokenizer(input);

while (parts.hasMoreTokens()) {
String token = parts.nextToken();
int i = Integer.decode(token);
requestFrame.write((byte) i);
}
return requestFrame.toByteArray();
}

private void scanMulticast(AddonInfo candidate, String request, String requestPlain, String response, int timeoutMs,
int listenPort, @Nullable InetAddress destIp, int destPort) throws ParseException {
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, listenPort))
.setOption(StandardSocketOptions.IP_MULTICAST_TTL, 64).configureBlocking(false);
Selector selector = Selector.open()) {
byte[] requestArray = "".equals(requestPlain)
? buildRequestArray(channel.getLocalAddress(), Objects.toString(request))
: buildRequestArrayPlain(channel.getLocalAddress(), Objects.toString(requestPlain));
if (logger.isTraceEnabled()) {
InetSocketAddress sock = (InetSocketAddress) channel.getLocalAddress();
String id = candidate.getUID();
logger.trace("{}: probing {} -> {}:{}", id, localIp, destIp != null ? destIp.getHostAddress() : "",
destPort);
if (!"".equals(requestPlain)) {
logger.trace("{}: \'{}\'", id, new String(requestArray));
}
logger.trace("{}: {}", id, HexFormat.of().withDelimiter(" ").formatHex(requestArray));
logger.trace("{}: listening on {}:{} for {} ms", id, sock.getAddress().getHostAddress(),
sock.getPort(), timeoutMs);
}

channel.send(ByteBuffer.wrap(requestArray), new InetSocketAddress(destIp, destPort));

// listen to responses
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 received on {}", candidate.getUID(), localIp);
}
break;
default:
logger.warn("{}: match-property response \"{}\" is unknown", candidate.getUID(),
TYPE_IP_MULTICAST);
break; // end loop
}
} catch (IOException e) {
logger.debug("{}: network error", candidate.getUID(), e);
}
}
}

// build from plaintext string
private byte[] buildRequestArrayPlain(DatagramChannel channel, String request)
private byte[] buildRequestArrayPlain(SocketAddress address, String request)
throws java.io.IOException, ParseException {
InetSocketAddress sock = (InetSocketAddress) channel.getLocalAddress();
InetSocketAddress sock = (InetSocketAddress) address;

// replace first
StringBuilder req = new StringBuilder(request);
Expand All @@ -420,9 +511,8 @@ private byte[] buildRequestArrayPlain(DatagramChannel channel, String request)
}

// build from hex string
private byte[] buildRequestArray(DatagramChannel channel, String request)
throws java.io.IOException, ParseException {
InetSocketAddress sock = (InetSocketAddress) channel.getLocalAddress();
private byte[] buildRequestArray(SocketAddress address, String request) throws java.io.IOException, ParseException {
InetSocketAddress sock = (InetSocketAddress) address;

ByteArrayOutputStream requestFrame = new ByteArrayOutputStream();
StringTokenizer parts = new StringTokenizer(request);
Expand Down