Files
david fda24c18a2 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
2026-07-01 01:25:38 -07:00

128 lines
4.5 KiB
Python
Executable File

#!/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"<unknown-{ieee[-6:]}>")
if name.startswith("<unknown-"):
print(f"[repair] joined (NEW): {ieee} -> {name}")
print(f"[repair] (will not auto-rename; use: just z2m-rename {ieee} <name>)")
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())