SLZB-MR5U setup — Zigbee, Thread, WireGuard, VLANs

I've been running the SLZB-MR5U for a while now and figured I'd write up how I have it set up, since I couldn't find a complete guide that covered all the pieces together. The short version: the device runs ESPHome instead of the stock SMLIGHT firmware, sits on an isolated IoT VLAN, and everything talks to Home Assistant over a WireGuard tunnel. Zigbee2MQTT and OTBR never touch the IoT VLAN directly.

It took me a while to get all the moving parts right — especially the iptables DNAT rules and the hassio bridge routing — so hopefully this saves someone else the same headaches. I'll walk through the firmware choice, the flash process, and then the full network and add-on configuration.

Fair warning: this is probably massively over-engineered for what is essentially a Zigbee stick. A direct USB connection would have worked fine. But where's the fun in that.

The other thing I wanted to avoid was punching firewall holes from the IoT VLAN into the rest of my network. The IoT VLAN is isolated for a reason — I don't trust the devices on it. Opening a rule from IoT → HA just so a coordinator can phone home felt like the wrong direction. With WireGuard the device initiates the tunnel outbound, the stream servers only listen on the VPN interface, and nothing on the IoT VLAN can reach the radios directly.


Required add-ons and apps

You need four Home Assistant add-ons and the ESPHome CLI on your workstation.

Add-on GitHub Role
ESPHome esphome/home-assistant-addon Manages the ESP32-S3 firmware, OTA updates, and exposes device entities in HA
WireGuard hassio-addons/addon-wireguard Runs the WireGuard server and the iptables DNAT rules that proxy Zigbee/Thread traffic into the tunnel
Zigbee2MQTT zigbee2mqtt/hassio-zigbee2mqtt Talks to the Zigbee radio over TCP via the WireGuard tunnel
OpenThread Border Router home-assistant/addons Talks to the Thread radio over TCP and bridges the Thread mesh to your IP network for Matter

For the initial USB flash you need ESPHome on your workstation — either the ESPHome CLI (pip install esphome) or by using the ESPHome add-on directly from HA (Dashboard → three dots → Install from USB). After the first flash, OTA updates work through the add-on.


Flashing to ESPHome

Official firmware vs. ESPHome

The SLZB-MR5U ships with SMLIGHT's own firmware which is fine honestly — it has WireGuard, OTA, a decent web UI, and a full HA integration. The one thing I couldn't find is whether it lets you bind the serial stream server to a specific network interface. If it listens on all interfaces, anything on your IoT VLAN can connect to the radio directly — which defeats the point of isolating it. I created a fork of the stream server (sir-Unknown/esphome-stream-server) that adds a bind_wg option, which locks the socket to the WireGuard interface only. That was the deciding factor for me.

Feature Official SMLIGHT firmware ESPHome (smlight-tech/slzb-esphome)
Management Proprietary web UI + cloud ESPHome dashboard / HA integration
WireGuard built-in Yes Yes (via wireguard: component)
Stream server bind to interface Not documented — likely all interfaces Yes — bind_wg restricts to WireGuard
OTA updates SMLIGHT OTA (core + radio chip) ESPHome OTA
Home Assistant sensor entities Full (temp, uptime, RAM, VPN status via SMLIGHT integration) Full (ESP32 temp, uptime, etc.)
Open source No — MR5U source code not published Yes
Network isolation WireGuard present, but stream binding unconfirmed Yes — stream servers on WireGuard only

Flash procedure

The main chip is an ESP32-S3. First flash has to go over USB-C since there's no firmware on it yet to do OTA — after that you can update wirelessly.

Requirements:

  • USB-C cable (data, not charge-only)
  • ESPHome CLI or ESPHome Dashboard
  • slzb-mr5u.yaml from post 2 (update the three IPs and secrets before flashing)

Steps:

  1. Put the device in flash mode:
    Hold the BOOT button on the SLZB-MR5U, connect the USB-C cable to the computer, then release the button. The device appears as a serial port (e.g. /dev/ttyUSB0).

  2. Compile and flash:

    esphome run slzb-mr5u.yaml
    

    ESPHome auto-detects the serial port. On first flash select the USB port; afterwards OTA works.

  3. After the flash:
    The device reboots and joins the ESPHome network. Adopt it via the ESPHome Dashboard or configure Ethernet through the captive portal.

Note: After switching to ESPHome the SMLIGHT web interface is no longer available. Management is fully handled by ESPHome and Home Assistant.


Network layout

Three VLANs are relevant here:

