diff --git a/esphome/core/config.py b/esphome/core/config.py index b925f0b7d9..59c96035b8 100644 --- a/esphome/core/config.py +++ b/esphome/core/config.py @@ -407,6 +407,17 @@ def preload_core_config(config, result) -> str: CORE.name = conf[CONF_NAME] CORE.friendly_name = conf.get(CONF_FRIENDLY_NAME) + # Record the node's area name now (substitutions are already resolved at this + # point). storage.json is written before to_code() runs, so deferring this to + # to_code() left the area as null in storage.json. The value here is the raw + # post-substitution form (a plain string or a {name: ...} mapping). Assign + # unconditionally (like friendly_name) so a config without an area never + # inherits a stale value from a previous load in a long-running process, and + # use .get() so a malformed mapping surfaces later as a proper validation + # error rather than a KeyError here. to_code() sets it again from the + # validated config, which yields the same name. + area = conf.get(CONF_AREA) + CORE.area = area.get(CONF_NAME) if isinstance(area, dict) else area CORE.data[KEY_CORE] = {} if CONF_BUILD_PATH not in conf: @@ -760,7 +771,6 @@ async def to_code(config: ConfigType) -> None: # Process areas all_areas: list[dict[str, str | core.ID]] = [] if CONF_AREA in config: - CORE.area = config[CONF_AREA][CONF_NAME] all_areas.append(config[CONF_AREA]) all_areas.extend(config[CONF_AREAS]) diff --git a/tests/unit_tests/core/test_config.py b/tests/unit_tests/core/test_config.py index e2b34d92d8..b3d87f6857 100644 --- a/tests/unit_tests/core/test_config.py +++ b/tests/unit_tests/core/test_config.py @@ -152,15 +152,21 @@ def test_multiple_areas_and_devices(yaml_file: Callable[[str], str]) -> None: ("multiple_areas_devices.yaml", "Main Area"), ], ) -async def test_to_code_records_core_area( +async def test_core_area_recorded_at_config_load( yaml_file: Callable[[str], Path], fixture: str, expected_area: str, ) -> None: - """``to_code`` records the node's area name on CORE for StorageJSON.""" + """The node's area name is recorded on CORE for StorageJSON. + + It must be set during config load (preload_core_config), not deferred to + to_code(): storage.json is written before to_code() runs, so a late + assignment left the area as null in storage.json (regression #17218). + """ result = load_config_from_fixture(yaml_file, fixture, FIXTURES_DIR) assert result is not None - assert CORE.area is None + # Recorded already at config-load time, before any code generation. + assert CORE.area == expected_area with patch("esphome.core.config.cg") as mock_cg: mock_cg.RawStatement.side_effect = lambda *args, **kwargs: MagicMock() @@ -170,6 +176,23 @@ async def test_to_code_records_core_area( assert CORE.area == expected_area +def test_config_load_without_area_clears_stale_core_area( + yaml_file: Callable[[str], Path], +) -> None: + """A config without an area must not inherit a stale CORE.area. + + preload_core_config assigns CORE.area unconditionally, so the area from a + previous load in a long-running process cannot leak into a config that + omits it. + """ + CORE.area = "Stale Area From Previous Load" + result = load_config_from_fixture( + yaml_file, "device_without_area.yaml", FIXTURES_DIR + ) + assert result is not None + assert CORE.area is None + + def test_legacy_string_area( yaml_file: Callable[[str], str], caplog: pytest.LogCaptureFixture ) -> None: