Skip to Content
DevelopersDecision records0023 HomeKit bridge and per-unit identity

ADR 0023: HomeKit bridge and per-unit identity

Status: Accepted Date: 2026-11-01

Context

Plan item 5.2 asks for HomeKit: the light as a Lightbulb, the pump as a Switch, and the sensors, reusing sleepypod ’s identity derivation. sleepypod embeds a hap-nodejs bridge in its server (src/homekit/). It took several PRs of field fixes to get right, and those lessons carry over:

  • Identity is the durable part (sleepypod ADR 0020). iOS keys accessories, rooms, scenes and automations on the bridge’s MAC-style username. Losing it orphans all of them. Losing only the pairing files means scanning the QR again.
  • destroy() is a factory reset. It deletes the pairing keys and accessory ids. Shutdown must call unpublish() (sleepypod PR #718).
  • iOS can remove the bridge without telling the app. Home sends the removal straight to the HAP server. Republishing the same username then meets iOS’s cached keys: every tile reads “No Response” and pairing again is refused (sleepypod PRs #566 and #578).
  • mDNS. hap-nodejs’s avahi-over-D-Bus advertiser registered but never published on the Pod’s avahi build. sleepypod switched to ciao, which coexists with avahi-daemon on port 5353.

seedypod differs in four ways that matter here:

  • The unit is a Pi Zero W: ARMv6, one core, 512 MB.
  • Every actuator command must pass the safety layer (AGENTS.md), and the light has one owner, the arbiter (ADR 0020).
  • Core can run on a LAN box instead of the unit’s Pi.
  • A backup restores a unit onto a new card (ADR 0021).

Decision

An embedded bridge, off by default

src/homekit/ holds one HAP bridge with three accessories. It is off until the owner turns it on under Settings → Apple Home. That switch is device_settings.homekitEnabled, and only homekit.setEnabled changes it, after the bridge has started. A failed start leaves it off. The generic settings form can’t set it.

Off costs nothing. hap-nodejs and qrcode are loaded with dynamic import() on the first start. The accessory modules get the hap-nodejs module passed in instead of importing it, because the server bundle (esbuild --packages=external) hoists static imports of external packages to the top and would load hap-nodejs at boot. Loaded, hap-nodejs adds about 19 MB RSS and 7 MB of heap (measured on macOS arm64).

All dependencies are pure JavaScript, so ARMv6 is fine: hap-nodejs 1.2.0 (engines ^18 || ^20 || ^22), @homebridge/ciao, @homebridge/dbus-native, bonjour-hap, fast-srp-hap, tweetnacl, node-persist and qrcode. None has a binding.gyp, a prebuilt .node file or an install script.

Accessories go through core’s tRPC caller

The accessories write through createCallerFactory(appRouter)(ctx), the same procedures the app calls. They never touch the HAL client or MQTT directly.

HomeKitCharacteristicsCore
Grow light: LightbulbOn, Brightness 0–100light.set. A change from Home is a manual change, like one on the Light page. It holds over Autopilot and the schedule for 30 minutes, then the arbiter hands the light back. The tile follows actuator events, so it moves when the hold ends. Turning the light on with a brightness arrives as two writes, which are merged into one light.set.
Pump: SwitchOnOn calls pump.runOnce with 300 s, the Water page’s default, and hald clamps it to pumpMaxRunS. Off calls pump.stop. When the HAL refuses (dry tank, over-temperature, a latched fault), or returns a state that isn’t running, the switch reads off and the write fails with HAP’s “not allowed in current state”. The switch flips off when the run ends.
Sensors: TemperatureSensorCurrentTemperature, StatusFaultair_temp (°C). StatusFault is set when the reading isn’t good quality.
Sensors: HumiditySensorCurrentRelativeHumidity, StatusFaulthumidity.
Sensors: Battery (“Water tank”)BatteryLevel, StatusLowBattery, ChargingStateBatteryLevel is water_level_pct. StatusLowBattery is hald’s water_low alert. ChargingState is not chargeable.

While hald is offline, reads answer “No Response” rather than a stale value.

Why the tank is a Battery service. Battery is the one HomeKit service that carries a 0–100 level and a low flag, so the owner gets both the percent full and the “refill” state. Home lists a low one in its status summary, with no alarm. The alternatives were rejected:

  • A Leak sensor raises a critical “Leak detected” alert that can break through Do Not Disturb. A low tank is the opposite of a leak.
  • A Contact sensor reduces the tank to Open/Closed with labels that don’t fit, and it loses the level.

The Battery service sits on the same accessory as the temperature and humidity sensors, so Home always has a primary service to show.

The set is deliberately small. Dosing pumps and pH/EC have no HomeKit service that doesn’t misrepresent them, and dosing has its own safety design (ADR 0014).

Identity: sleepypod ADR 0020, with a seedypod seed chain

HKDF-SHA256 over a per-unit seed, with salt seedypod-homekit-v1, derives:

  • username: 6 bytes, locally administered, unicast.
  • The setup code: 8 digits. HAP’s banned codes are rejected, with a bounded retry.
  • The setup id: 4 characters.

All three come from the seed and a rotation counter. They are cached in $DATA_DIR/homekit/identity.json (mode 0600, directory 0700). No unit uses a shared default code.

OrderSeedWhy
1hal-unit-id: the unitId hald reportsFollows the tower even when core runs on a LAN box. By default it is gardyn-<Pi serial>, so it survives a reinstall and a new SD card. The simulators’ ids and gardyn-unknown don’t count, since they are the same on every machine.
2pi-serial: /proc/cpuinfo Serial, written as gardyn-<serial> exactly as hald/src/identity.rs writes itCore on the unit’s Pi derives the same seed whether or not hald has reported yet.
3machine-id: /etc/machine-idA LAN box that isn’t a Pi. It doesn’t survive a reinstall.
4randomDev machines and the simulator. It is cached in identity.json like the others.

sleepypod reads the eMMC CID first. On a Pi, mmcblk0’s CID is the SD card’s serial, so a card swap would re-key the unit. That is why the Pi serial replaces it.

The first start waits up to 10 s for hald to report, so the preferred seed is used. GET /api/homekit/seed-probe shows which seed the unit resolves to, without the seed or any code.

Confidentiality. None of the seeds is secret: the unit id is in hald’s mDNS TXT record. The code isn’t secret either: any LAN client can read it from GET /api/homekit, under the LAN-only trust model (ADR 0012). The derivation gives uniqueness and stability, not secrecy. What protects the unit is that HAP accepts a new pairing only while the bridge is unpaired, and it is unpaired only between the owner turning HomeKit on and adding it in Home. Logs carry the username and the seed source, never the code or setup id.

Rotation. Three things rotate the identity. Each one clears the old pairing files first and bumps rotation, so the username, code and QR all change:

  • Settings → Reset pairing.
  • The last controller removes the bridge in Home. hap-nodejs’s unpaired event triggers it, a second after Home’s request is answered.
  • A start that finds the bridge stranded. identity.json says wasPaired, which is written on hap-nodejs’s paired event or on a boot that finds pairings, but there are none on disk.

sleepypod  polled every 30 s for the wasPaired marker. seedypod uses the events instead. seedypod has no identities from before ADR 0020, so sleepypod’s legacy-migration branch isn’t ported.

mDNS: ciao, as in sleepypod

The default advertiser is ciao, hap-nodejs’s in-process responder:

  • It is sleepypod’s final choice. Its avahi D-Bus path silently failed in the field.
  • It needs no D-Bus access or daemon configuration.
  • It runs the same under pnpm dev:sim on macOS and in CI.
  • It follows hald’s own pattern: hald’s mdns-sd responder is also in-process and coexists with Raspberry Pi OS’s avahi-daemon on 5353 through SO_REUSEPORT.

ciao’s service hostname comes from the bridge name (<unit name>-XXXX.local), so it doesn’t fight avahi for the Pi’s own hostname.

Overrides:

  • HOMEKIT_ADVERTISER=avahi|bonjour-hap|resolved uses another advertiser. avahi offloads mDNS parsing to the daemon that’s already running, which is worth trying if ciao’s CPU use shows on a single-core Pi Zero.
  • HOMEKIT_BIND=wlan0 pins the bind interface. sleepypod needed this on its kernel, where ciao’s multicast didn’t leave wlan0 unbound.
  • HOMEKIT_PORT sets the HAP port. The default is 51827.

Backups carry HomeKit

$DATA_DIR/homekit/*.json goes into the backup bundle as homekit/ (ADR 0021): identity.json, plus hap-nodejs’s AccessoryInfo.<MAC>.json and IdentifierCache.<MAC>.json. The restore helper swaps the directory as one item, so undo works, and only when the bundle has it. A restore onto the same unit therefore comes back paired, with the same accessory ids, and Home’s automations keep working.

The bundle already holds secrets, the bucket keys and the MQTT password. HomeKit’s long-term keys join them under the same warning.

Restoring onto a different unit moves the HomeKit identity with it, the same way a restored hal.toml moves unit_id. That is right when the old unit is dead. Don’t run both at once. Reset pairing gives the new one its own identity.

Consequences

  • Home can switch the light and run the pump, under the same arbiter and HAL guard as the app. The HAL caps every pump run Home starts, and Home can’t show a refused run as running.
  • A unit that never turns HomeKit on loads nothing and writes nothing.
  • A reinstall or card swap without a backup keeps the identity, so the owner scans the QR again and the automations stay. With a backup, the unit stays paired.
  • Not verified on hardware in this change: discovery and pairing from an iPhone on a Pi Zero W, ciao’s CPU use there, and whether HOMEKIT_BIND is needed on Raspberry Pi OS. The HAP flow itself was checked end to end on the simulator with a HAP controller: the wrong code is refused, the derived one pairs, writes reach the light and pump, pairings survive a restart, and a removal rotates the identity.

Sources: sleepypod ADR 0020: Deterministic HomeKit identity derivation  · sleepypod PR #578: stranded-bridge rotation and the ciao advertiser  · sleepypod PR #718: unpublish, not destroy, on shutdown  · HAP-NodeJS  · ADR 0012: Deployment and trust model · ADR 0020: Autopilot (light arbiter) · ADR 0021: SD card wear and fast recovery

Imported from docs/adr/0023-homekit.md in seedypod/core. Edit it there, not here.

Last updated on