diff --git a/homeassistant/components/device_tracker/config_entry.py b/homeassistant/components/device_tracker/config_entry.py index c4450ab60a7..286929c5345 100644 --- a/homeassistant/components/device_tracker/config_entry.py +++ b/homeassistant/components/device_tracker/config_entry.py @@ -373,7 +373,6 @@ class ScannerEntity(BaseTrackerEntity): # Entities without a unique ID don't have a device if ( not self.registry_entry - or not self.platform or not self.platform.config_entry or not self.mac_address or (device_entry := self.find_device_entry()) is None diff --git a/homeassistant/components/meteo_france/sensor.py b/homeassistant/components/meteo_france/sensor.py index c87aea05260..ca3284d957c 100644 --- a/homeassistant/components/meteo_france/sensor.py +++ b/homeassistant/components/meteo_france/sensor.py @@ -247,11 +247,7 @@ class MeteoFranceSensor(CoordinatorEntity[DataUpdateCoordinator[_DataT]], Sensor @property def device_info(self) -> DeviceInfo: """Return the device info.""" - assert ( - self.platform - and self.platform.config_entry - and self.platform.config_entry.unique_id - ) + assert self.platform.config_entry and self.platform.config_entry.unique_id return DeviceInfo( entry_type=DeviceEntryType.SERVICE, identifiers={(DOMAIN, self.platform.config_entry.unique_id)}, diff --git a/homeassistant/components/meteo_france/weather.py b/homeassistant/components/meteo_france/weather.py index e1a530eef97..7709ba0a638 100644 --- a/homeassistant/components/meteo_france/weather.py +++ b/homeassistant/components/meteo_france/weather.py @@ -109,11 +109,7 @@ class MeteoFranceWeather( @property def device_info(self) -> DeviceInfo: """Return the device info.""" - assert ( - self.platform - and self.platform.config_entry - and self.platform.config_entry.unique_id - ) + assert self.platform.config_entry and self.platform.config_entry.unique_id return DeviceInfo( entry_type=DeviceEntryType.SERVICE, identifiers={(DOMAIN, self.platform.config_entry.unique_id)}, diff --git a/homeassistant/components/mysensors/device.py b/homeassistant/components/mysensors/device.py index d7405dba187..42c5a40636e 100644 --- a/homeassistant/components/mysensors/device.py +++ b/homeassistant/components/mysensors/device.py @@ -227,7 +227,6 @@ class MySensorsEntity(MySensorsDevice, Entity): """Return entity specific state attributes.""" attr = self._extra_attributes - assert self.platform assert self.platform.config_entry attr[ATTR_DEVICE] = self.platform.config_entry.data[CONF_DEVICE] diff --git a/homeassistant/components/synology_dsm/camera.py b/homeassistant/components/synology_dsm/camera.py index b85ef5f2e3a..425475dc0d0 100644 --- a/homeassistant/components/synology_dsm/camera.py +++ b/homeassistant/components/synology_dsm/camera.py @@ -128,7 +128,6 @@ class SynoDSMCamera(SynologyDSMBaseEntity[SynologyDSMCameraUpdateCoordinator], C _LOGGER.debug("Update stream URL for camera %s", self.camera_data.name) self.stream.update_source(url) - assert self.platform assert self.platform.config_entry self.async_on_remove( async_dispatcher_connect( diff --git a/homeassistant/components/tts/media_source.py b/homeassistant/components/tts/media_source.py index 9fc0d40dae0..d371d457dde 100644 --- a/homeassistant/components/tts/media_source.py +++ b/homeassistant/components/tts/media_source.py @@ -165,7 +165,6 @@ class TTSMediaSource(MediaSource): raise BrowseError("Unknown provider") if isinstance(engine_instance, TextToSpeechEntity): - assert engine_instance.platform is not None engine_domain = engine_instance.platform.domain else: engine_domain = engine diff --git a/homeassistant/components/zha/light.py b/homeassistant/components/zha/light.py index 705176ceda4..9e71691aaa5 100644 --- a/homeassistant/components/zha/light.py +++ b/homeassistant/components/zha/light.py @@ -622,7 +622,7 @@ class BaseLight(LogMixin, light.LightEntity): ) if self._debounced_member_refresh is not None: self.debug("transition complete - refreshing group member states") - assert self.platform and self.platform.config_entry + assert self.platform.config_entry self.platform.config_entry.async_create_background_task( self.hass, self._debounced_member_refresh.async_call(), diff --git a/homeassistant/helpers/entity.py b/homeassistant/helpers/entity.py index cdb20833a3d..68f64f0c749 100644 --- a/homeassistant/helpers/entity.py +++ b/homeassistant/helpers/entity.py @@ -258,6 +258,9 @@ class Entity(ABC): # it should be using async_write_ha_state. _async_update_ha_state_reported = False + # If we reported this entity was added without its platform set + _no_platform_reported = False + # Protect for multiple updates _update_staged = False @@ -331,7 +334,6 @@ class Entity(ABC): if hasattr(self, "_attr_name"): return self._attr_name if self.translation_key is not None and self.has_entity_name: - assert self.platform name_translation_key = ( f"component.{self.platform.platform_name}.entity.{self.platform.domain}" f".{self.translation_key}.name" @@ -584,6 +586,22 @@ class Entity(ABC): if self.hass is None: raise RuntimeError(f"Attribute hass is None for {self}") + # The check for self.platform guards against integrations not using an + # EntityComponent and can be removed in HA Core 2024.1 + if self.platform is None and not self._no_platform_reported: # type: ignore[unreachable] + report_issue = self._suggest_report_issue() # type: ignore[unreachable] + _LOGGER.warning( + ( + "Entity %s (%s) does not have a platform, this may be caused by " + "adding it manually instead of with an EntityComponent helper" + ", please %s" + ), + self.entity_id, + type(self), + report_issue, + ) + self._no_platform_reported = True + if self.entity_id is None: raise NoEntitySpecifiedError( f"No entity id specified for entity {self.name}" @@ -636,7 +654,6 @@ class Entity(ABC): if entry and entry.disabled_by: if not self._disabled_reported: self._disabled_reported = True - assert self.platform is not None _LOGGER.warning( ( "Entity %s is incorrectly being triggered for updates while it" @@ -861,6 +878,8 @@ class Entity(ABC): If the entity doesn't have a non disabled entry in the entity registry, or if force_remove=True, its state will be removed. """ + # The check for self.platform guards against integrations not using an + # EntityComponent and can be removed in HA Core 2024.1 if self.platform and self._platform_state != EntityPlatformState.ADDED: raise HomeAssistantError( f"Entity {self.entity_id} async_remove called twice" @@ -908,19 +927,18 @@ class Entity(ABC): Not to be extended by integrations. """ - if self.platform: - info = { - "domain": self.platform.platform_name, - "custom_component": "custom_components" in type(self).__module__, - } + info = { + "domain": self.platform.platform_name, + "custom_component": "custom_components" in type(self).__module__, + } - if self.platform.config_entry: - info["source"] = SOURCE_CONFIG_ENTRY - info["config_entry"] = self.platform.config_entry.entry_id - else: - info["source"] = SOURCE_PLATFORM_CONFIG + if self.platform.config_entry: + info["source"] = SOURCE_CONFIG_ENTRY + info["config_entry"] = self.platform.config_entry.entry_id + else: + info["source"] = SOURCE_PLATFORM_CONFIG - self.hass.data[DATA_ENTITY_SOURCE][self.entity_id] = info + self.hass.data[DATA_ENTITY_SOURCE][self.entity_id] = info if self.registry_entry is not None: # This is an assert as it should never happen, but helps in tests @@ -940,6 +958,8 @@ class Entity(ABC): Not to be extended by integrations. """ + # The check for self.platform guards against integrations not using an + # EntityComponent and can be removed in HA Core 2024.1 if self.platform: self.hass.data[DATA_ENTITY_SOURCE].pop(self.entity_id) @@ -974,7 +994,6 @@ class Entity(ABC): await self.async_remove(force_remove=True) - assert self.platform is not None self.entity_id = self.registry_entry.entity_id await self.platform.async_add_entities([self]) @@ -1048,6 +1067,8 @@ class Entity(ABC): "create a bug report at " "https://github.com/home-assistant/core/issues?q=is%3Aopen+is%3Aissue" ) + # The check for self.platform guards against integrations not using an + # EntityComponent and can be removed in HA Core 2024.1 if self.platform: report_issue += ( f"+label%3A%22integration%3A+{self.platform.platform_name}%22" diff --git a/tests/components/arcam_fmj/conftest.py b/tests/components/arcam_fmj/conftest.py index 39b44d2ad9e..693cdc685c9 100644 --- a/tests/components/arcam_fmj/conftest.py +++ b/tests/components/arcam_fmj/conftest.py @@ -9,7 +9,7 @@ from homeassistant.components.arcam_fmj.const import DEFAULT_NAME from homeassistant.components.arcam_fmj.media_player import ArcamFmj from homeassistant.const import CONF_HOST, CONF_PORT -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, MockEntityPlatform MOCK_HOST = "127.0.0.1" MOCK_PORT = 50000 @@ -75,6 +75,7 @@ def player_fixture(hass, state): player = ArcamFmj(MOCK_NAME, state, MOCK_UUID) player.entity_id = MOCK_ENTITY_ID player.hass = hass + player.platform = MockEntityPlatform(hass) player.async_write_ha_state = Mock() return player diff --git a/tests/components/number/test_init.py b/tests/components/number/test_init.py index 6cd9a53b6f4..b039f3c7eb5 100644 --- a/tests/components/number/test_init.py +++ b/tests/components/number/test_init.py @@ -35,6 +35,7 @@ from homeassistant.setup import async_setup_component from homeassistant.util.unit_system import METRIC_SYSTEM, US_CUSTOMARY_SYSTEM from tests.common import ( + MockEntityPlatform, async_mock_restore_state_shutdown_restart, mock_restore_cache_with_extra_data, ) @@ -246,6 +247,7 @@ async def test_deprecation_warnings( """Test overriding the deprecated attributes is possible and warnings are logged.""" number = MockDefaultNumberEntityDeprecated() number.hass = hass + number.platform = MockEntityPlatform(hass) assert number.max_value == 100.0 assert number.min_value == 0.0 assert number.step == 1.0 @@ -254,6 +256,7 @@ async def test_deprecation_warnings( number_2 = MockNumberEntityDeprecated() number_2.hass = hass + number_2.platform = MockEntityPlatform(hass) assert number_2.max_value == 0.5 assert number_2.min_value == -0.5 assert number_2.step == 0.1 @@ -262,6 +265,7 @@ async def test_deprecation_warnings( number_3 = MockNumberEntityAttrDeprecated() number_3.hass = hass + number_3.platform = MockEntityPlatform(hass) assert number_3.max_value == 1000.0 assert number_3.min_value == -1000.0 assert number_3.step == 100.0 @@ -270,6 +274,7 @@ async def test_deprecation_warnings( number_4 = MockNumberEntityDescrDeprecated() number_4.hass = hass + number_4.platform = MockEntityPlatform(hass) assert number_4.max_value == 10.0 assert number_4.min_value == -10.0 assert number_4.step == 2.0 diff --git a/tests/helpers/test_entity.py b/tests/helpers/test_entity.py index e556d9d5451..3ea820e684c 100644 --- a/tests/helpers/test_entity.py +++ b/tests/helpers/test_entity.py @@ -578,12 +578,14 @@ async def test_async_remove_no_platform(hass: HomeAssistant) -> None: async def test_async_remove_runs_callbacks(hass: HomeAssistant) -> None: - """Test async_remove method when no platform set.""" + """Test async_remove runs on_remove callback.""" result = [] + platform = MockEntityPlatform(hass, domain="test") ent = entity.Entity() ent.hass = hass ent.entity_id = "test.test" + await platform.async_add_entities([ent]) ent.async_on_remove(lambda: result.append(1)) await ent.async_remove() assert len(result) == 1 @@ -593,11 +595,12 @@ async def test_async_remove_ignores_in_flight_polling(hass: HomeAssistant) -> No """Test in flight polling is ignored after removing.""" result = [] + platform = MockEntityPlatform(hass, domain="test") ent = entity.Entity() ent.hass = hass ent.entity_id = "test.test" ent.async_on_remove(lambda: result.append(1)) - ent.async_write_ha_state() + await platform.async_add_entities([ent]) assert hass.states.get("test.test").state == STATE_UNKNOWN await ent.async_remove() assert len(result) == 1 @@ -798,18 +801,18 @@ async def test_setup_source(hass: HomeAssistant) -> None: async def test_removing_entity_unavailable(hass: HomeAssistant) -> None: """Test removing an entity that is still registered creates an unavailable state.""" - entry = er.RegistryEntry( + er.RegistryEntry( entity_id="hello.world", unique_id="test-unique-id", platform="test-platform", disabled_by=None, ) + platform = MockEntityPlatform(hass, domain="hello") ent = entity.Entity() - ent.hass = hass ent.entity_id = "hello.world" - ent.registry_entry = entry - ent.async_write_ha_state() + ent._attr_unique_id = "test-unique-id" + await platform.async_add_entities([ent]) state = hass.states.get("hello.world") assert state is not None @@ -1112,19 +1115,48 @@ async def test_warn_using_async_update_ha_state( """Test we warn once when using async_update_ha_state without force_update.""" ent = entity.Entity() ent.hass = hass + ent.platform = MockEntityPlatform(hass) ent.entity_id = "hello.world" + error_message = "is using self.async_update_ha_state()" # When forcing, it should not trigger the warning caplog.clear() await ent.async_update_ha_state(force_refresh=True) - assert "is using self.async_update_ha_state()" not in caplog.text + assert error_message not in caplog.text # When not forcing, it should trigger the warning caplog.clear() await ent.async_update_ha_state() - assert "is using self.async_update_ha_state()" in caplog.text + assert error_message in caplog.text # When not forcing, it should not trigger the warning again caplog.clear() await ent.async_update_ha_state() - assert "is using self.async_update_ha_state()" not in caplog.text + assert error_message not in caplog.text + + +async def test_warn_no_platform( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test we warn am entity does not have a platform.""" + ent = entity.Entity() + ent.hass = hass + ent.platform = MockEntityPlatform(hass) + ent.entity_id = "hello.world" + error_message = "does not have a platform" + + # No warning if the entity has a platform + caplog.clear() + ent.async_write_ha_state() + assert error_message not in caplog.text + + # Without a platform, it should trigger the warning + ent.platform = None + caplog.clear() + ent.async_write_ha_state() + assert error_message in caplog.text + + # Without a platform, it should not trigger the warning again + caplog.clear() + ent.async_write_ha_state() + assert error_message not in caplog.text diff --git a/tests/helpers/test_restore_state.py b/tests/helpers/test_restore_state.py index b5ce7afade0..56e931b4345 100644 --- a/tests/helpers/test_restore_state.py +++ b/tests/helpers/test_restore_state.py @@ -27,6 +27,7 @@ from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import dt as dt_util from tests.common import ( + MockEntityPlatform, MockModule, MockPlatform, async_fire_time_changed, @@ -266,15 +267,16 @@ async def test_dump_data(hass: HomeAssistant) -> None: State("input_boolean.b5", "unavailable", {"restored": True}), ] + platform = MockEntityPlatform(hass, domain="input_boolean") entity = Entity() entity.hass = hass entity.entity_id = "input_boolean.b0" - await entity.async_internal_added_to_hass() + await platform.async_add_entities([entity]) entity = RestoreEntity() entity.hass = hass entity.entity_id = "input_boolean.b1" - await entity.async_internal_added_to_hass() + await platform.async_add_entities([entity]) data = async_get(hass) now = dt_util.utcnow() @@ -340,15 +342,16 @@ async def test_dump_error(hass: HomeAssistant) -> None: State("input_boolean.b2", "on"), ] + platform = MockEntityPlatform(hass, domain="input_boolean") entity = Entity() entity.hass = hass entity.entity_id = "input_boolean.b0" - await entity.async_internal_added_to_hass() + await platform.async_add_entities([entity]) entity = RestoreEntity() entity.hass = hass entity.entity_id = "input_boolean.b1" - await entity.async_internal_added_to_hass() + await platform.async_add_entities([entity]) data = async_get(hass) @@ -378,10 +381,11 @@ async def test_load_error(hass: HomeAssistant) -> None: async def test_state_saved_on_remove(hass: HomeAssistant) -> None: """Test that we save entity state on removal.""" + platform = MockEntityPlatform(hass, domain="input_boolean") entity = RestoreEntity() entity.hass = hass entity.entity_id = "input_boolean.b0" - await entity.async_internal_added_to_hass() + await platform.async_add_entities([entity]) now = dt_util.utcnow() hass.states.async_set(