mirror of
https://github.com/esphome/esphome.git
synced 2026-10-12 17:23:04 +00:00
[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:
co-authored by
J. Nick Koston
Claude Sonnet 4.6
parent
5f6a910e2d
commit
2b11fbe164
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
Reference in New Issue
Block a user