Scott Conway

Information Security Researcher

Getting Started with ESPHome on Shelly Smart Switches

I’ve been on another home automation kick recently. I’d heard of Shelly devices, but I hadn’t gotten around to investigating them until recently. To dip my feet in, I bought a Shelly 1 Mini gen3 and a Shelly 2PM gen3.

The larger Shelly devices have 1.27mm pitch UART programming headers, whereas the “mini” series do not. I was hoping to go the OTA update route to install ESPHome anyway, so that shouldn’t be an issue! As I soon discovered however, the OTA upgrade process from Shelly stock to ESPHome is fraught with issues (as of 2025-03).

Shelly 1 Mini gen3

My search engine chose this page to be my guide for flashing ESPHome over-the-air onto a Shelly 1 Mini. PLEASE DO NOT FOLLOW THE INSTRUCTIONS PROVIDED THERE, AS THEY WILL SOFT-BRICK YOUR DEVICE. If only the poster updated their post to say that…

Be aware that this guide has issues until PR has come through in esphome

To be clear, the “issues” described are that devices cannot accept further OTA updates for ESPHome after initial flashing. Of course, UART flashing is always an option.

Even with the aforementioned PR, the update process still doesn’t appear to work properly. This comment shows errors from attempting an OTA while using unprotected_writes option in ESPHome’s ota configuration.

I will use this device someday, when I feel like soldering the tiny little pads.

Shelly 2PM gen3

Shortly after I made a Shelly 1 mini device temporarily unusable, I moved to investigating my larger Shelly device. This guide showed a nice, simple way to make a pitch adapter for the programming header. However, I had issues with my connections being strong enough. I ended up buying a pitch changer and some 1.27mm pins from digikey to make a nice stable adapter.

I struggled to find any ESPHome configs for the Shelly 2PM gen3 until I found this forum post, which provided a bare-bones config. NOTE THAT THIS CONFIG DOES NOT PROVIDE THERMAL LIMITING. Nevertheless, it’s a great starting point.

After playing around with a functional Shelly 2PM running ESPHome, I made two configs:

The latter was easily achievable with the on_multi_click function (see my config below!).

These devices are just fantastic. I soon decided that I wanted to get a Shelly Dimmer 2 as well.

Shelly Dimmer 2

There’s not much to note regarding differences flashing this compared to other Shelly devices, except for how awesome ESPHome is, specifically in how this firmware is flashed. As noted on its ESPHome page, the Dimmer 2 uses an STM32 co-processor for interactions with mains power. ESPHome automatically flashes it with custom firmware from ESPHome with the following configuration snippet:

light:
    - platform: shelly_dimmer
      name: Shelly Dimmer 2 Light
      firmware:
        version: "51.6"
        update: true
      ...

The following config for the Dimmer 2 is largely taken from this forum post.

Configs!

Shelly 2 PM gen3 - normal operation

esphome:
  name: DEVICE_NAME
  friendly_name: FRIENDLY_NAME

esp32:
  board: esp32-c3-devkitm-1
  flash_size: 8MB
  framework:
    type: esp-idf
    version: recommended
    sdkconfig_options:
      COMPILER_OPTIMIZATION_SIZE: y

substitutions:
  max_temp: "70" # °C

# Enable logging
logger:

# Enable Home Assistant API
api:
  encryption:
    key: "ENCRYPTION KEY"

ota:
  - platform: esphome
    password: "OTA PASSWORD"

wifi:
  ssid: !secret wifi_ssid
  password: !secret wifi_password

  # Enable fallback hotspot (captive portal) in case wifi connection fails
  ap:
    ssid: "SSID NAME"
    password: "AP PASSWORD"

captive_portal:


i2c:
  sda: GPIO6
  scl: GPIO7

