Skip to content

Commit

Permalink
feat(core): tapo discovery (#709)
Browse files Browse the repository at this point in the history
  • Loading branch information
petretiandrea committed Mar 4, 2024
1 parent 0f386a1 commit 6005d83
Show file tree
Hide file tree
Showing 21 changed files with 434 additions and 74 deletions.
2 changes: 2 additions & 0 deletions .devcontainer/configuration.yaml
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
default_config:

tapo:

logger:
default: info
logs:
Expand Down
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,5 @@ venv
coverage_re/
pytest-coverage.txt
pytest.xml

.idea/
21 changes: 11 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,14 @@ For some unknown reason email with capital letter thrown an "Invalid authenticat

## Features

### Discovery
The integration now supports native tapo discovery! To enable it you must add at least one tapo device or this line to your tapo configuration file
```
tapo:
```
This will enable tapo device discovery. Not all tapo devices supports tapo discovery, so if you not find it, try adding manually.
Also tapo integration discovery filters out not supported devices!

### Supported devices

- [x] pure async home assistant's method
Expand Down Expand Up @@ -101,18 +109,11 @@ This video show installation steps:
<!---->

## Configuration by configuration.yaml
[BREAKING]

Domain can be `switch`, `light` or `sensor`.
The latest version of this integration remove configuration.yaml device configuration support. This
is due to follow home assistant best practices https://developers.home-assistant.io/docs/configuration_yaml_index/ and https://github.com/home-assistant/architecture/blob/master/adr/0010-integration-configuration.md#decision

An example with switch:

```yaml
switch:
platform: tapo
host: ...
username: ...
password: ...
```

## Contributions are welcome!

Expand Down
43 changes: 43 additions & 0 deletions custom_components/tapo/__init__.py
Original file line number Diff line number Diff line change
@@ -1,18 +1,31 @@
"""The tapo integration."""
import asyncio
import logging
from typing import Any
from typing import cast
from typing import Optional

from custom_components.tapo.coordinators import HassTapoDeviceData
from custom_components.tapo.discovery import discovery_tapo_devices
from custom_components.tapo.errors import DeviceNotSupported
from custom_components.tapo.hass_tapo import HassTapo
from custom_components.tapo.migrations import migrate_entry_to_v6
from custom_components.tapo.setup_helpers import create_api_from_config
from homeassistant import config_entries
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_SCAN_INTERVAL
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers import discovery_flow
from homeassistant.helpers.event import async_track_time_interval
from plugp100.discovery.discovered_device import DiscoveredDevice

from .const import CONF_DISCOVERED_DEVICE_INFO
from .const import CONF_HOST
from .const import CONF_MAC
from .const import CONF_TRACK_DEVICE
from .const import DEFAULT_POLLING_RATE_S
from .const import DISCOVERY_INTERVAL
from .const import DOMAIN
from .const import HUB_PLATFORMS
from .const import PLATFORMS
Expand All @@ -23,6 +36,15 @@
async def async_setup(hass: HomeAssistant, config: dict):
"""Set up the tapo_p100 component."""
hass.data.setdefault(DOMAIN, {})

async def _start_discovery(_: Any = None) -> None:
if device_found := await discovery_tapo_devices(hass):
async_create_discovery_flow(hass, device_found)

hass.async_create_background_task(_start_discovery(), "Initial tapo discovery")
async_track_time_interval(
hass, _start_discovery, DISCOVERY_INTERVAL, cancel_on_shutdown=True
)
return True


Expand Down Expand Up @@ -73,3 +95,24 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry):
data.config_entry_update_unsub()

return unload_ok


