Feature-rich ESPHome OTA firmware for FW 1.7+ Shelly Plus Plug US

Hey everyone! After Shelly was acquired by Schneider, as well as recent trends towards secure bootloaders, I decided to take true ownership of my 5 Shelly Plus Plug USes and move fully to ESPHome

I started just hoping to get all the original features, but that quickly turned into a full on replacement firmware with all kinds of new features :grinning_face_with_smiling_eyes:

E.g

  • Persistent lifetime energy, relay on-time, relay-cycle, and button-press counters.
  • Peak power, current, voltage, internal temperature, and minimum-voltage records.
  • Apparent power and power factor.
  • Configurable overcurrent, overvoltage, undervoltage, overpower, and overtemperature limits, with trip counters, last-trip reason, and optional automatic recovery.
  • Configurable power-on behavior, auto-off timer, button lock, and blue LED brightness for on and standby states.
  • Reset buttons for statistics, protection history, extrema, and lifetime energy.

Unlike the previous shenanigans involving Tasmota bridge on pre-1.5 FW, I’ve discovered that it is possible to simply recreate Shelly’s firmware file but replaced with the ESPHome app, then you can use ESPHome to OTA update the bootloader and then finally use it like a normal ESPHome device.

I wouldn’t be surprised if the same method worked to OTA other Shelly devices, but currently I only have the Plus Plug US to test.

Instructions and details are at my GitHub repo below​:backhand_index_pointing_down:t2:

I was just poking around to see what to do my Shelly products, and found your work. Nice.

And then I also found this automatous-io/shelly-gen4-esphome: ESPHome for Shelly Gen4 devices, installed through the stock web UI.

… and I think you guys should/could be friends. :slight_smile:

Nice. This was easier than the USB method.

I replaced all the PlugUS strings with Plus1PM, got my firmware zip link via https://updates.shelly.cloud/update/Plus1PM and, magic. I skipped the CSV partitions bit and just used the OTA bin I already had from ESPHome and didn’t realise it until I saw it boot up. Not sure if I now have a mixed bag of issues waiting to happen, so I’ll take the image apart this weekend and see what it looks like.

Ohh, now THAT is interesting. Can you open the logs from ESPHome and paste the running partition table?

Just so I understand and to confirm, you took the Python script and generated the OTA file using a vanilla ESPHome bin file WITHOUT defining those partitions? Or something else?

Haha, yeah I saw that project yesterday evening while doing research and I thought the same thing! I am glad there’s an unlock path for the Gen4s too.

I’ll plug into the UART again tomorrow to see if I can get better logs, but these are over-the-network logs, snipped heavily:

[00:14:23.071][I][app:151]: ESPHome version 2026.9.1 compiled on 2026-10-01 20:59:03 +0200
[00:14:23.072][I][app:158]: ESP32 Chip: ESP32 rev3.0, 1 core(s)
[00:14:23.072][W][app:171]: Chip rev >= 3.0 detected. Set minimum_chip_revision: "3.0" under esp32 > framework > advanced to reduce binary size
[00:14:23.072][W][app:198]: Bootloader supports SRAM1 as IRAM (+40KB). Set sram1_as_iram: true under esp32 > framework > advanced
...
[00:14:23.570][C][esphome.ota:125]: Over-The-Air updates:
[00:14:23.570][C][esphome.ota:125]:   Address: shp1pm-test.local:3232
[00:14:23.570][C][esphome.ota:125]:   Version: 2
[00:14:23.570][C][esphome.ota:125]:   Encryption: offered, plaintext accepted
[00:14:23.580][C][esphome.ota:149]:   Password configured
[00:14:23.598][C][safe_mode:058]: Safe Mode:
[00:14:23.598][C][safe_mode:058]:   Successful after: 60s
[00:14:23.598][C][safe_mode:058]:   Invoke after: 10 attempts
[00:14:23.598][C][safe_mode:058]:   Duration: 300s
[00:14:23.616][C][safe_mode:079]:   Bootloader rollback: support unknown
...
[00:15:15.798][I][safe_mode:154]: Boot seems successful; resetting boot loop counter
[00:15:15.806][D][esp-idf:000]: E (60542) esp_ota_ops: ota data invalid, no current app. Assuming factory

