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

slzb-mr5u.yaml

##############################################################
#  SLZB-MR5U  —  SMLIGHT dual-radio coordinator
#  Radio 1 : Zigbee  (EFR32MG24, UART1, TCP port 7638)
#  Radio 2 : Thread  (EFR32MG24, UART2, TCP port 6638)
#  MCU     : ESP32-S3 with 16 MB flash and quad PSRAM
#  Network : W5500 SPI Ethernet (static IP)
#  Extras  : Bluetooth proxy, PoE, USB-C
#
#  Reference: https://github.com/smlight-tech/slzb-esphome
#  Hardware:  mrxu-r1-73xx  (hw_defs/mrxu/r1_73.yaml)
##############################################################

substitutions:
  # ── Identity ───────────────────────────────────────────────
  # device_name becomes the ESPHome node name (hostname, API ID).
  # friendly_name is shown in the Home Assistant UI.
  device_name: slzb-mr5u
  friendly_name: "SLZB-MR5U"

  # ── Radio labels ───────────────────────────────────────────
  # These strings appear in every entity name for the two radios.
  # Change them if you repurpose a UART for a different protocol.
  uart1_radio_name: Zigbee
  uart2_radio_name: Thread

  # ── ESP32-S3 platform ──────────────────────────────────────
  esp32_board: esp32-s3-devkitc-1   # board definition used by the toolchain
  esp32_variant: esp32s3             # explicit variant so ESPHome enables S3 features
  esp32_flash_size: 16MB             # must match the physical flash on the module
  esp32_cpu_frequency: 240MHz        # maximum frequency for ESP32-S3
  esp32_watchdog_timeout: 30s        # reboot if the main loop stalls longer than this
  esp32_psram_mode: quad             # octal PSRAM is not available on this module
  esp32_psram_speed: 80MHz           # safe speed for quad PSRAM on ESP32-S3

  # ── Bluetooth proxy ────────────────────────────────────────
  # Ethernet devices have no WiFi overhead, so 4 slots is safe.
  # Both esp32_ble max_connections and bluetooth_proxy connection_slots
  # must be set to the same value.
  ble_connection_slots: "4"

  # ── Buffers ────────────────────────────────────────────────
  # UART rx_buffer_size and stream_server buffer_size must match to
  # avoid data loss during bursts from the radio chips.
  uart_buffer_size: "8192"

  # ── Radio auto-reset ───────────────────────────────────────
  # When a TCP client disconnects, the radio is reset after radio_reset_delay
  # to prevent a stale coordinator state. The RST line is held low for
  # radio_reset_pulse milliseconds — long enough for a clean EFR32 reset.
  radio_reset_delay: 5min
  radio_reset_pulse: 200ms

  # ── Safe mode ──────────────────────────────────────────────
  # Number of consecutive boot failures before the device enters safe mode
  # (firmware-only boot, no user components) to allow OTA recovery.
  safe_mode_attempts: "5"

  # ── Network — static IP ────────────────────────────────────
  eth_static_ip: "10.0.65.233"
  eth_gateway: "10.0.65.1"
  eth_subnet: "255.255.255.0"

  # ── Button ─────────────────────────────────────────────────
  # GPIO0 is the boot/user button on the ESP32-S3 devkit.
  # It is an active-low strapping pin; INPUT_PULLUP + inverted=true
  # gives a logical HIGH when not pressed.
  pin_btn1: "GPIO0"
  btn1_inverted: "true"

  # ── Status LEDs ────────────────────────────────────────────
  # LED1 (GPIO46) mirrors the Zigbee TCP connection status.
  # LED2 (GPIO45) mirrors the Thread TCP connection status.
  # Both GPIOs are strapping pins; the LEDs are open-drain with
  # inverted logic (HIGH = off).  ALWAYS_OFF prevents accidental
  # restores that could conflict with the stream_server automation.
  pin_led1: "GPIO46"
  led1_inverted: "true"
  led1_restore_mode: ALWAYS_OFF
  pin_led2: "GPIO45"
  led2_inverted: "true"
  led2_restore_mode: ALWAYS_OFF

  # ── I2C (defined but unused) ───────────────────────────────
  # The SLZB-MR5U PCB routes I2C to the expansion header, but
  # has no I2C devices on board.  Pins are listed for reference.
  pin_i2c_scl: "GPIO43"
  pin_i2c_sda: "GPIO44"

  # ── Ethernet — W5500 SPI ───────────────────────────────────
  pin_spi_miso: "GPIO41"
  pin_spi_mosi: "GPIO39"
  pin_spi_sclk: "GPIO42"
  pin_w5500_cs: "GPIO2"    # chip select
  pin_w5500_int: "GPIO38"  # interrupt — allows event-driven packet handling
  pin_w5500_rst: "GPIO40"  # hardware reset for the W5500
  pin_rj45_leds: "GPIO1"   # controls the RJ45 link/activity LEDs via GPIO
  rj45_leds_inverted: "true"

  # ── USB ────────────────────────────────────────────────────
  # USB Device-Host mux: selects whether the USB-C port acts as
  # a device (flash/serial) or host (USB dongle).  Default OFF = device mode.
  pin_usb_device_host: "GPIO47"
  usb_device_host_inverted: "true"
  # PoE to USB power: routes PoE-derived 5 V to the USB-A/C port.
  # Keep OFF unless you intend to power a USB peripheral from PoE.
  pin_poe_to_usb_power: "GPIO48"
  poe_to_usb_power_inverted: "false"
  # USB-C CC pins are read via ADC to detect cable orientation and
  # power-delivery role (host/device, voltage negotiation).
  pin_usbc_cc1_adc: "GPIO4"
  pin_usbc_cc2_adc: "GPIO5"

  # ── UART1 — Zigbee radio (EFR32MG24) ──────────────────────
  # Note: ESPHome's UART component does not support hardware flow control
  # (RTS/CTS).  The pins are listed here for documentation only; they are
  # not used in the uart: block.
  pin_uart1_tx: "GPIO17"
  pin_uart1_rx: "GPIO18"
  pin_uart1_rts: "GPIO14"  # hardware RTS — not wired in ESPHome UART
  pin_uart1_cts: "GPIO15"  # hardware CTS — not wired in ESPHome UART
  uart1_baud: "115200"
  uart1_default_port: "7638"  # TCP port exposed by the stream server
  pin_uart1_rst: "GPIO21"     # active-low reset for the EFR32
  uart1_rst_inverted: "true"
  pin_uart1_flash: "GPIO16"   # pull low to enter EFR32 firmware-update mode
  uart1_flash_inverted: "true"

  # ── UART2 — Thread radio (EFR32MG24) ──────────────────────
  pin_uart2_tx: "GPIO10"
  pin_uart2_rx: "GPIO8"
  pin_uart2_rts: "GPIO12"  # hardware RTS — not wired in ESPHome UART
  pin_uart2_cts: "GPIO11"  # hardware CTS — not wired in ESPHome UART
  uart2_baud: "460800"
  uart2_default_port: "6638"  # TCP port exposed by the stream server
  pin_uart2_rst: "GPIO13"
  uart2_rst_inverted: "true"
  pin_uart2_flash: "GPIO9"
  uart2_flash_inverted: "true"

  # ── WireGuard VPN ──────────────────────────────────────────
  wg_id: wg0                         # component ID — referenced by stream_server bind_wg
  wg_address: "172.27.66.2"          # IP assigned to the ESP32 WireGuard interface
  wg_ha_address: "172.27.66.1/32"    # HA host WireGuard IP — only peer allowed through tunnel
  wg_netmask: "255.255.255.0"
  wg_server_host: "10.0.65.10"       # LAN-IP of HA host
  wg_server_port: "51820"

