[preferences] modify IntervalSyncer to be a PollingComponent (#14370)

Co-authored-by: J. Nick Koston <nick@home-assistant.io>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
rwrozelle
2026-08-23 22:50:19 -05:00
committed by GitHub
co-authored by J. Nick Koston Claude Sonnet 4.6
parent 5f6a910e2d
commit 2b11fbe164
6 changed files with 194 additions and 20 deletions
+2 -5
View File
@@ -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)
+7 -14
View File
@@ -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
-1
View File
@@ -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
@@ -1,2 +1,9 @@
preferences:
id: prefs_syncer
flash_write_interval: 20s
esphome:
on_boot:
then:
- component.suspend: prefs_syncer
- component.resume: prefs_syncer
@@ -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<uint32_t>(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");
@@ -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,
)