From c985bee1dd256fcb45a9defe2cfc22953b87ca9a Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 1 Dec 2021 17:05:44 +0100 Subject: [PATCH] Add sensor platform to Tailscale (#60751) Co-authored-by: Martin Hjelmare --- .../components/tailscale/__init__.py | 48 +++++++++++- .../components/tailscale/binary_sensor.py | 42 +---------- homeassistant/components/tailscale/sensor.py | 73 +++++++++++++++++++ .../tailscale/fixtures/devices.json | 2 +- tests/components/tailscale/test_sensor.py | 41 +++++++++++ 5 files changed, 164 insertions(+), 42 deletions(-) create mode 100644 homeassistant/components/tailscale/sensor.py create mode 100644 tests/components/tailscale/test_sensor.py diff --git a/homeassistant/components/tailscale/__init__.py b/homeassistant/components/tailscale/__init__.py index f5e1c59b222..72a0ef49fc0 100644 --- a/homeassistant/components/tailscale/__init__.py +++ b/homeassistant/components/tailscale/__init__.py @@ -1,14 +1,23 @@ """The Tailscale integration.""" from __future__ import annotations +from tailscale import Device as TailscaleDevice + from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN +from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceEntryType +from homeassistant.helpers.entity import DeviceInfo, EntityDescription +from homeassistant.helpers.update_coordinator import ( + CoordinatorEntity, + DataUpdateCoordinator, +) from .const import DOMAIN from .coordinator import TailscaleDataUpdateCoordinator -PLATFORMS = (BINARY_SENSOR_DOMAIN,) +PLATFORMS = (BINARY_SENSOR_DOMAIN, SENSOR_DOMAIN) async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: @@ -28,3 +37,40 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: if unload_ok: del hass.data[DOMAIN][entry.entry_id] return unload_ok + + +class TailscaleEntity(CoordinatorEntity): + """Defines a Tailscale base entity.""" + + def __init__( + self, + *, + coordinator: DataUpdateCoordinator, + device: TailscaleDevice, + description: EntityDescription, + ) -> None: + """Initialize a Tailscale sensor.""" + super().__init__(coordinator=coordinator) + self.entity_description = description + self.device_id = device.device_id + self._attr_name = f"{device.hostname} {description.name}" + self._attr_unique_id = f"{device.device_id}_{description.key}" + + @property + def device_info(self) -> DeviceInfo: + """Return the device info.""" + device: TailscaleDevice = self.coordinator.data[self.device_id] + + configuration_url = "https://login.tailscale.com/admin/machines/" + if device.addresses: + configuration_url += device.addresses[0] + + return DeviceInfo( + configuration_url=configuration_url, + entry_type=DeviceEntryType.SERVICE, + identifiers={(DOMAIN, device.device_id)}, + manufacturer="Tailscale Inc.", + model=device.os, + name=device.hostname, + sw_version=device.client_version, + ) diff --git a/homeassistant/components/tailscale/binary_sensor.py b/homeassistant/components/tailscale/binary_sensor.py index 37a25055bfc..17a40d04241 100644 --- a/homeassistant/components/tailscale/binary_sensor.py +++ b/homeassistant/components/tailscale/binary_sensor.py @@ -13,14 +13,9 @@ from homeassistant.components.binary_sensor import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.device_registry import DeviceEntryType -from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.update_coordinator import ( - CoordinatorEntity, - DataUpdateCoordinator, -) +from . import TailscaleEntity from .const import DOMAIN @@ -66,44 +61,11 @@ async def async_setup_entry( ) -class TailscaleBinarySensorEntity(CoordinatorEntity, BinarySensorEntity): +class TailscaleBinarySensorEntity(TailscaleEntity, BinarySensorEntity): """Defines a Tailscale binary sensor.""" entity_description: TailscaleBinarySensorEntityDescription - def __init__( - self, - *, - coordinator: DataUpdateCoordinator, - device: TailscaleDevice, - description: TailscaleBinarySensorEntityDescription, - ) -> None: - """Initialize a Tailscale binary sensor.""" - super().__init__(coordinator=coordinator) - self.entity_description = description - self.device_id = device.device_id - self._attr_name = f"{device.hostname} {description.name}" - self._attr_unique_id = f"{device.device_id}_{description.key}" - - @property - def device_info(self) -> DeviceInfo: - """Return the device info.""" - device: TailscaleDevice = self.coordinator.data[self.device_id] - - configuration_url = "https://login.tailscale.com/admin/machines/" - if device.addresses: - configuration_url += device.addresses[0] - - return DeviceInfo( - configuration_url=configuration_url, - entry_type=DeviceEntryType.SERVICE, - identifiers={(DOMAIN, device.device_id)}, - manufacturer="Tailscale Inc.", - model=device.os, - name=device.hostname, - sw_version=device.client_version, - ) - @property def is_on(self) -> bool: """Return the state of the sensor.""" diff --git a/homeassistant/components/tailscale/sensor.py b/homeassistant/components/tailscale/sensor.py new file mode 100644 index 00000000000..d6ffdd177bf --- /dev/null +++ b/homeassistant/components/tailscale/sensor.py @@ -0,0 +1,73 @@ +"""Support for Tailscale sensors.""" +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from datetime import datetime + +from tailscale import Device as TailscaleDevice + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import TailscaleEntity +from .const import DOMAIN + + +@dataclass +class TailscaleSensorEntityDescriptionMixin: + """Mixin for required keys.""" + + value_fn: Callable[[TailscaleDevice], datetime | None] + + +@dataclass +class TailscaleSensorEntityDescription( + SensorEntityDescription, TailscaleSensorEntityDescriptionMixin +): + """Describes a Tailscale sensor entity.""" + + +SENSORS: tuple[TailscaleSensorEntityDescription, ...] = ( + TailscaleSensorEntityDescription( + key="expires", + name="Expires", + device_class=SensorDeviceClass.TIMESTAMP, + value_fn=lambda device: device.expires, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up a Tailscale sensors based on a config entry.""" + coordinator = hass.data[DOMAIN][entry.entry_id] + async_add_entities( + TailscaleSensorEntity( + coordinator=coordinator, + device=device, + description=description, + ) + for device in coordinator.data.values() + for description in SENSORS + ) + + +class TailscaleSensorEntity(TailscaleEntity, SensorEntity): + """Defines a Tailscale sensor.""" + + entity_description: TailscaleSensorEntityDescription + + @property + def native_value(self) -> datetime | None: + """Return the state of the sensor.""" + return self.entity_description.value_fn(self.coordinator.data[self.device_id]) diff --git a/tests/components/tailscale/fixtures/devices.json b/tests/components/tailscale/fixtures/devices.json index 1d7ea756399..776c32b5e40 100644 --- a/tests/components/tailscale/fixtures/devices.json +++ b/tests/components/tailscale/fixtures/devices.json @@ -42,7 +42,7 @@ }, { "addresses": [ - "100.11.11.111" + "100.11.11.112" ], "id": "123457", "user": "frenck", diff --git a/tests/components/tailscale/test_sensor.py b/tests/components/tailscale/test_sensor.py new file mode 100644 index 00000000000..0a8918f04d1 --- /dev/null +++ b/tests/components/tailscale/test_sensor.py @@ -0,0 +1,41 @@ +"""Tests for the sensors provided by the Tailscale integration.""" +from homeassistant.components.sensor import SensorDeviceClass +from homeassistant.components.tailscale.const import DOMAIN +from homeassistant.const import ATTR_DEVICE_CLASS, ATTR_FRIENDLY_NAME, ATTR_ICON +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr, entity_registry as er + +from tests.common import MockConfigEntry + + +async def test_tailscale_sensors( + hass: HomeAssistant, + init_integration: MockConfigEntry, +) -> None: + """Test the Tailscale sensors.""" + entity_registry = er.async_get(hass) + device_registry = dr.async_get(hass) + + state = hass.states.get("sensor.router_expires") + entry = entity_registry.async_get("sensor.router_expires") + assert entry + assert state + assert entry.unique_id == "123457_expires" + assert state.state == "2022-02-25T09:49:06+00:00" + assert state.attributes.get(ATTR_FRIENDLY_NAME) == "router Expires" + assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.TIMESTAMP + assert ATTR_ICON not in state.attributes + + assert entry.device_id + device_entry = device_registry.async_get(entry.device_id) + assert device_entry + assert device_entry.identifiers == {(DOMAIN, "123457")} + assert device_entry.manufacturer == "Tailscale Inc." + assert device_entry.model == "linux" + assert device_entry.name == "router" + assert device_entry.entry_type == dr.DeviceEntryType.SERVICE + assert device_entry.sw_version == "1.14.0-t5cff36945-g809e87bba" + assert ( + device_entry.configuration_url + == "https://login.tailscale.com/admin/machines/100.11.11.112" + )