# ── ESPHome core ────────────────────────────────────────────

esphome:
  name: ${device_name}
  friendly_name: ${friendly_name}
  name_add_mac_suffix: false  # fixed hostname — device has a static IP
  project:
    name: SMLIGHT.${device_name}
    version: "1.00"
  on_boot:
    # Enable hardware RTS/CTS flow control on both radio UARTs.
    # ESPHome's uart: component does not support rts_pin/cts_pin, so we
    # configure them directly via ESP-IDF after UART setup completes.
    - lambda: |-
        uart_port_t u1 = (uart_port_t) id(hw_uart1).get_hw_serial_number();
        uart_set_pin(u1, 17, 18, 14, 15);  // TX, RX, RTS, CTS — UART1 (Zigbee)
        uart_set_hw_flow_ctrl(u1, UART_HW_FLOWCTRL_CTS_RTS, 122);
        uart_port_t u2 = (uart_port_t) id(hw_uart2).get_hw_serial_number();
        uart_set_pin(u2, 10, 8, 12, 11);   // TX, RX, RTS, CTS — UART2 (Thread)
        uart_set_hw_flow_ctrl(u2, UART_HW_FLOWCTRL_CTS_RTS, 122);
    # Push the TCP port numbers to HA on every boot so they are always
    # current even if the port substitutions are changed and reflashed.
    - component.update: uart1_port_sensor
    - component.update: uart2_port_sensor
    - component.update: wg_ip_sensor

