ESPHome on SONOFF Dongle Max

SONOFF Dongle-M (Dongle Max) is a Zigbee/Thread adapter based on ESP32-D0WDR2 Wi-Fi SoC and EFR32MG24 2.4 GHz wireless SoC. It supports ethernet, Wi-Fi, and USB connectivity, allowing it to be deployed flexibly with open source smart home platforms.

With custom ESPHome firmware, ESP32 can be used as an ESPHome node while EFR32MG24 continues to provide Zigbee or Thread radio functionality. Flashing ESPHome firmware turns the Dongle-M’s ESP32 into a fully customizable ESPHome device.

Note: ESPHome firmware runs on ESP32 SoC of Dongle-M. It does not automatically replace the firmware of EFR32MG24 SoC.

WARNING: Building and running ESPHome firmware on Dongle-M is experimental and for DIY’ers only. ESPHome firmware is not necessarily more stable than Dongle-M’s official firmware. Please don’t proceed if you don’t know what ESPHome is and how it works.

1. GPIO Pinout

Function ESP32 GPIO Direction Notes
EFR32MG24 UART RX GPIO13 Input Data from EFR32MG24 to ESP32
EFR32MG24 UART TX GPIO17 Output Data from ESP32 to EFR32MG24
EFR32MG24 Reset GPIO15 Output Active LOW
EFR32MG24 Bootloader GPIO12 Output Used to enter radio bootloader mode
RGB LED - Red GPIO4 Output / PWM LEDC PWM
RGB LED - Green GPIO14 Output / PWM LEDC PWM
RGB LED - Blue GPIO2 Output / PWM LEDC PWM
User Button GPIO34 Input Active LOW
Ethernet MDC GPIO23 Output IP101 PHY management clock
Ethernet MDIO GPIO18 I/O IP101 PHY management data
Ethernet REF_CLK GPIO0 Input External 50 MHz RMII clock
Ethernet PHY Reset / Power GPIO5 Output Controls IP101 PHY

2. Features

The following features are based on the example YAML configuration provided here. All parameters can be modified in ESPHome Device Builder to suit your needs.

2.1. Bluetooth Proxy

ESP32 runs esp32_ble_tracker and bluetooth_proxy, extending Bluetooth coverage of Home Assistant. This allows Home Assistant to monitor and control BLE devices such as BTHome sensors.

2.2. UART over TCP

ESPHome firmware exposes EFR32MG24 UART over TCP on port 6638. This is how ZHA and Zigbee2MQTT communicate with MG24 SoC.

2.3. MG24 Bootloader Mode

A switch in Home Assistant which can puts MG24 into bootloader mode, allowing firmware flashing over TCP, also on port 6638. Turn the switch off to reboot MG24 back to normal mode.

2.4. Physical Button

  • Single click: Triggers a single click event, and also turns on the fallback AP.
  • Double click: Triggers a double click event.
  • Long press (5s): Triggers long press and long release events.

2.5. Status LED

  • Blue blinking: No network connection.
  • Solid blue: Network connected.
  • Yellow blinking: A single click on the physical button.

3. Example YAML

esphome:
  name: dongle-m
  friendly_name: "Dongle Max"
  includes:
    - <esp_wifi.h>

  # Initialize runtime state, reset the MG24 radio, and indicate booting.
  on_boot:
    - priority: 600
      then:
        - lambda: |-
            id(button_long_active) = false;
            id(mg24_bootloader_active) = false;
        - switch.turn_off: radio_bootloader
        - delay: 100ms
        - switch.turn_on: radio_reset
        - delay: 100ms
        - switch.turn_off: radio_reset
        - light.turn_on:
            id: status_rgb
            brightness: 100%
            red: 0%
            green: 0%
            blue: 100%
            effect: "Blink 500ms"

    # Initialize connection status after ESPHome components are ready.
    - priority: -100
      then:
        - text_sensor.template.publish:
            id: uart_tcp_connection_status
            state: "Disconnected"
        - script.execute: update_status_led

# ESP32 hardware and framework configuration.
esp32:
  variant: esp32
  flash_size: 16MB
  cpu_frequency: 240MHz
  framework:
    type: esp-idf

psram:
  mode: quad
  speed: 80MHz

