Skip to content

Runbook: Zigbee migration — ZHA/ZBT-1 (Pi) → Zigbee2MQTT/ZBT-2 (av)

Goal: move the 127-device Zigbee network to the ZBT-2 on av with zero re-pairing. Strategy: clone the network identity — PAN ID 0x598C, ext PAN ID, network key, channel 15, and the coordinator IEEE 1c:34:f1:ff:fe:ed:08:fe — onto the ZBT-2, and seed Zigbee2MQTT's database from ZHA's zigbee.db so every device (endpoints, clusters, modelId) is known without re-interviewing. Devices never notice the coordinator changed hardware.

Why the IEEE must be cloned: device→coordinator reporting bindings are addressed by IEEE. Z2M's ember driver restores network parameters but never writes a custom coordinator IEEE — so it's pre-provisioned out-of-band with zigpy-cli (bellows writes the rewritable EUI64 NVM3 token on EmberZNet 7.4+, not the one-time-programmable MFG token). Without this, Aqara devices (≈50 here) stop reporting until re-paired.

THE INVARIANT (applies to every step and every rollback): at most ONE powered radio holding IEEE 1c:34:f1:ff:fe:ed:08:fe / PAN 0x598C at any instant. The Pi's multiprotocol add-on has boot: auto + autoflash_firmware: true — a merely stopped add-on resurrects zigbeed (old network, same IEEE) on any Pi reboot. That is why the ZBT-1 is converted to Thread-only before Z2M ever starts, not after.

