You reboot a VM on your Synology NAS, and something that depends on a passed-through USB device just stops working. Virtual Machine Manager still insists the device is “paired,” but the guest OS can’t see it — because it isn’t actually there anymore. The only fix you know is rebooting the VM again and hoping. Spoiler: hope doesn’t scale, especially when it happens while you’re away and whatever depends on that device quietly stops working without telling anyone.

The twist is that this isn’t a flaky cable or a dying device — it’s a confirmed Synology VMM behavior. On every VM reboot, VMM simply fails to re-attach a passed-through USB device to its new bus address, so the device has to be manually reconnected before the guest can see it again — every single time, for any USB device you pass through this way. I hit this enough times to stop tolerating it, so I wrote a generic script that detects the failure and heals it in under 5 minutes — no VM reboot, no manual replugging, ever again. I’ll walk through it using my own case — a ConBee II Zigbee stick passed through to a Home Assistant OS VM — but the fix applies to any USB passthrough device on any Synology VMM guest.

TL;DR

  • Symptom: After a VMM guest restarts, a passed-through USB device disappears from the guest — even though VMM still shows it “paired.” (My case: the ConBee II vanishes, ZHA fails, and every Zigbee entity in Home Assistant goes with it.)
  • Root cause (confirmed Synology VMM quirk): on VM reboot, VMM doesn’t re-attach the passed-through USB device to its new address. The passthrough stays pinned to the old USB device number, the device re-enumerates on a new one, and QEMU chases a device that no longer exists — it has to be reconnected.
  • Fix: A live virsh detach-device / attach-device matched by vendor:product only (no pinned device number) rebinds the device to its current number — no VM reboot. This works for any USB passthrough device, not just Zigbee sticks.
  • Result: A NAS cron script polls a health signal every 5 min and self-heals on disconnect; the device comes back on its own, usually on the first attempt.

Who this is for

  • A Synology NAS running Virtual Machine Manager, with any USB device passed through to a guest VM — Zigbee/Z-Wave coordinators, DVB tuners, dongles, whatever.
  • The example throughout is a ConBee II Zigbee coordinator passed through to a Home Assistant OS (HAOS) VM, used by ZHA — swap in your own device and guest.
  • Root shell access to the NAS (DSM Task Scheduler runs as root).
  • virsh on the NAS (Synology ships it at /usr/local/bin/virsh).

Sound like your setup? Good — everything below generalises to any libvirt/QEMU host and any passed-through USB device, you just swap the vendor/product, paths, and health check for your own.

Why this actually happens

Here’s the part that took me way too long to figure out. The ConBee II is passed through to the HAOS VM via VMM (which is libvirt/QEMU under the hood). After the VM restarts, VMM still shows the USB device as “paired” — because it’s matching on vendor/product, and that hasn’t changed. Convincing, right up until you realize it’s lying to you.

The live libvirt hostdev, meanwhile, is still pinned to the old USB device number from before the reboot. VMM never re-attaches it to the stick’s new address. This is a confirmed Synology behavior, not a bug in your config: the passthrough is never automatically re-established on VM reboot, so the device genuinely has to be reconnected by hand.

When the stick re-enumerates on a new device number, QEMU is still faithfully watching for the old one — forever. The guest loses its /dev/serial/by-id/...ConBee_II... node, and in Home Assistant that surfaces as the ZHA integration failing and every single Zigbee entity disappearing at once.

Here’s the trap that makes it so easy to misdiagnose: a plain HA Core restart does not trigger this. It takes a full VM reboot to reproduce, so if you’ve only ever tested with Core restarts, everything looks fine — until the day something (a NAS update, a power blip) actually reboots the VM, and Zigbee just doesn’t come back.

The fix

Once you understand the cause, the fix is refreshingly small: rebind the passthrough by vendor:product only, with no pinned device number. That lets libvirt attach to whatever device number the stick currently holds, instead of chasing a number that no longer exists:

  • Vendor/product: 0x1cf1:0x0030 — dresden elektronik ConBee II. This is the same for every ConBee II, so you can copy it verbatim.

A live detach + attach cycle rebinds the stick without rebooting the VM at all. Once the device is back, reload the ZHA config entry and the entities snap back to life.

Turning the fix into something that heals itself

Knowing the fix is one thing; never having to run it by hand is the actual goal. So this became two layers — a primary healer on the NAS, and an optional backstop in Home Assistant, so you find out about a real failure instead of a silent one.

