junit
junit
diff --git a/src/main/java/io/github/hapjava/server/HomekitAdvertiser.java b/src/main/java/io/github/hapjava/server/HomekitAdvertiser.java
new file mode 100644
index 000000000..e04f8cec7
--- /dev/null
+++ b/src/main/java/io/github/hapjava/server/HomekitAdvertiser.java
@@ -0,0 +1,45 @@
+package io.github.hapjava.server;
+
+import java.io.IOException;
+
+/**
+ * Publishes a HomeKit accessory / bridge via DNS-SD ({@code _hap._tcp}).
+ *
+ * The default implementation uses JmDNS and binds UDP port 5353. On platforms where a system
+ * mDNS responder already owns that port (notably macOS {@code mDNSResponder}), use {@link
+ * io.github.hapjava.server.impl.dnssd.DnssdHomekitAdvertiser} instead, which registers through the
+ * native Bonjour / DNS-SD API without binding 5353.
+ *
+ * @see io.github.hapjava.server.impl.jmdns.JmdnsHomekitAdvertiser
+ * @see io.github.hapjava.server.impl.dnssd.DnssdHomekitAdvertiser
+ * @see HomekitAdvertisers
+ * @author Karel Goderis
+ */
+public interface HomekitAdvertiser {
+
+ /**
+ * Start advertising the accessory.
+ *
+ * @param label instance name shown in Home / Discovery
+ * @param category HomeKit accessory category ({@code ci} TXT key)
+ * @param mac accessory id ({@code id} TXT key), typically a MAC-like string
+ * @param port HAP TCP listen port
+ * @param configurationIndex configuration number ({@code c#} TXT key)
+ * @param setupId setup id used for the {@code sh} TXT hash
+ */
+ void advertise(
+ String label, int category, String mac, int port, int configurationIndex, String setupId)
+ throws Exception;
+
+ /** Stop advertising and release native resources. */
+ void stop();
+
+ /** Update discoverability ({@code sf} TXT). Re-registers when already advertising. */
+ void setDiscoverable(boolean discoverable) throws IOException;
+
+ /** Update accessory id ({@code id} TXT). Re-registers when already advertising. */
+ void setMac(String mac) throws IOException;
+
+ /** Update configuration index ({@code c#} TXT). Re-registers when already advertising. */
+ void setConfigurationIndex(int revision) throws IOException;
+}
diff --git a/src/main/java/io/github/hapjava/server/HomekitAdvertisers.java b/src/main/java/io/github/hapjava/server/HomekitAdvertisers.java
new file mode 100644
index 000000000..4b9d9ae3b
--- /dev/null
+++ b/src/main/java/io/github/hapjava/server/HomekitAdvertisers.java
@@ -0,0 +1,87 @@
+package io.github.hapjava.server;
+
+import io.github.hapjava.server.impl.dnssd.DnssdHomekitAdvertiser;
+import io.github.hapjava.server.impl.jmdns.JmdnsHomekitAdvertiser;
+import java.io.IOException;
+import java.net.InetAddress;
+import javax.jmdns.JmDNS;
+
+/**
+ * Factories for {@link HomekitAdvertiser} implementations.
+ *
+ *
Typical macOS usage:
+ *
+ *
{@code
+ * HomekitServer server =
+ * new HomekitServer(InetAddress.getByName("192.168.0.6"), 9124, HomekitAdvertisers.nativeDnssd());
+ * }
+ *
+ * That listens on HAP TCP without binding UDP 5353; Bonjour/mDNSResponder publishes {@code
+ * _hap._tcp}.
+ *
+ * @author Karel Goderis
+ */
+public final class HomekitAdvertisers {
+
+ private HomekitAdvertisers() {}
+
+ /**
+ * Classic JmDNS advertiser (binds UDP 5353 unless sharing an existing {@link JmDNS}).
+ *
+ * @param jmdns existing JmDNS instance to register with
+ * @return a JmDNS-backed advertiser
+ */
+ public static HomekitAdvertiser jmdns(JmDNS jmdns) {
+ return new JmdnsHomekitAdvertiser(jmdns);
+ }
+
+ /**
+ * Classic JmDNS advertiser that creates its own multicast socket on {@code localAddress}.
+ *
+ * @param localAddress address used to create the JmDNS instance
+ * @return a JmDNS-backed advertiser
+ * @throws IOException if JmDNS cannot be created on {@code localAddress}
+ */
+ public static HomekitAdvertiser jmdns(InetAddress localAddress) throws IOException {
+ return new JmdnsHomekitAdvertiser(localAddress);
+ }
+
+ /**
+ * Native DNS-SD / Bonjour advertiser (macOS {@code mDNSResponder}, or Avahi's dns_sd compat
+ * layer). Does not bind UDP 5353. Requires the JNA runtime dependency.
+ *
+ * @return a native DNS-SD advertiser
+ * @throws UnsupportedOperationException if the native library cannot be loaded
+ */
+ public static HomekitAdvertiser nativeDnssd() {
+ if (!DnssdHomekitAdvertiser.isSupported()) {
+ throw new UnsupportedOperationException(
+ "Native DNS-SD (dns_sd / Bonjour) is not available on this platform/JVM");
+ }
+ return new DnssdHomekitAdvertiser();
+ }
+
+ /**
+ * Prefer native DNS-SD when available (macOS); otherwise create a dedicated JmDNS instance on
+ * {@code localAddress}.
+ *
+ * @param localAddress address used if falling back to JmDNS
+ * @return a native DNS-SD advertiser when supported, otherwise a JmDNS-backed advertiser
+ * @throws IOException if native DNS-SD is unavailable and JmDNS cannot be created
+ */
+ public static HomekitAdvertiser auto(InetAddress localAddress) throws IOException {
+ if (DnssdHomekitAdvertiser.isSupported()) {
+ return new DnssdHomekitAdvertiser();
+ }
+ return new JmdnsHomekitAdvertiser(localAddress);
+ }
+
+ /**
+ * Whether {@link #nativeDnssd()} can be used on this JVM/OS.
+ *
+ * @return {@code true} if the platform DNS-SD library loaded successfully
+ */
+ public static boolean isNativeDnssdSupported() {
+ return DnssdHomekitAdvertiser.isSupported();
+ }
+}
diff --git a/src/main/java/io/github/hapjava/server/impl/HomekitRoot.java b/src/main/java/io/github/hapjava/server/impl/HomekitRoot.java
index 36bfeefa2..1721be95f 100644
--- a/src/main/java/io/github/hapjava/server/impl/HomekitRoot.java
+++ b/src/main/java/io/github/hapjava/server/impl/HomekitRoot.java
@@ -3,6 +3,7 @@
import io.github.hapjava.accessories.Bridge;
import io.github.hapjava.accessories.HomekitAccessory;
import io.github.hapjava.server.HomekitAccessoryCategories;
+import io.github.hapjava.server.HomekitAdvertiser;
import io.github.hapjava.server.HomekitAuthInfo;
import io.github.hapjava.server.HomekitWebHandler;
import io.github.hapjava.server.impl.connections.HomekitClientConnectionFactoryImpl;
@@ -27,7 +28,7 @@ public class HomekitRoot {
private static final Logger logger = LoggerFactory.getLogger(HomekitRoot.class);
private static final int DEFAULT_ACCESSORY_CATEGORY = HomekitAccessoryCategories.OTHER;
- private final JmdnsHomekitAdvertiser advertiser;
+ private final HomekitAdvertiser advertiser;
private final HomekitWebHandler webHandler;
private final HomekitAuthInfo authInfo;
private final String label;
@@ -60,7 +61,7 @@ public class HomekitRoot {
int category,
HomekitWebHandler webHandler,
HomekitAuthInfo authInfo,
- JmdnsHomekitAdvertiser advertiser)
+ HomekitAdvertiser advertiser)
throws IOException {
this.advertiser = advertiser;
this.webHandler = webHandler;
diff --git a/src/main/java/io/github/hapjava/server/impl/HomekitServer.java b/src/main/java/io/github/hapjava/server/impl/HomekitServer.java
index 7728896a2..e16c3518b 100644
--- a/src/main/java/io/github/hapjava/server/impl/HomekitServer.java
+++ b/src/main/java/io/github/hapjava/server/impl/HomekitServer.java
@@ -1,6 +1,7 @@
package io.github.hapjava.server.impl;
import io.github.hapjava.accessories.HomekitAccessory;
+import io.github.hapjava.server.HomekitAdvertiser;
import io.github.hapjava.server.HomekitAuthInfo;
import io.github.hapjava.server.impl.http.impl.HomekitHttpServer;
import java.io.IOException;
@@ -23,6 +24,9 @@
* same values on each start of your application or HomeKit will fail to recognize your device as
* being the same.
*
+ * On macOS, prefer constructing with an explicit {@link HomekitAdvertiser} from {@link
+ * io.github.hapjava.server.HomekitAdvertisers#nativeDnssd()} so advertising does not bind UDP 5353.
+ *
* @author Andy Lintner
*/
public class HomekitServer {
@@ -30,12 +34,15 @@ public class HomekitServer {
private final HomekitHttpServer http;
private final InetAddress localAddress;
private final JmDNS jmdns;
+ private final HomekitAdvertiser advertiser;
/**
* Constructor. Contains an argument indicating the number of threads to use in the http server.
* The other constructors default this to the number of available processors, however you may
* increase this in an environment with many users and/or blocking accessory implementations.
*
+ *
Advertising uses a dedicated JmDNS instance (binds UDP 5353).
+ *
* @param localAddress local address to bind to.
* @param port local port to bind to.
* @param nThreads number of threads to use in the http server
@@ -44,6 +51,28 @@ public class HomekitServer {
public HomekitServer(InetAddress localAddress, int port, int nThreads) throws IOException {
this.localAddress = localAddress;
this.jmdns = null;
+ this.advertiser = null;
+ http = new HomekitHttpServer(localAddress, port, nThreads);
+ }
+
+ /**
+ * Constructor that uses a caller-supplied advertiser (e.g. native DNS-SD) and does not create
+ * JmDNS.
+ *
+ * @param localAddress local address for the HAP HTTP server
+ * @param port local TCP port
+ * @param nThreads HTTP worker threads
+ * @param advertiser mDNS / DNS-SD advertiser (must not be null)
+ */
+ public HomekitServer(
+ InetAddress localAddress, int port, int nThreads, HomekitAdvertiser advertiser)
+ throws IOException {
+ if (advertiser == null) {
+ throw new IllegalArgumentException("advertiser must not be null");
+ }
+ this.localAddress = localAddress;
+ this.jmdns = null;
+ this.advertiser = advertiser;
http = new HomekitHttpServer(localAddress, port, nThreads);
}
@@ -58,6 +87,7 @@ public HomekitServer(InetAddress localAddress, int port, int nThreads) throws IO
public HomekitServer(JmDNS jmdns, int port, int nThreads) throws IOException {
this.jmdns = jmdns;
this.localAddress = null;
+ this.advertiser = null;
http = new HomekitHttpServer(jmdns.getInetAddress(), port, nThreads);
}
@@ -72,6 +102,15 @@ public HomekitServer(InetAddress localAddress, int port) throws IOException {
this(localAddress, port, Runtime.getRuntime().availableProcessors());
}
+ /**
+ * HAP HTTP on {@code localAddress}:{@code port} with an explicit advertiser (native DNSSD or
+ * shared JmDNS wrapper).
+ */
+ public HomekitServer(InetAddress localAddress, int port, HomekitAdvertiser advertiser)
+ throws IOException {
+ this(localAddress, port, Runtime.getRuntime().availableProcessors(), advertiser);
+ }
+
/**
* Constructor
*
@@ -111,7 +150,10 @@ public void stop() {
public HomekitStandaloneAccessoryServer createStandaloneAccessory(
HomekitAuthInfo authInfo, HomekitAccessory accessory)
throws IOException, ExecutionException, InterruptedException {
- if (jmdns != null) {
+ if (advertiser != null) {
+ return new HomekitStandaloneAccessoryServer(
+ accessory, http, localAddress, authInfo, advertiser);
+ } else if (jmdns != null) {
return new HomekitStandaloneAccessoryServer(accessory, http, jmdns, authInfo);
} else {
return new HomekitStandaloneAccessoryServer(accessory, http, localAddress, authInfo);
@@ -121,7 +163,10 @@ public HomekitStandaloneAccessoryServer createStandaloneAccessory(
public HomekitStandaloneAccessoryServer createStandaloneAccessory(
HomekitAuthInfo authInfo, HomekitAccessory accessory, int category)
throws IOException, ExecutionException, InterruptedException {
- if (jmdns != null) {
+ if (advertiser != null) {
+ return new HomekitStandaloneAccessoryServer(
+ accessory, http, localAddress, authInfo, category, advertiser);
+ } else if (jmdns != null) {
return new HomekitStandaloneAccessoryServer(accessory, http, jmdns, authInfo, category);
} else {
return new HomekitStandaloneAccessoryServer(
@@ -158,7 +203,9 @@ public HomekitRoot createBridge(
String hardwareRevision)
throws IOException {
HomekitRoot root;
- if (jmdns != null) {
+ if (advertiser != null) {
+ root = new HomekitRoot(label, category, http, authInfo, advertiser);
+ } else if (jmdns != null) {
root = new HomekitRoot(label, category, http, jmdns, authInfo);
} else {
root = new HomekitRoot(label, category, http, localAddress, authInfo);
diff --git a/src/main/java/io/github/hapjava/server/impl/HomekitStandaloneAccessoryServer.java b/src/main/java/io/github/hapjava/server/impl/HomekitStandaloneAccessoryServer.java
index 4a5dbabee..c0fc23b95 100644
--- a/src/main/java/io/github/hapjava/server/impl/HomekitStandaloneAccessoryServer.java
+++ b/src/main/java/io/github/hapjava/server/impl/HomekitStandaloneAccessoryServer.java
@@ -1,6 +1,8 @@
package io.github.hapjava.server.impl;
import io.github.hapjava.accessories.HomekitAccessory;
+import io.github.hapjava.server.HomekitAccessoryCategories;
+import io.github.hapjava.server.HomekitAdvertiser;
import io.github.hapjava.server.HomekitAuthInfo;
import io.github.hapjava.server.HomekitWebHandler;
import java.io.IOException;
@@ -63,6 +65,35 @@ public class HomekitStandaloneAccessoryServer {
root.addAccessory(accessory);
}
+ HomekitStandaloneAccessoryServer(
+ HomekitAccessory accessory,
+ HomekitWebHandler webHandler,
+ InetAddress localhost,
+ HomekitAuthInfo authInfo,
+ HomekitAdvertiser advertiser)
+ throws UnknownHostException, IOException, ExecutionException, InterruptedException {
+ root =
+ new HomekitRoot(
+ accessory.getName().get(),
+ HomekitAccessoryCategories.OTHER,
+ webHandler,
+ authInfo,
+ advertiser);
+ root.addAccessory(accessory);
+ }
+
+ HomekitStandaloneAccessoryServer(
+ HomekitAccessory accessory,
+ HomekitWebHandler webHandler,
+ InetAddress localhost,
+ HomekitAuthInfo authInfo,
+ int category,
+ HomekitAdvertiser advertiser)
+ throws UnknownHostException, IOException, ExecutionException, InterruptedException {
+ root = new HomekitRoot(accessory.getName().get(), category, webHandler, authInfo, advertiser);
+ root.addAccessory(accessory);
+ }
+
/** Begins advertising and handling requests for this accessory. */
public void start() {
root.start();
diff --git a/src/main/java/io/github/hapjava/server/impl/advertiser/HapDnsTxt.java b/src/main/java/io/github/hapjava/server/impl/advertiser/HapDnsTxt.java
new file mode 100644
index 000000000..632f23498
--- /dev/null
+++ b/src/main/java/io/github/hapjava/server/impl/advertiser/HapDnsTxt.java
@@ -0,0 +1,81 @@
+package io.github.hapjava.server.impl.advertiser;
+
+import static io.github.hapjava.server.impl.crypto.HAPSetupCodeUtils.generateSHA512Hash;
+
+import java.nio.charset.StandardCharsets;
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+/**
+ * Shared HAP DNS-SD TXT record helpers for JmDNS and native DNSSD advertisers.
+ *
+ * @author Karel Goderis
+ */
+public final class HapDnsTxt {
+
+ public static final String SERVICE_TYPE = "_hap._tcp";
+ public static final String SERVICE_TYPE_LOCAL = "_hap._tcp.local.";
+ public static final String DOMAIN_LOCAL = "local.";
+
+ private HapDnsTxt() {}
+
+ /**
+ * Build the standard HAP TXT key/value map.
+ *
+ * @param discoverable whether the accessory is unpaired / discoverable ({@code sf})
+ * @param mac accessory id ({@code id})
+ * @param label model / display name ({@code md})
+ * @param setupId setup id used to compute {@code sh}
+ * @param configurationIndex configuration number ({@code c#})
+ * @param category accessory category ({@code ci})
+ * @return ordered TXT properties suitable for JmDNS or {@link #encodeTxt(Map)}
+ */
+ public static Map buildProperties(
+ boolean discoverable,
+ String mac,
+ String label,
+ String setupId,
+ int configurationIndex,
+ int category) {
+ Map props = new LinkedHashMap<>();
+ props.put("sf", discoverable ? "1" : "0");
+ props.put("id", mac);
+ props.put("md", label);
+ props.put("sh", generateSHA512Hash(setupId + mac));
+ props.put("c#", Integer.toString(configurationIndex));
+ props.put("s#", "1");
+ props.put("ff", "0");
+ props.put("ci", Integer.toString(category));
+ props.put("pv", "1.1");
+ return props;
+ }
+
+ /**
+ * Encode TXT as DNS-SD wire format: a sequence of length-prefixed {@code key=value} strings (RFC
+ * 6763).
+ *
+ * @param props HAP TXT key/value pairs
+ * @return DNS-SD TXT RDATA bytes
+ * @throws IllegalArgumentException if any encoded pair exceeds 255 bytes
+ */
+ public static byte[] encodeTxt(Map props) {
+ int size = 0;
+ for (Map.Entry e : props.entrySet()) {
+ String pair = e.getKey() + "=" + e.getValue();
+ byte[] bytes = pair.getBytes(StandardCharsets.UTF_8);
+ if (bytes.length > 255) {
+ throw new IllegalArgumentException("TXT pair too long: " + e.getKey());
+ }
+ size += 1 + bytes.length;
+ }
+ byte[] out = new byte[size];
+ int offset = 0;
+ for (Map.Entry e : props.entrySet()) {
+ byte[] bytes = (e.getKey() + "=" + e.getValue()).getBytes(StandardCharsets.UTF_8);
+ out[offset++] = (byte) bytes.length;
+ System.arraycopy(bytes, 0, out, offset, bytes.length);
+ offset += bytes.length;
+ }
+ return out;
+ }
+}
diff --git a/src/main/java/io/github/hapjava/server/impl/connections/ConnectionImpl.java b/src/main/java/io/github/hapjava/server/impl/connections/ConnectionImpl.java
index 759506722..f302b5051 100644
--- a/src/main/java/io/github/hapjava/server/impl/connections/ConnectionImpl.java
+++ b/src/main/java/io/github/hapjava/server/impl/connections/ConnectionImpl.java
@@ -1,5 +1,6 @@
package io.github.hapjava.server.impl.connections;
+import io.github.hapjava.server.HomekitAdvertiser;
import io.github.hapjava.server.HomekitAuthInfo;
import io.github.hapjava.server.impl.HomekitRegistry;
import io.github.hapjava.server.impl.crypto.ChachaDecoder;
@@ -7,7 +8,6 @@
import io.github.hapjava.server.impl.http.HomekitClientConnection;
import io.github.hapjava.server.impl.http.HttpRequest;
import io.github.hapjava.server.impl.http.HttpResponse;
-import io.github.hapjava.server.impl.jmdns.JmdnsHomekitAdvertiser;
import io.github.hapjava.server.impl.pairing.UpgradeResponse;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
@@ -39,7 +39,7 @@ public ConnectionImpl(
HomekitRegistry registry,
Consumer outOfBandMessageCallback,
SubscriptionManager subscriptions,
- JmdnsHomekitAdvertiser advertiser) {
+ HomekitAdvertiser advertiser) {
httpSession = new HttpSession(authInfo, registry, subscriptions, this, advertiser);
this.outOfBandMessageCallback = outOfBandMessageCallback;
this.subscriptions = subscriptions;
diff --git a/src/main/java/io/github/hapjava/server/impl/connections/HomekitClientConnectionFactoryImpl.java b/src/main/java/io/github/hapjava/server/impl/connections/HomekitClientConnectionFactoryImpl.java
index 9c8ad9fc0..f23c79d08 100644
--- a/src/main/java/io/github/hapjava/server/impl/connections/HomekitClientConnectionFactoryImpl.java
+++ b/src/main/java/io/github/hapjava/server/impl/connections/HomekitClientConnectionFactoryImpl.java
@@ -1,11 +1,11 @@
package io.github.hapjava.server.impl.connections;
+import io.github.hapjava.server.HomekitAdvertiser;
import io.github.hapjava.server.HomekitAuthInfo;
import io.github.hapjava.server.impl.HomekitRegistry;
import io.github.hapjava.server.impl.http.HomekitClientConnection;
import io.github.hapjava.server.impl.http.HomekitClientConnectionFactory;
import io.github.hapjava.server.impl.http.HttpResponse;
-import io.github.hapjava.server.impl.jmdns.JmdnsHomekitAdvertiser;
import java.util.function.Consumer;
public class HomekitClientConnectionFactoryImpl implements HomekitClientConnectionFactory {
@@ -13,13 +13,13 @@ public class HomekitClientConnectionFactoryImpl implements HomekitClientConnecti
private final HomekitAuthInfo authInfo;
private final HomekitRegistry registry;
private final SubscriptionManager subscriptions;
- private final JmdnsHomekitAdvertiser advertiser;
+ private final HomekitAdvertiser advertiser;
public HomekitClientConnectionFactoryImpl(
HomekitAuthInfo authInfo,
HomekitRegistry registry,
SubscriptionManager subscriptions,
- JmdnsHomekitAdvertiser advertiser) {
+ HomekitAdvertiser advertiser) {
this.registry = registry;
this.authInfo = authInfo;
this.subscriptions = subscriptions;
diff --git a/src/main/java/io/github/hapjava/server/impl/connections/HttpSession.java b/src/main/java/io/github/hapjava/server/impl/connections/HttpSession.java
index f1c97c62c..66dbc24eb 100644
--- a/src/main/java/io/github/hapjava/server/impl/connections/HttpSession.java
+++ b/src/main/java/io/github/hapjava/server/impl/connections/HttpSession.java
@@ -1,12 +1,12 @@
package io.github.hapjava.server.impl.connections;
import io.github.hapjava.accessories.HomekitAccessory;
+import io.github.hapjava.server.HomekitAdvertiser;
import io.github.hapjava.server.HomekitAuthInfo;
import io.github.hapjava.server.impl.HomekitRegistry;
import io.github.hapjava.server.impl.http.HomekitClientConnection;
import io.github.hapjava.server.impl.http.HttpRequest;
import io.github.hapjava.server.impl.http.HttpResponse;
-import io.github.hapjava.server.impl.jmdns.JmdnsHomekitAdvertiser;
import io.github.hapjava.server.impl.json.AccessoryController;
import io.github.hapjava.server.impl.json.CharacteristicsController;
import io.github.hapjava.server.impl.pairing.PairSetupManager;
@@ -30,7 +30,7 @@ class HttpSession {
private final HomekitRegistry registry;
private final SubscriptionManager subscriptions;
private final HomekitClientConnection connection;
- private final JmdnsHomekitAdvertiser advertiser;
+ private final HomekitAdvertiser advertiser;
private static final Logger logger = LoggerFactory.getLogger(HttpSession.class);
@@ -39,7 +39,7 @@ public HttpSession(
HomekitRegistry registry,
SubscriptionManager subscriptions,
HomekitClientConnection connection,
- JmdnsHomekitAdvertiser advertiser) {
+ HomekitAdvertiser advertiser) {
this.authInfo = authInfo;
this.registry = registry;
this.subscriptions = subscriptions;
diff --git a/src/main/java/io/github/hapjava/server/impl/dnssd/DnsSdLibrary.java b/src/main/java/io/github/hapjava/server/impl/dnssd/DnsSdLibrary.java
new file mode 100644
index 000000000..4bb718f56
--- /dev/null
+++ b/src/main/java/io/github/hapjava/server/impl/dnssd/DnsSdLibrary.java
@@ -0,0 +1,49 @@
+package io.github.hapjava.server.impl.dnssd;
+
+import com.sun.jna.Library;
+import com.sun.jna.Native;
+import com.sun.jna.Pointer;
+import com.sun.jna.ptr.PointerByReference;
+
+/**
+ * Minimal JNA mapping of Apple's {@code dns_sd.h} / Avahi compat {@code DNSServiceRegister} API.
+ *
+ * On macOS the symbols live in {@code libSystem}; on Linux with Avahi they typically live in
+ * {@code libdns_sd}.
+ *
+ * @author Karel Goderis
+ */
+public interface DnsSdLibrary extends Library {
+
+ int kDNSServiceErr_NoError = 0;
+ int kDNSServiceFlagsNoAutoRename = 0; // default flags = 0
+
+ int DNSServiceRegister(
+ PointerByReference sdRef,
+ int flags,
+ int interfaceIndex,
+ String name,
+ String regtype,
+ String domain,
+ String host,
+ short port, // network byte order
+ short txtLen,
+ byte[] txtRecord,
+ Pointer callBack, // may be null
+ Pointer context);
+
+ void DNSServiceRefDeallocate(Pointer sdRef);
+
+ int DNSServiceRefSockFD(Pointer sdRef);
+
+ int DNSServiceProcessResult(Pointer sdRef);
+
+ static DnsSdLibrary load() {
+ String os = System.getProperty("os.name", "").toLowerCase();
+ if (os.contains("mac") || os.contains("darwin")) {
+ return Native.load("System", DnsSdLibrary.class);
+ }
+ // Avahi Bonjour compatibility library
+ return Native.load("dns_sd", DnsSdLibrary.class);
+ }
+}
diff --git a/src/main/java/io/github/hapjava/server/impl/dnssd/DnssdHomekitAdvertiser.java b/src/main/java/io/github/hapjava/server/impl/dnssd/DnssdHomekitAdvertiser.java
new file mode 100644
index 000000000..2761b8760
--- /dev/null
+++ b/src/main/java/io/github/hapjava/server/impl/dnssd/DnssdHomekitAdvertiser.java
@@ -0,0 +1,238 @@
+package io.github.hapjava.server.impl.dnssd;
+
+import com.sun.jna.Pointer;
+import com.sun.jna.ptr.PointerByReference;
+import io.github.hapjava.server.HomekitAdvertiser;
+import io.github.hapjava.server.impl.advertiser.HapDnsTxt;
+import java.io.IOException;
+import java.nio.ByteBuffer;
+import java.nio.ByteOrder;
+import java.util.Map;
+import java.util.Objects;
+import java.util.concurrent.atomic.AtomicBoolean;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * HomeKit advertiser that registers {@code _hap._tcp} through the platform DNS-SD API (macOS
+ * mDNSResponder / Avahi dns_sd compat) without binding UDP 5353.
+ *
+ *
Uses JNA to call {@code DNSServiceRegister} / {@code DNSServiceRefDeallocate}.
+ *
+ * @author Karel Goderis
+ */
+public class DnssdHomekitAdvertiser implements HomekitAdvertiser {
+
+ private static final Logger logger = LoggerFactory.getLogger(DnssdHomekitAdvertiser.class);
+
+ private static final boolean SUPPORTED = detectSupport();
+
+ private final DnsSdLibrary dnsSd;
+ private final Object lock = new Object();
+
+ private boolean discoverable = true;
+ private boolean isAdvertising = false;
+
+ private String label;
+ private String mac;
+ private String setupId;
+ private int port;
+ private int configurationIndex;
+ private int category;
+
+ private Pointer serviceRef;
+ private Thread processThread;
+ private final AtomicBoolean runProcessLoop = new AtomicBoolean(false);
+
+ public DnssdHomekitAdvertiser() {
+ if (!SUPPORTED) {
+ throw new UnsupportedOperationException("Native DNS-SD is not available");
+ }
+ this.dnsSd = DnsSdLibrary.load();
+ }
+
+ /** Visible for tests that inject a mock library. */
+ DnssdHomekitAdvertiser(DnsSdLibrary dnsSd) {
+ this.dnsSd = dnsSd;
+ }
+
+ public static boolean isSupported() {
+ return SUPPORTED;
+ }
+
+ private static boolean detectSupport() {
+ try {
+ DnsSdLibrary.load();
+ return true;
+ } catch (UnsatisfiedLinkError | Exception e) {
+ logger.debug("Native DNS-SD not available: {}", e.toString());
+ return false;
+ }
+ }
+
+ @Override
+ public void advertise(
+ String label, int category, String mac, int port, int configurationIndex, String setupId)
+ throws Exception {
+ synchronized (lock) {
+ if (isAdvertising) {
+ throw new IllegalStateException("HomeKit advertiser is already running");
+ }
+ this.label = label;
+ this.mac = mac;
+ this.port = port;
+ this.setupId = setupId;
+ this.category = category;
+ this.configurationIndex = configurationIndex;
+ logger.trace("Advertising accessory {} via native DNS-SD", label);
+ registerService();
+ isAdvertising = true;
+ }
+ }
+
+ @Override
+ public void stop() {
+ synchronized (lock) {
+ unregisterService();
+ isAdvertising = false;
+ }
+ }
+
+ @Override
+ public void setDiscoverable(boolean discoverable) throws IOException {
+ synchronized (lock) {
+ if (this.discoverable != discoverable) {
+ this.discoverable = discoverable;
+ if (isAdvertising) {
+ logger.trace("Re-creating DNSSD service due to discoverability={}", discoverable);
+ unregisterService();
+ registerService();
+ }
+ }
+ }
+ }
+
+ @Override
+ public void setMac(String mac) throws IOException {
+ synchronized (lock) {
+ if (!Objects.equals(this.mac, mac)) {
+ this.mac = mac;
+ if (isAdvertising) {
+ logger.trace("Re-creating DNSSD service due to mac change");
+ unregisterService();
+ registerService();
+ }
+ }
+ }
+ }
+
+ @Override
+ public void setConfigurationIndex(int revision) throws IOException {
+ synchronized (lock) {
+ if (this.configurationIndex != revision) {
+ this.configurationIndex = revision;
+ if (isAdvertising) {
+ logger.trace("Re-creating DNSSD service due to c#={}", revision);
+ unregisterService();
+ registerService();
+ }
+ }
+ }
+ }
+
+ private void registerService() throws IOException {
+ if (serviceRef != null) {
+ throw new AssertionError("DNSSD service already registered");
+ }
+ Map props =
+ HapDnsTxt.buildProperties(discoverable, mac, label, setupId, configurationIndex, category);
+ byte[] txt = HapDnsTxt.encodeTxt(props);
+ // DNSServiceRegister requires the port in network byte order (big-endian wire form).
+ short netPort = toNetworkByteOrderPort(port);
+
+ PointerByReference refOut = new PointerByReference();
+ int err =
+ dnsSd.DNSServiceRegister(
+ refOut,
+ 0,
+ 0,
+ label,
+ HapDnsTxt.SERVICE_TYPE,
+ HapDnsTxt.DOMAIN_LOCAL,
+ null,
+ netPort,
+ (short) txt.length,
+ txt,
+ Pointer.NULL,
+ Pointer.NULL);
+ if (err != DnsSdLibrary.kDNSServiceErr_NoError) {
+ throw new IOException("DNSServiceRegister failed with error code " + err);
+ }
+ serviceRef = refOut.getValue();
+ if (serviceRef == null) {
+ throw new IOException("DNSServiceRegister returned a null service reference");
+ }
+ logger.info(
+ "Registered {} via native DNS-SD on port {} (no UDP 5353 bind)",
+ HapDnsTxt.SERVICE_TYPE,
+ port);
+ startProcessLoop(serviceRef);
+ }
+
+ private void startProcessLoop(Pointer ref) {
+ runProcessLoop.set(true);
+ processThread =
+ new Thread(
+ () -> {
+ try {
+ // DNSServiceProcessResult blocks until daemon IPC data arrives (or FD closes).
+ while (runProcessLoop.get()) {
+ int err = dnsSd.DNSServiceProcessResult(ref);
+ if (err != DnsSdLibrary.kDNSServiceErr_NoError) {
+ if (runProcessLoop.get()) {
+ logger.debug("DNSServiceProcessResult returned {}", err);
+ }
+ break;
+ }
+ }
+ } catch (Throwable t) {
+ if (runProcessLoop.get()) {
+ logger.warn("DNSSD process loop ended: {}", t.toString());
+ }
+ }
+ },
+ "hap-dnssd-process");
+ processThread.setDaemon(true);
+ processThread.start();
+ }
+
+ private void unregisterService() {
+ runProcessLoop.set(false);
+ Pointer ref = serviceRef;
+ serviceRef = null;
+ // Deallocate first so a blocking DNSServiceProcessResult returns.
+ if (ref != null) {
+ try {
+ dnsSd.DNSServiceRefDeallocate(ref);
+ } catch (Throwable t) {
+ logger.debug("DNSServiceRefDeallocate: {}", t.toString());
+ }
+ }
+ if (processThread != null) {
+ try {
+ processThread.join(2000);
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ }
+ processThread = null;
+ }
+ }
+
+ /** Convert a host-order TCP port to the short value DNS-SD expects (network byte order). */
+ static short toNetworkByteOrderPort(int port) {
+ ByteBuffer buf = ByteBuffer.allocate(2).order(ByteOrder.BIG_ENDIAN);
+ buf.putShort((short) port);
+ buf.order(ByteOrder.nativeOrder());
+ return buf.getShort(0);
+ }
+}
diff --git a/src/main/java/io/github/hapjava/server/impl/jmdns/JmdnsHomekitAdvertiser.java b/src/main/java/io/github/hapjava/server/impl/jmdns/JmdnsHomekitAdvertiser.java
index 9086acb3e..4f28d9030 100644
--- a/src/main/java/io/github/hapjava/server/impl/jmdns/JmdnsHomekitAdvertiser.java
+++ b/src/main/java/io/github/hapjava/server/impl/jmdns/JmdnsHomekitAdvertiser.java
@@ -1,20 +1,18 @@
package io.github.hapjava.server.impl.jmdns;
-import static io.github.hapjava.server.impl.crypto.HAPSetupCodeUtils.generateSHA512Hash;
-
+import io.github.hapjava.server.HomekitAdvertiser;
+import io.github.hapjava.server.impl.advertiser.HapDnsTxt;
import java.io.IOException;
import java.net.InetAddress;
import java.net.UnknownHostException;
-import java.util.HashMap;
import java.util.Map;
import javax.jmdns.JmDNS;
import javax.jmdns.ServiceInfo;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
-public class JmdnsHomekitAdvertiser {
-
- private static final String SERVICE_TYPE = "_hap._tcp.local.";
+/** HomeKit advertiser backed by JmDNS (binds / uses UDP 5353). */
+public class JmdnsHomekitAdvertiser implements HomekitAdvertiser {
private final JmDNS jmdns;
private boolean discoverable = true;
@@ -37,6 +35,7 @@ public JmdnsHomekitAdvertiser(InetAddress localAddress) throws UnknownHostExcept
jmdns = JmDNS.create(localAddress);
}
+ @Override
public synchronized void advertise(
String label, int category, String mac, int port, int configurationIndex, String setupId)
throws Exception {
@@ -64,10 +63,12 @@ public synchronized void advertise(
isAdvertising = true;
}
+ @Override
public synchronized void stop() {
unregisterService();
}
+ @Override
public synchronized void setDiscoverable(boolean discoverable) throws IOException {
if (this.discoverable != discoverable) {
this.discoverable = discoverable;
@@ -79,6 +80,7 @@ public synchronized void setDiscoverable(boolean discoverable) throws IOExceptio
}
}
+ @Override
public synchronized void setMac(String mac) throws IOException {
if (this.mac != mac) {
this.mac = mac;
@@ -90,6 +92,7 @@ public synchronized void setMac(String mac) throws IOException {
}
}
+ @Override
public synchronized void setConfigurationIndex(int revision) throws IOException {
if (this.configurationIndex != revision) {
this.configurationIndex = revision;
@@ -109,7 +112,7 @@ private void unregisterService() {
}
private void registerService() throws IOException {
- logger.info("Registering " + SERVICE_TYPE + " on port " + port);
+ logger.info("Registering " + HapDnsTxt.SERVICE_TYPE_LOCAL + " on port " + port);
if (this.serviceInfo != null) {
throw new AssertionError(
"Registering an already registered service without unregistering first is not allowed");
@@ -120,17 +123,8 @@ private void registerService() throws IOException {
private ServiceInfo buildServiceInfo() {
logger.trace("MAC:" + mac + " Setup Id:" + setupId);
- Map props = new HashMap<>();
- props.put("sf", discoverable ? "1" : "0");
- props.put("id", mac);
- props.put("md", label);
- props.put("sh", generateSHA512Hash(setupId + mac));
- props.put("c#", Integer.toString(configurationIndex));
- props.put("s#", "1");
- props.put("ff", "0");
- props.put("ci", Integer.toString(category));
- props.put("pv", "1.1");
-
- return ServiceInfo.create(SERVICE_TYPE, label, port, 1, 1, props);
+ Map props =
+ HapDnsTxt.buildProperties(discoverable, mac, label, setupId, configurationIndex, category);
+ return ServiceInfo.create(HapDnsTxt.SERVICE_TYPE_LOCAL, label, port, 1, 1, props);
}
}
diff --git a/src/main/java/io/github/hapjava/server/impl/pairing/PairingsManager.java b/src/main/java/io/github/hapjava/server/impl/pairing/PairingsManager.java
index 11eb4a709..f0ff53c15 100644
--- a/src/main/java/io/github/hapjava/server/impl/pairing/PairingsManager.java
+++ b/src/main/java/io/github/hapjava/server/impl/pairing/PairingsManager.java
@@ -1,9 +1,9 @@
package io.github.hapjava.server.impl.pairing;
+import io.github.hapjava.server.HomekitAdvertiser;
import io.github.hapjava.server.HomekitAuthInfo;
import io.github.hapjava.server.impl.http.HttpRequest;
import io.github.hapjava.server.impl.http.HttpResponse;
-import io.github.hapjava.server.impl.jmdns.JmdnsHomekitAdvertiser;
import io.github.hapjava.server.impl.pairing.TypeLengthValueUtils.DecodeResult;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
@@ -15,11 +15,11 @@
public class PairingsManager {
private final HomekitAuthInfo authInfo;
- private final JmdnsHomekitAdvertiser advertiser;
+ private final HomekitAdvertiser advertiser;
private static final Logger LOGGER = LoggerFactory.getLogger(PairingsManager.class);
- public PairingsManager(HomekitAuthInfo authInfo, JmdnsHomekitAdvertiser advertiser) {
+ public PairingsManager(HomekitAuthInfo authInfo, HomekitAdvertiser advertiser) {
this.authInfo = authInfo;
this.advertiser = advertiser;
}
diff --git a/src/test/java/io/github/hapjava/server/HomekitAdvertisersTest.java b/src/test/java/io/github/hapjava/server/HomekitAdvertisersTest.java
new file mode 100644
index 000000000..bffd16e3d
--- /dev/null
+++ b/src/test/java/io/github/hapjava/server/HomekitAdvertisersTest.java
@@ -0,0 +1,23 @@
+package io.github.hapjava.server;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import io.github.hapjava.server.impl.dnssd.DnssdHomekitAdvertiser;
+import org.junit.Test;
+
+public class HomekitAdvertisersTest {
+
+ @Test
+ public void isNativeDnssdSupportedMatchesAdvertiserDetection() {
+ assertThat(HomekitAdvertisers.isNativeDnssdSupported())
+ .isEqualTo(DnssdHomekitAdvertiser.isSupported());
+ }
+
+ @Test
+ public void nativeDnssdFactoryWorksWhenSupported() {
+ org.junit.Assume.assumeTrue(HomekitAdvertisers.isNativeDnssdSupported());
+ HomekitAdvertiser advertiser = HomekitAdvertisers.nativeDnssd();
+ assertThat(advertiser).isInstanceOf(DnssdHomekitAdvertiser.class);
+ advertiser.stop();
+ }
+}
diff --git a/src/test/java/io/github/hapjava/server/impl/advertiser/HapDnsTxtTest.java b/src/test/java/io/github/hapjava/server/impl/advertiser/HapDnsTxtTest.java
new file mode 100644
index 000000000..e444c2eb1
--- /dev/null
+++ b/src/test/java/io/github/hapjava/server/impl/advertiser/HapDnsTxtTest.java
@@ -0,0 +1,52 @@
+package io.github.hapjava.server.impl.advertiser;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import java.nio.charset.StandardCharsets;
+import java.util.Map;
+import org.junit.Test;
+
+public class HapDnsTxtTest {
+
+ @Test
+ public void buildPropertiesContainsRequiredHapKeys() {
+ Map props =
+ HapDnsTxt.buildProperties(true, "AA:BB:CC:DD:EE:FF", "Bridge", "ABCD", 3, 2);
+
+ assertThat(props)
+ .containsEntry("sf", "1")
+ .containsEntry("id", "AA:BB:CC:DD:EE:FF")
+ .containsEntry("md", "Bridge")
+ .containsEntry("c#", "3")
+ .containsEntry("s#", "1")
+ .containsEntry("ff", "0")
+ .containsEntry("ci", "2")
+ .containsEntry("pv", "1.1");
+ assertThat(props.get("sh")).isNotBlank();
+ }
+
+ @Test
+ public void buildPropertiesSetsSfZeroWhenNotDiscoverable() {
+ Map props =
+ HapDnsTxt.buildProperties(false, "AA:BB:CC:DD:EE:FF", "Bridge", "ABCD", 1, 1);
+ assertThat(props).containsEntry("sf", "0");
+ }
+
+ @Test
+ public void encodeTxtUsesLengthPrefixedKeyValuePairs() {
+ Map props = HapDnsTxt.buildProperties(true, "id", "md", "setup", 1, 1);
+ byte[] txt = HapDnsTxt.encodeTxt(props);
+
+ int offset = 0;
+ int pairs = 0;
+ while (offset < txt.length) {
+ int len = txt[offset] & 0xff;
+ String pair = new String(txt, offset + 1, len, StandardCharsets.UTF_8);
+ assertThat(pair).contains("=");
+ offset += 1 + len;
+ pairs++;
+ }
+ assertThat(pairs).isEqualTo(props.size());
+ assertThat(offset).isEqualTo(txt.length);
+ }
+}
diff --git a/src/test/java/io/github/hapjava/server/impl/dnssd/DnssdHomekitAdvertiserTest.java b/src/test/java/io/github/hapjava/server/impl/dnssd/DnssdHomekitAdvertiserTest.java
new file mode 100644
index 000000000..d1cbba9a4
--- /dev/null
+++ b/src/test/java/io/github/hapjava/server/impl/dnssd/DnssdHomekitAdvertiserTest.java
@@ -0,0 +1,161 @@
+package io.github.hapjava.server.impl.dnssd;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyInt;
+import static org.mockito.ArgumentMatchers.anyShort;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.ArgumentMatchers.nullable;
+import static org.mockito.Mockito.atLeastOnce;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import com.sun.jna.Pointer;
+import com.sun.jna.ptr.PointerByReference;
+import java.nio.ByteOrder;
+import org.junit.Before;
+import org.junit.Test;
+import org.mockito.ArgumentCaptor;
+import org.mockito.stubbing.Answer;
+
+public class DnssdHomekitAdvertiserTest {
+
+ private DnsSdLibrary dnsSd;
+ private DnssdHomekitAdvertiser subject;
+ private Pointer serviceRef;
+
+ private final Answer successRegister =
+ invocation -> {
+ PointerByReference refOut = invocation.getArgument(0);
+ refOut.setValue(serviceRef);
+ return DnsSdLibrary.kDNSServiceErr_NoError;
+ };
+
+ @Before
+ public void setup() {
+ dnsSd = mock(DnsSdLibrary.class);
+ serviceRef = new Pointer(1L);
+ subject = new DnssdHomekitAdvertiser(dnsSd);
+
+ when(dnsSd.DNSServiceRegister(
+ any(PointerByReference.class),
+ anyInt(),
+ anyInt(),
+ anyString(),
+ anyString(),
+ anyString(),
+ nullable(String.class),
+ anyShort(),
+ anyShort(),
+ any(byte[].class),
+ any(),
+ any()))
+ .thenAnswer(successRegister);
+
+ when(dnsSd.DNSServiceProcessResult(any(Pointer.class)))
+ .thenReturn(DnsSdLibrary.kDNSServiceErr_NoError + 1);
+ }
+
+ @Test
+ public void advertiseRegistersHapService() throws Exception {
+ advertise();
+
+ ArgumentCaptor type = ArgumentCaptor.forClass(String.class);
+ ArgumentCaptor port = ArgumentCaptor.forClass(Short.class);
+ ArgumentCaptor txt = ArgumentCaptor.forClass(byte[].class);
+
+ verify(dnsSd)
+ .DNSServiceRegister(
+ any(PointerByReference.class),
+ anyInt(),
+ anyInt(),
+ anyString(),
+ type.capture(),
+ anyString(),
+ nullable(String.class),
+ port.capture(),
+ anyShort(),
+ txt.capture(),
+ any(),
+ any());
+
+ assertThat(type.getValue()).isEqualTo("_hap._tcp");
+ assertThat(port.getValue()).isEqualTo(DnssdHomekitAdvertiser.toNetworkByteOrderPort(9124));
+ assertThat(txt.getValue()).isNotEmpty();
+ }
+
+ @Test
+ public void advertiseTwiceFails() throws Exception {
+ advertise();
+ assertThatThrownBy(this::advertise).isInstanceOf(IllegalStateException.class);
+ }
+
+ @Test
+ public void setDiscoverableReRegisters() throws Exception {
+ advertise();
+ subject.setDiscoverable(false);
+
+ verify(dnsSd, atLeastOnce()).DNSServiceRefDeallocate(serviceRef);
+ verify(dnsSd, times(2))
+ .DNSServiceRegister(
+ any(PointerByReference.class),
+ anyInt(),
+ anyInt(),
+ anyString(),
+ anyString(),
+ anyString(),
+ nullable(String.class),
+ anyShort(),
+ anyShort(),
+ any(byte[].class),
+ any(),
+ any());
+ }
+
+ @Test
+ public void stopDeallocatesServiceRef() throws Exception {
+ advertise();
+ subject.stop();
+ verify(dnsSd).DNSServiceRefDeallocate(serviceRef);
+ }
+
+ @Test
+ public void registerFailureSurfacesAsIoException() {
+ when(dnsSd.DNSServiceRegister(
+ any(PointerByReference.class),
+ anyInt(),
+ anyInt(),
+ anyString(),
+ anyString(),
+ anyString(),
+ nullable(String.class),
+ anyShort(),
+ anyShort(),
+ any(byte[].class),
+ any(),
+ any()))
+ .thenReturn(-65537);
+
+ assertThatThrownBy(this::advertise)
+ .isInstanceOf(java.io.IOException.class)
+ .hasMessageContaining("DNSServiceRegister failed");
+ }
+
+ @Test
+ public void toNetworkByteOrderPortMatchesDnsSdExpectation() {
+ short net = DnssdHomekitAdvertiser.toNetworkByteOrderPort(9124);
+ if (ByteOrder.nativeOrder() == ByteOrder.LITTLE_ENDIAN) {
+ assertThat(net).isEqualTo(Short.reverseBytes((short) 9124));
+ } else {
+ assertThat(net).isEqualTo((short) 9124);
+ }
+ }
+
+ private void advertise() throws Exception {
+ subject.advertise("test", 2, "00:00:00:00:00:00", 9124, 1, "ABCD");
+ Thread.sleep(50);
+ }
+}
diff --git a/src/test/java/io/github/hapjava/server/impl/dnssd/DnssdLiveAdvertiserIT.java b/src/test/java/io/github/hapjava/server/impl/dnssd/DnssdLiveAdvertiserIT.java
new file mode 100644
index 000000000..98beae2bf
--- /dev/null
+++ b/src/test/java/io/github/hapjava/server/impl/dnssd/DnssdLiveAdvertiserIT.java
@@ -0,0 +1,29 @@
+package io.github.hapjava.server.impl.dnssd;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.junit.Assume.assumeTrue;
+
+import java.util.concurrent.TimeUnit;
+import org.junit.Test;
+
+/**
+ * Optional live registration against the host DNS-SD daemon (macOS mDNSResponder). Skipped when
+ * native DNS-SD is unavailable. Run with {@code mvn test} on macOS to exercise Bonjour IPC.
+ */
+public class DnssdLiveAdvertiserIT {
+
+ @Test
+ public void registersAndUnregistersWithoutBindingUdp5353() throws Exception {
+ assumeTrue("Native DNS-SD not available", DnssdHomekitAdvertiser.isSupported());
+
+ DnssdHomekitAdvertiser advertiser = new DnssdHomekitAdvertiser();
+ advertiser.advertise("hap-java-dnssd-it", 2, "AA:BB:CC:DD:EE:FF", 19124, 1, "TEST");
+ try {
+ // Allow mDNSResponder IPC to complete.
+ TimeUnit.MILLISECONDS.sleep(500);
+ assertThat(DnssdHomekitAdvertiser.isSupported()).isTrue();
+ } finally {
+ advertiser.stop();
+ }
+ }
+}