That last line seems interesting.

The ESPHome binary was built using the template from the esphome website and I flashed it onto a different Plus1PM with UART yesterday. The important bits:

esphome:
  name: shp1pm-test
  friendly_name: Shelly Plus 1PM Test
  platformio_options: # Only needed for the single core 160MHz variant
    board_build.f_cpu: 160000000L

esp32:
  variant: esp32
  framework:
    type: esp-idf
    sdkconfig_options: # Only needed for the single core 160MHz variant
      CONFIG_FREERTOS_UNICORE: y
      CONFIG_ESP32_DEFAULT_CPU_FREQ_160: y
      CONFIG_ESP32_DEFAULT_CPU_FREQ_MHZ: "160"

And for what it’s worth, I shoved some lazy code in the main block to make it easier to just run for other models:

if __name__ == "__main__":
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("model", type=str, help="Shelly model code")
    parser.add_argument("app", type=Path, help="ESPHome OTA-format app binary")
    parser.add_argument("output", type=Path, help="Candidate Shelly-format ZIP path")
    args = parser.parse_args()

    if args.output.suffix.lower() != ".zip":
        args.output = args.output.with_suffix(".zip")

    print("Getting official firmware info")
    with urllib.request.urlopen(
        f"https://updates.shelly.cloud/update/{args.model}",
        context=ssl._create_unverified_context()
        ) as resp:
        fw_info = json.load(resp)

    shelly_fw_url = fw_info["stable"]["url"]
    print("Downloading official fiemware zip file")
    with urllib.request.urlopen(
        shelly_fw_url, context=ssl._create_unverified_context()
        ) as resp, open(f"{args.model}.zip", "wb") as f:
        shutil.copyfileobj(resp, f)

    print("Building")
    build(args.model, args.app, args.output)
    print(f"Created {args.output}")

Edit: I have a bunch more to do, so will do the next one with the partition table properly and see if it’s any different.

You should see a few lines that show partition table, like so:

[19:24:28.335][C][esphome.ota:125]: Over-The-Air updates:
[19:24:28.335][C][esphome.ota:125]:   Address: shelly-plus-plug-us.local:3232
[19:24:28.335][C][esphome.ota:125]:   Version: 2
[19:24:28.335][C][esphome.ota:125]:   Encryption: required
[19:24:28.335][C][esphome.ota:153]:   Partition access allowed
[19:24:28.335][C][esphome.ota:153]:   Running app:
[19:24:28.335][C][esphome.ota:153]:     Partition address: 0x200000
[19:24:28.335][C][esphome.ota:153]:     Used size: 987136 bytes (0xF1000)
[19:24:28.496][C][esphome.ota:161]:   Partition table:
[19:24:28.496][C][esphome.ota:161]:     Name         Type Subtype  Address    Size      
[19:24:28.496][C][esphome.ota:168]:     nvs          0x1  0x2      0x9000     0x4000    
[19:24:28.496][C][esphome.ota:168]:     otadata      0x1  0x0      0xD000     0x2000    
[19:24:28.496][C][esphome.ota:168]:     app_0        0x0  0x10     0x10000    0x190000  
[19:24:28.496][C][esphome.ota:168]:     fs_0         0x1  0x82     0x1A0000   0x60000   
[19:24:28.496][C][esphome.ota:168]:     app_1        0x0  0x11     0x200000   0x190000  
[19:24:28.496][C][esphome.ota:168]:     fs_1         0x1  0x82     0x390000   0x60000   
[19:24:28.496][C][esphome.ota:168]:     aux          0x55 0x0      0x3F0000   0xC000    
[19:24:28.497][C][esphome.ota:168]:     shelly       0x1  0x88     0x3FC000   0x4000    

To see that, you HAVE to have partition access enabled, as my example config does. If you didn’t, you will not be able to update the bootloader OTA, which may prevent ESPHome OTA updates and will generate this warning:

To get rid of that warning, you have to update the bootloader after install as Shelly uses a custom bootloader and partition layout.

However, I find it very interesting that your non-custom partition table worked OTA! I am very curious to learn more about why that apparently worked

I wish I could freely experiment on my plugs, but these have programming pins which are VERY small and VERY hard to get to, and I REALLY do not want to brick mine.