forked from home-assistant/core
-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Add Tailscale integration (home-assistant#59764)
* Add Tailscale integration * Use DeviceEntryType * Fix tests * Adjust to new Pylint version * Use enums for device classes * Update homeassistant/components/tailscale/config_flow.py Co-authored-by: Martin Hjelmare <[email protected]> * Pass empty string as default Co-authored-by: Martin Hjelmare <[email protected]>
- Loading branch information
1 parent
59f87b9
commit 6a8c732
Showing
20 changed files
with
973 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,30 @@ | ||
"""The Tailscale integration.""" | ||
from __future__ import annotations | ||
|
||
from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN | ||
from homeassistant.config_entries import ConfigEntry | ||
from homeassistant.core import HomeAssistant | ||
|
||
from .const import DOMAIN | ||
from .coordinator import TailscaleDataUpdateCoordinator | ||
|
||
PLATFORMS = (BINARY_SENSOR_DOMAIN,) | ||
|
||
|
||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: | ||
"""Set up Tailscale from a config entry.""" | ||
coordinator = TailscaleDataUpdateCoordinator(hass, entry) | ||
await coordinator.async_config_entry_first_refresh() | ||
|
||
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator | ||
hass.config_entries.async_setup_platforms(entry, PLATFORMS) | ||
|
||
return True | ||
|
||
|
||
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: | ||
"""Unload Tailscale config entry.""" | ||
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) | ||
if unload_ok: | ||
del hass.data[DOMAIN][entry.entry_id] | ||
return unload_ok |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,112 @@ | ||
"""Support for Tailscale binary sensors.""" | ||
from __future__ import annotations | ||
|
||
from collections.abc import Callable | ||
from dataclasses import dataclass | ||
|
||
from tailscale import Device as TailscaleDevice | ||
|
||
from homeassistant.components.binary_sensor import ( | ||
BinarySensorDeviceClass, | ||
BinarySensorEntity, | ||
BinarySensorEntityDescription, | ||
) | ||
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 .const import DOMAIN | ||
|
||
|
||
@dataclass | ||
class TailscaleBinarySensorEntityDescriptionMixin: | ||
"""Mixin for required keys.""" | ||
|
||
is_on_fn: Callable[[TailscaleDevice], bool | None] | ||
|
||
|
||
@dataclass | ||
class TailscaleBinarySensorEntityDescription( | ||
BinarySensorEntityDescription, TailscaleBinarySensorEntityDescriptionMixin | ||
): | ||
"""Describes a Tailscale binary sensor entity.""" | ||
|
||
|
||
BINARY_SENSORS: tuple[TailscaleBinarySensorEntityDescription, ...] = ( | ||
TailscaleBinarySensorEntityDescription( | ||
key="update_available", | ||
name="Client", | ||
device_class=BinarySensorDeviceClass.UPDATE, | ||
is_on_fn=lambda device: device.update_available, | ||
), | ||
) | ||
|
||
|
||
async def async_setup_entry( | ||
hass: HomeAssistant, | ||
entry: ConfigEntry, | ||
async_add_entities: AddEntitiesCallback, | ||
) -> None: | ||
"""Set up a Tailscale binary sensors based on a config entry.""" | ||
coordinator = hass.data[DOMAIN][entry.entry_id] | ||
async_add_entities( | ||
TailscaleBinarySensorEntity( | ||
coordinator=coordinator, | ||
device=device, | ||
description=description, | ||
) | ||
for device in coordinator.data.values() | ||
for description in BINARY_SENSORS | ||
) | ||
|
||
|
||
class TailscaleBinarySensorEntity(CoordinatorEntity, 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.""" | ||
return bool( | ||
self.entity_description.is_on_fn(self.coordinator.data[self.device_id]) | ||
) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,122 @@ | ||
"""Config flow to configure the Tailscale integration.""" | ||
from __future__ import annotations | ||
|
||
from typing import Any | ||
|
||
from tailscale import Tailscale, TailscaleAuthenticationError, TailscaleError | ||
import voluptuous as vol | ||
|
||
from homeassistant.config_entries import ConfigEntry, ConfigFlow | ||
from homeassistant.const import CONF_API_KEY | ||
from homeassistant.core import HomeAssistant | ||
from homeassistant.data_entry_flow import FlowResult | ||
from homeassistant.helpers.aiohttp_client import async_get_clientsession | ||
|
||
from .const import CONF_TAILNET, DOMAIN | ||
|
||
|
||
async def validate_input(hass: HomeAssistant, *, tailnet: str, api_key: str) -> None: | ||
"""Try using the give tailnet & api key against the Tailscale API.""" | ||
session = async_get_clientsession(hass) | ||
tailscale = Tailscale( | ||
session=session, | ||
api_key=api_key, | ||
tailnet=tailnet, | ||
) | ||
await tailscale.devices() | ||
|
||
|
||
class TailscaleFlowHandler(ConfigFlow, domain=DOMAIN): | ||
"""Config flow for Tailscale.""" | ||
|
||
VERSION = 1 | ||
|
||
reauth_entry: ConfigEntry | None = None | ||
|
||
async def async_step_user( | ||
self, user_input: dict[str, Any] | None = None | ||
) -> FlowResult: | ||
"""Handle a flow initialized by the user.""" | ||
errors = {} | ||
|
||
if user_input is not None: | ||
try: | ||
await validate_input( | ||
self.hass, | ||
tailnet=user_input[CONF_TAILNET], | ||
api_key=user_input[CONF_API_KEY], | ||
) | ||
except TailscaleAuthenticationError: | ||
errors["base"] = "invalid_auth" | ||
except TailscaleError: | ||
errors["base"] = "cannot_connect" | ||
else: | ||
await self.async_set_unique_id(user_input[CONF_TAILNET]) | ||
self._abort_if_unique_id_configured() | ||
return self.async_create_entry( | ||
title=user_input[CONF_TAILNET], | ||
data={ | ||
CONF_TAILNET: user_input[CONF_TAILNET], | ||
CONF_API_KEY: user_input[CONF_API_KEY], | ||
}, | ||
) | ||
else: | ||
user_input = {} | ||
|
||
return self.async_show_form( | ||
step_id="user", | ||
data_schema=vol.Schema( | ||
{ | ||
vol.Required( | ||
CONF_TAILNET, default=user_input.get(CONF_TAILNET, "") | ||
): str, | ||
vol.Required( | ||
CONF_API_KEY, default=user_input.get(CONF_API_KEY, "") | ||
): str, | ||
} | ||
), | ||
errors=errors, | ||
) | ||
|
||
async def async_step_reauth(self, data: dict[str, Any]) -> FlowResult: | ||
"""Handle initiation of re-authentication with Tailscale.""" | ||
self.reauth_entry = self.hass.config_entries.async_get_entry( | ||
self.context["entry_id"] | ||
) | ||
return await self.async_step_reauth_confirm() | ||
|
||
async def async_step_reauth_confirm( | ||
self, user_input: dict[str, Any] | None = None | ||
) -> FlowResult: | ||
"""Handle re-authentication with Tailscale.""" | ||
errors = {} | ||
|
||
if user_input is not None and self.reauth_entry: | ||
try: | ||
await validate_input( | ||
self.hass, | ||
tailnet=self.reauth_entry.data[CONF_TAILNET], | ||
api_key=user_input[CONF_API_KEY], | ||
) | ||
except TailscaleAuthenticationError: | ||
errors["base"] = "invalid_auth" | ||
except TailscaleError: | ||
errors["base"] = "cannot_connect" | ||
else: | ||
self.hass.config_entries.async_update_entry( | ||
self.reauth_entry, | ||
data={ | ||
**self.reauth_entry.data, | ||
CONF_API_KEY: user_input[CONF_API_KEY], | ||
}, | ||
) | ||
self.hass.async_create_task( | ||
self.hass.config_entries.async_reload(self.reauth_entry.entry_id) | ||
) | ||
return self.async_abort(reason="reauth_successful") | ||
|
||
return self.async_show_form( | ||
step_id="reauth_confirm", | ||
data_schema=vol.Schema({vol.Required(CONF_API_KEY): str}), | ||
errors=errors, | ||
) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,13 @@ | ||
"""Constants for the Tailscale integration.""" | ||
from __future__ import annotations | ||
|
||
from datetime import timedelta | ||
import logging | ||
from typing import Final | ||
|
||
DOMAIN: Final = "tailscale" | ||
|
||
LOGGER = logging.getLogger(__package__) | ||
SCAN_INTERVAL = timedelta(minutes=1) | ||
|
||
CONF_TAILNET: Final = "tailnet" |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,39 @@ | ||
"""DataUpdateCoordinator for the Tailscale integration.""" | ||
from __future__ import annotations | ||
|
||
from tailscale import Device, Tailscale, TailscaleAuthenticationError | ||
|
||
from homeassistant.config_entries import ConfigEntry | ||
from homeassistant.const import CONF_API_KEY | ||
from homeassistant.core import HomeAssistant | ||
from homeassistant.exceptions import ConfigEntryAuthFailed | ||
from homeassistant.helpers.aiohttp_client import async_get_clientsession | ||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator | ||
|
||
from .const import CONF_TAILNET, DOMAIN, LOGGER, SCAN_INTERVAL | ||
|
||
|
||
class TailscaleDataUpdateCoordinator(DataUpdateCoordinator): | ||
"""The Tailscale Data Update Coordinator.""" | ||
|
||
config_entry: ConfigEntry | ||
|
||
def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: | ||
"""Initialize the Tailscale coordinator.""" | ||
self.config_entry = entry | ||
|
||
session = async_get_clientsession(hass) | ||
self.tailscale = Tailscale( | ||
session=session, | ||
api_key=entry.data[CONF_API_KEY], | ||
tailnet=entry.data[CONF_TAILNET], | ||
) | ||
|
||
super().__init__(hass, LOGGER, name=DOMAIN, update_interval=SCAN_INTERVAL) | ||
|
||
async def _async_update_data(self) -> dict[str, Device]: | ||
"""Fetch devices from Tailscale.""" | ||
try: | ||
return await self.tailscale.devices() | ||
except TailscaleAuthenticationError as err: | ||
raise ConfigEntryAuthFailed from err |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,10 @@ | ||
{ | ||
"domain": "tailscale", | ||
"name": "Tailscale", | ||
"config_flow": true, | ||
"documentation": "https://www.home-assistant.io/integrations/tailscale", | ||
"requirements": ["tailscale==0.1.2"], | ||
"codeowners": ["@frenck"], | ||
"quality_scale": "platinum", | ||
"iot_class": "cloud_polling" | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,27 @@ | ||
{ | ||
"config": { | ||
"step": { | ||
"user": { | ||
"description": "To authenticate with Tailscale you'll need to create an API key at https://login.tailscale.com/admin/settings/authkeys.\n\nA Tailnet is the name of your Tailscale network. You can find it in the top left corner in the Tailscale Admin Panel (beside the Tailscale logo).", | ||
"data": { | ||
"tailnet": "Tailnet", | ||
"api_key": "[%key:common::config_flow::data::api_key%]" | ||
} | ||
}, | ||
"reauth_confirm": { | ||
"description":"Tailscale API tokens are valid for 90-days. You can create a fresh Tailscale API key at https://login.tailscale.com/admin/settings/authkeys.", | ||
"data": { | ||
"api_key": "[%key:common::config_flow::data::api_key%]" | ||
} | ||
} | ||
|
||
}, | ||
"error": { | ||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", | ||
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]" | ||
}, | ||
"abort": { | ||
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" | ||
} | ||
} | ||
} |
Oops, something went wrong.