# ── ESP32-S3 platform ───────────────────────────────────────

esp32:
  board: ${esp32_board}
  variant: ${esp32_variant}
  flash_size: ${esp32_flash_size}
  cpu_frequency: ${esp32_cpu_frequency}
  # Reboot automatically if the watchdog is not fed within this period.
  # 30 s gives enough headroom for slow OTA writes without being too long
  # to recover from a genuine hang.
  watchdog_timeout: ${esp32_watchdog_timeout}
  framework:
    type: esp-idf
    advanced:
      # Run frequently-called code from PSRAM instead of IRAM, freeing
      # IRAM for the BLE/WiFi stack.  Beneficial on PSRAM-equipped S3 boards.
      execute_from_psram: true
    sdkconfig_options:
      # The default lwIP socket limit (10) is too low when the stream servers,
      # BLE proxy, and HA API connection are all open simultaneously.
      CONFIG_LWIP_MAX_SOCKETS: "16"

# ── PSRAM ───────────────────────────────────────────────────

psram:
  mode: ${esp32_psram_mode}   # quad-SPI PSRAM on this module
  speed: ${esp32_psram_speed}

# ── Logging ─────────────────────────────────────────────────

logger:
  # Disable UART logging (baud_rate: 0) so the hardware UART pins are
  # not occupied by the logger and remain available for the radio UARTs.
  baud_rate: 0
  # INFO suppresses verbose DEBUG output in production; change to DEBUG
  # temporarily when diagnosing issues.
  level: INFO

# ── Home Assistant API ──────────────────────────────────────

api:
  encryption:
    key: !secret api_encryption_key
  # Disable automatic reboot when HA is unreachable — the device must
  # keep serving TCP streams even if HA is temporarily offline.
  reboot_timeout: 0s

# ── OTA updates ─────────────────────────────────────────────

ota:
  - platform: esphome
    password: !secret ota_password

# ── Time ────────────────────────────────────────────────────

time:
  - platform: sntp
    id: sntp_time
    servers:
      - 0.nl.pool.ntp.org
      - 1.nl.pool.ntp.org
      - 2.nl.pool.ntp.org

# ── External components ─────────────────────────────────────

external_components:
  # stream_server exposes a UART as a raw TCP socket server.
  # Required for Zigbee2MQTT (zigbee) and OTBR / HA Thread Border Router (Thread)
  # to connect over the network.  Native serial_proxy is not suitable here
  # because it wraps data in the ESPHome API protocol.
  # - source: github://oxan/esphome-stream-server
  - source: github://sir-Unknown/esphome-stream-server