VLAN 10  Home         10.10.0.0/24    Main network. HA primary interface.
VLAN 20  Thread       10.20.0.0/24    Thread/Matter devices + WiFi SSID.
                                      HA dual-homed here for multicast.
                                      Router allows VLAN 20 → HA on VLAN 10
                                      (single HA entry in companion app).
VLAN 30  IoT          10.30.0.0/24    Isolated IoT devices.
                                      SLZB-MR5U lives here (10.30.0.200).
                                      No direct access to VLAN 10 or 20.

The coordinator sits on VLAN 30 with no route to the rest of the network. All traffic between HA and the device goes through the WireGuard tunnel.


SLZB-MR5U (ESPHome)

SLZB-MR5U

The hardware has two radio chips — one for Zigbee, one for Thread — each connected to the ESP32-S3 over a separate UART. The W5500 handles wired Ethernet.

Hardware: ESP32-S3 + 2× EFR32MG24 + W5500 Ethernet.

Radio UART TCP port Client
Zigbee 1 7638 Z2M
Thread 2 6638 OTBR
Bluetooth proxy HA (active, 4 slots)

ESPHome uses my fork of the stream server (sir-Unknown/esphome-stream-server) which adds bind_wg. Both stream servers are bound to the WireGuard interface only — they do not listen on the Ethernet IP (10.30.0.200). Nothing on the IoT VLAN can connect to the radios directly.

Stream server — bind_wg

The upstream stream server component (oxan/esphome-stream-server) binds its TCP socket to 0.0.0.0, meaning it listens on every interface — Ethernet, WiFi, and WireGuard alike. That's fine for most setups, but not when the device is on an isolated VLAN and the whole point is that nothing else on that VLAN should be able to talk to the radios.

I forked oxan/esphome-stream-server and added a single option (sir-Unknown/esphome-stream-server):

stream_server:
  - uart_id: hw_uart1
    port: 7638
    bind_wg: wg0   # restrict socket to the WireGuard interface

When bind_wg is set, the component looks up the IP address assigned to the named WireGuard interface at startup and calls bind() with that IP instead of 0.0.0.0. The result is that the TCP socket only accepts connections arriving on wg0 — the Ethernet port stays completely silent on that port. A port scan from anywhere on the IoT VLAN shows nothing.

The tradeoff is that the stream server won't start accepting connections until the WireGuard tunnel is up and has an IP assigned. In practice this hasn't been an issue — WireGuard comes up within a few seconds of boot, well before Z2M or OTBR try to connect.


ESPHome configuration

Full slzb-mr5u.yaml is in the second post. The only things you'll need to change for your own setup are the three IPs in the substitutions block at the top (eth_static_ip, eth_gateway, wg_server_host) and the four secrets (api_encryption_key, ota_password, slzb_mr5u_wg_private_key, hass_wg_public_key, slzb_mr5u_wg_preshared_key).


WireGuard tunnel

ESP32          172.27.66.2  ←——— WireGuard tunnel ———→  172.27.66.1  HA WireGuard add-on
(IoT VLAN 30)  10.30.0.200                               10.30.0.10   (VLAN 10)

The ESP32 initiates the connection to HA at 10.30.0.10:51820 — so traffic flows outbound from the IoT VLAN, no inbound firewall rule needed. A few things worth noting:

  • peer_allowed_ips: 172.27.66.1/32 — only tunnel traffic for the HA peer, nothing else is routed through the tunnel.
  • peer_persistent_keepalive: 25s — keeps the NAT mapping alive.
  • reboot_timeout: 0s — device does not reboot if the tunnel drops; it reconnects automatically.

One gotcha: the WireGuard add-on runs with host_network: false, which means wg0 only exists inside the add-on's container. It's not visible from the HA host or other add-ons — which causes the routing problem described in the next section.


How Z2M and OTBR reach the radios

Because wg0 lives inside the WireGuard add-on container, Z2M and OTBR can't reach 172.27.66.2 directly. The fix is iptables DNAT rules in the WireGuard container that proxy incoming TCP connections from the hassio bridge through wg0 to the ESP32.

Z2M (172.30.33.4)  ──┐
                      ├──→  WireGuard add-on (172.30.33.2)
OTBR (host net)  ────┘         DNAT :7638 → 172.27.66.2:7638
                                DNAT :6638 → 172.27.66.2:6638
                                MASQUERADE -o wg0  (src becomes 172.27.66.1)
                                     │
                                     └──→  wg0 → tunnel → ESP32

The MASQUERADE -o wg0 rule is the part that tripped me up initially. The ESP32 only has a route back to 172.27.66.0/24 — if the source IP of an incoming connection is a hassio bridge address the return packets go nowhere. Masquerading makes everything look like it comes from 172.27.66.1.

