From bd197dffad08082a139c64562c7dc9f3bb691ab0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johan=20Nenz=C3=A9n?= Date: Fri, 9 Aug 2024 16:06:17 +0200 Subject: [PATCH] Adds the new api client (#100) * Adds devcontainer * Fixes in devcontainer * Added new API client * Update aiohttp * Deps * Get entity data from new api --- .devcontainer.json | 41 +++ .gitignore | 5 +- custom_components/wellbeing/__init__.py | 58 ++-- custom_components/wellbeing/api.py | 279 ++++-------------- custom_components/wellbeing/config_flow.py | 117 +++++--- custom_components/wellbeing/const.py | 2 + custom_components/wellbeing/entity.py | 1 + custom_components/wellbeing/fan.py | 2 +- custom_components/wellbeing/manifest.json | 2 +- custom_components/wellbeing/switch.py | 8 +- .../wellbeing/translations/en.json | 17 +- .../wellbeing/translations/se.json | 17 +- hacs.json | 2 +- requirements.txt | 3 + requirements_dev.txt | 2 - scripts/develop | 20 ++ scripts/lint | 8 + scripts/setup | 8 + 18 files changed, 293 insertions(+), 299 deletions(-) create mode 100644 .devcontainer.json create mode 100644 requirements.txt delete mode 100644 requirements_dev.txt create mode 100755 scripts/develop create mode 100755 scripts/lint create mode 100755 scripts/setup diff --git a/.devcontainer.json b/.devcontainer.json new file mode 100644 index 0000000..138470e --- /dev/null +++ b/.devcontainer.json @@ -0,0 +1,41 @@ +{ + "name": "JohNan/homeassistant-wellbeing", + "image": "mcr.microsoft.com/devcontainers/python:1-3.12", + "postCreateCommand": "scripts/setup", + "forwardPorts": [ + 8123 + ], + "portsAttributes": { + "8123": { + "label": "Home Assistant", + "onAutoForward": "notify" + } + }, + "customizations": { + "vscode": { + "extensions": [ + "charliermarsh.ruff", + "github.vscode-pull-request-github", + "ms-python.python", + "ms-python.vscode-pylance", + "ryanluker.vscode-coverage-gutters" + ], + "settings": { + "files.eol": "\n", + "editor.tabSize": 4, + "editor.formatOnPaste": true, + "editor.formatOnSave": true, + "editor.formatOnType": false, + "files.trimTrailingWhitespace": true, + "python.analysis.typeCheckingMode": "basic", + "python.analysis.autoImportCompletions": true, + "python.defaultInterpreterPath": "/usr/local/bin/python", + "[python]": { + "editor.defaultFormatter": "charliermarsh.ruff" + } + } + } + }, + "remoteUser": "vscode", + "features": {} +} diff --git a/.gitignore b/.gitignore index 2b87d98..0e9b978 100644 --- a/.gitignore +++ b/.gitignore @@ -139,4 +139,7 @@ venv.bak/ dmypy.json # Pyre type checker -.pyre/ \ No newline at end of file +.pyre/ + +/config +temp_credentials.py diff --git a/custom_components/wellbeing/__init__.py b/custom_components/wellbeing/__init__.py index e91638c..f05900e 100644 --- a/custom_components/wellbeing/__init__.py +++ b/custom_components/wellbeing/__init__.py @@ -9,14 +9,19 @@ from datetime import timedelta from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_API_KEY, CONF_ACCESS_TOKEN from homeassistant.core import Config from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady, ConfigEntryAuthFailed from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from homeassistant.helpers.update_coordinator import UpdateFailed +from homeassistant.util.hass_dict import HassKey +from pyelectroluxgroup.api import ElectroluxHubAPI +from pyelectroluxgroup.token_manager import TokenManager + from .api import WellbeingApiClient -from .const import CONF_PASSWORD, CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL +from .const import CONF_PASSWORD, CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL, CONF_REFRESH_TOKEN from .const import CONF_USERNAME from .const import DOMAIN from .const import PLATFORMS @@ -40,14 +45,18 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): else: update_interval = timedelta(seconds=DEFAULT_SCAN_INTERVAL) - username = entry.data.get(CONF_USERNAME) - password = entry.data.get(CONF_PASSWORD) + token_manager = WellBeingTokenManager(hass, entry) + try: + hub = ElectroluxHubAPI( + session=async_get_clientsession(hass), + token_manager=token_manager + ) + except Exception: + raise ConfigEntryAuthFailed - client = WellbeingApiClient(username, password, hass) + client = WellbeingApiClient(hub) coordinator = WellbeingDataUpdateCoordinator(hass, client=client, update_interval=update_interval) - if not await coordinator.async_login(): - raise ConfigEntryAuthFailed await coordinator.async_config_entry_first_refresh() @@ -73,24 +82,11 @@ def __init__(self, hass: HomeAssistant, client: WellbeingApiClient, update_inter super().__init__(hass, _LOGGER, name=DOMAIN, update_interval=update_interval) - async def async_login(self) -> bool: - """Login to Wellbeing.""" - try: - await self.api.async_login() - except Exception as ex: - _LOGGER.error( - "Could not log in to WellBeing, %s. Will try again after %d", - ex, - self.update_interval.seconds - ) - return False - - return True async def _async_update_data(self): """Update data via library.""" try: - appliances = await self.api.async_get_data() + appliances = await self.api.async_get_appliances() return { "appliances": appliances } @@ -120,3 +116,25 @@ 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) + +class WellBeingTokenManager(TokenManager): + def __init__(self, hass: HomeAssistant, entry: ConfigEntry): + self._hass = hass + self._entry = entry + api_key = entry.data.get(CONF_API_KEY) + refresh_token = entry.data.get(CONF_REFRESH_TOKEN) + access_token = entry.data.get(CONF_ACCESS_TOKEN) + super().__init__(access_token, refresh_token, api_key) + + def update(self, access_token: str, refresh_token: str, api_key: str | None = None): + super().update(access_token, refresh_token, api_key) + + self._hass.config_entries.async_update_entry( + self._entry, + data={ + **self._entry.data, + CONF_API_KEY: api_key if api_key is not None else api_key, + CONF_REFRESH_TOKEN: refresh_token, + CONF_ACCESS_TOKEN: access_token + }, + ) \ No newline at end of file diff --git a/custom_components/wellbeing/api.py b/custom_components/wellbeing/api.py index 9ef06ae..ac92d5e 100644 --- a/custom_components/wellbeing/api.py +++ b/custom_components/wellbeing/api.py @@ -1,33 +1,16 @@ """Sample API Client.""" -import asyncio import logging -import socket -from datetime import datetime, timedelta from enum import Enum -import aiohttp -import async_timeout - -from custom_components.wellbeing.const import SENSOR, FAN, BINARY_SENSOR -from homeassistant.core import HomeAssistant from homeassistant.components.binary_sensor import BinarySensorDeviceClass from homeassistant.components.sensor import SensorDeviceClass from homeassistant.const import UnitOfTemperature, PERCENTAGE, CONCENTRATION_PARTS_PER_MILLION, \ CONCENTRATION_PARTS_PER_BILLION, CONCENTRATION_MICROGRAMS_PER_CUBIC_METER -from homeassistant.helpers.aiohttp_client import async_get_clientsession +from pyelectroluxgroup.api import ElectroluxHubAPI +from pyelectroluxgroup.appliance import Appliance as ApiAppliance -TIMEOUT = 10 -RETRIES = 3 -CLIENT_ID = "ElxOneApp" -CLIENT_SECRET = "8UKrsKD7jH9zvTV7rz5HeCLkit67Mmj68FvRVTlYygwJYy4dW6KF2cVLPKeWzUQUd6KJMtTifFf4NkDnjI7ZLdfnwcPtTSNtYvbP7OzEkmQD9IjhMOf5e1zeAQYtt2yN" -X_API_KEY = "2AMqwEV5MqVhTKrRCyYfVF8gmKrd2rAmp7cUsfky" -USER_AGENT = "Electrolux/2.9 android/9" +from custom_components.wellbeing.const import SENSOR, FAN, BINARY_SENSOR -BASE_URL = "https://api.ocp.electrolux.one" -TOKEN_URL = f"{BASE_URL}/one-account-authorization/api/v1/token" -AUTHENTICATION_URL = f"{BASE_URL}/one-account-authentication/api/v1/authenticate" -API_URL = f"{BASE_URL}/appliance/api/v2" -APPLIANCES_URL = f"{API_URL}/appliances" FILTER_TYPE = { 48: "BREEZE Complete air filter", @@ -47,6 +30,7 @@ _LOGGER: logging.Logger = logging.getLogger(__package__) + class Mode(str, Enum): OFF = "PowerOff" AUTO = "Auto" @@ -108,6 +92,7 @@ class Appliance: firmware: str mode: Mode entities: [] + capabilities: {} def __init__(self, name, pnc_id, model) -> None: self.model = model @@ -144,11 +129,11 @@ def _create_entities(data): unit=PERCENTAGE ), ApplianceSensor( - name="CO2", - attr='CO2', - unit=CONCENTRATION_PARTS_PER_MILLION, - device_class=SensorDeviceClass.CO2 - ) + name="CO2", + attr='CO2', + unit=CONCENTRATION_PARTS_PER_MILLION, + device_class=SensorDeviceClass.CO2 + ) ] common_entities = [ @@ -234,19 +219,23 @@ def get_entity(self, entity_type, entity_attr): if entity.attr == entity_attr and entity.entity_type == entity_type ) + def has_capability(self, capability) -> bool: + return capability in self.capabilities and self.capabilities[capability]['access'] == 'readwrite' + def clear_mode(self): self.mode = Mode.UNDEFINED - def setup(self, data): + def setup(self, data, capabilities): self.firmware = data.get('FrmVer_NIU') self.mode = Mode(data.get('Workmode')) + self.capabilities = capabilities self.entities = [ entity.setup(data) for entity in Appliance._create_entities(data) if entity.attr in data ] @property - def speed_range(self) -> tuple: + def speed_range(self) -> tuple[float, float]: ## Electrolux Devices: if self.model == "WELLA5": return 1, 5 @@ -255,7 +244,6 @@ def speed_range(self) -> tuple: if self.model == "PUREA9": return 1, 9 - ## AEG Devices: if self.model == "AX5": return 1, 5 @@ -277,145 +265,40 @@ def get_appliance(self, pnc_id): class WellbeingApiClient: - def __init__(self, username: str, password: str, hass: HomeAssistant) -> None: + def __init__(self, hub: ElectroluxHubAPI) -> None: """Sample API Client.""" - self._username = username - self._password = password - self._access_token = None - self._token = None - self._hass = hass - self._current_access_token = None - self._token_expires = datetime.now() - self.appliances = None - - async def _get_token(self) -> dict: - json = { - "clientId": CLIENT_ID, - "clientSecret": CLIENT_SECRET, - "grantType": "client_credentials" - } - headers = { - "Content-Type": "application/json", - "Accept": "application/json" - } - return await self.api_wrapper("post", TOKEN_URL, json, headers) - - async def _login(self, access_token: str) -> dict: - credentials = { - "username": self._username, - "password": self._password - } - headers = { - "Authorization": f"Bearer {access_token}", - "Content-Type": "application/json", - "Accept": "application/json", - "x-api-key": X_API_KEY - } - return await self.api_wrapper("post", AUTHENTICATION_URL, credentials, headers) - - async def _get_token2(self, id_token: str, country_code: str) -> dict: - credentials = { - "clientId": CLIENT_ID, - "idToken": id_token, - "grantType": "urn:ietf:params:oauth:grant-type:token-exchange" - } - headers = { - "Content-Type": "application/json", - "Accept": "application/json", - "Origin-Country-Code": country_code - } - return await self.api_wrapper("post", TOKEN_URL, credentials, headers) - - async def _get_appliances(self, access_token: str) -> dict: - headers = { - "Authorization": f"Bearer {access_token}", - "Content-Type": "application/json", - "Accept": "application/json", - "x-api-key": X_API_KEY - } - return await self.api_wrapper("get", APPLIANCES_URL, headers=headers) - - async def _get_appliance_info(self, access_token: str, pnc_id: str) -> dict: - headers = { - "Authorization": f"Bearer {access_token}", - "Content-Type": "application/json", - "Accept": "application/json", - "x-api-key": X_API_KEY - } - url = f"{APPLIANCES_URL}/{pnc_id}/info" - return await self.api_wrapper("get", url, headers=headers) - - async def async_login(self) -> bool: - if self._current_access_token is not None and self._token_expires > datetime.now(): - return True - - _LOGGER.debug("Current token is not set or expired") - - self._token = None - self._current_access_token = None - access_token = await self._get_token() + self._api_appliances: {str: ApiAppliance} = None + self._hub = hub - if 'accessToken' not in access_token: - self._access_token = None - self._current_access_token = None - _LOGGER.error("AccessToken 1 is missing") - return False - - userToken = await self._login(access_token['accessToken']) - - if 'idToken' not in userToken: - self._current_access_token = None - _LOGGER.error("User login failed") - return False - - token = await self._get_token2(userToken['idToken'], userToken['countryCode']) - - if 'accessToken' not in token: - self._current_access_token = None - _LOGGER.error("AccessToken 2 is missing") - return False - - _LOGGER.debug("Received new token sucssfully") - - self._token_expires = datetime.now() + timedelta(seconds=token['expiresIn']) - self._current_access_token = token['accessToken'] - return True - - async def async_get_data(self) -> Appliances: + async def async_get_appliances(self) -> Appliances: """Get data from the API.""" - n = 0 - while not await self.async_login() and n < RETRIES: - _LOGGER.debug(f"Re-trying login. Attempt {n + 1} / {RETRIES}") - n += 1 - - if self._current_access_token is None: - raise Exception("Unable to login") - access_token = self._current_access_token - appliances = await self._get_appliances(access_token) + appliances: [ApiAppliance] = await self._hub.async_get_appliances() + self._api_appliances = dict((appliance.id, appliance) for appliance in appliances) _LOGGER.debug(f"Fetched data: {appliances}") found_appliances = {} - for appliance in (appliance for appliance in appliances if 'applianceId' in appliance): - modelName = appliance['applianceData']['modelName'] - applianceId = appliance['applianceId'] - applianceName = appliance['applianceData']['applianceName'] + for appliance in (appliance for appliance in appliances): + await appliance.async_update() + + model_name = appliance.type + appliance_id = appliance.id + appliance_name = appliance.name - app = Appliance(applianceName, applianceId, modelName) - appliance_info = await self._get_appliance_info(access_token, applianceId) - _LOGGER.debug(f"Fetched data: {appliance_info}") + app = Appliance(appliance_name, appliance_id, model_name) + _LOGGER.debug(f"Fetched data: {appliance.state}") - app.brand = appliance_info['brand'] - app.serialNumber = appliance_info['serialNumber'] - app.device = appliance_info['deviceType'] + app.brand = appliance.brand + app.serialNumber = appliance.serial_number + app.device = appliance.device_type if app.device != 'AIR_PURIFIER': continue - data = appliance.get('properties', {}).get('reported', {}) - data['status'] = appliance.get('connectionState') - data['connectionState'] = appliance.get('connectionState') - app.setup(data) + data = appliance.state + data['status'] = appliance.state_data.get('status', 'unknown') + data['connectionState'] = appliance.state_data.get('connectionState', 'unknown') + app.setup(data, appliance.capabilities_data) found_appliances[app.pnc_id] = app @@ -425,82 +308,34 @@ async def set_fan_speed(self, pnc_id: str, level: int): data = { "Fanspeed": level } - result = await self._send_command(self._current_access_token, pnc_id, data) + appliance = self._api_appliances.get(pnc_id, None) + if appliance is None: + _LOGGER.error(f"Failed to set fan speed for appliance with id {pnc_id}") + return + + result = await appliance.send_command(data) _LOGGER.debug(f"Set Fan Speed: {result}") async def set_work_mode(self, pnc_id: str, mode: Mode): data = { - "WorkMode": mode + "Workmode": mode.value } - result = await self._send_command(self._current_access_token, pnc_id, data) - _LOGGER.debug(f"Set Fan Speed: {result}") + appliance = self._api_appliances.get(pnc_id, None) + if appliance is None: + _LOGGER.error(f"Failed to set work mode for appliance with id {pnc_id}") + return + + result = await appliance.send_command(data) + _LOGGER.debug(f"Set work mode: {result}") async def set_feature_state(self, pnc_id: str, feature: str, state: bool): """Set the state of a feature (Ionizer, UILight, SafetyLock).""" # Construct the command directly using the feature name data = {feature: state} - await self._send_command(self._current_access_token, pnc_id, data) - _LOGGER.debug(f"Set {feature} State to {state}") - - async def _send_command(self, access_token: str, pnc_id: str, command: dict) -> None: - """Get data from the API.""" - headers = { - "Authorization": f"Bearer {access_token}", - "Content-Type": "application/json", - "Accept": "application/json", - "x-api-key": X_API_KEY - } - - await self.api_wrapper("put", f"{APPLIANCES_URL}/{pnc_id}/command", data=command, headers=headers) - - async def api_wrapper(self, method: str, url: str, data: dict = {}, headers: dict = {}) -> dict: - """Get information from the API.""" - session = async_get_clientsession(self._hass) - try: - async with async_timeout.timeout(TIMEOUT): - if method == "get": - response = await session.get(url, headers=headers, json=data) - response.raise_for_status() - return await response.json() - elif method == "put": - response = await session.put(url, headers=headers, json=data) - response.raise_for_status() - return await response.json() - elif method == "post": - response = await session.post(url, headers=headers, json=data) - response.raise_for_status() - return await response.json() - else: - raise Exception("Unsupported http method '%s'" % method) - - except asyncio.TimeoutError as exception: - _LOGGER.error( - "Timeout error fetching information from %s - %s", - url, - exception, - ) + appliance = self._api_appliances.get(pnc_id, None) + if appliance is None: + _LOGGER.error(f"Failed to set feature {feature} for appliance with id {pnc_id}") + return - except (KeyError, TypeError) as exception: - _LOGGER.error( - "Error parsing information from %s - %s", - url, - exception, - ) - except aiohttp.ClientResponseError as exception: - _LOGGER.error( - "Error, got error %s (%s) from server %s - %s", - response.status, - response.reason, - 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) - - return {} + await appliance.send_command(data) + _LOGGER.debug(f"Set {feature} State to {state}") diff --git a/custom_components/wellbeing/config_flow.py b/custom_components/wellbeing/config_flow.py index 0c9a80f..ba12720 100644 --- a/custom_components/wellbeing/config_flow.py +++ b/custom_components/wellbeing/config_flow.py @@ -1,18 +1,21 @@ """Adds config flow for Wellbeing.""" -from typing import Mapping, Any - -import voluptuous as vol import logging +from typing import Mapping, Any import homeassistant.helpers.config_validation as cv +import voluptuous as vol from homeassistant import config_entries +from homeassistant.config_entries import ConfigFlowResult, ConfigEntry +from homeassistant.const import CONF_API_KEY, CONF_ACCESS_TOKEN from homeassistant.core import callback -from homeassistant.data_entry_flow import FlowResult -from homeassistant.helpers.aiohttp_client import async_create_clientsession -from .api import WellbeingApiClient -from .const import CONF_PASSWORD, CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL -from .const import CONF_USERNAME +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from pyelectroluxgroup.api import ElectroluxHubAPI +from pyelectroluxgroup.token_manager import TokenManager + +from . import CONF_REFRESH_TOKEN +from .const import CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL, CONFIG_FLOW_TITLE from .const import DOMAIN +from .temp_credentials import TEMP_API_KEY, TEMP_ACCESS_TOKEN, TEMP_REFRESH_TOKEN _LOGGER: logging.Logger = logging.getLogger(__package__) @@ -24,50 +27,80 @@ class WellbeingFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): def __init__(self): """Initialize.""" + self.entry: ConfigEntry self._errors = {} + self._token_manager = WellBeingConfigFlowTokenManager() - async def async_step_user(self, user_input=None): + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: """Handle a flow initialized by the user.""" self._errors = {} - + _LOGGER.debug(user_input) 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 + try: + await self._test_credentials( + user_input[CONF_ACCESS_TOKEN], + user_input[CONF_REFRESH_TOKEN], + user_input[CONF_API_KEY] ) - else: + + # Copy the maybe possibly credentials + user_input[CONF_ACCESS_TOKEN] = self._token_manager.access_token + user_input[CONF_REFRESH_TOKEN] = self._token_manager.refresh_token + except Exception as exp: # pylint: disable=broad-except + _LOGGER.error("Validating credentials failed - %s", exp) self._errors["base"] = "auth" + return await self._show_config_form(user_input) - return await self._show_config_form(user_input) + return self.async_create_entry( + title=CONFIG_FLOW_TITLE, + data=user_input + ) return await self._show_config_form(user_input) - async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: + async def async_step_reauth( + self, entry_data: Mapping[str, Any] + ) -> ConfigFlowResult: """Handle configuration by re-auth.""" + if entry := self.hass.config_entries.async_get_entry(self.context["entry_id"]): + self.entry = entry return await self.async_step_reauth_validate() async def async_step_reauth_validate(self, user_input=None): """Handle reauth and validation.""" - errors = {} + errors: dict[str, str] = {} + if user_input is not None: - return await self._test_credentials( - user_input[CONF_USERNAME], user_input[CONF_PASSWORD] + try: + await self._test_credentials( + user_input[CONF_ACCESS_TOKEN], + user_input[CONF_REFRESH_TOKEN], + user_input[CONF_API_KEY] + ) + + # Copy the maybe possibly credentials + user_input[CONF_ACCESS_TOKEN] = self._token_manager.access_token + user_input[CONF_REFRESH_TOKEN] = self._token_manager.refresh_token + except Exception as exp: # pylint: disable=broad-except + _LOGGER.error("Validating credentials failed - %s", exp) + + self.hass.config_entries.async_update_entry( + self.entry, data={**user_input} ) + return self.async_abort(reason="reauth_successful") return self.async_show_form( step_id="reauth_validate", data_schema=vol.Schema( { - vol.Required(CONF_PASSWORD): str, + vol.Required(CONF_API_KEY, default=TEMP_API_KEY): str, + vol.Required(CONF_ACCESS_TOKEN, default=TEMP_ACCESS_TOKEN): str, + vol.Required(CONF_REFRESH_TOKEN, default=TEMP_REFRESH_TOKEN): str, } ), errors=errors, - description_placeholders={ - CONF_USERNAME: user_input[CONF_USERNAME], - }, ) @staticmethod @@ -81,21 +114,32 @@ async def _show_config_form(self, user_input): # pylint: disable=unused-argumen step_id="user", data_schema=vol.Schema( { - vol.Required(CONF_USERNAME): str, - vol.Required(CONF_PASSWORD): str + vol.Required(CONF_API_KEY, default=TEMP_API_KEY): str, + vol.Required(CONF_ACCESS_TOKEN, default=TEMP_ACCESS_TOKEN): str, + vol.Required(CONF_REFRESH_TOKEN, default=TEMP_REFRESH_TOKEN): str, } ), errors=self._errors, ) - async def _test_credentials(self, username, password): + async def _test_credentials(self, access_token: str, refresh_token: str, api_key: str): """Return true if credentials is valid.""" - try: - client = WellbeingApiClient(username, password, self.hass) - return await client.async_login() - except Exception: # pylint: disable=broad-except - pass - return False + + self._token_manager.update(access_token, refresh_token, api_key) + client = ElectroluxHubAPI( + session=async_get_clientsession(self.hass), + token_manager=self._token_manager + ) + await client.async_get_appliances() + +class WellBeingConfigFlowTokenManager(TokenManager): + """TokenManager implementation for config flow """ + + def __init__(self): + pass + + def update(self, access_token: str, refresh_token: str, api_key: str | None = None): + super().update(access_token, refresh_token, api_key) class WellbeingOptionsFlowHandler(config_entries.OptionsFlow): @@ -133,5 +177,6 @@ async def async_step_user(self, user_input=None): 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 + title=CONFIG_FLOW_TITLE, + data=self.options ) diff --git a/custom_components/wellbeing/const.py b/custom_components/wellbeing/const.py index 584c080..ffab982 100644 --- a/custom_components/wellbeing/const.py +++ b/custom_components/wellbeing/const.py @@ -1,5 +1,6 @@ """Constants for Wellbeing.""" # Base component constants +CONFIG_FLOW_TITLE = "Electrolux Wellbeing" NAME = "Wellbeing" DOMAIN = "wellbeing" DOMAIN_DATA = f"{DOMAIN}_data" @@ -23,6 +24,7 @@ CONF_USERNAME = "username" CONF_PASSWORD = "password" CONF_SCAN_INTERVAL = "scan_interval" +CONF_REFRESH_TOKEN = "refresh_token" # Defaults DEFAULT_NAME = DOMAIN diff --git a/custom_components/wellbeing/entity.py b/custom_components/wellbeing/entity.py index 050eb85..0b348ca 100644 --- a/custom_components/wellbeing/entity.py +++ b/custom_components/wellbeing/entity.py @@ -51,6 +51,7 @@ def extra_state_attributes(self): """Return the state attributes.""" return { "integration": DOMAIN, + "capabilities": [key for key, value in self.get_appliance.capabilities.items() if value['access'] == 'readwrite'] } @property diff --git a/custom_components/wellbeing/fan.py b/custom_components/wellbeing/fan.py index 9e5ae17..c60f165 100644 --- a/custom_components/wellbeing/fan.py +++ b/custom_components/wellbeing/fan.py @@ -46,7 +46,7 @@ def __init__(self, coordinator: WellbeingDataUpdateCoordinator, config_entry, pn self._speed = self.get_entity.state @property - def _speed_range(self) -> tuple: + def _speed_range(self) -> tuple[float, float]: return self.get_appliance.speed_range @property diff --git a/custom_components/wellbeing/manifest.json b/custom_components/wellbeing/manifest.json index ffa912e..e0ac55f 100644 --- a/custom_components/wellbeing/manifest.json +++ b/custom_components/wellbeing/manifest.json @@ -7,6 +7,6 @@ "documentation": "https://github.com/JohNan/homeassistant-wellbeing", "iot_class": "cloud_polling", "issue_tracker": "https://github.com/JohNan/homeassistant-wellbeing/issues", - "requirements": [], + "requirements": ["pyelectroluxgroup==0.2.0"], "version": "v0.0.0" } diff --git a/custom_components/wellbeing/switch.py b/custom_components/wellbeing/switch.py index 0b52c58..1d5b071 100644 --- a/custom_components/wellbeing/switch.py +++ b/custom_components/wellbeing/switch.py @@ -1,20 +1,22 @@ """Switch platform for Wellbeing.""" from homeassistant.components.switch import SwitchEntity + from .const import DOMAIN from .entity import WellbeingEntity + async def async_setup_entry(hass, entry, async_add_devices): """Setup switch platform.""" coordinator = hass.data[DOMAIN][entry.entry_id] appliances = coordinator.data.get('appliances', None) + capabilities = ["Ionizer", "UILight", "SafetyLock"] if appliances is not None: for pnc_id, appliance in appliances.appliances.items(): # Assuming that the appliance supports these features async_add_devices([ - WellbeingSwitch(coordinator, entry, pnc_id, "Ionizer"), - WellbeingSwitch(coordinator, entry, pnc_id, "UILight"), - WellbeingSwitch(coordinator, entry, pnc_id, "SafetyLock"), + WellbeingSwitch(coordinator, entry, pnc_id, capability) + for capability in capabilities if appliance.has_capability(capability) ]) class WellbeingSwitch(WellbeingEntity, SwitchEntity): diff --git a/custom_components/wellbeing/translations/en.json b/custom_components/wellbeing/translations/en.json index 475262c..fa04d5c 100644 --- a/custom_components/wellbeing/translations/en.json +++ b/custom_components/wellbeing/translations/en.json @@ -4,20 +4,25 @@ "user": { "description": "If you need help with the configuration have a look here: https://github.com/JohNan/homeassistant-wellbeing", "data": { - "username": "Username", - "password": "Password" + "api_key": "API key", + "refresh_token": "Refresh Token", + "access_token": "Access Token" } }, "reauth_validate": { "data": { - "password": "Password" + "api_key": "API key", + "refresh_token": "Refresh Token", + "access_token": "Access Token" }, - "description": "Enter the password for {username}.", - "title": "Reauthenticate an Electrolux account" + "description": "Check that the information is correct.", + "title": "Re-authenticate against the Electrolux API" } }, "error": { - "auth": "Username/Password is wrong." + "auth": "Authentication failed", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", + "reauth_unsuccessful": "Re-authentication was unsuccessful, please remove the integration and set it up again." }, "abort": { "single_instance_allowed": "Only a single instance is allowed." diff --git a/custom_components/wellbeing/translations/se.json b/custom_components/wellbeing/translations/se.json index d8c2722..6513a5a 100644 --- a/custom_components/wellbeing/translations/se.json +++ b/custom_components/wellbeing/translations/se.json @@ -4,20 +4,25 @@ "user": { "description": "Om du behöver hjälp med konfigurationen, se här: https://github.com/JohNan/homeassistant-wellbeing", "data": { - "username": "Användarnamn", - "password": "Lösenord" + "api_key": "API key", + "refresh_token": "Refresh Token", + "access_token": "Access Token" } }, "reauth_validate": { "data": { - "password": "Lösenord" + "api_key": "API key", + "refresh_token": "Refresh Token", + "access_token": "Access Token" }, - "description": "Ange lösenord för {username}.", - "title": "Återautentisera Electrolux-kontot" + "description": "Kontrollera att uppgifter är korrekt.", + "title": "Återautentisera mot Electrolux API" } }, "error": { - "auth": "Användarnamn eller lösenord är felaktigt." + "auth": "Autentiseringen misslyckades", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", + "reauth_unsuccessful": "Återautentiseringen misslyckades. Ta bort integrationen och konfigurera den igen." }, "abort": { "single_instance_allowed": "Endast en instans är tillåten." diff --git a/hacs.json b/hacs.json index 9f04093..0676249 100644 --- a/hacs.json +++ b/hacs.json @@ -1,5 +1,5 @@ { "name": "Electrolux Wellbeing", "hacs": "1.6.0", - "homeassistant": "2021.12.0" + "homeassistant": "2024.6.0" } diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..357c0c5 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +homeassistant==2024.8.0 +aiohttp +pyelectroluxgroup @ git+https://github.com/JohNan/pyelectroluxgroup@johnan/update-project diff --git a/requirements_dev.txt b/requirements_dev.txt deleted file mode 100644 index 23186aa..0000000 --- a/requirements_dev.txt +++ /dev/null @@ -1,2 +0,0 @@ -homeassistant==2024.1.6 -aiohttp==3.9.3 diff --git a/scripts/develop b/scripts/develop new file mode 100755 index 0000000..89eda50 --- /dev/null +++ b/scripts/develop @@ -0,0 +1,20 @@ +#!/usr/bin/env bash + +set -e + +cd "$(dirname "$0")/.." + +# Create config dir if not present +if [[ ! -d "${PWD}/config" ]]; then + mkdir -p "${PWD}/config" + hass --config "${PWD}/config" --script ensure_config +fi + +# Set the path to custom_components +## This let's us have the structure we want /custom_components/integration_blueprint +## while at the same time have Home Assistant configuration inside /config +## without resulting to symlinks. +export PYTHONPATH="${PYTHONPATH}:${PWD}/custom_components" + +# Start Home Assistant +hass --config "${PWD}/config" --debug diff --git a/scripts/lint b/scripts/lint new file mode 100755 index 0000000..5d68d15 --- /dev/null +++ b/scripts/lint @@ -0,0 +1,8 @@ +#!/usr/bin/env bash + +set -e + +cd "$(dirname "$0")/.." + +ruff format . +ruff check . --fix diff --git a/scripts/setup b/scripts/setup new file mode 100755 index 0000000..b6b80e8 --- /dev/null +++ b/scripts/setup @@ -0,0 +1,8 @@ +#!/usr/bin/env bash + +set -e + +cd "$(dirname "$0")/.." + +python3 -m pip install --requirement requirements.txt +python3 -m pip install mutagen home-assistant-frontend