Skip to content

OTA firmware rolls back silently on every battery wake (PMIC shutdown fires before rollback timer) #1

Description

@h0verin

Describe the bug

Every OTA update silently rolls back to the previous firmware on the first battery-powered wake. The device appears to flash successfully, but after the next PMIC-shutdown sleep cycle it boots the old firmware again with no error in logs.

Root cause

ESPHome enables CONFIG_BOOTLOADER_APP_ROLLBACK_ENABLE by default for all ESP32 builds (CONF_ENABLE_OTA_ROLLBACK: true in the esp32: framework component). After an OTA flash, the new firmware partition is in ESP_OTA_IMG_PENDING_VERIFY state and must call esp_ota_mark_app_valid_cancel_rollback() before the device reboots, otherwise the bootloader rolls back to the previous partition on the next cold boot.

On a normal battery wake, PMIC full-shutdown (SYS_CMD_OFF) fires at approximately 35–45 seconds into boot — before esp_ota_mark_app_valid_cancel_rollback() is ever called. Since the device is completely powerless during PMIC shutdown, every wake is a full cold boot, and the bootloader sees the firmware as still-pending and rolls back.

This is not visible in logs — the rollback happens silently in the bootloader before ESPHome starts. The only way to detect it is to check a Build DateTime template sensor across wakes.

Why it isn't obvious

The rollback is silent and the symptoms look identical to a failed OTA flash. It only affects battery wakes — USB-connected OTAs are unaffected because the device stays awake long enough for the bootloader's implicit validity check. The problem is also specific to PMIC full-shutdown: ESP32 deep sleep does not produce a cold boot, so the bootloader OTA check never re-triggers on a deep sleep wake.

Fix

Call esp_ota_mark_app_valid_cancel_rollback() before prepare_for_pmic_shutdown() in both the normal (on_download_finished) path and the 90s safety net. In your YAML this means adding a lambda immediately before each shutdown call:

on_download_finished:
  - delay: 150ms
  - component.update: epd
  - delay: 16s
  - light.turn_off: status_led
  - if:
      condition:
        lambda: 'return !id(epd_power)->is_usb_present();'
      then:
        - lambda: |-
            esp_ota_mark_app_valid_cancel_rollback();
            ESP_LOGI("OTA", "Boot marked valid — OTA rollback cancelled");
        - logger.log: "On battery -> PMIC full shutdown, self-wake in 15 min"
        - lambda: 'id(epd_power)->prepare_for_pmic_shutdown(900);'

And in the 90s safety net:

- lambda: |-
    esp_ota_mark_app_valid_cancel_rollback();
    ESP_LOGI("OTA", "Boot marked valid — OTA rollback cancelled");
- lambda: 'id(epd_power)->prepare_for_pmic_shutdown(900);'

No additional headers are required — the function is transitively available via the OTA component.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions