Skip to main content

Stable USB Passthrough of Identical Dongles to a HAOS VM (Proxmox)

Last updated: 2026-06-23. Environment: Proxmox host "pve1", HAOS VM 116 ("srv-homeassistant"), two identical Sonoff Dongle Plus MG24 USB sticks.

The problem

Two physically identical USB dongles need to be passed through to the same VM, with each one reliably landing at a predictable device path. The usual Proxmox approaches do not work here:

  • Vendor/Device ID mapping fails because both dongles share the same vendor:product ID (CP210x, 10c4:ea60), so Proxmox cannot tell them apart.
  • "Use USB Port" (bus/port path) is unstable: every time a USB device is added or the host reboots, the enumeration order can change, so the port path is not guaranteed to map to the same physical dongle.
  • PCI passthrough / a dedicated USB controller is not an option on this host (no spare expansion slot / cannot dedicate a controller).
  • The reliable discriminator is each dongle's unique USB serial number. This guide uses the serial to pin each dongle to a stable path, then passes that path into the VM.

Device / serial / firmware reference

  • OpenThread dongle: serial e8eaaed3e3a2ef119de0926661ce3355. Flashed with OpenThread RCP firmware (SPINEL protocol, SL-OPENTHREAD 2.4.4.0, runs at 460800 baud). Used by the OpenThread Border Router add-on.
  • Zigbee dongle: serial 1e69957bb0a2ef11a942966661ce3355. Default Sonoff MG24 firmware (EZSP protocol, ember adapter, EmberZNet 8.0.2 [GA], 115200 baud). Used by Zigbee2MQTT.

Step 1: Identify the dongles and confirm firmware

On the Proxmox host, list the stable by-id symlinks. Each dongle exposes its serial here:

ls -l /dev/serial/by-id/

The two CP210x dongles appear here as usb-SONOFF_SONOFF_Dongle_Plus_MG24_<serial>-if00-port0. Note which serial is which. To positively confirm which firmware each one runs, probe them with universal-silabs-flasher (install once into a venv on the host):

apt install -y python3.13-venv pipx
python3 -m venv /opt/usf-venv
/opt/usf-venv/bin/pip install universal-silabs-flasher
/opt/usf-venv/bin/universal-silabs-flasher --device /dev/serial/by-id/usb-SONOFF_..._<serial>-if00-port0 probe

The probe reports the protocol per dongle: SPINEL means OpenThread firmware; EZSP means the default Zigbee firmware. In this case serial e8eaaed3... probed as SPINEL (OpenThread) and serial 1e69957b... probed as EZSP/ember (Zigbee).

Step 2: Pin each dongle to a stable path on the host

There are two equally valid ways to get a stable, predictable path for each dongle. Method A (udev aliases) gives friendly names; Method B (direct by-id) needs no setup at all. Both are documented so either can be used.

Method A: udev aliases (friendly names)

Create a udev rule that matches each dongle by its serial and makes a named symlink. Create /etc/udev/rules.d/99-usb-serial.rules with:

SUBSYSTEM=="tty", ATTRS{serial}=="e8eaaed3e3a2ef119de0926661ce3355", SYMLINK+="OpenThread"
SUBSYSTEM=="tty", ATTRS{serial}=="1e69957bb0a2ef11a942966661ce3355", SYMLINK+="Zigbee"

Then reload udev and verify the symlinks were created:

udevadm control --reload-rules && udevadm trigger
ls -l /dev/OpenThread /dev/Zigbee

/dev/OpenThread now points at the OpenThread dongle and /dev/Zigbee at the Zigbee dongle, regardless of ttyUSB enumeration order.

Method B: direct /dev/serial/by-id (no setup needed)

The kernel already creates a stable, serial-based symlink for each dongle under /dev/serial/by-id/. udev can be skipped entirely by just referencing these paths:

/dev/serial/by-id/usb-SONOFF_SONOFF_Dongle_Plus_MG24_e8eaaed3e3a2ef119de0926661ce3355-if00-port0 (OpenThread)
/dev/serial/by-id/usb-SONOFF_SONOFF_Dongle_Plus_MG24_1e69957bb0a2ef11a942966661ce3355-if00-port0 (Zigbee)

Both methods are stable across reboots because they key off the serial number. Method A is just a readability convenience on top of Method B.

Step 3: Pass the dongles into the VM (Proxmox)

Because the dongles are identical, the Proxmox GUI USB options (vendor:product or bus-port) cannot reliably target them. Instead, attach them via QEMU args using a chardev bound to the stable host path. First back up the VM config:

cp /etc/pve/qemu-server/116.conf /root/116.conf.bak-$(date +%Y%m%d-%H%M%S)