logger:

api:
  encryption:
    key:

ota:
  - platform: esphome
    allow_partition_access: true
    encryption:

external_components:
  - source: github://SONOFF-Technologies/esphome-stream-server

# Prefer wired Ethernet when available, with Wi-Fi as fallback.
network:
  priority:
    - ethernet
    - wifi

wifi:
  id: wifi_network

  # Optional: configure station-mode Wi-Fi credentials in secrets.yaml.
  # ssid: !secret wifi_ssid
  # password: !secret wifi_password

  # Access point configuration used for local setup and recovery.
  ap:
    ssid: "Dongle-M_ESPHome"
    ap_timeout: 0s
  on_connect:
    - script.execute: update_status_led
  on_disconnect:
    - script.execute: update_status_led

captive_portal:

# Ethernet PHY configuration.
# The PHY type, clock mode, and GPIO assignments are hardware-specific.
ethernet:
  id: ethernet_network
  type: IP101
  mdc_pin: GPIO23
  mdio_pin: GPIO18
  clk:
    pin: GPIO0
    mode: CLK_EXT_IN
  phy_addr: 1
  power_pin:
    number: GPIO5
  on_connect:
    then:
      - script.execute: update_status_led
  on_disconnect:
    then:
      - script.execute: update_status_led

# Enable ESPHome Bluetooth Proxy for BLE device discovery and forwarding.
esp32_ble_tracker:
  scan_parameters:
    interval: 320ms
    window: 160ms
    active: true

bluetooth_proxy:
  active: true

preferences:
  flash_write_interval: 1s

# UART interface connected to the MG24 radio.
# The application baud rate can be changed at runtime below.
uart:
  id: radio_uart
  rx_pin: GPIO13
  tx_pin: GPIO17
  baud_rate: 115200

# Expose the MG24 UART over TCP.
stream_server:
  - id: radio_stream_server
    uart_id: radio_uart
    port: 6638
    buffer_size: 4096

# RGB status LED driven by three PWM channels.
output:
  - platform: ledc
    id: status_led_red
    pin: GPIO4
    frequency: 5000Hz
    channel: 0

  - platform: ledc
    id: status_led_green
    pin: GPIO14
    frequency: 5000Hz
    channel: 2

  - platform: ledc
    id: status_led_blue
    pin: GPIO2
    frequency: 5000Hz
    channel: 4

# Status LED indications:
# - Blinking blue: network disconnected
# - Solid blue: network connected
# - Blinking orange: temporary single-click indication
light:
  - platform: rgb
    id: status_rgb
    name: "LED"
    icon: mdi:led-on
    red: status_led_red
    green: status_led_green
    blue: status_led_blue
    gamma_correct: 2.0
    default_transition_length: 0s
    flash_transition_length: 0s
    restore_mode: ALWAYS_OFF
    effects:
      - pulse:
          name: "Blink 500ms"
          transition_length: 0s
          update_interval: 500ms
          min_brightness: 0%
          max_brightness: 100%
      - pulse:
          name: "Breathing"
          transition_length: 500ms
          update_interval: 500ms
          min_brightness: 0%
          max_brightness: 100%

globals:
  - id: button_long_active
    type: bool
    restore_value: no
    initial_value: 'false'

  - id: mg24_bootloader_active
    type: bool
    restore_value: no
    initial_value: 'false'

event:
  - platform: template
    id: user_button_event
    name: "Button"
    icon: mdi:gesture-tap-button
    event_types:
      - "single click"
      - "double click"
      - "long press"
      - "long release"

