org.openhab.core.bundles
org.openhab.core.config.discovery.usbserial
diff --git a/bundles/org.openhab.core.config.discovery.addon.sddp/.classpath b/bundles/org.openhab.core.config.discovery.addon.sddp/.classpath
new file mode 100644
index 000000000..d3d6b3c11
--- /dev/null
+++ b/bundles/org.openhab.core.config.discovery.addon.sddp/.classpath
@@ -0,0 +1,29 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/bundles/org.openhab.core.config.discovery.addon.sddp/.project b/bundles/org.openhab.core.config.discovery.addon.sddp/.project
new file mode 100644
index 000000000..f86bba378
--- /dev/null
+++ b/bundles/org.openhab.core.config.discovery.addon.sddp/.project
@@ -0,0 +1,23 @@
+
+
+ org.openhab.core.config.discovery.addon.upnp
+
+
+
+
+
+ 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.sddp/NOTICE b/bundles/org.openhab.core.config.discovery.addon.sddp/NOTICE
new file mode 100644
index 000000000..6c17d0d8a
--- /dev/null
+++ b/bundles/org.openhab.core.config.discovery.addon.sddp/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.sddp/pom.xml b/bundles/org.openhab.core.config.discovery.addon.sddp/pom.xml
new file mode 100644
index 000000000..f56d06abf
--- /dev/null
+++ b/bundles/org.openhab.core.config.discovery.addon.sddp/pom.xml
@@ -0,0 +1,34 @@
+
+
+
+ 4.0.0
+
+
+ org.openhab.core.bundles
+ org.openhab.core.reactor.bundles
+ 4.2.0-SNAPSHOT
+
+
+ org.openhab.core.config.discovery.addon.sddp
+
+ openHAB Core :: Bundles :: SDDP 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}
+
+
+ org.openhab.core.bundles
+ org.openhab.core.config.discovery.sddp
+ ${project.version}
+
+
+
diff --git a/bundles/org.openhab.core.config.discovery.addon.sddp/src/main/java/org/openhab/core/config/discovery/addon/sddp/SddpAddonFinder.java b/bundles/org.openhab.core.config.discovery.addon.sddp/src/main/java/org/openhab/core/config/discovery/addon/sddp/SddpAddonFinder.java
new file mode 100644
index 000000000..9c8448641
--- /dev/null
+++ b/bundles/org.openhab.core.config.discovery.addon.sddp/src/main/java/org/openhab/core/config/discovery/addon/sddp/SddpAddonFinder.java
@@ -0,0 +1,166 @@
+/**
+ * Copyright (c) 2010-2024 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.sddp;
+
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.regex.Pattern;
+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.addon.AddonMatchProperty;
+import org.openhab.core.config.discovery.DiscoveryService;
+import org.openhab.core.config.discovery.addon.AddonFinder;
+import org.openhab.core.config.discovery.addon.AddonFinderConstants;
+import org.openhab.core.config.discovery.addon.BaseAddonFinder;
+import org.openhab.core.config.discovery.sddp.SddpDevice;
+import org.openhab.core.config.discovery.sddp.SddpDeviceParticipant;
+import org.openhab.core.config.discovery.sddp.SddpDiscoveryService;
+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;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * This is a {@link SddpAddonFinder} for finding suggested Addons via SDDP.
+ *
+ * It checks the binding's addon.xml 'match-property' elements for the following SDDP properties:
+ *
driver
+ * host
+ * ipAddress
+ * macAddress
+ * manufacturer
+ * model
+ * port
+ * primaryProxy
+ * proxies
+ * type
+ *
+ *
+ * @author Andrew Fiddian-Green - Initial contribution
+ */
+@NonNullByDefault
+@Component(service = AddonFinder.class, name = SddpAddonFinder.SERVICE_NAME)
+public class SddpAddonFinder extends BaseAddonFinder implements SddpDeviceParticipant {
+
+ public static final String SERVICE_TYPE = AddonFinderConstants.SERVICE_TYPE_SDDP;
+ public static final String SERVICE_NAME = AddonFinderConstants.SERVICE_NAME_SDDP;
+
+ private static final String DRIVER = "driver";
+ private static final String HOST = "host";
+ private static final String IP_ADDRESS = "ipAddress";
+ private static final String MAC_ADDRESS = "macAddress";
+ private static final String MANUFACTURER = "manufacturer";
+ private static final String MODEL = "model";
+ private static final String PORT = "port";
+ private static final String PRIMARY_PROXY = "primaryProxy";
+ private static final String PROXIES = "proxies";
+ private static final String TYPE = "type";
+
+ private static final Set SUPPORTED_PROPERTIES = Set.of(DRIVER, HOST, IP_ADDRESS, MAC_ADDRESS, MANUFACTURER,
+ MODEL, PORT, PRIMARY_PROXY, PROXIES, TYPE);
+
+ private final Logger logger = LoggerFactory.getLogger(SddpAddonFinder.class);
+ private final Set foundDevices = new HashSet<>();
+
+ private @Nullable SddpDiscoveryService sddpDiscoveryService = null;
+
+ @Activate
+ public SddpAddonFinder(
+ @Reference(service = DiscoveryService.class, target = "(protocol=sddp)") DiscoveryService discoveryService) {
+ if (discoveryService instanceof SddpDiscoveryService sddpDiscoveryService) {
+ sddpDiscoveryService.addSddpDeviceParticipant(this);
+ this.sddpDiscoveryService = sddpDiscoveryService;
+ } else {
+ logger.warn("SddpAddonFinder() DiscoveryService is not an SddpDiscoveryService");
+ }
+ }
+
+ @Deactivate
+ public void deactivate() {
+ SddpDiscoveryService sddpDiscoveryService = this.sddpDiscoveryService;
+ if (sddpDiscoveryService != null) {
+ sddpDiscoveryService.removeSddpDeviceParticipant(this);
+ this.sddpDiscoveryService = null;
+ }
+ unsetAddonCandidates();
+ foundDevices.clear();
+ }
+
+ @Override
+ public void deviceAdded(SddpDevice device) {
+ foundDevices.add(device);
+ }
+
+ @Override
+ public void deviceRemoved(SddpDevice device) {
+ foundDevices.remove(device);
+ }
+
+ @Override
+ public String getServiceName() {
+ return SERVICE_NAME;
+ }
+
+ @Override
+ public Set getSuggestedAddons() {
+ Set result = new HashSet<>();
+ for (AddonInfo candidate : addonCandidates) {
+ for (AddonDiscoveryMethod method : candidate.getDiscoveryMethods().stream()
+ .filter(method -> SERVICE_TYPE.equals(method.getServiceType())).toList()) {
+ Map matchProperties = method.getMatchProperties().stream()
+ .collect(Collectors.toMap(AddonMatchProperty::getName, AddonMatchProperty::getPattern));
+
+ Set propertyNames = new HashSet<>(matchProperties.keySet());
+ propertyNames.removeAll(SUPPORTED_PROPERTIES);
+
+ if (!propertyNames.isEmpty()) {
+ logger.warn("Add-on '{}' addon.xml file contains unsupported 'match-property' [{}]",
+ candidate.getUID(), String.join(",", propertyNames));
+ break;
+ }
+
+ logger.trace("Checking candidate: {}", candidate.getUID());
+ for (SddpDevice device : foundDevices) {
+ logger.trace("Checking device: {}", device.host);
+ if (propertyMatches(matchProperties, HOST, device.host)
+ && propertyMatches(matchProperties, IP_ADDRESS, device.ipAddress)
+ && propertyMatches(matchProperties, MAC_ADDRESS, device.macAddress)
+ && propertyMatches(matchProperties, MANUFACTURER, device.manufacturer)
+ && propertyMatches(matchProperties, MODEL, device.model)
+ && propertyMatches(matchProperties, PORT, device.port)
+ && propertyMatches(matchProperties, PRIMARY_PROXY, device.primaryProxy)
+ && propertyMatches(matchProperties, PROXIES, device.proxies)
+ && propertyMatches(matchProperties, TYPE, device.type)) {
+ result.add(candidate);
+ logger.debug("Suggested add-on found: {}", candidate.getUID());
+ break;
+ }
+ }
+ }
+ }
+ return result;
+ }
+
+ @Override
+ public void setAddonCandidates(List candidates) {
+ super.setAddonCandidates(candidates);
+ }
+}
diff --git a/bundles/org.openhab.core.config.discovery.addon.sddp/src/test/java/org/openhab/core/config/discovery/addon/sddp/test/SddpAddonFinderTests.java b/bundles/org.openhab.core.config.discovery.addon.sddp/src/test/java/org/openhab/core/config/discovery/addon/sddp/test/SddpAddonFinderTests.java
new file mode 100644
index 000000000..fff72278c
--- /dev/null
+++ b/bundles/org.openhab.core.config.discovery.addon.sddp/src/test/java/org/openhab/core/config/discovery/addon/sddp/test/SddpAddonFinderTests.java
@@ -0,0 +1,88 @@
+/**
+ * Copyright (c) 2010-2024 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.sddp.test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.Mockito.mock;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.junit.jupiter.api.Test;
+import org.openhab.core.addon.AddonDiscoveryMethod;
+import org.openhab.core.addon.AddonInfo;
+import org.openhab.core.addon.AddonMatchProperty;
+import org.openhab.core.config.discovery.addon.sddp.SddpAddonFinder;
+import org.openhab.core.config.discovery.sddp.SddpDevice;
+import org.openhab.core.config.discovery.sddp.SddpDiscoveryService;
+
+/**
+ * JUnit tests for the {@link SddpAddonFinder}.
+ *
+ * @author Andrew Fiddian-Green - Initial contribution
+ */
+@NonNullByDefault
+public class SddpAddonFinderTests {
+
+ private static final Map DEVICE_FIELDS = Map.of(
+ // @formatter:off
+ "From", "\"192.168.4.237:1902\"",
+ "Host", "\"JVC_PROJECTOR-E0DADC152802\"",
+ "Max-Age", "1800",
+ "Type", "\"JVCKENWOOD:Projector\"",
+ "Primary-Proxy", "\"projector\"",
+ "Proxies", "\"projector\"",
+ "Manufacturer", "\"JVCKENWOOD\"",
+ "Model", "\"DLA-RS3100_NZ8\"",
+ "Driver", "\"projector_JVCKENWOOD_DLA-RS3100_NZ8.c4i\"");
+ // @formatter:on
+
+ private List createAddonInfos() {
+ AddonDiscoveryMethod method = new AddonDiscoveryMethod().setServiceType(SddpAddonFinder.SERVICE_TYPE)
+ .setMatchProperties(List.of(new AddonMatchProperty("host", "JVC.*")));
+ List addonInfos = new ArrayList<>();
+ addonInfos.add(AddonInfo.builder("jvc", "binding").withName("JVC").withDescription("JVC Kenwood")
+ .withDiscoveryMethods(List.of(method)).build());
+ return addonInfos;
+ }
+
+ @Test
+ public void testFinder() {
+ SddpDevice device = new SddpDevice(DEVICE_FIELDS, false);
+
+ List addonInfos = createAddonInfos();
+ SddpAddonFinder finder = new SddpAddonFinder(mock(SddpDiscoveryService.class));
+
+ finder.setAddonCandidates(addonInfos);
+
+ Set suggestions;
+ AddonInfo info;
+
+ finder.deviceAdded(device);
+ suggestions = finder.getSuggestedAddons();
+ assertFalse(suggestions.isEmpty());
+ info = suggestions.stream().findFirst().orElse(null);
+ assertNotNull(info);
+ assertEquals("JVC Kenwood", info.getDescription());
+
+ finder.deviceRemoved(device);
+ suggestions = finder.getSuggestedAddons();
+ assertTrue(suggestions.isEmpty());
+ }
+}
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 8486506a8..774f3064b 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
@@ -35,6 +35,10 @@ public class AddonFinderConstants {
public static final String CFG_FINDER_MDNS = "suggestionFinderMdns";
public static final String SERVICE_NAME_MDNS = SERVICE_TYPE_MDNS + ADDON_SUGGESTION_FINDER;
+ public static final String SERVICE_TYPE_SDDP = "sddp";
+ public static final String CFG_FINDER_SDDP = "suggestionFinderSddp";
+ public static final String SERVICE_NAME_SDDP = SERVICE_TYPE_SDDP + ADDON_SUGGESTION_FINDER;
+
public static final String SERVICE_TYPE_UPNP = "upnp";
public static final String CFG_FINDER_UPNP = "suggestionFinderUpnp";
public static final String SERVICE_NAME_UPNP = SERVICE_TYPE_UPNP + ADDON_SUGGESTION_FINDER;
@@ -43,13 +47,14 @@ public class AddonFinderConstants {
public static final String CFG_FINDER_USB = "suggestionFinderUsb";
public static final String SERVICE_NAME_USB = SERVICE_TYPE_USB + ADDON_SUGGESTION_FINDER;
- public static final List SUGGESTION_FINDERS = List.of(SERVICE_NAME_IP, SERVICE_NAME_MDNS, SERVICE_NAME_UPNP,
- SERVICE_NAME_USB);
+ public static final List SUGGESTION_FINDERS = List.of(SERVICE_NAME_IP, SERVICE_NAME_MDNS, SERVICE_NAME_SDDP,
+ SERVICE_NAME_UPNP, SERVICE_NAME_USB);
public static final Map SUGGESTION_FINDER_TYPES = Map.of(SERVICE_NAME_IP, SERVICE_TYPE_IP,
- SERVICE_NAME_MDNS, SERVICE_TYPE_MDNS, SERVICE_NAME_UPNP, SERVICE_TYPE_UPNP, SERVICE_NAME_USB,
- SERVICE_TYPE_USB);
+ SERVICE_NAME_MDNS, SERVICE_TYPE_MDNS, SERVICE_NAME_SDDP, SERVICE_TYPE_SDDP, SERVICE_NAME_UPNP,
+ SERVICE_TYPE_UPNP, SERVICE_NAME_USB, SERVICE_TYPE_USB);
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, SERVICE_NAME_USB, CFG_FINDER_USB);
+ SERVICE_NAME_MDNS, CFG_FINDER_MDNS, SERVICE_NAME_SDDP, CFG_FINDER_SDDP, SERVICE_NAME_UPNP, CFG_FINDER_UPNP,
+ SERVICE_NAME_USB, CFG_FINDER_USB);
}
diff --git a/bundles/org.openhab.core.config.discovery.sddp/.classpath b/bundles/org.openhab.core.config.discovery.sddp/.classpath
new file mode 100644
index 000000000..585aba264
--- /dev/null
+++ b/bundles/org.openhab.core.config.discovery.sddp/.classpath
@@ -0,0 +1,34 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/bundles/org.openhab.core.config.discovery.sddp/.project b/bundles/org.openhab.core.config.discovery.sddp/.project
new file mode 100644
index 000000000..29654a1ee
--- /dev/null
+++ b/bundles/org.openhab.core.config.discovery.sddp/.project
@@ -0,0 +1,23 @@
+
+
+ org.openhab.core.config.discovery.upnp
+
+
+
+
+
+ org.eclipse.jdt.core.javabuilder
+
+
+
+
+ org.eclipse.m2e.core.maven2Builder
+
+
+
+
+
+ org.eclipse.jdt.core.javanature
+ org.eclipse.m2e.core.maven2Nature
+
+
diff --git a/bundles/org.openhab.core.config.discovery.sddp/NOTICE b/bundles/org.openhab.core.config.discovery.sddp/NOTICE
new file mode 100644
index 000000000..6c17d0d8a
--- /dev/null
+++ b/bundles/org.openhab.core.config.discovery.sddp/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.sddp/pom.xml b/bundles/org.openhab.core.config.discovery.sddp/pom.xml
new file mode 100644
index 000000000..3e0fc524e
--- /dev/null
+++ b/bundles/org.openhab.core.config.discovery.sddp/pom.xml
@@ -0,0 +1,25 @@
+
+
+
+ 4.0.0
+
+
+ org.openhab.core.bundles
+ org.openhab.core.reactor.bundles
+ 4.2.0-SNAPSHOT
+
+
+ org.openhab.core.config.discovery.sddp
+
+ openHAB Core :: Bundles :: Configuration SDDP Discovery
+
+
+
+ org.openhab.core.bundles
+ org.openhab.core.config.discovery
+ ${project.version}
+
+
+
+
diff --git a/bundles/org.openhab.core.config.discovery.sddp/src/main/java/org/openhab/core/config/discovery/sddp/SddpDevice.java b/bundles/org.openhab.core.config.discovery.sddp/src/main/java/org/openhab/core/config/discovery/sddp/SddpDevice.java
new file mode 100644
index 000000000..cc7b9d8f2
--- /dev/null
+++ b/bundles/org.openhab.core.config.discovery.sddp/src/main/java/org/openhab/core/config/discovery/sddp/SddpDevice.java
@@ -0,0 +1,98 @@
+/**
+ * Copyright (c) 2010-2024 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.sddp;
+
+import java.time.Instant;
+import java.util.Map;
+import java.util.Objects;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+
+/**
+ * A DTO class containing data from an SDDP device discovery result.
+ *
+ * @author Andrew Fiddian-Green - Initial contribution
+ */
+@NonNullByDefault
+public class SddpDevice {
+
+ public final String from;
+ public final String host;
+ public final String maxAge;
+ public final String type;
+ public final String primaryProxy;
+ public final String proxies;
+ public final String manufacturer;
+ public final String model;
+ public final String driver;
+ public final String ipAddress;
+ public final String port;
+ public final String macAddress;
+ public final Instant expireInstant;
+
+ /**
+ * Constructor.
+ *
+ * @param headers a map of parameter name / value pairs.
+ * @param offline indicates if the device is being created from a NOTIFY OFFLINE announcement.
+ */
+ public SddpDevice(Map headers, boolean offline) {
+ from = headers.getOrDefault("From", "").replaceAll("^\"|\"$", "");
+ host = headers.getOrDefault("Host", "").replaceAll("^\"|\"$", "");
+ maxAge = headers.getOrDefault("Max-Age", "").replaceAll("^\"|\"$", "");
+ type = headers.getOrDefault("Type", "").replaceAll("^\"|\"$", "");
+ primaryProxy = headers.getOrDefault("Primary-Proxy", "").replaceAll("^\"|\"$", "");
+ proxies = headers.getOrDefault("Proxies", "").replaceAll("^\"|\"$", "");
+ manufacturer = headers.getOrDefault("Manufacturer", "").replaceAll("^\"|\"$", "");
+ model = headers.getOrDefault("Model", "").replaceAll("^\"|\"$", "");
+ driver = headers.getOrDefault("Driver", "").replaceAll("^\"|\"$", "");
+
+ String[] fromParts = from.split(":");
+ ipAddress = fromParts[0];
+ port = fromParts.length > 1 ? fromParts[1] : "";
+
+ String[] hostParts = host.split("-|_");
+ macAddress = hostParts.length <= 1 ? ""
+ : hostParts[hostParts.length - 1].replaceAll("(..)(?!$)", "$1-").toLowerCase();
+
+ expireInstant = offline ? Instant.now().minusMillis(1)
+ : Instant.now().plusSeconds(maxAge.isBlank() ? 0 : Integer.parseInt(maxAge));
+ }
+
+ /**
+ * Set uniqueness is determined by the From field only
+ */
+ @Override
+ public boolean equals(@Nullable Object obj) {
+ if (obj instanceof SddpDevice other) {
+ return Objects.equals(from, other.from);
+ }
+ return false;
+ }
+
+ /**
+ * Set uniqueness is determined by the From field only
+ */
+ @Override
+ public int hashCode() {
+ return Objects.hash(from);
+ }
+
+ /**
+ * Check if the creation time plus max-age instant is exceeded.
+ */
+ public boolean isExpired() {
+ return Instant.now().isAfter(expireInstant);
+ }
+}
diff --git a/bundles/org.openhab.core.config.discovery.sddp/src/main/java/org/openhab/core/config/discovery/sddp/SddpDeviceParticipant.java b/bundles/org.openhab.core.config.discovery.sddp/src/main/java/org/openhab/core/config/discovery/sddp/SddpDeviceParticipant.java
new file mode 100644
index 000000000..e01aa7b56
--- /dev/null
+++ b/bundles/org.openhab.core.config.discovery.sddp/src/main/java/org/openhab/core/config/discovery/sddp/SddpDeviceParticipant.java
@@ -0,0 +1,29 @@
+/**
+ * Copyright (c) 2010-2024 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.sddp;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+
+/**
+ * A {@link SddpDeviceParticipant} that is registered as a service is picked up by the {@link SddpDiscoveryService} and
+ * can thus be informed when the SDDP service discovers or removes an {@link SddpDevice}.
+ *
+ * @author Andrew Fiddian-Green - Initial contribution
+ */
+@NonNullByDefault
+public interface SddpDeviceParticipant {
+
+ void deviceAdded(SddpDevice device);
+
+ void deviceRemoved(SddpDevice device);
+}
diff --git a/bundles/org.openhab.core.config.discovery.sddp/src/main/java/org/openhab/core/config/discovery/sddp/SddpDiscoveryParticipant.java b/bundles/org.openhab.core.config.discovery.sddp/src/main/java/org/openhab/core/config/discovery/sddp/SddpDiscoveryParticipant.java
new file mode 100644
index 000000000..fd1137550
--- /dev/null
+++ b/bundles/org.openhab.core.config.discovery.sddp/src/main/java/org/openhab/core/config/discovery/sddp/SddpDiscoveryParticipant.java
@@ -0,0 +1,58 @@
+/**
+ * Copyright (c) 2010-2024 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.sddp;
+
+import java.util.Set;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.openhab.core.config.discovery.DiscoveryResult;
+import org.openhab.core.thing.ThingTypeUID;
+import org.openhab.core.thing.ThingUID;
+
+/**
+ * A {@link SddpDiscoveryParticipant} that is registered as a service is picked up by the {@link SddpDiscoveryService}
+ * and can thus contribute {@link DiscoveryResult}s from SDDP scans.
+ *
+ * @author Andrew Fiddian-Green - Initial contribution
+ */
+@NonNullByDefault
+public interface SddpDiscoveryParticipant {
+
+ /**
+ * Defines the list of thing types that this participant can identify
+ *
+ * @return a set of thing type UIDs for which results can be created
+ */
+ Set getSupportedThingTypeUIDs();
+
+ /**
+ * Creates a discovery result for a SDDP device
+ *
+ * @param device the SDDP device found on the network
+ * @return the according discovery result or null
, if device is not
+ * supported by this participant
+ */
+ @Nullable
+ DiscoveryResult createResult(SddpDevice device);
+
+ /**
+ * Returns the thing UID for a SDDP device
+ *
+ * @param device the SDDP device on the network
+ * @return a thing UID or null
, if device is not supported
+ * by this participant
+ */
+ @Nullable
+ ThingUID getThingUID(SddpDevice device);
+}
diff --git a/bundles/org.openhab.core.config.discovery.sddp/src/main/java/org/openhab/core/config/discovery/sddp/SddpDiscoveryService.java b/bundles/org.openhab.core.config.discovery.sddp/src/main/java/org/openhab/core/config/discovery/sddp/SddpDiscoveryService.java
new file mode 100644
index 000000000..f54181b19
--- /dev/null
+++ b/bundles/org.openhab.core.config.discovery.sddp/src/main/java/org/openhab/core/config/discovery/sddp/SddpDiscoveryService.java
@@ -0,0 +1,438 @@
+/**
+ * Copyright (c) 2010-2024 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.sddp;
+
+import java.io.IOException;
+import java.net.DatagramPacket;
+import java.net.DatagramSocket;
+import java.net.InetAddress;
+import java.net.InetSocketAddress;
+import java.net.MulticastSocket;
+import java.net.NetworkInterface;
+import java.net.ServerSocket;
+import java.net.SocketTimeoutException;
+import java.net.StandardSocketOptions;
+import java.nio.charset.StandardCharsets;
+import java.time.Duration;
+import java.time.Instant;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.Future;
+import java.util.concurrent.ScheduledFuture;
+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.config.discovery.AbstractDiscoveryService;
+import org.openhab.core.config.discovery.DiscoveryResult;
+import org.openhab.core.config.discovery.DiscoveryService;
+import org.openhab.core.i18n.LocaleProvider;
+import org.openhab.core.i18n.TranslationProvider;
+import org.openhab.core.net.CidrAddress;
+import org.openhab.core.net.NetworkAddressChangeListener;
+import org.openhab.core.net.NetworkAddressService;
+import org.openhab.core.thing.ThingTypeUID;
+import org.openhab.core.thing.ThingUID;
+import org.osgi.framework.FrameworkUtil;
+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.Modified;
+import org.osgi.service.component.annotations.Reference;
+import org.osgi.service.component.annotations.ReferenceCardinality;
+import org.osgi.service.component.annotations.ReferencePolicy;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * This is a {@link DiscoveryService} implementation, which can find SDDP devices in the network.
+ * Support for bindings can be achieved by implementing and registering a {@link SddpDiscoveryParticipant}.
+ * Support for finders can be achieved by implementing and registering a {@link SddpDeviceParticipant}.
+ *
+ * @author Andrew Fiddian-Green - Initial contribution
+ */
+@NonNullByDefault
+@Component(immediate = true, service = DiscoveryService.class, property = "protocol=sddp", configurationPid = "discovery.sddp")
+public class SddpDiscoveryService extends AbstractDiscoveryService
+ implements AutoCloseable, NetworkAddressChangeListener {
+
+ private static final int SDDP_PORT = 1902;
+ private static final String SDDP_IP_ADDRESS = "239.255.255.250";
+ private static final InetSocketAddress SDDP_GROUP = new InetSocketAddress(SDDP_IP_ADDRESS, SDDP_PORT);
+
+ private static final int READ_BUFFER_SIZE = 1024;
+ private static final Duration SOCKET_TIMOUT = Duration.ofMillis(1000);
+ private static final Duration SEARCH_LISTEN_DURATION = Duration.ofSeconds(5);
+ private static final Duration CACHE_PURGE_INTERVAL = Duration.ofSeconds(300);
+
+ private static final String SEARCH_REQUEST_BODY_FORMAT = "SEARCH * SDDP/1.0\r\nHost: \"%s:%d\"\r\n";
+ private static final String SEARCH_RESPONSE_HEADER = "SDDP/1.0 200 OK";
+
+ private static final String NOTIFY_ALIVE_HEADER = "NOTIFY ALIVE SDDP/1.0";
+ private static final String NOTIFY_OFFLINE_HEADER = "NOTIFY OFFLINE SDDP/1.0";
+
+ private final Logger logger = LoggerFactory.getLogger(SddpDiscoveryService.class);
+ private final Set foundDevicesCache = ConcurrentHashMap.newKeySet();
+ private final Set discoveryParticipants = ConcurrentHashMap.newKeySet();
+ private final Set deviceParticipants = ConcurrentHashMap.newKeySet();
+
+ private final NetworkAddressService networkAddressService;
+
+ private boolean closing = false;
+
+ private @Nullable Future> listenBackgroundMulticastTask = null;
+ private @Nullable Future> listenActiveScanUnicastTask = null;
+ private @Nullable ScheduledFuture> purgeExpiredDevicesTask = null;
+
+ @Activate
+ public SddpDiscoveryService(final @Nullable Map configProperties, //
+ final @Reference NetworkAddressService networkAddressService, //
+ final @Reference TranslationProvider i18nProvider, //
+ final @Reference LocaleProvider localeProvider) {
+ super((int) SEARCH_LISTEN_DURATION.getSeconds());
+
+ this.networkAddressService = networkAddressService;
+ this.i18nProvider = i18nProvider;
+ this.localeProvider = localeProvider;
+
+ super.activate(configProperties); // note: this starts listenBackgroundMulticastTask
+
+ purgeExpiredDevicesTask = scheduler.scheduleWithFixedDelay(() -> purgeExpiredDevices(),
+ CACHE_PURGE_INTERVAL.getSeconds(), CACHE_PURGE_INTERVAL.getSeconds(), TimeUnit.SECONDS);
+ }
+
+ @Reference(cardinality = ReferenceCardinality.MULTIPLE, policy = ReferencePolicy.DYNAMIC)
+ public void addSddpDeviceParticipant(SddpDeviceParticipant participant) {
+ deviceParticipants.add(participant);
+ foundDevicesCache.stream().filter(d -> !d.isExpired()).forEach(d -> participant.deviceAdded(d));
+ startScan();
+ }
+
+ @Reference(cardinality = ReferenceCardinality.MULTIPLE, policy = ReferencePolicy.DYNAMIC)
+ protected void addSddpDiscoveryParticipant(SddpDiscoveryParticipant participant) {
+ discoveryParticipants.add(participant);
+ foundDevicesCache.stream().filter(d -> !d.isExpired()).forEach(d -> {
+ DiscoveryResult result = participant.createResult(d);
+ if (result != null) {
+ DiscoveryResult localizedResult = getLocalizedDiscoveryResult(result,
+ FrameworkUtil.getBundle(participant.getClass()));
+ thingDiscovered(localizedResult);
+ }
+ });
+ }
+
+ /**
+ * Cancel the given task.
+ */
+ private void cancelTask(@Nullable Future> task) {
+ if (task != null) {
+ task.cancel(true);
+ }
+ }
+
+ @Override
+ public void close() {
+ deactivate();
+ }
+
+ /**
+ * Optionally create an {@link SddpDevice) object from UDP packet data if the data is good.
+ */
+ public Optional createSddpDevice(String data) {
+ if (!data.isBlank()) {
+ List lines = data.lines().toList();
+ if (lines.size() > 1) {
+ String statement = lines.get(0).strip();
+ boolean offline = statement.startsWith(NOTIFY_OFFLINE_HEADER);
+ if (offline || statement.startsWith(NOTIFY_ALIVE_HEADER)
+ || statement.startsWith(SEARCH_RESPONSE_HEADER)) {
+ Map headers = new HashMap<>();
+ for (int i = 1; i < lines.size(); i++) {
+ String[] header = lines.get(i).split(":", 2);
+ if (header.length > 1) {
+ headers.put(header[0].strip(), header[1].strip());
+ }
+ }
+ return Optional.of(new SddpDevice(headers, offline));
+ }
+ }
+ }
+ return Optional.empty();
+ }
+
+ @Deactivate
+ @Override
+ protected void deactivate() {
+ closing = true;
+
+ foundDevicesCache.clear();
+ discoveryParticipants.clear();
+ deviceParticipants.clear();
+
+ super.deactivate(); // note: this cancels and nulls listenBackgroundMulticastTask
+
+ cancelTask(listenActiveScanUnicastTask);
+ listenActiveScanUnicastTask = null;
+
+ cancelTask(purgeExpiredDevicesTask);
+ purgeExpiredDevicesTask = null;
+ }
+
+ @Override
+ public Set getSupportedThingTypes() {
+ Set supportedThingTypes = new HashSet<>();
+ discoveryParticipants.forEach(p -> supportedThingTypes.addAll(p.getSupportedThingTypeUIDs()));
+ return supportedThingTypes;
+ }
+
+ /**
+ * Continue to listen for incoming SDDP multicast messages until the thread is externally interrupted.
+ */
+ private void listenBackGroundMulticast() {
+ MulticastSocket socket = null;
+ NetworkInterface networkInterface = null;
+
+ try {
+ networkInterface = NetworkInterface
+ .getByInetAddress(InetAddress.getByName(networkAddressService.getPrimaryIpv4HostAddress()));
+
+ if (logger.isDebugEnabled()) {
+ logger.debug("listenBackGroundMulticast() starting on interface '{}'",
+ networkInterface.getDisplayName());
+ }
+
+ socket = new MulticastSocket(SDDP_PORT);
+ socket.joinGroup(SDDP_GROUP, networkInterface);
+ socket.setSoTimeout((int) SOCKET_TIMOUT.toMillis());
+
+ DatagramPacket packet = null;
+ byte[] buffer = new byte[READ_BUFFER_SIZE];
+
+ // loop listen for responses
+ while (!Thread.currentThread().isInterrupted()) {
+ try {
+ if (packet == null) {
+ packet = new DatagramPacket(buffer, buffer.length);
+ }
+ socket.receive(packet);
+ processPacket(packet);
+ packet = null;
+ } catch (SocketTimeoutException e) {
+ // socket.receive() will time out every 1 second so the thread won't block
+ }
+ }
+ } catch (IOException e) {
+ if (!closing) {
+ logger.warn("listenBackGroundMulticast error '{}'", e.getMessage());
+ }
+ } finally {
+ if (socket != null && networkInterface != null) {
+ try {
+ socket.leaveGroup(SDDP_GROUP, networkInterface);
+ } catch (IOException e) {
+ if (!closing) {
+ logger.warn("listenBackGroundMulticast() error '{}'", e.getMessage());
+ }
+ }
+ socket.close();
+ }
+ }
+ }
+
+ /**
+ * Send a single outgoing SEARCH 'ping' and then continue to listen for incoming SDDP unicast responses until the
+ * loop time elapses or the thread is externally interrupted.
+ */
+ private void listenActiveScanUnicast() {
+ // get a free port number
+ int port;
+ try (ServerSocket portFinder = new ServerSocket(0)) {
+ port = portFinder.getLocalPort();
+ } catch (IOException e) {
+ logger.warn("listenActiveScanUnicast() port finder error '{}'", e.getMessage());
+ return;
+ }
+
+ try (DatagramSocket socket = new DatagramSocket(port)) {
+ String ipAddress = networkAddressService.getPrimaryIpv4HostAddress();
+ NetworkInterface networkInterface = NetworkInterface.getByInetAddress(InetAddress.getByName(ipAddress));
+
+ if (logger.isDebugEnabled()) {
+ logger.debug("listenActiveScanUnicast() starting on '{}:{}' on interface '{}'", ipAddress, port,
+ networkInterface.getDisplayName());
+ }
+
+ socket.setOption(StandardSocketOptions.IP_MULTICAST_IF, networkInterface);
+ socket.setSoTimeout((int) SOCKET_TIMOUT.toMillis());
+
+ DatagramPacket packet;
+ byte[] buffer;
+
+ // send search request
+ String search = String.format(SEARCH_REQUEST_BODY_FORMAT, ipAddress, port);
+ buffer = search.getBytes(StandardCharsets.UTF_8);
+ packet = new DatagramPacket(buffer, buffer.length, new InetSocketAddress(SDDP_IP_ADDRESS, SDDP_PORT));
+ socket.send(packet);
+ logger.trace("Packet sent to '{}:{}' content:\r\n{}", SDDP_IP_ADDRESS, SDDP_PORT, search);
+
+ final Instant listenDoneTime = Instant.now().plus(SEARCH_LISTEN_DURATION);
+ buffer = new byte[READ_BUFFER_SIZE];
+ packet = null;
+
+ // loop listen for responses
+ while (Instant.now().isBefore(listenDoneTime) && !Thread.currentThread().isInterrupted()) {
+ try {
+ if (packet == null) {
+ packet = new DatagramPacket(buffer, buffer.length);
+ }
+ socket.receive(packet);
+ processPacket(packet);
+ packet = null;
+ } catch (SocketTimeoutException e) {
+ // receive will time out every 1 second so the thread won't block
+ }
+ }
+ } catch (IOException e) {
+ if (!closing) {
+ logger.warn("listenActiveScanUnicast() error '{}'", e.getMessage());
+ }
+ }
+ }
+
+ @Modified
+ @Override
+ protected void modified(@Nullable Map configProperties) {
+ super.modified(configProperties);
+ }
+
+ /**
+ * If the network interfaces change then cancel and recreate all pending tasks.
+ */
+ @Override
+ public synchronized void onChanged(List added, List removed) {
+ Future> multicastTask = listenBackgroundMulticastTask;
+ if (multicastTask != null && !multicastTask.isDone()) {
+ multicastTask.cancel(true);
+ listenBackgroundMulticastTask = scheduler.submit(() -> listenBackGroundMulticast());
+ }
+ Future> unicastTask = listenActiveScanUnicastTask;
+ if (unicastTask != null && !unicastTask.isDone()) {
+ unicastTask.cancel(true);
+ listenActiveScanUnicastTask = scheduler.submit(() -> listenActiveScanUnicast());
+ }
+ }
+
+ /**
+ * Process the {@link DatagramPacket} content by trying to create an {@link SddpDevice} and eventually adding it to
+ * the foundDevicesCache, and if so, then notifying all listeners.
+ *
+ * @param packet a datagram packet that arrived over the network.
+ */
+ private synchronized void processPacket(DatagramPacket packet) {
+ String content = new String(packet.getData(), 0, packet.getLength(), StandardCharsets.UTF_8);
+ if (logger.isTraceEnabled()) {
+ logger.trace("Packet received from '{}:{}' content:\r\n{}", packet.getAddress().getHostAddress(),
+ packet.getPort(), content);
+ }
+ Optional deviceOptional = createSddpDevice(content);
+ if (deviceOptional.isPresent()) {
+ SddpDevice device = deviceOptional.get();
+ foundDevicesCache.remove(device);
+
+ if (device.isExpired()) {
+ // device created from a NOTIFY OFFLINE announcement
+ discoveryParticipants.forEach(p -> {
+ DiscoveryResult discoveryResult = p.createResult(device);
+ if (discoveryResult != null) {
+ thingRemoved(discoveryResult.getThingUID());
+ }
+ });
+ deviceParticipants.forEach(f -> f.deviceRemoved(device));
+ } else {
+ // device created from a NOTIFY ALIVE announcement or SEARCH response
+ foundDevicesCache.add(device);
+ discoveryParticipants.forEach(p -> {
+ DiscoveryResult discoveryResult = p.createResult(device);
+ if (discoveryResult != null) {
+ DiscoveryResult localizedResult = getLocalizedDiscoveryResult(discoveryResult,
+ FrameworkUtil.getBundle(p.getClass()));
+ thingDiscovered(localizedResult);
+ }
+ });
+ deviceParticipants.forEach(f -> f.deviceAdded(device));
+ }
+
+ if (logger.isDebugEnabled()) {
+ logger.debug("processPacket() foundDevices={}, deviceParticipants={}, discoveryParticipants={}",
+ foundDevicesCache.size(), deviceParticipants.size(), discoveryParticipants.size());
+ }
+ }
+ }
+
+ /**
+ * Purge expired devices and notify all listeners.
+ */
+ private synchronized void purgeExpiredDevices() {
+ Set devices = new HashSet<>(foundDevicesCache);
+ devices.stream().filter(d -> d.isExpired()).forEach(d -> {
+ discoveryParticipants.forEach(p -> {
+ ThingUID thingUID = p.getThingUID(d);
+ if (thingUID != null) {
+ thingRemoved(thingUID);
+ }
+ });
+ deviceParticipants.forEach(f -> f.deviceRemoved(d));
+ });
+ foundDevicesCache.clear();
+ foundDevicesCache.addAll(devices.stream().filter(d -> !d.isExpired()).collect(Collectors.toSet()));
+ }
+
+ public void removeSddpDeviceParticipant(SddpDeviceParticipant participant) {
+ deviceParticipants.remove(participant);
+ }
+
+ public void removeSddpDiscoveryParticipant(SddpDiscoveryParticipant participant) {
+ discoveryParticipants.remove(participant);
+ }
+
+ @Override
+ protected void startBackgroundDiscovery() {
+ Future> task = listenBackgroundMulticastTask;
+ if (task == null || task.isDone()) {
+ listenBackgroundMulticastTask = scheduler.submit(() -> listenBackGroundMulticast());
+ }
+ }
+
+ /**
+ * Schedule to send one single SDDP SEARCH request, and listen for responses.
+ */
+ @Override
+ protected void startScan() {
+ Future> task = listenActiveScanUnicastTask;
+ if (task == null || task.isDone()) {
+ listenActiveScanUnicastTask = scheduler.submit(() -> listenActiveScanUnicast());
+ }
+ }
+
+ @Override
+ protected void stopBackgroundDiscovery() {
+ cancelTask(listenBackgroundMulticastTask);
+ listenBackgroundMulticastTask = null;
+ }
+}
diff --git a/bundles/org.openhab.core.config.discovery.sddp/src/test/java/org/openhab/core/config/discovery/sddp/test/SddpDiscoveryServiceTests.java b/bundles/org.openhab.core.config.discovery.sddp/src/test/java/org/openhab/core/config/discovery/sddp/test/SddpDiscoveryServiceTests.java
new file mode 100644
index 000000000..ee571e3bd
--- /dev/null
+++ b/bundles/org.openhab.core.config.discovery.sddp/src/test/java/org/openhab/core/config/discovery/sddp/test/SddpDiscoveryServiceTests.java
@@ -0,0 +1,158 @@
+/**
+ * Copyright (c) 2010-2024 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.sddp.test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+import java.util.Optional;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.TestInstance;
+import org.junit.jupiter.api.TestInstance.Lifecycle;
+import org.openhab.core.config.discovery.sddp.SddpDevice;
+import org.openhab.core.config.discovery.sddp.SddpDiscoveryService;
+import org.openhab.core.i18n.LocaleProvider;
+import org.openhab.core.i18n.TranslationProvider;
+import org.openhab.core.net.NetworkAddressService;
+
+/**
+ * JUnit tests for parsing SDDP discovery results.
+ *
+ * @author Andrew Fiddian-Green - Initial contribution
+ */
+@NonNullByDefault
+@TestInstance(Lifecycle.PER_CLASS)
+public class SddpDiscoveryServiceTests {
+
+ private static final String ALIVE_NOTIFICATION = """
+ NOTIFY ALIVE SDDP/1.0
+ From: "192.168.4.237:1902"
+ Host: "JVC_PROJECTOR-E0DADC152802"
+ Max-Age: 1800
+ Type: "JVCKENWOOD:Projector"
+ Primary-Proxy: "projector"
+ Proxies: "projector"
+ Manufacturer: "JVCKENWOOD"
+ Model: "DLA-RS3100_NZ8"
+ Driver: "projector_JVCKENWOOD_DLA-RS3100_NZ8.c4i"
+ """;
+
+ private static final String BAD_HEADER = """
+ SDDP/1.0 404 NOT FOUND\r
+ From: "192.168.4.237:1902"\r
+ Host: "JVC_PROJECTOR-E0DADC152802"\r
+ Max-Age: 1800\r
+ Type: "JVCKENWOOD:Projector"\r
+ Primary-Proxy: "projector"\r
+ Proxies: "projector"\r
+ Manufacturer: "JVCKENWOOD"\r
+ Model: "DLA-RS3100_NZ8"\r
+ Driver: "projector_JVCKENWOOD_DLA-RS3100_NZ8.c4i"\r
+ """;
+
+ private static final String BAD_PAYLOAD = """
+ SDDP/1.0 200 OK\r
+ """;
+
+ private static final String SEARCH_RESPONSE = """
+ SDDP/1.0 200 OK\r
+ From: "192.168.4.237:1902"\r
+ Host: "JVC_PROJECTOR-E0DADC152802"\r
+ Max-Age: 1800\r
+ Type: "JVCKENWOOD:Projector"\r
+ Primary-Proxy: "projector"\r
+ Proxies: "projector"\r
+ Manufacturer: "JVCKENWOOD"\r
+ Model: "DLA-RS3100_NZ8"\r
+ Driver: "projector_JVCKENWOOD_DLA-RS3100_NZ8.c4i"\r
+ """;
+
+ private @NonNullByDefault({}) NetworkAddressService networkAddressService;
+
+ @BeforeAll
+ public void setup() {
+ networkAddressService = mock(NetworkAddressService.class);
+ when(networkAddressService.getPrimaryIpv4HostAddress()).thenReturn("192.168.1.1");
+ }
+
+ @Test
+ void testAliveNotification() throws Exception {
+ try (SddpDiscoveryService service = new SddpDiscoveryService(null, networkAddressService,
+ mock(TranslationProvider.class), mock(LocaleProvider.class))) {
+ Optional deviceOptional = service.createSddpDevice(ALIVE_NOTIFICATION);
+ assertTrue(deviceOptional.isPresent());
+ SddpDevice device = deviceOptional.orElse(null);
+ assertNotNull(device);
+ assertEquals("192.168.4.237:1902", device.from);
+ assertEquals("JVC_PROJECTOR-E0DADC152802", device.host);
+ assertEquals("1800", device.maxAge);
+ assertEquals("JVCKENWOOD:Projector", device.type);
+ assertEquals("projector", device.primaryProxy);
+ assertEquals("projector", device.proxies);
+ assertEquals("JVCKENWOOD", device.manufacturer);
+ assertEquals("DLA-RS3100_NZ8", device.model);
+ assertEquals("projector_JVCKENWOOD_DLA-RS3100_NZ8.c4i", device.driver);
+ assertEquals("192.168.4.237", device.ipAddress);
+ assertEquals("e0-da-dc-15-28-02", device.macAddress);
+ assertEquals("1902", device.port);
+ }
+ }
+
+ @Test
+ void testBadHeader() throws Exception {
+ try (SddpDiscoveryService service = new SddpDiscoveryService(null, networkAddressService,
+ mock(TranslationProvider.class), mock(LocaleProvider.class))) {
+ Optional deviceOptional = service.createSddpDevice(BAD_HEADER);
+ assertFalse(deviceOptional.isPresent());
+ }
+ }
+
+ @Test
+ void testBadPayload() throws Exception {
+ try (SddpDiscoveryService service = new SddpDiscoveryService(null, networkAddressService,
+ mock(TranslationProvider.class), mock(LocaleProvider.class))) {
+ Optional deviceOptional = service.createSddpDevice(BAD_PAYLOAD);
+ assertFalse(deviceOptional.isPresent());
+ }
+ }
+
+ @Test
+ void testSearchResponse() throws Exception {
+ try (SddpDiscoveryService service = new SddpDiscoveryService(null, networkAddressService,
+ mock(TranslationProvider.class), mock(LocaleProvider.class))) {
+ Optional deviceOptional = service.createSddpDevice(SEARCH_RESPONSE);
+ assertTrue(deviceOptional.isPresent());
+ SddpDevice device = deviceOptional.orElse(null);
+ assertNotNull(device);
+ assertEquals("192.168.4.237:1902", device.from);
+ assertEquals("JVC_PROJECTOR-E0DADC152802", device.host);
+ assertEquals("1800", device.maxAge);
+ assertEquals("JVCKENWOOD:Projector", device.type);
+ assertEquals("projector", device.primaryProxy);
+ assertEquals("projector", device.proxies);
+ assertEquals("JVCKENWOOD", device.manufacturer);
+ assertEquals("DLA-RS3100_NZ8", device.model);
+ assertEquals("projector_JVCKENWOOD_DLA-RS3100_NZ8.c4i", device.driver);
+ assertEquals("192.168.4.237", device.ipAddress);
+ assertEquals("e0-da-dc-15-28-02", device.macAddress);
+ assertEquals("1902", device.port);
+ }
+ }
+}
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 752351223..ae137724f 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
@@ -34,6 +34,12 @@
Use IP network discovery broadcasts to suggest add-ons. Enabling/disabling may take up to 1 minute.
+
+ true
+
+ Use SDDP network scan to suggest add-ons. Enabling/disabling may take up to 1 minute.
+ true
+
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 0d22f9d7d..88e513f26 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
@@ -6,6 +6,8 @@ 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.suggestionFinderSddp.label = SDDP Suggestion Finder
+system.config.addons.suggestionFinderSddp.description = Use SDDP network scan to suggest add-ons. Enabling/disabling may take up to 1 minute.
system.config.addons.suggestionFinderUpnp.label = UPnP Suggestion Finder
system.config.addons.suggestionFinderUpnp.description = Use UPnP network scan to suggest add-ons. Enabling/disabling may take up to 1 minute.
system.config.addons.suggestionFinderUsb.label = USB Suggestion Finder
diff --git a/bundles/pom.xml b/bundles/pom.xml
index d83958afe..25918c2b2 100644
--- a/bundles/pom.xml
+++ b/bundles/pom.xml
@@ -34,9 +34,11 @@
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.sddp
org.openhab.core.config.discovery.addon.upnp
org.openhab.core.config.discovery.addon.usb
org.openhab.core.config.discovery.mdns
+ org.openhab.core.config.discovery.sddp
org.openhab.core.config.discovery.usbserial
org.openhab.core.config.discovery.usbserial.linuxsysfs
org.openhab.core.config.discovery.usbserial.ser2net
diff --git a/features/karaf/openhab-core/src/main/feature/feature.xml b/features/karaf/openhab-core/src/main/feature/feature.xml
index 9f55be0cd..2305b15a2 100644
--- a/features/karaf/openhab-core/src/main/feature/feature.xml
+++ b/features/karaf/openhab-core/src/main/feature/feature.xml
@@ -105,6 +105,18 @@
mvn:org.openhab.core.bundles/org.openhab.core.config.discovery.addon.usb/${project.version}
+
+ openhab-core-base
+ openhab-core-config-discovery-addon
+ openhab-core-config-discovery-sddp
+ mvn:org.openhab.core.bundles/org.openhab.core.config.discovery.addon.sddp/${project.version}
+
+
+
+ openhab-core-base
+ mvn:org.openhab.core.bundles/org.openhab.core.config.discovery.sddp/${project.version}
+
+
kar
openhab-core-base