def async_create_discovery_flow(
hass: HomeAssistant,
discovered_devices: dict[str, DiscoveredDevice],
) -> None:
for mac, device in discovered_devices.items():
discovery_flow.async_create_flow(
hass,
DOMAIN,
context={
"source": config_entries.SOURCE_INTEGRATION_DISCOVERY,
CONF_DISCOVERED_DEVICE_INFO: device,
},
data={
CONF_HOST: device.ip,
CONF_MAC: mac,
CONF_SCAN_INTERVAL: DEFAULT_POLLING_RATE_S,
CONF_TRACK_DEVICE: False,
},
)
183 changes: 146 additions & 37 deletions custom_components/tapo/config_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import aiohttp
import voluptuous as vol
from custom_components.tapo.const import CONF_ADVANCED_SETTINGS
from custom_components.tapo.const import CONF_DISCOVERED_DEVICE_INFO
from custom_components.tapo.const import CONF_HOST
from custom_components.tapo.const import CONF_MAC
from custom_components.tapo.const import CONF_PASSWORD
Expand All @@ -15,25 +16,30 @@
from custom_components.tapo.const import DEFAULT_POLLING_RATE_S
from custom_components.tapo.const import DOMAIN
from custom_components.tapo.const import STEP_ADVANCED_SETTINGS
from custom_components.tapo.const import STEP_DISCOVERY_REQUIRE_AUTH
from custom_components.tapo.const import STEP_INIT
from custom_components.tapo.const import SUPPORTED_DEVICES
from custom_components.tapo.errors import CannotConnect
from custom_components.tapo.errors import InvalidAuth
from custom_components.tapo.errors import InvalidHost
from custom_components.tapo.setup_helpers import get_host_port
from homeassistant import config_entries
from homeassistant import data_entry_flow
from homeassistant.config_entries import ConfigEntry
from homeassistant.config_entries import ConfigEntryState
from homeassistant.const import CONF_SCAN_INTERVAL
from homeassistant.core import callback
from homeassistant.helpers.aiohttp_client import async_create_clientsession
from homeassistant.helpers.typing import DiscoveryInfoType
from plugp100.api.tapo_client import TapoClient
from plugp100.common.credentials import AuthCredential
from plugp100.discovery.discovered_device import DiscoveredDevice
from plugp100.responses.device_state import DeviceInfo
from plugp100.responses.tapo_exception import TapoError
from plugp100.responses.tapo_exception import TapoException

_LOGGER = logging.getLogger(__name__)


