Reserve saved values per component and check the shared total after codegen

This commit is contained in:
J. Nick Koston
2026-10-13 14:14:41 -10:00
parent 44e962ab96
commit 66dfaa6d2a
2 changed files with 118 additions and 29 deletions
+61 -14
View File
@@ -1,9 +1,13 @@
import logging
import esphome.codegen as cg
# Re-exported from the shared definition; here it indexes the BOARDS
# metadata dicts, whose entries in boards.py spell the literal.
from esphome.const import KEY_FLASH_SIZE # noqa: F401 # pylint: disable=unused-import
from esphome.core import CORE
from esphome.core import CORE, CoroPriority, EsphomeError, coroutine_with_priority
_LOGGER = logging.getLogger(__name__)
KEY_ESP8266 = "esp8266"
KEY_BOARD = "board"
@@ -32,25 +36,68 @@ PREFERENCES_FLASH_WORDS = 128
PREFERENCES_FLASH_WORDS_WITHOUT_RESTORE = 64
KEY_PREFERENCES = "preferences"
# Words of RTC memory open to saved values; the crash handler keeps the rest
PREFERENCES_RTC_WORDS = PREFERENCES_RTC_NORMAL_WORDS + PREFERENCES_RTC_EBOOT_WORDS
def preference_words(value_bytes: int) -> int:
"""Words a saved value takes, checksum word included."""
return (value_bytes + 3) // 4 + 1
def preference_slots(value_bytes: int, in_flash: bool, restore_from_flash: bool) -> int:
"""How many values of this size fit where make_preference() puts them."""
words = preference_words(value_bytes)
if in_flash or restore_from_flash:
store = (
PREFERENCES_FLASH_WORDS
if restore_from_flash
else PREFERENCES_FLASH_WORDS_WITHOUT_RESTORE
)
return store // words
return sum(
region // words
for region in (PREFERENCES_RTC_NORMAL_WORDS, PREFERENCES_RTC_EBOOT_WORDS)
def reserve_preferences(count: int, value_bytes: int, in_flash: bool = False) -> None:
"""Record saved values a component creates, so all of them are checked to fit; call from to_code."""
if not CORE.is_esp8266:
return
reservations = CORE.data.setdefault(KEY_ESP8266, {}).setdefault(KEY_PREFERENCES, [])
if not reservations:
CORE.add_job(check_preferences_fit)
reservations.append((count, preference_words(value_bytes), in_flash))
@coroutine_with_priority(CoroPriority.FINAL)
async def check_preferences_fit() -> None:
"""Fail the build when the reserved saved values cannot fit the space preferences.cpp has."""
restore_from_flash = CORE.config[KEY_ESP8266].get(CONF_RESTORE_FROM_FLASH, False)
flash_store = (
PREFERENCES_FLASH_WORDS
if restore_from_flash
else PREFERENCES_FLASH_WORDS_WITHOUT_RESTORE
)
rtc_words = flash_words = 0
for count, words, in_flash in CORE.data[KEY_ESP8266][KEY_PREFERENCES]:
if in_flash or restore_from_flash:
if words > flash_store:
raise EsphomeError(
f"A saved value of {words} words does not fit the ESP8266 flash store of {flash_store} words"
)
flash_words += count * words
else:
# A value cannot span the two RTC regions, so the larger one bounds it
if words > PREFERENCES_RTC_NORMAL_WORDS:
raise EsphomeError(
f"A saved value of {words} words does not fit an ESP8266 RTC region of "
f"{PREFERENCES_RTC_NORMAL_WORDS} words"
)
rtc_words += count * words
if flash_words > flash_store:
raise EsphomeError(
f"Saved values need {flash_words} words but the ESP8266 flash store has {flash_store}; "
"use fewer or smaller saved values"
)
if rtc_words > PREFERENCES_RTC_WORDS:
raise EsphomeError(
f"Saved values need {rtc_words} words but ESP8266 RTC memory has {PREFERENCES_RTC_WORDS}; "
"use fewer or smaller saved values, or set restore_from_flash: true"
)
if rtc_words > PREFERENCES_RTC_NORMAL_WORDS:
_LOGGER.warning(
"Saved values need %d of the %d words of ESP8266 RTC memory; values that straddle the two regions "
"and saved settings that are not counted here may not all fit",
rtc_words,
PREFERENCES_RTC_WORDS,
)
# esp8266 namespace is already defined by arduino, manually prefix esphome
@@ -1,8 +1,29 @@
"""The config-time fit rule must match make_preference() in preferences.cpp."""
"""The config-time fit check must agree with make_preference() in preferences.cpp."""
import asyncio
import pytest
from esphome.components.esp8266.const import preference_slots, preference_words
from esphome.components.esp8266.const import (
KEY_ESP8266,
KEY_PREFERENCES,
check_preferences_fit,
preference_words,
reserve_preferences,
)
from esphome.const import KEY_CORE, KEY_TARGET_PLATFORM
from esphome.core import CORE, EsphomeError
@pytest.fixture(autouse=True)
def esp8266_core() -> None:
CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: "esp8266"}
CORE.data[KEY_ESP8266] = {}
CORE.config = {KEY_ESP8266: {"restore_from_flash": False}}
def _check() -> None:
asyncio.run(check_preferences_fit())
@pytest.mark.parametrize(
@@ -14,18 +35,39 @@ def test_preference_words_rounds_up_and_adds_the_checksum(
assert preference_words(value_bytes) == words
def test_rtc_values_cannot_straddle_the_regions() -> None:
# 25 words each: 3 fit in the 78-word region and 1 in the 32-word one
assert preference_slots(94, in_flash=False, restore_from_flash=False) == 4
# 32 words each: 2 + 1, where 110 // 32 would also say 3
assert preference_slots(124, in_flash=False, restore_from_flash=False) == 3
# 39 words each: 2 + 0, where 110 // 39 would say 2 as well but the eboot region is skipped
assert preference_slots(152, in_flash=False, restore_from_flash=False) == 2
# Larger than the biggest region
assert preference_slots(400, in_flash=False, restore_from_flash=False) == 0
def test_reservations_from_several_components_add_up() -> None:
reserve_preferences(2, 94) # 50 words
reserve_preferences(2, 94) # another component, 100 in total
_check()
reserve_preferences(1, 94) # 125 > 110
with pytest.raises(EsphomeError, match="RTC memory has 110"):
_check()
def test_flash_store_size_follows_restore_from_flash() -> None:
assert preference_slots(94, in_flash=True, restore_from_flash=True) == 5
assert preference_slots(94, in_flash=False, restore_from_flash=True) == 5
assert preference_slots(94, in_flash=True, restore_from_flash=False) == 2
def test_a_value_cannot_exceed_the_larger_rtc_region() -> None:
reserve_preferences(1, 400)
with pytest.raises(EsphomeError, match="RTC region"):
_check()
def test_flash_store_follows_restore_from_flash() -> None:
CORE.config[KEY_ESP8266]["restore_from_flash"] = True
reserve_preferences(5, 94) # 125 of 128 words
_check()
reserve_preferences(1, 94)
with pytest.raises(EsphomeError, match="flash store has 128"):
_check()
def test_explicit_flash_values_use_the_small_store_without_restore() -> None:
reserve_preferences(2, 94, in_flash=True) # 50 of 64 words
_check()
reserve_preferences(1, 94, in_flash=True)
with pytest.raises(EsphomeError, match="flash store has 64"):
_check()
def test_reserving_schedules_the_check_once() -> None:
reserve_preferences(1, 4)
reserve_preferences(1, 4)
assert len(CORE.data[KEY_ESP8266][KEY_PREFERENCES]) == 2