diff --git a/homeassistant/components/ping/__init__.py b/homeassistant/components/ping/__init__.py index 81df1401f91..9aaf8ffbd99 100644 --- a/homeassistant/components/ping/__init__.py +++ b/homeassistant/components/ping/__init__.py @@ -19,7 +19,7 @@ from .helpers import PingDataICMPLib, PingDataSubProcess _LOGGER = logging.getLogger(__name__) CONFIG_SCHEMA = cv.platform_only_config_schema(DOMAIN) -PLATFORMS = [Platform.BINARY_SENSOR, Platform.DEVICE_TRACKER] +PLATFORMS = [Platform.BINARY_SENSOR, Platform.DEVICE_TRACKER, Platform.SENSOR] @dataclass(slots=True) diff --git a/homeassistant/components/ping/binary_sensor.py b/homeassistant/components/ping/binary_sensor.py index 97636111586..eacf718b6a2 100644 --- a/homeassistant/components/ping/binary_sensor.py +++ b/homeassistant/components/ping/binary_sensor.py @@ -18,11 +18,11 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from homeassistant.helpers.update_coordinator import CoordinatorEntity from . import PingDomainData from .const import CONF_IMPORTED_BY, CONF_PING_COUNT, DEFAULT_PING_COUNT, DOMAIN from .coordinator import PingUpdateCoordinator +from .entity import PingEntity _LOGGER = logging.getLogger(__name__) @@ -84,20 +84,18 @@ async def async_setup_entry( async_add_entities([PingBinarySensor(entry, data.coordinators[entry.entry_id])]) -class PingBinarySensor(CoordinatorEntity[PingUpdateCoordinator], BinarySensorEntity): +class PingBinarySensor(PingEntity, BinarySensorEntity): """Representation of a Ping Binary sensor.""" _attr_device_class = BinarySensorDeviceClass.CONNECTIVITY _attr_available = False + _attr_name = None def __init__( self, config_entry: ConfigEntry, coordinator: PingUpdateCoordinator ) -> None: """Initialize the Ping Binary sensor.""" - super().__init__(coordinator) - - self._attr_name = config_entry.title - self._attr_unique_id = config_entry.entry_id + super().__init__(coordinator, config_entry.entry_id) # if this was imported just enable it when it was enabled before if CONF_IMPORTED_BY in config_entry.data: @@ -113,11 +111,9 @@ class PingBinarySensor(CoordinatorEntity[PingUpdateCoordinator], BinarySensorEnt @property def extra_state_attributes(self) -> dict[str, Any] | None: """Return the state attributes of the ICMP checo request.""" - if self.coordinator.data.data is None: - return None return { - ATTR_ROUND_TRIP_TIME_AVG: self.coordinator.data.data["avg"], - ATTR_ROUND_TRIP_TIME_MAX: self.coordinator.data.data["max"], - ATTR_ROUND_TRIP_TIME_MDEV: self.coordinator.data.data["mdev"], - ATTR_ROUND_TRIP_TIME_MIN: self.coordinator.data.data["min"], + ATTR_ROUND_TRIP_TIME_AVG: self.coordinator.data.data.get("avg"), + ATTR_ROUND_TRIP_TIME_MAX: self.coordinator.data.data.get("max"), + ATTR_ROUND_TRIP_TIME_MDEV: self.coordinator.data.data.get("mdev"), + ATTR_ROUND_TRIP_TIME_MIN: self.coordinator.data.data.get("min"), } diff --git a/homeassistant/components/ping/coordinator.py b/homeassistant/components/ping/coordinator.py index f6bda9693b8..57e212ed69d 100644 --- a/homeassistant/components/ping/coordinator.py +++ b/homeassistant/components/ping/coordinator.py @@ -20,7 +20,7 @@ class PingResult: ip_address: str is_alive: bool - data: dict[str, Any] | None + data: dict[str, Any] class PingUpdateCoordinator(DataUpdateCoordinator[PingResult]): @@ -49,5 +49,5 @@ class PingUpdateCoordinator(DataUpdateCoordinator[PingResult]): return PingResult( ip_address=self.ping.ip_address, is_alive=self.ping.is_alive, - data=self.ping.data, + data=self.ping.data or {}, ) diff --git a/homeassistant/components/ping/entity.py b/homeassistant/components/ping/entity.py new file mode 100644 index 00000000000..1332b82e9a8 --- /dev/null +++ b/homeassistant/components/ping/entity.py @@ -0,0 +1,26 @@ +"""Base entity for the Ping component.""" +from homeassistant.core import DOMAIN +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .coordinator import PingUpdateCoordinator + + +class PingEntity(CoordinatorEntity[PingUpdateCoordinator]): + """Represents a Ping base entity.""" + + _attr_has_entity_name = True + + def __init__( + self, + coordinator: PingUpdateCoordinator, + unique_id: str, + ) -> None: + """Initialize the sensor.""" + super().__init__(coordinator) + + self._attr_unique_id = unique_id + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, self.coordinator.data.ip_address)}, + manufacturer="Ping", + ) diff --git a/homeassistant/components/ping/helpers.py b/homeassistant/components/ping/helpers.py index e3ebaffec12..16d36b61ab7 100644 --- a/homeassistant/components/ping/helpers.py +++ b/homeassistant/components/ping/helpers.py @@ -70,7 +70,6 @@ class PingDataICMPLib(PingData): "min": data.min_rtt, "max": data.max_rtt, "avg": data.avg_rtt, - "mdev": "", } @@ -135,7 +134,7 @@ class PingDataSubProcess(PingData): if TYPE_CHECKING: assert match is not None rtt_min, rtt_avg, rtt_max = match.groups() - return {"min": rtt_min, "avg": rtt_avg, "max": rtt_max, "mdev": ""} + return {"min": rtt_min, "avg": rtt_avg, "max": rtt_max} match = PING_MATCHER.search(str(out_data).rsplit("\n", maxsplit=1)[-1]) if TYPE_CHECKING: assert match is not None diff --git a/homeassistant/components/ping/sensor.py b/homeassistant/components/ping/sensor.py new file mode 100644 index 00000000000..5010c93f314 --- /dev/null +++ b/homeassistant/components/ping/sensor.py @@ -0,0 +1,116 @@ +"""Sensor platform that for Ping integration.""" +from collections.abc import Callable +from dataclasses import dataclass + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import EntityCategory, UnitOfTime +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import PingDomainData +from .const import DOMAIN +from .coordinator import PingResult, PingUpdateCoordinator +from .entity import PingEntity + + +@dataclass(frozen=True, kw_only=True) +class PingSensorEntityDescription(SensorEntityDescription): + """Class to describe a Ping sensor entity.""" + + value_fn: Callable[[PingResult], float | None] + has_fn: Callable[[PingResult], bool] + + +SENSORS: tuple[PingSensorEntityDescription, ...] = ( + PingSensorEntityDescription( + key="round_trip_time_avg", + translation_key="round_trip_time_avg", + native_unit_of_measurement=UnitOfTime.MILLISECONDS, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.DURATION, + entity_registry_enabled_default=False, + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda result: result.data.get("avg"), + has_fn=lambda result: "avg" in result.data, + ), + PingSensorEntityDescription( + key="round_trip_time_max", + translation_key="round_trip_time_max", + native_unit_of_measurement=UnitOfTime.MILLISECONDS, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.DURATION, + entity_registry_enabled_default=False, + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda result: result.data.get("max"), + has_fn=lambda result: "max" in result.data, + ), + PingSensorEntityDescription( + key="round_trip_time_mdev", + translation_key="round_trip_time_mdev", + native_unit_of_measurement=UnitOfTime.MILLISECONDS, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.DURATION, + entity_registry_enabled_default=False, + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda result: result.data.get("mdev"), + has_fn=lambda result: "mdev" in result.data, + ), + PingSensorEntityDescription( + key="round_trip_time_min", + translation_key="round_trip_time_min", + native_unit_of_measurement=UnitOfTime.MILLISECONDS, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.DURATION, + entity_registry_enabled_default=False, + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda result: result.data.get("min"), + has_fn=lambda result: "min" in result.data, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up Ping sensors from config entry.""" + data: PingDomainData = hass.data[DOMAIN] + coordinator = data.coordinators[entry.entry_id] + + async_add_entities( + PingSensor(entry, description, coordinator) + for description in SENSORS + if description.has_fn(coordinator.data) + ) + + +class PingSensor(PingEntity, SensorEntity): + """Represents a Ping sensor.""" + + entity_description: PingSensorEntityDescription + + def __init__( + self, + config_entry: ConfigEntry, + description: PingSensorEntityDescription, + coordinator: PingUpdateCoordinator, + ) -> None: + """Initialize the sensor.""" + super().__init__(coordinator, f"{config_entry.entry_id}-{description.key}") + + self.entity_description = description + + @property + def available(self) -> bool: + """Return True if entity is available.""" + return super().available and self.coordinator.data.is_alive + + @property + def native_value(self) -> float | None: + """Return the sensor state.""" + return self.entity_description.value_fn(self.coordinator.data) diff --git a/homeassistant/components/ping/strings.json b/homeassistant/components/ping/strings.json index 421d9079c62..ef9f74b4207 100644 --- a/homeassistant/components/ping/strings.json +++ b/homeassistant/components/ping/strings.json @@ -1,4 +1,20 @@ { + "entity": { + "sensor": { + "round_trip_time_avg": { + "name": "Round Trip Time Average" + }, + "round_trip_time_max": { + "name": "Round Trip Time Maximum" + }, + "round_trip_time_mdev": { + "name": "Round Trip Time Mean Deviation" + }, + "round_trip_time_min": { + "name": "Round Trip Time Minimum" + } + } + }, "config": { "step": { "user": { diff --git a/tests/components/ping/conftest.py b/tests/components/ping/conftest.py index 24dd3314e3c..b0f772e603f 100644 --- a/tests/components/ping/conftest.py +++ b/tests/components/ping/conftest.py @@ -26,7 +26,7 @@ def patch_setup(*args, **kwargs): @pytest.fixture(autouse=True) async def patch_ping(): """Patch icmplib async_ping.""" - mock = Host("10.10.10.10", 5, [10, 1, 2]) + mock = Host("10.10.10.10", 5, [10, 1, 2, 5, 6]) with patch( "homeassistant.components.ping.helpers.async_ping", return_value=mock diff --git a/tests/components/ping/snapshots/test_binary_sensor.ambr b/tests/components/ping/snapshots/test_binary_sensor.ambr index 0924c383fc2..e4d26fe873e 100644 --- a/tests/components/ping/snapshots/test_binary_sensor.ambr +++ b/tests/components/ping/snapshots/test_binary_sensor.ambr @@ -72,7 +72,7 @@ 'domain': 'binary_sensor', 'entity_category': None, 'entity_id': 'binary_sensor.10_10_10_10', - 'has_entity_name': False, + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -83,7 +83,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': '10.10.10.10', + 'original_name': None, 'platform': 'ping', 'previous_unique_id': None, 'supported_features': 0, @@ -96,9 +96,9 @@ 'attributes': ReadOnlyDict({ 'device_class': 'connectivity', 'friendly_name': '10.10.10.10', - 'round_trip_time_avg': 4.333, + 'round_trip_time_avg': 4.8, 'round_trip_time_max': 10, - 'round_trip_time_mdev': '', + 'round_trip_time_mdev': None, 'round_trip_time_min': 1, }), 'context': , @@ -113,6 +113,10 @@ 'attributes': ReadOnlyDict({ 'device_class': 'connectivity', 'friendly_name': '10.10.10.10', + 'round_trip_time_avg': None, + 'round_trip_time_max': None, + 'round_trip_time_mdev': None, + 'round_trip_time_min': None, }), 'context': , 'entity_id': 'binary_sensor.10_10_10_10', diff --git a/tests/components/ping/snapshots/test_sensor.ambr b/tests/components/ping/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..c91dcff687a --- /dev/null +++ b/tests/components/ping/snapshots/test_sensor.ambr @@ -0,0 +1,154 @@ +# serializer version: 1 +# name: test_setup_and_update[round_trip_time_average] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.10_10_10_10_round_trip_time_average', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Round Trip Time Average', + 'platform': 'ping', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'round_trip_time_avg', + 'unit_of_measurement': , + }) +# --- +# name: test_setup_and_update[round_trip_time_average].1 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': '10.10.10.10 Round Trip Time Average', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.10_10_10_10_round_trip_time_average', + 'last_changed': , + 'last_updated': , + 'state': '4.8', + }) +# --- +# name: test_setup_and_update[round_trip_time_maximum] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.10_10_10_10_round_trip_time_maximum', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Round Trip Time Maximum', + 'platform': 'ping', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'round_trip_time_max', + 'unit_of_measurement': , + }) +# --- +# name: test_setup_and_update[round_trip_time_maximum].1 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': '10.10.10.10 Round Trip Time Maximum', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.10_10_10_10_round_trip_time_maximum', + 'last_changed': , + 'last_updated': , + 'state': '10', + }) +# --- +# name: test_setup_and_update[round_trip_time_mean_deviation] + None +# --- +# name: test_setup_and_update[round_trip_time_mean_deviation].1 + None +# --- +# name: test_setup_and_update[round_trip_time_minimum] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.10_10_10_10_round_trip_time_minimum', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Round Trip Time Minimum', + 'platform': 'ping', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'round_trip_time_min', + 'unit_of_measurement': , + }) +# --- +# name: test_setup_and_update[round_trip_time_minimum].1 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': '10.10.10.10 Round Trip Time Minimum', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.10_10_10_10_round_trip_time_minimum', + 'last_changed': , + 'last_updated': , + 'state': '1', + }) +# --- diff --git a/tests/components/ping/test_sensor.py b/tests/components/ping/test_sensor.py new file mode 100644 index 00000000000..5c4833aaf06 --- /dev/null +++ b/tests/components/ping/test_sensor.py @@ -0,0 +1,33 @@ +"""Test sensor platform of Ping.""" + +import pytest +from syrupy import SnapshotAssertion +from syrupy.filters import props + +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default", "setup_integration") +@pytest.mark.parametrize( + "sensor_name", + [ + "round_trip_time_average", + "round_trip_time_maximum", + "round_trip_time_mean_deviation", # should be None in the snapshot + "round_trip_time_minimum", + ], +) +async def test_setup_and_update( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + sensor_name: str, +) -> None: + """Test sensor setup and update.""" + + entry = entity_registry.async_get(f"sensor.10_10_10_10_{sensor_name}") + assert entry == snapshot(exclude=props("unique_id")) + + state = hass.states.get(f"sensor.10_10_10_10_{sensor_name}") + assert state == snapshot