From 6eb1dbdb74a578a2872bc91450d141c8c1fc3e6d Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Tue, 9 Aug 2022 17:51:04 +0200 Subject: [PATCH] Add NextDNS binary sensor platform (#75266) * Add binary_sensor platform * Add tests * Add quality scale * Sort coordinators * Remove quality scale * Fix docstring --- homeassistant/components/nextdns/__init__.py | 14 ++- .../components/nextdns/binary_sensor.py | 103 ++++++++++++++++++ homeassistant/components/nextdns/const.py | 2 + tests/components/nextdns/__init__.py | 5 + .../components/nextdns/test_binary_sensor.py | 86 +++++++++++++++ 5 files changed, 209 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/nextdns/binary_sensor.py create mode 100644 tests/components/nextdns/test_binary_sensor.py diff --git a/homeassistant/components/nextdns/__init__.py b/homeassistant/components/nextdns/__init__.py index 2f68abee847..a92186f6f14 100644 --- a/homeassistant/components/nextdns/__init__.py +++ b/homeassistant/components/nextdns/__init__.py @@ -15,6 +15,7 @@ from nextdns import ( AnalyticsProtocols, AnalyticsStatus, ApiError, + ConnectionStatus, InvalidApiKeyError, NextDns, Settings, @@ -31,6 +32,7 @@ from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import ( + ATTR_CONNECTION, ATTR_DNSSEC, ATTR_ENCRYPTION, ATTR_IP_VERSIONS, @@ -40,6 +42,7 @@ from .const import ( CONF_PROFILE_ID, DOMAIN, UPDATE_INTERVAL_ANALYTICS, + UPDATE_INTERVAL_CONNECTION, UPDATE_INTERVAL_SETTINGS, ) @@ -131,10 +134,19 @@ class NextDnsSettingsUpdateCoordinator(NextDnsUpdateCoordinator[Settings]): return await self.nextdns.get_settings(self.profile_id) +class NextDnsConnectionUpdateCoordinator(NextDnsUpdateCoordinator[ConnectionStatus]): + """Class to manage fetching NextDNS connection data from API.""" + + async def _async_update_data_internal(self) -> ConnectionStatus: + """Update data via library.""" + return await self.nextdns.connection_status(self.profile_id) + + _LOGGER = logging.getLogger(__name__) -PLATFORMS = [Platform.BUTTON, Platform.SENSOR, Platform.SWITCH] +PLATFORMS = [Platform.BINARY_SENSOR, Platform.BUTTON, Platform.SENSOR, Platform.SWITCH] COORDINATORS = [ + (ATTR_CONNECTION, NextDnsConnectionUpdateCoordinator, UPDATE_INTERVAL_CONNECTION), (ATTR_DNSSEC, NextDnsDnssecUpdateCoordinator, UPDATE_INTERVAL_ANALYTICS), (ATTR_ENCRYPTION, NextDnsEncryptionUpdateCoordinator, UPDATE_INTERVAL_ANALYTICS), (ATTR_IP_VERSIONS, NextDnsIpVersionsUpdateCoordinator, UPDATE_INTERVAL_ANALYTICS), diff --git a/homeassistant/components/nextdns/binary_sensor.py b/homeassistant/components/nextdns/binary_sensor.py new file mode 100644 index 00000000000..af80d14a89b --- /dev/null +++ b/homeassistant/components/nextdns/binary_sensor.py @@ -0,0 +1,103 @@ +"""Support for the NextDNS service.""" +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from typing import Generic + +from nextdns import ConnectionStatus + +from homeassistant.components.binary_sensor import ( + BinarySensorDeviceClass, + BinarySensorEntity, + BinarySensorEntityDescription, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity import EntityCategory +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from . import NextDnsConnectionUpdateCoordinator, TCoordinatorData +from .const import ATTR_CONNECTION, DOMAIN + +PARALLEL_UPDATES = 1 + + +@dataclass +class NextDnsBinarySensorRequiredKeysMixin(Generic[TCoordinatorData]): + """Mixin for required keys.""" + + state: Callable[[TCoordinatorData, str], bool] + + +@dataclass +class NextDnsBinarySensorEntityDescription( + BinarySensorEntityDescription, + NextDnsBinarySensorRequiredKeysMixin[TCoordinatorData], +): + """NextDNS binary sensor entity description.""" + + +SENSORS = ( + NextDnsBinarySensorEntityDescription[ConnectionStatus]( + key="this_device_nextdns_connection_status", + entity_category=EntityCategory.DIAGNOSTIC, + name="This device NextDNS connection status", + device_class=BinarySensorDeviceClass.CONNECTIVITY, + state=lambda data, _: data.connected, + ), + NextDnsBinarySensorEntityDescription[ConnectionStatus]( + key="this_device_profile_connection_status", + entity_category=EntityCategory.DIAGNOSTIC, + name="This device profile connection status", + device_class=BinarySensorDeviceClass.CONNECTIVITY, + state=lambda data, profile_id: profile_id == data.profile_id, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Add NextDNS entities from a config_entry.""" + coordinator: NextDnsConnectionUpdateCoordinator = hass.data[DOMAIN][entry.entry_id][ + ATTR_CONNECTION + ] + + sensors: list[NextDnsBinarySensor] = [] + for description in SENSORS: + sensors.append(NextDnsBinarySensor(coordinator, description)) + + async_add_entities(sensors) + + +class NextDnsBinarySensor( + CoordinatorEntity[NextDnsConnectionUpdateCoordinator], BinarySensorEntity +): + """Define an NextDNS binary sensor.""" + + _attr_has_entity_name = True + entity_description: NextDnsBinarySensorEntityDescription + + def __init__( + self, + coordinator: NextDnsConnectionUpdateCoordinator, + description: NextDnsBinarySensorEntityDescription, + ) -> None: + """Initialize.""" + super().__init__(coordinator) + self._attr_device_info = coordinator.device_info + self._attr_unique_id = f"{coordinator.profile_id}_{description.key}" + self._attr_is_on = description.state(coordinator.data, coordinator.profile_id) + self.entity_description = description + + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + self._attr_is_on = self.entity_description.state( + self.coordinator.data, self.coordinator.profile_id + ) + self.async_write_ha_state() diff --git a/homeassistant/components/nextdns/const.py b/homeassistant/components/nextdns/const.py index d455dd79635..8cac556c87c 100644 --- a/homeassistant/components/nextdns/const.py +++ b/homeassistant/components/nextdns/const.py @@ -1,6 +1,7 @@ """Constants for NextDNS integration.""" from datetime import timedelta +ATTR_CONNECTION = "connection" ATTR_DNSSEC = "dnssec" ATTR_ENCRYPTION = "encryption" ATTR_IP_VERSIONS = "ip_versions" @@ -11,6 +12,7 @@ ATTR_STATUS = "status" CONF_PROFILE_ID = "profile_id" CONF_PROFILE_NAME = "profile_name" +UPDATE_INTERVAL_CONNECTION = timedelta(minutes=5) UPDATE_INTERVAL_ANALYTICS = timedelta(minutes=10) UPDATE_INTERVAL_SETTINGS = timedelta(minutes=1) diff --git a/tests/components/nextdns/__init__.py b/tests/components/nextdns/__init__.py index 04d838f8f58..8c80db1fdff 100644 --- a/tests/components/nextdns/__init__.py +++ b/tests/components/nextdns/__init__.py @@ -7,6 +7,7 @@ from nextdns import ( AnalyticsIpVersions, AnalyticsProtocols, AnalyticsStatus, + ConnectionStatus, Settings, ) @@ -16,6 +17,7 @@ from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry +CONNECTION_STATUS = ConnectionStatus(connected=True, profile_id="abcdef") PROFILES = [{"id": "xyz12", "fingerprint": "aabbccdd123", "name": "Fake Profile"}] STATUS = AnalyticsStatus( default_queries=40, allowed_queries=30, blocked_queries=20, relayed_queries=10 @@ -129,6 +131,9 @@ async def init_integration(hass: HomeAssistant) -> MockConfigEntry: ), patch( "homeassistant.components.nextdns.NextDns.get_settings", return_value=SETTINGS, + ), patch( + "homeassistant.components.nextdns.NextDns.connection_status", + return_value=CONNECTION_STATUS, ): entry.add_to_hass(hass) await hass.config_entries.async_setup(entry.entry_id) diff --git a/tests/components/nextdns/test_binary_sensor.py b/tests/components/nextdns/test_binary_sensor.py new file mode 100644 index 00000000000..eb1478a5809 --- /dev/null +++ b/tests/components/nextdns/test_binary_sensor.py @@ -0,0 +1,86 @@ +"""Test binary sensor of NextDNS integration.""" +from datetime import timedelta +from unittest.mock import patch + +from nextdns import ApiError + +from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNAVAILABLE +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er +from homeassistant.util.dt import utcnow + +from . import CONNECTION_STATUS, init_integration + +from tests.common import async_fire_time_changed + + +async def test_binary_Sensor(hass: HomeAssistant) -> None: + """Test states of the binary sensors.""" + registry = er.async_get(hass) + + await init_integration(hass) + + state = hass.states.get( + "binary_sensor.fake_profile_this_device_nextdns_connection_status" + ) + assert state + assert state.state == STATE_ON + + entry = registry.async_get( + "binary_sensor.fake_profile_this_device_nextdns_connection_status" + ) + assert entry + assert entry.unique_id == "xyz12_this_device_nextdns_connection_status" + + state = hass.states.get( + "binary_sensor.fake_profile_this_device_profile_connection_status" + ) + assert state + assert state.state == STATE_OFF + + entry = registry.async_get( + "binary_sensor.fake_profile_this_device_profile_connection_status" + ) + assert entry + assert entry.unique_id == "xyz12_this_device_profile_connection_status" + + +async def test_availability(hass: HomeAssistant) -> None: + """Ensure that we mark the entities unavailable correctly when service causes an error.""" + await init_integration(hass) + + state = hass.states.get( + "binary_sensor.fake_profile_this_device_nextdns_connection_status" + ) + assert state + assert state.state != STATE_UNAVAILABLE + assert state.state == STATE_ON + + future = utcnow() + timedelta(minutes=10) + with patch( + "homeassistant.components.nextdns.NextDns.connection_status", + side_effect=ApiError("API Error"), + ): + async_fire_time_changed(hass, future) + await hass.async_block_till_done() + + state = hass.states.get( + "binary_sensor.fake_profile_this_device_nextdns_connection_status" + ) + assert state + assert state.state == STATE_UNAVAILABLE + + future = utcnow() + timedelta(minutes=20) + with patch( + "homeassistant.components.nextdns.NextDns.connection_status", + return_value=CONNECTION_STATUS, + ): + async_fire_time_changed(hass, future) + await hass.async_block_till_done() + + state = hass.states.get( + "binary_sensor.fake_profile_this_device_nextdns_connection_status" + ) + assert state + assert state.state != STATE_UNAVAILABLE + assert state.state == STATE_ON