Skip to content

Commit 9c032ca

Browse files
committed
Add native DNS-SD advertiser for macOS Bonjour without binding UDP 5353.
Pluggable HomekitAdvertiser lets callers use DnssdHomekitAdvertiser (JNA DNSServiceRegister) instead of JmDNS when mDNSResponder owns UDP 5353. Fixes #212.
1 parent 46e2a02 commit 9c032ca

21 files changed

Lines changed: 1024 additions & 35 deletions

‎CHANGES.md‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
# HAP-Java 2.0.10
22
* Add optional Mute characteristic to Doorbell service
3+
* 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))
4+
* Extract shared HAP TXT helpers (`HapDnsTxt`)
5+
* 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
36

47
# HAP-Java 2.0.9
58
* Fix Set Duration and Remaining Duration characteristics to advertise format `uint32` instead of `int` [#202](https://github.com/hap-java/HAP-Java/issues/202)

‎README.md‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,23 @@ Include HAP-Java in your project using maven:
2525
After that, check out the [Sample](https://github.com/hap-java/HAP-Java/tree/sample) and
2626
read the [Javadoc](https://hap-java.github.io/HAP-Java/apidocs/index.html)
2727

28+
Native DNS-SD / Bonjour (macOS)
29+
=========
30+
31+
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`.
32+
33+
This build adds a native advertiser that registers through Bonjour without binding 5353:
34+
35+
```java
36+
import io.github.hapjava.server.HomekitAdvertisers;
37+
import io.github.hapjava.server.impl.HomekitServer;
38+
39+
HomekitServer server =
40+
new HomekitServer(bindAddress, 9124, HomekitAdvertisers.nativeDnssd());
41+
```
42+
43+
Or `HomekitAdvertisers.auto(bindAddress)` to prefer native DNS-SD when available. Full details: [docs/NATIVE_DNSSD.md](docs/NATIVE_DNSSD.md).
44+
2845
Supported HomeKit Accessories
2946
=========
3047

‎docs/NATIVE_DNSSD.md‎

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
# Native DNS-SD / Bonjour advertiser
2+
3+
## Problem
4+
5+
HAP-Java historically advertises `_hap._tcp` with **JmDNS**, which opens a multicast UDP socket on **port 5353**.
6+
7+
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.
8+
9+
Typical symptoms on macOS: empty `dns-sd -B _hap._tcp` results while `lsof -iUDP:5353` only lists `mDNSResponder`.
10+
11+
## Solution
12+
13+
This fork adds a pluggable **`HomekitAdvertiser`** and a native implementation **`DnssdHomekitAdvertiser`** that calls Apple’s DNS-SD API (`DNSServiceRegister`) via JNA:
14+
15+
| Implementation | Class | Binds UDP 5353? | Typical platform |
16+
| --- | --- | --- | --- |
17+
| JmDNS (default / classic) | `JmdnsHomekitAdvertiser` | Yes (unless sharing an existing `JmDNS`) | Linux containers, Windows |
18+
| Native DNS-SD | `DnssdHomekitAdvertiser` | **No** — registers through the system daemon | **macOS** (mDNSResponder), Linux with Avahi `libdns_sd` |
19+
20+
TXT records (`sf`, `id`, `md`, `sh`, `c#`, `s#`, `ff`, `ci`, `pv`) are shared via `HapDnsTxt` so both advertisers publish the same HAP payload.
21+
22+
## Quick start
23+
24+
```java
25+
import io.github.hapjava.server.HomekitAdvertisers;
26+
import io.github.hapjava.server.impl.HomekitServer;
27+
import java.net.InetAddress;
28+
29+
InetAddress bind = InetAddress.getByName("192.168.0.6");
30+
HomekitServer server =
31+
new HomekitServer(bind, 9124, HomekitAdvertisers.nativeDnssd());
32+
33+
// or: prefer native when present, else JmDNS
34+
HomekitServer auto =
35+
new HomekitServer(bind, 9124, HomekitAdvertisers.auto(bind));
36+
```
37+
38+
Check availability:
39+
40+
```java
41+
if (HomekitAdvertisers.isNativeDnssdSupported()) {
42+
// safe to call nativeDnssd()
43+
}
44+
```
45+
46+
Existing constructors that take only `InetAddress` / `JmDNS` keep the previous JmDNS behaviour (backward compatible).
47+
48+
## Verifying on macOS
49+
50+
After starting a server with `HomekitAdvertisers.nativeDnssd()` (or `auto(...)`):
51+
52+
```bash
53+
dns-sd -B _hap._tcp local.
54+
# or
55+
dns-sd -Z _hap._tcp local.
56+
```
57+
58+
You should see the bridge/accessory instance. No Java process should be listening on UDP 5353 (`lsof -iUDP:5353` should only show `mDNSResponder`).
59+
60+
## API surface
61+
62+
### `io.github.hapjava.server.HomekitAdvertiser`
63+
64+
```text
65+
advertise(label, category, mac, port, configurationIndex, setupId)
66+
stop()
67+
setDiscoverable(boolean)
68+
setMac(String)
69+
setConfigurationIndex(int)
70+
```
71+
72+
### `io.github.hapjava.server.HomekitAdvertisers`
73+
74+
| Factory | Behaviour |
75+
| --- | --- |
76+
| `jmdns(JmDNS)` | Wrap an existing JmDNS |
77+
| `jmdns(InetAddress)` | Create JmDNS on that address |
78+
| `nativeDnssd()` | Native Bonjour / dns_sd (throws if unsupported) |
79+
| `auto(InetAddress)` | Native if supported, else JmDNS |
80+
| `isNativeDnssdSupported()` | Probe without constructing |
81+
82+
### `HomekitServer` constructors
83+
84+
```java
85+
new HomekitServer(InetAddress localAddress, int port, HomekitAdvertiser advertiser)
86+
new HomekitServer(InetAddress localAddress, int port, int nThreads, HomekitAdvertiser advertiser)
87+
```
88+
89+
When an advertiser is supplied, `createBridge` / `createStandaloneAccessory` use it and do **not** open a private JmDNS socket.
90+
91+
## Implementation notes
92+
93+
* **JNA** loads `System` on macOS/Darwin and `dns_sd` elsewhere (Avahi Bonjour compatibility).
94+
* `DNSServiceRegister` receives the TCP port in **network byte order**.
95+
* TXT is RFC 6763 length-prefixed `key=value` bytes (`HapDnsTxt.encodeTxt`).
96+
* A daemon thread runs `DNSServiceProcessResult` until `DNSServiceRefDeallocate` on `stop()` / re-register.
97+
* Re-registration (discoverability, MAC, `c#`) deallocates and registers again — same pattern as the JmDNS advertiser.
98+
99+
## Tests
100+
101+
| Test | Purpose |
102+
| --- | --- |
103+
| `HapDnsTxtTest` | Required TXT keys + wire encoding |
104+
| `DnssdHomekitAdvertiserTest` | Mocked JNA register / re-register / stop / port endianness |
105+
| `JmdnsHomekitAdvertiserTest` | Existing JmDNS behaviour (unchanged) |
106+
| `HomekitAdvertisersTest` | Factory / support probe |
107+
| `DnssdLiveAdvertiserIT` | Real register+stop when native DNS-SD is present |
108+
109+
```bash
110+
mvn test
111+
```
112+
113+
## Troubleshooting
114+
115+
| Symptom | Likely cause |
116+
| --- | --- |
117+
| `Address already in use` / “no suitable mDNS” | Still using JmDNS against mDNSResponder — switch to `nativeDnssd()` |
118+
| `UnsupportedOperationException: Native DNS-SD` | No `libSystem`/`libdns_sd` on this JVM/OS |
119+
| `DNSServiceRegister failed with error code …` | Bad name/type, permissions, or daemon unavailable — check Console / `mDNSResponder` |
120+
| Browse empty after register | Wrong interface, firewall, or advertiser stopped; confirm with `dns-sd -B _hap._tcp` |
121+
122+
## License
123+
124+
Same MIT license as HAP-Java. Native symbols are provided by the host OS; this library only calls them through JNA.

‎pom.xml‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,13 @@
145145
<version>3.5.6</version>
146146
</dependency>
147147

148+
<!-- Native Bonjour / DNS-SD (macOS libSystem, Linux libdns_sd) -->
149+
<dependency>
150+
<groupId>net.java.dev.jna</groupId>
151+
<artifactId>jna</artifactId>
152+
<version>5.14.0</version>
153+
</dependency>
154+
148155
<dependency>
149156
<groupId>junit</groupId>
150157
<artifactId>junit</artifactId>
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
package io.github.hapjava.server;
2+
3+
import java.io.IOException;
4+
5+
/**
6+
* Publishes a HomeKit accessory / bridge via DNS-SD ({@code _hap._tcp}).
7+
*
8+
* <p>The default implementation uses JmDNS and binds UDP port 5353. On platforms where a system
9+
* mDNS responder already owns that port (notably macOS {@code mDNSResponder}), use {@link
10+
* io.github.hapjava.server.impl.dnssd.DnssdHomekitAdvertiser} instead, which registers through the
11+
* native Bonjour / DNS-SD API without binding 5353.
12+
*
13+
* @see io.github.hapjava.server.impl.jmdns.JmdnsHomekitAdvertiser
14+
* @see io.github.hapjava.server.impl.dnssd.DnssdHomekitAdvertiser
15+
* @see HomekitAdvertisers
16+
* @author Karel Goderis
17+
*/
18+
public interface HomekitAdvertiser {
19+
20+
/**
21+
* Start advertising the accessory.
22+
*
23+
* @param label instance name shown in Home / Discovery
24+
* @param category HomeKit accessory category ({@code ci} TXT key)
25+
* @param mac accessory id ({@code id} TXT key), typically a MAC-like string
26+
* @param port HAP TCP listen port
27+
* @param configurationIndex configuration number ({@code c#} TXT key)
28+
* @param setupId setup id used for the {@code sh} TXT hash
29+
*/
30+
void advertise(
31+
String label, int category, String mac, int port, int configurationIndex, String setupId)
32+
throws Exception;
33+
34+
/** Stop advertising and release native resources. */
35+
void stop();
36+
37+
/** Update discoverability ({@code sf} TXT). Re-registers when already advertising. */
38+
void setDiscoverable(boolean discoverable) throws IOException;
39+
40+
/** Update accessory id ({@code id} TXT). Re-registers when already advertising. */
41+
void setMac(String mac) throws IOException;
42+
43+
/** Update configuration index ({@code c#} TXT). Re-registers when already advertising. */
44+
void setConfigurationIndex(int revision) throws IOException;
45+
}
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
package io.github.hapjava.server;
2+
3+
import io.github.hapjava.server.impl.dnssd.DnssdHomekitAdvertiser;
4+
import io.github.hapjava.server.impl.jmdns.JmdnsHomekitAdvertiser;
5+
import java.io.IOException;
6+
import java.net.InetAddress;
7+
import javax.jmdns.JmDNS;
8+
9+
/**
10+
* Factories for {@link HomekitAdvertiser} implementations.
11+
*
12+
* <p>Typical macOS usage:
13+
*
14+
* <pre>{@code
15+
* HomekitServer server =
16+
* new HomekitServer(InetAddress.getByName("192.168.0.6"), 9124, HomekitAdvertisers.nativeDnssd());
17+
* }</pre>
18+
*
19+
* That listens on HAP TCP without binding UDP 5353; Bonjour/mDNSResponder publishes {@code
20+
* _hap._tcp}.
21+
*
22+
* @author Karel Goderis
23+
*/
24+
public final class HomekitAdvertisers {
25+
26+
private HomekitAdvertisers() {}
27+
28+
/**
29+
* Classic JmDNS advertiser (binds UDP 5353 unless sharing an existing {@link JmDNS}).
30+
*
31+
* @param jmdns existing JmDNS instance to register with
32+
* @return a JmDNS-backed advertiser
33+
*/
34+
public static HomekitAdvertiser jmdns(JmDNS jmdns) {
35+
return new JmdnsHomekitAdvertiser(jmdns);
36+
}
37+
38+
/**
39+
* Classic JmDNS advertiser that creates its own multicast socket on {@code localAddress}.
40+
*
41+
* @param localAddress address used to create the JmDNS instance
42+
* @return a JmDNS-backed advertiser
43+
* @throws IOException if JmDNS cannot be created on {@code localAddress}
44+
*/
45+
public static HomekitAdvertiser jmdns(InetAddress localAddress) throws IOException {
46+
return new JmdnsHomekitAdvertiser(localAddress);
47+
}
48+
49+
/**
50+
* Native DNS-SD / Bonjour advertiser (macOS {@code mDNSResponder}, or Avahi's dns_sd compat
51+
* layer). Does not bind UDP 5353. Requires the JNA runtime dependency.
52+
*
53+
* @return a native DNS-SD advertiser
54+
* @throws UnsupportedOperationException if the native library cannot be loaded
55+
*/
56+
public static HomekitAdvertiser nativeDnssd() {
57+
if (!DnssdHomekitAdvertiser.isSupported()) {
58+
throw new UnsupportedOperationException(
59+
"Native DNS-SD (dns_sd / Bonjour) is not available on this platform/JVM");
60+
}
61+
return new DnssdHomekitAdvertiser();
62+
}
63+
64+
/**
65+
* Prefer native DNS-SD when available (macOS); otherwise create a dedicated JmDNS instance on
66+
* {@code localAddress}.
67+
*
68+
* @param localAddress address used if falling back to JmDNS
69+
* @return a native DNS-SD advertiser when supported, otherwise a JmDNS-backed advertiser
70+
* @throws IOException if native DNS-SD is unavailable and JmDNS cannot be created
71+
*/
72+
public static HomekitAdvertiser auto(InetAddress localAddress) throws IOException {
73+
if (DnssdHomekitAdvertiser.isSupported()) {
74+
return new DnssdHomekitAdvertiser();
75+
}
76+
return new JmdnsHomekitAdvertiser(localAddress);
77+
}
78+
79+
/**
80+
* Whether {@link #nativeDnssd()} can be used on this JVM/OS.
81+
*
82+
* @return {@code true} if the platform DNS-SD library loaded successfully
83+
*/
84+
public static boolean isNativeDnssdSupported() {
85+
return DnssdHomekitAdvertiser.isSupported();
86+
}
87+
}

‎src/main/java/io/github/hapjava/server/impl/HomekitRoot.java‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import io.github.hapjava.accessories.Bridge;
44
import io.github.hapjava.accessories.HomekitAccessory;
55
import io.github.hapjava.server.HomekitAccessoryCategories;
6+
import io.github.hapjava.server.HomekitAdvertiser;
67
import io.github.hapjava.server.HomekitAuthInfo;
78
import io.github.hapjava.server.HomekitWebHandler;
89
import io.github.hapjava.server.impl.connections.HomekitClientConnectionFactoryImpl;
@@ -27,7 +28,7 @@ public class HomekitRoot {
2728

2829
private static final Logger logger = LoggerFactory.getLogger(HomekitRoot.class);
2930
private static final int DEFAULT_ACCESSORY_CATEGORY = HomekitAccessoryCategories.OTHER;
30-
private final JmdnsHomekitAdvertiser advertiser;
31+
private final HomekitAdvertiser advertiser;
3132
private final HomekitWebHandler webHandler;
3233
private final HomekitAuthInfo authInfo;
3334
private final String label;
@@ -60,7 +61,7 @@ public class HomekitRoot {
6061
int category,
6162
HomekitWebHandler webHandler,
6263
HomekitAuthInfo authInfo,
63-
JmdnsHomekitAdvertiser advertiser)
64+
HomekitAdvertiser advertiser)
6465
throws IOException {
6566
this.advertiser = advertiser;
6667
this.webHandler = webHandler;

0 commit comments

Comments
 (0)