diff --git a/custom_components/vaillant_vsmart/__init__.py b/custom_components/vaillant_vsmart/__init__.py index 0d74c62..229e29d 100644 --- a/custom_components/vaillant_vsmart/__init__.py +++ b/custom_components/vaillant_vsmart/__init__.py @@ -4,106 +4,67 @@ For more details about this integration, please refer to https://github.com/MislavMandaric/home-assistant-vaillant-vsmart """ -import asyncio -from datetime import timedelta -import logging +from __future__ import annotations +from authlib.oauth2.rfc6749.wrappers import OAuth2Token from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_CLIENT_ID, CONF_CLIENT_SECRET, CONF_TOKEN from homeassistant.core import Config, HomeAssistant -from homeassistant.exceptions import ConfigEntryNotReady -from homeassistant.helpers.aiohttp_client import async_get_clientsession -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed - -from .api import VaillantApiClient +from vaillant_netatmo_api import ThermostatClient, deserialize_token, serialize_token from .const import ( - CONF_PASSWORD, - CONF_USERNAME, DOMAIN, PLATFORMS, - STARTUP_MESSAGE, ) +from .entity import VaillantCoordinator -SCAN_INTERVAL = timedelta(seconds=30) -_LOGGER: logging.Logger = logging.getLogger(__package__) +async def async_setup(hass: HomeAssistant, config: Config) -> bool: + """Set up Vaillant vSMART component.""" + hass.data.setdefault(DOMAIN, {}) -async def async_setup(hass: HomeAssistant, config: Config): - """Set up this integration using YAML is not supported.""" return True -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): - """Set up this integration using UI.""" - if hass.data.get(DOMAIN) is None: - hass.data.setdefault(DOMAIN, {}) - _LOGGER.info(STARTUP_MESSAGE) +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Vaillant vSMART from a config entry.""" + + async def async_token_updater( + token: OAuth2Token, refresh_token: str = None, access_token: str = None + ) -> None: + data = entry.data.copy() + data[CONF_TOKEN] = serialize_token(token) - username = entry.data.get(CONF_USERNAME) - password = entry.data.get(CONF_PASSWORD) + hass.config_entries.async_update_entry(entry, data=data) - session = async_get_clientsession(hass) - client = VaillantApiClient(username, password, session) + client_id = entry.data.get(CONF_CLIENT_ID) + client_secret = entry.data.get(CONF_CLIENT_SECRET) + token = deserialize_token(entry.data.get(CONF_TOKEN)) - coordinator = VaillantDataUpdateCoordinator(hass, client=client) - await coordinator.async_refresh() + client = ThermostatClient( + client_id, + client_secret, + token, + async_token_updater, + ) - if not coordinator.last_update_success: - raise ConfigEntryNotReady + coordinator = VaillantCoordinator(hass, client) + await coordinator.async_config_entry_first_refresh() hass.data[DOMAIN][entry.entry_id] = coordinator - for platform in PLATFORMS: - if entry.options.get(platform, True): - coordinator.platforms.append(platform) - hass.async_add_job( - hass.config_entries.async_forward_entry_setup(entry, platform) - ) + hass.config_entries.async_setup_platforms(entry, PLATFORMS) - entry.add_update_listener(async_reload_entry) return True -class VaillantDataUpdateCoordinator(DataUpdateCoordinator): - """Class to manage fetching data from the API.""" - - def __init__( - self, hass: HomeAssistant, client: VaillantApiClient - ) -> None: - """Initialize.""" - self.api = client - self.platforms = [] - - super().__init__(hass, _LOGGER, name=DOMAIN, update_interval=SCAN_INTERVAL) - - async def _async_update_data(self): - """Update data via library.""" - try: - return await self.api.async_get_data() - except Exception as exception: - raise UpdateFailed() from exception - - async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: - """Handle removal of an entry.""" - coordinator = hass.data[DOMAIN][entry.entry_id] - unloaded = all( - await asyncio.gather( - *[ - hass.config_entries.async_forward_entry_unload(entry, platform) - for platform in PLATFORMS - if platform in coordinator.platforms - ] - ) - ) - if unloaded: - hass.data[DOMAIN].pop(entry.entry_id) - - return unloaded + """Unload a config entry.""" + unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + if unload_ok: + coordinator: VaillantCoordinator = hass.data[DOMAIN].pop(entry.entry_id) + await coordinator.async_close() -async def async_reload_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: - """Reload config entry.""" - await async_unload_entry(hass, entry) - await async_setup_entry(hass, entry) + return unload_ok diff --git a/custom_components/vaillant_vsmart/api.py b/custom_components/vaillant_vsmart/api.py deleted file mode 100644 index 1f50c0e..0000000 --- a/custom_components/vaillant_vsmart/api.py +++ /dev/null @@ -1,75 +0,0 @@ -"""Sample API Client.""" -import logging -import asyncio -import socket - -import aiohttp -import async_timeout - -TIMEOUT = 10 - - -_LOGGER: logging.Logger = logging.getLogger(__package__) - -HEADERS = {"Content-type": "application/json; charset=UTF-8"} - - -class VaillantApiClient: - def __init__( - self, username: str, password: str, session: aiohttp.ClientSession - ) -> None: - """Sample API Client.""" - self._username = username - self._password = password - self._session = session - - async def async_get_data(self) -> dict: - """Get data from the API.""" - url = "https://jsonplaceholder.typicode.com/posts/1" - return await self.api_wrapper("get", url) - - async def async_set_title(self, value: str) -> None: - """Get data from the API.""" - url = "https://jsonplaceholder.typicode.com/posts/1" - await self.api_wrapper("patch", url, data={"title": value}, headers=HEADERS) - - async def api_wrapper( - self, method: str, url: str, data: dict = {}, headers: dict = {} - ) -> dict: - """Get information from the API.""" - try: - async with async_timeout.timeout(TIMEOUT, loop=asyncio.get_event_loop()): - if method == "get": - response = await self._session.get(url, headers=headers) - return await response.json() - - elif method == "put": - await self._session.put(url, headers=headers, json=data) - - elif method == "patch": - await self._session.patch(url, headers=headers, json=data) - - elif method == "post": - await self._session.post(url, headers=headers, json=data) - - except asyncio.TimeoutError as exception: - _LOGGER.error( - "Timeout error fetching information from %s - %s", - url, - exception, - ) - - except (KeyError, TypeError) as exception: - _LOGGER.error( - "Error parsing information from %s - %s", - url, - exception, - ) - except (aiohttp.ClientError, socket.gaierror) as exception: - _LOGGER.error( - "Error fetching information from %s - %s", - url, - exception, - ) - except Exception as exception: # pylint: disable=broad-except - _LOGGER.error("Something really wrong happened! - %s", exception) diff --git a/custom_components/vaillant_vsmart/binary_sensor.py b/custom_components/vaillant_vsmart/binary_sensor.py deleted file mode 100644 index 701381f..0000000 --- a/custom_components/vaillant_vsmart/binary_sensor.py +++ /dev/null @@ -1,35 +0,0 @@ -"""Binary sensor platform for Vaillant vSMART.""" -from homeassistant.components.binary_sensor import BinarySensorEntity - -from .const import ( - BINARY_SENSOR, - BINARY_SENSOR_DEVICE_CLASS, - DEFAULT_NAME, - DOMAIN, -) -from .entity import VaillantEntity - - -async def async_setup_entry(hass, entry, async_add_devices): - """Setup binary_sensor platform.""" - coordinator = hass.data[DOMAIN][entry.entry_id] - async_add_devices([VaillantBinarySensor(coordinator, entry)]) - - -class VaillantBinarySensor(VaillantEntity, BinarySensorEntity): - """vaillant_vsmart binary_sensor class.""" - - @property - def name(self): - """Return the name of the binary_sensor.""" - return f"{DEFAULT_NAME}_{BINARY_SENSOR}" - - @property - def device_class(self): - """Return the class of this binary_sensor.""" - return BINARY_SENSOR_DEVICE_CLASS - - @property - def is_on(self): - """Return true if the binary_sensor is on.""" - return self.coordinator.data.get("title", "") == "foo" diff --git a/custom_components/vaillant_vsmart/config_flow.py b/custom_components/vaillant_vsmart/config_flow.py index 1adb662..d788fe1 100644 --- a/custom_components/vaillant_vsmart/config_flow.py +++ b/custom_components/vaillant_vsmart/config_flow.py @@ -1,116 +1,119 @@ -"""Adds config flow for Blueprint.""" +"""Adds config flow for Vaillant vSMART.""" +from __future__ import annotations + +import logging +from typing import Any + from homeassistant import config_entries -from homeassistant.core import callback -from homeassistant.helpers.aiohttp_client import async_create_clientsession +from homeassistant.const import ( + CONF_CLIENT_ID, + CONF_CLIENT_SECRET, + CONF_PASSWORD, + CONF_TOKEN, + CONF_USERNAME, +) +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResult +from vaillant_netatmo_api import auth_client, serialize_token import voluptuous as vol -from .api import VaillantApiClient from .const import ( - CONF_PASSWORD, - CONF_USERNAME, + CONF_APP_VERSION, + CONF_USER_PREFIX, DOMAIN, - PLATFORMS, + SCOPE, ) +_LOGGER = logging.getLogger(__name__) + class VaillantFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): """Config flow for vaillant_vsmart.""" VERSION = 1 - CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL - def __init__(self): - """Initialize.""" - self._errors = {} - - async def async_step_user(self, user_input=None): + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Handle a flow initialized by the user.""" - self._errors = {} - - # Uncomment the next 2 lines if only a single instance of the integration is allowed: - # if self._async_current_entries(): - # return self.async_abort(reason="single_instance_allowed") - - if user_input is not None: - valid = await self._test_credentials( - user_input[CONF_USERNAME], user_input[CONF_PASSWORD] - ) - if valid: - return self.async_create_entry( - title=user_input[CONF_USERNAME], data=user_input - ) - else: - self._errors["base"] = "auth" - return await self._show_config_form(user_input) + if user_input is None: + return await self._show_config_form() - user_input = {} - # Provide defaults for form - user_input[CONF_USERNAME] = "" - user_input[CONF_PASSWORD] = "" + errors = {} - return await self._show_config_form(user_input) - - @staticmethod - @callback - def async_get_options_flow(config_entry): - return VaillantOptionsFlowHandler(config_entry) - - async def _show_config_form(self, user_input): # pylint: disable=unused-argument - """Show the configuration form to edit location data.""" - return self.async_show_form( - step_id="user", - data_schema=vol.Schema( - { - vol.Required(CONF_USERNAME, default=user_input[CONF_USERNAME]): str, - vol.Required(CONF_PASSWORD, default=user_input[CONF_PASSWORD]): str, - } - ), - errors=self._errors, - ) - - async def _test_credentials(self, username, password): - """Return true if credentials is valid.""" try: - session = async_create_clientsession(self.hass) - client = VaillantApiClient(username, password, session) - await client.async_get_data() - return True + data = await self._get_config_storage_data(user_input) + # except CannotConnect: + # errors["base"] = "cannot_connect" + # except InvalidAuth: + # errors["base"] = "invalid_auth" except Exception: # pylint: disable=broad-except - pass - return False - + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" -class VaillantOptionsFlowHandler(config_entries.OptionsFlow): - """Config flow options handler for vaillant_vsmart.""" + if errors: + return await self._show_config_form(user_input, errors) - def __init__(self, config_entry): - """Initialize HACS options flow.""" - self.config_entry = config_entry - self.options = dict(config_entry.options) + await self.async_set_unique_id(user_input[CONF_USERNAME]) + self._abort_if_unique_id_configured() - async def async_step_init(self, user_input=None): # pylint: disable=unused-argument - """Manage the options.""" - return await self.async_step_user() + return self.async_create_entry(title=user_input[CONF_USERNAME], data=data) - async def async_step_user(self, user_input=None): - """Handle a flow initialized by the user.""" - if user_input is not None: - self.options.update(user_input) - return await self._update_options() + async def _show_config_form( + self, user_input: dict[str, Any] = {}, errors: dict[str, str] = None + ): + """Show the configuration form to edit location data.""" return self.async_show_form( step_id="user", data_schema=vol.Schema( { - vol.Required(x, default=self.options.get(x, True)): bool - for x in sorted(PLATFORMS) + vol.Required( + CONF_CLIENT_ID, default=user_input.get(CONF_CLIENT_ID) + ): str, + vol.Required( + CONF_CLIENT_SECRET, + default=user_input.get(CONF_CLIENT_SECRET), + ): str, + vol.Required( + CONF_USERNAME, default=user_input.get(CONF_USERNAME) + ): str, + vol.Required( + CONF_PASSWORD, default=user_input.get(CONF_PASSWORD) + ): str, + vol.Required( + CONF_USER_PREFIX, default=user_input.get(CONF_USER_PREFIX) + ): str, + vol.Required( + CONF_APP_VERSION, default=user_input.get(CONF_APP_VERSION) + ): str, } ), + errors=errors, ) - async def _update_options(self): - """Update config entry options.""" - return self.async_create_entry( - title=self.config_entry.data.get(CONF_USERNAME), data=self.options - ) + async def _get_config_storage_data( + self, user_input: dict[str, Any] + ) -> dict[str, Any]: + """Get config storage data from user input form data.""" + + async with auth_client( + user_input[CONF_CLIENT_ID], + user_input[CONF_CLIENT_SECRET], + SCOPE, + ) as client: + token = await client.async_get_token( + user_input[CONF_USERNAME], + user_input[CONF_PASSWORD], + user_input[CONF_USER_PREFIX], + user_input[CONF_APP_VERSION], + ) + + return { + CONF_CLIENT_ID: user_input[CONF_CLIENT_ID], + CONF_CLIENT_SECRET: user_input[CONF_CLIENT_SECRET], + CONF_USER_PREFIX: user_input[CONF_USER_PREFIX], + CONF_APP_VERSION: user_input[CONF_APP_VERSION], + CONF_TOKEN: serialize_token(token), + } diff --git a/custom_components/vaillant_vsmart/const.py b/custom_components/vaillant_vsmart/const.py index 9d9dbcd..0bee6cd 100644 --- a/custom_components/vaillant_vsmart/const.py +++ b/custom_components/vaillant_vsmart/const.py @@ -1,40 +1,14 @@ """Constants for Vaillant vSMART.""" + # Base component constants NAME = "Vaillant vSMART" DOMAIN = "vaillant_vsmart" -DOMAIN_DATA = f"{DOMAIN}_data" -VERSION = "0.0.0" -ATTRIBUTION = "Data provided by http://jsonplaceholder.typicode.com/" -ISSUE_URL = "https://github.com/MislavMandaric/home-assistant-vaillant-vsmart/issues" - -# Icons -ICON = "mdi:format-quote-close" - -# Device classes -BINARY_SENSOR_DEVICE_CLASS = "connectivity" # Platforms -BINARY_SENSOR = "binary_sensor" -SENSOR = "sensor" -SWITCH = "switch" -PLATFORMS = [BINARY_SENSOR, SENSOR, SWITCH] - +CLIMATE = "climate" +PLATFORMS = [CLIMATE] # Configuration and options -CONF_ENABLED = "enabled" -CONF_USERNAME = "username" -CONF_PASSWORD = "password" - -# Defaults -DEFAULT_NAME = DOMAIN - - -STARTUP_MESSAGE = f""" -------------------------------------------------------------------- -{NAME} -Version: {VERSION} -This is a custom integration! -If you have any issues with this you need to open an issue here: -{ISSUE_URL} -------------------------------------------------------------------- -""" +CONF_APP_VERSION = "app_version" +CONF_USER_PREFIX = "user_prefix" +SCOPE = "read_thermostat write_thermostat" diff --git a/custom_components/vaillant_vsmart/entity.py b/custom_components/vaillant_vsmart/entity.py index 9a416f0..8f4da09 100644 --- a/custom_components/vaillant_vsmart/entity.py +++ b/custom_components/vaillant_vsmart/entity.py @@ -1,33 +1,111 @@ -"""VaillantEntity class""" -from homeassistant.helpers.update_coordinator import CoordinatorEntity +"""Vaillant vSMART entity classes.""" +from datetime import timedelta +import logging +from typing import Any -from .const import DOMAIN, NAME, VERSION, ATTRIBUTION +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import ( + CoordinatorEntity, + DataUpdateCoordinator, + UpdateFailed, +) +from vaillant_netatmo_api import Device, Module, ThermostatClient +from .const import DOMAIN + + +UPDATE_INTERVAL = timedelta(seconds=30) + +_LOGGER: logging.Logger = logging.getLogger(__package__) + + +class VaillantData: + """Class holding data which coordinator provides to the entity.""" + + def __init__(self, client: ThermostatClient, devices: list[Device]) -> None: + """Initialize.""" + + self.client = client + self.devices = {device.id: device for device in devices} + self.modules = { + module.id: module for device in devices for module in device.modules + } + + +class VaillantCoordinator(DataUpdateCoordinator[VaillantData]): + """Class to manage fetching data from the API.""" + + def __init__(self, hass: HomeAssistant, client: ThermostatClient) -> None: + """Initialize.""" + + super().__init__( + hass, + _LOGGER, + name=DOMAIN, + update_method=self._update_method, + update_interval=UPDATE_INTERVAL, + ) + + self._client = client + + async def _update_method(self): + """Fetch data from API endpoint. + + This is the place to pre-process the data to lookup tables + so entities can quickly look up their data. + """ + + try: + devices = await self._client.async_get_thermostats_data() + + return VaillantData(self._client, devices) + except Exception as err: + _LOGGER.exception("API communication exception") + raise UpdateFailed(f"Error communicating with API: {err}") + + async def async_close(self) -> None: + await self._client.async_close() + + +class VaillantEntity(CoordinatorEntity[VaillantData]): + """Base class for Vaillant entities.""" + + def __init__( + self, + coordinator: DataUpdateCoordinator[VaillantData], + device_id: str, + module_id: str, + ): + """Initialize.""" -class VaillantEntity(CoordinatorEntity): - def __init__(self, coordinator, config_entry): super().__init__(coordinator) - self.config_entry = config_entry + + self._device_id = device_id + self._module_id = module_id @property - def unique_id(self): - """Return a unique ID to use for this entity.""" - return self.config_entry.entry_id + def _client(self) -> ThermostatClient: + return self.coordinator.data.client @property - def device_info(self): - return { - "identifiers": {(DOMAIN, self.unique_id)}, - "name": NAME, - "model": VERSION, - "manufacturer": NAME, - } + def _device(self) -> Device: + return self.coordinator.data.devices[self._device_id] + + @property + def _module(self) -> Module: + return self.coordinator.data.modules[self._module_id] @property - def device_state_attributes(self): - """Return the state attributes.""" + def unique_id(self) -> str: + """Return a unique ID to use for this entity.""" + return self._module.id + + @property + def device_info(self) -> dict[str, Any]: return { - "attribution": ATTRIBUTION, - "id": str(self.coordinator.data.get("id")), - "integration": DOMAIN, + "identifiers": {(DOMAIN, self.unique_id)}, + "name": self._module.module_name, + "sw_version": self._module.firmware, + "manufacturer": self._device.type, + "via_device": (DOMAIN, self._device.id), } diff --git a/custom_components/vaillant_vsmart/sensor.py b/custom_components/vaillant_vsmart/sensor.py deleted file mode 100644 index a0581c9..0000000 --- a/custom_components/vaillant_vsmart/sensor.py +++ /dev/null @@ -1,28 +0,0 @@ -"""Sensor platform for Vaillant vSMART.""" -from .const import DEFAULT_NAME, DOMAIN, ICON, SENSOR -from .entity import VaillantEntity - - -async def async_setup_entry(hass, entry, async_add_devices): - """Setup sensor platform.""" - coordinator = hass.data[DOMAIN][entry.entry_id] - async_add_devices([VaillantSensor(coordinator, entry)]) - - -class VaillantSensor(VaillantEntity): - """vaillant_vsmart Sensor class.""" - - @property - def name(self): - """Return the name of the sensor.""" - return f"{DEFAULT_NAME}_{SENSOR}" - - @property - def state(self): - """Return the state of the sensor.""" - return self.coordinator.data.get("body") - - @property - def icon(self): - """Return the icon of the sensor.""" - return ICON diff --git a/custom_components/vaillant_vsmart/switch.py b/custom_components/vaillant_vsmart/switch.py deleted file mode 100644 index cf95f07..0000000 --- a/custom_components/vaillant_vsmart/switch.py +++ /dev/null @@ -1,40 +0,0 @@ -"""Switch platform for Vaillant vSMART.""" -from homeassistant.components.switch import SwitchEntity - -from .const import DEFAULT_NAME, DOMAIN, ICON, SWITCH -from .entity import VaillantEntity - - -async def async_setup_entry(hass, entry, async_add_devices): - """Setup sensor platform.""" - coordinator = hass.data[DOMAIN][entry.entry_id] - async_add_devices([VaillantBinarySwitch(coordinator, entry)]) - - -class VaillantBinarySwitch(VaillantEntity, SwitchEntity): - """vaillant_vsmart switch class.""" - - async def async_turn_on(self, **kwargs): # pylint: disable=unused-argument - """Turn on the switch.""" - await self.coordinator.api.async_set_title("bar") - await self.coordinator.async_request_refresh() - - async def async_turn_off(self, **kwargs): # pylint: disable=unused-argument - """Turn off the switch.""" - await self.coordinator.api.async_set_title("foo") - await self.coordinator.async_request_refresh() - - @property - def name(self): - """Return the name of the switch.""" - return f"{DEFAULT_NAME}_{SWITCH}" - - @property - def icon(self): - """Return the icon of this switch.""" - return ICON - - @property - def is_on(self): - """Return true if the switch is on.""" - return self.coordinator.data.get("title", "") == "foo"