From 32b20d598f1e80263c20fb56bf2d3fdb87932b9a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johan=20Nenz=C3=A9n?= Date: Tue, 11 May 2021 13:19:38 +0200 Subject: [PATCH] Initial commit --- .gitignore | 142 ++++++++ LICENSE | 21 ++ README.md | 44 +++ custom_components/__init__.py | 1 + custom_components/wellbeing/__init__.py | 121 +++++++ custom_components/wellbeing/api.py | 315 ++++++++++++++++++ custom_components/wellbeing/binary_sensor.py | 32 ++ custom_components/wellbeing/config_flow.py | 108 ++++++ custom_components/wellbeing/const.py | 42 +++ custom_components/wellbeing/entity.py | 59 ++++ custom_components/wellbeing/fan.py | 50 +++ custom_components/wellbeing/manifest.json | 10 + custom_components/wellbeing/sensor.py | 38 +++ custom_components/wellbeing/switch.py | 43 +++ .../wellbeing/translations/en.json | 31 ++ .../wellbeing/translations/fr.json | 31 ++ .../wellbeing/translations/nb.json | 31 ++ hacs.json | 7 + info.md | 35 ++ requirements_dev.txt | 2 + setup.cfg | 46 +++ 21 files changed, 1209 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md create mode 100644 custom_components/__init__.py create mode 100644 custom_components/wellbeing/__init__.py create mode 100644 custom_components/wellbeing/api.py create mode 100644 custom_components/wellbeing/binary_sensor.py create mode 100644 custom_components/wellbeing/config_flow.py create mode 100644 custom_components/wellbeing/const.py create mode 100644 custom_components/wellbeing/entity.py create mode 100644 custom_components/wellbeing/fan.py create mode 100644 custom_components/wellbeing/manifest.json create mode 100644 custom_components/wellbeing/sensor.py create mode 100644 custom_components/wellbeing/switch.py create mode 100644 custom_components/wellbeing/translations/en.json create mode 100644 custom_components/wellbeing/translations/fr.json create mode 100644 custom_components/wellbeing/translations/nb.json create mode 100644 hacs.json create mode 100644 info.md create mode 100644 requirements_dev.txt create mode 100644 setup.cfg diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2b87d98 --- /dev/null +++ b/.gitignore @@ -0,0 +1,142 @@ +# Hide some OS X stuff +.DS_Store +.AppleDouble +.LSOverride +Icon + +# Thumbnails +._* + +# IntelliJ IDEA +.idea +*.iml + +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +pip-wheel-metadata/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +.python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..ae87c62 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2021 JohNan + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..47d8ce9 --- /dev/null +++ b/README.md @@ -0,0 +1,44 @@ +# Electrolux Wellbeing + +[![GitHub Release][releases-shield]][releases] +[![License][license-shield]](LICENSE) + +[![hacs][hacsbadge]][hacs] +[![Project Maintenance][maintenance-shield]][user_profile] +[![BuyMeCoffee][buymecoffeebadge]][buymecoffee] + +Get the status from your Electrolux devices connected to Wellbeing. **Currently, only fetching of values are supported** + +### Supported and tested devices + +- Pure A9 Air Purifier + +### Install with HACS (recommended) +Add the url to the repository as a custom integration. + +### Installation + +1. Using the tool of choice open the directory (folder) for your HA configuration (where you find `configuration.yaml`). +2. If you do not have a `custom_components` directory (folder) there, you need to create it. +3. In the `custom_components` directory (folder) create a new folder called `wellbeing`. +4. Download _all_ the files from the `custom_components/wellbeing/` directory (folder) in this repository. +5. Place the files you downloaded in the new directory (folder) you created. +6. Restart Home Assistant +7. In the HA UI go to "Configuration" -> "Integrations" click "+" and search for "Wellbeing" + + +Contributions are welcome! + +--- + +[buymecoffee]: https://www.buymeacoffee.com/JohNan +[buymecoffeebadge]: https://img.shields.io/badge/buy%20me%20a%20coffee-donate-yellow.svg?style=for-the-badge +[commits-shield]: https://img.shields.io/github/commit-activity/y/JohNan/wellbeing.svg?style=for-the-badge +[commits]: https://github.com/JohNan/homeassistant-wellbeing/commits/main +[hacs]: https://hacs.xyz +[hacsbadge]: https://img.shields.io/badge/HACS-Custom-orange.svg?style=for-the-badge +[license-shield]: https://img.shields.io/github/license/JohNan/wellbeing.svg?style=for-the-badge +[maintenance-shield]: https://img.shields.io/badge/maintainer-%40JohNan-blue.svg?style=for-the-badge +[releases-shield]: https://img.shields.io/github/release/JohNan/wellbeing.svg?style=for-the-badge +[releases]: https://github.com/JohNan/homeassistant-wellbeing/releases +[user_profile]: https://github.com/JohNan diff --git a/custom_components/__init__.py b/custom_components/__init__.py new file mode 100644 index 0000000..9e5dc14 --- /dev/null +++ b/custom_components/__init__.py @@ -0,0 +1 @@ +"""Dummy init so that pytest works.""" diff --git a/custom_components/wellbeing/__init__.py b/custom_components/wellbeing/__init__.py new file mode 100644 index 0000000..8f85838 --- /dev/null +++ b/custom_components/wellbeing/__init__.py @@ -0,0 +1,121 @@ +""" +Custom integration to integrate Wellbeing with Home Assistant. + +For more details about this integration, please refer to +https://github.com/JohNan/wellbeing +""" +import asyncio +import logging +from datetime import timedelta + +from homeassistant.config_entries import ConfigEntry +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 .api import WellbeingApiClient +from .const import CONF_PASSWORD +from .const import CONF_USERNAME +from .const import DOMAIN +from .const import PLATFORMS +from .const import STARTUP_MESSAGE + +SCAN_INTERVAL = timedelta(seconds=30) + +_LOGGER: logging.Logger = logging.getLogger(__package__) + + +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) + + username = entry.data.get(CONF_USERNAME) + password = entry.data.get(CONF_PASSWORD) + + session = async_get_clientsession(hass) + client = WellbeingApiClient(username, password, session) + + coordinator = WellbeingDataUpdateCoordinator(hass, client=client) + if not await coordinator.async_login(): + raise ConfigEntryAuthFailed + + await coordinator.async_config_entry_first_refresh() + + if not coordinator.last_update_success: + raise ConfigEntryNotReady + + hass.data[DOMAIN][entry.entry_id] = coordinator + + hass.config_entries.async_setup_platforms(entry, PLATFORMS) + + entry.add_update_listener(async_reload_entry) + return True + + +class WellbeingDataUpdateCoordinator(DataUpdateCoordinator): + """Class to manage fetching data from the API.""" + + def __init__( + self, + hass: HomeAssistant, + client: WellbeingApiClient, + ) -> None: + """Initialize.""" + self.api = client + self.platforms = [] + + super().__init__(hass, _LOGGER, name=DOMAIN, update_interval=SCAN_INTERVAL) + + async def async_login(self) -> bool: + """Login to Verisure.""" + try: + await self.api.async_login() + except Exception as ex: + _LOGGER.error("Could not log in to WellBeing, %s", ex) + return False + + return True + + async def _async_update_data(self): + """Update data via library.""" + try: + appliances = await self.api.async_get_data() + return { + "appliances": appliances + } + 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 + + +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) diff --git a/custom_components/wellbeing/api.py b/custom_components/wellbeing/api.py new file mode 100644 index 0000000..cc216ff --- /dev/null +++ b/custom_components/wellbeing/api.py @@ -0,0 +1,315 @@ +"""Sample API Client.""" +import json +from datetime import datetime, timedelta + +import asyncio +import logging +import socket + +import aiohttp +import async_timeout + +from custom_components.wellbeing.const import SENSOR, FAN, BINARY_SENSOR +from homeassistant.components.binary_sensor import DEVICE_CLASS_CONNECTIVITY +from homeassistant.const import TEMP_CELSIUS, PERCENTAGE, DEVICE_CLASS_TEMPERATURE, DEVICE_CLASS_CO2, \ + DEVICE_CLASS_HUMIDITY, CONCENTRATION_PARTS_PER_MILLION, CONCENTRATION_PARTS_PER_BILLION + +TIMEOUT = 10 +RETRIES = 3 +TOKEN_URL = "https://electrolux-wellbeing-client.vercel.app/api/mu52m5PR9X" +LOGIN_URL = "https://api.delta.electrolux.com/api/Users/Login" +APPLIANCES_URL = "https://api.delta.electrolux.com/api/Domains/Appliances" +APPLIANCE_INFO_URL = "https://api.delta.electrolux.com/api/AppliancesInfo" +APPLIANCE_DATA_URL = "https://api.delta.electrolux.com/api/Appliances" + +_LOGGER: logging.Logger = logging.getLogger(__package__) + +HEADERS = {"Content-type": "application/json; charset=UTF-8"} + + +class ApplianceEntity: + entity_type: int = None + + def __init__(self, name, attr, device_class=None) -> None: + self.attr = attr + self.name = name + self.device_class = device_class + self._data = None + + def setup(self, data): + self._data = data + return self + + @property + def state(self): + return self._data[self.attr] + + +class ApplianceSensor(ApplianceEntity): + entity_type: int = SENSOR + + def __init__(self, name, attr, unit="", device_class=None) -> None: + super().__init__(name, attr, device_class) + self.unit = unit + + +class ApplianceFan(ApplianceEntity): + entity_type: int = FAN + + def __init__(self, name, attr) -> None: + super().__init__(name, attr) + + +class ApplianceBinary(ApplianceEntity): + entity_type: int = BINARY_SENSOR + + def __init__(self, name, attr, device_class=None) -> None: + super().__init__(name, attr, device_class) + + @property + def state(self): + return self._data[self.attr] in ['enabled', True, 'Connected'] + + +class Appliance: + serialNumber: str + brand: str + device: str + entities: [] + + def __init__(self, name, pnc_id, model) -> None: + self.model = model + self.pnc_id = pnc_id + self.name = name + + @staticmethod + def _create_entities(): + return [ + ApplianceFan( + name="Fan Speed", + attr='Fanspeed' + ), + ApplianceSensor( + name="Temperature", + attr='Temp', + unit=TEMP_CELSIUS, + device_class=DEVICE_CLASS_TEMPERATURE + ), + ApplianceSensor( + name="CO2", + attr='CO2', + unit=CONCENTRATION_PARTS_PER_MILLION, + device_class=DEVICE_CLASS_CO2 + ), + ApplianceSensor( + name="TVOC", + attr='TVOC', + unit=CONCENTRATION_PARTS_PER_BILLION + ), + ApplianceSensor( + name="Humidity", + attr='Humidity', + unit=PERCENTAGE, + device_class=DEVICE_CLASS_HUMIDITY + ), + ApplianceSensor( + name="Filter Life", + attr='FilterLife', + unit=PERCENTAGE + ), + ApplianceSensor( + name="Mode", + attr='Workmode' + ), + ApplianceBinary( + name="Ionizer", + attr='Ionizer' + ), + ApplianceBinary( + name="UI Light", + attr='UILight' + ), + ApplianceBinary( + name="Connection State", + attr='connectionState', + device_class=DEVICE_CLASS_CONNECTIVITY + ), + ApplianceBinary( + name="Status", + attr='status' + ) + ] + + def get_entity(self, entity_type, entity_attr): + return next( + entity + for entity in self.entities + if entity.attr == entity_attr and entity.entity_type == entity_type + ) + + def setup(self, data): + self.entities = [ + entity.setup(data) + for entity in Appliance._create_entities() + ] + + +class Appliances: + def __init__(self, appliances) -> None: + self.appliances = appliances + + def get_appliance(self, pnc_id): + return self.appliances.get(pnc_id, None) + + +class WellbeingApiClient: + def __init__(self, username: str, password: str, session: aiohttp.ClientSession) -> None: + """Sample API Client.""" + self._username = username + self._password = password + self._session = session + self._access_token = None + self._token = None + self._current_token = None + self.appliances = None + + async def _get_token(self) -> dict: + return await self.api_wrapper("get", TOKEN_URL) + + 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" + } + return await self.api_wrapper("post", LOGIN_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" + } + 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" + } + url = f"{APPLIANCE_INFO_URL}/{pnc_id}" + return await self.api_wrapper("get", url, headers=headers) + + async def _get_appliance_data(self, access_token: str, pnc_id: str) -> dict: + headers = { + "Authorization": f"Bearer {access_token}" + } + return await self.api_wrapper("get", f"{APPLIANCE_DATA_URL}/{pnc_id}", headers=headers) + + async def async_login(self) -> bool: + if self._access_token is None: + self._access_token = await self._get_token() + + if 'accessToken' not in self._access_token: + self._access_token = None + self._current_token = None + _LOGGER.error("Unable to get token") + return False + + if self._token is None or datetime.now() + timedelta(seconds=self._token['expiresIn']) > datetime.now(): + self._token = await self._login(self._access_token['accessToken']) + + if 'accessToken' not in self._token: + self._token = None + self._current_token = None + _LOGGER.error("Unable to login") + return False + + self._current_token = self._token['accessToken'] + return True + + async def async_get_data(self) -> Appliances: + """Get data from the API.""" + n = 0 + while not await self.async_login() and n < RETRIES: + n += 1 + + if self._current_token is None: + raise Exception("Unable to login") + + access_token = self._current_token + appliances = await self._get_appliances(access_token) + _LOGGER.info(f"Fetched data: {appliances}") + + found_appliances = {} + for appliance in (appliance for appliance in appliances if 'pncId' in appliance): + app = Appliance(appliance['applianceName'], appliance['pncId'], appliance['modelName']) + appliance_info = await self._get_appliance_info(access_token, appliance['pncId']) + _LOGGER.info(f"Fetched data: {appliance_info}") + + app.brand = appliance_info['brand'] + app.serialNumber = appliance_info['serialNumber'] + app.device = appliance_info['device'] + + appliance_data = await self._get_appliance_data(access_token, appliance['pncId']) + _LOGGER.info(f"{appliance_data.get('applianceData', {}).get('applianceName', 'N/A')}: {appliance_data}") + + data = appliance_data.get('twin', {}).get('properties', {}).get('reported', {}) + data['connectionState'] = appliance_data.get('twin', {}).get('connectionState') + data['status'] = appliance_data.get('twin', {}).get('connectionState') + app.setup(data) + + found_appliances[app.pnc_id] = app + + return Appliances(found_appliances) + + 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": + response = await self._session.post(url, headers=headers, json=data) + return await response.json() + + 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/wellbeing/binary_sensor.py b/custom_components/wellbeing/binary_sensor.py new file mode 100644 index 0000000..a6a2767 --- /dev/null +++ b/custom_components/wellbeing/binary_sensor.py @@ -0,0 +1,32 @@ +"""Binary sensor platform for Wellbeing.""" +from homeassistant.components.binary_sensor import BinarySensorEntity + +from .const import BINARY_SENSOR +from .const import BINARY_SENSOR_DEVICE_CLASS +from .const import DEFAULT_NAME +from .const import DOMAIN +from .entity import WellbeingEntity + + +async def async_setup_entry(hass, entry, async_add_devices): + """Setup binary sensor platform.""" + coordinator = hass.data[DOMAIN][entry.entry_id] + appliances = coordinator.data.get('appliances', None) + + if appliances is not None: + for pnc_id, appliance in appliances.appliances.items(): + async_add_devices( + [ + WellbeingBinarySensor(coordinator, entry, pnc_id, entity.entity_type, entity.attr) + for entity in appliance.entities if entity.entity_type == BINARY_SENSOR + ] + ) + + +class WellbeingBinarySensor(WellbeingEntity, BinarySensorEntity): + """wellbeing binary_sensor class.""" + + @property + def is_on(self): + """Return true if the binary_sensor is on.""" + return self.get_entity.state diff --git a/custom_components/wellbeing/config_flow.py b/custom_components/wellbeing/config_flow.py new file mode 100644 index 0000000..77dd781 --- /dev/null +++ b/custom_components/wellbeing/config_flow.py @@ -0,0 +1,108 @@ +"""Adds config flow for Wellbeing.""" +import voluptuous as vol +from homeassistant import config_entries +from homeassistant.core import callback +from homeassistant.helpers.aiohttp_client import async_create_clientsession + +from .api import WellbeingApiClient +from .const import CONF_PASSWORD +from .const import CONF_USERNAME +from .const import DOMAIN +from .const import PLATFORMS + + +class WellbeingFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): + """Config flow for wellbeing.""" + + 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): + """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) + + return await self._show_config_form(user_input) + + @staticmethod + @callback + def async_get_options_flow(config_entry): + return WellbeingOptionsFlowHandler(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): str, + vol.Required(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 = WellbeingApiClient(username, password, session) + return await client.async_login() + except Exception: # pylint: disable=broad-except + pass + return False + + +class WellbeingOptionsFlowHandler(config_entries.OptionsFlow): + """Config flow options handler for wellbeing.""" + + def __init__(self, config_entry): + """Initialize HACS options flow.""" + self.config_entry = config_entry + self.options = dict(config_entry.options) + + async def async_step_init(self, user_input=None): # pylint: disable=unused-argument + """Manage the options.""" + return await self.async_step_user() + + 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() + + 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) + } + ), + ) + + 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 + ) diff --git a/custom_components/wellbeing/const.py b/custom_components/wellbeing/const.py new file mode 100644 index 0000000..e223f24 --- /dev/null +++ b/custom_components/wellbeing/const.py @@ -0,0 +1,42 @@ +"""Constants for Wellbeing.""" +# Base component constants +NAME = "Wellbeing" +DOMAIN = "wellbeing" +DOMAIN_DATA = f"{DOMAIN}_data" +VERSION = "0.0.0" + +ATTRIBUTION = "Data provided by http://jsonplaceholder.typicode.com/" +ISSUE_URL = "https://github.com/JohNan/wellbeing/issues" + +# Icons +ICON = "mdi:format-quote-close" + +# Device classes +BINARY_SENSOR_DEVICE_CLASS = "connectivity" + +# Platforms +BINARY_SENSOR = "binary_sensor" +SENSOR = "sensor" +SWITCH = "switch" +FAN = "fan" +PLATFORMS = [SENSOR, FAN, BINARY_SENSOR] + + +# 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} +------------------------------------------------------------------- +""" diff --git a/custom_components/wellbeing/entity.py b/custom_components/wellbeing/entity.py new file mode 100644 index 0000000..b462dcc --- /dev/null +++ b/custom_components/wellbeing/entity.py @@ -0,0 +1,59 @@ +"""WellbeingEntity class""" +from homeassistant.components.sensor import ENTITY_ID_FORMAT +from homeassistant.helpers.update_coordinator import CoordinatorEntity +from .api import Appliance, ApplianceEntity + +from .const import ATTRIBUTION, DEFAULT_NAME +from .const import DOMAIN +from .const import NAME +from .const import VERSION + + +class WellbeingEntity(CoordinatorEntity): + def __init__(self, coordinator, config_entry, pnc_id, entity_type, entity_attr): + super().__init__(coordinator) + self.entity_attr = entity_attr + self.entity_type = entity_type + self.config_entry = config_entry + self.pnc_id = pnc_id + self.entity_id = ENTITY_ID_FORMAT.format(f"{DEFAULT_NAME}_{self.entity_attr}") + + @property + def name(self): + """Return the name of the sensor.""" + return self.get_entity.name + + @property + def get_entity(self) -> ApplianceEntity: + return self.get_appliance.get_entity(self.entity_type, self.entity_attr) + + @property + def get_appliance(self) -> Appliance: + return self.coordinator.data['appliances'].get_appliance(self.pnc_id) + + @property + def unique_id(self): + """Return a unique ID to use for this entity.""" + return f"{self.config_entry.entry_id}-{self.entity_attr}-{self.pnc_id}" + + @property + def device_info(self): + return { + "identifiers": {(DOMAIN, self.pnc_id)}, + "name": self.get_appliance.name, + "model": self.get_appliance.model, + "manufacturer": self.get_appliance.brand, + } + + @property + def device_state_attributes(self): + """Return the state attributes.""" + return { + "id": str(self.pnc_id), + "integration": DOMAIN, + } + + @property + def device_class(self): + """Return de device class of the sensor.""" + return self.get_entity.device_class diff --git a/custom_components/wellbeing/fan.py b/custom_components/wellbeing/fan.py new file mode 100644 index 0000000..3cf9720 --- /dev/null +++ b/custom_components/wellbeing/fan.py @@ -0,0 +1,50 @@ +"""Sensor platform for Wellbeing.""" +from homeassistant.components.fan import FanEntity, SUPPORT_SET_SPEED +from homeassistant.util.percentage import ranged_value_to_percentage, int_states_in_range +from .const import DEFAULT_NAME, FAN +from .const import DOMAIN +from .const import ICON +from .const import SENSOR +from .entity import WellbeingEntity + +SUPPORTED_FEATURES = SUPPORT_SET_SPEED +SPEED_RANGE = (1, 10) + + +async def async_setup_entry(hass, entry, async_add_devices): + """Setup sensor platform.""" + coordinator = hass.data[DOMAIN][entry.entry_id] + appliances = coordinator.data.get('appliances', None) + + if appliances is not None: + for pnc_id, appliance in appliances.appliances.items(): + async_add_devices( + [ + WellbeingFan(coordinator, entry, pnc_id, entity.entity_type, entity.attr) + for entity in appliance.entities if entity.entity_type == FAN + ] + ) + + +class WellbeingFan(WellbeingEntity, FanEntity): + """wellbeing Sensor class.""" + + @property + def name(self): + """Return the name of the sensor.""" + return self.get_entity.name + + @property + def speed_count(self) -> int: + """Return the number of speeds the fan supports.""" + return int_states_in_range(SPEED_RANGE) + + @property + def percentage(self): + """Return the current speed percentage.""" + return ranged_value_to_percentage(SPEED_RANGE, self.get_entity.state) + + @property + def supported_features(self): + """Flag supported features.""" + return SUPPORTED_FEATURES diff --git a/custom_components/wellbeing/manifest.json b/custom_components/wellbeing/manifest.json new file mode 100644 index 0000000..349c016 --- /dev/null +++ b/custom_components/wellbeing/manifest.json @@ -0,0 +1,10 @@ +{ + "domain": "wellbeing", + "name": "Wellbeing", + "documentation": "https://github.com/JohNan/wellbeing", + "issue_tracker": "https://github.com/JohNan/wellbeing/issues", + "dependencies": [], + "config_flow": true, + "codeowners": ["@JohNan"], + "requirements": [] +} diff --git a/custom_components/wellbeing/sensor.py b/custom_components/wellbeing/sensor.py new file mode 100644 index 0000000..d2e489d --- /dev/null +++ b/custom_components/wellbeing/sensor.py @@ -0,0 +1,38 @@ +"""Sensor platform for Wellbeing.""" +from typing import cast + +from .api import ApplianceSensor +from .const import DEFAULT_NAME +from .const import DOMAIN +from .const import ICON +from .const import SENSOR +from .entity import WellbeingEntity + + +async def async_setup_entry(hass, entry, async_add_devices): + """Setup sensor platform.""" + coordinator = hass.data[DOMAIN][entry.entry_id] + appliances = coordinator.data.get('appliances', None) + + if appliances is not None: + for pnc_id, appliance in appliances.appliances.items(): + async_add_devices( + [ + WellbeingSensor(coordinator, entry, pnc_id, entity.entity_type, entity.attr) + for entity in appliance.entities if entity.entity_type == SENSOR + ] + ) + + +class WellbeingSensor(WellbeingEntity): + """wellbeing Sensor class.""" + + @property + def state(self): + """Return the state of the sensor.""" + return self.get_entity.state + + @property + def unit_of_measurement(self): + return cast(ApplianceSensor, self.get_entity).unit + diff --git a/custom_components/wellbeing/switch.py b/custom_components/wellbeing/switch.py new file mode 100644 index 0000000..bdc8666 --- /dev/null +++ b/custom_components/wellbeing/switch.py @@ -0,0 +1,43 @@ +"""Switch platform for Wellbeing.""" +from homeassistant.components.switch import SwitchEntity + +from .const import DEFAULT_NAME +from .const import DOMAIN +from .const import ICON +from .const import SWITCH +from .entity import WellbeingEntity + + +async def async_setup_entry(hass, entry, async_add_devices): + """Setup sensor platform.""" + coordinator = hass.data[DOMAIN][entry.entry_id] + async_add_devices([WellbeingBinarySwitch(coordinator, entry)]) + + +class WellbeingBinarySwitch(WellbeingEntity, SwitchEntity): + """wellbeing 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" diff --git a/custom_components/wellbeing/translations/en.json b/custom_components/wellbeing/translations/en.json new file mode 100644 index 0000000..cfd7aa3 --- /dev/null +++ b/custom_components/wellbeing/translations/en.json @@ -0,0 +1,31 @@ +{ + "config": { + "step": { + "user": { + "title": "Wellbeing", + "description": "If you need help with the configuration have a look here: https://github.com/JohNan/wellbeing", + "data": { + "username": "Username", + "password": "Password" + } + } + }, + "error": { + "auth": "Username/Password is wrong." + }, + "abort": { + "single_instance_allowed": "Only a single instance is allowed." + } + }, + "options": { + "step": { + "user": { + "data": { + "binary_sensor": "Binary sensor enabled", + "sensor": "Sensor enabled", + "switch": "Switch enabled" + } + } + } + } +} diff --git a/custom_components/wellbeing/translations/fr.json b/custom_components/wellbeing/translations/fr.json new file mode 100644 index 0000000..d67ab08 --- /dev/null +++ b/custom_components/wellbeing/translations/fr.json @@ -0,0 +1,31 @@ +{ + "config": { + "step": { + "user": { + "title": "Wellbeing", + "description": "Si vous avez besoin d'aide pour la configuration, regardez ici: https://github.com/JohNan/wellbeing", + "data": { + "username": "Identifiant", + "password": "Mot de Passe" + } + } + }, + "error": { + "auth": "Identifiant ou mot de passe erroné." + }, + "abort": { + "single_instance_allowed": "Une seule instance est autorisée." + } + }, + "options": { + "step": { + "user": { + "data": { + "binary_sensor": "Capteur binaire activé", + "sensor": "Capteur activé", + "switch": "Interrupteur activé" + } + } + } + } +} diff --git a/custom_components/wellbeing/translations/nb.json b/custom_components/wellbeing/translations/nb.json new file mode 100644 index 0000000..a6ff6f7 --- /dev/null +++ b/custom_components/wellbeing/translations/nb.json @@ -0,0 +1,31 @@ +{ + "config": { + "step": { + "user": { + "title": "Wellbeing", + "description": "Hvis du trenger hjep til konfigurasjon ta en titt her: https://github.com/JohNan/wellbeing", + "data": { + "username": "Brukernavn", + "password": "Passord" + } + } + }, + "error": { + "auth": "Brukernavn/Passord er feil." + }, + "abort": { + "single_instance_allowed": "Denne integrasjonen kan kun konfigureres en gang." + } + }, + "options": { + "step": { + "user": { + "data": { + "binary_sensor": "Binær sensor aktivert", + "sensor": "Sensor aktivert", + "switch": "Bryter aktivert" + } + } + } + } +} diff --git a/hacs.json b/hacs.json new file mode 100644 index 0000000..74df02d --- /dev/null +++ b/hacs.json @@ -0,0 +1,7 @@ +{ + "name": "Electrolux Wellbeing", + "hacs": "1.6.0", + "domains": ["binary_sensor", "sensor", "fan"], + "iot_class": "Cloud Polling", + "homeassistant": "0.118.0" +} diff --git a/info.md b/info.md new file mode 100644 index 0000000..4ab4f7c --- /dev/null +++ b/info.md @@ -0,0 +1,35 @@ +# Electrolux Wellbeing + +[![GitHub Release][releases-shield]][releases] +[![License][license-shield]](LICENSE) + +[![hacs][hacsbadge]][hacs] +[![Project Maintenance][maintenance-shield]][user_profile] +[![BuyMeCoffee][buymecoffeebadge]][buymecoffee] + +Get the status from your Electrolux devices connected to Wellbeing. **Currently, only fetching of values are supported** + +### Supported and tested devices + +- Pure A9 Air Purifier + +{% if not installed %} + +## Installation + +1. Click install. +1. In the HA UI go to "Configuration" -> "Integrations" click "+" and search for "Wellbeing". + +{% endif %} + +[buymecoffee]: https://www.buymeacoffee.com/JohNan +[buymecoffeebadge]: https://img.shields.io/badge/buy%20me%20a%20coffee-donate-yellow.svg?style=for-the-badge +[commits-shield]: https://img.shields.io/github/commit-activity/y/JohNan/wellbeing.svg?style=for-the-badge +[commits]: https://github.com/JohNan/homeassistant-wellbeing/commits/main +[hacs]: https://hacs.xyz +[hacsbadge]: https://img.shields.io/badge/HACS-Custom-orange.svg?style=for-the-badge +[license-shield]: https://img.shields.io/github/license/JohNan/wellbeing.svg?style=for-the-badge +[maintenance-shield]: https://img.shields.io/badge/maintainer-%40JohNan-blue.svg?style=for-the-badge +[releases-shield]: https://img.shields.io/github/release/JohNan/wellbeing.svg?style=for-the-badge +[releases]: https://github.com/JohNan/homeassistant-wellbeing/releases +[user_profile]: https://github.com/JohNan diff --git a/requirements_dev.txt b/requirements_dev.txt new file mode 100644 index 0000000..36070b7 --- /dev/null +++ b/requirements_dev.txt @@ -0,0 +1,2 @@ +homeassistant +aiohttp==3.7.4 diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 0000000..747b84d --- /dev/null +++ b/setup.cfg @@ -0,0 +1,46 @@ +[flake8] +exclude = .venv,.git,.tox,docs,venv,bin,lib,deps,build +doctests = True +# To work with Black +max-line-length = 88 +# E501: line too long +# W503: Line break occurred before a binary operator +# E203: Whitespace before ':' +# D202 No blank lines allowed after function docstring +# W504 line break after binary operator +ignore = + E501, + W503, + E203, + D202, + W504 + +[isort] +# https://github.com/timothycrosley/isort +# https://github.com/timothycrosley/isort/wiki/isort-Settings +# splits long import on multiple lines indented by 4 spaces +multi_line_output = 3 +include_trailing_comma=True +force_grid_wrap=0 +use_parentheses=True +line_length=88 +indent = " " +# by default isort don't check module indexes +not_skip = __init__.py +# will group `import x` and `from x import` of the same module. +force_sort_within_sections = true +sections = FUTURE,STDLIB,INBETWEENS,THIRDPARTY,FIRSTPARTY,LOCALFOLDER +default_section = THIRDPARTY +known_first_party = custom_components.wellbeing, tests +combine_as_imports = true + +[tool:pytest] +addopts = -qq --cov=custom_components.wellbeing +console_output_style = count + +[coverage:run] +branch = False + +[coverage:report] +show_missing = true +fail_under = 100