From 30c5baf5228eb5adb5fa2a65078c4775c27b5d67 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Wed, 31 Jan 2024 12:47:23 +0100 Subject: [PATCH] Add configflow to Proximity integration (#103894) * add config flow * fix tests * adjust and fix tests * fix tests * config_zones as fixture * add config flow tests * use coordinator.async_config_entry_first_refresh * use entry.entry_id for hass.data * fix doc string * remove unused unit_of_measurement string key * don't store friendly_name, just use self.name * abort on matching entiry * break out legacy setup into seperate function * make tracked entites required * move _asnyc_setup_legacy to module level * use zone name as config entry title * add entity_used_in helper * check entry source if imported * create repair issue for removed tracked entities * separate state change from registry change event handling * migrate unique ids after tracked entity renamed * use full words for the variable names * use defaultdict * add test * remove unnecessary if not in check * use unique_id of tracked entity * use the entity registry entry id * Apply suggestions from code review Co-authored-by: Martin Hjelmare --------- Co-authored-by: Martin Hjelmare --- .../components/proximity/__init__.py | 179 ++++++--- .../components/proximity/config_flow.py | 133 +++++++ homeassistant/components/proximity/const.py | 1 + .../components/proximity/coordinator.py | 93 ++++- homeassistant/components/proximity/helpers.py | 11 + .../components/proximity/manifest.json | 1 + homeassistant/components/proximity/sensor.py | 92 +++-- .../components/proximity/strings.json | 40 ++ homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 2 +- tests/components/proximity/conftest.py | 20 + .../components/proximity/test_config_flow.py | 187 +++++++++ tests/components/proximity/test_init.py | 366 +++++++++++++----- 13 files changed, 919 insertions(+), 207 deletions(-) create mode 100644 homeassistant/components/proximity/config_flow.py create mode 100644 homeassistant/components/proximity/helpers.py create mode 100644 tests/components/proximity/conftest.py create mode 100644 tests/components/proximity/test_config_flow.py diff --git a/homeassistant/components/proximity/__init__.py b/homeassistant/components/proximity/__init__.py index fabbcaec51a..3f28028d703 100644 --- a/homeassistant/components/proximity/__init__.py +++ b/homeassistant/components/proximity/__init__.py @@ -5,18 +5,20 @@ import logging import voluptuous as vol -from homeassistant.components.automation import automations_with_entity -from homeassistant.components.script import scripts_with_entity +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import ( CONF_DEVICES, CONF_NAME, CONF_UNIT_OF_MEASUREMENT, CONF_ZONE, + Platform, ) -from homeassistant.core import HomeAssistant +from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.discovery import async_load_platform -from homeassistant.helpers.event import async_track_state_change +from homeassistant.helpers.event import ( + async_track_entity_registry_updated_event, + async_track_state_change, +) from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.typing import ConfigType from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -27,12 +29,14 @@ from .const import ( ATTR_NEAREST, CONF_IGNORED_ZONES, CONF_TOLERANCE, + CONF_TRACKED_ENTITIES, DEFAULT_PROXIMITY_ZONE, DEFAULT_TOLERANCE, DOMAIN, UNITS, ) from .coordinator import ProximityDataUpdateCoordinator +from .helpers import entity_used_in _LOGGER = logging.getLogger(__name__) @@ -49,63 +53,134 @@ ZONE_SCHEMA = vol.Schema( ) CONFIG_SCHEMA = vol.Schema( - {DOMAIN: cv.schema_with_slug_keys(ZONE_SCHEMA)}, extra=vol.ALLOW_EXTRA + vol.All( + cv.deprecated(DOMAIN), + {DOMAIN: cv.schema_with_slug_keys(ZONE_SCHEMA)}, + ), + extra=vol.ALLOW_EXTRA, ) +async def _async_setup_legacy( + hass: HomeAssistant, entry: ConfigEntry, coordinator: ProximityDataUpdateCoordinator +) -> None: + """Legacy proximity entity handling, can be removed in 2024.8.""" + friendly_name = entry.data[CONF_NAME] + proximity = Proximity(hass, friendly_name, coordinator) + await proximity.async_added_to_hass() + proximity.async_write_ha_state() + + if used_in := entity_used_in(hass, f"{DOMAIN}.{friendly_name}"): + async_create_issue( + hass, + DOMAIN, + f"deprecated_proximity_entity_{friendly_name}", + breaks_in_ha_version="2024.8.0", + is_fixable=True, + is_persistent=True, + severity=IssueSeverity.WARNING, + translation_key="deprecated_proximity_entity", + translation_placeholders={ + "entity": f"{DOMAIN}.{friendly_name}", + "used_in": "\n- ".join([f"`{x}`" for x in used_in]), + }, + ) + + async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Get the zones and offsets from configuration.yaml.""" - hass.data.setdefault(DOMAIN, {}) - for friendly_name, proximity_config in config[DOMAIN].items(): - _LOGGER.debug("setup %s with config:%s", friendly_name, proximity_config) - - coordinator = ProximityDataUpdateCoordinator( - hass, friendly_name, proximity_config - ) - - async_track_state_change( - hass, - proximity_config[CONF_DEVICES], - coordinator.async_check_proximity_state_change, - ) - - await coordinator.async_refresh() - hass.data[DOMAIN][friendly_name] = coordinator - - proximity = Proximity(hass, friendly_name, coordinator) - await proximity.async_added_to_hass() - proximity.async_write_ha_state() - - await async_load_platform( - hass, - "sensor", - DOMAIN, - {CONF_NAME: friendly_name, **proximity_config}, - config, - ) - - # deprecate proximity entity - can be removed in 2024.8 - used_in = automations_with_entity(hass, f"{DOMAIN}.{friendly_name}") - used_in += scripts_with_entity(hass, f"{DOMAIN}.{friendly_name}") - if used_in: - async_create_issue( - hass, - DOMAIN, - f"deprecated_proximity_entity_{friendly_name}", - breaks_in_ha_version="2024.8.0", - is_fixable=True, - is_persistent=True, - severity=IssueSeverity.WARNING, - translation_key="deprecated_proximity_entity", - translation_placeholders={ - "entity": f"{DOMAIN}.{friendly_name}", - "used_in": "\n- ".join([f"`{x}`" for x in used_in]), - }, + if DOMAIN in config: + for friendly_name, proximity_config in config[DOMAIN].items(): + _LOGGER.debug("import %s with config:%s", friendly_name, proximity_config) + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data={ + CONF_NAME: friendly_name, + CONF_ZONE: f"zone.{proximity_config[CONF_ZONE]}", + CONF_TRACKED_ENTITIES: proximity_config[CONF_DEVICES], + CONF_IGNORED_ZONES: [ + f"zone.{zone}" + for zone in proximity_config[CONF_IGNORED_ZONES] + ], + CONF_TOLERANCE: proximity_config[CONF_TOLERANCE], + CONF_UNIT_OF_MEASUREMENT: proximity_config.get( + CONF_UNIT_OF_MEASUREMENT, hass.config.units.length_unit + ), + }, + ) ) + async_create_issue( + hass, + HOMEASSISTANT_DOMAIN, + f"deprecated_yaml_{DOMAIN}", + breaks_in_ha_version="2024.8.0", + is_fixable=False, + issue_domain=DOMAIN, + severity=IssueSeverity.WARNING, + translation_key="deprecated_yaml", + translation_placeholders={ + "domain": DOMAIN, + "integration_title": "Proximity", + }, + ) + return True +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Proximity from a config entry.""" + _LOGGER.debug("setup %s with config:%s", entry.title, entry.data) + + hass.data.setdefault(DOMAIN, {}) + + coordinator = ProximityDataUpdateCoordinator(hass, entry.title, dict(entry.data)) + + entry.async_on_unload( + async_track_state_change( + hass, + entry.data[CONF_TRACKED_ENTITIES], + coordinator.async_check_proximity_state_change, + ) + ) + + entry.async_on_unload( + async_track_entity_registry_updated_event( + hass, + entry.data[CONF_TRACKED_ENTITIES], + coordinator.async_check_tracked_entity_change, + ) + ) + + await coordinator.async_config_entry_first_refresh() + hass.data[DOMAIN][entry.entry_id] = coordinator + + if entry.source == SOURCE_IMPORT: + await _async_setup_legacy(hass, entry, coordinator) + + await hass.config_entries.async_forward_entry_setups(entry, [Platform.SENSOR]) + entry.async_on_unload(entry.add_update_listener(_async_update_listener)) + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + unload_ok = await hass.config_entries.async_unload_platforms( + entry, [Platform.SENSOR] + ) + if unload_ok: + hass.data[DOMAIN].pop(entry.entry_id) + + return unload_ok + + +async def _async_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Handle options update.""" + await hass.config_entries.async_reload(entry.entry_id) + + class Proximity(CoordinatorEntity[ProximityDataUpdateCoordinator]): """Representation of a Proximity.""" diff --git a/homeassistant/components/proximity/config_flow.py b/homeassistant/components/proximity/config_flow.py new file mode 100644 index 00000000000..231a50c6c00 --- /dev/null +++ b/homeassistant/components/proximity/config_flow.py @@ -0,0 +1,133 @@ +"""Config flow for proximity.""" +from __future__ import annotations + +from typing import Any, cast + +import voluptuous as vol + +from homeassistant.components.device_tracker import DOMAIN as DEVICE_TRACKER_DOMAIN +from homeassistant.components.person import DOMAIN as PERSON_DOMAIN +from homeassistant.components.zone import DOMAIN as ZONE_DOMAIN +from homeassistant.config_entries import ConfigEntry, ConfigFlow, OptionsFlow +from homeassistant.const import CONF_ZONE +from homeassistant.core import State, callback +from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers.selector import ( + EntitySelector, + EntitySelectorConfig, + NumberSelector, + NumberSelectorConfig, +) + +from .const import ( + CONF_IGNORED_ZONES, + CONF_TOLERANCE, + CONF_TRACKED_ENTITIES, + DEFAULT_PROXIMITY_ZONE, + DEFAULT_TOLERANCE, + DOMAIN, +) + +RESULT_SUCCESS = "success" + + +def _base_schema(user_input: dict[str, Any]) -> vol.Schema: + return { + vol.Required( + CONF_TRACKED_ENTITIES, default=user_input.get(CONF_TRACKED_ENTITIES, []) + ): EntitySelector( + EntitySelectorConfig( + domain=[DEVICE_TRACKER_DOMAIN, PERSON_DOMAIN], multiple=True + ), + ), + vol.Optional( + CONF_IGNORED_ZONES, default=user_input.get(CONF_IGNORED_ZONES, []) + ): EntitySelector( + EntitySelectorConfig(domain=ZONE_DOMAIN, multiple=True), + ), + vol.Required( + CONF_TOLERANCE, + default=user_input.get(CONF_TOLERANCE, DEFAULT_TOLERANCE), + ): NumberSelector( + NumberSelectorConfig(min=1, max=100, step=1), + ), + } + + +class ProximityConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a proximity config flow.""" + + VERSION = 1 + + def _user_form_schema(self, user_input: dict[str, Any] | None = None) -> vol.Schema: + if user_input is None: + user_input = {} + return vol.Schema( + { + vol.Required( + CONF_ZONE, + default=user_input.get( + CONF_ZONE, f"{ZONE_DOMAIN}.{DEFAULT_PROXIMITY_ZONE}" + ), + ): EntitySelector( + EntitySelectorConfig(domain=ZONE_DOMAIN), + ), + **_base_schema(user_input), + } + ) + + @staticmethod + @callback + def async_get_options_flow(config_entry: ConfigEntry) -> OptionsFlow: + """Get the options flow for this handler.""" + return ProximityOptionsFlow(config_entry) + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle a flow initialized by the user.""" + if user_input is not None: + self._async_abort_entries_match(user_input) + + zone = self.hass.states.get(user_input[CONF_ZONE]) + + return self.async_create_entry( + title=cast(State, zone).name, data=user_input + ) + + return self.async_show_form( + step_id="user", + data_schema=self._user_form_schema(user_input), + ) + + async def async_step_import( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Import a yaml config entry.""" + return await self.async_step_user(user_input) + + +class ProximityOptionsFlow(OptionsFlow): + """Handle a option flow.""" + + def __init__(self, config_entry: ConfigEntry) -> None: + """Initialize options flow.""" + self.config_entry = config_entry + + def _user_form_schema(self, user_input: dict[str, Any]) -> vol.Schema: + return vol.Schema(_base_schema(user_input)) + + async def async_step_init( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle options flow.""" + if user_input is not None: + self.hass.config_entries.async_update_entry( + self.config_entry, data={**self.config_entry.data, **user_input} + ) + return self.async_create_entry(title=self.config_entry.title, data={}) + + return self.async_show_form( + step_id="init", + data_schema=self._user_form_schema(dict(self.config_entry.data)), + ) diff --git a/homeassistant/components/proximity/const.py b/homeassistant/components/proximity/const.py index 166029fef37..7627d550e1f 100644 --- a/homeassistant/components/proximity/const.py +++ b/homeassistant/components/proximity/const.py @@ -13,6 +13,7 @@ ATTR_PROXIMITY_DATA: Final = "proximity_data" CONF_IGNORED_ZONES = "ignored_zones" CONF_TOLERANCE = "tolerance" +CONF_TRACKED_ENTITIES = "tracked_entities" DEFAULT_DIR_OF_TRAVEL = "not set" DEFAULT_DIST_TO_ZONE = "not set" diff --git a/homeassistant/components/proximity/coordinator.py b/homeassistant/components/proximity/coordinator.py index 05561bd0406..4ae923276cc 100644 --- a/homeassistant/components/proximity/coordinator.py +++ b/homeassistant/components/proximity/coordinator.py @@ -1,19 +1,23 @@ """Data update coordinator for the Proximity integration.""" +from collections import defaultdict from dataclasses import dataclass import logging +from homeassistant.components.zone import DOMAIN as ZONE_DOMAIN +from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_LATITUDE, ATTR_LONGITUDE, ATTR_NAME, - CONF_DEVICES, CONF_UNIT_OF_MEASUREMENT, CONF_ZONE, UnitOfLength, ) -from homeassistant.core import HomeAssistant, State -from homeassistant.helpers.typing import ConfigType +from homeassistant.core import HomeAssistant, State, callback +from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue +from homeassistant.helpers.typing import ConfigType, EventType from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from homeassistant.util.location import distance from homeassistant.util.unit_conversion import DistanceConverter @@ -25,9 +29,11 @@ from .const import ( ATTR_NEAREST, CONF_IGNORED_ZONES, CONF_TOLERANCE, + CONF_TRACKED_ENTITIES, DEFAULT_DIR_OF_TRAVEL, DEFAULT_DIST_TO_ZONE, DEFAULT_NEAREST, + DOMAIN, ) _LOGGER = logging.getLogger(__name__) @@ -63,18 +69,21 @@ DEFAULT_DATA = ProximityData( class ProximityDataUpdateCoordinator(DataUpdateCoordinator[ProximityData]): """Proximity data update coordinator.""" + config_entry: ConfigEntry + def __init__( self, hass: HomeAssistant, friendly_name: str, config: ConfigType ) -> None: """Initialize the Proximity coordinator.""" - self.ignored_zones: list[str] = config[CONF_IGNORED_ZONES] - self.tracked_entities: list[str] = config[CONF_DEVICES] + self.ignored_zone_ids: list[str] = config[CONF_IGNORED_ZONES] + self.tracked_entities: list[str] = config[CONF_TRACKED_ENTITIES] self.tolerance: int = config[CONF_TOLERANCE] - self.proximity_zone: str = config[CONF_ZONE] + self.proximity_zone_id: str = config[CONF_ZONE] + self.proximity_zone_name: str = self.proximity_zone_id.split(".")[-1] self.unit_of_measurement: str = config.get( CONF_UNIT_OF_MEASUREMENT, hass.config.units.length_unit ) - self.friendly_name = friendly_name + self.entity_mapping: dict[str, list[str]] = defaultdict(list) super().__init__( hass, @@ -87,6 +96,11 @@ class ProximityDataUpdateCoordinator(DataUpdateCoordinator[ProximityData]): self.state_change_data: StateChangedData | None = None + @callback + def async_add_entity_mapping(self, tracked_entity_id: str, entity_id: str) -> None: + """Add an tracked entity to proximity entity mapping.""" + self.entity_mapping[tracked_entity_id].append(entity_id) + async def async_check_proximity_state_change( self, entity: str, old_state: State | None, new_state: State | None ) -> None: @@ -94,6 +108,31 @@ class ProximityDataUpdateCoordinator(DataUpdateCoordinator[ProximityData]): self.state_change_data = StateChangedData(entity, old_state, new_state) await self.async_refresh() + async def async_check_tracked_entity_change( + self, event: EventType[er.EventEntityRegistryUpdatedData] + ) -> None: + """Fetch and process tracked entity change event.""" + data = event.data + if data["action"] == "remove": + self._create_removed_tracked_entity_issue(data["entity_id"]) + + if data["action"] == "update" and "entity_id" in data["changes"]: + old_tracked_entity_id = data["old_entity_id"] + new_tracked_entity_id = data["entity_id"] + + self.hass.config_entries.async_update_entry( + self.config_entry, + data={ + **self.config_entry.data, + CONF_TRACKED_ENTITIES: [ + tracked_entity + for tracked_entity in self.tracked_entities + + [new_tracked_entity_id] + if tracked_entity != old_tracked_entity_id + ], + }, + ) + def _convert(self, value: float | str) -> float | str: """Round and convert given distance value.""" if isinstance(value, str): @@ -113,10 +152,10 @@ class ProximityDataUpdateCoordinator(DataUpdateCoordinator[ProximityData]): latitude: float | None, longitude: float | None, ) -> int | None: - if device.state.lower() == self.proximity_zone.lower(): + if device.state.lower() == self.proximity_zone_name.lower(): _LOGGER.debug( "%s: %s in zone -> distance=0", - self.friendly_name, + self.name, device.entity_id, ) return 0 @@ -124,7 +163,7 @@ class ProximityDataUpdateCoordinator(DataUpdateCoordinator[ProximityData]): if latitude is None or longitude is None: _LOGGER.debug( "%s: %s has no coordinates -> distance=None", - self.friendly_name, + self.name, device.entity_id, ) return None @@ -149,10 +188,10 @@ class ProximityDataUpdateCoordinator(DataUpdateCoordinator[ProximityData]): new_latitude: float | None, new_longitude: float | None, ) -> str | None: - if device.state.lower() == self.proximity_zone.lower(): + if device.state.lower() == self.proximity_zone_name.lower(): _LOGGER.debug( "%s: %s in zone -> direction_of_travel=arrived", - self.friendly_name, + self.name, device.entity_id, ) return "arrived" @@ -193,11 +232,11 @@ class ProximityDataUpdateCoordinator(DataUpdateCoordinator[ProximityData]): async def _async_update_data(self) -> ProximityData: """Calculate Proximity data.""" - if (zone_state := self.hass.states.get(f"zone.{self.proximity_zone}")) is None: + if (zone_state := self.hass.states.get(self.proximity_zone_id)) is None: _LOGGER.debug( "%s: zone %s does not exist -> reset", - self.friendly_name, - self.proximity_zone, + self.name, + self.proximity_zone_id, ) return DEFAULT_DATA @@ -208,12 +247,12 @@ class ProximityDataUpdateCoordinator(DataUpdateCoordinator[ProximityData]): if (tracked_entity_state := self.hass.states.get(entity_id)) is None: if entities_data.pop(entity_id, None) is not None: _LOGGER.debug( - "%s: %s does not exist -> remove", self.friendly_name, entity_id + "%s: %s does not exist -> remove", self.name, entity_id ) continue if entity_id not in entities_data: - _LOGGER.debug("%s: %s is new -> add", self.friendly_name, entity_id) + _LOGGER.debug("%s: %s is new -> add", self.name, entity_id) entities_data[entity_id] = { ATTR_DIST_TO: None, ATTR_DIR_OF_TRAVEL: None, @@ -221,7 +260,8 @@ class ProximityDataUpdateCoordinator(DataUpdateCoordinator[ProximityData]): ATTR_IN_IGNORED_ZONE: False, } entities_data[entity_id][ATTR_IN_IGNORED_ZONE] = ( - tracked_entity_state.state.lower() in self.ignored_zones + f"{ZONE_DOMAIN}.{tracked_entity_state.state.lower()}" + in self.ignored_zone_ids ) entities_data[entity_id][ATTR_DIST_TO] = self._calc_distance_to_zone( zone_state, @@ -232,7 +272,7 @@ class ProximityDataUpdateCoordinator(DataUpdateCoordinator[ProximityData]): if entities_data[entity_id][ATTR_DIST_TO] is None: _LOGGER.debug( "%s: %s has unknown distance got -> direction_of_travel=None", - self.friendly_name, + self.name, entity_id, ) entities_data[entity_id][ATTR_DIR_OF_TRAVEL] = None @@ -243,7 +283,7 @@ class ProximityDataUpdateCoordinator(DataUpdateCoordinator[ProximityData]): ) is not None: _LOGGER.debug( "%s: calculate direction of travel for %s", - self.friendly_name, + self.name, state_change_data.entity_id, ) @@ -304,3 +344,16 @@ class ProximityDataUpdateCoordinator(DataUpdateCoordinator[ProximityData]): proximity_data[ATTR_DIST_TO] = self._convert(proximity_data[ATTR_DIST_TO]) return ProximityData(proximity_data, entities_data) + + def _create_removed_tracked_entity_issue(self, entity_id: str) -> None: + """Create a repair issue for a removed tracked entity.""" + async_create_issue( + self.hass, + DOMAIN, + f"tracked_entity_removed_{entity_id}", + is_fixable=True, + is_persistent=True, + severity=IssueSeverity.WARNING, + translation_key="tracked_entity_removed", + translation_placeholders={"entity_id": entity_id, "name": self.name}, + ) diff --git a/homeassistant/components/proximity/helpers.py b/homeassistant/components/proximity/helpers.py new file mode 100644 index 00000000000..9c0787538e5 --- /dev/null +++ b/homeassistant/components/proximity/helpers.py @@ -0,0 +1,11 @@ +"""Helper functions for proximity.""" +from homeassistant.components.automation import automations_with_entity +from homeassistant.components.script import scripts_with_entity +from homeassistant.core import HomeAssistant + + +def entity_used_in(hass: HomeAssistant, entity_id: str) -> list[str]: + """Get list of related automations and scripts.""" + used_in = automations_with_entity(hass, entity_id) + used_in += scripts_with_entity(hass, entity_id) + return used_in diff --git a/homeassistant/components/proximity/manifest.json b/homeassistant/components/proximity/manifest.json index 3f1ea950d0e..b29a0f495b8 100644 --- a/homeassistant/components/proximity/manifest.json +++ b/homeassistant/components/proximity/manifest.json @@ -2,6 +2,7 @@ "domain": "proximity", "name": "Proximity", "codeowners": ["@mib1185"], + "config_flow": true, "dependencies": ["device_tracker", "zone"], "documentation": "https://www.home-assistant.io/integrations/proximity", "iot_class": "calculated", diff --git a/homeassistant/components/proximity/sensor.py b/homeassistant/components/proximity/sensor.py index 44121dcacb4..a1bd4d33914 100644 --- a/homeassistant/components/proximity/sensor.py +++ b/homeassistant/components/proximity/sensor.py @@ -7,10 +7,12 @@ from homeassistant.components.sensor import ( SensorEntity, SensorEntityDescription, ) -from homeassistant.const import CONF_NAME, UnitOfLength +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import UnitOfLength from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import ATTR_DIR_OF_TRAVEL, ATTR_DIST_TO, ATTR_NEAREST, DOMAIN @@ -48,29 +50,51 @@ SENSORS_PER_PROXIMITY: list[SensorEntityDescription] = [ ] -async def async_setup_platform( - hass: HomeAssistant, - config: ConfigType, - async_add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, -) -> None: - """Set up the Proximity sensor platform.""" - if discovery_info is None: - return +def _device_info(coordinator: ProximityDataUpdateCoordinator) -> DeviceInfo: + return DeviceInfo( + identifiers={(DOMAIN, coordinator.config_entry.entry_id)}, + name=coordinator.config_entry.title, + entry_type=DeviceEntryType.SERVICE, + ) - coordinator: ProximityDataUpdateCoordinator = hass.data[DOMAIN][ - discovery_info[CONF_NAME] - ] + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up the proximity sensors.""" + + coordinator: ProximityDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] entities: list[ProximitySensor | ProximityTrackedEntitySensor] = [ ProximitySensor(description, coordinator) for description in SENSORS_PER_PROXIMITY ] + tracked_entity_descriptors = [] + + entity_reg = er.async_get(hass) + for tracked_entity_id in coordinator.tracked_entities: + if (entity_entry := entity_reg.async_get(tracked_entity_id)) is not None: + tracked_entity_descriptors.append( + { + "entity_id": tracked_entity_id, + "identifier": entity_entry.id, + } + ) + else: + tracked_entity_descriptors.append( + { + "entity_id": tracked_entity_id, + "identifier": tracked_entity_id, + } + ) + entities += [ - ProximityTrackedEntitySensor(description, coordinator, tracked_entity_id) + ProximityTrackedEntitySensor( + description, coordinator, tracked_entity_descriptor + ) for description in SENSORS_PER_ENTITY - for tracked_entity_id in coordinator.tracked_entities + for tracked_entity_descriptor in tracked_entity_descriptors ] async_add_entities(entities) @@ -91,9 +115,8 @@ class ProximitySensor(CoordinatorEntity[ProximityDataUpdateCoordinator], SensorE self.entity_description = description - # entity name will be removed as soon as we have a config entry - # and can follow the entity naming guidelines - self._attr_name = f"{coordinator.friendly_name} {description.name}" + self._attr_unique_id = f"{coordinator.config_entry.entry_id}_{description.key}" + self._attr_device_info = _device_info(coordinator) @property def native_value(self) -> str | float | None: @@ -116,23 +139,38 @@ class ProximityTrackedEntitySensor( self, description: SensorEntityDescription, coordinator: ProximityDataUpdateCoordinator, - tracked_entity_id: str, + tracked_entity_descriptor: dict[str, str], ) -> None: """Initialize the proximity.""" super().__init__(coordinator) self.entity_description = description - self.tracked_entity_id = tracked_entity_id + self.tracked_entity_id = tracked_entity_descriptor["entity_id"] - # entity name will be removed as soon as we have a config entry - # and can follow the entity naming guidelines - self._attr_name = ( - f"{coordinator.friendly_name} {tracked_entity_id} {description.name}" + self._attr_unique_id = f"{coordinator.config_entry.entry_id}_{tracked_entity_descriptor['identifier']}_{description.key}" + self._attr_name = f"{self.tracked_entity_id.split('.')[-1]} {description.name}" + self._attr_device_info = _device_info(coordinator) + + async def async_added_to_hass(self) -> None: + """Register entity mapping.""" + await super().async_added_to_hass() + self.coordinator.async_add_entity_mapping( + self.tracked_entity_id, self.entity_id ) + @property + def data(self) -> dict[str, str | int | None] | None: + """Get data from coordinator.""" + return self.coordinator.data.entities.get(self.tracked_entity_id) + + @property + def available(self) -> bool: + """Return if entity is available.""" + return super().available and self.data is not None + @property def native_value(self) -> str | float | None: """Return native sensor value.""" - if (data := self.coordinator.data.entities.get(self.tracked_entity_id)) is None: + if self.data is None: return None - return data.get(self.entity_description.key) + return self.data.get(self.entity_description.key) diff --git a/homeassistant/components/proximity/strings.json b/homeassistant/components/proximity/strings.json index de2c3443998..f52f3d03516 100644 --- a/homeassistant/components/proximity/strings.json +++ b/homeassistant/components/proximity/strings.json @@ -1,5 +1,34 @@ { "title": "Proximity", + "config": { + "flow_title": "Proximity", + "step": { + "user": { + "data": { + "zone": "Zone to track distance to", + "ignored_zones": "Zones to ignore", + "tracked_entities": "Devices or Persons to track", + "tolerance": "Tolerance distance" + } + } + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "success": "Changes saved" + } + }, + "options": { + "step": { + "init": { + "data": { + "zone": "Zone to track distance to", + "ignored_zones": "Zones to ignore", + "tracked_entities": "Devices or Persons to track", + "tolerance": "Tolerance distance" + } + } + } + }, "entity": { "sensor": { "dir_of_travel": { @@ -25,6 +54,17 @@ } } } + }, + "tracked_entity_removed": { + "title": "Tracked entity has been removed", + "fix_flow": { + "step": { + "confirm": { + "title": "[%key:component::proximity::issues::tracked_entity_removed::title%]", + "description": "The entity `{entity_id}` has been removed from HA, but is used in proximity {name}. Please remove `{entity_id}` from the list of tracked entities. Related proximity sensor entites were set to unavailable and can be removed." + } + } + } } } } diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index edaba2250e8..186dd41165a 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -393,6 +393,7 @@ FLOWS = { "profiler", "progettihwsw", "prosegur", + "proximity", "prusalink", "ps4", "pure_energie", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 1046536c660..16023bc1fca 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -4562,7 +4562,7 @@ }, "proximity": { "integration_type": "hub", - "config_flow": false, + "config_flow": true, "iot_class": "calculated" }, "proxmoxve": { diff --git a/tests/components/proximity/conftest.py b/tests/components/proximity/conftest.py new file mode 100644 index 00000000000..960ab6cf916 --- /dev/null +++ b/tests/components/proximity/conftest.py @@ -0,0 +1,20 @@ +"""Config test for proximity.""" +import pytest + +from homeassistant.core import HomeAssistant + + +@pytest.fixture(autouse=True) +def config_zones(hass: HomeAssistant): + """Set up zones for test.""" + hass.config.components.add("zone") + hass.states.async_set( + "zone.home", + "zoning", + {"name": "Home", "latitude": 2.1, "longitude": 1.1, "radius": 10}, + ) + hass.states.async_set( + "zone.work", + "zoning", + {"name": "Work", "latitude": 2.3, "longitude": 1.3, "radius": 10}, + ) diff --git a/tests/components/proximity/test_config_flow.py b/tests/components/proximity/test_config_flow.py new file mode 100644 index 00000000000..92b924be1ce --- /dev/null +++ b/tests/components/proximity/test_config_flow.py @@ -0,0 +1,187 @@ +"""Test proximity config flow.""" +from unittest.mock import patch + +import pytest + +from homeassistant.components.proximity.const import ( + CONF_IGNORED_ZONES, + CONF_TOLERANCE, + CONF_TRACKED_ENTITIES, + DOMAIN, +) +from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_USER +from homeassistant.const import CONF_NAME, CONF_UNIT_OF_MEASUREMENT, CONF_ZONE +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from tests.common import MockConfigEntry + + +@pytest.mark.parametrize( + ("user_input", "expected_result"), + [ + ( + { + CONF_ZONE: "zone.home", + CONF_TRACKED_ENTITIES: ["device_tracker.test1"], + }, + { + CONF_ZONE: "zone.home", + CONF_TRACKED_ENTITIES: ["device_tracker.test1"], + CONF_IGNORED_ZONES: [], + CONF_TOLERANCE: 1, + }, + ), + ( + { + CONF_ZONE: "zone.home", + CONF_TRACKED_ENTITIES: ["device_tracker.test1"], + CONF_IGNORED_ZONES: ["zone.work"], + CONF_TOLERANCE: 10, + }, + { + CONF_ZONE: "zone.home", + CONF_TRACKED_ENTITIES: ["device_tracker.test1"], + CONF_IGNORED_ZONES: ["zone.work"], + CONF_TOLERANCE: 10, + }, + ), + ], +) +async def test_user_flow( + hass: HomeAssistant, user_input: dict, expected_result: dict +) -> None: + """Test starting a flow by user.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + + with patch( + "homeassistant.components.proximity.async_setup_entry", return_value=True + ) as mock_setup_entry: + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=user_input, + ) + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["data"] == expected_result + + zone = hass.states.get(user_input[CONF_ZONE]) + assert result["title"] == zone.name + + await hass.async_block_till_done() + + assert mock_setup_entry.called + + +async def test_options_flow(hass: HomeAssistant) -> None: + """Test options flow.""" + + mock_config = MockConfigEntry( + domain=DOMAIN, + title="home", + data={ + CONF_ZONE: "zone.home", + CONF_TRACKED_ENTITIES: ["device_tracker.test1"], + CONF_IGNORED_ZONES: ["zone.work"], + CONF_TOLERANCE: 10, + }, + unique_id=f"{DOMAIN}_home", + ) + mock_config.add_to_hass(hass) + + with patch( + "homeassistant.components.proximity.async_setup_entry", return_value=True + ) as mock_setup_entry: + await hass.config_entries.async_setup(mock_config.entry_id) + await hass.async_block_till_done() + assert mock_setup_entry.called + + result = await hass.config_entries.options.async_init(mock_config.entry_id) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "init" + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + CONF_TRACKED_ENTITIES: ["device_tracker.test2"], + CONF_IGNORED_ZONES: [], + CONF_TOLERANCE: 1, + }, + ) + assert result["type"] == FlowResultType.CREATE_ENTRY + assert mock_config.data == { + CONF_ZONE: "zone.home", + CONF_TRACKED_ENTITIES: ["device_tracker.test2"], + CONF_IGNORED_ZONES: [], + CONF_TOLERANCE: 1, + } + + +async def test_import_flow(hass: HomeAssistant) -> None: + """Test import of yaml configuration.""" + with patch( + "homeassistant.components.proximity.async_setup_entry", return_value=True + ) as mock_setup_entry: + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data={ + CONF_NAME: "home", + CONF_ZONE: "zone.home", + CONF_TRACKED_ENTITIES: ["device_tracker.test1"], + CONF_IGNORED_ZONES: ["zone.work"], + CONF_TOLERANCE: 10, + CONF_UNIT_OF_MEASUREMENT: "km", + }, + ) + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["data"] == { + CONF_NAME: "home", + CONF_ZONE: "zone.home", + CONF_TRACKED_ENTITIES: ["device_tracker.test1"], + CONF_IGNORED_ZONES: ["zone.work"], + CONF_TOLERANCE: 10, + CONF_UNIT_OF_MEASUREMENT: "km", + } + + zone = hass.states.get("zone.home") + assert result["title"] == zone.name + + await hass.async_block_till_done() + + assert mock_setup_entry.called + + +async def test_abort_duplicated_entry(hass: HomeAssistant) -> None: + """Test if we abort on duplicate user input data.""" + DATA = { + CONF_ZONE: "zone.home", + CONF_TRACKED_ENTITIES: ["device_tracker.test1"], + CONF_IGNORED_ZONES: ["zone.work"], + CONF_TOLERANCE: 10, + } + mock_config = MockConfigEntry( + domain=DOMAIN, + title="home", + data=DATA, + unique_id=f"{DOMAIN}_home", + ) + mock_config.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + with patch( + "homeassistant.components.proximity.async_setup_entry", return_value=True + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=DATA, + ) + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "already_configured" + + await hass.async_block_till_done() diff --git a/tests/components/proximity/test_init.py b/tests/components/proximity/test_init.py index b3a83624952..059ba2658ee 100644 --- a/tests/components/proximity/test_init.py +++ b/tests/components/proximity/test_init.py @@ -4,39 +4,51 @@ import pytest from homeassistant.components import automation, script from homeassistant.components.automation import automations_with_entity -from homeassistant.components.proximity import DOMAIN +from homeassistant.components.proximity.const import ( + CONF_IGNORED_ZONES, + CONF_TOLERANCE, + CONF_TRACKED_ENTITIES, + DOMAIN, +) from homeassistant.components.script import scripts_with_entity -from homeassistant.const import STATE_UNKNOWN +from homeassistant.const import CONF_ZONE, STATE_UNAVAILABLE, STATE_UNKNOWN from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er import homeassistant.helpers.issue_registry as ir from homeassistant.setup import async_setup_component from homeassistant.util import slugify +from tests.common import MockConfigEntry -@pytest.mark.parametrize(("friendly_name"), ["home", "home_test2", "work"]) -async def test_proximities(hass: HomeAssistant, friendly_name: str) -> None: - """Test a list of proximities.""" - config = { - "proximity": { - "home": { + +@pytest.mark.parametrize( + ("friendly_name", "config"), + [ + ( + "home", + { "ignored_zones": ["work"], "devices": ["device_tracker.test1", "device_tracker.test2"], "tolerance": "1", }, - "home_test2": { - "ignored_zones": ["work"], - "devices": ["device_tracker.test1", "device_tracker.test2"], - "tolerance": "1", - }, - "work": { + ), + ( + "work", + { "devices": ["device_tracker.test1"], "tolerance": "1", "zone": "work", }, - } - } - - assert await async_setup_component(hass, DOMAIN, config) + ), + ], +) +async def test_proximities( + hass: HomeAssistant, friendly_name: str, config: dict +) -> None: + """Test a list of proximities.""" + assert await async_setup_component( + hass, DOMAIN, {"proximity": {friendly_name: config}} + ) await hass.async_block_till_done() # proximity entity @@ -50,31 +62,47 @@ async def test_proximities(hass: HomeAssistant, friendly_name: str) -> None: assert state.state == "0" # sensor entities - state = hass.states.get(f"sensor.{friendly_name}_nearest") + state = hass.states.get(f"sensor.{friendly_name}_nearest_device") assert state.state == STATE_UNKNOWN - for device in config["proximity"][friendly_name]["devices"]: - entity_base_name = f"sensor.{friendly_name}_{slugify(device)}" + for device in config["devices"]: + entity_base_name = f"sensor.{friendly_name}_{slugify(device.split('.')[-1])}" state = hass.states.get(f"{entity_base_name}_distance") - assert state.state == STATE_UNKNOWN + assert state.state == STATE_UNAVAILABLE state = hass.states.get(f"{entity_base_name}_direction_of_travel") - assert state.state == STATE_UNKNOWN + assert state.state == STATE_UNAVAILABLE -async def test_proximities_setup(hass: HomeAssistant) -> None: - """Test a list of proximities with missing devices.""" +async def test_legacy_setup(hass: HomeAssistant) -> None: + """Test legacy setup only on imported entries.""" config = { "proximity": { "home": { - "ignored_zones": ["work"], - "devices": ["device_tracker.test1", "device_tracker.test2"], + "devices": ["device_tracker.test1"], "tolerance": "1", }, - "work": {"tolerance": "1", "zone": "work"}, } } - assert await async_setup_component(hass, DOMAIN, config) + await hass.async_block_till_done() + assert hass.states.get("proximity.home") + + mock_config = MockConfigEntry( + domain=DOMAIN, + title="work", + data={ + CONF_ZONE: "zone.work", + CONF_TRACKED_ENTITIES: ["device_tracker.test2"], + CONF_IGNORED_ZONES: [], + CONF_TOLERANCE: 1, + }, + unique_id=f"{DOMAIN}_work", + ) + mock_config.add_to_hass(hass) + assert await hass.config_entries.async_setup(mock_config.entry_id) + await hass.async_block_till_done() + + assert not hass.states.get("proximity.work") async def test_device_tracker_test1_in_zone(hass: HomeAssistant) -> None: @@ -105,10 +133,10 @@ async def test_device_tracker_test1_in_zone(hass: HomeAssistant) -> None: assert state.attributes.get("dir_of_travel") == "arrived" # sensor entities - state = hass.states.get("sensor.home_nearest") + state = hass.states.get("sensor.home_nearest_device") assert state.state == "test1" - entity_base_name = f"sensor.home_{slugify('device_tracker.test1')}" + entity_base_name = "sensor.home_test1" state = hass.states.get(f"{entity_base_name}_distance") assert state.state == "0" state = hass.states.get(f"{entity_base_name}_direction_of_travel") @@ -143,20 +171,21 @@ async def test_device_tracker_test1_away(hass: HomeAssistant) -> None: assert state.attributes.get("dir_of_travel") == "unknown" # sensor entities - state = hass.states.get("sensor.home_nearest") + state = hass.states.get("sensor.home_nearest_device") assert state.state == "test1" - entity_base_name = f"sensor.home_{slugify('device_tracker.test1')}" + entity_base_name = "sensor.home_test1" state = hass.states.get(f"{entity_base_name}_distance") - assert state.state == "11912010" + assert state.state == "2218752" state = hass.states.get(f"{entity_base_name}_direction_of_travel") assert state.state == STATE_UNKNOWN -async def test_device_tracker_test1_awayfurther(hass: HomeAssistant) -> None: +async def test_device_tracker_test1_awayfurther( + hass: HomeAssistant, config_zones +) -> None: """Test for tracker state away further.""" - config_zones(hass) await hass.async_block_till_done() config = { @@ -184,10 +213,10 @@ async def test_device_tracker_test1_awayfurther(hass: HomeAssistant) -> None: assert state.attributes.get("dir_of_travel") == "unknown" # sensor entities - state = hass.states.get("sensor.home_nearest") + state = hass.states.get("sensor.home_nearest_device") assert state.state == "test1" - entity_base_name = f"sensor.home_{slugify('device_tracker.test1')}" + entity_base_name = "sensor.home_test1" state = hass.states.get(f"{entity_base_name}_distance") assert state.state == "2218752" state = hass.states.get(f"{entity_base_name}_direction_of_travel") @@ -206,19 +235,20 @@ async def test_device_tracker_test1_awayfurther(hass: HomeAssistant) -> None: assert state.attributes.get("dir_of_travel") == "away_from" # sensor entities - state = hass.states.get("sensor.home_nearest") + state = hass.states.get("sensor.home_nearest_device") assert state.state == "test1" - entity_base_name = f"sensor.home_{slugify('device_tracker.test1')}" + entity_base_name = "sensor.home_test1" state = hass.states.get(f"{entity_base_name}_distance") assert state.state == "4625264" state = hass.states.get(f"{entity_base_name}_direction_of_travel") assert state.state == "away_from" -async def test_device_tracker_test1_awaycloser(hass: HomeAssistant) -> None: +async def test_device_tracker_test1_awaycloser( + hass: HomeAssistant, config_zones +) -> None: """Test for tracker state away closer.""" - config_zones(hass) await hass.async_block_till_done() config = { @@ -246,10 +276,10 @@ async def test_device_tracker_test1_awaycloser(hass: HomeAssistant) -> None: assert state.attributes.get("dir_of_travel") == "unknown" # sensor entities - state = hass.states.get("sensor.home_nearest") + state = hass.states.get("sensor.home_nearest_device") assert state.state == "test1" - entity_base_name = f"sensor.home_{slugify('device_tracker.test1')}" + entity_base_name = "sensor.home_test1" state = hass.states.get(f"{entity_base_name}_distance") assert state.state == "4625264" state = hass.states.get(f"{entity_base_name}_direction_of_travel") @@ -268,10 +298,10 @@ async def test_device_tracker_test1_awaycloser(hass: HomeAssistant) -> None: assert state.attributes.get("dir_of_travel") == "towards" # sensor entities - state = hass.states.get("sensor.home_nearest") + state = hass.states.get("sensor.home_nearest_device") assert state.state == "test1" - entity_base_name = f"sensor.home_{slugify('device_tracker.test1')}" + entity_base_name = "sensor.home_test1" state = hass.states.get(f"{entity_base_name}_distance") assert state.state == "2218752" state = hass.states.get(f"{entity_base_name}_direction_of_travel") @@ -302,10 +332,10 @@ async def test_all_device_trackers_in_ignored_zone(hass: HomeAssistant) -> None: assert state.attributes.get("dir_of_travel") == "not set" # sensor entities - state = hass.states.get("sensor.home_nearest") + state = hass.states.get("sensor.home_nearest_device") assert state.state == STATE_UNKNOWN - entity_base_name = f"sensor.home_{slugify('device_tracker.test1')}" + entity_base_name = "sensor.home_test1" state = hass.states.get(f"{entity_base_name}_distance") assert state.state == STATE_UNKNOWN state = hass.states.get(f"{entity_base_name}_direction_of_travel") @@ -337,10 +367,10 @@ async def test_device_tracker_test1_no_coordinates(hass: HomeAssistant) -> None: assert state.attributes.get("dir_of_travel") == "not set" # sensor entities - state = hass.states.get("sensor.home_nearest") + state = hass.states.get("sensor.home_nearest_device") assert state.state == STATE_UNKNOWN - entity_base_name = f"sensor.home_{slugify('device_tracker.test1')}" + entity_base_name = "sensor.home_test1" state = hass.states.get(f"{entity_base_name}_distance") assert state.state == STATE_UNKNOWN state = hass.states.get(f"{entity_base_name}_direction_of_travel") @@ -377,12 +407,12 @@ async def test_device_tracker_test1_awayfurther_a_bit(hass: HomeAssistant) -> No assert state.attributes.get("dir_of_travel") == "unknown" # sensor entities - state = hass.states.get("sensor.home_nearest") + state = hass.states.get("sensor.home_nearest_device") assert state.state == "test1" - entity_base_name = f"sensor.home_{slugify('device_tracker.test1')}" + entity_base_name = "sensor.home_test1" state = hass.states.get(f"{entity_base_name}_distance") - assert state.state == "11912010" + assert state.state == "2218752" state = hass.states.get(f"{entity_base_name}_direction_of_travel") assert state.state == STATE_UNKNOWN @@ -399,12 +429,12 @@ async def test_device_tracker_test1_awayfurther_a_bit(hass: HomeAssistant) -> No assert state.attributes.get("dir_of_travel") == "stationary" # sensor entities - state = hass.states.get("sensor.home_nearest") + state = hass.states.get("sensor.home_nearest_device") assert state.state == "test1" - entity_base_name = f"sensor.home_{slugify('device_tracker.test1')}" + entity_base_name = "sensor.home_test1" state = hass.states.get(f"{entity_base_name}_distance") - assert state.state == "11912010" + assert state.state == "2218752" state = hass.states.get(f"{entity_base_name}_direction_of_travel") assert state.state == "stationary" @@ -445,11 +475,11 @@ async def test_device_trackers_in_zone(hass: HomeAssistant) -> None: assert state.attributes.get("dir_of_travel") == "arrived" # sensor entities - state = hass.states.get("sensor.home_nearest") + state = hass.states.get("sensor.home_nearest_device") assert state.state == "test1, test2" - for device in ["device_tracker.test1", "device_tracker.test2"]: - entity_base_name = f"sensor.home_{slugify(device)}" + for device in ["test1", "test2"]: + entity_base_name = f"sensor.home_{device}" state = hass.states.get(f"{entity_base_name}_distance") assert state.state == "0" state = hass.states.get(f"{entity_base_name}_direction_of_travel") @@ -457,10 +487,9 @@ async def test_device_trackers_in_zone(hass: HomeAssistant) -> None: async def test_device_tracker_test1_awayfurther_than_test2_first_test1( - hass: HomeAssistant, + hass: HomeAssistant, config_zones ) -> None: """Test for tracker ordering.""" - config_zones(hass) await hass.async_block_till_done() hass.states.async_set( @@ -500,16 +529,16 @@ async def test_device_tracker_test1_awayfurther_than_test2_first_test1( assert state.attributes.get("dir_of_travel") == "unknown" # sensor entities - state = hass.states.get("sensor.home_nearest") + state = hass.states.get("sensor.home_nearest_device") assert state.state == "test1" - entity_base_name = f"sensor.home_{slugify('device_tracker.test1')}" + entity_base_name = "sensor.home_test1" state = hass.states.get(f"{entity_base_name}_distance") assert state.state == "2218752" state = hass.states.get(f"{entity_base_name}_direction_of_travel") assert state.state == STATE_UNKNOWN - entity_base_name = f"sensor.home_{slugify('device_tracker.test2')}" + entity_base_name = "sensor.home_test2" state = hass.states.get(f"{entity_base_name}_distance") assert state.state == STATE_UNKNOWN state = hass.states.get(f"{entity_base_name}_direction_of_travel") @@ -528,16 +557,16 @@ async def test_device_tracker_test1_awayfurther_than_test2_first_test1( assert state.attributes.get("dir_of_travel") == "unknown" # sensor entities - state = hass.states.get("sensor.home_nearest") + state = hass.states.get("sensor.home_nearest_device") assert state.state == "test1" - entity_base_name = f"sensor.home_{slugify('device_tracker.test1')}" + entity_base_name = "sensor.home_test1" state = hass.states.get(f"{entity_base_name}_distance") assert state.state == "2218752" state = hass.states.get(f"{entity_base_name}_direction_of_travel") assert state.state == STATE_UNKNOWN - entity_base_name = f"sensor.home_{slugify('device_tracker.test2')}" + entity_base_name = "sensor.home_test2" state = hass.states.get(f"{entity_base_name}_distance") assert state.state == "4625264" state = hass.states.get(f"{entity_base_name}_direction_of_travel") @@ -545,10 +574,9 @@ async def test_device_tracker_test1_awayfurther_than_test2_first_test1( async def test_device_tracker_test1_awayfurther_than_test2_first_test2( - hass: HomeAssistant, + hass: HomeAssistant, config_zones ) -> None: """Test for tracker ordering.""" - config_zones(hass) await hass.async_block_till_done() hass.states.async_set( @@ -586,16 +614,16 @@ async def test_device_tracker_test1_awayfurther_than_test2_first_test2( assert state.attributes.get("dir_of_travel") == "unknown" # sensor entities - state = hass.states.get("sensor.home_nearest") + state = hass.states.get("sensor.home_nearest_device") assert state.state == "test2" - entity_base_name = f"sensor.home_{slugify('device_tracker.test1')}" + entity_base_name = "sensor.home_test1" state = hass.states.get(f"{entity_base_name}_distance") assert state.state == STATE_UNKNOWN state = hass.states.get(f"{entity_base_name}_direction_of_travel") assert state.state == STATE_UNKNOWN - entity_base_name = f"sensor.home_{slugify('device_tracker.test2')}" + entity_base_name = "sensor.home_test2" state = hass.states.get(f"{entity_base_name}_distance") assert state.state == "4625264" state = hass.states.get(f"{entity_base_name}_direction_of_travel") @@ -614,16 +642,16 @@ async def test_device_tracker_test1_awayfurther_than_test2_first_test2( assert state.attributes.get("dir_of_travel") == "unknown" # sensor entities - state = hass.states.get("sensor.home_nearest") + state = hass.states.get("sensor.home_nearest_device") assert state.state == "test1" - entity_base_name = f"sensor.home_{slugify('device_tracker.test1')}" + entity_base_name = "sensor.home_test1" state = hass.states.get(f"{entity_base_name}_distance") assert state.state == "2218752" state = hass.states.get(f"{entity_base_name}_direction_of_travel") assert state.state == STATE_UNKNOWN - entity_base_name = f"sensor.home_{slugify('device_tracker.test2')}" + entity_base_name = "sensor.home_test2" state = hass.states.get(f"{entity_base_name}_distance") assert state.state == "4625264" state = hass.states.get(f"{entity_base_name}_direction_of_travel") @@ -667,16 +695,16 @@ async def test_device_tracker_test1_awayfurther_test2_in_ignored_zone( assert state.attributes.get("dir_of_travel") == "unknown" # sensor entities - state = hass.states.get("sensor.home_nearest") + state = hass.states.get("sensor.home_nearest_device") assert state.state == "test1" - entity_base_name = f"sensor.home_{slugify('device_tracker.test1')}" + entity_base_name = "sensor.home_test1" state = hass.states.get(f"{entity_base_name}_distance") - assert state.state == "11912010" + assert state.state == "2218752" state = hass.states.get(f"{entity_base_name}_direction_of_travel") assert state.state == STATE_UNKNOWN - entity_base_name = f"sensor.home_{slugify('device_tracker.test2')}" + entity_base_name = "sensor.home_test2" state = hass.states.get(f"{entity_base_name}_distance") assert state.state == STATE_UNKNOWN state = hass.states.get(f"{entity_base_name}_direction_of_travel") @@ -684,10 +712,9 @@ async def test_device_tracker_test1_awayfurther_test2_in_ignored_zone( async def test_device_tracker_test1_awayfurther_test2_first( - hass: HomeAssistant, + hass: HomeAssistant, config_zones ) -> None: """Test for tracker state.""" - config_zones(hass) await hass.async_block_till_done() hass.states.async_set( @@ -750,16 +777,16 @@ async def test_device_tracker_test1_awayfurther_test2_first( assert state.attributes.get("dir_of_travel") == "unknown" # sensor entities - state = hass.states.get("sensor.home_nearest") + state = hass.states.get("sensor.home_nearest_device") assert state.state == "test2" - entity_base_name = f"sensor.home_{slugify('device_tracker.test1')}" + entity_base_name = "sensor.home_test1" state = hass.states.get(f"{entity_base_name}_distance") assert state.state == STATE_UNKNOWN state = hass.states.get(f"{entity_base_name}_direction_of_travel") assert state.state == STATE_UNKNOWN - entity_base_name = f"sensor.home_{slugify('device_tracker.test2')}" + entity_base_name = "sensor.home_test2" state = hass.states.get(f"{entity_base_name}_distance") assert state.state == "2218752" state = hass.states.get(f"{entity_base_name}_direction_of_travel") @@ -767,10 +794,9 @@ async def test_device_tracker_test1_awayfurther_test2_first( async def test_device_tracker_test1_nearest_after_test2_in_ignored_zone( - hass: HomeAssistant, + hass: HomeAssistant, config_zones ) -> None: """Test for tracker states.""" - config_zones(hass) await hass.async_block_till_done() hass.states.async_set( @@ -809,16 +835,16 @@ async def test_device_tracker_test1_nearest_after_test2_in_ignored_zone( assert state.attributes.get("dir_of_travel") == "unknown" # sensor entities - state = hass.states.get("sensor.home_nearest") + state = hass.states.get("sensor.home_nearest_device") assert state.state == "test1" - entity_base_name = f"sensor.home_{slugify('device_tracker.test1')}" + entity_base_name = "sensor.home_test1" state = hass.states.get(f"{entity_base_name}_distance") assert state.state == "2218752" state = hass.states.get(f"{entity_base_name}_direction_of_travel") assert state.state == STATE_UNKNOWN - entity_base_name = f"sensor.home_{slugify('device_tracker.test2')}" + entity_base_name = "sensor.home_test2" state = hass.states.get(f"{entity_base_name}_distance") assert state.state == STATE_UNKNOWN state = hass.states.get(f"{entity_base_name}_direction_of_travel") @@ -837,16 +863,16 @@ async def test_device_tracker_test1_nearest_after_test2_in_ignored_zone( assert state.attributes.get("dir_of_travel") == "unknown" # sensor entities - state = hass.states.get("sensor.home_nearest") + state = hass.states.get("sensor.home_nearest_device") assert state.state == "test2" - entity_base_name = f"sensor.home_{slugify('device_tracker.test1')}" + entity_base_name = "sensor.home_test1" state = hass.states.get(f"{entity_base_name}_distance") assert state.state == "2218752" state = hass.states.get(f"{entity_base_name}_direction_of_travel") assert state.state == STATE_UNKNOWN - entity_base_name = f"sensor.home_{slugify('device_tracker.test2')}" + entity_base_name = "sensor.home_test2" state = hass.states.get(f"{entity_base_name}_distance") assert state.state == "989156" state = hass.states.get(f"{entity_base_name}_direction_of_travel") @@ -865,23 +891,23 @@ async def test_device_tracker_test1_nearest_after_test2_in_ignored_zone( assert state.attributes.get("dir_of_travel") == "unknown" # sensor entities - state = hass.states.get("sensor.home_nearest") + state = hass.states.get("sensor.home_nearest_device") assert state.state == "test1" - entity_base_name = f"sensor.home_{slugify('device_tracker.test1')}" + entity_base_name = "sensor.home_test1" state = hass.states.get(f"{entity_base_name}_distance") assert state.state == "2218752" state = hass.states.get(f"{entity_base_name}_direction_of_travel") assert state.state == STATE_UNKNOWN - entity_base_name = f"sensor.home_{slugify('device_tracker.test2')}" + entity_base_name = "sensor.home_test2" state = hass.states.get(f"{entity_base_name}_distance") assert state.state == "1364567" state = hass.states.get(f"{entity_base_name}_direction_of_travel") assert state.state == "away_from" -async def test_create_issue( +async def test_create_deprecated_proximity_issue( hass: HomeAssistant, issue_registry: ir.IssueRegistry, ) -> None: @@ -946,16 +972,142 @@ async def test_create_issue( ) -def config_zones(hass): - """Set up zones for test.""" - hass.config.components.add("zone") - hass.states.async_set( - "zone.home", - "zoning", - {"name": "home", "latitude": 2.1, "longitude": 1.1, "radius": 10}, +async def test_create_removed_tracked_entity_issue( + hass: HomeAssistant, + issue_registry: ir.IssueRegistry, + entity_registry: er.EntityRegistry, +) -> None: + """Test we create an issue for removed tracked entities.""" + t1 = entity_registry.async_get_or_create( + "device_tracker", "device_tracker", "test1" ) - hass.states.async_set( - "zone.work", - "zoning", - {"name": "work", "latitude": 2.3, "longitude": 1.3, "radius": 10}, + t2 = entity_registry.async_get_or_create( + "device_tracker", "device_tracker", "test2" + ) + + hass.states.async_set(t1.entity_id, "not_home") + hass.states.async_set(t2.entity_id, "not_home") + + mock_config = MockConfigEntry( + domain=DOMAIN, + title="home", + data={ + CONF_ZONE: "zone.home", + CONF_TRACKED_ENTITIES: [t1.entity_id, t2.entity_id], + CONF_IGNORED_ZONES: [], + CONF_TOLERANCE: 1, + }, + unique_id=f"{DOMAIN}_home", + ) + + mock_config.add_to_hass(hass) + assert await hass.config_entries.async_setup(mock_config.entry_id) + await hass.async_block_till_done() + + sensor_t1 = f"sensor.home_{t1.entity_id.split('.')[-1]}_distance" + sensor_t2 = f"sensor.home_{t2.entity_id.split('.')[-1]}_distance" + + state = hass.states.get(sensor_t1) + assert state.state == STATE_UNKNOWN + state = hass.states.get(sensor_t2) + assert state.state == STATE_UNKNOWN + + hass.states.async_remove(t2.entity_id) + entity_registry.async_remove(t2.entity_id) + await hass.async_block_till_done() + + state = hass.states.get(sensor_t1) + assert state.state == STATE_UNKNOWN + state = hass.states.get(sensor_t2) + assert state.state == STATE_UNAVAILABLE + + assert issue_registry.async_get_issue( + DOMAIN, f"tracked_entity_removed_{t2.entity_id}" + ) + + +async def test_track_renamed_tracked_entity( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, +) -> None: + """Test that when tracked entity is renamed.""" + t1 = entity_registry.async_get_or_create( + "device_tracker", "device_tracker", "test1" + ) + + hass.states.async_set(t1.entity_id, "not_home") + + mock_config = MockConfigEntry( + domain=DOMAIN, + title="home", + data={ + CONF_ZONE: "zone.home", + CONF_TRACKED_ENTITIES: [t1.entity_id], + CONF_IGNORED_ZONES: [], + CONF_TOLERANCE: 1, + }, + unique_id=f"{DOMAIN}_home", + ) + + mock_config.add_to_hass(hass) + assert await hass.config_entries.async_setup(mock_config.entry_id) + await hass.async_block_till_done() + + sensor_t1 = f"sensor.home_{t1.entity_id.split('.')[-1]}_distance" + + entity = entity_registry.async_get(sensor_t1) + assert entity + assert entity.unique_id == f"{mock_config.entry_id}_{t1.id}_dist_to_zone" + + entity_registry.async_update_entity( + t1.entity_id, new_entity_id=f"{t1.entity_id}_renamed" + ) + await hass.async_block_till_done() + + entity = entity_registry.async_get(sensor_t1) + assert entity + assert entity.unique_id == f"{mock_config.entry_id}_{t1.id}_dist_to_zone" + + entry = hass.config_entries.async_get_entry(mock_config.entry_id) + assert entry + assert entry.data[CONF_TRACKED_ENTITIES] == [f"{t1.entity_id}_renamed"] + + +async def test_sensor_unique_ids( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, +) -> None: + """Test that when tracked entity is renamed.""" + t1 = entity_registry.async_get_or_create( + "device_tracker", "device_tracker", "test1" + ) + hass.states.async_set(t1.entity_id, "not_home") + + hass.states.async_set("device_tracker.test2", "not_home") + + mock_config = MockConfigEntry( + domain=DOMAIN, + title="home", + data={ + CONF_ZONE: "zone.home", + CONF_TRACKED_ENTITIES: [t1.entity_id, "device_tracker.test2"], + CONF_IGNORED_ZONES: [], + CONF_TOLERANCE: 1, + }, + unique_id=f"{DOMAIN}_home", + ) + + mock_config.add_to_hass(hass) + assert await hass.config_entries.async_setup(mock_config.entry_id) + await hass.async_block_till_done() + + sensor_t1 = f"sensor.home_{t1.entity_id.split('.')[-1]}_distance" + entity = entity_registry.async_get(sensor_t1) + assert entity + assert entity.unique_id == f"{mock_config.entry_id}_{t1.id}_dist_to_zone" + + entity = entity_registry.async_get("sensor.home_test2_distance") + assert entity + assert ( + entity.unique_id == f"{mock_config.entry_id}_device_tracker.test2_dist_to_zone" )