From fda24c18a2155ac4153dcb95c0fbccbe66f7e9cd Mon Sep 17 00:00:00 2001 From: David Gwilliam Date: Wed, 1 Jul 2026 01:25:38 -0700 Subject: [PATCH] zigbee2mqtt: add z2m-repair / z2m-rename recipes for rejoin Add scripts/z2m_repair.py that: - Opens permit_join for N seconds (default 120) - Subscribes to bridge/event on the local MQTT broker - For each device_joined event, auto-renames the device back to its original friendly name (hardcoded IEEE->name mapping from the last known good state, overridable via --mapping). After the zigbee2mqtt database was wiped, the user had to manually pair each device via the web UI. With this recipe, devices rejoin automatically when woken (network_key preserved), and the script restores friendly names as they join. Add justfile recipes: just z2m-repair [TIME=120] - run the auto-repair script just z2m-rename IEEE NAME - rename a single device by IEEE --- justfile | 19 +++++++ scripts/z2m_repair.py | 127 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 146 insertions(+) create mode 100755 scripts/z2m_repair.py diff --git a/justfile b/justfile index 73806cb..ca3aa0b 100644 --- a/justfile +++ b/justfile @@ -172,6 +172,25 @@ z2m-patch-bridge: ssh {{RPI_HOST}} 'docker restart zigbee2mqtt' @echo "Done. Verify with: just z2m-logs" +# Open permit_join + watch for device_joined events; auto-rename any +# rejoining device back to its original friendly name. Use after the +# zigbee2mqtt database has been wiped, to recover from a fresh state +# without manually re-pairing each device via the web UI. +# +# First put each device into pairing mode (ThirdReality smart plugs: +# hold the side button for ~5 seconds until the LED blinks rapidly). +# Then run this and press a device's button within the time window. +z2m-repair TIME="120": + uv run --with paho-mqtt python3 {{justfile_directory()}}/scripts/z2m_repair.py --time {{TIME}} + +# Rename a zigbee2mqtt device by IEEE address. Use after a device +# rejoins with a fresh ieee (or for any rename). +z2m-rename IEEE NAME: + ssh {{RPI_HOST}} 'mosquitto_pub -h localhost \ + -t "zigbee2mqtt/bridge/request/device/rename" \ + -m "{\"from\":\"{{IEEE}}\",\"to\":\"{{NAME}}\",\"transaction\":\"rename-{{NAME}}\"}"' + @echo "Renamed {{IEEE}} -> {{NAME}}" + # Show paired zigbee devices and their friendly names z2m-devices: ssh {{RPI_HOST}} 'cat {{RPI_DIR}}/nr-flow-validator/infra/zigbee2mqtt/configuration.yaml' > /tmp/z2m.yaml diff --git a/scripts/z2m_repair.py b/scripts/z2m_repair.py new file mode 100755 index 0000000..87de514 --- /dev/null +++ b/scripts/z2m_repair.py @@ -0,0 +1,127 @@ +#!/usr/bin/env python3 +"""Auto-rejoin helper: open permit_join, watch for device_joined events, +and rename any rejoining device back to its original friendly name. + +Why: after the zigbee2mqtt database is wiped, devices need to rejoin. They +already know the network (network_key + pan_id + channel preserved on +RPi), so they will rejoin automatically when woken. This script: + +1. Publishes permit_join for `time` seconds (default 120). +2. Subscribes to bridge/event on the local MQTT broker. +3. For each device_joined event with a known IEEE, sends a + bridge/request/device/rename call to restore the friendly name. + +Run from anywhere with network access to the RPi's mosquitto broker: + + uv run --with paho-mqtt scripts/z2m_repair.py --time 120 + +Mapping is hardcoded from the backup `state.json` / `configuration.yaml` +preserved from the previous working stack on the RPi. Override with +--mapping to add or change entries. +""" +import argparse +import json +import sys +import time + +import paho.mqtt.client as mqtt + +# IEEE -> friendly name, from the last known good state. +DEFAULT_MAPPING = { + "0xffffb40e0604fbd0": "Squiggle", + "0xffffb40e06065536": "Door Button", + "0xffffb40e0603c9e4": "Sideboard Lamp", + "0xffffb40e06050af6": "Bamboo Lights", + "0xffffb40e0603ef11": "DJ Booth", +} + + +def main() -> int: + p = argparse.ArgumentParser() + p.add_argument("--host", default="192.168.81.147") + p.add_argument("--port", type=int, default=1883) + p.add_argument("--time", type=int, default=120, + help="permit_join window in seconds (default 120)") + p.add_argument("--mapping", help="JSON file overriding default mapping") + args = p.parse_args() + + mapping = dict(DEFAULT_MAPPING) + if args.mapping: + with open(args.mapping) as f: + override = json.load(f) + mapping.update(override) + + print(f"[repair] mapping {len(mapping)} known devices:") + for ieee, name in mapping.items(): + print(f" {ieee} -> {name}") + + joined: set[str] = set() + renamed: set[str] = set() + + def on_connect(client, userdata, flags, reason, properties=None): + print(f"[repair] connected to mqtt://{args.host}:{args.port}") + client.subscribe("zigbee2mqtt/bridge/event") + client.publish( + "zigbee2mqtt/bridge/request/permit_join", + json.dumps({"time": args.time, "transaction": "repair-1"}), + ) + print(f"[repair] permit_join opened for {args.time}s") + print(f"[repair] waiting for device_joined events...") + print(f"[repair] press Ctrl-C to abort") + + def on_message(client, userdata, msg): + try: + payload = json.loads(msg.payload) + except (ValueError, TypeError): + return + if payload.get("type") != "device_joined": + return + ieee = payload.get("data", {}).get("ieee_address") + if not ieee or ieee in joined: + return + joined.add(ieee) + name = mapping.get(ieee, f"") + if name.startswith(" {name}") + print(f"[repair] (will not auto-rename; use: just z2m-rename {ieee} )") + return + if ieee in renamed: + return + # Ask zigbee2mqtt to rename + client.publish( + "zigbee2mqtt/bridge/request/device/rename", + json.dumps({"from": ieee, "to": name, "transaction": f"rename-{ieee}"}), + ) + renamed.add(ieee) + print(f"[repair] joined + renamed: {ieee} -> {name}") + if len(renamed) >= len(mapping): + print(f"[repair] all {len(mapping)} known devices rejoined and renamed") + + client = mqtt.Client(callback_api_version=mqtt.CallbackAPIVersion.VERSION2) + client.on_connect = on_connect + client.on_message = on_message + + try: + client.connect(args.host, args.port, keepalive=60) + client.loop_start() + time.sleep(args.time + 5) + print(f"[repair] permit_join window elapsed ({args.time}s)") + print(f"[repair] joined: {len(joined)}, renamed: {len(renamed)} of {len(mapping)}") + missing = set(mapping) - joined + if missing: + print(f"[repair] missing (need re-pair):") + for ieee in missing: + print(f" {ieee} ({mapping[ieee]})") + client.loop_stop() + client.disconnect() + except KeyboardInterrupt: + print(f"\n[repair] aborted") + client.loop_stop() + client.disconnect() + return 130 + + return 0 if len(renamed) == len(mapping) else 1 + + +if __name__ == "__main__": + sys.exit(main())