flowchart TD
    A[Task Scheduler every 5 min] --> B[Poll sensor.conbee_ii_connected]
    B -->|connected| C[Exit quietly]
    B -->|disconnected| D[virsh detach + attach by vendor:product]
    D --> E{Sensor connected?}
    E -->|yes| F[Reload ZHA entry] --> G[Recovered]
    E -->|no, retry up to 3x| D
    E -->|failed 3x| H[Send phone notification]
    B -->|unknown / API down| C
  1. Primary — NAS Task Scheduler, every 5 min, as root. A script polls the HA sensor sensor.conbee_ii_connected:

    • connected → exit quietly.
    • disconnected → up to virsh detach/attach; on recovery, reload the ZHA entry; only if all 3 attempts fail does it push a phone notification.
    • Any other state (API/token unreachable) → no action. Never re-plug on uncertainty — a stuck API shouldn’t cause hardware thrashing.
  2. Backstop — a HA automation (optional). On HA start, wait 10 minutes; if still disconnected, send one notification. This only fires if the NAS heal didn’t fix it.

Step 1: teach Home Assistant to notice

The whole thing hinges on knowing whether the stick is present before you try to fix anything. A command_line sensor checks for the stick’s /dev/serial/by-id/... node inside the VM and reports connected / disconnected:

- sensor:
    name: ConBee II connected
    unique_id: conbee_ii_connected
    # Replace the by-id path with YOUR stick's node (run: ls -l /dev/serial/by-id/)
    command: >-
      test -e /dev/serial/by-id/usb-dresden_elektronik_ingenieurtechnik_GmbH_ConBee_II_XXXXXXXX-if00
      && echo connected || echo disconnected
    scan_interval: 60

That gives you sensor.conbee_ii_connected, which the heal script polls every cycle.

Step 2: the script that does the actual healing

This is the part that runs unattended, at 3 a.m., so it needs to be paranoid about doing nothing when it isn’t sure. The core loop: refresh the sensor, bail immediately if healthy, and only ever act on a confirmed disconnected. Everything environment-specific lives in one CONFIG block at the top, so you’re not hunting through the script to adapt it to your own setup.

### --- CONFIG (edit these for your environment) ---
HA_URL="http://homeassistant.local:8123"                 # your Home Assistant base URL
TOKEN_FILE="/volume1/System/scripts/conbee-heal.token"   # root-only file, holds HA long-lived token
SENSOR="sensor.conbee_ii_connected"                      # command_line sensor (see above)
ZHA_ENTRY="REPLACE_WITH_YOUR_ZHA_ENTRY_ID"               # ZHA config entry id
DOM="REPLACE_WITH_YOUR_VM_UUID"                          # HAOS VM libvirt UUID (virsh list --all)
VENDOR="0x1cf1"; PRODUCT="0x0030"                        # ConBee II (dresden elektronik) — from lsusb
NOTIFY="notify/mobile_app_yourphone"                     # optional: your HA notify service
VIRSH="/usr/local/bin/virsh"
LOG="/volume1/System/scripts/conbee-heal.log"
MAX_TRIES=3
WAIT_AFTER_REPLUG=8
### ---------------

The decision logic is the single most important part of this whole script — notice it refuses to act unless the sensor explicitly says disconnected:

STATE="$(refresh_state)"
[ "$STATE" = "connected" ] && exit 0          # healthy -> do nothing, stay quiet
if [ "$STATE" != "disconnected" ]; then       # empty/unknown => API or token issue; do NOT re-plug
  log "sensor state='$STATE' (not 'disconnected'; API/token unreachable?) — no action"
  exit 0
fi

The rebind itself writes a minimal hostdev XML matched by vendor:product only, then loops detach/attach up to three times, reloading ZHA the moment the stick returns:

XML="/tmp/conbee-heal.xml"
cat > "$XML" <<EOF
<hostdev mode='subsystem' type='usb' managed='no'>
  <source>
    <vendor id='$VENDOR'/>
    <product id='$PRODUCT'/>
  </source>
</hostdev>
EOF