# ── Network — Ethernet (W5500 SPI) ─────────────────────────

ethernet:
  type: W5500
  clk_pin: ${pin_spi_sclk}
  mosi_pin: ${pin_spi_mosi}
  miso_pin: ${pin_spi_miso}
  cs_pin: ${pin_w5500_cs}
  interrupt_pin: ${pin_w5500_int}
  reset_pin: ${pin_w5500_rst}
  # use_address overrides the DNS/mDNS lookup ESPHome uses during OTA/API.
  # Set it to the static IP so the device is reachable even if mDNS fails.
  use_address: ${eth_static_ip}
  manual_ip:
    static_ip: ${eth_static_ip}
    gateway: ${eth_gateway}
    subnet: ${eth_subnet}
    dns1: ${eth_gateway}

# ── Network performance ─────────────────────────────────────

network:
  # Allocates larger TCP/IP buffers from PSRAM instead of internal SRAM.
  # Improves throughput for the stream server TCP connections on PSRAM boards.
  enable_high_performance: true

# ── WireGuard VPN ───────────────────────────────────────────

wireguard:
  id: ${wg_id}
  address: ${wg_address}
  netmask: ${wg_netmask}
  private_key: !secret slzb_mr5u_wg_private_key
  peer_public_key: !secret hass_wg_public_key
  # Preshared key adds a post-quantum symmetric encryption layer on top of
  # the public key handshake. Generate with: openssl rand -base64 32
  # Must match the preshared_key in the HA WireGuard add-on peer config.
  peer_preshared_key: !secret slzb_mr5u_wg_preshared_key
  peer_endpoint: ${wg_server_host}
  peer_port: ${wg_server_port}
  # Only allow traffic from the HA host through the tunnel.
  peer_allowed_ips:
    - ${wg_ha_address}
  peer_persistent_keepalive: 25s
  reboot_timeout: 0s


# ── Bluetooth proxy ─────────────────────────────────────────

esp32_ble:
  # Total BLE connection pool.  Must equal bluetooth_proxy connection_slots.
  max_connections: ${ble_connection_slots}

esp32_ble_tracker:
  # Passive scanning uses less power and does not disturb BLE peripherals.
  # Default scan parameters are used (interval ~320 ms, window ~30 ms).

bluetooth_proxy:
  # active: true enables two-way BLE communication (not just passive advertisement).
  active: true
  # Ethernet devices can safely handle more simultaneous BLE connections
  # than WiFi devices because there is no shared radio contention.
  connection_slots: ${ble_connection_slots}

# ── I2C (not used on SLZB-MR5U) ────────────────────────────

# The I2C bus is present on the PCB but the SLZB-MR5U has no I2C devices
# on board.  The TCA9555 IO-expander packages in slzb-esphome target other
# SMLIGHT models (e.g. Ultima).  Enable only if you attach an external I2C
# peripheral to the expansion header.
# i2c:
#   sda: ${pin_i2c_sda}
#   scl: ${pin_i2c_scl}
#   scan: true

# ── UART buses ──────────────────────────────────────────────

uart:
  # UART1 → Zigbee radio (EFR32MG24)
  # Hardware flow control (RTS/CTS) is not supported by the ESPHome UART
  # component.  The RTS/CTS pins are defined in substitutions for reference
  # but are not connected here.
  - id: hw_uart1
    tx_pin: ${pin_uart1_tx}
    rx_pin: ${pin_uart1_rx}
    baud_rate: ${uart1_baud}
    # rx_buffer_size must match the stream_server buffer_size to prevent
    # overflow when the coordinator sends bursts of Zigbee frames.
    rx_buffer_size: ${uart_buffer_size}

  # UART2 → Thread radio (EFR32MG24)
  - id: hw_uart2
    tx_pin: ${pin_uart2_tx}
    rx_pin: ${pin_uart2_rx}
    baud_rate: ${uart2_baud}
    rx_buffer_size: ${uart_buffer_size}