If old GUI USB entries point at these dongles, remove them, then set the args line (this example uses the Method A alias paths):

qm set 116 --delete usb1,usb2
qm set 116 --args "-chardev serial,id=openthread,path=/dev/OpenThread -device usb-serial,chardev=openthread -chardev serial,id=zigbee,path=/dev/Zigbee -device usb-serial,chardev=zigbee"

The Method B /dev/serial/by-id/... paths can be substituted in place of /dev/OpenThread and /dev/Zigbee if preferred (to avoid udev). Then fully stop and start the VM (a reboot from inside the guest is not enough for args changes to take effect):

qm stop 116 && qm start 116

Step 4: How the dongles look inside the VM

Once passed through with usb-serial chardevs, the dongles lose their original SONOFF identity. Inside the guest they enumerate as generic "QEMU USB SERIAL" devices. They are still stable, but the by-id name is now keyed on the QEMU USB topology (port path) rather than the original serial:

/dev/serial/by-id/usb-QEMU_QEMU_USB_SERIAL_1-0000:00:01.2-1-if00-port0 (OpenThread, first chardev)
/dev/serial/by-id/usb-QEMU_QEMU_USB_SERIAL_1-0000:00:01.2-2.1-if00-port0 (Zigbee, second chardev)

The order of the chardevs in the args line determines which port path each dongle gets (the first chardev becomes 01.2-1, the second becomes 01.2-2.1). Keep the order consistent if the args are ever edited.

Step 5: Point the Home Assistant integrations at the new paths

Because the in-guest device identity changed (now generic QEMU USB SERIAL), each integration that referenced the old SONOFF path must be updated once to the new QEMU by-id path. The network credentials are NOT affected — only the serial device path changes.

OpenThread Border Router (OTBR)

In the OTBR add-on Configuration tab, change the Device field to the OpenThread QEMU by-id path (...01.2-1-if00-port0) and keep baudrate 460800. Save and restart. The existing Thread dataset/credentials and ULA prefix are preserved; only the device path changes.

Zigbee2MQTT (Z2M)

Update the serial port in /config/zigbee2mqtt/configuration.yaml to the Zigbee QEMU by-id path (...01.2-2.1-if00-port0), adapter: ember, baudrate: 115200. Back up the file first:

cp /config/zigbee2mqtt/configuration.yaml /config/zigbee2mqtt/configuration.yaml.bak-$(date +%Y%m%d-%H%M%S)

Also make sure the Mosquitto broker add-on is running (Z2M needs MQTT). On startup, confirm the log shows the coordinator firmware (e.g. EmberZNet 8.0.2 [GA]) and MQTT discovery publishing, with no EZSP/ASH HOST_FATAL_ERROR. Note: Z2M v2.x shows an Onboarding screen and will not start the coordinator until the onboarding is completed — see Gotchas below.

Gotchas / things future-me will forget

  • args/chardev passthrough is INVISIBLE in the Proxmox Hardware GUI. After applying it, the dongles will NOT show up as usbN entries in the VM's Hardware tab (only whatever GUI USB devices remain, e.g. usb0 and usb3, will be visible). This is expected — the args line is separate from the GUI USB mappings. To see/edit it, look at the args: line in /etc/pve/qemu-server/116.conf.
  • Inside the VM, the dongles appear as generic "QEMU USB SERIAL", NOT as SONOFF. The in-guest /dev/serial/by-id name is keyed on the QEMU USB port path (01.2-1 vs 01.2-2.1), set by chardev order in the args line — not on the original dongle serial.
  • On the host, by-id is serial-based; in the guest, by-id is port-path-based. Both are stable, but for different reasons. Don't expect the original SONOFF serial to appear inside the VM.
  • Zigbee2MQTT v2.x has an Onboarding gate: even with a correct configuration.yaml, Z2M will sit at "Onboarding page is available" and not start the coordinator until the onboarding form is completed (select the coordinator, leave network fields unchanged, Submit). Leaving network fields unchanged means no re-pairing.
  • Reach the Z2M onboarding/web UI via the add-on's "Open Web UI" (Home Assistant Ingress), NOT the raw :8099 port.
  • Hostname/proxy caveat: homeassistant.digilope.com resolves to the nginx reverse proxy, which only serves the proxied HA frontend (443). It will NOT expose arbitrary add-on ports. If a non-proxied port ever needs to be reached directly (e.g. an add-on on a raw port), use the VM's own hostname srv-homeassistant.digilope.com:<port> instead. For Z2M onboarding specifically a raw port isn't needed at all — use Ingress via "Open Web UI".
  • After args changes, do a full qm stop + qm start. A guest-side reboot alone does not re-read the args line.