binary_sensor:
  - platform: stream_server
    stream_server: radio_stream_server
    connected:
      id: radio_tcp_connected
      internal: true
      on_press:
        - text_sensor.template.publish:
            id: uart_tcp_connection_status
            state: "Connected"
      on_release:
        - text_sensor.template.publish:
            id: uart_tcp_connection_status
            state: "Disconnected"

  # Physical user button.
  #
  # Actions:
  # - Single click: emit an event and enable the access point
  # - Double click: emit a double-click event
  # - Long press (5 s): emit a long-press event
  # - Release after long press: emit a long-release event
  - platform: gpio
    id: user_button
    icon: mdi:gesture-tap-button
    pin:
      number: GPIO34
      mode:
        input: true
      inverted: true
    filters:
      - delayed_on_off: 20ms
    on_press:
      then:
        - lambda: |-
            id(button_long_active) = false;
        - script.execute: button_long_press_detector
    on_release:
      then:
        - script.stop: button_long_press_detector
        - if:
            condition:
              lambda: |-
                return id(button_long_active);
            then:
              - event.trigger:
                  id: user_button_event
                  event_type: "long release"
              - lambda: |-
                  id(button_long_active) = false;
    on_multi_click:
      - timing:
          - ON for 40ms to 800ms
          - OFF for 40ms to 400ms
          - ON for 40ms to 800ms
          - OFF for at least 500ms
        then:
          - event.trigger:
              id: user_button_event
              event_type: "double click"

      - timing:
          - ON for 40ms to 800ms
          - OFF for at least 500ms
        then:
          - event.trigger:
              id: user_button_event
              event_type: "single click"
          - script.execute: force_ap_on
          - script.execute: single_click_indicator

sensor:
  - platform: wifi_signal
    id: wifi_rssi
    name: "RSSI"
    update_interval: 30s
    entity_category: diagnostic

  - platform: uptime
    id: device_uptime
    name: "Uptime"
    type: seconds
    update_interval: 60s
    entity_category: diagnostic

text_sensor:
  - platform: template
    id: uart_tcp_connection_status
    name: "UART over TCP"
    icon: mdi:lan-connect
    update_interval: never

switch:
  # Internal MG24 reset control.
  - platform: gpio
    id: radio_reset
    restore_mode: ALWAYS_OFF
    pin:
      number: GPIO15
      mode: OUTPUT
      inverted: true

  # Internal MG24 bootloader control.
  - platform: gpio
    id: radio_bootloader
    restore_mode: ALWAYS_OFF
    pin:
      number: GPIO12
      mode: OUTPUT
      inverted: false

  # User-facing control for entering or leaving the MG24 bootloader.
  - platform: template
    id: mg24_bootloader_switch
    name: "MG24 bootloader mode"
    icon: mdi:memory
    lambda: |-
      return id(mg24_bootloader_active);
    turn_on_action:
      - script.execute: enter_radio_bootloader
    turn_off_action:
      - script.execute: reset_radio_to_app

  # Runtime access point control.
  #
  # ESP-IDF Wi-Fi APIs are used here to enable or disable AP mode
  # without restarting the device.
  - platform: template
    id: ap_control
    name: "Access Point"
    icon: mdi:access-point
    lambda: |-
      wifi_mode_t mode = WIFI_MODE_NULL;
      const esp_err_t err = esp_wifi_get_mode(&mode);

      if (err == ESP_ERR_WIFI_NOT_INIT) {
        return {};
      }

      if (err != ESP_OK) {
        ESP_LOGW("ap_control", "esp_wifi_get_mode failed: %s", esp_err_to_name(err));
        return {};
      }

      return mode == WIFI_MODE_AP || mode == WIFI_MODE_APSTA;

    turn_on_action:
      - script.execute: force_ap_on

    turn_off_action:
      - script.execute: force_ap_off

button:
  - platform: template
    id: radio_reset_button
    name: "MG24 restart"
    icon: mdi:restart
    on_press:
      - script.execute: reset_radio_to_app

# Select the MG24 application baud rate.
# The selected value is persisted across restarts.
# Bootloader communication always uses 115200 baud.
select:
  - platform: template
    id: radio_baud_rate
    name: "MG24 baud rate"
    icon: mdi:swap-horizontal
    entity_category: config
    options:
      - "115200"
      - "460800"
      - "921600"

    initial_option: "115200"
    optimistic: true
    restore_value: true

    on_value:
      - if:
          condition:
            lambda: |-
              return !id(mg24_bootloader_active);
          then:
            - script.execute: apply_radio_app_baud_rate