STEP_USER_DATA_SCHEMA = vol.Schema(
{
vol.Required(
Expand All @@ -53,6 +59,15 @@
}
)

STEP_AUTH_DATA_SCHEMA = vol.Schema(
{
vol.Required(
CONF_USERNAME, description="The username used with Tapo App, so your email"
): str,
vol.Required(CONF_PASSWORD, description="The password used with Tapo App"): str,
}
)

STEP_ADVANCED_CONFIGURATION = vol.Schema(
{
vol.Optional(
Expand Down Expand Up @@ -96,12 +111,23 @@ class FirstStepData:
class TapoConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
"""Handle a config flow for tapo."""

VERSION = 3
VERSION = 4
CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_POLL

def __init__(self) -> None:
super().__init__()
self.first_step_data: Optional[FirstStepData] = None
self._discovered_info: DiscoveredDevice | None = None

async def async_step_integration_discovery(
self, discovery_info: DiscoveryInfoType
) -> data_entry_flow.FlowResult:
"""Handle integration discovery."""
return await self._async_handle_discovery(
discovery_info[CONF_HOST],
discovery_info[CONF_MAC],
self.context[CONF_DISCOVERED_DEVICE_INFO],
)

async def async_step_user(
self, user_input: Optional[dict[str, Any]] = None
Expand All @@ -113,25 +139,17 @@ async def async_step_user(

if user_input is not None:
try:
tapo_client = await self._try_setup_api(user_input)
device_data = await self._get_first_data_from_api(tapo_client)
device_id = device_data.device_id
await self.async_set_unique_id(device_id)
device_info = await self._async_get_device_info(user_input)
await self.async_set_unique_id(device_info.device_id)
self._abort_if_unique_id_configured()

config_entry_data = user_input | {
CONF_MAC: device_data.mac,
CONF_SCAN_INTERVAL: DEFAULT_POLLING_RATE_S,
CONF_TRACK_DEVICE: user_input.pop(CONF_TRACK_DEVICE, False),
}
self._async_abort_entries_match({CONF_HOST: device_info.ip})

if user_input.get(CONF_ADVANCED_SETTINGS, False):
self.first_step_data = FirstStepData(device_data, user_input)
self.first_step_data = FirstStepData(device_info, user_input)
return await self.async_step_advanced_config()
else:
return self.async_create_entry(
title=device_data.friendly_name,
data=config_entry_data,
return await self._async_create_config_entry_from_device_info(
device_info, user_input
)
except InvalidAuth as error:
errors["base"] = "invalid_auth"
Expand All @@ -157,7 +175,6 @@ async def async_step_user(
def async_get_options_flow(
config_entry: config_entries.ConfigEntry,
) -> config_entries.OptionsFlow:
_LOGGER.info(config_entry)
return OptionsFlowHandler(config_entry)

async def async_step_advanced_config(
Expand All @@ -178,33 +195,120 @@ async def async_step_advanced_config(
errors=errors,
)

async def _get_first_data_from_api(self, tapo_client: TapoClient) -> DeviceInfo:
try:
return (
(await tapo_client.get_device_info())
.map(lambda x: DeviceInfo(**x))
.get_or_raise()
# TODO: use mac address as unique id
async def _async_handle_discovery(
self,
host: str,
mac_address: str,
discovered_device: DiscoveredDevice,
) -> data_entry_flow.FlowResult:
self._discovered_info = discovered_device
existing_entry = await self.async_set_unique_id(
discovered_device.device_id, raise_on_progress=False
)
if existing_entry:
if result := self._recover_config_on_entry_error(
existing_entry, discovered_device.ip
):
return result

self._abort_if_unique_id_configured(updates={CONF_HOST: host})
self._async_abort_entries_match({CONF_HOST: host})

if is_supported_device(discovered_device):
return await self.async_step_discovery_auth_confirm()
else:
return self.async_abort(reason="Device not supported")

async def async_step_discovery_auth_confirm(
self, user_input: dict[str, Any] | None = None
) -> data_entry_flow.FlowResult:
assert self._discovered_info is not None
errors = {}

if user_input:
try:
device_info = await self._async_get_device_info_from_discovered(
self._discovered_info, user_input
)
await self.async_set_unique_id(device_info.device_id)
self._abort_if_unique_id_configured()
except InvalidAuth as error:
errors["base"] = "invalid_auth"
_LOGGER.exception("Failed to setup, invalid auth %s", str(error))
except CannotConnect as error:
errors["base"] = "cannot_connect"
_LOGGER.exception("Failed to setup cannot connect %s", str(error))
except InvalidHost as error:
errors["base"] = "invalid_hostname"
_LOGGER.exception("Failed to setup invalid host %s", str(error))
else:
return await self._async_create_config_entry_from_device_info(
device_info, user_input
)

discovery_data = {
"name": self._discovered_info.device_model,
"mac": self._discovered_info.mac.replace("-", "")[:5],
"host": self._discovered_info.ip,
}
self.context.update({"title_placeholders": discovery_data})
return self.async_show_form(
step_id=STEP_DISCOVERY_REQUIRE_AUTH,
data_schema=STEP_AUTH_DATA_SCHEMA,
errors=errors,
description_placeholders=discovery_data,
)

@callback
def _recover_config_on_entry_error(
self, entry: ConfigEntry, host: str
) -> data_entry_flow.FlowResult | None:
if entry.state not in (
ConfigEntryState.SETUP_ERROR,
ConfigEntryState.SETUP_RETRY,
):
return None
if entry.data[CONF_HOST] != host:
return self.async_update_reload_and_abort(
entry, data={**entry.data, CONF_HOST: host}, reason="already_configured"
)
except TapoException as error:
self._raise_from_tapo_exception(error)
except (aiohttp.ClientError, Exception) as error:
raise CannotConnect from error
return None

async def _try_setup_api(
self, user_input: Optional[dict[str, Any]] = None
) -> TapoClient:
if not user_input[CONF_HOST]:
async def _async_create_config_entry_from_device_info(
self, info: DeviceInfo, options: dict[str, Any]
):
return self.async_create_entry(
title=info.friendly_name,
data=options
| {
CONF_HOST: info.ip,
CONF_MAC: info.mac,
CONF_SCAN_INTERVAL: DEFAULT_POLLING_RATE_S,
CONF_TRACK_DEVICE: options.pop(CONF_TRACK_DEVICE, False),
},
)

async def _async_get_device_info_from_discovered(
self, discovered: DiscoveredDevice, config: dict[str, Any]
) -> DeviceInfo:
return await self._async_get_device_info(config | {CONF_HOST: discovered.ip})

async def _async_get_device_info(self, config: dict[str, Any]) -> DeviceInfo:
if not config[CONF_HOST]:
raise InvalidHost
try:
session = async_create_clientsession(self.hass)
credential = AuthCredential(
user_input[CONF_USERNAME], user_input[CONF_PASSWORD]
)
host, port = get_host_port(user_input[CONF_HOST])
credential = AuthCredential(config[CONF_USERNAME], config[CONF_PASSWORD])
host, port = get_host_port(config[CONF_HOST])
client = TapoClient.create(
credential, address=host, port=port, http_session=session
)
return client
return (
(await client.get_device_info())
.map(lambda x: DeviceInfo(**x))
.get_or_raise()
)
except TapoException as error:
self._raise_from_tapo_exception(error)
except (aiohttp.ClientError, Exception) as error:
Expand Down Expand Up @@ -233,6 +337,11 @@ async def async_step_init(
)
return self.async_create_entry(title="", data={})
return self.async_show_form(
step_id="init",
step_id=STEP_INIT,
data_schema=step_options(self.config_entry),
)


def is_supported_device(discovered_device: DiscoveredDevice) -> bool:
model = discovered_device.device_model.lower()
return len(list(filter(lambda x: x in model, SUPPORTED_DEVICES))) > 0

0 comments on commit 6005d83

Please sign in to comment.