diff --git a/CHANGES.md b/CHANGES.md index 2f8e24550..a9412bcb1 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,5 +1,8 @@ # HAP-Java 2.0.10 * Add optional Mute characteristic to Doorbell service +* Add pluggable `HomekitAdvertiser` with native DNS-SD / Bonjour implementation (`DnssdHomekitAdvertiser`) so macOS can advertise `_hap._tcp` without binding UDP 5353 [#212](https://github.com/hap-java/HAP-Java/issues/212) (see [docs/NATIVE_DNSSD.md](docs/NATIVE_DNSSD.md)) +* Extract shared HAP TXT helpers (`HapDnsTxt`) +* Add optional runtime dependency on JNA (`net.java.dev.jna:jna`) for native `DNSServiceRegister` (macOS `libSystem` / Avahi `libdns_sd`); JmDNS path unchanged when native DNS-SD is not used # HAP-Java 2.0.9 * Fix Set Duration and Remaining Duration characteristics to advertise format `uint32` instead of `int` [#202](https://github.com/hap-java/HAP-Java/issues/202) diff --git a/README.md b/README.md index 6d1f88458..f1064aabd 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,23 @@ Include HAP-Java in your project using maven: After that, check out the [Sample](https://github.com/hap-java/HAP-Java/tree/sample) and read the [Javadoc](https://hap-java.github.io/HAP-Java/apidocs/index.html) +Native DNS-SD / Bonjour (macOS) +========= + +JmDNS binds UDP **5353**. On macOS that port is owned by `mDNSResponder`, so dedicated JmDNS advertising fails with “Address already in use” and Home never sees `_hap._tcp`. + +This build adds a native advertiser that registers through Bonjour without binding 5353: + +```java +import io.github.hapjava.server.HomekitAdvertisers; +import io.github.hapjava.server.impl.HomekitServer; + +HomekitServer server = + new HomekitServer(bindAddress, 9124, HomekitAdvertisers.nativeDnssd()); +``` + +Or `HomekitAdvertisers.auto(bindAddress)` to prefer native DNS-SD when available. Full details: [docs/NATIVE_DNSSD.md](docs/NATIVE_DNSSD.md). + Supported HomeKit Accessories ========= diff --git a/docs/NATIVE_DNSSD.md b/docs/NATIVE_DNSSD.md new file mode 100644 index 000000000..cd1220fea --- /dev/null +++ b/docs/NATIVE_DNSSD.md @@ -0,0 +1,124 @@ +# Native DNS-SD / Bonjour advertiser + +## Problem + +HAP-Java historically advertises `_hap._tcp` with **JmDNS**, which opens a multicast UDP socket on **port 5353**. + +On **macOS**, Apple’s `mDNSResponder` already owns `*:5353`. A second process (or a JVM) that tries to bind 5353 gets `Address already in use`, and Home / Controllers never see the accessory — even when the HAP TCP port (e.g. 9124) is listening. + +Typical symptoms on macOS: empty `dns-sd -B _hap._tcp` results while `lsof -iUDP:5353` only lists `mDNSResponder`. + +## Solution + +This fork adds a pluggable **`HomekitAdvertiser`** and a native implementation **`DnssdHomekitAdvertiser`** that calls Apple’s DNS-SD API (`DNSServiceRegister`) via JNA: + +| Implementation | Class | Binds UDP 5353? | Typical platform | +| --- | --- | --- | --- | +| JmDNS (default / classic) | `JmdnsHomekitAdvertiser` | Yes (unless sharing an existing `JmDNS`) | Linux containers, Windows | +| Native DNS-SD | `DnssdHomekitAdvertiser` | **No** — registers through the system daemon | **macOS** (mDNSResponder), Linux with Avahi `libdns_sd` | + +TXT records (`sf`, `id`, `md`, `sh`, `c#`, `s#`, `ff`, `ci`, `pv`) are shared via `HapDnsTxt` so both advertisers publish the same HAP payload. + +## Quick start + +```java +import io.github.hapjava.server.HomekitAdvertisers; +import io.github.hapjava.server.impl.HomekitServer; +import java.net.InetAddress; + +InetAddress bind = InetAddress.getByName("192.168.0.6"); +HomekitServer server = + new HomekitServer(bind, 9124, HomekitAdvertisers.nativeDnssd()); + +// or: prefer native when present, else JmDNS +HomekitServer auto = + new HomekitServer(bind, 9124, HomekitAdvertisers.auto(bind)); +``` + +Check availability: + +```java +if (HomekitAdvertisers.isNativeDnssdSupported()) { + // safe to call nativeDnssd() +} +``` + +Existing constructors that take only `InetAddress` / `JmDNS` keep the previous JmDNS behaviour (backward compatible). + +## Verifying on macOS + +After starting a server with `HomekitAdvertisers.nativeDnssd()` (or `auto(...)`): + +```bash +dns-sd -B _hap._tcp local. +# or +dns-sd -Z _hap._tcp local. +``` + +You should see the bridge/accessory instance. No Java process should be listening on UDP 5353 (`lsof -iUDP:5353` should only show `mDNSResponder`). + +## API surface + +### `io.github.hapjava.server.HomekitAdvertiser` + +```text +advertise(label, category, mac, port, configurationIndex, setupId) +stop() +setDiscoverable(boolean) +setMac(String) +setConfigurationIndex(int) +``` + +### `io.github.hapjava.server.HomekitAdvertisers` + +| Factory | Behaviour | +| --- | --- | +| `jmdns(JmDNS)` | Wrap an existing JmDNS | +| `jmdns(InetAddress)` | Create JmDNS on that address | +| `nativeDnssd()` | Native Bonjour / dns_sd (throws if unsupported) | +| `auto(InetAddress)` | Native if supported, else JmDNS | +| `isNativeDnssdSupported()` | Probe without constructing | + +### `HomekitServer` constructors + +```java +new HomekitServer(InetAddress localAddress, int port, HomekitAdvertiser advertiser) +new HomekitServer(InetAddress localAddress, int port, int nThreads, HomekitAdvertiser advertiser) +``` + +When an advertiser is supplied, `createBridge` / `createStandaloneAccessory` use it and do **not** open a private JmDNS socket. + +## Implementation notes + +* **JNA** loads `System` on macOS/Darwin and `dns_sd` elsewhere (Avahi Bonjour compatibility). +* `DNSServiceRegister` receives the TCP port in **network byte order**. +* TXT is RFC 6763 length-prefixed `key=value` bytes (`HapDnsTxt.encodeTxt`). +* A daemon thread runs `DNSServiceProcessResult` until `DNSServiceRefDeallocate` on `stop()` / re-register. +* Re-registration (discoverability, MAC, `c#`) deallocates and registers again — same pattern as the JmDNS advertiser. + +## Tests + +| Test | Purpose | +| --- | --- | +| `HapDnsTxtTest` | Required TXT keys + wire encoding | +| `DnssdHomekitAdvertiserTest` | Mocked JNA register / re-register / stop / port endianness | +| `JmdnsHomekitAdvertiserTest` | Existing JmDNS behaviour (unchanged) | +| `HomekitAdvertisersTest` | Factory / support probe | +| `DnssdLiveAdvertiserIT` | Real register+stop when native DNS-SD is present | + +```bash +mvn test +``` + +## Troubleshooting + +| Symptom | Likely cause | +| --- | --- | +| `Address already in use` / “no suitable mDNS” | Still using JmDNS against mDNSResponder — switch to `nativeDnssd()` | +| `UnsupportedOperationException: Native DNS-SD` | No `libSystem`/`libdns_sd` on this JVM/OS | +| `DNSServiceRegister failed with error code …` | Bad name/type, permissions, or daemon unavailable — check Console / `mDNSResponder` | +| Browse empty after register | Wrong interface, firewall, or advertiser stopped; confirm with `dns-sd -B _hap._tcp` | + +## License + +Same MIT license as HAP-Java. Native symbols are provided by the host OS; this library only calls them through JNA. diff --git a/pom.xml b/pom.xml index a1a952779..28ca9dc7b 100644 --- a/pom.xml +++ b/pom.xml @@ -145,6 +145,13 @@ 3.5.6 + + + net.java.dev.jna + jna + 5.14.0 + + 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(); + } + } +}