sensor:
  - platform: ade7953_i2c
    irq_pin: GPIO1
    voltage:
      name: "Voltage"
      id: voltage
      icon: mdi:alpha-v-circle-outline
      device_class: voltage
    frequency:
      name: "Frequency"
      id: frequency
      accuracy_decimals: 2
      icon: mdi:cosine-wave
      device_class: frequency
    current_a:
      name: "Current A"
      id: currenta
      icon: mdi:alpha-a-circle-outline
      device_class: current
    current_b:
      name: "Current B"
      id: currentb
      icon: mdi:alpha-a-circle-outline
      device_class: current
    active_power_a:
      name: "Power A"
      id: powera
      icon: mdi:power
      device_class: power
      filters:
        - multiply: -1
    active_power_b:
      name: "Power b"
      id: powerb
      icon: mdi:power
      device_class: power
      filters:
        - multiply: -1
    update_interval: 5s
    

  # NTC Temperature
  - platform: ntc
    sensor: temp_resistance_reading
    name: "Temperature"
    id: temperature
    icon: "mdi:thermometer"
    calibration:
      b_constant: 3350
      reference_resistance: 10kOhm
      reference_temperature: 298.15K
    on_value:
      then:
        - if:
            condition:
              - sensor.in_range:
                  id: temperature
                  above: ${max_temp}
            then:
              - switch.turn_off:
                  id: relay0
              - logger.log: "Switch turned off because temperature exceeded ${max_temp}°C"

    on_value_range:
      - above: ${max_temp}
        then:
          - logger.log: "Temperature exceeded ${max_temp}°C"
  
  - platform: resistance
    id: temp_resistance_reading
    sensor: temp_analog_reading
    configuration: DOWNSTREAM
    resistor: 32kOhm
  
  - platform: adc
    id: temp_analog_reading
    pin: 4

status_led:
  pin:
    number: 2
    inverted: true

output:
  - platform: gpio
    id: "relay_output0"
    pin: 5

  - platform: gpio
    id: "relay_output1"
    pin: 3

switch:
  - platform: output
    id: "relay0"
    name: "Relay0"
    output: "relay_output0"

  - platform: output
    id: "relay1"
    name: "Relay1"
    output: "relay_output1"

binary_sensor:
  - platform: gpio
    name: "Switch 0"
    id: switch0
    pin: 18
    on_state: 
      then:
        - lambda: |-
            if (id(switch0).state) {
              id(relay0).turn_on();
            } else {
              id(relay0).turn_off();
            }    
    filters:
      - delayed_on_off: 50ms

  - platform: gpio
    name: "Switch 1"
    id: switch1
    pin: 10
    on_state: 
      then:
        - lambda: |-
            if (id(switch1).state) {
              id(relay1).turn_on();
            } else {
              id(relay1).turn_off();
            }    
    filters:
      - delayed_on_off: 50ms

  - platform: gpio
    name: "Button"
    pin:
      number: 19
      inverted: yes
      mode:
        input: true
        pullup: true

Shelly 2PM gen3 - single switch multiple loads

esphome:
  name: DEVICE_NAME
  friendly_name: FRIENDLY_NAME

esp32:
  board: esp32-c3-devkitm-1
  flash_size: 8MB
  framework:
    type: esp-idf
    version: recommended
    sdkconfig_options:
      COMPILER_OPTIMIZATION_SIZE: y

substitutions:
  max_temp: "70" # °C

# Enable logging
logger:

# Enable Home Assistant API
api:
  encryption:
    key: "ENCRYPTSSIDION KEY"

ota:
  - platform: esphome
    password: "OTA PASSWORD"

wifi:
  ssid: !secret wifi_ssid
  password: !secret wifi_password

  # Enable fallback hotspot (captive portal) in case wifi connection fails
  ap:
    ssid: "SSID NAME"
    password: "AP PASSWORD"

captive_portal:


i2c:
  sda: GPIO6
  scl: GPIO7

sensor:
  - platform: ade7953_i2c
    irq_pin: GPIO1
    voltage:
      name: "Voltage"
      id: voltage
      icon: mdi:alpha-v-circle-outline
      device_class: voltage
    frequency:
      name: "Frequency"
      id: frequency
      accuracy_decimals: 2
      icon: mdi:cosine-wave
      device_class: frequency
    current_a:
      name: "Current A"
      id: currenta
      icon: mdi:alpha-a-circle-outline
      device_class: current
    current_b:
      name: "Current B"
      id: currentb
      icon: mdi:alpha-a-circle-outline
      device_class: current
    active_power_a:
      name: "Power A"
      id: powera
      icon: mdi:power
      device_class: power
      filters:
        - multiply: -1
    active_power_b:
      name: "Power b"
      id: powerb
      icon: mdi:power
      device_class: power
      filters:
        - multiply: -1
    update_interval: 5s
    

  # NTC Temperature
  - platform: ntc
    sensor: temp_resistance_reading
    name: "Temperature"
    id: temperature
    icon: "mdi:thermometer"
    calibration:
      b_constant: 3350
      reference_resistance: 10kOhm
      reference_temperature: 298.15K
    on_value:
      then:
        - if:
            condition:
              - sensor.in_range:
                  id: temperature
                  above: ${max_temp}
            then:
              - switch.turn_off:
                  id: relay0
              - logger.log: "Switch turned off because temperature exceeded ${max_temp}°C"
    on_value_range:
      - above: ${max_temp}
        then:
          - logger.log: "Temperature exceeded ${max_temp}°C"

  - platform: resistance
    id: temp_resistance_reading
    sensor: temp_analog_reading
    configuration: DOWNSTREAM
    resistor: 32kOhm
  
  - platform: adc
    id: temp_analog_reading
    pin: 4

