Skip to content

Commit

Permalink
Add Tailscale integration (home-assistant#59764)
Browse files Browse the repository at this point in the history
* 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
frenck and MartinHjelmare authored Dec 1, 2021
1 parent 59f87b9 commit 6a8c732
Show file tree
Hide file tree
Showing 20 changed files with 973 additions and 0 deletions.
1 change: 1 addition & 0 deletions .strict-typing
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,7 @@ homeassistant.components.switcher_kis.*
homeassistant.components.synology_dsm.*
homeassistant.components.systemmonitor.*
homeassistant.components.tag.*
homeassistant.components.tailscale.*
homeassistant.components.tautulli.*
homeassistant.components.tcp.*
homeassistant.components.tile.*
Expand Down
1 change: 1 addition & 0 deletions CODEOWNERS
Original file line number Diff line number Diff line change
Expand Up @@ -528,6 +528,7 @@ homeassistant/components/system_bridge/* @timmo001
homeassistant/components/tado/* @michaelarnauts @noltari
homeassistant/components/tag/* @balloob @dmulcahey
homeassistant/components/tahoma/* @philklei
homeassistant/components/tailscale/* @frenck
homeassistant/components/tankerkoenig/* @guillempages
homeassistant/components/tapsaff/* @bazwilliams
homeassistant/components/tasmota/* @emontnemery
Expand Down
30 changes: 30 additions & 0 deletions homeassistant/components/tailscale/__init__.py
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
112 changes: 112 additions & 0 deletions homeassistant/components/tailscale/binary_sensor.py
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])
)
122 changes: 122 additions & 0 deletions homeassistant/components/tailscale/config_flow.py
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,
)
13 changes: 13 additions & 0 deletions homeassistant/components/tailscale/const.py
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"
39 changes: 39 additions & 0 deletions homeassistant/components/tailscale/coordinator.py
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
10 changes: 10 additions & 0 deletions homeassistant/components/tailscale/manifest.json
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"
}
27 changes: 27 additions & 0 deletions homeassistant/components/tailscale/strings.json
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%]"
}
}
}
Loading

0 comments on commit 6a8c732

Please sign in to comment.