script:
  - id: button_long_press_detector
    mode: restart
    then:
      - delay: 5s
      - if:
          condition:
            binary_sensor.is_on: user_button
          then:
            - lambda: |-
                id(button_long_active) = true;
            - event.trigger:
                id: user_button_event
                event_type: "long press"

  - id: update_status_led
    mode: restart
    then:
      - if:
          condition:
            lambda: |-
              return network::is_connected();
          then:
            - light.turn_on:
                id: status_rgb
                effect: none
            - light.turn_on:
                id: status_rgb
                brightness: 100%
                red: 0%
                green: 0%
                blue: 100%
                transition_length: 0s
          else:
            - light.turn_on:
                id: status_rgb
                brightness: 100%
                red: 0%
                green: 0%
                blue: 100%
                effect: "Blink 500ms"

  - id: single_click_indicator
    mode: restart
    then:
      - light.turn_on:
          id: status_rgb
          brightness: 100%
          red: 100%
          green: 50%
          blue: 0%
          effect: "Blink 500ms"
      - delay: 10s
      - script.execute: update_status_led

  - id: force_ap_on
    mode: restart
    then:
      - lambda: |-
          wifi_mode_t current_mode = WIFI_MODE_NULL;
          esp_err_t err = esp_wifi_get_mode(&current_mode);

          if (err != ESP_OK) {
            ESP_LOGE("ap_control", "esp_wifi_get_mode failed: %s", esp_err_to_name(err));
            return;
          }

          wifi_mode_t target_mode = current_mode;

          if (current_mode == WIFI_MODE_STA) {
            target_mode = WIFI_MODE_APSTA;
          } else if (current_mode == WIFI_MODE_NULL) {
            target_mode = WIFI_MODE_AP;
          }

          if (target_mode != current_mode) {
            err = esp_wifi_set_mode(target_mode);
            if (err != ESP_OK) {
              ESP_LOGE("ap_control", "Enable AP failed: %s", esp_err_to_name(err));
              return;
            }
          }

      # Allow the Wi-Fi mode change to take effect before applying
      # the AP configuration.
      - delay: 100ms

      - lambda: |-
          wifi_mode_t current_mode = WIFI_MODE_NULL;
          esp_err_t err = esp_wifi_get_mode(&current_mode);

          if (err != ESP_OK) {
            ESP_LOGE("ap_control", "esp_wifi_get_mode failed after enabling AP: %s", esp_err_to_name(err));
            return;
          }

          if (current_mode != WIFI_MODE_AP && current_mode != WIFI_MODE_APSTA) {
            ESP_LOGE("ap_control", "AP mode is not active");
            return;
          }

          const auto ap = id(wifi_network).get_ap();
          const auto ssid = ap.get_ssid();
          const auto password = ap.get_password();

          wifi_config_t config = {};

          const size_t ssid_len = std::min(ssid.size(), sizeof(config.ap.ssid));
          std::memcpy(config.ap.ssid, ssid.c_str(), ssid_len);
          config.ap.ssid_len = ssid_len;

          const size_t password_len = std::min(password.size(), sizeof(config.ap.password) - 1);

          if (password_len > 0) {
            std::memcpy(config.ap.password, password.c_str(), password_len);
            config.ap.password[password_len] = '\0';
            config.ap.authmode = WIFI_AUTH_WPA2_PSK;
          } else {
            config.ap.authmode = WIFI_AUTH_OPEN;
          }

          config.ap.channel = ap.has_channel() ? ap.get_channel() : 1;
          config.ap.ssid_hidden = ap.get_hidden() ? 1 : 0;
          config.ap.max_connection = 5;
          config.ap.beacon_interval = 100;

          err = esp_wifi_set_config(WIFI_IF_AP, &config);

          if (err != ESP_OK) {
            ESP_LOGE("ap_control", "esp_wifi_set_config(AP) failed: %s", esp_err_to_name(err));
            return;
          }

          if (captive_portal::global_captive_portal != nullptr &&
              !captive_portal::global_captive_portal->is_active()) {
            captive_portal::global_captive_portal->start();
          }

          ESP_LOGI("ap_control", "Access Point enabled");

  - id: force_ap_off
    mode: restart
    then:
      - lambda: |-
          wifi_mode_t current_mode = WIFI_MODE_NULL;
          esp_err_t err = esp_wifi_get_mode(&current_mode);

          if (err != ESP_OK) {
            ESP_LOGE("ap_control", "esp_wifi_get_mode failed: %s", esp_err_to_name(err));
            return;
          }

          if (current_mode == WIFI_MODE_APSTA) {
            err = esp_wifi_set_mode(WIFI_MODE_STA);
          } else if (current_mode == WIFI_MODE_AP) {
            err = esp_wifi_set_mode(WIFI_MODE_NULL);
          } else {
            if (captive_portal::global_captive_portal != nullptr &&
                captive_portal::global_captive_portal->is_active()) {
              captive_portal::global_captive_portal->end();
            }
            return;
          }

          if (err != ESP_OK) {
            ESP_LOGE("ap_control", "Disable AP failed: %s", esp_err_to_name(err));
            return;
          }

          if (captive_portal::global_captive_portal != nullptr &&
              captive_portal::global_captive_portal->is_active()) {
            captive_portal::global_captive_portal->end();
          }

          ESP_LOGI("ap_control", "Access Point disabled");

  - id: apply_radio_app_baud_rate
    mode: restart
    then:
      - lambda: |-
          const auto option = id(radio_baud_rate).current_option();

          uint32_t baud_rate = 115200;
          if (option == "460800")
            baud_rate = 460800;
          else if (option == "921600")
            baud_rate = 921600;

          if (id(radio_uart).get_baud_rate() != baud_rate) {
            id(radio_uart).flush();
            id(radio_uart).set_baud_rate(baud_rate);
            id(radio_uart).load_settings();
          }

  # Enter the MG24 bootloader.
  # Bootloader communication always uses 115200 baud without
  # changing the saved application baud rate.
  - id: enter_radio_bootloader
    mode: restart
    then:
      - lambda: |-
          id(mg24_bootloader_active) = true;

          id(radio_uart).flush();
          if (id(radio_uart).get_baud_rate() != 115200) {
            id(radio_uart).set_baud_rate(115200);
            id(radio_uart).load_settings();
          }

          id(mg24_bootloader_switch).publish_state(true);

      - delay: 100ms
      - switch.turn_on: radio_bootloader
      - delay: 100ms
      - switch.turn_on: radio_reset
      - delay: 100ms
      - switch.turn_off: radio_reset
      - delay: 500ms
      - switch.turn_off: radio_bootloader

  # Reset the MG24 back into normal application mode and restore
  # the configured application baud rate.
  - id: reset_radio_to_app
    mode: restart
    then:
      - switch.turn_off: radio_bootloader
      - delay: 100ms
      - switch.turn_on: radio_reset
      - delay: 100ms
      - switch.turn_off: radio_reset
      - delay: 500ms

      - lambda: |-
          id(mg24_bootloader_active) = false;
          id(mg24_bootloader_switch).publish_state(false);
      - script.execute: apply_radio_app_baud_rate

