Refactor Tado to use OAuth in the DeviceTracker (#102610)

* Refactor to use TadoConnector in the DeviceTracker

* Proposing myself as code owner to be notified of issues

* Update homeassistant/components/tado/device_tracker.py

Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>

* Fixing method names

* Current progress, switching machines

* Updating DeviceTracker to working prototype

* Removing unnecessary callback

* Adding dispatcher logic

* Minor fine-tuning the intervals

* Removing unnecessary debug log

* Update homeassistant/components/tado/device_tracker.py

Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>

* Update homeassistant/components/tado/device_tracker.py

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>

* Fix sorting

* Retrieve devices from the Tado connector data

* Asyncio feedback & dispatch generic mobile devices

* Updating const

* Fine-tuning unloading

* Making add_tracked_entites callback

* Adding unload over dispatcher_connect

* Convert on_demand_update to callback

* Removing now unused method

* Merging method to on_demand_u

* Adding create_issue to address repair

* Updating with better translation

* Converting to callback

* Adding _attr_should_poll

* Putting back the on_demand_update

* Adding unique_id

* Converting to TrackerEntity

* Adding import step (review needed!)

* Update homeassistant/components/tado/device_tracker.py

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>

* Update homeassistant/components/tado/device_tracker.py

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>

* Update homeassistant/components/tado/device_tracker.py

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>

* Update homeassistant/components/tado/config_flow.py

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>

* Typing and location_name

* Changing to _attr_unique_id

* Import improvement attempt

* Property feedback

* Update homeassistant/components/tado/config_flow.py

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>

* Adding CONF_HOME_ID and task in get_scanner

* Updating descriptions

* Removing the create_task

* Putting back PLATFORM_SCHEMA

* Adding device_tracker

* Adding get for HomeID

* Get it better ;)

* Retrieve HomeID from API

* Add integration title in dialogs

* Update homeassistant/components/tado/config_flow.py

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>

* Update homeassistant/components/tado/config_flow.py

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>

* Update homeassistant/components/tado/config_flow.py

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>

* Fixing homeID and strings.json

* Delete request in strings

* Update deprecation date

* Adding test cases for import flow

* Update tests/components/tado/test_config_flow.py

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>

* Update tests/components/tado/test_config_flow.py

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>

* Update tests/components/tado/test_config_flow.py

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>

* Removing none

* Fixing test cases

* Update homeassistant/components/tado/config_flow.py

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>

* Removing from context manager

* Removing code owner

* Re-adding code owner

* Fix get scanner return value

* Fix device tracker interface

---------

Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
This commit is contained in:
Erwin Douna 2023-12-27 14:17:23 +01:00 committed by GitHub
parent 0694ff8965
commit 4decc2bbfb
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
8 changed files with 382 additions and 119 deletions

View file

@ -1299,8 +1299,8 @@ build.json @home-assistant/supervisor
/tests/components/system_bridge/ @timmo001
/homeassistant/components/systemmonitor/ @gjohansson-ST
/tests/components/systemmonitor/ @gjohansson-ST
/homeassistant/components/tado/ @michaelarnauts @chiefdragon
/tests/components/tado/ @michaelarnauts @chiefdragon
/homeassistant/components/tado/ @michaelarnauts @chiefdragon @erwindouna
/tests/components/tado/ @michaelarnauts @chiefdragon @erwindouna
/homeassistant/components/tag/ @balloob @dmulcahey
/tests/components/tag/ @balloob @dmulcahey
/homeassistant/components/tailscale/ @frenck

View file