# ── Radio stream servers ─────────────────────────────────────

stream_server:
  # Exposes hw_uart1 (Zigbee) as a raw TCP socket on uart1_default_port.
  # Zigbee2MQTT connects here via tcp://<wg-addon-hassio-ip>:7638.
  # bind_wg restricts the socket to the WireGuard interface only —
  # requires a patched stream_server component (see: bind_wg feature PR).
  - id: ss_uart1
    uart_id: hw_uart1
    port: ${uart1_default_port}
    buffer_size: ${uart_buffer_size}
    bind_wg: ${wg_id}

  # Exposes hw_uart2 (Thread) as a raw TCP socket on uart2_default_port.
  # OTBR connects here via network_device: <wg-addon-hassio-ip>:6638.
  - id: ss_uart2
    uart_id: hw_uart2
    port: ${uart2_default_port}
    buffer_size: ${uart_buffer_size}
    bind_wg: ${wg_id}

# ── Binary sensors ───────────────────────────────────────────

binary_sensor:
  # Physical button on GPIO0.
  # Hold 3–9 s  → software restart.
  # Hold ≥ 10 s → factory reset (clears all stored settings).
  - platform: gpio
    id: btn1
    name: "${friendly_name} Hardware Button"
    entity_category: diagnostic
    disabled_by_default: true
    pin:
      number: ${pin_btn1}
      mode: INPUT_PULLUP
      inverted: ${btn1_inverted}
    filters:
      - delayed_on: 20ms   # debounce: ignore pulses shorter than 20 ms
      - delayed_off: 20ms
    on_multi_click:
      - timing:
          - ON for at least 10s
        then:
          - button.press: factory_reset_btn
      - timing:
          - ON for 3s to 9s
        then:
          - button.press: restart_btn

  # Zigbee TCP connection status (ss_uart1).
  # on_press   : lights LED1, cancels any pending auto-reset.
  # on_release : turns off LED1, starts the auto-reset countdown.
  - platform: stream_server
    stream_server: ss_uart1
    connected:
      name: "${friendly_name} ${uart1_radio_name} TCP Connected"
      device_class: connectivity
      entity_category: diagnostic
      on_press:
        - switch.turn_on: led1
        - script.stop: auto_reset_zigbee
      on_release:
        - switch.turn_off: led1
        - script.execute: auto_reset_zigbee

  # Thread TCP connection status (ss_uart2).
  - platform: stream_server
    stream_server: ss_uart2
    connected:
      name: "${friendly_name} ${uart2_radio_name} TCP Connected"
      device_class: connectivity
      entity_category: diagnostic
      on_press:
        - switch.turn_on: led2
        - script.stop: auto_reset_thread
      on_release:
        - switch.turn_off: led2
        - script.execute: auto_reset_thread

  # Reports whether the W5500 Ethernet link is up and an IP address is assigned.
  - platform: template
    name: "${friendly_name} Ethernet Connected"
    device_class: connectivity
    entity_category: diagnostic
    lambda: return network::is_connected();

  # WireGuard peer status.
  - platform: wireguard
    status:
      name: "${friendly_name} WireGuard Connected"
      device_class: connectivity
      entity_category: diagnostic

# ── Switches ─────────────────────────────────────────────────

switch:
  # LED1 — Zigbee connection indicator.
  # Controlled automatically by the stream_server binary sensor.
  # restore_mode ALWAYS_OFF prevents the LED from turning on at boot
  # before a TCP client has actually connected.
  - platform: gpio
    id: led1
    name: "${friendly_name} Zigbee Status LED"
    restore_mode: ${led1_restore_mode}
    entity_category: config
    icon: mdi:led-on
    pin:
      number: ${pin_led1}
      mode: OUTPUT
      inverted: ${led1_inverted}

  # LED2 — Thread connection indicator.
  - platform: gpio
    id: led2
    name: "${friendly_name} Thread Status LED"
    restore_mode: ${led2_restore_mode}
    entity_category: config
    icon: mdi:led-on
    pin:
      number: ${pin_led2}
      mode: OUTPUT
      inverted: ${led2_inverted}

  # Controls the two LEDs in the RJ45 connector (link/activity).
  # Default ON so the LEDs work out of the box; disable if you want
  # a darker installation.
  - platform: gpio
    id: rj45_leds
    name: "${friendly_name} RJ45 LEDs"
    restore_mode: ALWAYS_ON
    entity_category: config
    icon: mdi:ethernet
    pin:
      number: ${pin_rj45_leds}
      mode: OUTPUT
      inverted: ${rj45_leds_inverted}

  # Selects USB-C port direction: OFF = device mode (flash/serial via PC),
  # ON = host mode (attach a USB dongle to the device).
  - platform: gpio
    id: usb_device_host
    name: "${friendly_name} USB-C Mode"
    restore_mode: ALWAYS_OFF
    entity_category: config
    icon: mdi:usb
    disabled_by_default: true
    pin:
      number: ${pin_usb_device_host}
      mode: OUTPUT
      inverted: ${usb_device_host_inverted}

  # Routes PoE-derived 5 V to the USB-A/C port so a USB peripheral
  # can be powered from PoE.  Keep OFF unless explicitly needed.
  - platform: gpio
    id: poe_to_usb_power
    name: "${friendly_name} PoE to USB Power"
    restore_mode: ALWAYS_OFF
    entity_category: config
    icon: mdi:power-plug
    disabled_by_default: true
    pin:
      number: ${pin_poe_to_usb_power}
      mode: OUTPUT
      inverted: ${poe_to_usb_power_inverted}

  # Holds the Zigbee EFR32 RST line low while ON.
  # Used by the auto-reset script and for manual resets via HA.
  - platform: gpio
    id: uart1_rst
    name: "${friendly_name} ${uart1_radio_name} Reset"
    restore_mode: ALWAYS_OFF
    entity_category: config
    icon: mdi:restart
    disabled_by_default: true
    pin:
      number: ${pin_uart1_rst}
      mode: OUTPUT
      inverted: ${uart1_rst_inverted}

  # Holds the Zigbee EFR32 FLASH/BOOT pin low while ON.
  # Must be held low together with RST to enter firmware-update mode
  # (used by universal-silabs-flasher for NabuCasa firmware upgrades).
  - platform: gpio
    id: uart1_flash
    name: "${friendly_name} ${uart1_radio_name} Flash Mode"
    restore_mode: ALWAYS_OFF
    entity_category: config
    icon: mdi:memory
    disabled_by_default: true
    pin:
      number: ${pin_uart1_flash}
      mode: OUTPUT
      inverted: ${uart1_flash_inverted}

  # Thread EFR32 RST — same role as uart1_rst for the Thread radio.
  - platform: gpio
    id: uart2_rst
    name: "${friendly_name} ${uart2_radio_name} Reset"
    restore_mode: ALWAYS_OFF
    entity_category: config
    icon: mdi:restart
    disabled_by_default: true
    pin:
      number: ${pin_uart2_rst}
      mode: OUTPUT
      inverted: ${uart2_rst_inverted}

  # Thread EFR32 FLASH — same role as uart1_flash for the Thread radio.
  - platform: gpio
    id: uart2_flash
    name: "${friendly_name} ${uart2_radio_name} Flash Mode"
    restore_mode: ALWAYS_OFF
    entity_category: config
    icon: mdi:memory
    disabled_by_default: true
    pin:
      number: ${pin_uart2_flash}
      mode: OUTPUT
      inverted: ${uart2_flash_inverted}

# ── Scripts — auto-reset radio on TCP disconnect ─────────────

