Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGES.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
# 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`) with stable key order; JmDNS and native advertisers publish the same property set
* JNA (`net.java.dev.jna:jna`) is **optional** for `nativeDnssd()`; JmDNS-only consumers do not need it. Applications should choose `jmdns(...)` or `nativeDnssd(InetAddress)` explicitly (`auto(...)` is deprecated)
* `nativeDnssd(InetAddress)` scopes DNS-SD by interface index only and keeps SRV `host` null (system hostname). Passing an IP literal as host broke pairing (SRV target with no A record)

# 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)
Expand Down
18 changes: 18 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,24 @@ 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 can fail with “Address already in use” and Home never sees `_hap._tcp`.

This build adds a native advertiser that registers through Bonjour without binding 5353. Choose the advertiser explicitly in your application (e.g. openHAB); declare optional JNA when using the native path:

```java
import io.github.hapjava.server.HomekitAdvertisers;
import io.github.hapjava.server.impl.HomekitServer;

// Scope native registration to the HAP bind address, or use jmdns(sharedJmDNS) to force JmDNS:
HomekitServer server =
new HomekitServer(bindAddress, 9124, HomekitAdvertisers.nativeDnssd(bindAddress));
```

Full details: [docs/NATIVE_DNSSD.md](docs/NATIVE_DNSSD.md).

Supported HomeKit Accessories
=========

Expand Down
139 changes: 139 additions & 0 deletions docs/NATIVE_DNSSD.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
# 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 library 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.

**Policy belongs in the application** (e.g. openHAB): choose `jmdns(...)` or `nativeDnssd(...)` explicitly. Do not rely on HAP-Java to auto-pick.

## Quick start

Declare the optional JNA dependency if you use the native advertiser (JmDNS-only consumers can omit it):

```xml
<dependency>
<groupId>net.java.dev.jna</groupId>
<artifactId>jna</artifactId>
<version>5.14.0</version>
</dependency>
```

```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");

// Prefer scoping native registration to the HAP bind interface (SRV host stays system default):
HomekitServer server =
new HomekitServer(bind, 9124, HomekitAdvertisers.nativeDnssd(bind));

// Or share an existing JmDNS (e.g. openHAB's):
// HomekitServer server =
// new HomekitServer(bind, 9124, HomekitAdvertisers.jmdns(sharedJmDNS));
```

Check whether the native library **loads** (does not prove registration will succeed on the LAN):

```java
if (HomekitAdvertisers.isNativeDnssdSupported()) {
// safe to construct nativeDnssd(...)
}
```

Existing constructors that take only `InetAddress` / `JmDNS` keep the previous JmDNS behaviour (backward compatible).

## Verifying on macOS (manual)

There is no automated live IT in CI (ubuntu runners lack `libdns_sd` / Bonjour). After starting a server with `HomekitAdvertisers.nativeDnssd(bind)`:

```bash
dns-sd -B _hap._tcp local.
dns-sd -L "<instance name>" _hap._tcp local.
# or
dns-sd -Z _hap._tcp local.
```

You should see the bridge/accessory instance and HAP TXT keys. 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 (force JmDNS / share openHAB’s instance) |
| `jmdns(InetAddress)` | Create dedicated JmDNS on that address |
| `nativeDnssd()` | Native Bonjour on **all** interfaces (`interfaceIndex = 0`, default host) |
| `nativeDnssd(InetAddress)` | Native Bonjour on that address’s interface index; SRV host = system default (`null`) |
| `isNativeDnssdSupported()` | Whether JNA + dns_sd **load** (not a live register probe) |
| `auto(InetAddress)` | **Deprecated** — prefer an explicit `jmdns` / `nativeDnssd` choice |