@ -26,9 +26,11 @@ from .const import (
DOMAIN,
INSIDE_TEMPERATURE_MEASUREMENT,
PRESET_AUTO,
SIGNAL_TADO_MOBILE_DEVICE_UPDATE_RECEIVED,
SIGNAL_TADO_UPDATE_RECEIVED,
TEMP_OFFSET,
UPDATE_LISTENER,
UPDATE_MOBILE_DEVICE_TRACK,
UPDATE_TRACK,
)
@ -38,12 +40,14 @@ _LOGGER = logging.getLogger(__name__)
PLATFORMS = [
Platform.BINARY_SENSOR,
Platform.CLIMATE,
Platform.DEVICE_TRACKER,
Platform.SENSOR,
Platform.WATER_HEATER,
]
MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=4)
SCAN_INTERVAL = timedelta(minutes=5)
SCAN_MOBILE_DEVICE_INTERVAL = timedelta(seconds=30)
CONFIG_SCHEMA = cv.removed(DOMAIN, raise_if_present=False)
@ -85,12 +89,19 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
SCAN_INTERVAL,
)
update_mobile_devices = async_track_time_interval(
hass,
lambda now: tadoconnector.update_mobile_devices(),
SCAN_MOBILE_DEVICE_INTERVAL,
)
update_listener = entry.add_update_listener(_async_update_listener)
hass.data.setdefault(DOMAIN, {})
hass.data[DOMAIN][entry.entry_id] = {
DATA: tadoconnector,
UPDATE_TRACK: update_track,
UPDATE_MOBILE_DEVICE_TRACK: update_mobile_devices,
UPDATE_LISTENER: update_listener,
}
@ -127,6 +138,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
hass.data[DOMAIN][entry.entry_id][UPDATE_TRACK]()
hass.data[DOMAIN][entry.entry_id][UPDATE_LISTENER]()
hass.data[DOMAIN][entry.entry_id][UPDATE_MOBILE_DEVICE_TRACK]()
if unload_ok:
hass.data[DOMAIN].pop(entry.entry_id)
@ -151,6 +163,7 @@ class TadoConnector:
self.devices = None
self.data = {
"device": {},
"mobile_device": {},
"weather": {},
"geofence": {},
"zone": {},
@ -171,6 +184,10 @@ class TadoConnector:
self.home_id = tado_home["id"]
self.home_name = tado_home["name"]
def get_mobile_devices(self):
"""Return the Tado mobile devices."""
return self.tado.getMobileDevices()
@Throttle(MIN_TIME_BETWEEN_UPDATES)
def update(self):
"""Update the registered zones."""
@ -178,6 +195,27 @@ class TadoConnector:
self.update_zones()
self.update_home()
def update_mobile_devices(self) -> None:
"""Update the mobile devices."""
try:
mobile_devices = self.get_mobile_devices()
except RuntimeError:
_LOGGER.error("Unable to connect to Tado while updating mobile devices")
return
for mobile_device in mobile_devices:
self.data["mobile_device"][mobile_device["id"]] = mobile_device
_LOGGER.debug(
"Dispatching update to %s mobile devices: %s",
self.home_id,
mobile_devices,
)
dispatcher_send(
self.hass,
SIGNAL_TADO_MOBILE_DEVICE_UPDATE_RECEIVED,
)
def update_devices(self):
"""Update the device data from Tado."""
try:

View file

@ -16,6 +16,7 @@ from homeassistant.data_entry_flow import FlowResult
from .const import (
CONF_FALLBACK,
CONF_HOME_ID,
CONST_OVERLAY_TADO_DEFAULT,
CONST_OVERLAY_TADO_OPTIONS,
DOMAIN,
@ -110,6 +111,45 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
self._abort_if_unique_id_configured()
return await self.async_step_user()
async def async_step_import(self, import_config: dict[str, Any]) -> FlowResult:
"""Import a config entry from configuration.yaml."""
_LOGGER.debug("Importing Tado from configuration.yaml")
username = import_config[CONF_USERNAME]
password = import_config[CONF_PASSWORD]
imported_home_id = import_config[CONF_HOME_ID]
self._async_abort_entries_match(
{
CONF_USERNAME: username,
CONF_PASSWORD: password,
CONF_HOME_ID: imported_home_id,
}
)
try:
validate_result = await validate_input(
self.hass,
{
CONF_USERNAME: username,
CONF_PASSWORD: password,
},
)
except exceptions.HomeAssistantError:
return self.async_abort(reason="import_failed")
home_id = validate_result[UNIQUE_ID]
await self.async_set_unique_id(home_id)
self._abort_if_unique_id_configured()
return self.async_create_entry(
title=import_config[CONF_USERNAME],
data={
CONF_USERNAME: username,
CONF_PASSWORD: password,
CONF_HOME_ID: home_id,
},
)
@staticmethod
@callback
def async_get_options_flow(

View file

@ -36,8 +36,10 @@ TADO_HVAC_ACTION_TO_HA_HVAC_ACTION = {
# Configuration
CONF_FALLBACK = "fallback"
CONF_HOME_ID = "home_id"
DATA = "data"
UPDATE_TRACK = "update_track"
UPDATE_MOBILE_DEVICE_TRACK = "update_mobile_device_track"
# Weather
CONDITIONS_MAP = {
@ -177,6 +179,7 @@ TADO_TO_HA_SWING_MODE_MAP = {
DOMAIN = "tado"
SIGNAL_TADO_UPDATE_RECEIVED = "tado_update_received_{}_{}_{}"
SIGNAL_TADO_MOBILE_DEVICE_UPDATE_RECEIVED = "tado_mobile_device_update_received"
UNIQUE_ID = "unique_id"
DEFAULT_NAME = "Tado"

View file

@ -1,33 +1,31 @@
"""Support for Tado Smart device trackers."""
from __future__ import annotations
import asyncio
from collections import namedtuple
from datetime import timedelta
from http import HTTPStatus
import logging
from typing import Any
import aiohttp
import voluptuous as vol
from homeassistant.components.device_tracker import (
DOMAIN,
PLATFORM_SCHEMA as BASE_PLATFORM_SCHEMA,
DeviceScanner,
SourceType,
TrackerEntity,
)
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
from homeassistant.core import HomeAssistant
from homeassistant.helpers.aiohttp_client import async_create_clientsession
from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, STATE_HOME, STATE_NOT_HOME
from homeassistant.core import HomeAssistant, callback
from homeassistant.data_entry_flow import FlowResultType
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue
from homeassistant.helpers.typing import ConfigType
from homeassistant.util import Throttle
from .const import CONF_HOME_ID, DATA, DOMAIN, SIGNAL_TADO_MOBILE_DEVICE_UPDATE_RECEIVED
_LOGGER = logging.getLogger(__name__)
CONF_HOME_ID = "home_id"
MIN_TIME_BETWEEN_SCANS = timedelta(seconds=30)
PLATFORM_SCHEMA = BASE_PLATFORM_SCHEMA.extend(
{
vol.Required(CONF_USERNAME): cv.string,
@ -37,113 +35,166 @@ PLATFORM_SCHEMA = BASE_PLATFORM_SCHEMA.extend(
)
def get_scanner(hass: HomeAssistant, config: ConfigType) -> TadoDeviceScanner | None:
"""Return a Tado scanner."""
scanner = TadoDeviceScanner(hass, config[DOMAIN])
return scanner if scanner.success_init else None
async def async_get_scanner(
hass: HomeAssistant, config: ConfigType
) -> DeviceScanner | None:
"""Configure the Tado device scanner."""
device_config = config["device_tracker"]
import_result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_IMPORT},
data={
CONF_USERNAME: device_config[CONF_USERNAME],
CONF_PASSWORD: device_config[CONF_PASSWORD],
CONF_HOME_ID: device_config.get(CONF_HOME_ID),
},
)
translation_key = "deprecated_yaml_import_device_tracker"
if import_result.get("type") == FlowResultType.ABORT:
translation_key = "import_aborted"
if import_result.get("reason") == "import_failed":
translation_key = "import_failed"
async_create_issue(
hass,
DOMAIN,
"deprecated_yaml_import_device_tracker",
breaks_in_ha_version="2024.6.0",
is_fixable=False,
severity=IssueSeverity.WARNING,
translation_key=translation_key,
)
return None
Device = namedtuple("Device", ["mac", "name"])
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the Tado device scannery entity."""
_LOGGER.debug("Setting up Tado device scanner entity")
tado = hass.data[DOMAIN][entry.entry_id][DATA]
tracked: set = set()
@callback
def update_devices() -> None:
"""Update the values of the devices."""
add_tracked_entities(hass, tado, async_add_entities, tracked)
update_devices()
entry.async_on_unload(
async_dispatcher_connect(
hass,
SIGNAL_TADO_MOBILE_DEVICE_UPDATE_RECEIVED,
update_devices,
)
)
class TadoDeviceScanner(DeviceScanner):
"""Scanner for geofenced devices from Tado."""
def __init__(self, hass, config):
"""Initialize the scanner."""
self.hass = hass
self.last_results = []
self.username = config[CONF_USERNAME]
self.password = config[CONF_PASSWORD]
# The Tado device tracker can work with or without a home_id
self.home_id = config[CONF_HOME_ID] if CONF_HOME_ID in config else None
# If there's a home_id, we need a different API URL
if self.home_id is None:
self.tadoapiurl = "https://my.tado.com/api/v2/me"
else:
self.tadoapiurl = "https://my.tado.com/api/v2/homes/{home_id}/mobileDevices"
# The API URL always needs a username and password
self.tadoapiurl += "?username={username}&password={password}"
self.websession = None
self.success_init = asyncio.run_coroutine_threadsafe(
self._async_update_info(), hass.loop
).result()
_LOGGER.info("Scanner initialized")
async def async_scan_devices(self):
"""Scan for devices and return a list containing found device ids."""
await self._async_update_info()
return [device.mac for device in self.last_results]
async def async_get_device_name(self, device):
"""Return the name of the given device or None if we don't know."""
filter_named = [
result.name for result in self.last_results if result.mac == device
]
if filter_named:
return filter_named[0]
return None
@Throttle(MIN_TIME_BETWEEN_SCANS)
async def _async_update_info(self):
"""Query Tado for device marked as at home.
Returns boolean if scanning successful.
"""
_LOGGER.debug("Requesting Tado")
if self.websession is None:
self.websession = async_create_clientsession(
self.hass, cookie_jar=aiohttp.CookieJar(unsafe=True)
)
last_results = []
try:
async with asyncio.timeout(10):
# Format the URL here, so we can log the template URL if
# anything goes wrong without exposing username and password.
url = self.tadoapiurl.format(
home_id=self.home_id, username=self.username, password=self.password
)
response = await self.websession.get(url)
if response.status != HTTPStatus.OK:
_LOGGER.warning("Error %d on %s", response.status, self.tadoapiurl)
return False
tado_json = await response.json()
except (asyncio.TimeoutError, aiohttp.ClientError):
_LOGGER.error("Cannot load Tado data")
return False
# Without a home_id, we fetched an URL where the mobile devices can be
# found under the mobileDevices key.
if "mobileDevices" in tado_json:
tado_json = tado_json["mobileDevices"]
# Find devices that have geofencing enabled, and are currently at home.
for mobile_device in tado_json:
if mobile_device.get("location") and mobile_device["location"]["atHome"]:
device_id = mobile_device["id"]
device_name = mobile_device["name"]
last_results.append(Device(device_id, device_name))
self.last_results = last_results
@callback
def add_tracked_entities(
hass: HomeAssistant,
tado: Any,
async_add_entities: AddEntitiesCallback,
tracked: set[str],
) -> None:
"""Add new tracker entities from Tado."""
_LOGGER.debug("Fetching Tado devices from API")
new_tracked = []
for device_key, device in tado.data["mobile_device"].items():
if device_key in tracked:
continue
_LOGGER.debug(
"Tado presence query successful, %d device(s) at home",
len(self.last_results),
"Adding Tado device %s with deviceID %s", device["name"], device_key
)
new_tracked.append(TadoDeviceTrackerEntity(device_key, device["name"], tado))
tracked.add(device_key)
async_add_entities(new_tracked)
class TadoDeviceTrackerEntity(TrackerEntity):
"""A Tado Device Tracker entity."""
_attr_should_poll = False
def __init__(
self,
device_id: str,
device_name: str,
tado: Any,
) -> None:
"""Initialize a Tado Device Tracker entity."""
super().__init__()
self._attr_unique_id = device_id
self._device_id = device_id
self._device_name = device_name
self._tado = tado
self._active = False
self._latitude = None
self._longitude = None
@callback
def update_state(self) -> None:
"""Update the Tado device."""
_LOGGER.debug(
"Updating Tado mobile device: %s (ID: %s)",
self._device_name,
self._device_id,
)
device = self._tado.data["mobile_device"][self._device_id]
self._active = False
if device.get("location") is not None and device["location"]["atHome"]:
_LOGGER.debug("Tado device %s is at home", device["name"])
self._active = True
else:
_LOGGER.debug("Tado device %s is not at home", device["name"])
@callback
def on_demand_update(self) -> None:
"""Update state on demand."""
self.update_state()
self.async_write_ha_state()
async def async_added_to_hass(self) -> None:
"""Register state update callback."""
_LOGGER.debug("Registering Tado device tracker entity")
self.async_on_remove(
async_dispatcher_connect(
self.hass,
SIGNAL_TADO_MOBILE_DEVICE_UPDATE_RECEIVED,
self.on_demand_update,
)
)
return True
self.update_state()
@property
def name(self) -> str:
"""Return the name of the device."""
return self._device_name
@property
def location_name(self) -> str:
"""Return the state of the device."""
return STATE_HOME if self._active else STATE_NOT_HOME
@property
def latitude(self) -> None:
"""Return latitude value of the device."""
return None
@property
def longitude(self) -> None:
"""Return longitude value of the device."""
return None
@property
def source_type(self) -> SourceType:
"""Return the source type."""
return SourceType.GPS

View file

@ -1,7 +1,7 @@
{
"domain": "tado",
"name": "Tado",
"codeowners": ["@michaelarnauts", "@chiefdragon"],
"codeowners": ["@michaelarnauts", "@chiefdragon", "@erwindouna"],
"config_flow": true,
"dhcp": [
{

View file

@ -123,5 +123,19 @@
}
}
}
},
"issues": {
"deprecated_yaml_import_device_tracker": {
"title": "Tado YAML device tracker configuration imported",
"description": "Configuring the Tado Device Tracker using YAML is being removed.\nRemove the YAML device tracker configuration and restart Home Assistant."
},
"import_aborted": {
"title": "Import aborted",
"description": "Configuring the Tado Device Tracker using YAML is being removed.\n The import was aborted, due to an existing config entry being the same as the data being imported in the YAML. Remove the YAML device tracker configuration and restart Home Assistant. Please use the UI to configure Tado."
},
"failed_to_import": {
"title": "Failed to import",
"description": "Failed to import the configuration for the Tado Device Tracker. Please use the UI to configure Tado. Don't forget to delete the YAML configuration."
}
}
}

View file

@ -260,3 +260,120 @@ async def test_form_homekit(hass: HomeAssistant) -> None:
),
)
assert result["type"] == "abort"
async def test_import_step(hass: HomeAssistant) -> None:
"""Test import step."""
mock_tado_api = _get_mock_tado_api(getMe={"homes": [{"id": 1, "name": "myhome"}]})
with patch(
"homeassistant.components.tado.config_flow.Tado",
return_value=mock_tado_api,
), patch(
"homeassistant.components.tado.async_setup_entry",
return_value=True,
) as mock_setup_entry:
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_IMPORT},
data={
"username": "test-username",
"password": "test-password",
"home_id": 1,
},
)
await hass.async_block_till_done()
assert result["type"] == FlowResultType.CREATE_ENTRY
assert result["data"] == {
"username": "test-username",
"password": "test-password",
"home_id": "1",
}
assert mock_setup_entry.call_count == 1
async def test_import_step_existing_entry(hass: HomeAssistant) -> None:
"""Test import step with existing entry."""
entry = MockConfigEntry(
domain=DOMAIN,
data={
"username": "test-username",
"password": "test-password",
"home_id": 1,
},
)
entry.add_to_hass(hass)
with patch(
"homeassistant.components.tado.async_setup_entry",
return_value=True,
) as mock_setup_entry:
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_IMPORT},
data={
"username": "test-username",
"password": "test-password",
"home_id": 1,
},
)
await hass.async_block_till_done()
assert result["type"] == FlowResultType.ABORT
assert result["reason"] == "already_configured"
assert mock_setup_entry.call_count == 0
async def test_import_step_validation_failed(hass: HomeAssistant) -> None:
"""Test import step with validation failed."""
with patch(
"homeassistant.components.tado.config_flow.Tado",
side_effect=RuntimeError,
):
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_IMPORT},
data={
"username": "test-username",
"password": "test-password",
"home_id": 1,
},
)
await hass.async_block_till_done()
assert result["type"] == FlowResultType.ABORT
assert result["reason"] == "import_failed"
async def test_import_step_unique_id_configured(hass: HomeAssistant) -> None:
"""Test import step with unique ID already configured."""
entry = MockConfigEntry(
domain=DOMAIN,
data={
"username": "test-username",
"password": "test-password",
"home_id": 1,
},
unique_id="unique_id",
)
entry.add_to_hass(hass)
with patch(
"homeassistant.components.tado.async_setup_entry",
return_value=True,
) as mock_setup_entry:
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_IMPORT},
data={
"username": "test-username",
"password": "test-password",
"home_id": 1,
},
)
await hass.async_block_till_done()
assert result["type"] == FlowResultType.ABORT
assert result["reason"] == "already_configured"
assert mock_setup_entry.call_count == 0