diff --git a/esphome/components/esp8266/const.py b/esphome/components/esp8266/const.py index bab40174c4f..a9f53626336 100644 --- a/esphome/components/esp8266/const.py +++ b/esphome/components/esp8266/const.py @@ -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 diff --git a/tests/unit_tests/components/esp8266/test_preferences.py b/tests/unit_tests/components/esp8266/test_preferences.py index 550dbf58b14..fb6c7e554c2 100644 --- a/tests/unit_tests/components/esp8266/test_preferences.py +++ b/tests/unit_tests/components/esp8266/test_preferences.py @@ -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