HomeKit Bridge¶
The built-in HomekitService runs a HAP-NodeJS bridge inside the automation engine. It exposes every device from the engine's shared, unified device layer — Zigbee2MQTT devices, Shelly devices, Nanoleaf-derived devices, and boolean StateManager state toggles — as HomeKit accessories in real time. No separate Homebridge process required.
The bridge itself is source-agnostic: it owns the HAP bridge lifecycle (publish/unpublish, pairing PIN, port, persist path, the accessory map, the status endpoint) and reads every device family through Engine.devices (the aggregate device accessor) rather than talking to Zigbee2MQTT, MQTT, or Shelly RPC directly.
Prerequisites¶
Engine.devicesis always present — an unconfigured or disabled device family (noDEVICE_REGISTRY_ENABLED, noShellyService, nostateToggles) simply contributes no devices; it does not prevent the bridge from starting.hap-nodejsis already bundled as a dependency of@ts-ha/core. No additional installation is needed.
Registering the service¶
Pass a HomekitService factory to the services.homekit field in your entry point.
The factory receives a single HomekitServiceContext object carrying the
dependencies HomeKit needs, so there is no circular reference between the factory
and the engine object:
import { createEngine, HomekitService, HOMEKIT_SERVICE_KEY } from "@ts-ha/core";
const engine = createEngine({
automationsDir: "./automations",
services: {
[HOMEKIT_SERVICE_KEY]: ({ logger, devices }) =>
new HomekitService(logger, devices, {
pinCode: "031-45-154",
}),
},
});
await engine.start();
Note:
HomekitServiceuses a dedicatedHomekitServiceFactorytype(ctx: HomekitServiceContext) => HomekitServiceinstead of the genericServiceFactory<T>. The context object containshttp,logger, anddevices(the engine's aggregate device accessor), all resolved by the engine before the factory is called.Breaking change: earlier versions took a 6-argument constructor
new HomekitService(mqtt, logger, deviceRegistry, shelly, state, options)and a factory receiving{ http, logger, mqtt, deviceRegistry, shelly, state }.HomekitServicenow reads every device family through the shared device layer instead:new HomekitService(logger, devices, options), with a factory receiving{ http, logger, devices }.
Bridging Shelly devices¶
To expose Shelly plugs, switches, and covers, register a ShellyService with the
appropriate device type — no separate HomeKit wiring is needed, since
HomekitService reads every registered device through Engine.devices:
import { createEngine, HomekitService, ShellyService } from "@ts-ha/core";
const engine = createEngine({
automationsDir: "./automations",
services: {
shelly: ({ http, mqtt, logger }) => {
const shelly = new ShellyService(http, mqtt, logger);
shelly.register("living_room_plug", "192.168.1.50"); // type defaults to "switch"
shelly.register("kitchen_outlet", "192.168.1.51", "outlet");
shelly.register("bedroom_blind", "192.168.1.60", "cover");
// MQTT-transport devices are bridged via push status instead of polling:
shelly.register("garage_plug", { transport: "mqtt", topicPrefix: "shellyplus1-a8032abe54dc" });
return shelly;
},
homekit: ({ logger, devices }) =>
new HomekitService(logger, devices, {
pinCode: "031-45-154",
}),
},
});
The Shelly device source (shared with the web UI) keeps HTTP-transport devices'
state fresh on the SHELLY_POLL_MS interval, and routes HomeKit write-back
through ShellyService methods (turnOn/turnOff for switches/outlets,
coverGoToPosition for covers) regardless of transport. MQTT-transport devices
are excluded from the poll loop — instead, the source subscribes to each
device's <topicPrefix>/events/rpc for instant NotifyStatus push updates and
<topicPrefix>/online to track reachability from its LWT presence topic.
Because automations register Shelly devices after services start, the
device source reacts to registration events, so devices registered at any
time — including at runtime — are bridged automatically. See
Shelly Devices for MQTT transport registration and required
on-device setup.
Bridging state toggles¶
Automations already communicate through the shared StateManager (e.g.
night_mode, away_mode booleans). Configuring stateToggles at the engine
level exposes any boolean state key as a device — controllable from HomeKit
and the web UI, whether or not HomeKit is even enabled — giving automations
a human-controllable interface without writing MQTT or HTTP glue:
import { createEngine, HomekitService } from "@ts-ha/core";
const engine = createEngine({
automationsDir: "./automations",
stateToggles: [
{ stateKey: "night_mode", name: "Night Mode" },
{ stateKey: "away_mode", name: "Away Mode" },
],
services: {
homekit: ({ logger, devices }) =>
new HomekitService(logger, devices, {
pinCode: "031-45-154",
}),
},
});
Breaking change:
stateTogglesis engine-level configuration now, not aHomekitServiceoption — a source consumed by both HomeKit and the web UI cannot live inside one of them. PassingstateTogglesunderservices.homekit's options throws, naming this new location.
State toggles sync bidirectionally:
- State → Home app: when an automation calls
state.set("night_mode", true)(or deletes the key), the corresponding switch'sOncharacteristic updates immediately. A missing key reads as OFF at startup. - Home app → state: flipping the switch writes a real boolean via
StateManager.set(stateKey, value), which firesstate-trigger automations.
Each toggle's HomeKit UUID is seeded from its state key, so renaming the display
name in configuration does not orphan the accessory in the Home app. Duplicate
state keys are skipped with a warning. When stateToggles is empty or omitted,
the state device source creates no devices and does not affect the bridge.
Options¶
new HomekitService(logger, devices, {
pinCode: "031-45-154", // required — shown in the Home app when pairing
bridgeName: "My Home Bridge", // optional, default: "TS-Home-Automation"
port: 47128, // optional, default: 47128
username: "CC:22:3D:E3:CE:F8",// optional, default: "CC:22:3D:E3:CE:F8"
persistPath: "./homekit-persist", // optional, default: "./homekit-persist"
bind: ["net1"], // optional — restrict mDNS to specific interfaces
})
| Option | Type | Default | Description |
|---|---|---|---|
pinCode |
string |
(required) | HAP pairing PIN in XXX-XX-XXX format |
bridgeName |
string |
"TS-Home-Automation" |
Display name shown in the Apple Home app |
port |
number |
47128 |
TCP port for the HAP server |
username |
string |
"CC:22:3D:E3:CE:F8" |
Bridge MAC address — must be unique per bridge on your network |
persistPath |
string |
"./homekit-persist" |
Directory for HAP pairing data; created automatically if missing. Resolved to an absolute path at runtime. |
bind |
string \| string[] |
(all interfaces) | Restrict mDNS advertisement to specific network interfaces or IPs. Interface names (e.g. "eth0") are preferred over IPs because they survive address changes. For containers see below. |
pollIntervalMsandstateTogglesare still accepted on the options type for a smoother upgrade path, but are deprecated:pollIntervalMshas no effect (each device source owns its own poll interval —SHELLY_POLL_MS,NANOLEAF_POLL_MS) andstateTogglesthrows when set (see above).
Pairing¶
- Start the engine — the bridge is announced via mDNS automatically.
- Open the Home app on iPhone/iPad, tap + → Add Accessory → More options.
- Select the bridge (it will appear as
bridgeName). - Enter the
pinCodewhen prompted. - All supported devices (Zigbee, Shelly, and state toggles) are exposed as individual accessories inside the bridge.
Supported device types¶
Every device family is projected onto HomeKit from the same source-neutral
capability vocabulary (design.md D22) — the same one the web UI's generic
device renderer consumes — so support here does not depend on
family-specific code.
Zigbee2MQTT¶
| Zigbee capability | HomeKit service |
|---|---|
| On/off + brightness | Lightbulb (dimmable) |
| On/off + brightness + color temperature | Lightbulb (white spectrum) |
| On/off + brightness + color (XY or HS) | Lightbulb (full color) |
| On/off only (no brightness) | Switch / Outlet |
occupancy |
Motion Sensor |
contact |
Contact Sensor |
water_leak |
Leak Sensor |
temperature |
Temperature Sensor |
humidity |
Humidity Sensor |
battery |
Battery level (added to any type above) |
Devices that expose none of the above capabilities are silently skipped.
Shelly¶
Shelly type |
HomeKit service |
|---|---|
"switch" (default) |
Switch |
"outlet" |
Outlet |
"cover" |
Window Covering |
For covers, position maps to CurrentPosition (0 = closed, 100 = open) and
movement maps to PositionState (opening → increasing, closing →
decreasing, otherwise stopped). An uncalibrated cover (no reported
position) is still exposed but reports position 0 and logs a calibration
warning.
State toggles¶
Configured stateToggles entry |
HomeKit service |
|---|---|
{ stateKey, name } |
Switch — On mirrors the state key's value bidirectionally |
Dynamic accessories¶
The bridge reacts to device changes at runtime, through the shared device layer:
- A device appears (a Zigbee join, a Shelly registration, a Nanoleaf/state toggle becoming known) — an accessory is created and added to the bridge immediately.
- A device disappears (e.g. a Zigbee device leaves the network) — the accessory is removed from the bridge.
- A device's state changes (push-backed or on the next poll) — the accessory's characteristics are updated in real time so the Home app always shows the current state.
- A HomeKit write dispatches a command through
Engine.devices, which routes it to the owning device source's transport (MQTT for Zigbee, HTTP/MQTT RPC for Shelly,StateManager.set()for a toggle).
Multiple bridges¶
If you run multiple engine instances on the same network, each bridge must have a unique username (MAC address) and port:
// Instance A
new HomekitService(logger, devices, {
pinCode: "031-45-154",
username: "CC:22:3D:E3:CE:F8",
port: 47128,
});
// Instance B — different username and port
new HomekitService(logger, devices, {
pinCode: "031-45-155",
username: "DD:33:4E:F4:DF:A9",
port: 47129,
});
Status API¶
The service registers a route on the shared HTTP server:
| Method | Path | Description |
|---|---|---|
GET |
/api/homekit/status |
Returns the current bridge status snapshot |
Example response:
{
"running": true,
"bridgeName": "My Home Bridge",
"port": 47128,
"username": "CC:22:3D:E3:CE:F8",
"persistPath": "./homekit-persist",
"accessoryCount": 12
}
This endpoint is protected by the same HTTP_TOKEN bearer auth as all other /api/* routes.
Running in Docker / Kubernetes¶
Container networking & mDNS discovery¶
hap-nodejs advertises the bridge via mDNS (Bonjour) multicast so Apple devices can
discover it on the local network. Docker bridge networks and Kubernetes pod network
namespaces isolate multicast traffic — the bridge starts and runs correctly, but
Apple Home cannot discover it.
Three options ranked by simplicity:
1. Host networking (simplest)¶
| Platform | Fix |
|---|---|
| Docker Compose | network_mode: host on the service (see docker-compose.yml) |
| Kubernetes | hostNetwork: true in the pod spec (see docs/deployment.md) |
When using host networking:
- Docker: remove networks / depends_on blocks (they conflict with host mode) and
use localhost or host IPs for service references.
- Kubernetes: MQTT_HOST must be an IP or hostname reachable from the host network,
not a cluster-internal service DNS name.
2. Multus CNI + macvlan + bind (Kubernetes, no hostNetwork)¶
Attach a secondary network interface with a LAN IP to the pod using
Multus CNI and a macvlan
attachment. Then use the bind option to advertise mDNS only on that interface:
# NetworkAttachmentDefinition (applied once per namespace)
apiVersion: k8s.cni.cncf.io/v1
kind: NetworkAttachmentDefinition
metadata:
name: lan-macvlan
spec:
config: |
{
"cniVersion": "0.3.1",
"type": "macvlan",
"master": "eth0",
"mode": "bridge",
"ipam": {
"type": "host-local",
"subnet": "192.168.1.0/24",
"rangeStart": "192.168.1.200",
"rangeEnd": "192.168.1.210"
}
}
# Pod (from docs/deployment.md) — add the annotation and bind option:
apiVersion: v1
kind: Pod
metadata:
name: home-automation
annotations:
k8s.v1.cni.cncf.io/networks: lan-macvlan
spec:
# hostNetwork: true ← NOT needed with Multus
containers:
- name: engine
env:
- name: HOMEKIT_BIND
value: net1 # the macvlan interface (first attachment = net1)
// Pass the bind value from the environment into HomekitServiceOptions:
bind: process.env.HOMEKIT_BIND?.split(",") ?? undefined,
The pod keeps its cluster network (eth0) for MQTT and HTTP while mDNS goes out the macvlan interface (net1) directly onto the LAN. Apple devices discover the bridge at the macvlan IP.
3. mDNS repeater (any environment)¶
If neither host networking nor Multus is available, run an mDNS repeater/proxy (e.g. avahi-reflector in reflection mode) to forward multicast between the container network and the host network. This approach is more complex and not covered here.
Pairing data persistence¶
hap-nodejs uses node-persist for storage, and older versions of node-persist resolve relative paths against their own __dirname inside node_modules rather than process.cwd(). In a container this often points to a read-only layer, causing an EACCES: permission denied crash.
HomekitService resolves the configured persistPath to an absolute path before handing it to hap-nodejs, so ./homekit-persist becomes /app/homekit-persist instead of /app/node_modules/node-persist/src/storage/homekit-persist.
For production deployments it is recommended to mount a dedicated volume and use an absolute path explicitly:
new HomekitService(logger, devices, {
pinCode: "031-45-154",
persistPath: "/data/homekit-persist",
});
Make sure the container user has write access to that directory.
CLI dashboard¶
The interactive ts-ha dashboard includes a dedicated HomeKit tab (key 6) showing:
- Bridge running/stopped status
- Number of registered accessories
- Full configuration (bridge name, HAP port, MAC address, pairing PIN, persist path)
When the service is not configured the tab displays a setup hint. The Overview tab (key 1) also shows a HomeKit: running / stopped badge whenever the service is present.
Web UI¶
The browser dashboard includes a HomeKit page in the navigation sidebar. It shows:
- Status cards: bridge running state, accessory count, HAP port, paired/offline badge
- A configuration panel with all bridge settings
When the service is not configured an informational notice is shown explaining how to register it.