diff --git a/homeassistant/components/pi_hole/__init__.py b/homeassistant/components/pi_hole/__init__.py index 07b81a2702e..df156353b88 100644 --- a/homeassistant/components/pi_hole/__init__.py +++ b/homeassistant/components/pi_hole/__init__.py @@ -153,7 +153,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: @callback def _async_platforms(entry: ConfigEntry) -> list[Platform]: """Return platforms to be loaded / unloaded.""" - platforms = [Platform.BINARY_SENSOR, Platform.SENSOR] + platforms = [Platform.BINARY_SENSOR, Platform.UPDATE, Platform.SENSOR] if not entry.data[CONF_STATISTICS_ONLY]: platforms.append(Platform.SWITCH) return platforms diff --git a/homeassistant/components/pi_hole/const.py b/homeassistant/components/pi_hole/const.py index 69e8ecacdaa..c73660faedb 100644 --- a/homeassistant/components/pi_hole/const.py +++ b/homeassistant/components/pi_hole/const.py @@ -119,8 +119,10 @@ class PiHoleBinarySensorEntityDescription( BINARY_SENSOR_TYPES: tuple[PiHoleBinarySensorEntityDescription, ...] = ( PiHoleBinarySensorEntityDescription( + # Deprecated, scheduled to be removed in 2022.6 key="core_update_available", name="Core Update Available", + entity_registry_enabled_default=False, device_class=BinarySensorDeviceClass.UPDATE, extra_value=lambda api: { "current_version": api.versions["core_current"], @@ -129,8 +131,10 @@ BINARY_SENSOR_TYPES: tuple[PiHoleBinarySensorEntityDescription, ...] = ( state_value=lambda api: bool(api.versions["core_update"]), ), PiHoleBinarySensorEntityDescription( + # Deprecated, scheduled to be removed in 2022.6 key="web_update_available", name="Web Update Available", + entity_registry_enabled_default=False, device_class=BinarySensorDeviceClass.UPDATE, extra_value=lambda api: { "current_version": api.versions["web_current"], @@ -139,8 +143,10 @@ BINARY_SENSOR_TYPES: tuple[PiHoleBinarySensorEntityDescription, ...] = ( state_value=lambda api: bool(api.versions["web_update"]), ), PiHoleBinarySensorEntityDescription( + # Deprecated, scheduled to be removed in 2022.6 key="ftl_update_available", name="FTL Update Available", + entity_registry_enabled_default=False, device_class=BinarySensorDeviceClass.UPDATE, extra_value=lambda api: { "current_version": api.versions["FTL_current"], diff --git a/homeassistant/components/pi_hole/update.py b/homeassistant/components/pi_hole/update.py new file mode 100644 index 00000000000..14e6761f7c5 --- /dev/null +++ b/homeassistant/components/pi_hole/update.py @@ -0,0 +1,121 @@ +"""Support for update entities of a Pi-hole system.""" +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass + +from hole import Hole + +from homeassistant.components.update import UpdateEntity, UpdateEntityDescription +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_NAME +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity import EntityCategory +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +from . import PiHoleEntity +from .const import DATA_KEY_API, DATA_KEY_COORDINATOR, DOMAIN + + +@dataclass +class PiHoleUpdateEntityDescription(UpdateEntityDescription): + """Describes PiHole update entity.""" + + current_version: Callable[[dict], str | None] = lambda api: None + latest_version: Callable[[dict], str | None] = lambda api: None + release_base_url: str | None = None + title: str | None = None + + +UPDATE_ENTITY_TYPES: tuple[PiHoleUpdateEntityDescription, ...] = ( + PiHoleUpdateEntityDescription( + key="core_update_available", + name="Core Update Available", + title="Pi-hole Core", + entity_category=EntityCategory.DIAGNOSTIC, + current_version=lambda versions: versions.get("core_current"), + latest_version=lambda versions: versions.get("core_latest"), + release_base_url="https://github.com/pi-hole/pi-hole/releases/tag", + ), + PiHoleUpdateEntityDescription( + key="web_update_available", + name="Web Update Available", + title="Pi-hole Web interface", + entity_category=EntityCategory.DIAGNOSTIC, + current_version=lambda versions: versions.get("web_current"), + latest_version=lambda versions: versions.get("web_latest"), + release_base_url="https://github.com/pi-hole/AdminLTE/releases/tag", + ), + PiHoleUpdateEntityDescription( + key="ftl_update_available", + name="FTL Update Available", + title="Pi-hole FTL DNS", + entity_category=EntityCategory.DIAGNOSTIC, + current_version=lambda versions: versions.get("FTL_current"), + latest_version=lambda versions: versions.get("FTL_latest"), + release_base_url="https://github.com/pi-hole/FTL/releases/tag", + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up the Pi-hole update entities.""" + name = entry.data[CONF_NAME] + hole_data = hass.data[DOMAIN][entry.entry_id] + + async_add_entities( + PiHoleUpdateEntity( + hole_data[DATA_KEY_API], + hole_data[DATA_KEY_COORDINATOR], + name, + entry.entry_id, + description, + ) + for description in UPDATE_ENTITY_TYPES + ) + + +class PiHoleUpdateEntity(PiHoleEntity, UpdateEntity): + """Representation of a Pi-hole update entity.""" + + entity_description: PiHoleUpdateEntityDescription + + def __init__( + self, + api: Hole, + coordinator: DataUpdateCoordinator, + name: str, + server_unique_id: str, + description: PiHoleUpdateEntityDescription, + ) -> None: + """Initialize a Pi-hole update entity.""" + super().__init__(api, coordinator, name, server_unique_id) + self.entity_description = description + + self._attr_name = f"{name} {description.name}" + self._attr_unique_id = f"{self._server_unique_id}/{description.name}" + self._attr_title = description.title + + @property + def current_version(self) -> str | None: + """Version currently in use.""" + if isinstance(self.api.versions, dict): + return self.entity_description.current_version(self.api.versions) + return None + + @property + def latest_version(self) -> str | None: + """Latest version available for install.""" + if isinstance(self.api.versions, dict): + return self.entity_description.latest_version(self.api.versions) + return None + + @property + def release_url(self) -> str | None: + """URL to the full release notes of the latest version available.""" + if self.latest_version: + return f"{self.entity_description.release_base_url}/{self.latest_version}" + return None diff --git a/tests/components/pi_hole/__init__.py b/tests/components/pi_hole/__init__.py index f45c05cfe74..57ea89fc7e0 100644 --- a/tests/components/pi_hole/__init__.py +++ b/tests/components/pi_hole/__init__.py @@ -83,7 +83,7 @@ CONF_CONFIG_ENTRY = { SWITCH_ENTITY_ID = "switch.pi_hole" -def _create_mocked_hole(raise_exception=False): +def _create_mocked_hole(raise_exception=False, has_versions=True): mocked_hole = MagicMock() type(mocked_hole).get_data = AsyncMock( side_effect=HoleError("") if raise_exception else None @@ -94,7 +94,10 @@ def _create_mocked_hole(raise_exception=False): type(mocked_hole).enable = AsyncMock() type(mocked_hole).disable = AsyncMock() mocked_hole.data = ZERO_DATA - mocked_hole.versions = SAMPLE_VERSIONS + if has_versions: + mocked_hole.versions = SAMPLE_VERSIONS + else: + mocked_hole.versions = None return mocked_hole diff --git a/tests/components/pi_hole/test_init.py b/tests/components/pi_hole/test_init.py index 9c336f8bb6d..dce3773acdc 100644 --- a/tests/components/pi_hole/test_init.py +++ b/tests/components/pi_hole/test_init.py @@ -47,106 +47,45 @@ async def test_setup_minimal_config(hass): await hass.async_block_till_done() - assert ( - hass.states.get("sensor.pi_hole_ads_blocked_today").name - == "Pi-Hole Ads Blocked Today" - ) - assert ( - hass.states.get("sensor.pi_hole_ads_percentage_blocked_today").name - == "Pi-Hole Ads Percentage Blocked Today" - ) - assert ( - hass.states.get("sensor.pi_hole_dns_queries_cached").name - == "Pi-Hole DNS Queries Cached" - ) - assert ( - hass.states.get("sensor.pi_hole_dns_queries_forwarded").name - == "Pi-Hole DNS Queries Forwarded" - ) - assert ( - hass.states.get("sensor.pi_hole_dns_queries_today").name - == "Pi-Hole DNS Queries Today" - ) - assert ( - hass.states.get("sensor.pi_hole_dns_unique_clients").name - == "Pi-Hole DNS Unique Clients" - ) - assert ( - hass.states.get("sensor.pi_hole_dns_unique_domains").name - == "Pi-Hole DNS Unique Domains" - ) - assert ( - hass.states.get("sensor.pi_hole_domains_blocked").name - == "Pi-Hole Domains Blocked" - ) - assert hass.states.get("sensor.pi_hole_seen_clients").name == "Pi-Hole Seen Clients" + state = hass.states.get("sensor.pi_hole_ads_blocked_today") + assert state.name == "Pi-Hole Ads Blocked Today" + assert state.state == "0" - assert hass.states.get("sensor.pi_hole_ads_blocked_today").state == "0" - assert hass.states.get("sensor.pi_hole_ads_percentage_blocked_today").state == "0" - assert hass.states.get("sensor.pi_hole_dns_queries_cached").state == "0" - assert hass.states.get("sensor.pi_hole_dns_queries_forwarded").state == "0" - assert hass.states.get("sensor.pi_hole_dns_queries_today").state == "0" - assert hass.states.get("sensor.pi_hole_dns_unique_clients").state == "0" - assert hass.states.get("sensor.pi_hole_dns_unique_domains").state == "0" - assert hass.states.get("sensor.pi_hole_domains_blocked").state == "0" - assert hass.states.get("sensor.pi_hole_seen_clients").state == "0" + state = hass.states.get("sensor.pi_hole_ads_percentage_blocked_today") + assert state.name == "Pi-Hole Ads Percentage Blocked Today" + assert state.state == "0" - assert hass.states.get("binary_sensor.pi_hole").name == "Pi-Hole" - assert hass.states.get("binary_sensor.pi_hole").state == "off" + state = hass.states.get("sensor.pi_hole_dns_queries_cached") + assert state.name == "Pi-Hole DNS Queries Cached" + assert state.state == "0" - assert ( - hass.states.get("binary_sensor.pi_hole_core_update_available").name - == "Pi-Hole Core Update Available" - ) - assert hass.states.get("binary_sensor.pi_hole_core_update_available").state == "on" - assert ( - hass.states.get("binary_sensor.pi_hole_core_update_available").attributes[ - "current_version" - ] - == "v5.5" - ) - assert ( - hass.states.get("binary_sensor.pi_hole_core_update_available").attributes[ - "latest_version" - ] - == "v5.6" - ) + state = hass.states.get("sensor.pi_hole_dns_queries_forwarded") + assert state.name == "Pi-Hole DNS Queries Forwarded" + assert state.state == "0" - assert ( - hass.states.get("binary_sensor.pi_hole_ftl_update_available").name - == "Pi-Hole FTL Update Available" - ) - assert hass.states.get("binary_sensor.pi_hole_ftl_update_available").state == "on" - assert ( - hass.states.get("binary_sensor.pi_hole_ftl_update_available").attributes[ - "current_version" - ] - == "v5.10" - ) - assert ( - hass.states.get("binary_sensor.pi_hole_ftl_update_available").attributes[ - "latest_version" - ] - == "v5.11" - ) + state = hass.states.get("sensor.pi_hole_dns_queries_today") + assert state.name == "Pi-Hole DNS Queries Today" + assert state.state == "0" - assert ( - hass.states.get("binary_sensor.pi_hole_web_update_available").name - == "Pi-Hole Web Update Available" - ) - assert hass.states.get("binary_sensor.pi_hole_web_update_available").state == "on" - assert ( - hass.states.get("binary_sensor.pi_hole_web_update_available").attributes[ - "current_version" - ] - == "v5.7" - ) - assert ( - hass.states.get("binary_sensor.pi_hole_web_update_available").attributes[ - "latest_version" - ] - == "v5.8" - ) + state = hass.states.get("sensor.pi_hole_dns_unique_clients") + assert state.name == "Pi-Hole DNS Unique Clients" + assert state.state == "0" + + state = hass.states.get("sensor.pi_hole_dns_unique_domains") + assert state.name == "Pi-Hole DNS Unique Domains" + assert state.state == "0" + + state = hass.states.get("sensor.pi_hole_domains_blocked") + assert state.name == "Pi-Hole Domains Blocked" + assert state.state == "0" + + state = hass.states.get("sensor.pi_hole_seen_clients") + assert state.name == "Pi-Hole Seen Clients" + assert state.state == "0" + + state = hass.states.get("binary_sensor.pi_hole") + assert state.name == "Pi-Hole" + assert state.state == "off" async def test_setup_name_config(hass): diff --git a/tests/components/pi_hole/test_update.py b/tests/components/pi_hole/test_update.py new file mode 100644 index 00000000000..375104a9ce0 --- /dev/null +++ b/tests/components/pi_hole/test_update.py @@ -0,0 +1,80 @@ +"""Test pi_hole component.""" + +from homeassistant.components import pi_hole +from homeassistant.const import STATE_ON, STATE_UNKNOWN +from homeassistant.setup import async_setup_component + +from . import _create_mocked_hole, _patch_config_flow_hole, _patch_init_hole + + +async def test_update(hass): + """Tests update entity.""" + mocked_hole = _create_mocked_hole() + with _patch_config_flow_hole(mocked_hole), _patch_init_hole(mocked_hole): + assert await async_setup_component( + hass, pi_hole.DOMAIN, {pi_hole.DOMAIN: [{"host": "pi.hole"}]} + ) + + await hass.async_block_till_done() + + state = hass.states.get("update.pi_hole_core_update_available") + assert state.name == "Pi-Hole Core Update Available" + assert state.state == STATE_ON + assert state.attributes["current_version"] == "v5.5" + assert state.attributes["latest_version"] == "v5.6" + assert ( + state.attributes["release_url"] + == "https://github.com/pi-hole/pi-hole/releases/tag/v5.6" + ) + + state = hass.states.get("update.pi_hole_ftl_update_available") + assert state.name == "Pi-Hole FTL Update Available" + assert state.state == STATE_ON + assert state.attributes["current_version"] == "v5.10" + assert state.attributes["latest_version"] == "v5.11" + assert ( + state.attributes["release_url"] + == "https://github.com/pi-hole/FTL/releases/tag/v5.11" + ) + + state = hass.states.get("update.pi_hole_web_update_available") + assert state.name == "Pi-Hole Web Update Available" + assert state.state == STATE_ON + assert state.attributes["current_version"] == "v5.7" + assert state.attributes["latest_version"] == "v5.8" + assert ( + state.attributes["release_url"] + == "https://github.com/pi-hole/AdminLTE/releases/tag/v5.8" + ) + + +async def test_update_no_versions(hass): + """Tests update entity when no version data available.""" + mocked_hole = _create_mocked_hole(has_versions=False) + with _patch_config_flow_hole(mocked_hole), _patch_init_hole(mocked_hole): + assert await async_setup_component( + hass, pi_hole.DOMAIN, {pi_hole.DOMAIN: [{"host": "pi.hole"}]} + ) + + await hass.async_block_till_done() + + state = hass.states.get("update.pi_hole_core_update_available") + assert state.name == "Pi-Hole Core Update Available" + assert state.state == STATE_UNKNOWN + assert state.attributes["current_version"] is None + assert state.attributes["latest_version"] is None + assert state.attributes["release_url"] is None + + state = hass.states.get("update.pi_hole_ftl_update_available") + assert state.name == "Pi-Hole FTL Update Available" + assert state.state == STATE_UNKNOWN + assert state.attributes["current_version"] is None + assert state.attributes["latest_version"] is None + assert state.attributes["release_url"] is None + + state = hass.states.get("update.pi_hole_web_update_available") + assert state.name == "Pi-Hole Web Update Available" + assert state.state == STATE_UNKNOWN + assert state.attributes["current_version"] is None + assert state.attributes["latest_version"] is None + assert state.attributes["release_url"] is None