Preconditions

  • [x] ha stack up on av: mosquitto (auth OK), HA 2026.7.1 (onboarded, MQTT entry created)
  • [x] Z2M 2.12.1 staged behind compose profile zigbee; data/ seeded: configuration.yaml (ember/460800/rtscts), database.db (127 devices), devices.yaml, groups.yaml (native per-room groups)
  • [x] Network identity in stacks/ha/.env (ZIGBEE_*, ext PAN ID byte-reversed for herdsman)
  • [x] ZBT-2 probe: EmberZNet 7.4.4.0 EZSP @460800 (Z2M supports 7.4–8.2), factory-blank, mapped strictly by /dev/serial/by-id/ (the MOTU M4 also enumerates as ttyACM — never use raw ttyACMx)
  • [ ] ufw rule (host-network HA is LAN-blocked; docker-published ports 3000/11434 were explicitly allowed earlier, 8123/8080 are not): sudo ufw allow proto tcp from 192.168.86.0/24 to any port 8123,8080 comment 'homelab: ha + z2m'
  • [ ] Physical key access to the Entry Door Lock confirmed (Thread/Matter offline ~30–60 min)
  • [ ] Fresh backup taken MINUTES before cutover (frame counters age fast — the April backup's tx_counter is months stale; the restore bumps counters generously on top of a fresh value)
  • [ ] Preferred: ZBT-2 on a USB-2 port or short extension lead away from USB-3 sockets (2.4 GHz hygiene)

Cutover (execute top-to-bottom; ~60 min hands-on)

0. Re-snapshot + reseed: fresh ZHA network backup (WS zha/network/backups/create) + fresh zigbee.db snapshot → re-run tools/zha2z2m.py → restage data/database.db (+ diff devices.yaml) → refresh ZIGBEE_* in .env from the new backup.

1. Zigbee off the Pi, permanently: a. Disable the ZHA config entry (Settings → Devices → ZHA → ⋮ → Disable). b. UNINSTALL the Silicon Labs Multiprotocol add-on (not stop — uninstall). zigbeed is now unresurrectable. Thread is down as of this moment; the clock on lock-downtime starts.

2. ZBT-1 → Thread-only immediately (see thread-multiprotocol.md for detail): install the official OpenThread Border Router add-on (device = ZBT-1 by-id, autoflash_firmware: true — the add-on flashes OT-RCP itself, no manual flashing), then in Settings → Devices → Thread push the preserved dataset (channel 15, xpan f3b647a7fe582081) to the new OTBR. GATE: Entry Door Lock + Bedroom C Door reattach — the lock is a sleepy device, allow 30–60 min before declaring failure; proceed to step 3 in parallel while waiting.

3. Provision ZBT-2 (on av; writes params + bumped counters + rewritable EUI64):

docker run --rm -v ~/homelab-private:/bk:ro \
  --device /dev/serial/by-id/usb-Nabu_Casa_ZBT-2_DCB4D90BB164-if00:/dev/ttyUSB0 \
  python:3.13-slim bash -c "pip -q install zigpy-cli && \
    zigpy radio --baudrate 460800 ezsp /dev/ttyUSB0 restore /bk/zha-backup.zigpy.json"

4. GATE — read-back before Z2M ever starts (point of no return):

zigpy radio --baudrate 460800 ezsp /dev/ttyUSB0 backup -
  → ieee == 1c:34:f1:ff:fe:ed:08:fe        ← THE gate
  → PAN == 0x598C, EPID == bf:cd:7b:a7:d3:55:d9:1f, channel == 15
  → network_key frame counter STRICTLY GREATER than the fresh backup's value
Abort ⇒ rollback R1, investigate the EUI64 token write.

5. Start Zigbee2MQTT:

cd ~/homelab/stacks/ha && docker compose --profile zigbee up -d zigbee2mqtt
docker logs -f zigbee2mqtt
GATE: the log must show it adopting the existing network ("Network parameters match" / no mismatch). If it logs FORMING a network, kill it within 60 s — that's the ext-PAN-ID byte order or an env format slip (ZIGBEE_EXT_PAN_ID array form) — fix, re-run step 3 (idempotent), retry. Stale/foreign coordinator_backup.json error → delete it from data/.

6. Bidirectional mesh validation: - coordinator→device: toggle one bulb per room (frontend http://av:8080 or MQTT set). - device→coordinator: trip a motion sensor / press a room button and watch the state land — a pushed report proves bindings + counters, which polling cannot. - Routers (≈62) should be LQI-visible within ~15 min.

7. New-HA integration: MQTT discovery brings devices in (renamed) → tools/assign_areas.py (floors, areas incl. Downstairs Hallway, per-device fixes, switch_as_x helpers) → tools/dry_run_rooms.py for the no-walking automation matrix.

8. Battery audit (T+2h, then 24–48 h soak): - 49 Lumi/Aqara battery devices heartbeat ~hourly → at T+2h expect all seen; sweep stragglers. - Exactly 9 devices never report unprompted (5× SOMRIG, 2× RWL022 dimmers, RDM002 dial, 3RSB015BZ blinds) — press each once during the walk. Never hold an Aqara button ~5 s — that factory-resets it. - Soak criteria: zero unavailable devices (minus the press-only list until pressed); verify the two exotics got proper converters: TS1201 IR blasters (fingerprint _TZ3290_j37rooaxrcdcqo5n) and the Titan TPZRCO2HT-Z3 CO₂ sensor (both Bedroom C — non-blocking for lighting).

Rollbacks (counter-aware — this is where naive plans die)

  • R1 — before Z2M's first start: re-install the multiprotocol add-on (its autoflash restores the RCP firmware to ZBT-1 automatically), re-enable the ZHA entry → original world. ~15 min.
  • R2 — after Z2M has transmitted (even briefly): the mesh has recorded HIGHER frame counters for this IEEE. The ZBT-1's old NVRAM is now a trap: reviving it "successfully" yields a network that silently ignores the coordinator. Procedure: stop Z2M → UNPLUG the ZBT-2 first (invariant!) → restore Z2M's CURRENT data/coordinator_backup.json (same open format, fresh counters — never the pre-cutover backup) onto whichever EmberZNet radio will serve ZHA (reflash ZBT-1 back to EmberZNet NCP via universal-silabs-flasher, or use the ZBT-2 itself with ZHA) → re-enable ZHA pointing at that radio.
  • R3 — days later: same as R2. If ZBT-1 is reclaimed for Zigbee, Thread needs another radio (or accept temporary Matter loss for the 2 devices).

As-executed log

Cutover executed 2026-07-04, ~19:20–20:20 UTC (12:20–13:20 PDT). Zigbee fully migrated; all gates passed. Deviations and findings:

Time (UTC) Event
19:21 Fresh backup (tx_counter 226,081,647 — April's was 207M; staleness warning validated) + fresh zigbee.db reseed
19:23 ZHA entry disabled cleanly (require_restart: false)
19:24 Multiprotocol add-on uninstalled (zigbeed unresurrectable) · OTBR install blocked: Pi has <1.5 GB free → Thread restore DEFERRED (user accepted lock downtime)
19:25 zigpy restore onto ZBT-2 — silent success
19:26 GATE PASSED 5/5: IEEE 1c:34:f1:ff:fe:ed:08:fe, PAN 0x598C, EPID, ch 15, counter 226,095,105 > baseline
19:27 Z2M first start: config rejected — env format (pan_id hex string, ext_pan_id hex-literal array). Fix: decimal (22924, [31,217,...])
19:27 Second start: ADOPTED ("Adapter network matches config") but 1 device — converter wrote ieee, herdsman wants ieeeAddr (fixed in tools/zha2z2m.py)
19:29 v4→v5 config migration inlined devices; a undefined poison key (from the 1-device boot) removed
19:33 "Currently 127 devices are joined" — renamed names live, mesh traffic flowing
19:35 Round-trip probes: 7/8 zones answer with linkquality (Bedroom D chandelier silent — wall switch off)
19:40 Z2M 2.x ignores YAML groups → created 9 native groups via bridge API (33/41 members joined; Bedroom D pending power)
19:45 HA discovery: 906 entities · assign_areas.py: 4 floors, 21 areas, 127/127 devices filed, both switch_as_x created (entity ids came out doubled — renamed via registry)
19:50 LR presence (Apollo MTR-1) adopted via ESPHome flow; exposes target counts only → threshold helper added. MSR-1 (Backyard Presence — previously unknown to the design!) adopt timed out, deferred
20:00–20:20 Dry-run matrix exposed 2 real design bugs, both fixed in the blueprint: (1) doors-as-levels pinned approach groups → door-opened 30 s pulse sensors (13, packages/house/door_pulses.yaml); (2) presence≡approach alias rooms could never auto-on → alias-aware guard. Plus: unavailable hold-motion now counts as occupied (hold, never strand). Final: 51/52 checks green; residual = live-house interference during forged tests

Soak checklist (open): - [ ] Bedroom D chandelier: flip wall switch → probe → re-add 5 group members (tools/create_z2m_groups.py re-run) - [ ] Stairwell Front bulb unavailable — power? - [ ] Basement motion + Bathroom Full toilet motion: battery heartbeat/availability settle (toilet flapped offline at 19:51 — check availability classification) - [ ] Third Reality plugs: configure errors (custom cluster missing in seed) — re-interview if energy readings wanted - [ ] TS1201 IR blasters + Titan CO₂: converter support check (Bedroom C, non-blocking) - [ ] T+2h battery sweep: 49 hourly Aqara all seen; press the 9 press-only devices on next walk - [ ] MSR-1 Backyard Presence: adopt (mDNS timeout first try); consider backyard presence logic later - [ ] Thread/Matter restore on Pi: BLOCKED on disk space (needs 1.5 GB; then OTBR add-on + preserved dataset f3b647a7fe582081) — Entry lock + Bedroom C door offline until then (physical key works) - [ ] Physical walk-test per room + button matrix; gaming smoke test (Sunshine/Moonlight)