From ba72298a638a2240d0186ffbfaac5e21c1caf402 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Thu, 24 Jul 2025 21:21:59 +1000 Subject: [PATCH] [factory_reset] Allow factory reset by rapid power cycle (#9749) --- esphome/components/factory_reset/__init__.py | 92 +++++++++++++++++++ .../factory_reset/factory_reset.cpp | 76 +++++++++++++++ .../components/factory_reset/factory_reset.h | 43 +++++++++ tests/components/factory_reset/common.yaml | 4 + .../factory_reset/test.bk72xx-ard.yaml | 1 + .../factory_reset/test.esp8266-ard.yaml | 3 + .../factory_reset/test.rp2040-ard.yaml | 4 +- 7 files changed, 222 insertions(+), 1 deletion(-) create mode 100644 esphome/components/factory_reset/factory_reset.cpp create mode 100644 esphome/components/factory_reset/factory_reset.h create mode 100644 tests/components/factory_reset/test.bk72xx-ard.yaml diff --git a/esphome/components/factory_reset/__init__.py b/esphome/components/factory_reset/__init__.py index f1bcfd8c55..f3cefe6970 100644 --- a/esphome/components/factory_reset/__init__.py +++ b/esphome/components/factory_reset/__init__.py @@ -1,5 +1,97 @@ +from esphome.automation import Trigger, build_automation, validate_automation import esphome.codegen as cg +from esphome.components.esp8266 import CONF_RESTORE_FROM_FLASH, KEY_ESP8266 +import esphome.config_validation as cv +from esphome.const import ( + CONF_ID, + CONF_TRIGGER_ID, + PLATFORM_BK72XX, + PLATFORM_ESP32, + PLATFORM_ESP8266, + PLATFORM_LN882X, + PLATFORM_RTL87XX, +) +from esphome.core import CORE +from esphome.final_validate import full_config CODEOWNERS = ["@anatoly-savchenkov"] factory_reset_ns = cg.esphome_ns.namespace("factory_reset") +FactoryResetComponent = factory_reset_ns.class_("FactoryResetComponent", cg.Component) +FastBootTrigger = factory_reset_ns.class_("FastBootTrigger", Trigger, cg.Component) + +CONF_MAX_DELAY = "max_delay" +CONF_RESETS_REQUIRED = "resets_required" +CONF_ON_INCREMENT = "on_increment" + + +def _validate(config): + if CONF_RESETS_REQUIRED in config: + return cv.only_on( + [ + PLATFORM_BK72XX, + PLATFORM_ESP32, + PLATFORM_ESP8266, + PLATFORM_LN882X, + PLATFORM_RTL87XX, + ] + )(config) + + if CONF_ON_INCREMENT in config: + raise cv.Invalid( + f"'{CONF_ON_INCREMENT}' requires a value for '{CONF_RESETS_REQUIRED}'" + ) + return config + + +CONFIG_SCHEMA = cv.All( + cv.Schema( + { + cv.GenerateID(): cv.declare_id(FactoryResetComponent), + cv.Optional(CONF_MAX_DELAY, default="10s"): cv.All( + cv.positive_time_period_seconds, + cv.Range(min=cv.TimePeriod(milliseconds=1000)), + ), + cv.Optional(CONF_RESETS_REQUIRED): cv.positive_not_null_int, + cv.Optional(CONF_ON_INCREMENT): validate_automation( + { + cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(FastBootTrigger), + } + ), + } + ).extend(cv.COMPONENT_SCHEMA), + _validate, +) + + +def _final_validate(config): + if CORE.is_esp8266 and CONF_RESETS_REQUIRED in config: + fconfig = full_config.get() + if not fconfig.get_config_for_path([KEY_ESP8266, CONF_RESTORE_FROM_FLASH]): + raise cv.Invalid( + "'resets_required' needs 'restore_from_flash' to be enabled in the 'esp8266' configuration" + ) + return config + + +FINAL_VALIDATE_SCHEMA = _final_validate + + +async def to_code(config): + if reset_count := config.get(CONF_RESETS_REQUIRED): + var = cg.new_Pvariable( + config[CONF_ID], + reset_count, + config[CONF_MAX_DELAY].total_milliseconds, + ) + await cg.register_component(var, config) + for conf in config.get(CONF_ON_INCREMENT, []): + trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) + await build_automation( + trigger, + [ + (cg.uint8, "x"), + (cg.uint8, "target"), + ], + conf, + ) diff --git a/esphome/components/factory_reset/factory_reset.cpp b/esphome/components/factory_reset/factory_reset.cpp new file mode 100644 index 0000000000..c900759d90 --- /dev/null +++ b/esphome/components/factory_reset/factory_reset.cpp @@ -0,0 +1,76 @@ +#include "factory_reset.h" + +#include "esphome/core/application.h" +#include "esphome/core/hal.h" +#include "esphome/core/log.h" + +#include + +#if !defined(USE_RP2040) && !defined(USE_HOST) + +namespace esphome { +namespace factory_reset { + +static const char *const TAG = "factory_reset"; +static const uint32_t POWER_CYCLES_KEY = 0xFA5C0DE; + +static bool was_power_cycled() { +#ifdef USE_ESP32 + return esp_reset_reason() == ESP_RST_POWERON; +#endif +#ifdef USE_ESP8266 + auto reset_reason = EspClass::getResetReason(); + return strcasecmp(reset_reason.c_str(), "power On") == 0 || strcasecmp(reset_reason.c_str(), "external system") == 0; +#endif +#ifdef USE_LIBRETINY + auto reason = lt_get_reboot_reason(); + return reason == REBOOT_REASON_POWER || reason == REBOOT_REASON_HARDWARE; +#endif +} + +void FactoryResetComponent::dump_config() { + uint8_t count = 0; + this->flash_.load(&count); + ESP_LOGCONFIG(TAG, "Factory Reset by Reset:"); + ESP_LOGCONFIG(TAG, + " Max interval between resets %" PRIu32 " seconds\n" + " Current count: %u\n" + " Factory reset after %u resets", + this->max_interval_ / 1000, count, this->required_count_); +} + +void FactoryResetComponent::save_(uint8_t count) { + this->flash_.save(&count); + global_preferences->sync(); + this->defer([count, this] { this->increment_callback_.call(count, this->required_count_); }); +} + +void FactoryResetComponent::setup() { + this->flash_ = global_preferences->make_preference(POWER_CYCLES_KEY, true); + if (was_power_cycled()) { + uint8_t count = 0; + this->flash_.load(&count); + // this is a power on reset or external system reset + count++; + if (count == this->required_count_) { + ESP_LOGW(TAG, "Reset count reached, factory resetting"); + global_preferences->reset(); + // delay to allow log to be sent + delay(100); // NOLINT + App.safe_reboot(); // should not return + } + this->save_(count); + ESP_LOGD(TAG, "Power on reset detected, incremented count to %u", count); + this->set_timeout(this->max_interval_, [this]() { + ESP_LOGD(TAG, "No reset in the last %" PRIu32 " seconds, resetting count", this->max_interval_ / 1000); + this->save_(0); // reset count + }); + } else { + this->save_(0); // reset count if not a power cycle + } +} + +} // namespace factory_reset +} // namespace esphome + +#endif // !defined(USE_RP2040) && !defined(USE_HOST) diff --git a/esphome/components/factory_reset/factory_reset.h b/esphome/components/factory_reset/factory_reset.h new file mode 100644 index 0000000000..80942b29bd --- /dev/null +++ b/esphome/components/factory_reset/factory_reset.h @@ -0,0 +1,43 @@ +#pragma once + +#include "esphome/core/component.h" +#include "esphome/core/automation.h" +#include "esphome/core/preferences.h" +#if !defined(USE_RP2040) && !defined(USE_HOST) + +#ifdef USE_ESP32 +#include +#endif + +namespace esphome { +namespace factory_reset { +class FactoryResetComponent : public Component { + public: + FactoryResetComponent(uint8_t required_count, uint32_t max_interval) + : required_count_(required_count), max_interval_(max_interval) {} + + void dump_config() override; + void setup() override; + void add_increment_callback(std::function &&callback) { + this->increment_callback_.add(std::move(callback)); + } + + protected: + ~FactoryResetComponent() = default; + void save_(uint8_t count); + ESPPreferenceObject flash_{}; // saves the number of fast power cycles + uint8_t required_count_; // The number of boot attempts before fast boot is enabled + uint32_t max_interval_; // max interval between power cycles + CallbackManager increment_callback_{}; +}; + +class FastBootTrigger : public Trigger { + public: + explicit FastBootTrigger(FactoryResetComponent *parent) { + parent->add_increment_callback([this](uint8_t current, uint8_t target) { this->trigger(current, target); }); + } +}; +} // namespace factory_reset +} // namespace esphome + +#endif // !defined(USE_RP2040) && !defined(USE_HOST) diff --git a/tests/components/factory_reset/common.yaml b/tests/components/factory_reset/common.yaml index ad3abd603e..7617dc4b81 100644 --- a/tests/components/factory_reset/common.yaml +++ b/tests/components/factory_reset/common.yaml @@ -1,3 +1,7 @@ button: - platform: factory_reset name: Reset to Factory Default Settings + +factory_reset: + resets_required: 5 + max_delay: 10s diff --git a/tests/components/factory_reset/test.bk72xx-ard.yaml b/tests/components/factory_reset/test.bk72xx-ard.yaml new file mode 100644 index 0000000000..dade44d145 --- /dev/null +++ b/tests/components/factory_reset/test.bk72xx-ard.yaml @@ -0,0 +1 @@ +<<: !include common.yaml diff --git a/tests/components/factory_reset/test.esp8266-ard.yaml b/tests/components/factory_reset/test.esp8266-ard.yaml index dade44d145..591a319b81 100644 --- a/tests/components/factory_reset/test.esp8266-ard.yaml +++ b/tests/components/factory_reset/test.esp8266-ard.yaml @@ -1 +1,4 @@ +esp8266: + restore_from_flash: true + <<: !include common.yaml diff --git a/tests/components/factory_reset/test.rp2040-ard.yaml b/tests/components/factory_reset/test.rp2040-ard.yaml index dade44d145..ad3abd603e 100644 --- a/tests/components/factory_reset/test.rp2040-ard.yaml +++ b/tests/components/factory_reset/test.rp2040-ard.yaml @@ -1 +1,3 @@ -<<: !include common.yaml +button: + - platform: factory_reset + name: Reset to Factory Default Settings