### `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** is an **optional** dependency. It loads `System` on macOS/Darwin and `dns_sd` elsewhere (Avahi Bonjour compatibility). Missing JNA surfaces as `LinkageError` and `isNativeDnssdSupported()` returns `false`.
* `DNSServiceRegister` receives the TCP port in **network byte order**.
* Default `nativeDnssd()` uses `interfaceIndex = 0` (all interfaces) and `host = null` (system hostname). Prefer `nativeDnssd(InetAddress)` to limit the interface index when the HAP TCP listener is bound to one LAN address — still with `host = null`. Never pass a raw IP as DNS-SD `host` (no A record → Home hangs on “Setting up”).
* TXT is RFC 6763 length-prefixed `key=value` bytes (`HapDnsTxt.encodeTxt`). Key insertion order is stable (`LinkedHashMap`); RFC 6763 does not require a particular pair order.
* 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` | Full TXT key set, stable order, wire encoding |
| `DnssdHomekitAdvertiserTest` | Mocked JNA register / interface+host / re-register / stop / port endianness |
| `JmdnsHomekitAdvertiserTest` | JmDNS register including full HAP property set |

```bash
mvn test
```

## Troubleshooting

| Symptom | Likely cause |
| --- | --- |
| `Address already in use` / “no suitable mDNS” | Still using dedicated JmDNS against mDNSResponder — switch to `nativeDnssd(bind)` or share an existing JmDNS |
| `UnsupportedOperationException: Native DNS-SD` | No JNA on classpath, or 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 / too many interfaces | Prefer `nativeDnssd(localAddress)` instead of all-interfaces `nativeDnssd()` |

## License

Same MIT license as HAP-Java. Native symbols are provided by the host OS; this library only calls them through JNA.
10 changes: 10 additions & 0 deletions pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,16 @@
<version>3.5.6</version>
</dependency>

<!-- Native Bonjour / DNS-SD (macOS libSystem, Linux libdns_sd). Optional:
consumers that only use JmDNS need not pull JNA; nativeDnssd() users
must declare JNA themselves (openHAB already embeds it). -->
<dependency>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This makes JNA (~1.9 MB, native access) a compile-scope transitive dependency for every HAP-Java consumer, including everyone who only uses the JmDNS path. mvn dependency:tree shows net.java.dev.jna:jna:jar:5.14.0:compile.

Since native DNS-SD is opt-in, can this be optional? Consumers who want the native advertiser then declare JNA themselves. openhab-addons#22013 already embeds JNA in the bundle, so nothing changes for openHAB.
i.e.

net.java.dev.jna
jna
5.14.0
true

<groupId>net.java.dev.jna</groupId>
<artifactId>jna</artifactId>
<version>5.14.0</version>
<optional>true</optional>
</dependency>

<dependency>
<groupId>junit</groupId>
<artifactId>junit</artifactId>
Expand Down
45 changes: 45 additions & 0 deletions src/main/java/io/github/hapjava/server/HomekitAdvertiser.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
package io.github.hapjava.server;

import java.io.IOException;

/**
* Publishes a HomeKit accessory / bridge via DNS-SD ({@code _hap._tcp}).
*
* <p>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;
}
122 changes: 122 additions & 0 deletions src/main/java/io/github/hapjava/server/HomekitAdvertisers.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
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.
*
* <p>Callers (e.g. openHAB) should choose the advertiser explicitly: use {@link #jmdns(JmDNS)} to
* share an existing JmDNS instance, {@link #jmdns(InetAddress)} for a dedicated JmDNS socket, or
* {@link #nativeDnssd()} / {@link #nativeDnssd(InetAddress)} for Bonjour without binding UDP 5353.
* Do not rely on HAP-Java to pick a policy automatically.
*
* <pre>{@code
* // Force JmDNS (e.g. share openHAB's instance):
* HomekitAdvertiser adv = HomekitAdvertisers.jmdns(sharedJmDNS);
*
* // Native DNS-SD on the interface that owns the HAP bind address (SRV host = system default):
* HomekitAdvertiser adv = HomekitAdvertisers.nativeDnssd(InetAddress.getByName("192.168.0.6"));
* }</pre>
*
* @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 on <strong>all</strong> interfaces ({@code interfaceIndex =
* 0}, default host). Prefer {@link #nativeDnssd(InetAddress)} when the HAP TCP listener is bound
* to a single LAN address.
*
* <p>Requires the optional JNA dependency on the classpath.
*
* @return a native DNS-SD advertiser
* @throws UnsupportedOperationException if JNA or 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();
}

/**
* Native DNS-SD / Bonjour advertiser scoped to the interface that owns {@code localAddress}.
* Registration uses that interface index when resolvable; SRV host stays {@code null} (system
* hostname). Passing an IP literal as DNS-SD host is incorrect — it yields an SRV target with no
* A record and breaks Home pairing.
*
* @param localAddress LAN address the HAP TCP server listens on
* @return a native DNS-SD advertiser
* @throws UnsupportedOperationException if JNA or the native library cannot be loaded
*/
public static HomekitAdvertiser nativeDnssd(InetAddress localAddress) {
if (!DnssdHomekitAdvertiser.isSupported()) {
throw new UnsupportedOperationException(
"Native DNS-SD (dns_sd / Bonjour) is not available on this platform/JVM");
}
return new DnssdHomekitAdvertiser(localAddress);
}

/**
* Convenience: prefer native DNS-SD when the library loads, otherwise JmDNS on {@code
* localAddress}.
*
* <p><strong>Applications should prefer an explicit {@link #jmdns(JmDNS)} / {@link
* #nativeDnssd(InetAddress)} choice</strong> (policy belongs in openHAB / the integrator). This
* helper only probes whether the native library <em>loads</em>; it does not verify that
* registration will succeed on the network.
*
* @param localAddress address used for native scoping or JmDNS fallback
* @return a native DNS-SD advertiser when the library loads, otherwise a JmDNS-backed advertiser
* @throws IOException if native DNS-SD is unavailable and JmDNS cannot be created
* @deprecated Prefer {@link #jmdns(JmDNS)}, {@link #jmdns(InetAddress)}, or {@link
* #nativeDnssd(InetAddress)} so the application chooses the advertiser.
*/
@Deprecated
public static HomekitAdvertiser auto(InetAddress localAddress) throws IOException {
if (DnssdHomekitAdvertiser.isSupported()) {
return new DnssdHomekitAdvertiser(localAddress);
}
return new JmdnsHomekitAdvertiser(localAddress);
Comment on lines +104 to +108

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i would prefer to able to force jmDNS usage, even on linux or macosx library. i.e. i could use share jmDNS instance from openHab. it should be configurable in openHAB and not automatic decision in Java HAP.
in addition, we only check whether library can be loaded but not whether it works.

}

/**
* Whether {@link #nativeDnssd()} can be constructed on this JVM/OS.
*
* <p>Only checks that JNA and the platform DNS-SD library <em>load</em>. It does not register a
* service or prove mDNS is reachable on the LAN.
*
* @return {@code true} if the platform DNS-SD library loaded successfully
*/
public static boolean isNativeDnssdSupported() {
return DnssdHomekitAdvertiser.isSupported();
}
}
5 changes: 3 additions & 2 deletions src/main/java/io/github/hapjava/server/impl/HomekitRoot.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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;
Expand Down
Loading