OTBR runs with host_network: true so it shares the host's network namespace and can reach the hassio bridge at 172.30.33.2. Z2M runs with host_network: false but it's on the same hassio bridge as the WireGuard add-on, so it can reach 172.30.33.2 directly.

WireGuard add-on — full config

server:
  host: homeassistant2.local
  addresses:
    - 172.27.66.1
  dns: []
  post_up: >-
    iptables -A FORWARD -i %i -j ACCEPT; iptables -A FORWARD -o %i -j ACCEPT;
    iptables -t nat -A POSTROUTING -o eth0 -j MASQUERADE; iptables -t nat -A
    POSTROUTING -o %i -j MASQUERADE; iptables -t nat -A PREROUTING -p tcp
    --dport 6638 -j DNAT --to-destination 172.27.66.2:6638; iptables -t nat -A
    PREROUTING -p tcp --dport 7638 -j DNAT --to-destination 172.27.66.2:7638
  post_down: >-
    iptables -D FORWARD -i %i -j ACCEPT; iptables -D FORWARD -o %i -j ACCEPT;
    iptables -t nat -D POSTROUTING -o eth0 -j MASQUERADE; iptables -t nat -D
    POSTROUTING -o %i -j MASQUERADE; iptables -t nat -D PREROUTING -p tcp
    --dport 6638 -j DNAT --to-destination 172.27.66.2:6638; iptables -t nat -D
    PREROUTING -p tcp --dport 7638 -j DNAT --to-destination 172.27.66.2:7638
peers:
  - name: SLZB-MR5U
    public_key: <your-public-key>
    addresses:
      - 172.27.66.2
    allowed_ips:
      - 172.27.66.2/32
    client_allowed_ips:
      - 172.27.66.0/24
    pre_shared_key: <your-pre-shared-key>

Zigbee2MQTT — serial config

serial:
  port: tcp://172.30.33.2:7638
  baudrate: 115200          # must match uart1_baud in ESPHome substitutions
  rtscts: false
  adapter: ember

OTBR add-on config

device: /dev/ttyS2
baudrate: "460800"
flow_control: true
otbr_log_level: notice
firewall: true
nat64: false
beta: true
network_device: 172.30.33.2:6638
backbone_interface: enp6s19   # HA's VLAN 20 interface

device, baudrate, and flow_control are present but ignored when network_device is set — OTBR uses the TCP socket instead of the local serial port.


Thread network — why VLAN 20

Thread uses 802.15.4 radio, but Matter over Thread relies on IPv6 multicast for device discovery. The OTBR bridges the Thread mesh to your IP network, and for that multicast to flow properly the backbone interface needs to be on the same L2 segment as your Thread devices.

My Thread devices are on a dedicated WiFi SSID on VLAN 20. HA is dual-homed on that VLAN so:

  • enp6s19 (VLAN 20) is the OTBR backbone interface.
  • Thread/Matter devices on the VLAN 20 WiFi SSID are reachable via multicast.
  • The router allows VLAN 20 → HA on VLAN 10 so the companion app only needs one HA address.

Startup ordering

There's a race condition on boot: OTBR starts before the WireGuard add-on has finished applying its iptables rules, so it briefly can't reach the radio and logs "No route to host". It recovers on its own eventually, but I got tired of seeing the error so I added an automation that restarts OTBR a minute after HA starts — by then WireGuard is always up.

- id: restart_otbr_after_startup
  alias: Restart OTBR after startup
  triggers:
    - trigger: homeassistant
      event: start
  actions:
    - delay: "00:01:00"
    - action: hassio.addon_restart
      data:
        addon: core_openthread_border_router
  mode: single

Key IPs and ports

For reference, here's everything in one place:

What Address
SLZB-MR5U Ethernet 10.30.0.200
SLZB-MR5U WireGuard 172.27.66.2
HA WireGuard server 10.30.0.10:51820
HA WireGuard tunnel IP 172.27.66.1
WireGuard add-on hassio IP 172.30.33.2
Zigbee stream (Z2M) 172.30.33.2:7638
Thread stream (OTBR) 172.30.33.2:6638
OTBR backbone interface enp6s19 (VLAN 20)

The WireGuard add-on's hassio IP can change after a restart. To check the current value:

ha addons info a0d7b954_wireguard | grep ip_address

That's the whole setup. It's a lot of moving parts for a coordinator, but once it's running it's been rock solid — Zigbee and Thread both stable, no firewall holes, and the device is fully managed from HA. If something does break, the WireGuard handshake timestamp and the TCP connected sensors in HA are usually enough to tell you where the problem is. Hope this helps someone.

1 Like