[discovery] Add ser2net mDNS USB serial discovery (#2519)

* Add ser2net mDNS USB serial discovery

* Add support for using multiple UsbSerialDiscovery services
* Add Ser2NetUsbSerialDiscovery that can use mDNS to discover ser2net RFC2217 serial ports
* Use discovered USB ports in SerialConfigOptionProvider

mDNS discovery is supported in ser2net 4.3.0 and newer.
E.g. you can install a ser2net version that provides this using APT in Ubuntu 21.04 and Debian 11.

Example ser2net YAML configuration that allows a serial port to be discovered using mDNS discovery:

%YAML 1.1
---
connection: &con01
  accepter: telnet(rfc2217),tcp,2222
  connector: serialdev,/dev/ttyUSB0
  options:
    mdns: true
    mdns-sysattrs: true
    mdns-name: devicename

Closes #1511

Signed-off-by: Wouter Born <github@maindrain.net>
This commit is contained in:
Wouter Born 2021-11-12 23:27:50 +01:00 committed by GitHub
parent 5d5b7665b4
commit 917e268e68
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 835 additions and 45 deletions

View File

@ -316,6 +316,12 @@
<version>${project.version}</version>
<scope>compile</scope>
</dependency>
<dependency>
<groupId>org.openhab.core.bundles</groupId>
<artifactId>org.openhab.core.config.discovery.usbserial.ser2net</artifactId>
<version>${project.version}</version>
<scope>compile</scope>
</dependency>
<dependency>
<groupId>org.openhab.core.bundles</groupId>
<artifactId>org.openhab.core.config.discovery.upnp</artifactId>

View File

@ -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="con" path="org.eclipse.jdt.launching.JRE_CONTAINER/org.eclipse.jdt.internal.debug.ui.launcher.StandardVMType/JavaSE-11">
<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="src" output="target/test-classes" path="src/test/java">
<attributes>
<attribute name="optional" value="true"/>
<attribute name="maven.pomderived" value="true"/>
<attribute name="test" value="true"/>
</attributes>
</classpathentry>
<classpathentry kind="output" path="target/classes"/>
</classpath>

View File

@ -0,0 +1,23 @@
<?xml version="1.0" encoding="UTF-8"?>
<projectDescription>
<name>org.openhab.core.config.discovery.usbserial.ser2net</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.jdt.core.javanature</nature>
<nature>org.eclipse.m2e.core.maven2Nature</nature>
</natures>
</projectDescription>

View File

@ -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

View File

@ -0,0 +1,30 @@
<?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 http://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>3.2.0-SNAPSHOT</version>
</parent>
<artifactId>org.openhab.core.config.discovery.usbserial.ser2net</artifactId>
<name>openHAB Core :: Bundles :: Configuration USB-Serial Discovery using ser2net mDNS scanning</name>
<dependencies>
<dependency>
<groupId>org.openhab.core.bundles</groupId>
<artifactId>org.openhab.core.config.discovery.usbserial</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>org.openhab.core.bundles</groupId>
<artifactId>org.openhab.core.io.transport.mdns</artifactId>
<version>${project.version}</version>
</dependency>
</dependencies>
</project>

View File

@ -0,0 +1,206 @@
/**
* Copyright (c) 2010-2021 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.usbserial.ser2net.internal;
import java.time.Duration;
import java.util.HashSet;
import java.util.Optional;
import java.util.Set;
import java.util.concurrent.CopyOnWriteArraySet;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import javax.jmdns.ServiceEvent;
import javax.jmdns.ServiceInfo;
import javax.jmdns.ServiceListener;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.openhab.core.config.discovery.usbserial.UsbSerialDeviceInformation;
import org.openhab.core.config.discovery.usbserial.UsbSerialDiscovery;
import org.openhab.core.config.discovery.usbserial.UsbSerialDiscoveryListener;
import org.openhab.core.io.transport.mdns.MDNSClient;
import org.osgi.service.component.annotations.Activate;
import org.osgi.service.component.annotations.Component;
import org.osgi.service.component.annotations.Reference;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* A {@link UsbSerialDiscovery} that implements background discovery of RFC2217 by listening to
* ser2net mDNS service events.
*
* @author Wouter Born - Initial contribution
*/
@NonNullByDefault
@Component(service = UsbSerialDiscovery.class)
public class Ser2NetUsbSerialDiscovery implements ServiceListener, UsbSerialDiscovery {
private final Logger logger = LoggerFactory.getLogger(Ser2NetUsbSerialDiscovery.class);
static final String SERVICE_TYPE = "_iostream._tcp.local.";
static final String PROPERTY_PROVIDER = "provider";
static final String PROPERTY_DEVICE_TYPE = "devicetype";
static final String PROPERTY_GENSIO_STACK = "gensiostack";
static final String PROPERTY_VENDOR_ID = "idVendor";
static final String PROPERTY_PRODUCT_ID = "idProduct";
static final String PROPERTY_SERIAL_NUMBER = "serial";
static final String PROPERTY_MANUFACTURER = "manufacturer";
static final String PROPERTY_PRODUCT = "product";
static final String PROPERTY_INTERFACE_NUMBER = "bInterfaceNumber";
static final String PROPERTY_INTERFACE = "interface";
static final String SER2NET = "ser2net";
static final String SERIALUSB = "serialusb";
static final String TELNET_RFC2217_TCP = "telnet(rfc2217),tcp";
static final Duration SINGLE_SCAN_DURATION = Duration.ofSeconds(5);
static final String SERIAL_PORT_NAME_FORMAT = "rfc2217://%s:%s";
private final Set<UsbSerialDiscoveryListener> discoveryListeners = new CopyOnWriteArraySet<>();
private final MDNSClient mdnsClient;
private boolean notifyListeners = false;
private Set<UsbSerialDeviceInformation> lastScanResult = new HashSet<>();
@Activate
public Ser2NetUsbSerialDiscovery(final @Reference MDNSClient mdnsClient) {
this.mdnsClient = mdnsClient;
}
@Override
public void registerDiscoveryListener(UsbSerialDiscoveryListener listener) {
discoveryListeners.add(listener);
}
@Override
public void unregisterDiscoveryListener(UsbSerialDiscoveryListener listener) {
discoveryListeners.remove(listener);
}
@Override
public synchronized void startBackgroundScanning() {
notifyListeners = true;
mdnsClient.addServiceListener(SERVICE_TYPE, this);
logger.debug("Started ser2net USB-Serial mDNS background discovery");
}
@Override
public synchronized void stopBackgroundScanning() {
notifyListeners = false;
mdnsClient.removeServiceListener(SERVICE_TYPE, this);
logger.debug("Stopped ser2net USB-Serial mDNS background discovery");
}
@Override
public synchronized void doSingleScan() {
logger.debug("Starting ser2net USB-Serial mDNS single discovery scan");
Set<UsbSerialDeviceInformation> scanResult = Stream.of(mdnsClient.list(SERVICE_TYPE, SINGLE_SCAN_DURATION))
.map(this::createUsbSerialDeviceInformation) //
.filter(Optional::isPresent) //
.map(Optional::get) //
.collect(Collectors.toSet());
Set<UsbSerialDeviceInformation> added = setDifference(scanResult, lastScanResult);
Set<UsbSerialDeviceInformation> removed = setDifference(lastScanResult, scanResult);
Set<UsbSerialDeviceInformation> unchanged = setDifference(scanResult, added);
lastScanResult = scanResult;
removed.stream().forEach(this::announceRemovedDevice);
added.stream().forEach(this::announceAddedDevice);
unchanged.stream().forEach(this::announceAddedDevice);
logger.debug("Completed ser2net USB-Serial mDNS single discovery scan");
}
private <T> Set<T> setDifference(Set<T> set1, Set<T> set2) {
Set<T> result = new HashSet<>(set1);
result.removeAll(set2);
return result;
}
private void announceAddedDevice(UsbSerialDeviceInformation deviceInfo) {
for (UsbSerialDiscoveryListener listener : discoveryListeners) {
listener.usbSerialDeviceDiscovered(deviceInfo);
}
}
private void announceRemovedDevice(UsbSerialDeviceInformation deviceInfo) {
for (UsbSerialDiscoveryListener listener : discoveryListeners) {
listener.usbSerialDeviceRemoved(deviceInfo);
}
}
@Override
public void serviceAdded(@NonNullByDefault({}) ServiceEvent event) {
if (notifyListeners) {
Optional<UsbSerialDeviceInformation> deviceInfo = createUsbSerialDeviceInformation(event.getInfo());
deviceInfo.ifPresent(this::announceAddedDevice);
}
}
@Override
public void serviceRemoved(@NonNullByDefault({}) ServiceEvent event) {
if (notifyListeners) {
Optional<UsbSerialDeviceInformation> deviceInfo = createUsbSerialDeviceInformation(event.getInfo());
deviceInfo.ifPresent(this::announceRemovedDevice);
}
}
@Override
public void serviceResolved(@NonNullByDefault({}) ServiceEvent event) {
serviceAdded(event);
}
private Optional<UsbSerialDeviceInformation> createUsbSerialDeviceInformation(ServiceInfo serviceInfo) {
String provider = serviceInfo.getPropertyString(PROPERTY_PROVIDER);
String deviceType = serviceInfo.getPropertyString(PROPERTY_DEVICE_TYPE);
String gensioStack = serviceInfo.getPropertyString(PROPERTY_GENSIO_STACK);
// Check ser2net specific properties when present
if (SER2NET.equals(provider) && (deviceType != null && !SERIALUSB.equals(deviceType))
|| (gensioStack != null && !TELNET_RFC2217_TCP.equals(gensioStack))) {
logger.debug("Skipping creation of UsbSerialDeviceInformation based on {}", serviceInfo);
return Optional.empty();
}
try {
int vendorId = Integer.parseInt(serviceInfo.getPropertyString(PROPERTY_VENDOR_ID), 16);
int productId = Integer.parseInt(serviceInfo.getPropertyString(PROPERTY_PRODUCT_ID), 16);
String serialNumber = serviceInfo.getPropertyString(PROPERTY_SERIAL_NUMBER);
String manufacturer = serviceInfo.getPropertyString(PROPERTY_MANUFACTURER);
String product = serviceInfo.getPropertyString(PROPERTY_PRODUCT);
int interfaceNumber = Integer.parseInt(serviceInfo.getPropertyString(PROPERTY_INTERFACE_NUMBER), 16);
String interfaceDescription = serviceInfo.getPropertyString(PROPERTY_INTERFACE);
String serialPortName = String.format(SERIAL_PORT_NAME_FORMAT, serviceInfo.getHostAddresses()[0],
serviceInfo.getPort());
UsbSerialDeviceInformation deviceInfo = new UsbSerialDeviceInformation(vendorId, productId, serialNumber,
manufacturer, product, interfaceNumber, interfaceDescription, serialPortName);
logger.debug("Created {} based on {}", deviceInfo, serviceInfo);
return Optional.of(deviceInfo);
} catch (NumberFormatException e) {
logger.debug("Failed to create UsbSerialDeviceInformation based on {}", serviceInfo, e);
return Optional.empty();
}
}
}

View File

@ -0,0 +1,269 @@
/**
* Copyright (c) 2010-2021 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.usbserial.ser2net.internal;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
import static org.openhab.core.config.discovery.usbserial.ser2net.internal.Ser2NetUsbSerialDiscovery.*;
import java.io.IOException;
import java.time.Duration;
import javax.jmdns.ServiceEvent;
import javax.jmdns.ServiceInfo;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentMatchers;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.mockito.junit.jupiter.MockitoSettings;
import org.mockito.quality.Strictness;
import org.openhab.core.config.discovery.usbserial.UsbSerialDeviceInformation;
import org.openhab.core.config.discovery.usbserial.UsbSerialDiscoveryListener;
import org.openhab.core.io.transport.mdns.MDNSClient;
/**
* Unit tests for the {@link Ser2NetUsbSerialDiscovery}.
*
* @author Wouter Born - Initial contribution
*/
@NonNullByDefault
@ExtendWith(MockitoExtension.class)
@MockitoSettings(strictness = Strictness.LENIENT)
public class Ser2NetUsbSerialDiscoveryTest {
private @Mock @NonNullByDefault({}) UsbSerialDiscoveryListener discoveryListenerMock;
private @Mock @NonNullByDefault({}) MDNSClient mdnsClientMock;
private @Mock @NonNullByDefault({}) ServiceInfo serviceInfo1Mock;
private @Mock @NonNullByDefault({}) ServiceInfo serviceInfo2Mock;
private @Mock @NonNullByDefault({}) ServiceInfo serviceInfo3Mock;
private @Mock @NonNullByDefault({}) ServiceInfo invalidServiceInfoMock;
private @Mock @NonNullByDefault({}) ServiceEvent serviceEvent1Mock;
private @Mock @NonNullByDefault({}) ServiceEvent serviceEvent2Mock;
private @Mock @NonNullByDefault({}) ServiceEvent serviceEvent3Mock;
private @Mock @NonNullByDefault({}) ServiceEvent invalidServiceEventMock;
private @NonNullByDefault({}) Ser2NetUsbSerialDiscovery discovery;
private UsbSerialDeviceInformation usb1 = new UsbSerialDeviceInformation(0x100, 0x111, "serial1", "manufacturer1",
"product1", 0x1, "interface1", "rfc2217://1.1.1.1:1000");
private UsbSerialDeviceInformation usb2 = new UsbSerialDeviceInformation(0x200, 0x222, "serial2", "manufacturer2",
"product2", 0x2, "interface2", "rfc2217://[0:0:0:0:0:ffff:0202:0202]:2222");
private UsbSerialDeviceInformation usb3 = new UsbSerialDeviceInformation(0x300, 0x333, null, null, null, 0x3, null,
"rfc2217://123.222.100.000:3030");
@BeforeEach
public void beforeEach() {
discovery = new Ser2NetUsbSerialDiscovery(mdnsClientMock);
discovery.registerDiscoveryListener(discoveryListenerMock);
setupServiceInfo1Mock();
setupServiceInfo2Mock();
setupServiceInfo3Mock();
setupInvalidServiceInfoMock();
when(serviceEvent1Mock.getInfo()).thenReturn(serviceInfo1Mock);
when(serviceEvent2Mock.getInfo()).thenReturn(serviceInfo2Mock);
when(serviceEvent3Mock.getInfo()).thenReturn(serviceInfo3Mock);
when(invalidServiceEventMock.getInfo()).thenReturn(invalidServiceInfoMock);
}
private void setupServiceInfo1Mock() {
when(serviceInfo1Mock.getHostAddresses()).thenReturn(new String[] { "1.1.1.1" });
when(serviceInfo1Mock.getPort()).thenReturn(1000);
when(serviceInfo1Mock.getPropertyString(PROPERTY_VENDOR_ID)).thenReturn("0100");
when(serviceInfo1Mock.getPropertyString(PROPERTY_PRODUCT_ID)).thenReturn("0111");
when(serviceInfo1Mock.getPropertyString(PROPERTY_SERIAL_NUMBER)).thenReturn("serial1");
when(serviceInfo1Mock.getPropertyString(PROPERTY_MANUFACTURER)).thenReturn("manufacturer1");
when(serviceInfo1Mock.getPropertyString(PROPERTY_PRODUCT)).thenReturn("product1");
when(serviceInfo1Mock.getPropertyString(PROPERTY_INTERFACE_NUMBER)).thenReturn("01");
when(serviceInfo1Mock.getPropertyString(PROPERTY_INTERFACE)).thenReturn("interface1");
when(serviceInfo1Mock.getPropertyString(PROPERTY_PROVIDER)).thenReturn(SER2NET);
when(serviceInfo1Mock.getPropertyString(PROPERTY_DEVICE_TYPE)).thenReturn(SERIALUSB);
when(serviceInfo1Mock.getPropertyString(PROPERTY_GENSIO_STACK)).thenReturn(TELNET_RFC2217_TCP);
}
private void setupServiceInfo2Mock() {
when(serviceInfo2Mock.getHostAddresses()).thenReturn(new String[] { "[0:0:0:0:0:ffff:0202:0202]" });
when(serviceInfo2Mock.getPort()).thenReturn(2222);
when(serviceInfo2Mock.getPropertyString(PROPERTY_VENDOR_ID)).thenReturn("0200");
when(serviceInfo2Mock.getPropertyString(PROPERTY_PRODUCT_ID)).thenReturn("0222");
when(serviceInfo2Mock.getPropertyString(PROPERTY_SERIAL_NUMBER)).thenReturn("serial2");
when(serviceInfo2Mock.getPropertyString(PROPERTY_MANUFACTURER)).thenReturn("manufacturer2");
when(serviceInfo2Mock.getPropertyString(PROPERTY_PRODUCT)).thenReturn("product2");
when(serviceInfo2Mock.getPropertyString(PROPERTY_INTERFACE_NUMBER)).thenReturn("02");
when(serviceInfo2Mock.getPropertyString(PROPERTY_INTERFACE)).thenReturn("interface2");
}
private void setupServiceInfo3Mock() {
when(serviceInfo3Mock.getHostAddresses()).thenReturn(new String[] { "123.222.100.000" });
when(serviceInfo3Mock.getPort()).thenReturn(3030);
when(serviceInfo3Mock.getPropertyString(PROPERTY_VENDOR_ID)).thenReturn("0300");
when(serviceInfo3Mock.getPropertyString(PROPERTY_PRODUCT_ID)).thenReturn("0333");
when(serviceInfo3Mock.getPropertyString(PROPERTY_INTERFACE_NUMBER)).thenReturn("03");
}
private void setupInvalidServiceInfoMock() {
when(invalidServiceInfoMock.getHostAddresses()).thenReturn(new String[] { "1.1.1.1" });
when(invalidServiceInfoMock.getPort()).thenReturn(1000);
when(invalidServiceInfoMock.getPropertyString(PROPERTY_VENDOR_ID)).thenReturn("invalid");
}
@Test
public void noScansWithoutBackgroundDiscovery() throws InterruptedException {
// Wait a little more than one second to give background scanning a chance to kick in.
Thread.sleep(1200);
verify(mdnsClientMock, never()).list(anyString());
verify(mdnsClientMock, never()).list(anyString(), ArgumentMatchers.any(Duration.class));
}
@Test
public void singleScanReportsResultsCorrectAfterOneScan() {
when(mdnsClientMock.list(SERVICE_TYPE, SINGLE_SCAN_DURATION))
.thenReturn(new ServiceInfo[] { serviceInfo1Mock, serviceInfo2Mock });
discovery.doSingleScan();
// Expectation: discovery listener called with newly discovered devices usb1 and usb2; not called with removed
// devices.
verify(discoveryListenerMock, times(1)).usbSerialDeviceDiscovered(usb1);
verify(discoveryListenerMock, times(1)).usbSerialDeviceDiscovered(usb2);
verify(discoveryListenerMock, never()).usbSerialDeviceDiscovered(usb3);
verify(discoveryListenerMock, never()).usbSerialDeviceRemoved(any(UsbSerialDeviceInformation.class));
}
@Test
public void singleScanReportsResultsCorrectAfterOneScanWithInvalidServiceInfo() {
when(mdnsClientMock.list(SERVICE_TYPE, SINGLE_SCAN_DURATION))
.thenReturn(new ServiceInfo[] { serviceInfo1Mock, invalidServiceInfoMock, serviceInfo2Mock });
discovery.doSingleScan();
// Expectation: discovery listener is still called with newly discovered devices usb1 and usb2; not called with
// removed devices.
verify(discoveryListenerMock, times(1)).usbSerialDeviceDiscovered(usb1);
verify(discoveryListenerMock, times(1)).usbSerialDeviceDiscovered(usb2);
verify(discoveryListenerMock, never()).usbSerialDeviceDiscovered(usb3);
verify(discoveryListenerMock, never()).usbSerialDeviceRemoved(any(UsbSerialDeviceInformation.class));
}
@Test
public void singleScanReportsResultsCorrectlyAfterTwoScans() {
when(mdnsClientMock.list(SERVICE_TYPE, SINGLE_SCAN_DURATION))
.thenReturn(new ServiceInfo[] { serviceInfo1Mock, serviceInfo2Mock })
.thenReturn(new ServiceInfo[] { serviceInfo2Mock, serviceInfo3Mock });
discovery.unregisterDiscoveryListener(discoveryListenerMock);
discovery.doSingleScan();
discovery.registerDiscoveryListener(discoveryListenerMock);
discovery.doSingleScan();
// Expectation: discovery listener called once for removing usb1, and once for adding usb2/usb3 each.
verify(discoveryListenerMock, never()).usbSerialDeviceDiscovered(usb1);
verify(discoveryListenerMock, times(1)).usbSerialDeviceRemoved(usb1);
verify(discoveryListenerMock, times(1)).usbSerialDeviceDiscovered(usb2);
verify(discoveryListenerMock, never()).usbSerialDeviceRemoved(usb2);
verify(discoveryListenerMock, times(1)).usbSerialDeviceDiscovered(usb3);
verify(discoveryListenerMock, never()).usbSerialDeviceRemoved(usb3);
}
@Test
public void backgroundScanning() {
discovery.startBackgroundScanning();
discovery.serviceAdded(serviceEvent1Mock);
discovery.serviceRemoved(serviceEvent1Mock);
discovery.serviceAdded(serviceEvent2Mock);
discovery.serviceAdded(invalidServiceEventMock);
discovery.serviceResolved(serviceEvent3Mock);
discovery.stopBackgroundScanning();
// Expectation: discovery listener called once for each discovered device, and once for removal of usb1.
verify(discoveryListenerMock, times(1)).usbSerialDeviceDiscovered(usb1);
verify(discoveryListenerMock, times(1)).usbSerialDeviceRemoved(usb1);
verify(discoveryListenerMock, times(1)).usbSerialDeviceDiscovered(usb2);
verify(discoveryListenerMock, never()).usbSerialDeviceRemoved(usb2);
verify(discoveryListenerMock, times(1)).usbSerialDeviceDiscovered(usb3);
verify(discoveryListenerMock, never()).usbSerialDeviceRemoved(usb3);
}
@Test
public void noBackgroundScanning() throws IOException, InterruptedException {
discovery.stopBackgroundScanning();
discovery.serviceAdded(serviceEvent1Mock);
discovery.serviceRemoved(serviceEvent1Mock);
discovery.serviceAdded(serviceEvent2Mock);
discovery.serviceResolved(serviceEvent3Mock);
// Expectation: discovery listener is never called when there is no background scanning is.
verify(discoveryListenerMock, never()).usbSerialDeviceDiscovered(usb1);
verify(discoveryListenerMock, never()).usbSerialDeviceRemoved(usb1);
verify(discoveryListenerMock, never()).usbSerialDeviceDiscovered(usb2);
verify(discoveryListenerMock, never()).usbSerialDeviceRemoved(usb2);
verify(discoveryListenerMock, never()).usbSerialDeviceDiscovered(usb3);
verify(discoveryListenerMock, never()).usbSerialDeviceRemoved(usb3);
}
@Test
public void discoveryChecksSer2NetSpecificProperties() {
discovery.startBackgroundScanning();
when(serviceInfo3Mock.getPropertyString(PROPERTY_PROVIDER)).thenReturn(SER2NET);
when(serviceInfo3Mock.getPropertyString(PROPERTY_GENSIO_STACK)).thenReturn("incompatible");
discovery.serviceAdded(serviceEvent3Mock);
when(serviceInfo3Mock.getPropertyString(PROPERTY_PROVIDER)).thenReturn(SER2NET);
when(serviceInfo3Mock.getPropertyString(PROPERTY_DEVICE_TYPE)).thenReturn("incompatible");
discovery.serviceAdded(serviceEvent3Mock);
// Expectation: discovery listener is never called when the ser2net specific properties do not match.
verify(discoveryListenerMock, never()).usbSerialDeviceDiscovered(usb1);
verify(discoveryListenerMock, never()).usbSerialDeviceRemoved(usb1);
verify(discoveryListenerMock, never()).usbSerialDeviceDiscovered(usb2);
verify(discoveryListenerMock, never()).usbSerialDeviceRemoved(usb2);
verify(discoveryListenerMock, never()).usbSerialDeviceDiscovered(usb3);
verify(discoveryListenerMock, never()).usbSerialDeviceRemoved(usb3);
}
}

View File

@ -47,14 +47,14 @@ import org.slf4j.LoggerFactory;
* This discovery service is intended to be used by bindings that support USB devices, but do not directly talk to the
* USB devices but rather use a serial port for the communication, where the serial port is provided by an operating
* system driver outside the scope of openHAB. Examples for such USB devices are USB dongles that provide
* access to wireless networks, like, e.g., Zigbeee or Zwave dongles.
* access to wireless networks, like, e.g., Zigbee or Zwave dongles.
* <p/>
* This discovery service provides functionality for discovering added and removed USB devices and the corresponding
* serial ports. The actual {@link DiscoveryResult}s are then provided by {@link UsbSerialDiscoveryParticipant}s, which
* are called by this discovery service whenever new devices are detected or devices are removed. Such
* {@link UsbSerialDiscoveryParticipant}s should be provided by bindings accessing USB devices via a serial port.
* <p/>
* This discovery service requires a component implementing the interface {@link UsbSerialDiscovery}, which performs the
* This discovery service requires components implementing the interface {@link UsbSerialDiscovery}, which perform the
* actual serial port and USB device discovery (as this discovery might differ depending on the operating system).
*
* @author Henning Sudbrock - Initial contribution
@ -70,10 +70,8 @@ public class UsbSerialDiscoveryService extends AbstractDiscoveryService implemen
private static final String THING_PROPERTY_USB_PRODUCT_ID = "usb_product_id";
private final Set<UsbSerialDiscoveryParticipant> discoveryParticipants = new CopyOnWriteArraySet<>();
private final Set<UsbSerialDeviceInformation> previouslyDiscovered = new CopyOnWriteArraySet<>();
private @NonNullByDefault({}) UsbSerialDiscovery usbSerialDiscovery;
private final Set<UsbSerialDiscovery> usbSerialDiscoveries = new CopyOnWriteArraySet<>();
public UsbSerialDiscoveryService() {
super(5);
@ -83,7 +81,6 @@ public class UsbSerialDiscoveryService extends AbstractDiscoveryService implemen
@Activate
protected void activate(@Nullable Map<String, Object> configProperties) {
super.activate(configProperties);
usbSerialDiscovery.registerDiscoveryListener(this);
}
@Modified
@ -100,7 +97,7 @@ public class UsbSerialDiscoveryService extends AbstractDiscoveryService implemen
@Reference(cardinality = ReferenceCardinality.MULTIPLE, policy = ReferencePolicy.DYNAMIC)
protected void addUsbSerialDiscoveryParticipant(UsbSerialDiscoveryParticipant participant) {
this.discoveryParticipants.add(participant);
discoveryParticipants.add(participant);
for (UsbSerialDeviceInformation usbSerialDeviceInformation : previouslyDiscovered) {
DiscoveryResult result = participant.createResult(usbSerialDeviceInformation);
if (result != null) {
@ -110,19 +107,23 @@ public class UsbSerialDiscoveryService extends AbstractDiscoveryService implemen
}
protected void removeUsbSerialDiscoveryParticipant(UsbSerialDiscoveryParticipant participant) {
this.discoveryParticipants.remove(participant);
discoveryParticipants.remove(participant);
}
@Reference
protected void setUsbSerialDiscovery(UsbSerialDiscovery usbSerialDiscovery) {
this.usbSerialDiscovery = usbSerialDiscovery;
@Reference(cardinality = ReferenceCardinality.MULTIPLE, policy = ReferencePolicy.DYNAMIC)
protected void addUsbSerialDiscovery(UsbSerialDiscovery usbSerialDiscovery) {
usbSerialDiscoveries.add(usbSerialDiscovery);
usbSerialDiscovery.registerDiscoveryListener(this);
if (isBackgroundDiscoveryEnabled()) {
usbSerialDiscovery.startBackgroundScanning();
}
}
protected synchronized void unsetUsbSerialDiscovery(UsbSerialDiscovery usbSerialDiscovery) {
protected synchronized void removeUsbSerialDiscovery(UsbSerialDiscovery usbSerialDiscovery) {
usbSerialDiscovery.stopBackgroundScanning();
usbSerialDiscovery.unregisterDiscoveryListener(this);
this.usbSerialDiscovery = null;
this.previouslyDiscovered.clear();
usbSerialDiscoveries.remove(usbSerialDiscovery);
previouslyDiscovered.clear();
}
@Override
@ -133,30 +134,17 @@ public class UsbSerialDiscoveryService extends AbstractDiscoveryService implemen
@Override
protected void startScan() {
if (usbSerialDiscovery != null) {
usbSerialDiscovery.doSingleScan();
} else {
logger.info("Could not scan, as there is no USB-Serial discovery service configured.");
}
usbSerialDiscoveries.forEach(UsbSerialDiscovery::doSingleScan);
}
@Override
protected void startBackgroundDiscovery() {
if (usbSerialDiscovery != null) {
usbSerialDiscovery.startBackgroundScanning();
} else {
logger.info(
"Could not start background discovery, as there is no USB-Serial discovery service configured.");
}
usbSerialDiscoveries.forEach(UsbSerialDiscovery::startBackgroundScanning);
}
@Override
protected void stopBackgroundDiscovery() {
if (usbSerialDiscovery != null) {
usbSerialDiscovery.stopBackgroundScanning();
} else {
logger.info("Could not stop background discovery, as there is no USB-Serial discovery service configured.");
}
usbSerialDiscoveries.forEach(UsbSerialDiscovery::stopBackgroundScanning);
}
@Override

View File

@ -25,6 +25,11 @@
<artifactId>org.openhab.core.io.transport.serial</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>org.openhab.core.bundles</groupId>
<artifactId>org.openhab.core.config.discovery.usbserial</artifactId>
<version>${project.version}</version>
</dependency>
</dependencies>
</project>

View File

@ -13,43 +13,82 @@
package org.openhab.core.config.serial.internal;
import java.net.URI;
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
import java.util.Locale;
import java.util.Set;
import java.util.concurrent.CopyOnWriteArraySet;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.openhab.core.config.core.ConfigOptionProvider;
import org.openhab.core.config.core.ParameterOption;
import org.openhab.core.config.discovery.usbserial.UsbSerialDeviceInformation;
import org.openhab.core.config.discovery.usbserial.UsbSerialDiscovery;
import org.openhab.core.config.discovery.usbserial.UsbSerialDiscoveryListener;
import org.openhab.core.io.transport.serial.SerialPortIdentifier;
import org.openhab.core.io.transport.serial.SerialPortManager;
import org.osgi.service.component.annotations.Activate;
import org.osgi.service.component.annotations.Component;
import org.osgi.service.component.annotations.Reference;
import org.osgi.service.component.annotations.ReferenceCardinality;
import org.osgi.service.component.annotations.ReferencePolicy;
/**
* This service provides serial port names as options for configuration parameters.
*
* @author Kai Kreuzer - Initial contribution
* @author Wouter Born - Add discovered USB serial port names to serial port parameter options
*/
@NonNullByDefault
@Component
public class SerialConfigOptionProvider implements ConfigOptionProvider {
public class SerialConfigOptionProvider implements ConfigOptionProvider, UsbSerialDiscoveryListener {
private SerialPortManager serialPortManager;
static final String SERIAL_PORT = "serial-port";
@Reference
protected void setSerialPortManager(final SerialPortManager serialPortManager) {
private final SerialPortManager serialPortManager;
private final Set<UsbSerialDeviceInformation> previouslyDiscovered = new CopyOnWriteArraySet<>();
private final Set<UsbSerialDiscovery> usbSerialDiscoveries = new CopyOnWriteArraySet<>();
@Activate
public SerialConfigOptionProvider(final @Reference SerialPortManager serialPortManager) {
this.serialPortManager = serialPortManager;
}
protected void unsetSerialPortManager(final SerialPortManager serialPortManager) {
this.serialPortManager = null;
@Reference(cardinality = ReferenceCardinality.MULTIPLE, policy = ReferencePolicy.DYNAMIC)
protected void addUsbSerialDiscovery(UsbSerialDiscovery usbSerialDiscovery) {
usbSerialDiscoveries.add(usbSerialDiscovery);
usbSerialDiscovery.registerDiscoveryListener(this);
}
protected synchronized void removeUsbSerialDiscovery(UsbSerialDiscovery usbSerialDiscovery) {
usbSerialDiscovery.unregisterDiscoveryListener(this);
usbSerialDiscoveries.remove(usbSerialDiscovery);
previouslyDiscovered.clear();
}
@Override
public Collection<ParameterOption> getParameterOptions(URI uri, String param, String context, Locale locale) {
List<ParameterOption> options = new ArrayList<>();
if ("serial-port".equals(context)) {
serialPortManager.getIdentifiers()
.forEach(id -> options.add(new ParameterOption(id.getName(), id.getName())));
public void usbSerialDeviceDiscovered(UsbSerialDeviceInformation usbSerialDeviceInformation) {
previouslyDiscovered.add(usbSerialDeviceInformation);
}
@Override
public void usbSerialDeviceRemoved(UsbSerialDeviceInformation usbSerialDeviceInformation) {
previouslyDiscovered.remove(usbSerialDeviceInformation);
}
@Override
public @Nullable Collection<ParameterOption> getParameterOptions(URI uri, String param, @Nullable String context,
@Nullable Locale locale) {
if (SERIAL_PORT.equals(context)) {
return Stream
.concat(serialPortManager.getIdentifiers().map(SerialPortIdentifier::getName),
previouslyDiscovered.stream().map(UsbSerialDeviceInformation::getSerialPort))
.distinct() //
.map(serialPortName -> new ParameterOption(serialPortName, serialPortName)) //
.collect(Collectors.toList());
}
return options;
return null;
}
}

View File

@ -0,0 +1,178 @@
/**
* Copyright (c) 2010-2021 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.serial.internal;
import static org.hamcrest.CoreMatchers.*;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.mockito.Mockito.when;
import static org.openhab.core.config.serial.internal.SerialConfigOptionProvider.SERIAL_PORT;
import java.net.URI;
import java.util.Arrays;
import java.util.Collection;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.mockito.junit.jupiter.MockitoSettings;
import org.mockito.quality.Strictness;
import org.openhab.core.config.core.ParameterOption;
import org.openhab.core.config.discovery.usbserial.UsbSerialDeviceInformation;
import org.openhab.core.config.discovery.usbserial.UsbSerialDiscovery;
import org.openhab.core.io.transport.serial.SerialPortIdentifier;
import org.openhab.core.io.transport.serial.SerialPortManager;
/**
* Unit tests for the {@link SerialConfigOptionProvider}.
*
* @author Wouter Born - Initial contribution
*/
@NonNullByDefault
@ExtendWith(MockitoExtension.class)
@MockitoSettings(strictness = Strictness.LENIENT)
public class SerialConfigOptionProviderTest {
private static final String DEV_TTY_S1 = "/dev/ttyS1";
private static final String DEV_TTY_S2 = "/dev/ttyS2";
private static final String DEV_TTY_S3 = "/dev/ttyS3";
private static final String RFC2217_IPV4 = "rfc2217://1.1.1.1:1000";
private static final String RFC2217_IPV6 = "rfc2217://[0:0:0:0:0:ffff:0202:0202]:2222";
private UsbSerialDeviceInformation usb1 = new UsbSerialDeviceInformation(0x100, 0x111, "serial1", "manufacturer1",
"product1", 0x1, "interface1", RFC2217_IPV4);
private UsbSerialDeviceInformation usb2 = new UsbSerialDeviceInformation(0x200, 0x222, "serial2", "manufacturer2",
"product2", 0x2, "interface2", RFC2217_IPV6);
private UsbSerialDeviceInformation usb3 = new UsbSerialDeviceInformation(0x300, 0x333, "serial3", "manufacturer3",
"product3", 0x3, "interface3", DEV_TTY_S3);
private @Mock @NonNullByDefault({}) SerialPortManager serialPortManagerMock;
private @Mock @NonNullByDefault({}) UsbSerialDiscovery usbSerialDiscoveryMock;
private @Mock @NonNullByDefault({}) SerialPortIdentifier serialPortIdentifier1Mock;
private @Mock @NonNullByDefault({}) SerialPortIdentifier serialPortIdentifier2Mock;
private @Mock @NonNullByDefault({}) SerialPortIdentifier serialPortIdentifier3Mock;
private @NonNullByDefault({}) SerialConfigOptionProvider provider;
@BeforeEach
public void beforeEach() {
provider = new SerialConfigOptionProvider(serialPortManagerMock);
when(serialPortIdentifier1Mock.getName()).thenReturn(DEV_TTY_S1);
when(serialPortIdentifier2Mock.getName()).thenReturn(DEV_TTY_S2);
when(serialPortIdentifier3Mock.getName()).thenReturn(DEV_TTY_S3);
}
private void assertParameterOptions(String... serialPortIdentifiers) {
Collection<ParameterOption> actual = provider.getParameterOptions(URI.create("uri"), "serialPort", SERIAL_PORT,
null);
Collection<ParameterOption> expected = Arrays.stream(serialPortIdentifiers)
.map(id -> new ParameterOption(id, id)).collect(Collectors.toList());
assertThat(actual, is(expected));
}
@Test
public void noSerialPortIdentifiers() {
when(serialPortManagerMock.getIdentifiers()).thenReturn(Stream.of());
assertParameterOptions();
}
@Test
public void serialPortManagerIdentifiersOnly() {
when(serialPortManagerMock.getIdentifiers())
.thenReturn(Stream.of(serialPortIdentifier1Mock, serialPortIdentifier2Mock));
assertParameterOptions(DEV_TTY_S1, DEV_TTY_S2);
}
@Test
public void discoveredIdentifiersOnly() {
provider.addUsbSerialDiscovery(usbSerialDiscoveryMock);
provider.usbSerialDeviceDiscovered(usb1);
provider.usbSerialDeviceDiscovered(usb2);
when(serialPortManagerMock.getIdentifiers()).thenReturn(Stream.of());
assertParameterOptions(RFC2217_IPV4, RFC2217_IPV6);
}
@Test
public void serialPortManagerAndDiscoveredIdentifiers() {
provider.addUsbSerialDiscovery(usbSerialDiscoveryMock);
provider.usbSerialDeviceDiscovered(usb1);
provider.usbSerialDeviceDiscovered(usb2);
when(serialPortManagerMock.getIdentifiers())
.thenReturn(Stream.of(serialPortIdentifier1Mock, serialPortIdentifier2Mock));
assertParameterOptions(DEV_TTY_S1, DEV_TTY_S2, RFC2217_IPV4, RFC2217_IPV6);
}
@Test
public void removedDevicesAreRemoved() {
provider.addUsbSerialDiscovery(usbSerialDiscoveryMock);
provider.usbSerialDeviceDiscovered(usb1);
when(serialPortManagerMock.getIdentifiers()).thenReturn(Stream.of());
assertParameterOptions(RFC2217_IPV4);
provider.usbSerialDeviceRemoved(usb1);
when(serialPortManagerMock.getIdentifiers()).thenReturn(Stream.of());
assertParameterOptions();
}
@Test
public void discoveryRemovalClearsDiscoveryResults() {
provider.addUsbSerialDiscovery(usbSerialDiscoveryMock);
provider.usbSerialDeviceDiscovered(usb1);
provider.usbSerialDeviceDiscovered(usb2);
provider.usbSerialDeviceDiscovered(usb3);
when(serialPortManagerMock.getIdentifiers()).thenReturn(Stream.of());
assertParameterOptions(RFC2217_IPV4, RFC2217_IPV6, DEV_TTY_S3);
provider.removeUsbSerialDiscovery(usbSerialDiscoveryMock);
when(serialPortManagerMock.getIdentifiers()).thenReturn(Stream.of());
assertParameterOptions();
}
@Test
public void serialPortIdentifiersAreUnique() {
provider.addUsbSerialDiscovery(usbSerialDiscoveryMock);
provider.usbSerialDeviceDiscovered(usb3);
when(serialPortManagerMock.getIdentifiers()).thenReturn(Stream.of(serialPortIdentifier3Mock));
assertParameterOptions(DEV_TTY_S3);
}
@Test
public void nullResultIfContextDoesNotMatch() {
Collection<ParameterOption> actual = provider.getParameterOptions(URI.create("uri"), "serialPort",
"otherContext", null);
assertThat(actual, is(nullValue()));
}
}

View File

@ -49,7 +49,6 @@ public class RFC2217PortProvider implements SerialPortProvider {
@Override
public Stream<SerialPortIdentifier> getSerialPortIdentifiers() {
// TODO implement discovery here. https://github.com/eclipse/smarthome/pull/5560
return Stream.empty();
}
}

View File

@ -31,6 +31,7 @@
<module>org.openhab.core.config.discovery.mdns</module>
<module>org.openhab.core.config.discovery.usbserial</module>
<module>org.openhab.core.config.discovery.usbserial.linuxsysfs</module>
<module>org.openhab.core.config.discovery.usbserial.ser2net</module>
<module>org.openhab.core.config.discovery.upnp</module>
<module>org.openhab.core.config.dispatch</module>
<module>org.openhab.core.config.serial</module>

View File

@ -463,9 +463,12 @@
<requirement>openhab.tp;filter:="(&amp;(feature=serial)(impl=rxtx))"</requirement>
<feature dependency="true">openhab.tp-serial-rxtx</feature>
<feature dependency="true">openhab-core-io-transport-mdns</feature>
<bundle>mvn:org.openhab.core.bundles/org.openhab.core.config.serial/${project.version}</bundle>
<bundle>mvn:org.openhab.core.bundles/org.openhab.core.config.discovery.usbserial/${project.version}</bundle>
<bundle>mvn:org.openhab.core.bundles/org.openhab.core.config.discovery.usbserial.linuxsysfs/${project.version}</bundle>
<bundle>mvn:org.openhab.core.bundles/org.openhab.core.config.discovery.usbserial.ser2net/${project.version}</bundle>
<bundle>mvn:org.openhab.core.bundles/org.openhab.core.io.transport.serial/${project.version}</bundle>
<bundle>mvn:org.openhab.core.bundles/org.openhab.core.io.transport.serial.rxtx/${project.version}</bundle>
<bundle>mvn:org.openhab.core.bundles/org.openhab.core.io.transport.serial.rxtx.rfc2217/${project.version}</bundle>