From 2b11fbe1646055feb49e275bb3021ffcc9bf0464 Mon Sep 17 00:00:00 2001 From: rwrozelle Date: Sun, 23 Aug 2026 23:50:19 -0400 Subject: [PATCH] [preferences] modify IntervalSyncer to be a PollingComponent (#14370) Co-authored-by: J. Nick Koston Co-authored-by: Claude Sonnet 4.6 --- esphome/components/preferences/__init__.py | 7 +- esphome/components/preferences/syncer.h | 21 +-- esphome/core/defines.h | 1 - .../preferences/test.esp32-idf.yaml | 7 + .../host_preferences_suspend_resume.yaml | 41 ++++++ .../test_host_preferences_suspend_resume.py | 137 ++++++++++++++++++ 6 files changed, 194 insertions(+), 20 deletions(-) create mode 100644 tests/integration/fixtures/host_preferences_suspend_resume.yaml create mode 100644 tests/integration/test_host_preferences_suspend_resume.py diff --git a/esphome/components/preferences/__init__.py b/esphome/components/preferences/__init__.py index c92903ec2e..abd050ec3b 100644 --- a/esphome/components/preferences/__init__.py +++ b/esphome/components/preferences/__init__.py @@ -9,7 +9,7 @@ from esphome.types import ConfigType CODEOWNERS = ["@esphome/core"] preferences_ns = cg.esphome_ns.namespace("preferences") -IntervalSyncer = preferences_ns.class_("IntervalSyncer", cg.Component) +IntervalSyncer = preferences_ns.class_("IntervalSyncer", cg.PollingComponent) CONF_FLASH_WRITE_INTERVAL = "flash_write_interval" CONF_RTC_STORAGE = "rtc_storage" @@ -31,10 +31,7 @@ CONFIG_SCHEMA = cv.Schema( async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) write_interval = config[CONF_FLASH_WRITE_INTERVAL] - if write_interval.total_milliseconds == 0: - cg.add_define("USE_PREFERENCES_SYNC_EVERY_LOOP") - else: - cg.add(var.set_write_interval(write_interval)) + cg.add(var.set_update_interval(write_interval)) if config.get(CONF_RTC_STORAGE): preferences.request_rtc_storage() await cg.register_component(var, config) diff --git a/esphome/components/preferences/syncer.h b/esphome/components/preferences/syncer.h index cee02394b4..8a809672db 100644 --- a/esphome/components/preferences/syncer.h +++ b/esphome/components/preferences/syncer.h @@ -2,26 +2,19 @@ #include "esphome/core/preferences.h" #include "esphome/core/component.h" +// Include for ESPDEPRECATED, Remove before 2027.3.0 +#include "esphome/core/helpers.h" namespace esphome::preferences { -class IntervalSyncer final : public Component { +class IntervalSyncer final : public PollingComponent { public: -#ifdef USE_PREFERENCES_SYNC_EVERY_LOOP - void loop() override { global_preferences->sync(); } -#else - void set_write_interval(uint32_t write_interval) { this->write_interval_ = write_interval; } - void setup() override { - this->set_interval(this->write_interval_, []() { global_preferences->sync(); }); - } -#endif + // Remove before 2027.3.0 + ESPDEPRECATED("Use set_update_interval() instead. Removed in 2027.3.0", "2026.9.0") + void set_write_interval(uint32_t write_interval) { this->set_update_interval(write_interval); } + void update() override { global_preferences->sync(); } void on_shutdown() override { global_preferences->sync(); } float get_setup_priority() const override { return setup_priority::BUS; } - -#ifndef USE_PREFERENCES_SYNC_EVERY_LOOP - protected: - uint32_t write_interval_{60000}; -#endif }; } // namespace esphome::preferences diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 42da0191ed..a8aab65d4a 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -160,7 +160,6 @@ #define USE_OUTPUT #define USE_OUTPUT_FLOAT_POWER_SCALING #define USE_POWER_SUPPLY -#define USE_PREFERENCES_SYNC_EVERY_LOOP // Only defined by key-lookup preference backends; the slot-based platforms // (esp8266, rp2040) never set it in generated builds, and their preferences // managers do not provide load_from_key(), so the PreferencesKeyLookupContract diff --git a/tests/components/preferences/test.esp32-idf.yaml b/tests/components/preferences/test.esp32-idf.yaml index 4c7c176fdc..f0ea0f5898 100644 --- a/tests/components/preferences/test.esp32-idf.yaml +++ b/tests/components/preferences/test.esp32-idf.yaml @@ -1,2 +1,9 @@ preferences: + id: prefs_syncer flash_write_interval: 20s + +esphome: + on_boot: + then: + - component.suspend: prefs_syncer + - component.resume: prefs_syncer diff --git a/tests/integration/fixtures/host_preferences_suspend_resume.yaml b/tests/integration/fixtures/host_preferences_suspend_resume.yaml new file mode 100644 index 0000000000..b32b9a1571 --- /dev/null +++ b/tests/integration/fixtures/host_preferences_suspend_resume.yaml @@ -0,0 +1,41 @@ +esphome: + name: test_suspend_resume_device + +host: + +logger: + level: DEBUG + +api: + +preferences: + id: prefs_syncer + flash_write_interval: 1s + +button: + - platform: template + name: "Save Preference" + on_press: + - lambda: |- + // save() only updates the in-memory map; only sync() persists it to disk. + ESPPreferenceObject pref = global_preferences->make_preference(0xBEEF); + uint32_t value = 123; + if (pref.save(&value)) { + ESP_LOGI("test", "Preference saved in memory"); + } else { + ESP_LOGE("test", "Preference save failed"); + } + + - platform: template + name: "Suspend Syncer" + on_press: + - component.suspend: prefs_syncer + - lambda: |- + ESP_LOGI("test", "Syncer suspended"); + + - platform: template + name: "Resume Syncer" + on_press: + - component.resume: prefs_syncer + - lambda: |- + ESP_LOGI("test", "Syncer resumed"); diff --git a/tests/integration/test_host_preferences_suspend_resume.py b/tests/integration/test_host_preferences_suspend_resume.py new file mode 100644 index 0000000000..ab08d5c440 --- /dev/null +++ b/tests/integration/test_host_preferences_suspend_resume.py @@ -0,0 +1,137 @@ +"""Test that suspending/resuming the preferences IntervalSyncer actually stops/starts flash writes.""" + +from __future__ import annotations + +import asyncio +from collections.abc import Awaitable +from pathlib import Path +import re +from typing import Any + +from aioesphomeapi import ButtonInfo, EntityInfo +import pytest + +from .types import APIClientConnectedFactory, RunCompiledFunction + +DEVICE_NAME = "test_suspend_resume_device" + + +def find_entity_by_name( + entities: list[EntityInfo], entity_type: type, name: str +) -> Any: + """Helper to find an entity by type and name.""" + return next( + (e for e in entities if isinstance(e, entity_type) and e.name == name), None + ) + + +async def _wait_for( + awaitable: Awaitable[Any], message: str, timeout: float = 5.0 +) -> None: + """Await a future or coroutine, failing the test with a clear message on timeout.""" + try: + await asyncio.wait_for(awaitable, timeout=timeout) + except TimeoutError: + pytest.fail(message) + + +async def _poll_until_exists(path: Path) -> None: + """Poll for a file to appear, rather than guessing a sleep duration.""" + while not path.exists(): + await asyncio.sleep(0.05) + + +@pytest.fixture(autouse=True) +def isolated_preferences(monkeypatch: pytest.MonkeyPatch, tmp_path) -> Path: + """Keep host preferences per-test so this test never touches the real + ~/.esphome/prefs and never races other tests over ESPHOME_PREFDIR.""" + prefdir = tmp_path / "prefs" + monkeypatch.setenv("ESPHOME_PREFDIR", str(prefdir)) + return prefdir / f"{DEVICE_NAME}.prefs" + + +@pytest.mark.asyncio +async def test_host_preferences_suspend_resume( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, + isolated_preferences: Path, +) -> None: + """Test that a running syncer flushes, a suspended one doesn't, and resume restores flushing.""" + pref_file = isolated_preferences + + loop = asyncio.get_running_loop() + saved_in_memory = loop.create_future() + syncer_suspended = loop.create_future() + syncer_resumed = loop.create_future() + + save_pattern = re.compile(r"Preference saved in memory") + suspend_pattern = re.compile(r"Syncer suspended") + resume_pattern = re.compile(r"Syncer resumed") + + def check_output(line: str) -> None: + if save_pattern.search(line) and not saved_in_memory.done(): + saved_in_memory.set_result(True) + if suspend_pattern.search(line) and not syncer_suspended.done(): + syncer_suspended.set_result(True) + if resume_pattern.search(line) and not syncer_resumed.done(): + syncer_resumed.set_result(True) + + async with ( + run_compiled(yaml_config, line_callback=check_output), + api_client_connected() as client, + ): + entities, _ = await client.list_entities_services() + + save_button = find_entity_by_name(entities, ButtonInfo, "Save Preference") + suspend_button = find_entity_by_name(entities, ButtonInfo, "Suspend Syncer") + resume_button = find_entity_by_name(entities, ButtonInfo, "Resume Syncer") + assert save_button is not None, "Save Preference button not found" + assert suspend_button is not None, "Suspend Syncer button not found" + assert resume_button is not None, "Resume Syncer button not found" + + # --- Positive control: a running syncer flushes to disk. Without this, + # the suspend assertion below could pass for the wrong reason (e.g. wrong prefs path). --- + client.button_command(save_button.key) + await _wait_for( + saved_in_memory, "Preference was not saved to memory within timeout" + ) + await _wait_for( + _poll_until_exists(pref_file), + "Running syncer never flushed to disk; positive control failed", + timeout=10.0, + ) + saved_in_memory = loop.create_future() + + # --- Suspend: a suspended syncer must not flush. --- + client.button_command(suspend_button.key) + await _wait_for( + syncer_suspended, "Syncer suspend command was not processed within timeout" + ) + # Delete only after suspend is confirmed: the poller is now stopped, so + # nothing can recreate the file before the negative assertion below. + pref_file.unlink() + + client.button_command(save_button.key) + await _wait_for( + saved_in_memory, "Preference was not saved to memory within timeout" + ) + + # Wait well past flash_write_interval (1s): a running syncer would + # have flushed to disk by now, a suspended one must not have. This is a + # negative assertion (proving absence), so a fixed sleep is unavoidable here. + await asyncio.sleep(1.5) + assert not pref_file.exists(), ( + "Suspended syncer flushed to disk; component.suspend did not stop the poller" + ) + + # --- Resume: flushing must restart. --- + client.button_command(resume_button.key) + await _wait_for( + syncer_resumed, "Syncer resume command was not processed within timeout" + ) + await _wait_for( + _poll_until_exists(pref_file), + "Resumed syncer never flushed to disk; component.resume did not restart the poller", + timeout=10.0, + )