Note 1: Please specify key under encryption of Native API component, and password under ap of WiFi component for security reasons.

Note 2: Specify ssid and password of WiFi component if Dongle-M is connected to your router through Wi-Fi.

Here is how Dongle-M looks in Home Assistant with ESPHome firmware:

4. Flashing Zigbee, Thread firmware to MG24 SoC

4.1. Using Dongle-M’s Web Console

Before flashing ESPHome to Dongle-M’s ESP32 SoC, you can use Dongle-M’s Web Console to flash Zigbee NCP, Zigbee Router, OpenThread RCP, Multiprotocol RCP, or even custom firmware to Dongle-M’s MG24 SoC.

4.2. Using Universal Silicon Labs Flasher

Universal Silicon Labs Flasher from Nabu Casa supports flashing firmware over TCP. Put Dongle-M’s MG24 SoC into bootloader mode, and then use command like the following to flash Zigbee or Thread firmware to MG24:

universal-silabs-flasher --device socket://192.168.4.71:6638 --probe-methods bootloader:115200 flash --firmware config/donglem-mg24/donglem_mg24_zigbee_stable_921600_9.1.0.gbl

# 192.168.4.71: the IP of your Dongle-M.
# config/donglem-mg24/donglem_mg24_zigbee_stable_921600_9.1.0.gbl: the local path to the firmware for MG24 SoC. 

Note: Universal Silicon Labs Flasher also supports Writing IEEE address to Zigbee NCP firmware, which is normally needed if you are migrating your old Zigbee adapter to a new one.

1 Like