From f0202155b318f42b8166d5d2046d0ea036a14616 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 30 May 2026 00:09:07 -0500 Subject: [PATCH] [core] Persist esphome.area in StorageJSON (#16710) --- esphome/core/config.py | 1 + esphome/storage_json.py | 7 +++++ tests/unit_tests/core/test_config.py | 27 +++++++++++++++++++ tests/unit_tests/test_storage_json.py | 38 +++++++++++++++++++++++++++ 4 files changed, 73 insertions(+) diff --git a/esphome/core/config.py b/esphome/core/config.py index 6125c4ecc9..8214fcf80c 100644 --- a/esphome/core/config.py +++ b/esphome/core/config.py @@ -722,6 +722,7 @@ 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/esphome/storage_json.py b/esphome/storage_json.py index 3df12f3985..ba576fcfd7 100644 --- a/esphome/storage_json.py +++ b/esphome/storage_json.py @@ -100,6 +100,7 @@ class StorageJSON: framework: str | None = None, core_platform: str | None = None, toolchain: str | None = None, + area: str | None = None, ) -> None: # Version of the storage JSON schema assert storage_version is None or isinstance(storage_version, int) @@ -138,6 +139,8 @@ class StorageJSON: self.core_platform = core_platform # The toolchain used for the build ("platformio" / "esp-idf") self.toolchain = toolchain + # The area of the node + self.area = area def as_dict(self): return { @@ -158,6 +161,7 @@ class StorageJSON: "framework": self.framework, "core_platform": self.core_platform, "toolchain": self.toolchain, + "area": self.area, } def to_json(self): @@ -195,6 +199,7 @@ class StorageJSON: framework=esph.target_framework, core_platform=esph.target_platform, toolchain=esph.toolchain.value if esph.toolchain is not None else None, + area=esph.area, ) @staticmethod @@ -243,6 +248,7 @@ class StorageJSON: framework = storage.get("framework") core_platform = storage.get("core_platform") toolchain = storage.get("toolchain") + area = storage.get("area") return StorageJSON( storage_version, name, @@ -261,6 +267,7 @@ class StorageJSON: framework, core_platform, toolchain, + area, ) @staticmethod diff --git a/tests/unit_tests/core/test_config.py b/tests/unit_tests/core/test_config.py index b5b35b5172..ff150f2540 100644 --- a/tests/unit_tests/core/test_config.py +++ b/tests/unit_tests/core/test_config.py @@ -140,6 +140,33 @@ def test_multiple_areas_and_devices(yaml_file: Callable[[str], str]) -> None: } +@pytest.mark.asyncio +@pytest.mark.filterwarnings("ignore::RuntimeWarning") +@pytest.mark.parametrize( + ("fixture", "expected_area"), + [ + ("legacy_string_area.yaml", "Living Room"), + ("multiple_areas_devices.yaml", "Main Area"), + ], +) +async def test_to_code_records_core_area( + yaml_file: Callable[[str], Path], + fixture: str, + expected_area: str, +) -> None: + """``to_code`` records the node's area name on CORE for StorageJSON.""" + result = load_config_from_fixture(yaml_file, fixture, FIXTURES_DIR) + assert result is not None + assert CORE.area is None + + with patch("esphome.core.config.cg") as mock_cg: + mock_cg.RawStatement.side_effect = lambda *args, **kwargs: MagicMock() + mock_cg.RawExpression.side_effect = lambda *args, **kwargs: MagicMock() + await config.to_code(result[CONF_ESPHOME]) + + assert CORE.area == expected_area + + def test_legacy_string_area( yaml_file: Callable[[str], str], caplog: pytest.LogCaptureFixture ) -> None: diff --git a/tests/unit_tests/test_storage_json.py b/tests/unit_tests/test_storage_json.py index b3f8a05605..105d78505f 100644 --- a/tests/unit_tests/test_storage_json.py +++ b/tests/unit_tests/test_storage_json.py @@ -205,6 +205,7 @@ def test_storage_json_as_dict() -> None: no_mdns=True, framework="arduino", core_platform="esp32", + area="Living Room", ) result = storage.as_dict() @@ -233,6 +234,7 @@ def test_storage_json_as_dict() -> None: assert result["no_mdns"] is True assert result["framework"] == "arduino" assert result["core_platform"] == "esp32" + assert result["area"] == "Living Room" def test_storage_json_to_json() -> None: @@ -309,6 +311,7 @@ def test_storage_json_from_esphome_core(setup_core: Path) -> None: mock_core.config = {CONF_MDNS: {CONF_DISABLED: True}} mock_core.target_framework = "esp-idf" mock_core.toolchain = Toolchain.ESP_IDF + mock_core.area = "Living Room" with patch("esphome.components.esp32.get_esp32_variant") as mock_variant: mock_variant.return_value = "ESP32-C3" @@ -329,6 +332,7 @@ def test_storage_json_from_esphome_core(setup_core: Path) -> None: assert result.framework == "esp-idf" assert result.core_platform == "esp32" assert result.toolchain == "esp-idf" + assert result.area == "Living Room" def test_storage_json_from_esphome_core_mdns_enabled(setup_core: Path) -> None: @@ -729,3 +733,37 @@ def test_storage_json_load_legacy_esphomeyaml_version(tmp_path: Path) -> None: assert result is not None assert result.esphome_version == "1.14.0" # Should map to esphome_version + + +def test_storage_json_load_area(tmp_path: Path) -> None: + """``area`` round-trips through load; absence loads as None.""" + file_path = tmp_path / "with_area.json" + file_path.write_text( + json.dumps( + { + "storage_version": 1, + "name": "lamp", + "friendly_name": "Lamp", + "esp_platform": "ESP32", + "area": "Living Room", + } + ) + ) + result = storage_json.StorageJSON.load(file_path) + assert result is not None + assert result.area == "Living Room" + + legacy_path = tmp_path / "no_area.json" + legacy_path.write_text( + json.dumps( + { + "storage_version": 1, + "name": "lamp", + "friendly_name": "Lamp", + "esp_platform": "ESP32", + } + ) + ) + legacy = storage_json.StorageJSON.load(legacy_path) + assert legacy is not None + assert legacy.area is None