script:
  # When Zigbee2MQTT disconnects (e.g. after a crash or HA restart),
  # wait radio_reset_delay before pulsing RST so the coordinator comes
  # up clean for the next connection.  mode: restart ensures a new
  # disconnect event resets the countdown.
  - id: auto_reset_zigbee
    mode: restart
    then:
      - delay: ${radio_reset_delay}
      - switch.turn_on: uart1_rst
      - delay: ${radio_reset_pulse}
      - switch.turn_off: uart1_rst

  # Same for the Thread radio (OTBR / HA Thread Border Router).
  - id: auto_reset_thread
    mode: restart
    then:
      - delay: ${radio_reset_delay}
      - switch.turn_on: uart2_rst
      - delay: ${radio_reset_pulse}
      - switch.turn_off: uart2_rst

# ── Debug component ──────────────────────────────────────────

debug:
  # Enables the debug sensor platform (heap, PSRAM, loop time, etc.)
  # All debug sensors are disabled_by_default and only enabled when needed.

# ── Sensors ──────────────────────────────────────────────────

sensor:
  # Reports the timestamp of the last reboot as a HA sensor.
  - platform: uptime
    type: timestamp
    name: "${friendly_name} Last Restart"
    entity_category: diagnostic

  # Internal ESP32-S3 temperature sensor — useful to detect thermal issues.
  - platform: internal_temperature
    name: "${friendly_name} Internal Temperature"
    entity_category: diagnostic

  # Debug sensors — all disabled by default to avoid cluttering the UI.
  # Enable individually in HA when diagnosing memory or performance issues.
  - platform: debug
    free:
      name: "${friendly_name} Free Heap"          # current free heap in bytes
      entity_category: diagnostic
      disabled_by_default: true
    min_free:
      name: "${friendly_name} Min Free Heap"       # lowest heap seen since boot
      entity_category: diagnostic
      disabled_by_default: true
    block:
      name: "${friendly_name} Max Block"           # largest contiguous free block
      entity_category: diagnostic
      disabled_by_default: true
    fragmentation:
      name: "${friendly_name} Heap Fragmentation"  # 0 % = no fragmentation
      entity_category: diagnostic
      disabled_by_default: true
    psram:
      name: "${friendly_name} Free PSRAM"          # free external PSRAM in bytes
      entity_category: diagnostic
      disabled_by_default: true
    loop_time:
      name: "${friendly_name} Loop Time"           # main loop iteration time in ms
      entity_category: diagnostic
      disabled_by_default: true
    cpu_frequency:
      name: "${friendly_name} CPU Frequency"       # confirms actual running frequency
      entity_category: diagnostic
      disabled_by_default: true

  # USB-C CC pin voltages.  CC1/CC2 are used by the USB-C spec to negotiate
  # cable orientation and power role.  Normally ~0 V or ~1 V depending on
  # what is connected.  Disabled by default; enable when debugging USB-C issues.
  - platform: adc
    id: usbc_cc1
    name: "${friendly_name} USB-C CC1 ADC"
    pin: ${pin_usbc_cc1_adc}
    update_interval: 30s
    internal: true
  - platform: adc
    id: usbc_cc2
    name: "${friendly_name} USB-C CC2 ADC"
    pin: ${pin_usbc_cc2_adc}
    update_interval: 30s
    internal: true

  - platform: wireguard
    latest_handshake:
      name: "${friendly_name} WireGuard Latest Handshake"
      entity_category: diagnostic
      disabled_by_default: true

# ── Text sensors ─────────────────────────────────────────────

text_sensor:
  # Debug text sensors.
  - platform: debug
    device:
      # Full chip/SDK info string — useful for bug reports.
      name: "${friendly_name} Device Info"
      entity_category: diagnostic
      disabled_by_default: true
    reset_reason:
      # Reason for the last reboot (power-on, watchdog, OTA, panic, etc.)
      name: "${friendly_name} Reset Reason"
      entity_category: diagnostic

  # ESPHome firmware version running on the device.
  - platform: version
    name: "${friendly_name} ESPHome Version"
    entity_category: diagnostic

  # Current IP address assigned to the W5500 interface.
  - platform: ethernet_info
    ip_address:
      name: "${friendly_name} IP Address"
      entity_category: diagnostic

  # WireGuard IP assigned to this device — useful when connecting via the tunnel.
  - platform: template
    id: wg_ip_sensor
    name: "${friendly_name} WireGuard IP"
    lambda: return std::string("${wg_address}");
    update_interval: never
    entity_category: diagnostic
    icon: mdi:vpn

  # Exposes the configured TCP port numbers as HA entities so that
  # automation and the UI always show the active port, even if changed.
  # Values are pushed on boot via esphome.on_boot.
  - platform: template
    id: uart1_port_sensor
    name: "${friendly_name} ${uart1_radio_name} TCP Port"
    lambda: return std::string("${uart1_default_port}");
    update_interval: never
    entity_category: diagnostic
  - platform: template
    id: uart2_port_sensor
    name: "${friendly_name} ${uart2_radio_name} TCP Port"
    lambda: return std::string("${uart2_default_port}");
    update_interval: never
    entity_category: diagnostic

# ── Runtime baud rate selector ───────────────────────────────

select:
  # Allows changing the Zigbee UART baud rate at runtime without reflashing.
  # restore_value: true remembers the last setting across reboots.
  # The action flushes pending data, then applies the new rate live.
  - id: change_baud_rate_uart1
    name: "${friendly_name} ${uart1_radio_name} baud rate"
    platform: template
    options: ["115200", "230400", "460800", "921600"]
    initial_option: "115200"  # must match uart1_baud substitution
    optimistic: true
    restore_value: true
    entity_category: config
    disabled_by_default: true
    icon: mdi:swap-horizontal
    set_action:
      - lambda: |-
          id(hw_uart1).flush();
          uint32_t br = stoi(x);
          if (id(hw_uart1).get_baud_rate() != br) {
            id(hw_uart1).set_baud_rate(br);
            id(hw_uart1).load_settings();
          }

  # Same for the Thread UART.
  - id: change_baud_rate_uart2
    name: "${friendly_name} ${uart2_radio_name} baud rate"
    platform: template
    options: ["115200", "230400", "460800", "921600"]
    initial_option: "460800"
    optimistic: true
    restore_value: true
    entity_category: config
    disabled_by_default: true
    icon: mdi:swap-horizontal
    set_action:
      - lambda: |-
          id(hw_uart2).flush();
          uint32_t br = stoi(x);
          if (id(hw_uart2).get_baud_rate() != br) {
            id(hw_uart2).set_baud_rate(br);
            id(hw_uart2).load_settings();
          }

# ── Safe mode ────────────────────────────────────────────────

safe_mode:
  # After safe_mode_attempts consecutive crashes at startup, ESPHome boots
  # with only the core components active (no user YAML), making it possible
  # to recover via OTA even if a bad config causes a boot loop.
  num_attempts: ${safe_mode_attempts}

# ── Buttons ──────────────────────────────────────────────────

button:
  # Soft restart — equivalent to power-cycling the ESP32-S3.
  # Also triggered by holding the physical button 3–9 s.
  - platform: restart
    id: restart_btn
    name: "${friendly_name} Restart"
    entity_category: config

  # Factory reset — erases all stored preferences (WiFi credentials,
  # select/switch states, etc.) and reboots.  Hidden by default to
  # prevent accidental triggering.
  - platform: factory_reset
    id: factory_reset_btn
    name: "${friendly_name} Factory Reset"
    entity_category: config
    disabled_by_default: true

# ── Optional local web UI ────────────────────────────────────
# Uncomment to enable a minimal read-only web interface on port 80.
# web_server:
#   port: 80
#   local: true

I'll probably stick to the stock smlight firmware though, your setup seems rather complicated.
I do have vlans and isolation, but matter and zigbee are not on my IoT devices vlan, so I will keep using default setup with very tight opnsense rules.

But very nice guide nevertheless, thanks.

I'm rocking mr3u, its smaller brother, rock solid for me with stock!