i=1
while [ "$i" -le "$MAX_TRIES" ]; do
  log "attempt $i/$MAX_TRIES: detach+attach"
  "$VIRSH" detach-device "$DOM" "$XML" --live >>"$LOG" 2>&1
  sleep 2
  "$VIRSH" attach-device "$DOM" "$XML" --live >>"$LOG" 2>&1
  sleep "$WAIT_AFTER_REPLUG"
  STATE="$(refresh_state)"
  log "attempt $i result: sensor=$STATE"
  if [ "$STATE" = "connected" ]; then
    log "device back — reloading ZHA entry"
    ha_post "services/homeassistant/reload_config_entry" "{\"entry_id\":\"$ZHA_ENTRY\"}"
    log "recovery SUCCESS on attempt $i"
    exit 0
  fi
  i=$((i+1))
done

Finding your environment-specific values

Value What it is How to find it
HA_URL Your HA base URL The address you use to reach HA, e.g. http://homeassistant.local:8123
ZHA_ENTRY ZHA config entry id HA API GET /api/config/config_entries/entry, pick the zha one; or read the VM’s .storage/core.config_entries
DOM HAOS VM libvirt UUID On the NAS as root: virsh list --all, then virsh domuuid <vm-name>
VENDOR / PRODUCT USB vendor:product lsusb (ConBee II is 1cf1:0030)
NOTIFY Optional notify service Any notify.* service in your HA

Putting it to work on the NAS

Five steps, all as root, and you’re done thinking about this problem forever:

  1. Fill in the CONFIG block with your values.

  2. Drop the script in place and make it executable:

    mkdir -p /volume1/System/scripts
    chmod +x /volume1/System/scripts/conbee-heal.sh
  3. Store the HA long-lived token in a root-only file:

    printf '%s' 'PASTE_HA_LONG_LIVED_TOKEN' > /volume1/System/scripts/conbee-heal.token
    chmod 600 /volume1/System/scripts/conbee-heal.token
  4. Test it — with the stick healthy this should be silent, exit 0, empty log:

    bash /volume1/System/scripts/conbee-heal.sh; echo "exit=$?"; cat /volume1/System/scripts/conbee-heal.log
  5. Schedule it in DSM → Control Panel → Task Scheduler → Create → Scheduled Task → User-defined script:

    • User: root
    • Schedule: Daily, first run 00:00, repeat every 5 minutes, last run 23:55
    • Command: bash /volume1/System/scripts/conbee-heal.sh

Does it actually work? Let’s break it on purpose

Talk is cheap, so prove it to yourself. The only honest way to test is a full VM reboot — a HA Core restart won’t drop the stick, so it won’t tell you anything. On the NAS as root:

virsh reboot <YOUR_VM_UUID>

Watch sensor.conbee_ii_connected: during boot it goes unavailable, then reads disconnected, and within one scheduler cycle (≤5 min) the healer flips it back to connected. A successful run leaves a log like this:

detected state=disconnected — starting recovery
attempt 1/3: detach+attach
Device detached successfully
Device attached successfully
attempt 1 result: sensor=connected
device back — reloading ZHA entry
recovery SUCCESS on attempt 1

Results — and what I’d do differently

Since putting this in place, a VM reboot has gone from “mildly stressful” to a complete non-event. Zigbee comes back on its own, almost always on the first detach/attach cycle, and the only time I hear about it is if all three attempts genuinely fail. The two design choices I’m happiest about:

  • Never act on uncertainty. If the HA API is unreachable, the script does nothing rather than blindly re-plugging hardware. That single guard has saved me from self-inflicted chaos more than once — a flaky API is not a green light to start yanking USB devices.
  • Log only when it acts. A healthy run is completely silent, so the log is a clean record of every real recovery instead of noise you have to scroll past.

If I were starting over, I’d wire the presence sensor up first and confirm it flips correctly on a real reboot before writing a single line of healing logic — the sensor is the foundation everything else has to trust, and it’s not worth building on sand.

If you’ve fought this exact ConBee-vanishes-on-reboot ghost, I’d genuinely like to hear how you solved it — there’s more than one way to skin this particular USB cat. Test it on your own setup before relying on it, and enjoy never manually replugging a Zigbee stick again.

The full script

Everything above in one file — the CONFIG block, the single-run lock, the state machine, and the notification on repeated failure. Fill in the CONFIG block for your own environment before deploying it.

