diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 4115b97391..d73009436b 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -33,6 +33,7 @@ #define USE_DEEP_SLEEP #define USE_DEVICES #define USE_DISPLAY +#define USE_ENTITY_ICON #define USE_ESP32_IMPROV_STATE_CALLBACK #define USE_EVENT #define USE_FAN diff --git a/esphome/core/entity_base.cpp b/esphome/core/entity_base.cpp index 6afd02ff65..2ea9c77a3e 100644 --- a/esphome/core/entity_base.cpp +++ b/esphome/core/entity_base.cpp @@ -27,12 +27,22 @@ void EntityBase::set_name(const char *name) { // Entity Icon std::string EntityBase::get_icon() const { +#ifdef USE_ENTITY_ICON if (this->icon_c_str_ == nullptr) { return ""; } return this->icon_c_str_; +#else + return ""; +#endif +} +void EntityBase::set_icon(const char *icon) { +#ifdef USE_ENTITY_ICON + this->icon_c_str_ = icon; +#else + // No-op when USE_ENTITY_ICON is not defined +#endif } -void EntityBase::set_icon(const char *icon) { this->icon_c_str_ = icon; } // Entity Object ID std::string EntityBase::get_object_id() const { diff --git a/esphome/core/entity_base.h b/esphome/core/entity_base.h index 4819b66108..00b1264ed0 100644 --- a/esphome/core/entity_base.h +++ b/esphome/core/entity_base.h @@ -80,7 +80,9 @@ class EntityBase { StringRef name_; const char *object_id_c_str_{nullptr}; +#ifdef USE_ENTITY_ICON const char *icon_c_str_{nullptr}; +#endif uint32_t object_id_hash_{}; #ifdef USE_DEVICES Device *device_{}; diff --git a/esphome/core/entity_helpers.py b/esphome/core/entity_helpers.py index 2442fbca4b..a3244856a2 100644 --- a/esphome/core/entity_helpers.py +++ b/esphome/core/entity_helpers.py @@ -1,6 +1,7 @@ from collections.abc import Callable import logging +import esphome.codegen as cg import esphome.config_validation as cv from esphome.const import ( CONF_DEVICE_ID, @@ -108,6 +109,8 @@ async def setup_entity(var: MockObj, config: ConfigType, platform: str) -> None: if CONF_INTERNAL in config: add(var.set_internal(config[CONF_INTERNAL])) if CONF_ICON in config: + # Add USE_ENTITY_ICON define when icons are used + cg.add_define("USE_ENTITY_ICON") add(var.set_icon(config[CONF_ICON])) if CONF_ENTITY_CATEGORY in config: add(var.set_entity_category(config[CONF_ENTITY_CATEGORY])) diff --git a/tests/integration/fixtures/entity_icon.yaml b/tests/integration/fixtures/entity_icon.yaml new file mode 100644 index 0000000000..2ce633fe2c --- /dev/null +++ b/tests/integration/fixtures/entity_icon.yaml @@ -0,0 +1,78 @@ +esphome: + name: icon-test + +host: + +api: + +logger: + +# Test entities with custom icons +sensor: + - platform: template + name: "Sensor With Icon" + icon: "mdi:temperature-celsius" + unit_of_measurement: "°C" + update_interval: 1s + lambda: |- + return 25.5; + + - platform: template + name: "Sensor Without Icon" + unit_of_measurement: "%" + update_interval: 1s + lambda: |- + return 50.0; + +binary_sensor: + - platform: template + name: "Binary Sensor With Icon" + icon: "mdi:motion-sensor" + lambda: |- + return true; + + - platform: template + name: "Binary Sensor Without Icon" + lambda: |- + return false; + +text_sensor: + - platform: template + name: "Text Sensor With Icon" + icon: "mdi:text-box" + lambda: |- + return {"Hello Icons"}; + +switch: + - platform: template + name: "Switch With Icon" + icon: "mdi:toggle-switch" + optimistic: true + +button: + - platform: template + name: "Button With Icon" + icon: "mdi:gesture-tap-button" + on_press: + - logger.log: "Button with icon pressed" + +number: + - platform: template + name: "Number With Icon" + icon: "mdi:numeric" + initial_value: 42 + min_value: 0 + max_value: 100 + step: 1 + optimistic: true + +select: + - platform: template + name: "Select With Icon" + icon: "mdi:format-list-bulleted" + options: + - "Option A" + - "Option B" + - "Option C" + initial_option: "Option A" + optimistic: true diff --git a/tests/integration/test_entity_icon.py b/tests/integration/test_entity_icon.py new file mode 100644 index 0000000000..56e266b486 --- /dev/null +++ b/tests/integration/test_entity_icon.py @@ -0,0 +1,97 @@ +"""Integration test for entity icons with USE_ENTITY_ICON feature.""" + +from __future__ import annotations + +import asyncio + +from aioesphomeapi import EntityState +import pytest + +from .types import APIClientConnectedFactory, RunCompiledFunction + + +@pytest.mark.asyncio +async def test_entity_icon( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Test that entities with custom icons work correctly with USE_ENTITY_ICON.""" + # Write, compile and run the ESPHome device, then connect to API + async with run_compiled(yaml_config), api_client_connected() as client: + # Get all entities + entities = await client.list_entities_services() + + # Create a map of entity names to entity info + entity_map = {entity.name: entity for entity in entities[0]} + + # Test entities with icons + icon_test_cases = [ + # (entity_name, expected_icon) + ("Sensor With Icon", "mdi:temperature-celsius"), + ("Binary Sensor With Icon", "mdi:motion-sensor"), + ("Text Sensor With Icon", "mdi:text-box"), + ("Switch With Icon", "mdi:toggle-switch"), + ("Button With Icon", "mdi:gesture-tap-button"), + ("Number With Icon", "mdi:numeric"), + ("Select With Icon", "mdi:format-list-bulleted"), + ] + + # Test entities without icons (should have empty string) + no_icon_test_cases = [ + "Sensor Without Icon", + "Binary Sensor Without Icon", + ] + + # Verify entities with icons + for entity_name, expected_icon in icon_test_cases: + assert entity_name in entity_map, ( + f"Entity '{entity_name}' not found in API response" + ) + entity = entity_map[entity_name] + + # Check icon field + assert hasattr(entity, "icon"), ( + f"{entity_name}: Entity should have icon attribute" + ) + assert entity.icon == expected_icon, ( + f"{entity_name}: icon mismatch - " + f"expected '{expected_icon}', got '{entity.icon}'" + ) + + # Verify entities without icons + for entity_name in no_icon_test_cases: + assert entity_name in entity_map, ( + f"Entity '{entity_name}' not found in API response" + ) + entity = entity_map[entity_name] + + # Check icon field is empty + assert hasattr(entity, "icon"), ( + f"{entity_name}: Entity should have icon attribute" + ) + assert entity.icon == "", ( + f"{entity_name}: icon should be empty string for entities without icons, " + f"got '{entity.icon}'" + ) + + # Subscribe to states to ensure everything works normally + states: dict[int, EntityState] = {} + state_received = asyncio.Event() + + def on_state(state: EntityState) -> None: + states[state.key] = state + state_received.set() + + client.subscribe_states(on_state) + + # Wait for states + try: + await asyncio.wait_for(state_received.wait(), timeout=5.0) + except asyncio.TimeoutError: + pytest.fail("No states received within 5 seconds") + + # Verify we received states + assert len(states) > 0, ( + "No states received - entities may not be working correctly" + )