status_led:
  pin:
    number: 2
    inverted: true

output:
  - platform: gpio
    id: "relay_output0"
    pin: 5

  - platform: gpio
    id: "relay_output1"
    pin: 3

switch:
  - platform: output
    id: "relay0"
    name: "Relay0"
    output: "relay_output0"

  - platform: output
    id: "relay1"
    name: "Relay1"
    output: "relay_output1"

binary_sensor:
  - platform: gpio
    name: "Switch 0"
    id: switch0
    pin: 18
    on_multi_click:
      # toggle fan on quick ON->OFF
      - timing:
        - ON for at most 100ms
        - OFF for at least 1ms
        then:
          - switch.toggle: relay1
          
      # toggle fan on quick OFF->ON
      - timing:
        - OFF for at most 100ms
        - ON for at least 1ms
        then:
          - switch.toggle: relay1

      # toggle light ON after 100ms
      - timing:
        - ON for at least 100ms
        then:
          - switch.turn_on: relay0

      # toggle light OFF after 100ms
      - timing:
        - OFF for at least 100ms
        then:
        - switch.turn_off: relay0

    filters:
      - delayed_on_off: 50ms


  - platform: gpio
    name: "Button"
    pin:
      number: 19
      inverted: yes
      mode:
        input: true
        pullup: true

Shelly Dimmer 2

This one is still in progress, but here’s what I have for now:

esphome:
  name: DEVICE_NAME
  friendly_name: FRIENDLY_NAME

substitutions:
  max_temp: "70" # °C

esp8266:
  board: esp01_1m

# Enable logging
logger:
    baud_rate: 0

# Enable Home Assistant API
api:
  encryption:
    key: "ENCRYPTION KEY"

ota:
  platform: esphome
  password: "OTA PASSWORD"

wifi:
  ssid: !secret wifi_ssid
  password: !secret wifi_password

  # Enable fallback hotspot (captive portal) in case wifi connection fails
  ap:
    ssid: "SSID NAME"
    password: "AP PASSWORD"

captive_portal:

uart:
    tx_pin: 1
    rx_pin: 3
    baud_rate: 115200

light:
    - platform: shelly_dimmer
      name: ${light_name}
      id: dimmer
      leading_edge: false #choose between leading edge and trailing edge (use trailing edge for led dimming)
      min_brightness: 470
      max_brightness: 1000
      restore_mode: ALWAYS_OFF
      default_transition_length: 1s
      gamma_correct: 0 #change this to your liking. Default value is 2.8, but 0 prevents some brightness pops for me
      firmware:
        version: "51.6"
        update: true

sensor: #Important: don't change this sensor-part unless you know what you are doing. These sensors will shut the light down when overheating temperature is reached.
  # NTC Temperature
  - platform: ntc
    sensor: temp_resistance_reading
    name: Temperature
    id: temperature
    unit_of_measurement: "°C"
    accuracy_decimals: 1
    icon: "mdi:thermometer"
    calibration:
      b_constant: 3350
      reference_resistance: 10kOhm
      reference_temperature: 298.15K
    on_value:
      then:
        - if:
            condition:
              - sensor.in_range:
                  id: temperature
                  above: ${max_temp}
              - light.is_on: dimmer
            then:
              - light.turn_off: 
                  id: dimmer
              - logger.log: "Switch turned off because temperature exceeded ${max_temp}°C"
    on_value_range:
      - above: ${max_temp}
        then:
          - logger.log: "Temperature exceeded ${max_temp}°C"
  - platform: resistance
    id: temp_resistance_reading
    sensor: temp_analog_reading
    configuration: DOWNSTREAM
    resistor: 32kOhm
  - platform: adc
    id: temp_analog_reading
    pin: A0

