From ffe9f0825a42124f466c44deb6de7bfffdeb75ed Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Mon, 5 Feb 2024 00:42:07 +0100 Subject: [PATCH] Add zone related sensors in proximity (#109630) * move legacy needed convertions into legacy entity * add zone related sensors * fix test coverage * fix typing * fix entity name translations * rename placeholder to tracked_entity --- .../components/proximity/__init__.py | 19 ++- homeassistant/components/proximity/const.py | 2 + .../components/proximity/coordinator.py | 19 ++- homeassistant/components/proximity/sensor.py | 39 ++++-- .../components/proximity/strings.json | 15 ++- .../proximity/snapshots/test_diagnostics.ambr | 4 +- tests/components/proximity/test_init.py | 126 ++++++++++++++++++ 7 files changed, 193 insertions(+), 31 deletions(-) diff --git a/homeassistant/components/proximity/__init__.py b/homeassistant/components/proximity/__init__.py index 3f28028d703..349658223f3 100644 --- a/homeassistant/components/proximity/__init__.py +++ b/homeassistant/components/proximity/__init__.py @@ -2,6 +2,7 @@ from __future__ import annotations import logging +from typing import cast import voluptuous as vol @@ -11,6 +12,7 @@ from homeassistant.const import ( CONF_NAME, CONF_UNIT_OF_MEASUREMENT, CONF_ZONE, + STATE_UNKNOWN, Platform, ) from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant @@ -203,16 +205,21 @@ class Proximity(CoordinatorEntity[ProximityDataUpdateCoordinator]): self._attr_unit_of_measurement = self.coordinator.unit_of_measurement @property - def state(self) -> str | int | float: + def data(self) -> dict[str, str | int | None]: + """Get data from coordinator.""" + return self.coordinator.data.proximity + + @property + def state(self) -> str | float: """Return the state.""" - return self.coordinator.data.proximity[ATTR_DIST_TO] + if isinstance(distance := self.data[ATTR_DIST_TO], str): + return distance + return self.coordinator.convert_legacy(cast(int, distance)) @property def extra_state_attributes(self) -> dict[str, str]: """Return the state attributes.""" return { - ATTR_DIR_OF_TRAVEL: str( - self.coordinator.data.proximity[ATTR_DIR_OF_TRAVEL] - ), - ATTR_NEAREST: str(self.coordinator.data.proximity[ATTR_NEAREST]), + ATTR_DIR_OF_TRAVEL: str(self.data[ATTR_DIR_OF_TRAVEL] or STATE_UNKNOWN), + ATTR_NEAREST: str(self.data[ATTR_NEAREST]), } diff --git a/homeassistant/components/proximity/const.py b/homeassistant/components/proximity/const.py index 7627d550e1f..e5b384b2f70 100644 --- a/homeassistant/components/proximity/const.py +++ b/homeassistant/components/proximity/const.py @@ -9,6 +9,8 @@ ATTR_DIST_TO: Final = "dist_to_zone" ATTR_ENTITIES_DATA: Final = "entities_data" ATTR_IN_IGNORED_ZONE: Final = "is_in_ignored_zone" ATTR_NEAREST: Final = "nearest" +ATTR_NEAREST_DIR_OF_TRAVEL: Final = "nearest_dir_of_travel" +ATTR_NEAREST_DIST_TO: Final = "nearest_dist_to_zone" ATTR_PROXIMITY_DATA: Final = "proximity_data" CONF_IGNORED_ZONES = "ignored_zones" diff --git a/homeassistant/components/proximity/coordinator.py b/homeassistant/components/proximity/coordinator.py index 53c1180e832..047ab1b6b3a 100644 --- a/homeassistant/components/proximity/coordinator.py +++ b/homeassistant/components/proximity/coordinator.py @@ -3,6 +3,7 @@ from collections import defaultdict from dataclasses import dataclass import logging +from typing import cast from homeassistant.components.zone import DOMAIN as ZONE_DOMAIN from homeassistant.config_entries import ConfigEntry @@ -52,11 +53,11 @@ class StateChangedData: class ProximityData: """ProximityCoordinatorData class.""" - proximity: dict[str, str | float] + proximity: dict[str, str | int | None] entities: dict[str, dict[str, str | int | None]] -DEFAULT_PROXIMITY_DATA: dict[str, str | float] = { +DEFAULT_PROXIMITY_DATA: dict[str, str | int | None] = { ATTR_DIST_TO: DEFAULT_DIST_TO_ZONE, ATTR_DIR_OF_TRAVEL: DEFAULT_DIR_OF_TRAVEL, ATTR_NEAREST: DEFAULT_NEAREST, @@ -130,7 +131,7 @@ class ProximityDataUpdateCoordinator(DataUpdateCoordinator[ProximityData]): }, ) - def _convert(self, value: float | str) -> float | str: + def convert_legacy(self, value: float | str) -> float | str: """Round and convert given distance value.""" if isinstance(value, str): return value @@ -303,7 +304,7 @@ class ProximityDataUpdateCoordinator(DataUpdateCoordinator[ProximityData]): ) # takeover data for legacy proximity entity - proximity_data: dict[str, str | float] = { + proximity_data: dict[str, str | int | None] = { ATTR_DIST_TO: DEFAULT_DIST_TO_ZONE, ATTR_DIR_OF_TRAVEL: DEFAULT_DIR_OF_TRAVEL, ATTR_NEAREST: DEFAULT_NEAREST, @@ -318,28 +319,26 @@ class ProximityDataUpdateCoordinator(DataUpdateCoordinator[ProximityData]): _LOGGER.debug("set first entity_data: %s", entity_data) proximity_data = { ATTR_DIST_TO: distance_to, - ATTR_DIR_OF_TRAVEL: entity_data[ATTR_DIR_OF_TRAVEL] or "unknown", + ATTR_DIR_OF_TRAVEL: entity_data[ATTR_DIR_OF_TRAVEL], ATTR_NEAREST: str(entity_data[ATTR_NAME]), } continue - if float(nearest_distance_to) > float(distance_to): + if cast(int, nearest_distance_to) > int(distance_to): _LOGGER.debug("set closer entity_data: %s", entity_data) proximity_data = { ATTR_DIST_TO: distance_to, - ATTR_DIR_OF_TRAVEL: entity_data[ATTR_DIR_OF_TRAVEL] or "unknown", + ATTR_DIR_OF_TRAVEL: entity_data[ATTR_DIR_OF_TRAVEL], ATTR_NEAREST: str(entity_data[ATTR_NAME]), } continue - if float(nearest_distance_to) == float(distance_to): + if cast(int, nearest_distance_to) == int(distance_to): _LOGGER.debug("set equally close entity_data: %s", entity_data) proximity_data[ ATTR_NEAREST ] = f"{proximity_data[ATTR_NEAREST]}, {str(entity_data[ATTR_NAME])}" - 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: diff --git a/homeassistant/components/proximity/sensor.py b/homeassistant/components/proximity/sensor.py index 4b1e1d1f29d..c000aa27683 100644 --- a/homeassistant/components/proximity/sensor.py +++ b/homeassistant/components/proximity/sensor.py @@ -17,38 +17,53 @@ from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import ATTR_DIR_OF_TRAVEL, ATTR_DIST_TO, ATTR_NEAREST, DOMAIN +from .const import ( + ATTR_DIR_OF_TRAVEL, + ATTR_DIST_TO, + ATTR_NEAREST, + ATTR_NEAREST_DIR_OF_TRAVEL, + ATTR_NEAREST_DIST_TO, + DOMAIN, +) from .coordinator import ProximityDataUpdateCoordinator +DIRECTIONS = ["arrived", "away_from", "stationary", "towards"] + SENSORS_PER_ENTITY: list[SensorEntityDescription] = [ SensorEntityDescription( key=ATTR_DIST_TO, - name="Distance", + translation_key=ATTR_DIST_TO, device_class=SensorDeviceClass.DISTANCE, native_unit_of_measurement=UnitOfLength.METERS, ), SensorEntityDescription( key=ATTR_DIR_OF_TRAVEL, - name="Direction of travel", translation_key=ATTR_DIR_OF_TRAVEL, icon="mdi:compass-outline", device_class=SensorDeviceClass.ENUM, - options=[ - "arrived", - "away_from", - "stationary", - "towards", - ], + options=DIRECTIONS, ), ] SENSORS_PER_PROXIMITY: list[SensorEntityDescription] = [ SensorEntityDescription( key=ATTR_NEAREST, - name="Nearest", translation_key=ATTR_NEAREST, icon="mdi:near-me", ), + SensorEntityDescription( + key=ATTR_DIST_TO, + translation_key=ATTR_NEAREST_DIST_TO, + device_class=SensorDeviceClass.DISTANCE, + native_unit_of_measurement=UnitOfLength.METERS, + ), + SensorEntityDescription( + key=ATTR_DIR_OF_TRAVEL, + translation_key=ATTR_NEAREST_DIR_OF_TRAVEL, + icon="mdi:compass-outline", + device_class=SensorDeviceClass.ENUM, + options=DIRECTIONS, + ), ] @@ -151,8 +166,10 @@ class ProximityTrackedEntitySensor( self.tracked_entity_id = tracked_entity_descriptor.entity_id 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) + self._attr_translation_placeholders = { + "tracked_entity": self.tracked_entity_id.split(".")[-1] + } async def async_added_to_hass(self) -> None: """Register entity mapping.""" diff --git a/homeassistant/components/proximity/strings.json b/homeassistant/components/proximity/strings.json index f52f3d03516..72c95eeeeae 100644 --- a/homeassistant/components/proximity/strings.json +++ b/homeassistant/components/proximity/strings.json @@ -32,7 +32,7 @@ "entity": { "sensor": { "dir_of_travel": { - "name": "Direction of travel", + "name": "{tracked_entity} Direction of travel", "state": { "arrived": "Arrived", "away_from": "Away from", @@ -40,7 +40,18 @@ "towards": "Towards" } }, - "nearest": { "name": "Nearest device" } + "dist_to_zone": { "name": "{tracked_entity} Distance" }, + "nearest": { "name": "Nearest device" }, + "nearest_dir_of_travel": { + "name": "Nearest direction of travel", + "state": { + "arrived": "Arrived", + "away_from": "Away from", + "stationary": "Stationary", + "towards": "Towards" + } + }, + "nearest_dist_to_zone": { "name": "Nearest distance" } } }, "issues": { diff --git a/tests/components/proximity/snapshots/test_diagnostics.ambr b/tests/components/proximity/snapshots/test_diagnostics.ambr index a93ff33f443..68270dc3297 100644 --- a/tests/components/proximity/snapshots/test_diagnostics.ambr +++ b/tests/components/proximity/snapshots/test_diagnostics.ambr @@ -41,8 +41,8 @@ ]), }), 'proximity': dict({ - 'dir_of_travel': 'unknown', - 'dist_to_zone': 2219, + 'dir_of_travel': None, + 'dist_to_zone': 2218752, 'nearest': 'test1', }), 'tracked_states': dict({ diff --git a/tests/components/proximity/test_init.py b/tests/components/proximity/test_init.py index 059ba2658ee..bce4c319ce0 100644 --- a/tests/components/proximity/test_init.py +++ b/tests/components/proximity/test_init.py @@ -907,6 +907,132 @@ async def test_device_tracker_test1_nearest_after_test2_in_ignored_zone( assert state.state == "away_from" +async def test_nearest_sensors(hass: HomeAssistant, config_zones) -> None: + """Test for nearest sensors.""" + mock_config = MockConfigEntry( + domain=DOMAIN, + title="home", + data={ + CONF_ZONE: "zone.home", + CONF_TRACKED_ENTITIES: ["device_tracker.test1", "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() + + hass.states.async_set( + "device_tracker.test1", + "not_home", + {"friendly_name": "test1", "latitude": 20, "longitude": 10}, + ) + hass.states.async_set( + "device_tracker.test2", + "not_home", + {"friendly_name": "test2", "latitude": 40, "longitude": 20}, + ) + await hass.async_block_till_done() + + hass.states.async_set( + "device_tracker.test1", + "not_home", + {"friendly_name": "test1", "latitude": 15, "longitude": 8}, + ) + hass.states.async_set( + "device_tracker.test2", + "not_home", + {"friendly_name": "test2", "latitude": 45, "longitude": 22}, + ) + await hass.async_block_till_done() + + # sensor entities + state = hass.states.get("sensor.home_nearest_device") + assert state.state == "test1" + state = hass.states.get("sensor.home_nearest_distance") + assert state.state == "1615590" + state = hass.states.get("sensor.home_test1_direction_of_travel") + assert state.state == "towards" + state = hass.states.get("sensor.home_test1_distance") + assert state.state == "1615590" + state = hass.states.get("sensor.home_test1_direction_of_travel") + assert state.state == "towards" + state = hass.states.get("sensor.home_test2_distance") + assert state.state == "5176058" + state = hass.states.get("sensor.home_test2_direction_of_travel") + assert state.state == "away_from" + + # move the far tracker + hass.states.async_set( + "device_tracker.test2", + "not_home", + {"friendly_name": "test2", "latitude": 40, "longitude": 20}, + ) + await hass.async_block_till_done() + state = hass.states.get("sensor.home_nearest_device") + assert state.state == "test1" + state = hass.states.get("sensor.home_nearest_distance") + assert state.state == "1615590" + state = hass.states.get("sensor.home_nearest_direction_of_travel") + assert state.state == "towards" + state = hass.states.get("sensor.home_test1_distance") + assert state.state == "1615590" + state = hass.states.get("sensor.home_test1_direction_of_travel") + assert state.state == "towards" + state = hass.states.get("sensor.home_test2_distance") + assert state.state == "4611404" + state = hass.states.get("sensor.home_test2_direction_of_travel") + assert state.state == "towards" + + # move the near tracker + hass.states.async_set( + "device_tracker.test1", + "not_home", + {"friendly_name": "test1", "latitude": 20, "longitude": 10}, + ) + await hass.async_block_till_done() + state = hass.states.get("sensor.home_nearest_device") + assert state.state == "test1" + state = hass.states.get("sensor.home_nearest_distance") + assert state.state == "2204122" + state = hass.states.get("sensor.home_nearest_direction_of_travel") + assert state.state == "away_from" + state = hass.states.get("sensor.home_test1_distance") + assert state.state == "2204122" + state = hass.states.get("sensor.home_test1_direction_of_travel") + assert state.state == "away_from" + state = hass.states.get("sensor.home_test2_distance") + assert state.state == "4611404" + state = hass.states.get("sensor.home_test2_direction_of_travel") + assert state.state == "towards" + + # get unknown distance and direction + hass.states.async_set( + "device_tracker.test1", "not_home", {"friendly_name": "test1"} + ) + hass.states.async_set( + "device_tracker.test2", "not_home", {"friendly_name": "test2"} + ) + await hass.async_block_till_done() + state = hass.states.get("sensor.home_nearest_device") + assert state.state == STATE_UNKNOWN + state = hass.states.get("sensor.home_nearest_distance") + assert state.state == STATE_UNKNOWN + state = hass.states.get("sensor.home_nearest_direction_of_travel") + assert state.state == STATE_UNKNOWN + state = hass.states.get("sensor.home_test1_distance") + assert state.state == STATE_UNKNOWN + state = hass.states.get("sensor.home_test1_direction_of_travel") + assert state.state == STATE_UNKNOWN + state = hass.states.get("sensor.home_test2_distance") + assert state.state == STATE_UNKNOWN + state = hass.states.get("sensor.home_test2_direction_of_travel") + assert state.state == STATE_UNKNOWN + + async def test_create_deprecated_proximity_issue( hass: HomeAssistant, issue_registry: ir.IssueRegistry,