#!/bin/bash
# conbee-heal.sh — auto-recover the ConBee II Zigbee USB passthrough to the HAOS VM.
#
# Root cause it fixes: after the HAOS VM restarts, Synology VMM keeps the ConBee
# "paired" (vendor/product persists) but the live libvirt hostdev stays pinned to a
# stale USB device number, so the stick re-enumerates and the guest loses /dev node.
# A live `virsh detach-device`/`attach-device` by vendor:product (no pinned device
# number) rebinds it without rebooting the VM.
#
# Deploy: /volume1/System/scripts/conbee-heal.sh  (run as root via Synology Task
# Scheduler, every 5 min). Quiet when healthy; only acts when the HA sensor reports
# "disconnected".
#
# Before deploying, fill in the CONFIG block below for your environment — see
# "Finding your environment-specific values" above for how to find each one.
set -u
export PATH=/usr/local/bin:/usr/bin:/bin:/sbin:$PATH

### --- CONFIG (edit these for your environment) ---
HA_URL="http://homeassistant.local:8123"                 # your Home Assistant base URL
TOKEN_FILE="/volume1/System/scripts/conbee-heal.token"   # root-only file, holds HA long-lived token
SENSOR="sensor.conbee_ii_connected"                      # command_line sensor (see guide)
ZHA_ENTRY="REPLACE_WITH_YOUR_ZHA_ENTRY_ID"               # ZHA config entry id (see guide)
DOM="REPLACE_WITH_YOUR_VM_UUID"                          # HAOS VM libvirt UUID (virsh list --all)
VENDOR="0x1cf1"; PRODUCT="0x0030"                        # ConBee II (dresden elektronik) — from lsusb
NOTIFY="notify/mobile_app_yourphone"                     # optional: your HA notify service
VIRSH="/usr/local/bin/virsh"
LOG="/volume1/System/scripts/conbee-heal.log"
MAX_TRIES=3
WAIT_AFTER_REPLUG=8
### ---------------

log(){ echo "$(date '+%F %T') $*" >> "$LOG"; }

# single-run lock (skip if a previous run is still going)
LOCK="/tmp/conbee-heal.lock"
mkdir "$LOCK" 2>/dev/null || exit 0
trap 'rmdir "$LOCK" 2>/dev/null' EXIT

TOKEN="$(cat "$TOKEN_FILE" 2>/dev/null)"
[ -z "$TOKEN" ] && { log "ERROR: no token in $TOKEN_FILE"; exit 1; }
AUTH="Authorization: Bearer $TOKEN"

ha_state(){ curl -s -m 15 -H "$AUTH" "$HA_URL/api/states/$SENSOR" \
            | grep -o '"state":"[^"]*"' | head -n1 | cut -d'"' -f4; }
ha_post(){ curl -s -m 20 -X POST -H "$AUTH" -H "Content-Type: application/json" \
            -d "$2" "$HA_URL/api/$1" >/dev/null 2>&1; }
refresh_state(){ ha_post "services/homeassistant/update_entity" \
                 "{\"entity_id\":\"$SENSOR\"}"; sleep 3; ha_state; }

STATE="$(refresh_state)"
[ "$STATE" = "connected" ] && exit 0          # healthy -> do nothing, stay quiet
if [ "$STATE" != "disconnected" ]; then       # empty/unknown => API or token issue; do NOT re-plug
  log "sensor state='$STATE' (not 'disconnected'; API/token unreachable?) — no action"
  exit 0
fi

log "detected state=disconnected — starting recovery"
XML="/tmp/conbee-heal.xml"
cat > "$XML" <<EOF
<hostdev mode='subsystem' type='usb' managed='no'>
  <source>
    <vendor id='$VENDOR'/>
    <product id='$PRODUCT'/>
  </source>
</hostdev>
EOF

i=1
while [ "$i" -le "$MAX_TRIES" ]; do
  log "attempt $i/$MAX_TRIES: detach+attach"
  "$VIRSH" detach-device "$DOM" "$XML" --live >>"$LOG" 2>&1
  sleep 2
  "$VIRSH" attach-device "$DOM" "$XML" --live >>"$LOG" 2>&1
  sleep "$WAIT_AFTER_REPLUG"
  STATE="$(refresh_state)"
  log "attempt $i result: sensor=$STATE"
  if [ "$STATE" = "connected" ]; then
    log "device back — reloading ZHA entry"
    ha_post "services/homeassistant/reload_config_entry" "{\"entry_id\":\"$ZHA_ENTRY\"}"
    log "recovery SUCCESS on attempt $i"
    exit 0
  fi
  i=$((i+1))
done

log "recovery FAILED after $MAX_TRIES attempts — notifying"
ha_post "$NOTIFY" "{\"title\":\"Hardware Alert!\",\"message\":\"ConBee II Zigbee auto-recovery failed after $MAX_TRIES attempts — check the stick.\"}"
exit 1