switch:
  - platform: restart
    name: Reboot

script:
  - id: script_dim_down_timer
    mode: restart     # script will be kept running for 5 seconds sinces the latest time the script is executed
    then:
      - logger.log: "Dim-down timer script started"
      - delay: 5s
      - logger.log: "Dim-down timer script finished"
  - id: script_dim_up_timer
    mode: restart     # script will be kept running for 5 seconds sinces the latest time the script is executed
    then:
      - logger.log: "Dim-up timer script started"
      - delay: 5s
      - logger.log: "Dim-up timer script finished"
  - id: script_dim_down
    mode: single     # script will run once
    then:
      - logger.log: "Dim-down script started"
      - while:
          condition:
            and:
              - script.is_running: script_dim_down_timer #makes sure that dimming will stop after the set period
              - light.is_on: dimmer #prevents dimming of a light that is off
              - lambda: 'return(id(dimmer).remote_values.get_brightness() >= 0.01);' #prevents the light from going off and prevents the script from running unnecessary long (it stops at 1% brightness)
          then:
            - light.dim_relative:
                id: dimmer
                relative_brightness: -0.5%
                transition_length: 0.01s
            - delay: 0.01s
      - logger.log: "Dim-down script finished"
  - id: script_dim_up
    mode: single     # script will run once
    then:
      - logger.log: "Dim-up script started"
      - while:
          condition:
            and:
              - script.is_running: script_dim_up_timer #makes sure that dimming will stop after the set period
              - light.is_on: dimmer #prevents dimming of a light that is off
              - lambda: 'return(id(dimmer).remote_values.get_brightness() <= 0.999);' #prevents the script from running unnecessary long (it stops at 100% brightness)
          then:
            - light.dim_relative:
                id: dimmer
                relative_brightness: 0.5%
                transition_length: 0.01s
            - delay: 0.01s
      - logger.log: "Dim-up script finished"
  - id: script_turn_on_off
    mode: single
    then:
      - logger.log: "Turn_on_off script started"
      - if:
          condition:
            light.is_on:
              id: dimmer
          then:
            - light.turn_off:
                id: dimmer
            - logger.log: "Light turned off"
          else:
            - light.turn_on:
                id: dimmer
                brightness: !lambda |-
                  return id(dimmer).remote_values.get_brightness();
            - logger.log: "Light turned on with previous brightness setting"

binary_sensor:
  - platform: gpio
    name: Dim Down
    id: sensor_dim_down
    pin:
      number: GPIO12
      mode: INPUT
    internal: false
    on_multi_click:
    - timing:
        - ON for at most 300ms
      then:
        - logger.log: "Physical short press (dim_down) trigger"
        - script.execute: script_turn_on_off
    - timing:
        - ON for at least 300ms
      then:
        - logger.log: "Physical long press (dim_down) trigger"
        - script.execute: script_dim_down_timer
        - script.execute: script_dim_down
    on_release:
      then:
        - if:
            condition:
              light.is_on:
                id: dimmer
            then:
              - logger.log: "Physical dim_down release trigger"
              - script.stop: script_dim_down_timer
              - logger.log: "Script_dim_down_timer stopped"
  - platform: gpio
    name: Dim Up
    id: sensor_dim_up
    pin:
      number: GPIO14
      mode: INPUT
    internal: false
    on_multi_click:
    - timing:
        - ON for at most 300ms
      then:
        - logger.log: "Physical short press (dim_up) trigger"
        - script.execute: script_turn_on_off
    - timing:
        - ON for at least 300ms   
      then:
        - logger.log: "Physical long press (dim_up) trigger"
        - script.execute: script_dim_up_timer
        - script.execute: script_dim_up
    on_release:
      then:
        - if:
            condition:
              light.is_on:
                id: dimmer
            then:
              - logger.log: "Physical dim_up release trigger"
              - script.stop: script_dim_up_timer
              - logger.log: "Script_dim_up_timer stopped"

button:
  - platform: template
    name: Dim Down
    on_press:
      then:
        - script.execute: script_dim_down_timer
        - script.execute: script_dim_down
  - platform: template
    name: Dim Up
    on_press:
      then:
        - script.execute: script_dim_up_timer
        - script.execute: script_dim_up
  - platform: template
    name: Dim Stop
    on_press:
      then:
        - logger.log: "Stopping timer script"
        - script.stop: script_dim_down_timer
        - script.stop: script_dim_up_timer