From 8c207aba19286de09161302cdc3f2c4d6c7979b4 Mon Sep 17 00:00:00 2001 From: Erik Kastelec Date: Sat, 25 Feb 2023 17:12:48 +0100 Subject: [PATCH 01/26] Implemented persisting session for web scraping #53 --- custom_components/wemportal/const.py | 4 + custom_components/wemportal/wemportalapi.py | 151 ++++++++++++++------ 2 files changed, 114 insertions(+), 41 deletions(-) diff --git a/custom_components/wemportal/const.py b/custom_components/wemportal/const.py index 5d3e200..7f00768 100644 --- a/custom_components/wemportal/const.py +++ b/custom_components/wemportal/const.py @@ -12,3 +12,7 @@ CONF_MODE: Final = "mode" PLATFORMS = ["sensor", "number", "select", "switch"] REFRESH_WAIT_TIME: int = 360 +class AuthErrorFlag(Enum): + OK = 0 + WRONG_CREDENTIALS = 1 + SESSION_EXPIRED = 2 \ No newline at end of file diff --git a/custom_components/wemportal/wemportalapi.py b/custom_components/wemportal/wemportalapi.py index 0e88d65..e891624 100644 --- a/custom_components/wemportal/wemportalapi.py +++ b/custom_components/wemportal/wemportalapi.py @@ -13,9 +13,16 @@ import scrapyscript from fuzzywuzzy import fuzz from homeassistant.const import CONF_PASSWORD, CONF_SCAN_INTERVAL, CONF_USERNAME -from scrapy import FormRequest, Spider +from scrapy import FormRequest, Spider, Request -from .const import _LOGGER, CONF_LANGUAGE, CONF_MODE, CONF_SCAN_INTERVAL_API, START_URLS +from .const import ( + _LOGGER, + CONF_LANGUAGE, + CONF_MODE, + CONF_SCAN_INTERVAL_API, + START_URLS, + AuthErrorFlag, +) class WemPortalApi: @@ -35,6 +42,7 @@ def __init__(self, config): self.device_id = None self.session = None self.modules = None + self.webscraping_cookie = {} self.last_scraping_update = None # Headers used for all API calls self.headers = { @@ -65,7 +73,9 @@ def fetch_data(self): def fetch_webscraping_data(self): """Call spider to crawl WEM Portal""" - wemportal_job = scrapyscript.Job(WemPortalSpider, self.username, self.password) + wemportal_job = scrapyscript.Job( + WemPortalSpider, self.username, self.password, self.webscraping_cookie + ) processor = scrapyscript.Processor(settings=None) try: self.data = processor.run([wemportal_job])[0] @@ -75,13 +85,21 @@ def fetch_webscraping_data(self): "open an issue at https://github.com/erikkastelec/hass-WEM-Portal/issues" ) try: - if self.data["authErrorFlag"]: + # Wrong credentials + if self.data["authErrorFlag"] == AuthErrorFlag.WRONG_CREDENTIALS: _LOGGER.exception( "AuthenticationError: Could not login with provided username and password. Check if " "your config contains the right credentials" ) + self.webscraping_cookie = {} + # Session expired. + else: + self.webscraping_cookie = {} + pass except KeyError: """If authentication was successful""" + self.webscraping_cookie = self.data["cookie"] + del self.data["cookie"] pass def fetch_api_data(self): @@ -215,8 +233,6 @@ def change_value( ) if response.status_code != 200: _LOGGER.error("Error changing parameter %s value", parameter_id) - # _LOGGER.info("Error: %s", response.content) - # _LOGGER.info(response.__dict__) # Refresh data and retrieve new data def get_data(self): @@ -545,53 +561,106 @@ def translate(self, language, value): return out +from scrapy.core.downloader.handlers.http11 import HTTP11DownloadHandler + +START_URLS = ["https://www.wemportal.com/Web/login.aspx"] + + class WemPortalSpider(Spider): name = "WemPortalSpider" start_urls = START_URLS - + custom_settings = { - "USER_AGENT": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/109.0.0.0 Safari/537.36" + "USER_AGENT": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/109.0.0.0 Safari/537.36", + "LOG_LEVEL": "ERROR", } - - def __init__(self, username, password, **kw): + + def __init__(self, username, password, cookie=None, **kw): self.username = username self.password = password - self.authErrorFlag = False + self.authErrorFlag = 0 + self.cookie = cookie if cookie else {} + self.retry = None super().__init__(**kw) - def parse(self, response): - """Login to WEM Portal""" - return FormRequest.from_response( - response, - formdata={ - "ctl00$content$tbxUserName": self.username, - "ctl00$content$tbxPassword": self.password, - "Accept-Language": "en-US,en;q=0.5", - }, - callback=self.navigate_to_expert_page, - ) - - def navigate_to_expert_page(self, response): - # sleep for 5 seconds to get proper language and updated data - time.sleep(5) - # _LOGGER.debug("Print user page HTML: %s", response.text) + def start_requests(self): + + # Check if we have a cookie/session. Skip to main website if we do. + if ".ASPXAUTH" in self.cookie: + return [ + Request( + "https://www.wemportal.com/Web/Default.aspx", + headers={ + "Accept-Language": "en-US,en;q=0.5", + "Connection": "keep-alive", + }, + cookies=self.cookie, + callback=self.check_valid_login, + ) + ] + return [ + Request("https://www.wemportal.com/Web/Login.aspx", callback=self.login) + ] + + def login(self, response): + return [ + FormRequest.from_response( + response, + formdata={ + "ctl00$content$tbxUserName": self.username, + "ctl00$content$tbxPassword": self.password, + "Accept-Language": "en-US,en;q=0.5", + }, + callback=self.check_valid_login, + ) + ] + + # Extract cookies from response.request.headers and convert them to dict + def get_cookies(self, response): + cookies = {} + response_cookies = response.request.headers.getlist("Cookie") + if len(response_cookies) > 0: + for cookie_entry in response_cookies[0].decode("utf-8").split("; "): + parts = cookie_entry.split("=") + if len(parts) == 2: + cookie_name, cookie_value = parts + cookies[cookie_name] = cookie_value + self.cookie = cookies + + # Check if login was successful. If not, return authErrorFlag + def check_valid_login(self, response): if ( - response.url - == "https://www.wemportal.com/Web/login.aspx?AspxAutoDetectCookieSupport=1" + response.url.lower() == "https://www.wemportal.com/Web/Login.aspx".lower() + and not self.retry ): - _LOGGER.debug("Authentication failed") - self.authErrorFlag = True + # TODO: Implement retry logic. If session expires + self.authErrorFlag = 2 form_data = {} - else: - _LOGGER.debug("Authentication successful") - form_data = self.generate_form_data(response) - _LOGGER.debug("Form data processed") + return {"authErrorFlag": 2} + + elif ( + response.url.lower() + == "https://www.wemportal.com/Web/Login.aspx?AspxAutoDetectCookieSupport=1".lower() + or response.status != 200 + ): + self.authErrorFlag = 1 + form_data = {} + return {"authErrorFlag": 1} + + self.retry = None + # Wait 2 seconds so everything loads + time.sleep(2) + form_data = self.generate_form_data(response) + self.get_cookies(response) + cookie_string = ";".join( + [f"{key}={value}" for key, value in self.cookie.items()] + ) return FormRequest( url="https://www.wemportal.com/Web/default.aspx", formdata=form_data, headers={ "Content-Type": "application/x-www-form-urlencoded", - "Cookie": response.request.headers.getlist("Cookie")[0].decode("utf-8"), + "Cookie": cookie_string, "Accept-Language": "en-US,en;q=0.5", }, method="POST", @@ -627,10 +696,9 @@ def generate_form_data(self, response): def scrape_pages(self, response): # sleep for 5 seconds to get proper language and updated data time.sleep(5) - # _LOGGER.debug("Print expert page HTML: %s", response.text) - if self.authErrorFlag: - yield {"authErrorFlag": True} - _LOGGER.debug("Scraping page") + _LOGGER.debug("Print expert page HTML: %s", response.text) + if self.authErrorFlag != 0: + yield {"authErrorFlag": self.authErrorFlag} output = {} for i, div in enumerate( response.xpath( @@ -702,5 +770,6 @@ def scrape_pages(self, response): } except IndexError: continue - + output["cookie"] = self.cookie + self.data = output yield output From 0f73843df5a32246af329ebc9ee9563d0cbe20bb Mon Sep 17 00:00:00 2001 From: Erik Kastelec Date: Sat, 25 Feb 2023 19:07:35 +0100 Subject: [PATCH 02/26] Fixed missing import --- custom_components/wemportal/const.py | 1 + 1 file changed, 1 insertion(+) diff --git a/custom_components/wemportal/const.py b/custom_components/wemportal/const.py index 7f00768..48db2fb 100644 --- a/custom_components/wemportal/const.py +++ b/custom_components/wemportal/const.py @@ -1,6 +1,7 @@ """ Constants for the WEM Portal Integration """ import logging from typing import Final +from enum import Enum _LOGGER = logging.getLogger("custom_components.wemportal") DOMAIN = "wemportal" From e9dc8d226f65f934a73f231dffc045b175b6ca86 Mon Sep 17 00:00:00 2001 From: Erik Kastelec Date: Tue, 7 Mar 2023 20:29:58 +0100 Subject: [PATCH 03/26] Implemented configuration using GUI (config flow) and improved exception handling --- README.md | 24 +- custom_components/wemportal/__init__.py | 140 ++- custom_components/wemportal/config_flow.py | 129 +++ custom_components/wemportal/const.py | 8 +- custom_components/wemportal/coordinator.py | 13 +- custom_components/wemportal/exceptions.py | 27 + custom_components/wemportal/manifest.json | 6 +- custom_components/wemportal/number.py | 74 +- custom_components/wemportal/select.py | 64 +- custom_components/wemportal/sensor.py | 63 +- custom_components/wemportal/strings.json | 32 + custom_components/wemportal/switch.py | 62 +- .../wemportal/translations/en.json | 32 + custom_components/wemportal/wemportalapi.py | 805 ++++++++++-------- info.md | 13 +- 15 files changed, 1004 insertions(+), 488 deletions(-) create mode 100644 custom_components/wemportal/config_flow.py create mode 100644 custom_components/wemportal/exceptions.py create mode 100644 custom_components/wemportal/strings.json create mode 100644 custom_components/wemportal/translations/en.json diff --git a/README.md b/README.md index d33ab99..2c32324 100644 --- a/README.md +++ b/README.md @@ -27,17 +27,17 @@ This is how your custom_components directory should look like: custom_components ├── wemportal │ ├── __init__.py -│ ├── const.py -│ ├── manifest.json -│ ├── coordinator.py -│ ├── select.py -│ ├── number.py -│ ├── sensor.py +│ ├── ... +│ ├── ... +│ ├── ... │ └── wemportalapi.py ``` ## Configuration +Integration must be configured in Home Assistant frontend: Go to `Settings > Devices&Services `, click on ` Add integration ` button and search for `Weishaupt WEM Portal`. + + Configuration variables: - `username`: Email address used for logging into WEM Portal @@ -53,18 +53,6 @@ Configuration variables: mobile API. Option `web` gets only the data on the website, while option `both` queries website and api and provides all the available data from both sources. -Add the following to your `configuration.yaml` file: - -```yaml -# Example configuration.yaml entry -wemportal: - #scan_interval: 1800 - #api_scan_interval: 300 - #language: en - #mode: api - username: your_username - password: your_password -``` ## Troubleshooting Please set your logging for the custom_component to debug: diff --git a/custom_components/wemportal/__init__.py b/custom_components/wemportal/__init__.py index 0329b64..c5e24a6 100644 --- a/custom_components/wemportal/__init__.py +++ b/custom_components/wemportal/__init__.py @@ -12,59 +12,131 @@ from homeassistant.const import CONF_PASSWORD, CONF_SCAN_INTERVAL, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.helpers.typing import ConfigType - +from homeassistant.config_entries import ConfigEntry from .const import CONF_LANGUAGE, CONF_MODE, CONF_SCAN_INTERVAL_API, DOMAIN, PLATFORMS from .coordinator import WemPortalDataUpdateCoordinator from .wemportalapi import WemPortalApi -CONFIG_SCHEMA = vol.Schema( - { - DOMAIN: vol.Schema( - { - vol.Optional( - CONF_SCAN_INTERVAL, default=timedelta(minutes=30) - ): config_validation.time_period, - vol.Optional( - CONF_SCAN_INTERVAL_API, default=timedelta(minutes=5) - ): config_validation.time_period, - vol.Optional(CONF_LANGUAGE, default="en"): config_validation.string, - vol.Optional(CONF_MODE, default="api"): config_validation.string, - vol.Required(CONF_USERNAME): config_validation.string, - vol.Required(CONF_PASSWORD): config_validation.string, - } - ) - }, - extra=vol.ALLOW_EXTRA, -) + +# CONFIG_SCHEMA = vol.Schema( +# { +# DOMAIN: vol.Schema( +# { +# vol.Optional( +# CONF_SCAN_INTERVAL, default=timedelta(minutes=30) +# ): config_validation.time_period, +# vol.Optional( +# CONF_SCAN_INTERVAL_API, default=timedelta(minutes=5) +# ): config_validation.time_period, +# vol.Optional(CONF_LANGUAGE, default="en"): config_validation.string, +# vol.Optional(CONF_MODE, default="api"): config_validation.string, +# vol.Required(CONF_USERNAME): config_validation.string, +# vol.Required(CONF_PASSWORD): config_validation.string, +# } +# ) +# }, +# extra=vol.ALLOW_EXTRA, +# ) + + +def get_wemportal_unique_id(config_entry_id: str, device_id: str, name: str): + """Return unique ID for WEM Portal.""" + return f"{config_entry_id}:{device_id}:{name}" async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up the wemportal component.""" + hass.data.setdefault(DOMAIN, {}) + return True + + +# # Set proper update_interval, based on selected mode +# if config[DOMAIN].get(CONF_MODE) == "web": +# update_interval = config[DOMAIN].get(CONF_SCAN_INTERVAL) + +# elif config[DOMAIN].get(CONF_MODE) == "api": +# update_interval = config[DOMAIN].get(CONF_SCAN_INTERVAL_API) +# else: +# update_interval = min( +# config[DOMAIN].get(CONF_SCAN_INTERVAL), +# config[DOMAIN].get(CONF_SCAN_INTERVAL_API), +# ) +# # Creatie API object +# api = WemPortalApi(config[DOMAIN]) +# # Create custom coordinator +# coordinator = WemPortalDataUpdateCoordinator(hass, api, update_interval) + +# hass.data[DOMAIN] = { +# "api": api, +# "coordinator": coordinator, +# } + +# await coordinator.async_config_entry_first_refresh() + +# # Initialize platforms +# for platform in PLATFORMS: +# hass.helpers.discovery.load_platform(platform, DOMAIN, {}, config) +# return True + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up the wemportal component.""" # Set proper update_interval, based on selected mode - if config[DOMAIN].get(CONF_MODE) == "web": - update_interval = config[DOMAIN].get(CONF_SCAN_INTERVAL) + if entry.data.get(CONF_MODE) == "web": + update_interval = entry.data.get(CONF_SCAN_INTERVAL) - elif config[DOMAIN].get(CONF_MODE) == "api": - update_interval = config[DOMAIN].get(CONF_SCAN_INTERVAL_API) + elif entry.data.get(CONF_MODE) == "api": + update_interval = entry.data.get(CONF_SCAN_INTERVAL_API) else: update_interval = min( - config[DOMAIN].get(CONF_SCAN_INTERVAL), - config[DOMAIN].get(CONF_SCAN_INTERVAL_API), + entry.data.get(CONF_SCAN_INTERVAL), + entry.data.get(CONF_SCAN_INTERVAL_API), ) # Creatie API object - api = WemPortalApi(config[DOMAIN]) + api = WemPortalApi(entry.data) # Create custom coordinator - coordinator = WemPortalDataUpdateCoordinator(hass, api, update_interval) + coordinator = WemPortalDataUpdateCoordinator( + hass, api, timedelta(seconds=update_interval) + ) + + await coordinator.async_config_entry_first_refresh() - hass.data[DOMAIN] = { + hass.data[DOMAIN][entry.entry_id] = { "api": api, - "config": config[DOMAIN], + # "config": entry.data, "coordinator": coordinator, } - await coordinator.async_config_entry_first_refresh() + # TODO: Implement removal of outdated entries + # current_devices: set[tuple[str, str]] = set({(DOMAIN, entry.entry_id)}) + + # device_registry = dr.async_get(hass) + # for device_entry in dr.async_entries_for_config_entry( + # device_registry, entry.entry_id + # ): + # for identifier in device_entry.identifiers: + # if identifier in current_devices: + # break + # else: + # device_registry.async_remove_device(device_entry.id) + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + entry.async_on_unload(entry.add_update_listener(_async_entry_updated)) - # Initialize platforms - for platform in PLATFORMS: - hass.helpers.discovery.load_platform(platform, DOMAIN, {}, config) return True + + +async def _async_entry_updated(hass: HomeAssistant, config_entry: ConfigEntry) -> None: + """Handle entry updates.""" + await hass.config_entries.async_reload(config_entry.entry_id) + + +async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: + """Handle removal of an entry.""" + unload_ok = bool( + await hass.config_entries.async_unload_platforms(config_entry, PLATFORMS) + ) + if unload_ok: + hass.data[DOMAIN].pop(config_entry.entry_id) + + return unload_ok diff --git a/custom_components/wemportal/config_flow.py b/custom_components/wemportal/config_flow.py new file mode 100644 index 0000000..6cab6c2 --- /dev/null +++ b/custom_components/wemportal/config_flow.py @@ -0,0 +1,129 @@ +"""Config flow for wemportal integration.""" +from __future__ import annotations + +import logging + +import voluptuous as vol + +from homeassistant import config_entries, core, exceptions +from homeassistant.const import CONF_PASSWORD, CONF_SCAN_INTERVAL, CONF_USERNAME +from homeassistant.core import callback +import homeassistant.helpers.config_validation as config_validation +from .wemportalapi import WemPortalApi +from .const import ( + DOMAIN, + CONF_LANGUAGE, + CONF_MODE, + CONF_SCAN_INTERVAL_API, +) +from .exceptions import AuthError, UnknownAuthError + +_LOGGER = logging.getLogger(__name__) + +DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_USERNAME): str, + vol.Required(CONF_PASSWORD): str, + } +) + + +async def validate_input(hass: core.HomeAssistant, data): + """Validate the user input allows us to connect.""" + og_data = data + # Populate with default values + # Should be fixed, but for now, this should work. + data[CONF_MODE] = "api" + data[CONF_LANGUAGE] = "en" + data[CONF_SCAN_INTERVAL_API] = 300 + data[CONF_SCAN_INTERVAL] = 1800 + + # Create API object + api = WemPortalApi(data) + + # Try to login + try: + await hass.async_add_executor_job(api.api_login) + except AuthError: + raise InvalidAuth from AuthError + except UnknownAuthError: + raise CannotConnect from UnknownAuthError + + return og_data + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for kmtronic.""" + + VERSION = 1 + + @staticmethod + @callback + def async_get_options_flow( + config_entry: config_entries.ConfigEntry, + ) -> WemportalOptionsFlow: + """Get the options flow for this handler.""" + return WemportalOptionsFlow(config_entry) + + async def async_step_user(self, user_input=None): + """Handle the initial step.""" + errors = {} + if user_input is not None: + try: + info = await validate_input(self.hass, user_input) + for existing_entry in self._async_current_entries(include_ignore=False): + if existing_entry.data[CONF_USERNAME] == user_input[CONF_USERNAME]: + return self.async_abort(reason="already_configured") + + return self.async_create_entry( + title=info[CONF_USERNAME], data=user_input + ) + + except CannotConnect: + errors["base"] = "cannot_connect" + except InvalidAuth: + errors["base"] = "invalid_auth" + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + + return self.async_show_form( + step_id="user", data_schema=DATA_SCHEMA, errors=errors + ) + + +class CannotConnect(exceptions.HomeAssistantError): + """Error to indicate we cannot connect.""" + + +class InvalidAuth(exceptions.HomeAssistantError): + """Error to indicate there is invalid auth.""" + + +class WemportalOptionsFlow(config_entries.OptionsFlow): + """Handle options.""" + + def __init__(self, config_entry: config_entries.ConfigEntry) -> None: + """Initialize options flow.""" + self.config_entry = config_entry + + async def async_step_init(self, user_input=None): + """Manage the options.""" + if user_input is not None: + return self.async_create_entry(title="", data=user_input) + + return self.async_show_form( + step_id="init", + data_schema=vol.Schema( + { + vol.Optional( + CONF_SCAN_INTERVAL, default=1800 + ): config_validation.positive_int, + vol.Optional( + CONF_SCAN_INTERVAL_API, default=300 + ): config_validation.positive_int, + vol.Optional(CONF_LANGUAGE, default="en"): config_validation.string, + vol.Optional(CONF_MODE, default="api"): config_validation.string, + } + ), + ) diff --git a/custom_components/wemportal/const.py b/custom_components/wemportal/const.py index 48db2fb..06913b3 100644 --- a/custom_components/wemportal/const.py +++ b/custom_components/wemportal/const.py @@ -1,7 +1,6 @@ """ Constants for the WEM Portal Integration """ import logging from typing import Final -from enum import Enum _LOGGER = logging.getLogger("custom_components.wemportal") DOMAIN = "wemportal" @@ -13,7 +12,6 @@ CONF_MODE: Final = "mode" PLATFORMS = ["sensor", "number", "select", "switch"] REFRESH_WAIT_TIME: int = 360 -class AuthErrorFlag(Enum): - OK = 0 - WRONG_CREDENTIALS = 1 - SESSION_EXPIRED = 2 \ No newline at end of file +DATA_GATHERING_ERROR: Final = "An error occurred while gathering parameters data. \ + This issue should resolve by itself. If this problem persists, \ + open an issue at https://github.com/erikkastelec/hass-WEM-Portal/issues " diff --git a/custom_components/wemportal/coordinator.py b/custom_components/wemportal/coordinator.py index 3599477..a60d8f8 100644 --- a/custom_components/wemportal/coordinator.py +++ b/custom_components/wemportal/coordinator.py @@ -3,8 +3,11 @@ import async_timeout from homeassistant.core import HomeAssistant -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator - +from homeassistant.helpers.update_coordinator import ( + DataUpdateCoordinator, + UpdateFailed, +) +from .exceptions import WemPortalError from .const import _LOGGER, DEFAULT_TIMEOUT from .wemportalapi import WemPortalApi @@ -26,4 +29,8 @@ def __init__(self, hass: HomeAssistant, api: WemPortalApi, update_interval): async def _async_update_data(self): """Fetch data from the wemportal api""" async with async_timeout.timeout(DEFAULT_TIMEOUT): - return await self.hass.async_add_executor_job(self.api.fetch_data) + try: + return await self.hass.async_add_executor_job(self.api.fetch_data) + except WemPortalError as exc: + _LOGGER.error("Error fetching data from wemportal", exc_info=exc) + raise UpdateFailed from exc diff --git a/custom_components/wemportal/exceptions.py b/custom_components/wemportal/exceptions.py new file mode 100644 index 0000000..65b82c3 --- /dev/null +++ b/custom_components/wemportal/exceptions.py @@ -0,0 +1,27 @@ +import requests as reqs + + +class AuthError(reqs.HTTPError): + """Exception to indicate an authentication error.""" + + +class UnknownAuthError(reqs.HTTPError): + """Exception to indicate an unknown authentication error.""" + + +class WemPortalError(Exception): + """ + Custom exception for WEM Portal errors + """ + + +class ExpiredSessionError(WemPortalError): + """ + Custom exception for expired session errors + """ + + +class ParameterChangeError(WemPortalError): + """ + Custom exception for parameter change errors + """ diff --git a/custom_components/wemportal/manifest.json b/custom_components/wemportal/manifest.json index 4f0f4c3..86c544c 100644 --- a/custom_components/wemportal/manifest.json +++ b/custom_components/wemportal/manifest.json @@ -4,7 +4,7 @@ "documentation": "https://github.com/erikkastelec/hass-WEM-Portal", "issue_tracker": "https://github.com/erikkastelec/hass-WEM-Portal/issues", "dependencies": [], - "version": "1.4.3", + "version": "1.5.0", "codeowners": [ "@erikkastelec" ], @@ -14,5 +14,7 @@ "fuzzywuzzy==0.18.0", "twisted<=22.9.0" ], - "iot_class": "cloud_polling" + "loggers": ["scrapy"], + "iot_class": "cloud_polling", + "config_flow": true } diff --git a/custom_components/wemportal/number.py b/custom_components/wemportal/number.py index cc3660c..5c1d4c1 100644 --- a/custom_components/wemportal/number.py +++ b/custom_components/wemportal/number.py @@ -7,24 +7,51 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity - +from . import get_wemportal_unique_id from .const import _LOGGER, DOMAIN +from homeassistant.helpers.entity import DeviceInfo async def async_setup_platform( - hass: HomeAssistant, - config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, - discovery_info=None, + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, + discovery_info=None, ): - """Setup the Wem Portal sensors.""" + """Setup the Wem Portal number.""" coordinator = hass.data[DOMAIN]["coordinator"] entities: list[WemPortalNumber] = [] - for unique_id, values in coordinator.data.items(): - if values["platform"] == "number": - entities.append(WemPortalNumber(coordinator, unique_id, values)) + for device_id, entity_data in coordinator.data.items(): + for unique_id, values in entity_data.items(): + if values["platform"] == "number": + entities.append( + WemPortalNumber( + coordinator, config_entry, device_id, unique_id, values + ) + ) + + async_add_entities(entities) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Number entry setup.""" + + coordinator = hass.data[DOMAIN][config_entry.entry_id]["coordinator"] + entities: list[WemPortalNumber] = [] + for device_id, entity_data in coordinator.data.items(): + for unique_id, values in entity_data.items(): + if values["platform"] == "number": + entities.append( + WemPortalNumber( + coordinator, config_entry, device_id, unique_id, values + ) + ) async_add_entities(entities) @@ -32,13 +59,19 @@ async def async_setup_platform( class WemPortalNumber(CoordinatorEntity, NumberEntity): """Representation of a WEM Portal number.""" - def __init__(self, coordinator, _unique_id, entity_data): + def __init__( + self, coordinator, config_entry: ConfigEntry, device_id, _unique_id, entity_data + ): """Initialize the sensor.""" super().__init__(coordinator) - self._last_updated = None + self._config_entry = config_entry + self._device_id = device_id self._name = _unique_id + self._unique_id = get_wemportal_unique_id( + self._config_entry.entry_id, str(self._device_id), str(self._name) + ) + self._last_updated = None self._parameter_id = entity_data["ParameterID"] - self._unique_id = _unique_id self._icon = entity_data["icon"] self._unit = entity_data["unit"] self._state = self.state @@ -52,15 +85,28 @@ async def async_set_native_value(self, value: float) -> None: """Update the current value.""" await self.hass.async_add_executor_job( self.coordinator.api.change_value, + self._device_id, self._parameter_id, self._module_index, self._module_type, value, ) self._state = value - self.coordinator.data[self._unique_id]["value"] = value + self.coordinator.data[self._device_id][self._name]["value"] = value self.async_write_ha_state() + @property + def device_info(self) -> DeviceInfo: + """Get device information.""" + return { + "identifiers": { + (DOMAIN, f"{self._config_entry.entry_id}:{str(self._device_id)}") + }, + "via_device": (DOMAIN, self._config_entry.entry_id), + "name": str(self._device_id), + "manufacturer": "Weishaupt", + } + @property def should_poll(self): """No need to poll. Coordinator notifies entity of updates.""" @@ -98,7 +144,7 @@ def icon(self): def state(self): """Return the state of the sensor.""" try: - state = self.coordinator.data[self._unique_id]["value"] + state = self.coordinator.data[self._device_id][self._name]["value"] if state: return state return 0 diff --git a/custom_components/wemportal/select.py b/custom_components/wemportal/select.py index 8937f09..984bf20 100644 --- a/custom_components/wemportal/select.py +++ b/custom_components/wemportal/select.py @@ -5,10 +5,12 @@ from homeassistant.components.select import SelectEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import _LOGGER, DOMAIN +from .const import DOMAIN +from . import get_wemportal_unique_id async def async_setup_platform( @@ -22,9 +24,35 @@ async def async_setup_platform( coordinator = hass.data[DOMAIN]["coordinator"] entities: list[WemPortalSelect] = [] - for unique_id, values in coordinator.data.items(): - if values["platform"] == "select": - entities.append(WemPortalSelect(coordinator, unique_id, values)) + for device_id, entity_data in coordinator.data.items(): + for unique_id, values in entity_data.items(): + if values["platform"] == "select": + entities.append( + WemPortalSelect( + coordinator, config_entry, device_id, unique_id, values + ) + ) + + async_add_entities(entities) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Select entry setup.""" + + coordinator = hass.data[DOMAIN][config_entry.entry_id]["coordinator"] + entities: list[WemPortalSelect] = [] + for device_id, entity_data in coordinator.data.items(): + for unique_id, values in entity_data.items(): + if values["platform"] == "select": + entities.append( + WemPortalSelect( + coordinator, config_entry, device_id, unique_id, values + ) + ) async_add_entities(entities) @@ -32,13 +60,19 @@ async def async_setup_platform( class WemPortalSelect(CoordinatorEntity, SelectEntity): """Representation of a WEM Portal Sensor.""" - def __init__(self, coordinator, _unique_id, entity_data): + def __init__( + self, coordinator, config_entry: ConfigEntry, device_id, _unique_id, entity_data + ): """Initialize the sensor.""" super().__init__(coordinator) self._last_updated = None + self._config_entry = config_entry + self._device_id = device_id self._name = _unique_id + self._unique_id = get_wemportal_unique_id( + self._config_entry.entry_id, str(self._device_id), str(self._name) + ) self._parameter_id = entity_data["ParameterID"] - self._unique_id = _unique_id self._icon = entity_data["icon"] self._options = entity_data["options"] self._options_names = entity_data["optionsNames"] @@ -55,12 +89,24 @@ async def async_select_option(self, option: str) -> None: self._options[self._options_names.index(option)], ) - self.coordinator.data[self._unique_id]["value"] = self._options[ + self.coordinator.data[self._device_id][self._name]["value"] = self._options[ self._options_names.index(option) ] self.async_write_ha_state() + @property + def device_info(self) -> DeviceInfo: + """Get device information.""" + return { + "identifiers": { + (DOMAIN, f"{self._config_entry.entry_id}:{str(self._device_id)}") + }, + "via_device": (DOMAIN, self._config_entry.entry_id), + "name": str(self._device_id), + "manufacturer": "Weishaupt", + } + @property def options(self) -> list[str]: """Return list of available options.""" @@ -70,7 +116,9 @@ def options(self) -> list[str]: def current_option(self) -> str: """Return the current option.""" return self._options_names[ - self._options.index(self.coordinator.data[self._unique_id]["value"]) + self._options.index( + self.coordinator.data[self._device_id][self._name]["value"] + ) ] async def async_added_to_hass(self): diff --git a/custom_components/wemportal/sensor.py b/custom_components/wemportal/sensor.py index 549913c..095ce85 100644 --- a/custom_components/wemportal/sensor.py +++ b/custom_components/wemportal/sensor.py @@ -10,10 +10,12 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import _LOGGER, DOMAIN +from . import get_wemportal_unique_id async def async_setup_platform( @@ -22,32 +24,77 @@ async def async_setup_platform( async_add_entities: AddEntitiesCallback, discovery_info=None, ): - """Setup the Wem Portal sensors.""" + """Setup the Wem Portal sensor.""" coordinator = hass.data[DOMAIN]["coordinator"] - entities: list[WemPortalSensor] = [] - for unique_id, values in coordinator.data.items(): - if values["platform"] == "sensor": - entities.append(WemPortalSensor(coordinator, unique_id, values)) + for device_id, entity_data in coordinator.data.items(): + for unique_id, values in entity_data.items(): + if values["platform"] == "sensor": + entities.append( + WemPortalSensor( + coordinator, config_entry, device_id, unique_id, values + ) + ) async_add_entities(entities) +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Sensor entry setup.""" + + coordinator = hass.data[DOMAIN][config_entry.entry_id]["coordinator"] + entities: list[WemPortalSensor] = [] + for device_id, entity_data in coordinator.data.items(): + for unique_id, values in entity_data.items(): + if values["platform"] == "sensor": + entities.append( + WemPortalSensor( + coordinator, config_entry, device_id, unique_id, values + ) + ) + try: + async_add_entities(entities) + except Exception as e: + print(e) + + class WemPortalSensor(CoordinatorEntity, SensorEntity): """Representation of a WEM Portal Sensor.""" - def __init__(self, coordinator, _unique_id, entity_data): + def __init__( + self, coordinator, config_entry: ConfigEntry, device_id, _unique_id, entity_data + ): """Initialize the sensor.""" super().__init__(coordinator) self._last_updated = None + self._config_entry = config_entry + self._device_id = device_id self._name = _unique_id - self._unique_id = _unique_id + self._unique_id = get_wemportal_unique_id( + self._config_entry.entry_id, str(self._device_id), str(self._name) + ) self._parameter_id = entity_data["ParameterID"] self._icon = entity_data["icon"] self._unit = entity_data["unit"] self._state = self.state + @property + def device_info(self) -> DeviceInfo: + """Get device information.""" + return { + "identifiers": { + (DOMAIN, f"{self._config_entry.entry_id}:{str(self._device_id)}") + }, + "via_device": (DOMAIN, self._config_entry.entry_id), + "name": str(self._device_id), + "manufacturer": "Weishaupt", + } + @property def should_poll(self): """No need to poll. Coordinator notifies entity of updates.""" @@ -85,7 +132,7 @@ def icon(self): def state(self): """Return the state of the sensor.""" try: - state = self.coordinator.data[self._unique_id]["value"] + state = self.coordinator.data[self._device_id][self._name]["value"] if state: return state return 0 diff --git a/custom_components/wemportal/strings.json b/custom_components/wemportal/strings.json new file mode 100644 index 0000000..c0c7c7c --- /dev/null +++ b/custom_components/wemportal/strings.json @@ -0,0 +1,32 @@ +{ + "config": { + "step": { + "user": { + "data": { + "username": "[%key:common::config_flow::data::username%]", + "password": "[%key:common::config_flow::data::password%]" + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + } + }, + "options": { + "step": { + "init": { + "data": { + "scan_interval": "Web scraping interval (default = 1800 sec)%]", + "api_scan_interval": "Api scan interval (default = 300 sec)%]", + "language": "Language (default = en)", + "Mono": "[%key:common::config_flow::data::modes%](default = 'api')" + } + } + } + } + } \ No newline at end of file diff --git a/custom_components/wemportal/switch.py b/custom_components/wemportal/switch.py index e818218..f50e41c 100644 --- a/custom_components/wemportal/switch.py +++ b/custom_components/wemportal/switch.py @@ -5,10 +5,12 @@ from homeassistant.components.switch import SwitchEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import _LOGGER, DOMAIN +from . import get_wemportal_unique_id async def async_setup_platform( @@ -20,11 +22,36 @@ async def async_setup_platform( """Setup the Wem Portal select.""" coordinator = hass.data[DOMAIN]["coordinator"] + entities: list[WemPortalSwitch] = [] + for device_id, entity_data in coordinator.data.items(): + for unique_id, values in entity_data.items(): + if values["platform"] == "switch": + entities.append( + WemPortalSwitch( + coordinator, config_entry, device_id, unique_id, values + ) + ) + + async_add_entities(entities) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Switch entry setup.""" + coordinator = hass.data[DOMAIN][config_entry.entry_id]["coordinator"] entities: list[WemPortalSwitch] = [] - for unique_id, values in coordinator.data.items(): - if values["platform"] == "switch": - entities.append(WemPortalSwitch(coordinator, unique_id, values)) + for device_id, entity_data in coordinator.data.items(): + for unique_id, values in entity_data.items(): + if values["platform"] == "switch": + entities.append( + WemPortalSwitch( + coordinator, config_entry, device_id, unique_id, values + ) + ) async_add_entities(entities) @@ -32,34 +59,55 @@ async def async_setup_platform( class WemPortalSwitch(CoordinatorEntity, SwitchEntity): """Representation of a WEM Portal Sensor.""" - def __init__(self, coordinator, _unique_id, entity_data): + def __init__( + self, coordinator, config_entry: ConfigEntry, device_id, _unique_id, entity_data + ): """Initialize the sensor.""" super().__init__(coordinator) self._last_updated = None + self._config_entry = config_entry + self._device_id = device_id + self._name = _unique_id + self._unique_id = get_wemportal_unique_id( + self._config_entry.entry_id, str(self._device_id), str(self._name) + ) self._name = _unique_id self._parameter_id = entity_data["ParameterID"] - self._unique_id = _unique_id self._icon = entity_data["icon"] self._unit = entity_data["unit"] self._state = self.state self._module_index = entity_data["ModuleIndex"] self._module_type = entity_data["ModuleType"] + @property + def device_info(self) -> DeviceInfo: + """Get device information.""" + return { + "identifiers": { + (DOMAIN, f"{self._config_entry.entry_id}:{str(self._device_id)}") + }, + "via_device": (DOMAIN, self._config_entry.entry_id), + "name": str(self._device_id), + "manufacturer": "Weishaupt", + } + async def async_turn_on(self, **kwargs) -> None: await self.hass.async_add_executor_job( self.coordinator.api.change_value, + self._device_id, self._parameter_id, self._module_index, self._module_type, 1.0, ) self._state = "on" - self.coordinator.data[self._unique_id]["value"] = 1 + self.coordinator.data[self._device_id][self._name]["value"] = 1 self.async_write_ha_state() async def async_turn_off(self, **kwargs) -> None: await self.hass.async_add_executor_job( self.coordinator.api.change_value, + self._device_id, self._parameter_id, self._module_index, self._module_type, @@ -114,7 +162,7 @@ def icon(self): def state(self): """Return the state of the sensor.""" try: - self._state = self.coordinator.data[self._unique_id]["value"] + self._state = self.coordinator.data[self._device_id][self._name]["value"] try: if int(self._state) == 1: return "on" diff --git a/custom_components/wemportal/translations/en.json b/custom_components/wemportal/translations/en.json new file mode 100644 index 0000000..8c2ade1 --- /dev/null +++ b/custom_components/wemportal/translations/en.json @@ -0,0 +1,32 @@ +{ + "config": { + "step": { + "user": { + "data": { + "username": "Username (email)", + "password": "Password" + } + } + }, + "error": { + "cannot_connect": "Failed to connect", + "invalid_auth": "Invalid authentication", + "unknown": "Unexpected error" + }, + "abort": { + "already_configured": "Acconut is already configured" + } + }, + "options": { + "step": { + "init": { + "data": { + "scan_interval": "Web scraping interval (default = 1800 sec)%]", + "api_scan_interval": "Api scan interval (default = 300 sec)%]", + "language": "Language (default = en)", + "mode": "Mode(default = 'api')" + } + } + } + } + } \ No newline at end of file diff --git a/custom_components/wemportal/wemportalapi.py b/custom_components/wemportal/wemportalapi.py index e891624..e94026e 100644 --- a/custom_components/wemportal/wemportalapi.py +++ b/custom_components/wemportal/wemportalapi.py @@ -14,6 +14,13 @@ from fuzzywuzzy import fuzz from homeassistant.const import CONF_PASSWORD, CONF_SCAN_INTERVAL, CONF_USERNAME from scrapy import FormRequest, Spider, Request +from .exceptions import ( + AuthError, + UnknownAuthError, + WemPortalError, + ExpiredSessionError, + ParameterChangeError, +) from .const import ( _LOGGER, @@ -21,7 +28,7 @@ CONF_MODE, CONF_SCAN_INTERVAL_API, START_URLS, - AuthErrorFlag, + DATA_GATHERING_ERROR, ) @@ -30,16 +37,17 @@ class WemPortalApi: def __init__(self, config): self.data = {} - self.mode = config.get(CONF_MODE) self.username = config.get(CONF_USERNAME) self.password = config.get(CONF_PASSWORD) - self.update_interval = min( - config.get(CONF_SCAN_INTERVAL), config.get(CONF_SCAN_INTERVAL_API) + self.mode = config.get(CONF_MODE) + self.update_interval = timedelta( + seconds=min( + config.get(CONF_SCAN_INTERVAL), config.get(CONF_SCAN_INTERVAL_API) + ) ) - self.scan_interval = config.get(CONF_SCAN_INTERVAL) - self.scan_interval_api = config.get(CONF_SCAN_INTERVAL_API) + self.scan_interval = timedelta(seconds=config.get(CONF_SCAN_INTERVAL)) + self.scan_interval_api = timedelta(seconds=config.get(CONF_SCAN_INTERVAL_API)) self.language = config.get(CONF_LANGUAGE) - self.device_id = None self.session = None self.modules = None self.webscraping_cookie = {} @@ -53,23 +61,37 @@ def __init__(self, config): self.scrapingMapper = {} def fetch_data(self): - - if self.mode == "web": - self.fetch_webscraping_data() - elif self.mode == "api": - self.fetch_api_data() - else: - if ( - self.last_scraping_update is None - or (datetime.now() - self.last_scraping_update + timedelta(seconds=10)) - > self.scan_interval - ): + try: + # Login and get device info + if self.session is None: + self.api_login() + if len(self.data.keys()) == 0 or self.modules is None: + self.get_devices() + self.get_parameters() + if self.mode == "web": self.fetch_webscraping_data() - self.fetch_api_data() - self.last_scraping_update = datetime.now() + elif self.mode == "api": + self.get_data() else: - self.fetch_api_data() - return self.data + if ( + self.last_scraping_update is None + or ( + datetime.now() + - self.last_scraping_update + + timedelta(seconds=10) + ) + > self.scan_interval + ): + webscrapint_data = self.fetch_webscraping_data() + self.data[next(iter(self.data), "0000")] = webscrapint_data + self.get_data() + + self.last_scraping_update = datetime.now() + else: + self.get_data() + return self.data + except Exception as exc: + raise WemPortalError from exc def fetch_webscraping_data(self): """Call spider to crawl WEM Portal""" @@ -78,39 +100,28 @@ def fetch_webscraping_data(self): ) processor = scrapyscript.Processor(settings=None) try: - self.data = processor.run([wemportal_job])[0] - except IndexError: - _LOGGER.exception( + data = processor.run([wemportal_job])[0] + except IndexError as exc: + raise WemPortalError( "There was a problem with getting data from WEM Portal. If this problem persists, " "open an issue at https://github.com/erikkastelec/hass-WEM-Portal/issues" - ) + ) from exc + except AuthError as exc: + self.webscraping_cookie = {} + raise AuthError( + "AuthenticationError: Could not login with provided username and password. Check if your config contains the right credentials" + ) from exc + except ExpiredSessionError as exc: + self.webscraping_cookie = {} + raise ExpiredSessionError( + "ExpiredSessionError: Session expired. Next update will try to login again." + ) from exc try: - # Wrong credentials - if self.data["authErrorFlag"] == AuthErrorFlag.WRONG_CREDENTIALS: - _LOGGER.exception( - "AuthenticationError: Could not login with provided username and password. Check if " - "your config contains the right credentials" - ) - self.webscraping_cookie = {} - # Session expired. - else: - self.webscraping_cookie = {} - pass + self.webscraping_cookie = data["cookie"] + del data["cookie"] except KeyError: - """If authentication was successful""" - self.webscraping_cookie = self.data["cookie"] - del self.data["cookie"] pass - - def fetch_api_data(self): - """Get data from the mobile API""" - if self.session is None: - self.api_login() - if self.device_id is None or self.modules is None: - self.get_devices() - self.get_parameters() - self.get_data() - return self.data + return data def api_login(self): @@ -124,87 +135,115 @@ def api_login(self): self.session = reqs.Session() self.session.cookies.clear() self.session.headers.update(self.headers) - response = self.session.post( - "https://www.wemportal.com/app/Account/Login", - data=payload, - ) - if response.status_code != 200: - _LOGGER.error( - "Authentication Error: Check if your login credentials are correct. Receive response code: %s, response: %s", - str(response.status_code), - str(response.content), + try: + response = self.session.post( + "https://www.wemportal.com/app/Account/Login", + data=payload, ) + response.raise_for_status() + except reqs.exceptions.HTTPError as exc: + if response.status_code == 400: + raise AuthError( + f"Authentication Error: Check if your login credentials are correct. Received response code: {response.status_code}, response: {response.content}" + ) from exc + else: + raise UnknownAuthError( + f"Authentication Error: Encountered an unknown authentication error. Received response code: {response.status_code}, response: {response.content}" + ) from exc + + def make_api_call(self, url: str, headers=None, data=None) -> reqs.Response: + try: + if not headers: + headers = self.headers + if not data: + response = self.session.get(url, headers=headers) + else: + headers["Content-Type"] = "application/json" + response = self.session.post( + url, headers=headers, data=json.dumps(data) + ) + response.raise_for_status() + except reqs.exceptions.HTTPError as exc: + if response.status_code == 401: + self.api_login() + if not headers: + headers = self.headers + if not data: + response = self.session.get(url, headers=headers) + else: + headers["Content-Type"] = "application/json" + response = self.session.post( + url, headers=headers, data=json.dumps(data) + ) + response.raise_for_status() + else: + raise WemPortalError(DATA_GATHERING_ERROR) from exc + except Exception as exc: + raise WemPortalError(DATA_GATHERING_ERROR) from exc + return response def get_devices(self): _LOGGER.debug("Fetching api device data") self.modules = {} - response = self.session.get( - "https://www.wemportal.com/app/device/Read", - ) - # If session expired - if response.status_code == 401: - self.api_login() - response = self.session.get( - "https://www.wemportal.com/app/device/Read", - ) - - data = response.json() - # TODO: add multiple device support - self.device_id = data["Devices"][0]["ID"] - for module in data["Devices"][0]["Modules"]: - self.modules[(module["Index"], module["Type"])] = { - "Index": module["Index"], - "Type": module["Type"], - "Name": module["Name"], - } + data = self.make_api_call("https://www.wemportal.com/app/device/Read").json() + + for device in data["Devices"]: + self.data[device["ID"]] = {} + self.modules[device["ID"]] = {} + for module in device["Modules"]: + self.modules[device["ID"]][(module["Index"], module["Type"])] = { + "Index": module["Index"], + "Type": module["Type"], + "Name": module["Name"], + } def get_parameters(self): - _LOGGER.debug("Fetching api parameters data") - delete_candidates = [] - for key, values in self.modules.items(): - data = { - "DeviceID": self.device_id, - "ModuleIndex": values["Index"], - "ModuleType": values["Type"], - } - response = self.session.post( - "https://www.wemportal.com/app/EventType/Read", - data=data, - ) - - # If session expired - if response.status_code == 401: - self.api_login() - response = self.session.post( - "https://www.wemportal.com/app/EventType/Read", - data=data, - ) - parameters = {} - try: - for parameter in response.json()["Parameters"]: - parameters[parameter["ParameterID"]] = parameter - if not parameters: - delete_candidates.append((values["Index"], values["Type"])) - else: - self.modules[(values["Index"], values["Type"])][ - "parameters" - ] = parameters - except KeyError: - _LOGGER.warning( - "An error occurred while gathering parameters data. This issue should resolve by " - "itself. If this problem persists, open an issue at " - "https://github.com/erikkastelec/hass-WEM-Portal/issues' " + for device_id in self.data.keys(): + _LOGGER.debug("Fetching api parameters data") + delete_candidates = [] + for key, values in self.modules[device_id].items(): + data = { + "DeviceID": device_id, + "ModuleIndex": values["Index"], + "ModuleType": values["Type"], + } + response = self.make_api_call( + "https://www.wemportal.com/app/EventType/Read", data=data ) - for key in delete_candidates: - del self.modules[key] + + parameters = {} + try: + for parameter in response.json()["Parameters"]: + parameters[parameter["ParameterID"]] = parameter + if not parameters: + delete_candidates.append((values["Index"], values["Type"])) + else: + self.modules[device_id][(values["Index"], values["Type"])][ + "parameters" + ] = parameters + except KeyError as exc: + # TODO: make sure that we should fail here or should we just log and continue + raise WemPortalError( + "An error occurred while gathering parameters data. This issue should resolve by " + "itself. If this problem persists, open an issue at " + "https://github.com/erikkastelec/hass-WEM-Portal/issues' " + ) from exc + for key in delete_candidates: + del self.modules[device_id][key] def change_value( - self, parameter_id, module_index, module_type, numeric_value, login=True + self, + device_id, + parameter_id, + module_index, + module_type, + numeric_value, + login=True, ): """POST request to API to change a specific value""" _LOGGER.debug("Changing value for %s", parameter_id) data = { - "DeviceID": self.device_id, + "DeviceID": device_id, "Modules": [ { "ModuleIndex": int(module_index), @@ -218,9 +257,11 @@ def change_value( } ], } + # _LOGGER.info(data) + + # TODO: do this with self.make_api_call() headers = copy.deepcopy(self.headers) headers["Content-Type"] = "application/json" - # _LOGGER.info(data) response = self.session.post( "https://www.wemportal.com/app/DataAccess/Write", headers=headers, @@ -231,278 +272,289 @@ def change_value( self.change_value( parameter_id, module_index, module_type, numeric_value, login=False ) - if response.status_code != 200: - _LOGGER.error("Error changing parameter %s value", parameter_id) + try: + response.raise_for_status() + except Exception as exc: + raise ParameterChangeError( + "Error changing parameter %s value" % parameter_id + ) from exc # Refresh data and retrieve new data def get_data(self): _LOGGER.debug("Fetching fresh api data") - try: - data = { - "DeviceID": self.device_id, - "Modules": [ - { - "ModuleIndex": module["Index"], - "ModuleType": module["Type"], - "Parameters": [ - {"ParameterID": parameter} - for parameter in module["parameters"].keys() - ], - } - for module in self.modules.values() - ], - } - except KeyError as _: - _LOGGER.warning( - "An error occurred while gathering data. This issue should resolve by " - "itself. If this problem persists, open an issue at " - "https://github.com/erikkastelec/hass-WEM-Portal/issues' " - ) - _LOGGER.debug(self.modules) - return - headers = copy.deepcopy(self.headers) - headers["Content-Type"] = "application/json" - response = self.session.post( - "https://www.wemportal.com/app/DataAccess/Refresh", - headers=headers, - data=json.dumps(data), - ) - if response.status_code == 401: - self.api_login() - response = self.session.post( + for device_id in self.data.keys(): # type: ignore + try: + data = { + "DeviceID": device_id, + "Modules": [ + { + "ModuleIndex": module["Index"], + "ModuleType": module["Type"], + "Parameters": [ + {"ParameterID": parameter} + for parameter in module["parameters"].keys() + ], + } + for module in self.modules[device_id].values() + ], + } + except KeyError as exc: + _LOGGER.debug(DATA_GATHERING_ERROR, ": ") + _LOGGER.debug(self.modules[device_id]) + raise WemPortalError(DATA_GATHERING_ERROR) from exc + + self.make_api_call( "https://www.wemportal.com/app/DataAccess/Refresh", - headers=headers, - data=json.dumps(data), + data=data, ) - values = self.session.post( - "https://www.wemportal.com/app/DataAccess/Read", - headers=headers, - data=json.dumps(data), - ).json() - - # TODO: CLEAN UP - # Map values to sensors we got during scraping. - icon_mapper = defaultdict(lambda: "mdi:flash") - icon_mapper["°C"] = "mdi:thermometer" - for module in values["Modules"]: - for value in module["Values"]: - - name = ( - self.modules[(module["ModuleIndex"], module["ModuleType"])]["Name"] - + "-" - + self.modules[(module["ModuleIndex"], module["ModuleType"])][ - "parameters" - ][value["ParameterID"]]["ParameterID"] - ) - - data[name] = { - "friendlyName": self.translate( - self.language, self.friendly_name_mapper(value["ParameterID"]) - ), - "ParameterID": value["ParameterID"], - "unit": value["Unit"], - "value": value["NumericValue"], - "IsWriteable": self.modules[ - (module["ModuleIndex"], module["ModuleType"]) - ]["parameters"][value["ParameterID"]]["IsWriteable"], - "DataType": self.modules[ + values = self.make_api_call( + "https://www.wemportal.com/app/DataAccess/Read", + data=data, + ).json() + + # TODO: CLEAN UP + # Map values to sensors we got during scraping. + icon_mapper = defaultdict(lambda: "mdi:flash") + icon_mapper["°C"] = "mdi:thermometer" + for module in values["Modules"]: + for value in module["Values"]: + + name = ( + self.modules[device_id][ + (module["ModuleIndex"], module["ModuleType"]) + ]["Name"] + + "-" + + self.modules[device_id][ + (module["ModuleIndex"], module["ModuleType"]) + ]["parameters"][value["ParameterID"]]["ParameterID"] + ) + + data[name] = { + "friendlyName": self.translate( + self.language, + self.friendly_name_mapper(value["ParameterID"]), + ), + "ParameterID": value["ParameterID"], + "unit": value["Unit"], + "value": value["NumericValue"], + "IsWriteable": self.modules[device_id][ + (module["ModuleIndex"], module["ModuleType"]) + ]["parameters"][value["ParameterID"]]["IsWriteable"], + "DataType": self.modules[device_id][ + (module["ModuleIndex"], module["ModuleType"]) + ]["parameters"][value["ParameterID"]]["DataType"], + "ModuleIndex": module["ModuleIndex"], + "ModuleType": module["ModuleType"], + } + if not value["NumericValue"]: + data[name]["value"] = value["StringValue"] + if self.modules[device_id][ (module["ModuleIndex"], module["ModuleType"]) - ]["parameters"][value["ParameterID"]]["DataType"], - "ModuleIndex": module["ModuleIndex"], - "ModuleType": module["ModuleType"], - } - if not value["NumericValue"]: - data[name]["value"] = value["StringValue"] - if self.modules[(module["ModuleIndex"], module["ModuleType"])][ - "parameters" - ][value["ParameterID"]]["EnumValues"]: - if value["StringValue"] in [ + ]["parameters"][value["ParameterID"]]["EnumValues"]: + if value["StringValue"] in [ + "off", + "Aus", + "Label ist null", + "Label ist null ", + ]: + data[name]["value"] = 0.0 + else: + try: + data[name]["value"] = float(value["StringValue"]) + except ValueError: + data[name]["value"] = value["StringValue"] + if data[name]["value"] in [ "off", "Aus", "Label ist null", "Label ist null ", ]: data[name]["value"] = 0.0 - else: - try: - data[name]["value"] = float(value["StringValue"]) - except ValueError: - data[name]["value"] = value["StringValue"] - if data[name]["value"] in [ - "off", - "Aus", - "Label ist null", - "Label ist null ", - ]: - data[name]["value"] = 0.0 - # Select entities - if data[name]["IsWriteable"]: - - # NUMBER PLATFORM - if data[name]["DataType"] == -1 or data[name]["DataType"] == 3: - self.data[name] = { - "friendlyName": data[name]["friendlyName"], - "ParameterID": value["ParameterID"], - "unit": value["Unit"], - "icon": icon_mapper[value["Unit"]], - "value": value["NumericValue"], - "DataType": data[name]["DataType"], - "ModuleIndex": module["ModuleIndex"], - "ModuleType": module["ModuleType"], - "platform": "number", - "min_value": float( - self.modules[ - (module["ModuleIndex"], module["ModuleType"]) - ]["parameters"][value["ParameterID"]]["MinValue"] - ), - "max_value": float( - self.modules[ - (module["ModuleIndex"], module["ModuleType"]) - ]["parameters"][value["ParameterID"]]["MaxValue"] - ), - } - if data[name]["DataType"] == -1: - self.data[name]["step"] = 0.5 - else: - self.data[name]["step"] = 1 - - # SELECT PLATFORM - elif data[name]["DataType"] == 1: - self.data[name] = { - "friendlyName": data[name]["friendlyName"], - "ParameterID": value["ParameterID"], - "unit": value["Unit"], - "icon": icon_mapper[value["Unit"]], - "value": value["NumericValue"], - "DataType": data[name]["DataType"], - "ModuleIndex": module["ModuleIndex"], - "ModuleType": module["ModuleType"], - "platform": "select", - "options": [ - x["Value"] - for x in self.modules[ - (module["ModuleIndex"], module["ModuleType"]) - ]["parameters"][value["ParameterID"]]["EnumValues"] - ], - "optionsNames": [ - x["Name"] - for x in self.modules[ - (module["ModuleIndex"], module["ModuleType"]) - ]["parameters"][value["ParameterID"]]["EnumValues"] - ], - } - # SWITCH PLATFORM - elif data[name]["DataType"] == 2: - try: - if ( - int( - self.modules[ + # Select entities + if data[name]["IsWriteable"]: + + # NUMBER PLATFORM + if data[name]["DataType"] == -1 or data[name]["DataType"] == 3: + self.data[device_id][name] = { + "friendlyName": data[name]["friendlyName"], + "ParameterID": value["ParameterID"], + "unit": value["Unit"], + "icon": icon_mapper[value["Unit"]], + "value": value["NumericValue"], + "DataType": data[name]["DataType"], + "ModuleIndex": module["ModuleIndex"], + "ModuleType": module["ModuleType"], + "platform": "number", + "min_value": float( + self.modules[device_id][ (module["ModuleIndex"], module["ModuleType"]) ]["parameters"][value["ParameterID"]]["MinValue"] - ) - == 0 - and int( - self.modules[ + ), + "max_value": float( + self.modules[device_id][ (module["ModuleIndex"], module["ModuleType"]) ]["parameters"][value["ParameterID"]]["MaxValue"] - ) - == 1 - ): - self.data[name] = { - "friendlyName": data[name]["friendlyName"], - "ParameterID": value["ParameterID"], - "unit": value["Unit"], - "icon": icon_mapper[value["Unit"]], - "value": value["NumericValue"], - "DataType": data[name]["DataType"], - "ModuleIndex": module["ModuleIndex"], - "ModuleType": module["ModuleType"], - "platform": "switch", - } + ), + } + if data[name]["DataType"] == -1: + self.data[device_id][name]["step"] = 0.5 else: - # Skip schedules - continue - # Catches exception when converting to int when MinValur or MaxValue is Nones - except Exception: - continue - - # if self.modules[(module["ModuleIndex"],module["ModuleType"])]["parameters"][value["ParameterID"]]["EnumValues"]: - # for entry in self.modules[(module["ModuleIndex"],module["ModuleType"])]["parameters"][value["ParameterID"]]["EnumValues"]: - # if entry["Value"] == round(value["NumericValue"]): - # self.data[value["ParameterID"]]["value"] = entry["Name"] - # break - - for key, value in data.items(): - if key not in ["DeviceID", "Modules"]: - # Match only values, which are not writable (sensors) - if not value["IsWriteable"]: - # Only when mode == both. Combines data from web and api - if self.mode == "both": + self.data[device_id][name]["step"] = 1 + + # SELECT PLATFORM + elif data[name]["DataType"] == 1: + self.data[device_id][name] = { + "friendlyName": data[name]["friendlyName"], + "ParameterID": value["ParameterID"], + "unit": value["Unit"], + "icon": icon_mapper[value["Unit"]], + "value": value["NumericValue"], + "DataType": data[name]["DataType"], + "ModuleIndex": module["ModuleIndex"], + "ModuleType": module["ModuleType"], + "platform": "select", + "options": [ + x["Value"] + for x in self.modules[device_id][ + (module["ModuleIndex"], module["ModuleType"]) + ]["parameters"][value["ParameterID"]]["EnumValues"] + ], + "optionsNames": [ + x["Name"] + for x in self.modules[device_id][ + (module["ModuleIndex"], module["ModuleType"]) + ]["parameters"][value["ParameterID"]]["EnumValues"] + ], + } + # SWITCH PLATFORM + elif data[name]["DataType"] == 2: try: - temp = self.scrapingMapper[value["ParameterID"]] - except KeyError: - for scraped_entity in self.data.keys(): - scraped_entity_id = self.data[scraped_entity][ - "ParameterID" - ] - try: - if ( - fuzz.ratio( - value["friendlyName"], - scraped_entity_id.split("-")[1], + if ( + int( + self.modules[device_id][ + ( + module["ModuleIndex"], + module["ModuleType"], ) - >= 90 - ): - try: - self.scrapingMapper[ - value["ParameterID"] - ].append(scraped_entity_id) - except KeyError: - self.scrapingMapper[ - value["ParameterID"] - ] = [scraped_entity_id] - except IndexError: - pass - # Check if empty + ]["parameters"][value["ParameterID"]][ + "MinValue" + ] + ) + == 0 + and int( + self.modules[device_id][ + ( + module["ModuleIndex"], + module["ModuleType"], + ) + ]["parameters"][value["ParameterID"]][ + "MaxValue" + ] + ) + == 1 + ): + self.data[device_id][name] = { + "friendlyName": data[name]["friendlyName"], + "ParameterID": value["ParameterID"], + "unit": value["Unit"], + "icon": icon_mapper[value["Unit"]], + "value": value["NumericValue"], + "DataType": data[name]["DataType"], + "ModuleIndex": module["ModuleIndex"], + "ModuleType": module["ModuleType"], + "platform": "switch", + } + else: + # Skip schedules + continue + # Catches exception when converting to int when MinValur or MaxValue is Nones + except Exception: + continue + + # if self.modules[(module["ModuleIndex"],module["ModuleType"])]["parameters"][value["ParameterID"]]["EnumValues"]: + # for entry in self.modules[(module["ModuleIndex"],module["ModuleType"])]["parameters"][value["ParameterID"]]["EnumValues"]: + # if entry["Value"] == round(value["NumericValue"]): + # self.data[value["ParameterID"]]["value"] = entry["Name"] + # break + + for key, value in data.items(): + if key not in ["DeviceID", "Modules"]: + # Match only values, which are not writable (sensors) + if not value["IsWriteable"]: + # Only when mode == both. Combines data from web and api + # Only for single device, don't know how to handle multiple devices with scraping yet + if self.mode == "both" and len(self.data.keys()) < 2: try: temp = self.scrapingMapper[value["ParameterID"]] except KeyError: - self.scrapingMapper[value["ParameterID"]] = [ - value["friendlyName"] - ] - finally: - for scraped_entity in self.scrapingMapper[ - value["ParameterID"] - ]: + for scraped_entity in self.data[device_id].keys(): + scraped_entity_id = self.data[device_id][ + scraped_entity + ]["ParameterID"] + try: + if ( + fuzz.ratio( + value["friendlyName"], + scraped_entity_id.split("-")[1], + ) + >= 90 + ): + try: + self.scrapingMapper[ + value["ParameterID"] + ].append(scraped_entity_id) + except KeyError: + self.scrapingMapper[ + value["ParameterID"] + ] = [scraped_entity_id] + except IndexError: + pass + # Check if empty try: - self.data[scraped_entity] = { - "value": value["value"], - "name": self.data[scraped_entity]["name"], - "unit": self.data[scraped_entity]["unit"], - "icon": self.data[scraped_entity]["icon"], - "friendlyName": scraped_entity, - "ParameterID": scraped_entity, - "platform": "sensor", - } + temp = self.scrapingMapper[value["ParameterID"]] except KeyError: - self.data[scraped_entity] = { - "value": value["value"], - "unit": value["unit"], - "icon": icon_mapper[value["unit"]], - "friendlyName": scraped_entity, - "name": scraped_entity, - "ParameterID": scraped_entity, - "platform": "sensor", - } - else: - self.data[key] = { - "value": value["value"], - "ParameterID": value["ParameterID"], - "unit": value["unit"], - "icon": icon_mapper[value["unit"]], - "friendlyName": value["friendlyName"], - "platform": "sensor", - } + self.scrapingMapper[value["ParameterID"]] = [ + value["friendlyName"] + ] + finally: + for scraped_entity in self.scrapingMapper[ + value["ParameterID"] + ]: + try: + self.data[device_id][scraped_entity] = { + "value": value["value"], + "name": self.data[device_id][ + scraped_entity + ]["name"], + "unit": self.data[device_id][ + scraped_entity + ]["unit"], + "icon": self.data[device_id][ + scraped_entity + ]["icon"], + "friendlyName": scraped_entity, + "ParameterID": scraped_entity, + "platform": "sensor", + } + except KeyError: + self.data[device_id][scraped_entity] = { + "value": value["value"], + "unit": value["unit"], + "icon": icon_mapper[value["unit"]], + "friendlyName": scraped_entity, + "name": scraped_entity, + "ParameterID": scraped_entity, + "platform": "sensor", + } + else: + self.data[device_id][key] = { + "value": value["value"], + "ParameterID": value["ParameterID"], + "unit": value["unit"], + "icon": icon_mapper[value["unit"]], + "friendlyName": value["friendlyName"], + "platform": "sensor", + } def friendly_name_mapper(self, value): friendly_name_dict = { @@ -561,8 +613,6 @@ def translate(self, language, value): return out -from scrapy.core.downloader.handlers.http11 import HTTP11DownloadHandler - START_URLS = ["https://www.wemportal.com/Web/login.aspx"] @@ -578,7 +628,6 @@ class WemPortalSpider(Spider): def __init__(self, username, password, cookie=None, **kw): self.username = username self.password = password - self.authErrorFlag = 0 self.cookie = cookie if cookie else {} self.retry = None super().__init__(**kw) @@ -633,19 +682,18 @@ def check_valid_login(self, response): response.url.lower() == "https://www.wemportal.com/Web/Login.aspx".lower() and not self.retry ): - # TODO: Implement retry logic. If session expires - self.authErrorFlag = 2 - form_data = {} - return {"authErrorFlag": 2} + raise ExpiredSessionError( + "Scrapy spider session expired. Skipping one update cycle." + ) elif ( response.url.lower() == "https://www.wemportal.com/Web/Login.aspx?AspxAutoDetectCookieSupport=1".lower() or response.status != 200 ): - self.authErrorFlag = 1 - form_data = {} - return {"authErrorFlag": 1} + raise AuthError( + f"Authentication Error: Encountered an unknown authentication error. Received response code: {response.status_code}, response: {response.content}" + ) self.retry = None # Wait 2 seconds so everything loads @@ -697,8 +745,8 @@ def scrape_pages(self, response): # sleep for 5 seconds to get proper language and updated data time.sleep(5) _LOGGER.debug("Print expert page HTML: %s", response.text) - if self.authErrorFlag != 0: - yield {"authErrorFlag": self.authErrorFlag} + # if self.authErrorFlag != 0: + # yield {"authErrorFlag": self.authErrorFlag} output = {} for i, div in enumerate( response.xpath( @@ -770,6 +818,7 @@ def scrape_pages(self, response): } except IndexError: continue + output["cookie"] = self.cookie - self.data = output + yield output diff --git a/info.md b/info.md index 3a73d57..e77f3a5 100644 --- a/info.md +++ b/info.md @@ -12,6 +12,8 @@ Full restart of the Home Assistant is required. Restarting from GUI won't work, ## Configuration +Integration must be configured in Home Assistant frontend: Go to `Settings > Devices&Services `, click on ` Add integration ` button and search for `Weishaupt WEM Portal`. + Configuration variables: - `username`: Email address used for logging into WEM Portal @@ -27,18 +29,7 @@ Configuration variables: mobile API. Option `web` gets only the data on the website, while option `both` queries website and api and provides all the available data from both sources. -Add the following to your `configuration.yaml` file: -```yaml -# Example configuration.yaml entry -wemportal: - #scan_interval: 1800 - #api_scan_interval: 300 - #language: en - #mode: api - username: your_username - password: your_password -``` ## Troubleshooting From f30bc864ba1b3ceeee27d3355745e59e0db81b2b Mon Sep 17 00:00:00 2001 From: Erik Kastelec Date: Wed, 8 Mar 2023 09:03:01 +0100 Subject: [PATCH 04/26] Added entity migration and fixed a few bugs #28 --- custom_components/wemportal/__init__.py | 139 ++++++++++---------- custom_components/wemportal/config_flow.py | 33 ++--- custom_components/wemportal/const.py | 14 +- custom_components/wemportal/coordinator.py | 2 +- custom_components/wemportal/manifest.json | 2 +- custom_components/wemportal/number.py | 8 +- custom_components/wemportal/select.py | 8 +- custom_components/wemportal/sensor.py | 8 +- custom_components/wemportal/switch.py | 18 +-- custom_components/wemportal/wemportalapi.py | 35 +++-- 10 files changed, 133 insertions(+), 134 deletions(-) diff --git a/custom_components/wemportal/__init__.py b/custom_components/wemportal/__init__.py index c5e24a6..07fcd9a 100644 --- a/custom_components/wemportal/__init__.py +++ b/custom_components/wemportal/__init__.py @@ -13,30 +13,19 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.typing import ConfigType from homeassistant.config_entries import ConfigEntry -from .const import CONF_LANGUAGE, CONF_MODE, CONF_SCAN_INTERVAL_API, DOMAIN, PLATFORMS +from .const import ( + CONF_LANGUAGE, + CONF_MODE, + CONF_SCAN_INTERVAL_API, + DOMAIN, + PLATFORMS, + _LOGGER, + DEFAULT_CONF_SCAN_INTERVAL_API_VALUE, + DEFAULT_CONF_SCAN_INTERVAL_VALUE, +) from .coordinator import WemPortalDataUpdateCoordinator from .wemportalapi import WemPortalApi - - -# CONFIG_SCHEMA = vol.Schema( -# { -# DOMAIN: vol.Schema( -# { -# vol.Optional( -# CONF_SCAN_INTERVAL, default=timedelta(minutes=30) -# ): config_validation.time_period, -# vol.Optional( -# CONF_SCAN_INTERVAL_API, default=timedelta(minutes=5) -# ): config_validation.time_period, -# vol.Optional(CONF_LANGUAGE, default="en"): config_validation.string, -# vol.Optional(CONF_MODE, default="api"): config_validation.string, -# vol.Required(CONF_USERNAME): config_validation.string, -# vol.Required(CONF_PASSWORD): config_validation.string, -# } -# ) -# }, -# extra=vol.ALLOW_EXTRA, -# ) +import homeassistant.helpers.entity_registry as entity_registry def get_wemportal_unique_id(config_entry_id: str, device_id: str, name: str): @@ -50,50 +39,64 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: return True -# # Set proper update_interval, based on selected mode -# if config[DOMAIN].get(CONF_MODE) == "web": -# update_interval = config[DOMAIN].get(CONF_SCAN_INTERVAL) - -# elif config[DOMAIN].get(CONF_MODE) == "api": -# update_interval = config[DOMAIN].get(CONF_SCAN_INTERVAL_API) -# else: -# update_interval = min( -# config[DOMAIN].get(CONF_SCAN_INTERVAL), -# config[DOMAIN].get(CONF_SCAN_INTERVAL_API), -# ) -# # Creatie API object -# api = WemPortalApi(config[DOMAIN]) -# # Create custom coordinator -# coordinator = WemPortalDataUpdateCoordinator(hass, api, update_interval) - -# hass.data[DOMAIN] = { -# "api": api, -# "coordinator": coordinator, -# } - -# await coordinator.async_config_entry_first_refresh() - -# # Initialize platforms -# for platform in PLATFORMS: -# hass.helpers.discovery.load_platform(platform, DOMAIN, {}, config) -# return True +# Migrate values from previous versions +async def migrate_unique_ids( + hass: HomeAssistant, config_entry: ConfigEntry, coordinator +): + er = await entity_registry.async_get_registry(hass) + # Do migration for first device if we have multiple + device_id = list(coordinator.data.keys())[0] + data = coordinator.data[device_id] + + change = False + for unique_id, values in data.items(): + name_id = er.async_get_entity_id(values["platform"], DOMAIN, unique_id) + new_id = get_wemportal_unique_id(config_entry.entry_id, device_id, unique_id) + if name_id is not None: + _LOGGER.info( + f"Found entity with old id ({name_id}). Updating to new unique_id ({new_id})." + ) + _LOGGER.error(unique_id) + # check if there already is a new one + new_entity_id = er.async_get_entity_id(values["platform"], DOMAIN, new_id) + if new_entity_id is not None: + _LOGGER.info( + "Found entity with old id and an entity with a new unique_id. Preserving old entity..." + ) + er.async_remove(new_entity_id) + er.async_update_entity( + name_id, + new_unique_id=new_id, + ) + change = True + if change: + await coordinator.async_config_entry_first_refresh() async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up the wemportal component.""" # Set proper update_interval, based on selected mode - if entry.data.get(CONF_MODE) == "web": - update_interval = entry.data.get(CONF_SCAN_INTERVAL) + if entry.options.get(CONF_MODE) == "web": + update_interval = entry.options.get( + CONF_SCAN_INTERVAL, DEFAULT_CONF_SCAN_INTERVAL_VALUE + ) - elif entry.data.get(CONF_MODE) == "api": - update_interval = entry.data.get(CONF_SCAN_INTERVAL_API) + elif entry.options.get(CONF_MODE) == "api": + update_interval = entry.options.get( + CONF_SCAN_INTERVAL_API, DEFAULT_CONF_SCAN_INTERVAL_API_VALUE + ) else: update_interval = min( - entry.data.get(CONF_SCAN_INTERVAL), - entry.data.get(CONF_SCAN_INTERVAL_API), + entry.options.get(CONF_SCAN_INTERVAL, DEFAULT_CONF_SCAN_INTERVAL_VALUE), + entry.options.get( + CONF_SCAN_INTERVAL_API, DEFAULT_CONF_SCAN_INTERVAL_API_VALUE + ), ) # Creatie API object - api = WemPortalApi(entry.data) + + api = WemPortalApi( + entry.data.get(CONF_USERNAME), entry.data.get(CONF_PASSWORD), entry.options + ) # Create custom coordinator coordinator = WemPortalDataUpdateCoordinator( hass, api, timedelta(seconds=update_interval) @@ -101,31 +104,29 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: await coordinator.async_config_entry_first_refresh() + try: + version = entry.version + if version < 2: + await migrate_unique_ids(hass, entry, coordinator) + except Exception: + await migrate_unique_ids(hass, entry, coordinator) + hass.data[DOMAIN][entry.entry_id] = { "api": api, # "config": entry.data, "coordinator": coordinator, } - # TODO: Implement removal of outdated entries - # current_devices: set[tuple[str, str]] = set({(DOMAIN, entry.entry_id)}) - - # device_registry = dr.async_get(hass) - # for device_entry in dr.async_entries_for_config_entry( - # device_registry, entry.entry_id - # ): - # for identifier in device_entry.identifiers: - # if identifier in current_devices: - # break - # else: - # device_registry.async_remove_device(device_entry.id) - await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) entry.async_on_unload(entry.add_update_listener(_async_entry_updated)) return True +async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: + return True + + async def _async_entry_updated(hass: HomeAssistant, config_entry: ConfigEntry) -> None: """Handle entry updates.""" await hass.config_entries.async_reload(config_entry.entry_id) diff --git a/custom_components/wemportal/config_flow.py b/custom_components/wemportal/config_flow.py index 6cab6c2..7dfee28 100644 --- a/custom_components/wemportal/config_flow.py +++ b/custom_components/wemportal/config_flow.py @@ -30,16 +30,9 @@ async def validate_input(hass: core.HomeAssistant, data): """Validate the user input allows us to connect.""" - og_data = data - # Populate with default values - # Should be fixed, but for now, this should work. - data[CONF_MODE] = "api" - data[CONF_LANGUAGE] = "en" - data[CONF_SCAN_INTERVAL_API] = 300 - data[CONF_SCAN_INTERVAL] = 1800 # Create API object - api = WemPortalApi(data) + api = WemPortalApi(data[CONF_USERNAME], data[CONF_PASSWORD]) # Try to login try: @@ -49,13 +42,13 @@ async def validate_input(hass: core.HomeAssistant, data): except UnknownAuthError: raise CannotConnect from UnknownAuthError - return og_data + return data class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): - """Handle a config flow for kmtronic.""" + """Handle a config flow for wemportal.""" - VERSION = 1 + VERSION = 2 @staticmethod @callback @@ -117,13 +110,23 @@ async def async_step_init(self, user_input=None): data_schema=vol.Schema( { vol.Optional( - CONF_SCAN_INTERVAL, default=1800 + CONF_SCAN_INTERVAL, + default=self.config_entry.options.get(CONF_SCAN_INTERVAL, 1800), ): config_validation.positive_int, vol.Optional( - CONF_SCAN_INTERVAL_API, default=300 + CONF_SCAN_INTERVAL_API, + default=self.config_entry.options.get( + CONF_SCAN_INTERVAL_API, 300 + ), ): config_validation.positive_int, - vol.Optional(CONF_LANGUAGE, default="en"): config_validation.string, - vol.Optional(CONF_MODE, default="api"): config_validation.string, + vol.Optional( + CONF_LANGUAGE, + default=self.config_entry.options.get(CONF_LANGUAGE, "en"), + ): config_validation.string, + vol.Optional( + CONF_MODE, + default=self.config_entry.options.get(CONF_MODE, "api"), + ): config_validation.string, } ), ) diff --git a/custom_components/wemportal/const.py b/custom_components/wemportal/const.py index 06913b3..f6c7efd 100644 --- a/custom_components/wemportal/const.py +++ b/custom_components/wemportal/const.py @@ -3,15 +3,19 @@ from typing import Final _LOGGER = logging.getLogger("custom_components.wemportal") -DOMAIN = "wemportal" -DEFAULT_NAME = "Weishaupt WEM Portal" -DEFAULT_TIMEOUT = 360 -START_URLS = ["https://www.wemportal.com/Web/login.aspx"] +DOMAIN: Final = "wemportal" +DEFAULT_NAME: Final = "Weishaupt WEM Portal" +DEFAULT_TIMEOUT: Final = 360 +START_URLS: Final = ["https://www.wemportal.com/Web/login.aspx"] CONF_SCAN_INTERVAL_API: Final = "api_scan_interval" CONF_LANGUAGE: Final = "language" CONF_MODE: Final = "mode" PLATFORMS = ["sensor", "number", "select", "switch"] -REFRESH_WAIT_TIME: int = 360 +REFRESH_WAIT_TIME: Final = 360 DATA_GATHERING_ERROR: Final = "An error occurred while gathering parameters data. \ This issue should resolve by itself. If this problem persists, \ open an issue at https://github.com/erikkastelec/hass-WEM-Portal/issues " +DEFAULT_CONF_SCAN_INTERVAL_API_VALUE: Final = 300 +DEFAULT_CONF_SCAN_INTERVAL_VALUE: Final = 1800 +DEFAULT_CONF_LANGUAGE_VALUE: Final = "en" +DEFAULT_CONF_MODE_VALUE: Final = "api" diff --git a/custom_components/wemportal/coordinator.py b/custom_components/wemportal/coordinator.py index a60d8f8..370385c 100644 --- a/custom_components/wemportal/coordinator.py +++ b/custom_components/wemportal/coordinator.py @@ -15,7 +15,7 @@ class WemPortalDataUpdateCoordinator(DataUpdateCoordinator): """DataUpdateCoordinator for wemportal component""" - def __init__(self, hass: HomeAssistant, api: WemPortalApi, update_interval): + def __init__(self, hass: HomeAssistant, api: WemPortalApi, update_interval) -> None: """Initialize DataUpdateCoordinator for the wemportal component""" super().__init__( hass, diff --git a/custom_components/wemportal/manifest.json b/custom_components/wemportal/manifest.json index 86c544c..8fe08da 100644 --- a/custom_components/wemportal/manifest.json +++ b/custom_components/wemportal/manifest.json @@ -4,7 +4,7 @@ "documentation": "https://github.com/erikkastelec/hass-WEM-Portal", "issue_tracker": "https://github.com/erikkastelec/hass-WEM-Portal/issues", "dependencies": [], - "version": "1.5.0", + "version": "1.5.1", "codeowners": [ "@erikkastelec" ], diff --git a/custom_components/wemportal/number.py b/custom_components/wemportal/number.py index 5c1d4c1..82ece88 100644 --- a/custom_components/wemportal/number.py +++ b/custom_components/wemportal/number.py @@ -119,11 +119,9 @@ def available(self): async def async_added_to_hass(self): """When entity is added to hass.""" - self.coordinator.async_add_listener(self.async_write_ha_state) - - async def async_will_remove_from_hass(self): - """When entity will be removed from hass.""" - self.coordinator.async_remove_listener(self.async_write_ha_state) + self.async_on_remove( + self.coordinator.async_add_listener(self.async_write_ha_state) + ) @property def name(self): diff --git a/custom_components/wemportal/select.py b/custom_components/wemportal/select.py index 984bf20..d5e4cc0 100644 --- a/custom_components/wemportal/select.py +++ b/custom_components/wemportal/select.py @@ -123,11 +123,9 @@ def current_option(self) -> str: async def async_added_to_hass(self): """When entity is added to hass.""" - self.coordinator.async_add_listener(self.async_write_ha_state) - - async def async_will_remove_from_hass(self): - """When entity will be removed from hass.""" - self.coordinator.async_remove_listener(self.async_write_ha_state) + self.async_on_remove( + self.coordinator.async_add_listener(self.async_write_ha_state) + ) @property def name(self): diff --git a/custom_components/wemportal/sensor.py b/custom_components/wemportal/sensor.py index 095ce85..980520d 100644 --- a/custom_components/wemportal/sensor.py +++ b/custom_components/wemportal/sensor.py @@ -107,11 +107,9 @@ def available(self): async def async_added_to_hass(self): """When entity is added to hass.""" - self.coordinator.async_add_listener(self.async_write_ha_state) - - async def async_will_remove_from_hass(self): - """When entity will be removed from hass.""" - self.coordinator.async_remove_listener(self.async_write_ha_state) + self.async_on_remove( + self.coordinator.async_add_listener(self.async_write_ha_state) + ) @property def name(self): diff --git a/custom_components/wemportal/switch.py b/custom_components/wemportal/switch.py index f50e41c..1d4e099 100644 --- a/custom_components/wemportal/switch.py +++ b/custom_components/wemportal/switch.py @@ -137,11 +137,9 @@ def available(self): async def async_added_to_hass(self): """When entity is added to hass.""" - self.coordinator.async_add_listener(self.async_write_ha_state) - - async def async_will_remove_from_hass(self): - """When entity will be removed from hass.""" - self.coordinator.async_remove_listener(self.async_write_ha_state) + self.async_on_remove( + self.coordinator.async_add_listener(self.async_write_ha_state) + ) @property def name(self): @@ -176,16 +174,6 @@ def state(self): _LOGGER.debug("Sensor data %s", self.coordinator.data) return None - # @property - # def state_class(self): - # """Return the state class of this entity, if any.""" - # if self._unit in ("°C", "kW", "W", "%"): - # return STATE_CLASS_MEASUREMENT - # elif self._unit in ("kWh", "Wh"): - # return STATE_CLASS_TOTAL_INCREASING - # else: - # return None - @property def device_class(self): return "switch" diff --git a/custom_components/wemportal/wemportalapi.py b/custom_components/wemportal/wemportalapi.py index e94026e..9576b65 100644 --- a/custom_components/wemportal/wemportalapi.py +++ b/custom_components/wemportal/wemportalapi.py @@ -29,25 +29,38 @@ CONF_SCAN_INTERVAL_API, START_URLS, DATA_GATHERING_ERROR, + DEFAULT_CONF_LANGUAGE_VALUE, + DEFAULT_CONF_MODE_VALUE, + DEFAULT_CONF_SCAN_INTERVAL_API_VALUE, + DEFAULT_CONF_SCAN_INTERVAL_VALUE, ) class WemPortalApi: """Wrapper class for Weishaupt WEM Portal""" - def __init__(self, config): + def __init__(self, username, password, config={}): self.data = {} - self.username = config.get(CONF_USERNAME) - self.password = config.get(CONF_PASSWORD) - self.mode = config.get(CONF_MODE) + self.username = username + self.password = password + self.mode = config.get(CONF_MODE, DEFAULT_CONF_MODE_VALUE) self.update_interval = timedelta( seconds=min( - config.get(CONF_SCAN_INTERVAL), config.get(CONF_SCAN_INTERVAL_API) + config.get(CONF_SCAN_INTERVAL, DEFAULT_CONF_SCAN_INTERVAL_VALUE), + config.get( + CONF_SCAN_INTERVAL_API, DEFAULT_CONF_SCAN_INTERVAL_API_VALUE + ), + ) + ) + self.scan_interval = timedelta( + seconds=config.get(CONF_SCAN_INTERVAL, DEFAULT_CONF_SCAN_INTERVAL_VALUE) + ) + self.scan_interval_api = timedelta( + seconds=config.get( + CONF_SCAN_INTERVAL_API, DEFAULT_CONF_SCAN_INTERVAL_API_VALUE ) ) - self.scan_interval = timedelta(seconds=config.get(CONF_SCAN_INTERVAL)) - self.scan_interval_api = timedelta(seconds=config.get(CONF_SCAN_INTERVAL_API)) - self.language = config.get(CONF_LANGUAGE) + self.language = config.get(CONF_LANGUAGE, DEFAULT_CONF_LANGUAGE_VALUE) self.session = None self.modules = None self.webscraping_cookie = {} @@ -69,7 +82,7 @@ def fetch_data(self): self.get_devices() self.get_parameters() if self.mode == "web": - self.fetch_webscraping_data() + self.data[next(iter(self.data), "0000")] = self.fetch_webscraping_data() elif self.mode == "api": self.get_data() else: @@ -124,7 +137,6 @@ def fetch_webscraping_data(self): return data def api_login(self): - payload = { "Name": self.username, "PasswordUTF8": self.password, @@ -318,7 +330,6 @@ def get_data(self): icon_mapper["°C"] = "mdi:thermometer" for module in values["Modules"]: for value in module["Values"]: - name = ( self.modules[device_id][ (module["ModuleIndex"], module["ModuleType"]) @@ -372,7 +383,6 @@ def get_data(self): data[name]["value"] = 0.0 # Select entities if data[name]["IsWriteable"]: - # NUMBER PLATFORM if data[name]["DataType"] == -1 or data[name]["DataType"] == 3: self.data[device_id][name] = { @@ -633,7 +643,6 @@ def __init__(self, username, password, cookie=None, **kw): super().__init__(**kw) def start_requests(self): - # Check if we have a cookie/session. Skip to main website if we do. if ".ASPXAUTH" in self.cookie: return [ From f40c4e790a497b48eaed68b9e3403be49fd90958 Mon Sep 17 00:00:00 2001 From: Erik Kastelec Date: Fri, 10 Mar 2023 10:04:58 +0100 Subject: [PATCH 05/26] Entity migration bug fix #28 --- custom_components/wemportal/__init__.py | 21 ++++++---- custom_components/wemportal/const.py | 4 +- custom_components/wemportal/wemportalapi.py | 43 +++++++++++++-------- 3 files changed, 43 insertions(+), 25 deletions(-) diff --git a/custom_components/wemportal/__init__.py b/custom_components/wemportal/__init__.py index 07fcd9a..8ac58b6 100644 --- a/custom_components/wemportal/__init__.py +++ b/custom_components/wemportal/__init__.py @@ -104,19 +104,22 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: await coordinator.async_config_entry_first_refresh() - try: - version = entry.version - if version < 2: - await migrate_unique_ids(hass, entry, coordinator) - except Exception: - await migrate_unique_ids(hass, entry, coordinator) + # try: + # version = entry.version + # if version < 2: + # await migrate_unique_ids(hass, entry, coordinator) + # except Exception: + # await migrate_unique_ids(hass, entry, coordinator) + + # Is there an on_update function that we can add listener to? + _LOGGER.debug("Migrating entity names for wemportal") + await migrate_unique_ids(hass, entry, coordinator) hass.data[DOMAIN][entry.entry_id] = { "api": api, # "config": entry.data, "coordinator": coordinator, } - await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) entry.async_on_unload(entry.add_update_listener(_async_entry_updated)) @@ -129,6 +132,10 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> async def _async_entry_updated(hass: HomeAssistant, config_entry: ConfigEntry) -> None: """Handle entry updates.""" + _LOGGER.debug("Migrating entity names for wemportal because of config entry update") + await migrate_unique_ids( + hass, config_entry, hass.data[DOMAIN][config_entry.entry_id]["coordinator"] + ) await hass.config_entries.async_reload(config_entry.entry_id) diff --git a/custom_components/wemportal/const.py b/custom_components/wemportal/const.py index f6c7efd..c33ad00 100644 --- a/custom_components/wemportal/const.py +++ b/custom_components/wemportal/const.py @@ -12,9 +12,9 @@ CONF_MODE: Final = "mode" PLATFORMS = ["sensor", "number", "select", "switch"] REFRESH_WAIT_TIME: Final = 360 -DATA_GATHERING_ERROR: Final = "An error occurred while gathering parameters data. \ +DATA_GATHERING_ERROR: Final = "An error occurred while gathering data. \ This issue should resolve by itself. If this problem persists, \ - open an issue at https://github.com/erikkastelec/hass-WEM-Portal/issues " + open an issue at https://github.com/erikkastelec/hass-WEM-Portal/issues" DEFAULT_CONF_SCAN_INTERVAL_API_VALUE: Final = 300 DEFAULT_CONF_SCAN_INTERVAL_VALUE: Final = 1800 DEFAULT_CONF_LANGUAGE_VALUE: Final = "en" diff --git a/custom_components/wemportal/wemportalapi.py b/custom_components/wemportal/wemportalapi.py index 9576b65..71de409 100644 --- a/custom_components/wemportal/wemportalapi.py +++ b/custom_components/wemportal/wemportalapi.py @@ -163,7 +163,10 @@ def api_login(self): f"Authentication Error: Encountered an unknown authentication error. Received response code: {response.status_code}, response: {response.content}" ) from exc - def make_api_call(self, url: str, headers=None, data=None) -> reqs.Response: + def make_api_call( + self, url: str, headers=None, data=None, login_retry=False, delay=1 + ) -> reqs.Response: + response = None try: if not headers: headers = self.headers @@ -175,23 +178,31 @@ def make_api_call(self, url: str, headers=None, data=None) -> reqs.Response: url, headers=headers, data=json.dumps(data) ) response.raise_for_status() - except reqs.exceptions.HTTPError as exc: - if response.status_code == 401: + except Exception as exc: + if response.status_code == 401 and not login_retry: self.api_login() - if not headers: - headers = self.headers - if not data: - response = self.session.get(url, headers=headers) - else: - headers["Content-Type"] = "application/json" - response = self.session.post( - url, headers=headers, data=json.dumps(data) - ) - response.raise_for_status() + headers = headers or self.headers + time.sleep(delay) + response = self.make_api_call( + url, + headers=headers, + data=data, + login_retry=True, + delay=delay, + ) else: - raise WemPortalError(DATA_GATHERING_ERROR) from exc - except Exception as exc: - raise WemPortalError(DATA_GATHERING_ERROR) from exc + try: + response_data = response.json() + # Status we get back from server + server_status = response_data["Status"] + server_message = response_data["Message"] + raise WemPortalError( + f"{DATA_GATHERING_ERROR} Server returned status code: {server_status} and message: {server_message}" + ) from exc + # If there is no Status or Message in response + except KeyError: + raise WemPortalError(DATA_GATHERING_ERROR) from exc + return response def get_devices(self): From 211b30644ce81e8f588472d580cdcd8cd9ede42f Mon Sep 17 00:00:00 2001 From: Erik Kastelec Date: Fri, 10 Mar 2023 10:11:06 +0100 Subject: [PATCH 06/26] README.md and info.md updated --- README.md | 3 ++- custom_components/wemportal/manifest.json | 2 +- info.md | 2 ++ 3 files changed, 5 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 2c32324..17edad3 100644 --- a/README.md +++ b/README.md @@ -37,13 +37,14 @@ custom_components Integration must be configured in Home Assistant frontend: Go to `Settings > Devices&Services `, click on ` Add integration ` button and search for `Weishaupt WEM Portal`. +After Adding the integration, you can click `CONFIGURE` button to edit the default settings. Make sure to read what each setting does below. Configuration variables: - `username`: Email address used for logging into WEM Portal - `password`: Password used for logging into WEM Portal - `scan_interval (Optional)`: Defines update frequency of web scraping. Optional and in seconds (defaults to 30 min). - Setting update frequency bellow 15 min is not recommended. + Setting update frequency below 15 min is not recommended. - `api_scan_interval (Optional)`: Defines update frequency for API data fetching. Optional and in seconds (defaults to 5 min, should not be lower than 3 min). - `language ( diff --git a/custom_components/wemportal/manifest.json b/custom_components/wemportal/manifest.json index 8fe08da..dd4aa23 100644 --- a/custom_components/wemportal/manifest.json +++ b/custom_components/wemportal/manifest.json @@ -4,7 +4,7 @@ "documentation": "https://github.com/erikkastelec/hass-WEM-Portal", "issue_tracker": "https://github.com/erikkastelec/hass-WEM-Portal/issues", "dependencies": [], - "version": "1.5.1", + "version": "1.5.2", "codeowners": [ "@erikkastelec" ], diff --git a/info.md b/info.md index e77f3a5..b7a9732 100644 --- a/info.md +++ b/info.md @@ -14,6 +14,8 @@ Full restart of the Home Assistant is required. Restarting from GUI won't work, Integration must be configured in Home Assistant frontend: Go to `Settings > Devices&Services `, click on ` Add integration ` button and search for `Weishaupt WEM Portal`. +After Adding the integration, you can click `CONFIGURE` button to edit the default settings. Make sure to read what each setting does below. + Configuration variables: - `username`: Email address used for logging into WEM Portal From a0f63f54c25efb42051b2ace0794c1781ebb934b Mon Sep 17 00:00:00 2001 From: Erik Kastelec Date: Fri, 10 Mar 2023 10:25:21 +0100 Subject: [PATCH 07/26] Fix logging bug --- custom_components/wemportal/__init__.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/custom_components/wemportal/__init__.py b/custom_components/wemportal/__init__.py index 8ac58b6..e98b527 100644 --- a/custom_components/wemportal/__init__.py +++ b/custom_components/wemportal/__init__.py @@ -56,7 +56,6 @@ async def migrate_unique_ids( _LOGGER.info( f"Found entity with old id ({name_id}). Updating to new unique_id ({new_id})." ) - _LOGGER.error(unique_id) # check if there already is a new one new_entity_id = er.async_get_entity_id(values["platform"], DOMAIN, new_id) if new_entity_id is not None: @@ -112,7 +111,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: # await migrate_unique_ids(hass, entry, coordinator) # Is there an on_update function that we can add listener to? - _LOGGER.debug("Migrating entity names for wemportal") + _LOGGER.info("Migrating entity names for wemportal") await migrate_unique_ids(hass, entry, coordinator) hass.data[DOMAIN][entry.entry_id] = { @@ -132,7 +131,7 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> async def _async_entry_updated(hass: HomeAssistant, config_entry: ConfigEntry) -> None: """Handle entry updates.""" - _LOGGER.debug("Migrating entity names for wemportal because of config entry update") + _LOGGER.info("Migrating entity names for wemportal because of config entry update") await migrate_unique_ids( hass, config_entry, hass.data[DOMAIN][config_entry.entry_id]["coordinator"] ) From d3f928bc029516b023791f2bd1dbab663d898adc Mon Sep 17 00:00:00 2001 From: Erik Kastelec Date: Fri, 10 Mar 2023 15:43:58 +0100 Subject: [PATCH 08/26] Typo fix #28 --- custom_components/wemportal/strings.json | 6 +++--- custom_components/wemportal/translations/en.json | 8 ++++---- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/custom_components/wemportal/strings.json b/custom_components/wemportal/strings.json index c0c7c7c..576c1b1 100644 --- a/custom_components/wemportal/strings.json +++ b/custom_components/wemportal/strings.json @@ -21,10 +21,10 @@ "step": { "init": { "data": { - "scan_interval": "Web scraping interval (default = 1800 sec)%]", - "api_scan_interval": "Api scan interval (default = 300 sec)%]", + "scan_interval": "Web scraping interval (default = 1800 sec)", + "api_scan_interval": "Api scan interval (default = 300 sec)", "language": "Language (default = en)", - "Mono": "[%key:common::config_flow::data::modes%](default = 'api')" + "mode": "[%key:common::config_flow::data::modes%](default = api)" } } } diff --git a/custom_components/wemportal/translations/en.json b/custom_components/wemportal/translations/en.json index 8c2ade1..9395d08 100644 --- a/custom_components/wemportal/translations/en.json +++ b/custom_components/wemportal/translations/en.json @@ -14,17 +14,17 @@ "unknown": "Unexpected error" }, "abort": { - "already_configured": "Acconut is already configured" + "already_configured": "Account is already configured" } }, "options": { "step": { "init": { "data": { - "scan_interval": "Web scraping interval (default = 1800 sec)%]", - "api_scan_interval": "Api scan interval (default = 300 sec)%]", + "scan_interval": "Web scraping interval (default = 1800 sec)", + "api_scan_interval": "Api scan interval (default = 300 sec)", "language": "Language (default = en)", - "mode": "Mode(default = 'api')" + "mode": "Mode(default = api)" } } } From 9707cf9ae14db8f486b1e4bbcc407434d66cffbd Mon Sep 17 00:00:00 2001 From: Erik Kastelec Date: Fri, 10 Mar 2023 16:13:26 +0100 Subject: [PATCH 09/26] Fix depracated async_get_registry() #28 --- custom_components/wemportal/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/wemportal/__init__.py b/custom_components/wemportal/__init__.py index e98b527..69b56ef 100644 --- a/custom_components/wemportal/__init__.py +++ b/custom_components/wemportal/__init__.py @@ -43,7 +43,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def migrate_unique_ids( hass: HomeAssistant, config_entry: ConfigEntry, coordinator ): - er = await entity_registry.async_get_registry(hass) + er = await entity_registry.async_get(hass) # Do migration for first device if we have multiple device_id = list(coordinator.data.keys())[0] data = coordinator.data[device_id] From dd39643177ce55eeb2da5b0c6954f8eda9075b28 Mon Sep 17 00:00:00 2001 From: Erik Kastelec Date: Fri, 10 Mar 2023 16:36:17 +0100 Subject: [PATCH 10/26] Bug fix for previous commit --- custom_components/wemportal/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/wemportal/__init__.py b/custom_components/wemportal/__init__.py index 69b56ef..5f08799 100644 --- a/custom_components/wemportal/__init__.py +++ b/custom_components/wemportal/__init__.py @@ -43,7 +43,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def migrate_unique_ids( hass: HomeAssistant, config_entry: ConfigEntry, coordinator ): - er = await entity_registry.async_get(hass) + er = entity_registry.async_get(hass) # Do migration for first device if we have multiple device_id = list(coordinator.data.keys())[0] data = coordinator.data[device_id] From 693d6fa0965c1151f6f5ec3ac3504134f121d27f Mon Sep 17 00:00:00 2001 From: Erik Kastelec Date: Sat, 11 Mar 2023 09:46:49 +0100 Subject: [PATCH 11/26] Worked on bug fix for spider failure #28 --- custom_components/wemportal/exceptions.py | 22 +- custom_components/wemportal/wemportalapi.py | 316 +++++++++----------- 2 files changed, 157 insertions(+), 181 deletions(-) diff --git a/custom_components/wemportal/exceptions.py b/custom_components/wemportal/exceptions.py index 65b82c3..45819d7 100644 --- a/custom_components/wemportal/exceptions.py +++ b/custom_components/wemportal/exceptions.py @@ -1,26 +1,28 @@ -import requests as reqs +""" Exceptions for the wemportal component.""" - -class AuthError(reqs.HTTPError): - """Exception to indicate an authentication error.""" - - -class UnknownAuthError(reqs.HTTPError): - """Exception to indicate an unknown authentication error.""" +from homeassistant.exceptions import HomeAssistantError -class WemPortalError(Exception): +class WemPortalError(HomeAssistantError): """ Custom exception for WEM Portal errors """ +class AuthError(WemPortalError): + """Exception to indicate an authentication error.""" + + +class UnknownAuthError(WemPortalError): + """Exception to indicate an unknown authentication error.""" + +class ServerError(WemPortalError): + """Exception to indicate a server error.""" class ExpiredSessionError(WemPortalError): """ Custom exception for expired session errors """ - class ParameterChangeError(WemPortalError): """ Custom exception for parameter change errors diff --git a/custom_components/wemportal/wemportalapi.py b/custom_components/wemportal/wemportalapi.py index 71de409..f7b5686 100644 --- a/custom_components/wemportal/wemportalapi.py +++ b/custom_components/wemportal/wemportalapi.py @@ -20,6 +20,7 @@ WemPortalError, ExpiredSessionError, ParameterChangeError, + ServerError, ) from .const import ( @@ -60,6 +61,7 @@ def __init__(self, username, password, config={}): CONF_SCAN_INTERVAL_API, DEFAULT_CONF_SCAN_INTERVAL_API_VALUE ) ) + self.valid_login = False self.language = config.get(CONF_LANGUAGE, DEFAULT_CONF_LANGUAGE_VALUE) self.session = None self.modules = None @@ -73,36 +75,63 @@ def __init__(self, username, password, config={}): } self.scrapingMapper = {} + # Used to keep track of how many update intervals to wait before retrying spider + self.spider_wait_interval = 0 + # Used to keep track of the number of times the spider consecutively fails + self.spider_retry_count = 0 + def fetch_data(self): try: # Login and get device info - if self.session is None: + if not self.valid_login: self.api_login() if len(self.data.keys()) == 0 or self.modules is None: self.get_devices() self.get_parameters() + + # Select data source based on mode if self.mode == "web": + # Get data by web scraping self.data[next(iter(self.data), "0000")] = self.fetch_webscraping_data() elif self.mode == "api": + # Get data using API self.get_data() else: - if ( - self.last_scraping_update is None - or ( - datetime.now() - - self.last_scraping_update - + timedelta(seconds=10) + # Get data using web scraping if it hasn't been updated recently, + # otherwise use API to get data + if self.last_scraping_update is None or ( + ( + ( + datetime.now() + - self.last_scraping_update + + timedelta(seconds=10) + ) + > self.scan_interval ) - > self.scan_interval + and self.spider_wait_interval == 0 ): + # Get data by web scraping webscrapint_data = self.fetch_webscraping_data() self.data[next(iter(self.data), "0000")] = webscrapint_data - self.get_data() + # Update last_scraping_update timestamp self.last_scraping_update = datetime.now() else: + # Reduce spider_wait_interval by 1 if > 0 + self.spider_wait_interval = ( + self.spider_wait_interval - 1 + if self.spider_wait_interval > 0 + else self.spider_wait_interval + ) + + # Get data using API + if self.last_scraping_update is not None: self.get_data() + + # Return data return self.data + + # Catch and raise any exceptions as a WemPortalError except Exception as exc: raise WemPortalError from exc @@ -115,10 +144,11 @@ def fetch_webscraping_data(self): try: data = processor.run([wemportal_job])[0] except IndexError as exc: - raise WemPortalError( - "There was a problem with getting data from WEM Portal. If this problem persists, " - "open an issue at https://github.com/erikkastelec/hass-WEM-Portal/issues" - ) from exc + self.spider_retry_count += 1 + if self.spider_retry_count == 2: + self.webscraping_cookie = {} + self.spider_wait_interval = self.spider_retry_count + raise WemPortalError(DATA_GATHERING_ERROR) from exc except AuthError as exc: self.webscraping_cookie = {} raise AuthError( @@ -134,6 +164,8 @@ def fetch_webscraping_data(self): del data["cookie"] except KeyError: pass + self.spider_retry_count = 0 + self.spider_wait_interval = 0 return data def api_login(self): @@ -154,14 +186,19 @@ def api_login(self): ) response.raise_for_status() except reqs.exceptions.HTTPError as exc: + self.valid_login = False if response.status_code == 400: raise AuthError( f"Authentication Error: Check if your login credentials are correct. Received response code: {response.status_code}, response: {response.content}" ) from exc + elif response.status_code == 500: + raise ServerError("WemPortal server error") from exc else: raise UnknownAuthError( f"Authentication Error: Encountered an unknown authentication error. Received response code: {response.status_code}, response: {response.content}" ) from exc + # Everything went fine, set valid_login to True + self.valid_login = True def make_api_call( self, url: str, headers=None, data=None, login_retry=False, delay=1 @@ -334,7 +371,6 @@ def get_data(self): "https://www.wemportal.com/app/DataAccess/Read", data=data, ).json() - # TODO: CLEAN UP # Map values to sensors we got during scraping. icon_mapper = defaultdict(lambda: "mdi:flash") @@ -350,7 +386,9 @@ def get_data(self): (module["ModuleIndex"], module["ModuleType"]) ]["parameters"][value["ParameterID"]]["ParameterID"] ) - + module_data = module_data = self.modules[device_id][ + (module["ModuleIndex"], module["ModuleType"]) + ]["parameters"][value["ParameterID"]] data[name] = { "friendlyName": self.translate( self.language, @@ -359,12 +397,8 @@ def get_data(self): "ParameterID": value["ParameterID"], "unit": value["Unit"], "value": value["NumericValue"], - "IsWriteable": self.modules[device_id][ - (module["ModuleIndex"], module["ModuleType"]) - ]["parameters"][value["ParameterID"]]["IsWriteable"], - "DataType": self.modules[device_id][ - (module["ModuleIndex"], module["ModuleType"]) - ]["parameters"][value["ParameterID"]]["DataType"], + "IsWriteable": module_data["IsWriteable"], + "DataType": module_data["DataType"], "ModuleIndex": module["ModuleIndex"], "ModuleType": module["ModuleType"], } @@ -392,190 +426,130 @@ def get_data(self): "Label ist null ", ]: data[name]["value"] = 0.0 - # Select entities + if data[name]["IsWriteable"]: + # NUMBER PLATFORM + # Define common attributes + common_attrs = { + "friendlyName": data[name]["friendlyName"], + "ParameterID": value["ParameterID"], + "unit": value["Unit"], + "icon": icon_mapper[value["Unit"]], + "value": value["NumericValue"], + "DataType": data[name]["DataType"], + "ModuleIndex": module["ModuleIndex"], + "ModuleType": module["ModuleType"], + } + + # Process data based on platform type + # NUMBER PLATFORM if data[name]["DataType"] == -1 or data[name]["DataType"] == 3: self.data[device_id][name] = { - "friendlyName": data[name]["friendlyName"], - "ParameterID": value["ParameterID"], - "unit": value["Unit"], - "icon": icon_mapper[value["Unit"]], - "value": value["NumericValue"], - "DataType": data[name]["DataType"], - "ModuleIndex": module["ModuleIndex"], - "ModuleType": module["ModuleType"], + **common_attrs, "platform": "number", - "min_value": float( - self.modules[device_id][ - (module["ModuleIndex"], module["ModuleType"]) - ]["parameters"][value["ParameterID"]]["MinValue"] - ), - "max_value": float( - self.modules[device_id][ - (module["ModuleIndex"], module["ModuleType"]) - ]["parameters"][value["ParameterID"]]["MaxValue"] - ), + "min_value": float(module_data["MinValue"]), + "max_value": float(module_data["MaxValue"]), + "step": 0.5 if data[name]["DataType"] == -1 else 1, } - if data[name]["DataType"] == -1: - self.data[device_id][name]["step"] = 0.5 - else: - self.data[device_id][name]["step"] = 1 - # SELECT PLATFORM elif data[name]["DataType"] == 1: self.data[device_id][name] = { - "friendlyName": data[name]["friendlyName"], - "ParameterID": value["ParameterID"], - "unit": value["Unit"], - "icon": icon_mapper[value["Unit"]], - "value": value["NumericValue"], - "DataType": data[name]["DataType"], - "ModuleIndex": module["ModuleIndex"], - "ModuleType": module["ModuleType"], + **common_attrs, "platform": "select", "options": [ - x["Value"] - for x in self.modules[device_id][ - (module["ModuleIndex"], module["ModuleType"]) - ]["parameters"][value["ParameterID"]]["EnumValues"] + x["Value"] for x in module_data["EnumValues"] ], "optionsNames": [ - x["Name"] - for x in self.modules[device_id][ - (module["ModuleIndex"], module["ModuleType"]) - ]["parameters"][value["ParameterID"]]["EnumValues"] + x["Name"] for x in module_data["EnumValues"] ], } # SWITCH PLATFORM elif data[name]["DataType"] == 2: try: if ( - int( - self.modules[device_id][ - ( - module["ModuleIndex"], - module["ModuleType"], - ) - ]["parameters"][value["ParameterID"]][ - "MinValue" - ] - ) - == 0 - and int( - self.modules[device_id][ - ( - module["ModuleIndex"], - module["ModuleType"], - ) - ]["parameters"][value["ParameterID"]][ - "MaxValue" - ] - ) - == 1 + int(module_data["MinValue"]) == 0 + and int(module_data["MaxValue"]) == 1 ): self.data[device_id][name] = { - "friendlyName": data[name]["friendlyName"], - "ParameterID": value["ParameterID"], - "unit": value["Unit"], - "icon": icon_mapper[value["Unit"]], - "value": value["NumericValue"], - "DataType": data[name]["DataType"], - "ModuleIndex": module["ModuleIndex"], - "ModuleType": module["ModuleType"], + **common_attrs, "platform": "switch", } else: # Skip schedules continue + except (ValueError, TypeError): + continue + # Catches exception when converting to int when MinValur or MaxValue is Nones except Exception: continue - # if self.modules[(module["ModuleIndex"],module["ModuleType"])]["parameters"][value["ParameterID"]]["EnumValues"]: - # for entry in self.modules[(module["ModuleIndex"],module["ModuleType"])]["parameters"][value["ParameterID"]]["EnumValues"]: - # if entry["Value"] == round(value["NumericValue"]): - # self.data[value["ParameterID"]]["value"] = entry["Name"] - # break - for key, value in data.items(): - if key not in ["DeviceID", "Modules"]: - # Match only values, which are not writable (sensors) - if not value["IsWriteable"]: - # Only when mode == both. Combines data from web and api - # Only for single device, don't know how to handle multiple devices with scraping yet - if self.mode == "both" and len(self.data.keys()) < 2: + if key not in ["DeviceID", "Modules"] and not value["IsWriteable"]: + # Ony when mode == both. Combines data from web and api + # Only for single device, don't know how to handle multiple devices with scraping yet + if self.mode == "both" and len(self.data.keys()) < 2: + try: + temp = self.scrapingMapper[value["ParameterID"]] + except KeyError: + for scraped_entity in self.data[device_id].keys(): + scraped_entity_id = self.data[device_id][ + scraped_entity + ]["ParameterID"] + try: + if ( + fuzz.ratio( + value["friendlyName"], + scraped_entity_id.split("-")[1], + ) + >= 90 + ): + self.scrapingMapper.setdefault( + value["ParameterID"], [] + ).append(scraped_entity_id) + except IndexError: + pass + # Check if empty try: temp = self.scrapingMapper[value["ParameterID"]] except KeyError: - for scraped_entity in self.data[device_id].keys(): - scraped_entity_id = self.data[device_id][ - scraped_entity - ]["ParameterID"] - try: - if ( - fuzz.ratio( - value["friendlyName"], - scraped_entity_id.split("-")[1], - ) - >= 90 - ): - try: - self.scrapingMapper[ - value["ParameterID"] - ].append(scraped_entity_id) - except KeyError: - self.scrapingMapper[ - value["ParameterID"] - ] = [scraped_entity_id] - except IndexError: - pass - # Check if empty - try: - temp = self.scrapingMapper[value["ParameterID"]] - except KeyError: - self.scrapingMapper[value["ParameterID"]] = [ - value["friendlyName"] - ] - finally: - for scraped_entity in self.scrapingMapper[ - value["ParameterID"] - ]: - try: - self.data[device_id][scraped_entity] = { - "value": value["value"], - "name": self.data[device_id][ - scraped_entity - ]["name"], - "unit": self.data[device_id][ - scraped_entity - ]["unit"], - "icon": self.data[device_id][ - scraped_entity - ]["icon"], - "friendlyName": scraped_entity, - "ParameterID": scraped_entity, - "platform": "sensor", - } - except KeyError: - self.data[device_id][scraped_entity] = { - "value": value["value"], - "unit": value["unit"], - "icon": icon_mapper[value["unit"]], - "friendlyName": scraped_entity, - "name": scraped_entity, - "ParameterID": scraped_entity, - "platform": "sensor", - } - else: - self.data[device_id][key] = { - "value": value["value"], - "ParameterID": value["ParameterID"], - "unit": value["unit"], - "icon": icon_mapper[value["unit"]], - "friendlyName": value["friendlyName"], - "platform": "sensor", - } + self.scrapingMapper[value["ParameterID"]] = [ + value["friendlyName"] + ] + + finally: + for scraped_entity in self.scrapingMapper[ + value["ParameterID"] + ]: + sensor_dict = { + "value": value.get("value"), + "name": self.data[device_id] + .get(scraped_entity, {}) + .get("name"), + "unit": self.data[device_id] + .get(scraped_entity, {}) + .get("unit", value.get("unit")), + "icon": self.data[device_id] + .get(scraped_entity, {}) + .get( + "icon", icon_mapper.get(value.get("unit")) + ), + "friendlyName": scraped_entity, + "ParameterID": scraped_entity, + "platform": "sensor", + } + + self.data[device_id][scraped_entity] = sensor_dict + else: + self.data[device_id][key] = { + "value": value["value"], + "ParameterID": value["ParameterID"], + "unit": value["unit"], + "icon": icon_mapper[value["unit"]], + "friendlyName": value["friendlyName"], + "platform": "sensor", + } def friendly_name_mapper(self, value): friendly_name_dict = { From b4724e3093d79c61e108281234bf1f6d19db6c14 Mon Sep 17 00:00:00 2001 From: Erik Kastelec Date: Sat, 11 Mar 2023 17:40:11 +0100 Subject: [PATCH 12/26] Fixed spider session expiry bug #28 --- custom_components/wemportal/const.py | 4 +--- custom_components/wemportal/wemportalapi.py | 23 ++++++++++++++------- 2 files changed, 16 insertions(+), 11 deletions(-) diff --git a/custom_components/wemportal/const.py b/custom_components/wemportal/const.py index c33ad00..bfc97bd 100644 --- a/custom_components/wemportal/const.py +++ b/custom_components/wemportal/const.py @@ -12,9 +12,7 @@ CONF_MODE: Final = "mode" PLATFORMS = ["sensor", "number", "select", "switch"] REFRESH_WAIT_TIME: Final = 360 -DATA_GATHERING_ERROR: Final = "An error occurred while gathering data. \ - This issue should resolve by itself. If this problem persists, \ - open an issue at https://github.com/erikkastelec/hass-WEM-Portal/issues" +DATA_GATHERING_ERROR: Final = "An error occurred while gathering data.This issue should resolve by itself. If this problem persists,open an issue at https://github.com/erikkastelec/hass-WEM-Portal/issues" DEFAULT_CONF_SCAN_INTERVAL_API_VALUE: Final = 300 DEFAULT_CONF_SCAN_INTERVAL_VALUE: Final = 1800 DEFAULT_CONF_LANGUAGE_VALUE: Final = "en" diff --git a/custom_components/wemportal/wemportalapi.py b/custom_components/wemportal/wemportalapi.py index f7b5686..58c93f3 100644 --- a/custom_components/wemportal/wemportalapi.py +++ b/custom_components/wemportal/wemportalapi.py @@ -111,11 +111,15 @@ def fetch_data(self): and self.spider_wait_interval == 0 ): # Get data by web scraping - webscrapint_data = self.fetch_webscraping_data() - self.data[next(iter(self.data), "0000")] = webscrapint_data + try: + webscrapint_data = self.fetch_webscraping_data() + self.data[next(iter(self.data), "0000")] = webscrapint_data + + # Update last_scraping_update timestamp + self.last_scraping_update = datetime.now() + except Exception as exc: + _LOGGER.error(exc) - # Update last_scraping_update timestamp - self.last_scraping_update = datetime.now() else: # Reduce spider_wait_interval by 1 if > 0 self.spider_wait_interval = ( @@ -126,7 +130,10 @@ def fetch_data(self): # Get data using API if self.last_scraping_update is not None: - self.get_data() + try: + self.get_data() + except Exception as exc: + _LOGGER.error(exc) # Return data return self.data @@ -146,16 +153,16 @@ def fetch_webscraping_data(self): except IndexError as exc: self.spider_retry_count += 1 if self.spider_retry_count == 2: - self.webscraping_cookie = {} + self.webscraping_cookie = None self.spider_wait_interval = self.spider_retry_count raise WemPortalError(DATA_GATHERING_ERROR) from exc except AuthError as exc: - self.webscraping_cookie = {} + self.webscraping_cookie = None raise AuthError( "AuthenticationError: Could not login with provided username and password. Check if your config contains the right credentials" ) from exc except ExpiredSessionError as exc: - self.webscraping_cookie = {} + self.webscraping_cookie = None raise ExpiredSessionError( "ExpiredSessionError: Session expired. Next update will try to login again." ) from exc From 70bd453b2fa7f8d411b9137f288428cb187f90e9 Mon Sep 17 00:00:00 2001 From: Erik Kastelec Date: Sun, 12 Mar 2023 10:01:42 +0100 Subject: [PATCH 13/26] Improve logging to get to the cause of #28 --- custom_components/wemportal/wemportalapi.py | 38 +++++++++++++-------- 1 file changed, 23 insertions(+), 15 deletions(-) diff --git a/custom_components/wemportal/wemportalapi.py b/custom_components/wemportal/wemportalapi.py index 58c93f3..597a6c8 100644 --- a/custom_components/wemportal/wemportalapi.py +++ b/custom_components/wemportal/wemportalapi.py @@ -194,19 +194,34 @@ def api_login(self): response.raise_for_status() except reqs.exceptions.HTTPError as exc: self.valid_login = False + response_status, response_message = self.get_response_details(response) if response.status_code == 400: raise AuthError( - f"Authentication Error: Check if your login credentials are correct. Received response code: {response.status_code}, response: {response.content}" + f"Authentication Error: Check if your login credentials are correct. Received response code: {response.status_code}, response: {response.content}. Server returned internal status code: {response_status} and message: {response_message}" ) from exc elif response.status_code == 500: - raise ServerError("WemPortal server error") from exc + raise ServerError( + f"WemPortal server error: Server returned internal status code: {response_status} and message: {response_message}" + ) from exc else: raise UnknownAuthError( - f"Authentication Error: Encountered an unknown authentication error. Received response code: {response.status_code}, response: {response.content}" + f"Authentication Error: Encountered an unknown authentication error. Received response code: {response.status_code}, response: {response.content}. Server returned internal status code: {response_status} and message: {response_message}" ) from exc # Everything went fine, set valid_login to True self.valid_login = True + def get_response_details(self, response: reqs.Response): + server_status = "" + server_message = "" + try: + response_data = response.json() + # Status we get back from server + server_status = response_data["Status"] + server_message = response_data["Message"] + except KeyError: + pass + return server_status, server_message + def make_api_call( self, url: str, headers=None, data=None, login_retry=False, delay=1 ) -> reqs.Response: @@ -235,17 +250,10 @@ def make_api_call( delay=delay, ) else: - try: - response_data = response.json() - # Status we get back from server - server_status = response_data["Status"] - server_message = response_data["Message"] - raise WemPortalError( - f"{DATA_GATHERING_ERROR} Server returned status code: {server_status} and message: {server_message}" - ) from exc - # If there is no Status or Message in response - except KeyError: - raise WemPortalError(DATA_GATHERING_ERROR) from exc + server_status, server_message = self.get_response_details(response) + raise WemPortalError( + f"{DATA_GATHERING_ERROR} Server returned status code: {server_status} and message: {server_message}" + ) from exc return response @@ -336,7 +344,7 @@ def change_value( ) if response.status_code == 401 and login: self.api_login() - self.change_value( + self.change_value(device_id, parameter_id, module_index, module_type, numeric_value, login=False ) try: From 919d5de71aaab01eebb044425f62c4daef7d98e5 Mon Sep 17 00:00:00 2001 From: Erik Kastelec Date: Mon, 13 Mar 2023 14:10:52 +0100 Subject: [PATCH 14/26] Added API instance reload on update failure #28 --- custom_components/wemportal/__init__.py | 2 +- custom_components/wemportal/coordinator.py | 22 +++++++++++++++++++++- 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/custom_components/wemportal/__init__.py b/custom_components/wemportal/__init__.py index 5f08799..6e29e95 100644 --- a/custom_components/wemportal/__init__.py +++ b/custom_components/wemportal/__init__.py @@ -98,7 +98,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) # Create custom coordinator coordinator = WemPortalDataUpdateCoordinator( - hass, api, timedelta(seconds=update_interval) + hass, api, entry, timedelta(seconds=update_interval) ) await coordinator.async_config_entry_first_refresh() diff --git a/custom_components/wemportal/coordinator.py b/custom_components/wemportal/coordinator.py index 370385c..f028932 100644 --- a/custom_components/wemportal/coordinator.py +++ b/custom_components/wemportal/coordinator.py @@ -2,6 +2,7 @@ from __future__ import annotations import async_timeout +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import ( DataUpdateCoordinator, @@ -9,13 +10,20 @@ ) from .exceptions import WemPortalError from .const import _LOGGER, DEFAULT_TIMEOUT +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from .wemportalapi import WemPortalApi class WemPortalDataUpdateCoordinator(DataUpdateCoordinator): """DataUpdateCoordinator for wemportal component""" - def __init__(self, hass: HomeAssistant, api: WemPortalApi, update_interval) -> None: + def __init__( + self, + hass: HomeAssistant, + api: WemPortalApi, + config_entry: ConfigEntry, + update_interval, + ) -> None: """Initialize DataUpdateCoordinator for the wemportal component""" super().__init__( hass, @@ -25,6 +33,7 @@ def __init__(self, hass: HomeAssistant, api: WemPortalApi, update_interval) -> N ) self.api = api self.has = hass + self.config_entry = config_entry async def _async_update_data(self): """Fetch data from the wemportal api""" @@ -33,4 +42,15 @@ async def _async_update_data(self): return await self.hass.async_add_executor_job(self.api.fetch_data) except WemPortalError as exc: _LOGGER.error("Error fetching data from wemportal", exc_info=exc) + _LOGGER.error("Creating new wemportal api instance") + # TODO: This is a temporary solution and should be removed when api cause from #28 is resolved + try: + new_api = WemPortalApi( + self.config_entry.data.get(CONF_USERNAME), + self.config_entry.data.get(CONF_PASSWORD), + self.config_entry.options, + ) + api = new_api + except Exception: + pass raise UpdateFailed from exc From 8e78c46b2844df0517ece1d142335d2f0cfbcf40 Mon Sep 17 00:00:00 2001 From: Erik Kastelec Date: Wed, 15 Mar 2023 20:09:07 +0100 Subject: [PATCH 15/26] Fixed api not being replaced #28 --- custom_components/wemportal/coordinator.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/custom_components/wemportal/coordinator.py b/custom_components/wemportal/coordinator.py index f028932..069f3f2 100644 --- a/custom_components/wemportal/coordinator.py +++ b/custom_components/wemportal/coordinator.py @@ -32,7 +32,7 @@ def __init__( update_interval=update_interval, ) self.api = api - self.has = hass + self.hass = hass self.config_entry = config_entry async def _async_update_data(self): @@ -50,7 +50,7 @@ async def _async_update_data(self): self.config_entry.data.get(CONF_PASSWORD), self.config_entry.options, ) - api = new_api + self.api = new_api except Exception: pass raise UpdateFailed from exc From 6c54b184c6d6c260ec077b2f8ebc7e534df8b37d Mon Sep 17 00:00:00 2001 From: Erik Kastelec Date: Thu, 23 Mar 2023 21:07:26 +0100 Subject: [PATCH 16/26] Bug fix for reauthentication #28 --- custom_components/wemportal/coordinator.py | 38 +++++++++++++-------- custom_components/wemportal/wemportalapi.py | 23 ++++++++----- 2 files changed, 38 insertions(+), 23 deletions(-) diff --git a/custom_components/wemportal/coordinator.py b/custom_components/wemportal/coordinator.py index 069f3f2..4b8338c 100644 --- a/custom_components/wemportal/coordinator.py +++ b/custom_components/wemportal/coordinator.py @@ -8,7 +8,7 @@ DataUpdateCoordinator, UpdateFailed, ) -from .exceptions import WemPortalError +from .exceptions import ServerError, WemPortalError from .const import _LOGGER, DEFAULT_TIMEOUT from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from .wemportalapi import WemPortalApi @@ -41,16 +41,26 @@ async def _async_update_data(self): try: return await self.hass.async_add_executor_job(self.api.fetch_data) except WemPortalError as exc: - _LOGGER.error("Error fetching data from wemportal", exc_info=exc) - _LOGGER.error("Creating new wemportal api instance") - # TODO: This is a temporary solution and should be removed when api cause from #28 is resolved - try: - new_api = WemPortalApi( - self.config_entry.data.get(CONF_USERNAME), - self.config_entry.data.get(CONF_PASSWORD), - self.config_entry.options, - ) - self.api = new_api - except Exception: - pass - raise UpdateFailed from exc + + if isinstance(exc.__cause__, ServerError): + _LOGGER.error("Creating new wemportal api instance") + # TODO: This is a temporary solution and should be removed when api cause from #28 is resolved + try: + new_api = WemPortalApi( + self.config_entry.data.get(CONF_USERNAME), + self.config_entry.data.get(CONF_PASSWORD), + self.config_entry.options, + ) + self.api = new_api + except Exception as exc2: + raise UpdateFailed from exc2 + try: + return await self.hass.async_add_executor_job(self.api.fetch_data) + except WemPortalError as exc2: + _LOGGER.error("Error fetching data from wemportal", exc_info=exc) + raise UpdateFailed from exc2 + else: + _LOGGER.error("Error fetching data from wemportal", exc_info=exc) + raise UpdateFailed from exc + + diff --git a/custom_components/wemportal/wemportalapi.py b/custom_components/wemportal/wemportalapi.py index 597a6c8..48bba33 100644 --- a/custom_components/wemportal/wemportalapi.py +++ b/custom_components/wemportal/wemportalapi.py @@ -195,7 +195,11 @@ def api_login(self): except reqs.exceptions.HTTPError as exc: self.valid_login = False response_status, response_message = self.get_response_details(response) - if response.status_code == 400: + if response is None: + raise UnknownAuthError( + f"Authentication Error: Encountered an unknown authentication error." + ) from exc + elif response.status_code == 400: raise AuthError( f"Authentication Error: Check if your login credentials are correct. Received response code: {response.status_code}, response: {response.content}. Server returned internal status code: {response_status} and message: {response_message}" ) from exc @@ -213,13 +217,14 @@ def api_login(self): def get_response_details(self, response: reqs.Response): server_status = "" server_message = "" - try: - response_data = response.json() - # Status we get back from server - server_status = response_data["Status"] - server_message = response_data["Message"] - except KeyError: - pass + if response: + try: + response_data = response.json() + # Status we get back from server + server_status = response_data["Status"] + server_message = response_data["Message"] + except KeyError: + pass return server_status, server_message def make_api_call( @@ -238,7 +243,7 @@ def make_api_call( ) response.raise_for_status() except Exception as exc: - if response.status_code == 401 and not login_retry: + if response and response.status_code == 401 and not login_retry: self.api_login() headers = headers or self.headers time.sleep(delay) From 452b5f57fa057f11267b7f47c28fae6595e736b1 Mon Sep 17 00:00:00 2001 From: Erik Kastelec Date: Fri, 24 Mar 2023 09:25:32 +0100 Subject: [PATCH 17/26] Bug fix for error on select platform value change --- custom_components/wemportal/number.py | 4 ++-- custom_components/wemportal/select.py | 3 ++- custom_components/wemportal/sensor.py | 2 +- custom_components/wemportal/switch.py | 4 ++-- 4 files changed, 7 insertions(+), 6 deletions(-) diff --git a/custom_components/wemportal/number.py b/custom_components/wemportal/number.py index 82ece88..fabdb38 100644 --- a/custom_components/wemportal/number.py +++ b/custom_components/wemportal/number.py @@ -61,7 +61,7 @@ class WemPortalNumber(CoordinatorEntity, NumberEntity): def __init__( self, coordinator, config_entry: ConfigEntry, device_id, _unique_id, entity_data - ): + ) -> None: """Initialize the sensor.""" super().__init__(coordinator) self._config_entry = config_entry @@ -147,7 +147,7 @@ def state(self): return state return 0 except KeyError: - _LOGGER.error("Can't find %s", self._unique_id) + _LOGGER.warning("Can't find %s", self._unique_id) _LOGGER.debug("Sensor data %s", self.coordinator.data) return None diff --git a/custom_components/wemportal/select.py b/custom_components/wemportal/select.py index d5e4cc0..7051e68 100644 --- a/custom_components/wemportal/select.py +++ b/custom_components/wemportal/select.py @@ -62,7 +62,7 @@ class WemPortalSelect(CoordinatorEntity, SelectEntity): def __init__( self, coordinator, config_entry: ConfigEntry, device_id, _unique_id, entity_data - ): + ) -> None: """Initialize the sensor.""" super().__init__(coordinator) self._last_updated = None @@ -83,6 +83,7 @@ async def async_select_option(self, option: str) -> None: """Call the API to change the parameter value""" await self.hass.async_add_executor_job( self.coordinator.api.change_value, + self._device_id, self._parameter_id, self._module_index, self._module_type, diff --git a/custom_components/wemportal/sensor.py b/custom_components/wemportal/sensor.py index 980520d..b31739d 100644 --- a/custom_components/wemportal/sensor.py +++ b/custom_components/wemportal/sensor.py @@ -135,7 +135,7 @@ def state(self): return state return 0 except KeyError: - _LOGGER.error("Can't find %s", self._unique_id) + _LOGGER.warning("Can't find %s", self._unique_id) _LOGGER.debug("Sensor data %s", self.coordinator.data) return None diff --git a/custom_components/wemportal/switch.py b/custom_components/wemportal/switch.py index 1d4e099..209b791 100644 --- a/custom_components/wemportal/switch.py +++ b/custom_components/wemportal/switch.py @@ -61,7 +61,7 @@ class WemPortalSwitch(CoordinatorEntity, SwitchEntity): def __init__( self, coordinator, config_entry: ConfigEntry, device_id, _unique_id, entity_data - ): + ) -> None: """Initialize the sensor.""" super().__init__(coordinator) self._last_updated = None @@ -170,7 +170,7 @@ def state(self): return self._state except KeyError: - _LOGGER.error("Can't find %s", self._unique_id) + _LOGGER.warning("Can't find %s", self._unique_id) _LOGGER.debug("Sensor data %s", self.coordinator.data) return None From 74a81b431b88686c268bf5c5778ee12407f257fa Mon Sep 17 00:00:00 2001 From: Erik Kastelec Date: Sat, 25 Mar 2023 16:18:12 +0100 Subject: [PATCH 18/26] Fix select platform bug #28 --- custom_components/wemportal/select.py | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/custom_components/wemportal/select.py b/custom_components/wemportal/select.py index 7051e68..4995233 100644 --- a/custom_components/wemportal/select.py +++ b/custom_components/wemportal/select.py @@ -9,7 +9,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import DOMAIN +from .const import DOMAIN, _LOGGER from . import get_wemportal_unique_id @@ -116,11 +116,19 @@ def options(self) -> list[str]: @property def current_option(self) -> str: """Return the current option.""" - return self._options_names[ - self._options.index( - self.coordinator.data[self._device_id][self._name]["value"] - ) - ] + try: + options = self._options_names[ + self._options.index( + self.coordinator.data[self._device_id][self._name]["value"] + ) + ] + if options: + return options + except KeyError: + _LOGGER.warning("Can't find %s", self._unique_id) + _LOGGER.debug("Sensor data %s", self.coordinator.data) + + return None async def async_added_to_hass(self): """When entity is added to hass.""" From ebd1619208b36b9fa7192c7db4d121fd6927865a Mon Sep 17 00:00:00 2001 From: Erik Kastelec Date: Thu, 30 Mar 2023 18:10:25 +0200 Subject: [PATCH 19/26] Fixed data update issue caused by api errors #28 --- custom_components/wemportal/number.py | 79 ++++++------------ custom_components/wemportal/select.py | 77 ++++++++---------- custom_components/wemportal/sensor.py | 85 +++++++------------ custom_components/wemportal/switch.py | 90 ++++++++------------- custom_components/wemportal/wemportalapi.py | 11 ++- 5 files changed, 128 insertions(+), 214 deletions(-) diff --git a/custom_components/wemportal/number.py b/custom_components/wemportal/number.py index fabdb38..8674fe6 100644 --- a/custom_components/wemportal/number.py +++ b/custom_components/wemportal/number.py @@ -4,7 +4,7 @@ from homeassistant.components.number import NumberEntity from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from . import get_wemportal_unique_id @@ -66,18 +66,19 @@ def __init__( super().__init__(coordinator) self._config_entry = config_entry self._device_id = device_id - self._name = _unique_id - self._unique_id = get_wemportal_unique_id( - self._config_entry.entry_id, str(self._device_id), str(self._name) + self._attr_name = _unique_id + self._attr_unique_id = get_wemportal_unique_id( + self._config_entry.entry_id, str(self._device_id), str(self._attr_name) ) self._last_updated = None self._parameter_id = entity_data["ParameterID"] - self._icon = entity_data["icon"] - self._unit = entity_data["unit"] - self._state = self.state + self._attr_icon = entity_data["icon"] + self._attr_native_unit_of_measurement = entity_data["unit"] + self._attr_native_value = entity_data["value"] self._attr_native_min_value = entity_data["min_value"] self._attr_native_max_value = entity_data["max_value"] self._attr_native_step = entity_data["step"] + self._attr_should_poll = False self._module_index = entity_data["ModuleIndex"] self._module_type = entity_data["ModuleType"] @@ -91,8 +92,7 @@ async def async_set_native_value(self, value: float) -> None: self._module_type, value, ) - self._state = value - self.coordinator.data[self._device_id][self._name]["value"] = value + self._attr_native_value = value # type: ignore self.async_write_ha_state() @property @@ -107,64 +107,31 @@ def device_info(self) -> DeviceInfo: "manufacturer": "Weishaupt", } - @property - def should_poll(self): - """No need to poll. Coordinator notifies entity of updates.""" - return False - @property def available(self): """Return if entity is available.""" return self.coordinator.last_update_success - async def async_added_to_hass(self): - """When entity is added to hass.""" - self.async_on_remove( - self.coordinator.async_add_listener(self.async_write_ha_state) - ) - - @property - def name(self): - """Return the name of the sensor.""" - return self._name + # async def async_added_to_hass(self): + # """When entity is added to hass.""" + # self.async_on_remove( + # self.coordinator.async_add_listener(self._handle_coordinator_update) + # ) - @property - def unique_id(self): - """Return the unique ID of the binary sensor.""" - return self._unique_id + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" - @property - def icon(self): - """Icon to use in the frontend, if any.""" - return self._icon - - @property - def state(self): - """Return the state of the sensor.""" try: - state = self.coordinator.data[self._device_id][self._name]["value"] - if state: - return state - return 0 + self._attr_native_value = self.coordinator.data[self._device_id][ + self._attr_name + ]["value"] except KeyError: - _LOGGER.warning("Can't find %s", self._unique_id) + self._attr_native_value = None + _LOGGER.warning("Can't find %s", self._attr_unique_id) _LOGGER.debug("Sensor data %s", self.coordinator.data) - return None - @property - def native_unit_of_measurement(self): - """Return the unit of measurement of this entity, if any.""" - return self._unit - - # @property - # def state_class(self): - # """Return the state class of this entity, if any.""" - # if self._unit in ("°C", "kW", "W", "%"): - # return STATE_CLASS_MEASUREMENT - # elif self._unit in ("kWh", "Wh"): - # return STATE_CLASS_TOTAL_INCREASING - # else: - # return None + self.async_write_ha_state() @property def extra_state_attributes(self): diff --git a/custom_components/wemportal/select.py b/custom_components/wemportal/select.py index 4995233..a36f3ac 100644 --- a/custom_components/wemportal/select.py +++ b/custom_components/wemportal/select.py @@ -4,7 +4,7 @@ from homeassistant.components.select import SelectEntity from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -68,16 +68,17 @@ def __init__( self._last_updated = None self._config_entry = config_entry self._device_id = device_id - self._name = _unique_id - self._unique_id = get_wemportal_unique_id( - self._config_entry.entry_id, str(self._device_id), str(self._name) + self._attr_name = _unique_id + self._attr_unique_id = get_wemportal_unique_id( + self._config_entry.entry_id, str(self._device_id), str(self._attr_name) ) self._parameter_id = entity_data["ParameterID"] - self._icon = entity_data["icon"] + self._attr_icon = entity_data["icon"] self._options = entity_data["options"] self._options_names = entity_data["optionsNames"] self._module_index = entity_data["ModuleIndex"] self._module_type = entity_data["ModuleType"] + self._attr_current_option = entity_data["value"] async def async_select_option(self, option: str) -> None: """Call the API to change the parameter value""" @@ -90,9 +91,7 @@ async def async_select_option(self, option: str) -> None: self._options[self._options_names.index(option)], ) - self.coordinator.data[self._device_id][self._name]["value"] = self._options[ - self._options_names.index(option) - ] + self._attr_current_option = option self.async_write_ha_state() @@ -108,48 +107,21 @@ def device_info(self) -> DeviceInfo: "manufacturer": "Weishaupt", } + @property + def available(self): + """Return if entity is available.""" + return self.coordinator.last_update_success + @property def options(self) -> list[str]: """Return list of available options.""" return self._options_names - @property - def current_option(self) -> str: - """Return the current option.""" - try: - options = self._options_names[ - self._options.index( - self.coordinator.data[self._device_id][self._name]["value"] - ) - ] - if options: - return options - except KeyError: - _LOGGER.warning("Can't find %s", self._unique_id) - _LOGGER.debug("Sensor data %s", self.coordinator.data) - - return None - - async def async_added_to_hass(self): - """When entity is added to hass.""" - self.async_on_remove( - self.coordinator.async_add_listener(self.async_write_ha_state) - ) - - @property - def name(self): - """Return the name of the sensor.""" - return self._name - - @property - def unique_id(self): - """Return the unique ID of the binary sensor.""" - return self._unique_id - - @property - def icon(self): - """Icon to use in the frontend, if any.""" - return self._icon + # async def async_added_to_hass(self): + # """When entity is added to hass.""" + # self.async_on_remove( + # self.coordinator.async_add_listener(self._handle_coordinator_update) + # ) @property def extra_state_attributes(self): @@ -159,6 +131,21 @@ def extra_state_attributes(self): attr["Last Updated"] = self._last_updated return attr + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + + try: + self._attr_current_option = self.coordinator.data[self._device_id][ + self._attr_name + ]["value"] + except KeyError: + self._attr_current_option = None + _LOGGER.warning("Can't find %s", self._attr_unique_id) + _LOGGER.debug("Sensor data %s", self.coordinator.data) + + self.async_write_ha_state() + async def async_update(self): """Update Entity Only used by the generic entity update service.""" diff --git a/custom_components/wemportal/sensor.py b/custom_components/wemportal/sensor.py index b31739d..e06bb84 100644 --- a/custom_components/wemportal/sensor.py +++ b/custom_components/wemportal/sensor.py @@ -9,7 +9,7 @@ ) from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -57,10 +57,7 @@ async def async_setup_entry( coordinator, config_entry, device_id, unique_id, values ) ) - try: - async_add_entities(entities) - except Exception as e: - print(e) + async_add_entities(entities) class WemPortalSensor(CoordinatorEntity, SensorEntity): @@ -68,20 +65,21 @@ class WemPortalSensor(CoordinatorEntity, SensorEntity): def __init__( self, coordinator, config_entry: ConfigEntry, device_id, _unique_id, entity_data - ): + ) -> None: """Initialize the sensor.""" super().__init__(coordinator) self._last_updated = None self._config_entry = config_entry self._device_id = device_id - self._name = _unique_id - self._unique_id = get_wemportal_unique_id( - self._config_entry.entry_id, str(self._device_id), str(self._name) + self._attr_name = _unique_id + self._attr_unique_id = get_wemportal_unique_id( + self._config_entry.entry_id, str(self._device_id), str(self._attr_name) ) self._parameter_id = entity_data["ParameterID"] - self._icon = entity_data["icon"] - self._unit = entity_data["unit"] - self._state = self.state + self._attr_icon = entity_data["icon"] + self._attr_native_unit_of_measurement = entity_data["unit"] + self._attr_native_value = entity_data["value"] + self._attr_should_poll = False @property def device_info(self) -> DeviceInfo: @@ -95,65 +93,42 @@ def device_info(self) -> DeviceInfo: "manufacturer": "Weishaupt", } - @property - def should_poll(self): - """No need to poll. Coordinator notifies entity of updates.""" - return False - @property def available(self): """Return if entity is available.""" return self.coordinator.last_update_success - async def async_added_to_hass(self): - """When entity is added to hass.""" - self.async_on_remove( - self.coordinator.async_add_listener(self.async_write_ha_state) - ) - - @property - def name(self): - """Return the name of the sensor.""" - return self._name - - @property - def unique_id(self): - """Return the unique ID of the sensor.""" - return self._unique_id + # async def async_added_to_hass(self): + # """When entity is added to hass.""" + # self.async_on_remove( + # self.coordinator.async_add_listener(self._handle_coordinator_update) + # ) - @property - def icon(self): - """Icon to use in the frontend, if any.""" - return self._icon + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" - @property - def state(self): - """Return the state of the sensor.""" try: - state = self.coordinator.data[self._device_id][self._name]["value"] - if state: - return state - return 0 + self._attr_native_value = self.coordinator.data[self._device_id][ + self._attr_name + ]["value"] except KeyError: - _LOGGER.warning("Can't find %s", self._unique_id) + self._attr_native_value = None + _LOGGER.warning("Can't find %s", self._attr_unique_id) _LOGGER.debug("Sensor data %s", self.coordinator.data) - return None - @property - def unit_of_measurement(self): - """Return the unit of measurement of this entity, if any.""" - return self._unit + self.async_write_ha_state() @property def device_class(self): """Return the device_class of this entity.""" - if self._unit == "°C": + if self._attr_native_unit_of_measurement == "°C": return SensorDeviceClass.TEMPERATURE - elif self._unit in ("kWh", "Wh"): + elif self._attr_native_unit_of_measurement in ("kWh", "Wh"): return SensorDeviceClass.ENERGY - elif self._unit in ("kW", "W"): + elif self._attr_native_unit_of_measurement in ("kW", "W"): return SensorDeviceClass.POWER - elif self._unit == "%": + elif self._attr_native_unit_of_measurement == "%": return SensorDeviceClass.POWER_FACTOR else: return None @@ -161,9 +136,9 @@ def device_class(self): @property def state_class(self): """Return the state class of this entity, if any.""" - if self._unit in ("°C", "kW", "W", "%"): + if self._attr_native_unit_of_measurement in ("°C", "kW", "W", "%"): return SensorStateClass.MEASUREMENT - elif self._unit in ("kWh", "Wh"): + elif self._attr_native_unit_of_measurement in ("kWh", "Wh"): return SensorStateClass.TOTAL_INCREASING else: return None diff --git a/custom_components/wemportal/switch.py b/custom_components/wemportal/switch.py index 209b791..c36f97b 100644 --- a/custom_components/wemportal/switch.py +++ b/custom_components/wemportal/switch.py @@ -4,7 +4,7 @@ from homeassistant.components.switch import SwitchEntity from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -60,22 +60,29 @@ class WemPortalSwitch(CoordinatorEntity, SwitchEntity): """Representation of a WEM Portal Sensor.""" def __init__( - self, coordinator, config_entry: ConfigEntry, device_id, _unique_id, entity_data + self, + coordinator: CoordinatorEntity, + config_entry: ConfigEntry, + device_id, + _unique_id, + entity_data, ) -> None: """Initialize the sensor.""" super().__init__(coordinator) self._last_updated = None self._config_entry = config_entry self._device_id = device_id - self._name = _unique_id - self._unique_id = get_wemportal_unique_id( - self._config_entry.entry_id, str(self._device_id), str(self._name) + self._attr_name = _unique_id + self._attr_unique_id = get_wemportal_unique_id( + self._config_entry.entry_id, str(self._device_id), str(self._attr_name) ) - self._name = _unique_id + self._parameter_id = entity_data["ParameterID"] - self._icon = entity_data["icon"] - self._unit = entity_data["unit"] - self._state = self.state + self._attr_icon = entity_data["icon"] + self._attr_unit = entity_data["unit"] + self._attr_state = entity_data["value"] + self._attr_should_poll = False + self._attr_device_class = "switch" # type: ignore self._module_index = entity_data["ModuleIndex"] self._module_type = entity_data["ModuleType"] @@ -100,8 +107,7 @@ async def async_turn_on(self, **kwargs) -> None: self._module_type, 1.0, ) - self._state = "on" - self.coordinator.data[self._device_id][self._name]["value"] = 1 + self._attr_state = "on" # type: ignore self.async_write_ha_state() async def async_turn_off(self, **kwargs) -> None: @@ -113,21 +119,15 @@ async def async_turn_off(self, **kwargs) -> None: self._module_type, 0.0, ) - self._state = "off" - self.coordinator.data[self._unique_id]["value"] = 0 + self._attr_state = "off" # type: ignore self.async_write_ha_state() @property def is_on(self) -> bool: """Return the state of the switch.""" - if self._state == 1.0: + if self._attr_state == 1.0: return True - else: - return False - @property - def should_poll(self): - """No need to poll. Coordinator notifies entity of updates.""" return False @property @@ -135,48 +135,28 @@ def available(self): """Return if entity is available.""" return self.coordinator.last_update_success - async def async_added_to_hass(self): - """When entity is added to hass.""" - self.async_on_remove( - self.coordinator.async_add_listener(self.async_write_ha_state) - ) - - @property - def name(self): - """Return the name of the sensor.""" - return self._name - - @property - def unique_id(self): - """Return the unique ID of the binary sensor.""" - return self._unique_id + # async def async_added_to_hass(self): + # """When entity is added to hass.""" + # self.async_on_remove( + # self.coordinator.async_add_listener(self._handle_coordinator_update) + # ) - @property - def icon(self): - """Icon to use in the frontend, if any.""" - return self._icon + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" - @property - def state(self): - """Return the state of the sensor.""" try: - self._state = self.coordinator.data[self._device_id][self._name]["value"] - try: - if int(self._state) == 1: - return "on" - else: - return "off" - except ValueError: - return self._state - + temp_val = self.coordinator.data[self._device_id][self._attr_name]["value"] + if temp_val == 1: + self._attr_state = "on" # type: ignore + else: + self._attr_state = "off" # type: ignore except KeyError: - _LOGGER.warning("Can't find %s", self._unique_id) + self._attr_state = None + _LOGGER.warning("Can't find %s", self._attr_unique_id) _LOGGER.debug("Sensor data %s", self.coordinator.data) - return None - @property - def device_class(self): - return "switch" + self.async_write_ha_state() @property def extra_state_attributes(self): diff --git a/custom_components/wemportal/wemportalapi.py b/custom_components/wemportal/wemportalapi.py index 48bba33..fad7dda 100644 --- a/custom_components/wemportal/wemportalapi.py +++ b/custom_components/wemportal/wemportalapi.py @@ -40,7 +40,7 @@ class WemPortalApi: """Wrapper class for Weishaupt WEM Portal""" - def __init__(self, username, password, config={}): + def __init__(self, username, password, config={}) -> None: self.data = {} self.username = username self.password = password @@ -349,8 +349,13 @@ def change_value( ) if response.status_code == 401 and login: self.api_login() - self.change_value(device_id, - parameter_id, module_index, module_type, numeric_value, login=False + self.change_value( + device_id, + parameter_id, + module_index, + module_type, + numeric_value, + login=False, ) try: response.raise_for_status() From e3089455bed25352d8b48b637b4595c45f67c6a2 Mon Sep 17 00:00:00 2001 From: Erik Kastelec Date: Fri, 31 Mar 2023 08:43:43 +0200 Subject: [PATCH 20/26] Added forbidden error handling for API #28 --- custom_components/wemportal/coordinator.py | 15 +++++++------ custom_components/wemportal/exceptions.py | 8 +++++++ custom_components/wemportal/wemportalapi.py | 25 ++++++++++++--------- 3 files changed, 31 insertions(+), 17 deletions(-) diff --git a/custom_components/wemportal/coordinator.py b/custom_components/wemportal/coordinator.py index 4b8338c..7d73de9 100644 --- a/custom_components/wemportal/coordinator.py +++ b/custom_components/wemportal/coordinator.py @@ -8,7 +8,7 @@ DataUpdateCoordinator, UpdateFailed, ) -from .exceptions import ServerError, WemPortalError +from .exceptions import ForbiddenError, ServerError, WemPortalError from .const import _LOGGER, DEFAULT_TIMEOUT from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from .wemportalapi import WemPortalApi @@ -41,8 +41,7 @@ async def _async_update_data(self): try: return await self.hass.async_add_executor_job(self.api.fetch_data) except WemPortalError as exc: - - if isinstance(exc.__cause__, ServerError): + if isinstance(exc.__cause__, (ServerError, ForbiddenError)): _LOGGER.error("Creating new wemportal api instance") # TODO: This is a temporary solution and should be removed when api cause from #28 is resolved try: @@ -55,12 +54,14 @@ async def _async_update_data(self): except Exception as exc2: raise UpdateFailed from exc2 try: - return await self.hass.async_add_executor_job(self.api.fetch_data) + return await self.hass.async_add_executor_job( + self.api.fetch_data + ) except WemPortalError as exc2: - _LOGGER.error("Error fetching data from wemportal", exc_info=exc) + _LOGGER.error( + "Error fetching data from wemportal", exc_info=exc + ) raise UpdateFailed from exc2 else: _LOGGER.error("Error fetching data from wemportal", exc_info=exc) raise UpdateFailed from exc - - diff --git a/custom_components/wemportal/exceptions.py b/custom_components/wemportal/exceptions.py index 45819d7..0fc770a 100644 --- a/custom_components/wemportal/exceptions.py +++ b/custom_components/wemportal/exceptions.py @@ -8,6 +8,7 @@ class WemPortalError(HomeAssistantError): Custom exception for WEM Portal errors """ + class AuthError(WemPortalError): """Exception to indicate an authentication error.""" @@ -15,14 +16,21 @@ class AuthError(WemPortalError): class UnknownAuthError(WemPortalError): """Exception to indicate an unknown authentication error.""" + class ServerError(WemPortalError): """Exception to indicate a server error.""" + +class ForbiddenError(WemPortalError): + """Exception to indicate a forbidden error (403).""" + + class ExpiredSessionError(WemPortalError): """ Custom exception for expired session errors """ + class ParameterChangeError(WemPortalError): """ Custom exception for parameter change errors diff --git a/custom_components/wemportal/wemportalapi.py b/custom_components/wemportal/wemportalapi.py index fad7dda..fe344eb 100644 --- a/custom_components/wemportal/wemportalapi.py +++ b/custom_components/wemportal/wemportalapi.py @@ -12,10 +12,11 @@ import requests as reqs import scrapyscript from fuzzywuzzy import fuzz -from homeassistant.const import CONF_PASSWORD, CONF_SCAN_INTERVAL, CONF_USERNAME +from homeassistant.const import CONF_SCAN_INTERVAL from scrapy import FormRequest, Spider, Request from .exceptions import ( AuthError, + ForbiddenError, UnknownAuthError, WemPortalError, ExpiredSessionError, @@ -73,7 +74,7 @@ def __init__(self, username, password, config={}) -> None: "X-Api-Version": "2.0.0.0", "Accept": "*/*", } - self.scrapingMapper = {} + self.scraping_mapper = {} # Used to keep track of how many update intervals to wait before retrying spider self.spider_wait_interval = 0 @@ -197,12 +198,16 @@ def api_login(self): response_status, response_message = self.get_response_details(response) if response is None: raise UnknownAuthError( - f"Authentication Error: Encountered an unknown authentication error." + "Authentication Error: Encountered an unknown authentication error." ) from exc elif response.status_code == 400: raise AuthError( f"Authentication Error: Check if your login credentials are correct. Received response code: {response.status_code}, response: {response.content}. Server returned internal status code: {response_status} and message: {response_message}" ) from exc + elif response.status_code == 403: + raise ForbiddenError( + f"WemPortal forbidden error: Server returned internal status code: {response_status} and message: {response_message}" + ) from exc elif response.status_code == 500: raise ServerError( f"WemPortal server error: Server returned internal status code: {response_status} and message: {response_message}" @@ -243,7 +248,7 @@ def make_api_call( ) response.raise_for_status() except Exception as exc: - if response and response.status_code == 401 and not login_retry: + if response and response.status_code in (401, 403) and not login_retry: self.api_login() headers = headers or self.headers time.sleep(delay) @@ -347,7 +352,7 @@ def change_value( headers=headers, data=json.dumps(data), ) - if response.status_code == 401 and login: + if response.status_code in (401, 403) and login: self.api_login() self.change_value( device_id, @@ -516,7 +521,7 @@ def get_data(self): # Only for single device, don't know how to handle multiple devices with scraping yet if self.mode == "both" and len(self.data.keys()) < 2: try: - temp = self.scrapingMapper[value["ParameterID"]] + temp = self.scraping_mapper[value["ParameterID"]] except KeyError: for scraped_entity in self.data[device_id].keys(): scraped_entity_id = self.data[device_id][ @@ -530,21 +535,21 @@ def get_data(self): ) >= 90 ): - self.scrapingMapper.setdefault( + self.scraping_mapper.setdefault( value["ParameterID"], [] ).append(scraped_entity_id) except IndexError: pass # Check if empty try: - temp = self.scrapingMapper[value["ParameterID"]] + temp = self.scraping_mapper[value["ParameterID"]] except KeyError: - self.scrapingMapper[value["ParameterID"]] = [ + self.scraping_mapper[value["ParameterID"]] = [ value["friendlyName"] ] finally: - for scraped_entity in self.scrapingMapper[ + for scraped_entity in self.scraping_mapper[ value["ParameterID"] ]: sensor_dict = { From d332142f0636c03eb06ce925c7cabc1e61f3ee7a Mon Sep 17 00:00:00 2001 From: Erik Kastelec Date: Sun, 2 Apr 2023 19:35:48 +0200 Subject: [PATCH 21/26] Bug fix for #28 --- custom_components/wemportal/coordinator.py | 27 ++++++++++++---------- 1 file changed, 15 insertions(+), 12 deletions(-) diff --git a/custom_components/wemportal/coordinator.py b/custom_components/wemportal/coordinator.py index 7d73de9..572db90 100644 --- a/custom_components/wemportal/coordinator.py +++ b/custom_components/wemportal/coordinator.py @@ -41,18 +41,19 @@ async def _async_update_data(self): try: return await self.hass.async_add_executor_job(self.api.fetch_data) except WemPortalError as exc: + # if isinstance(exc.__cause__, (ServerError, ForbiddenError)): + _LOGGER.error("Creating new wemportal api instance") + # TODO: This is a temporary solution and should be removed when api cause from #28 is resolved + try: + new_api = WemPortalApi( + self.config_entry.data.get(CONF_USERNAME), + self.config_entry.data.get(CONF_PASSWORD), + self.config_entry.options, + ) + self.api = new_api + except Exception as exc2: + raise UpdateFailed from exc2 if isinstance(exc.__cause__, (ServerError, ForbiddenError)): - _LOGGER.error("Creating new wemportal api instance") - # TODO: This is a temporary solution and should be removed when api cause from #28 is resolved - try: - new_api = WemPortalApi( - self.config_entry.data.get(CONF_USERNAME), - self.config_entry.data.get(CONF_PASSWORD), - self.config_entry.options, - ) - self.api = new_api - except Exception as exc2: - raise UpdateFailed from exc2 try: return await self.hass.async_add_executor_job( self.api.fetch_data @@ -63,5 +64,7 @@ async def _async_update_data(self): ) raise UpdateFailed from exc2 else: - _LOGGER.error("Error fetching data from wemportal", exc_info=exc) raise UpdateFailed from exc + # else: + # _LOGGER.error("Error fetching data from wemportal", exc_info=exc) + # raise UpdateFailed from exc From 557d7f834fc837aa5ba1e9f24f341c6d876cb34b Mon Sep 17 00:00:00 2001 From: Erik Kastelec Date: Thu, 6 Apr 2023 15:51:39 +0200 Subject: [PATCH 22/26] Fix for API failure #28 --- custom_components/wemportal/wemportalapi.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/custom_components/wemportal/wemportalapi.py b/custom_components/wemportal/wemportalapi.py index fe344eb..d912b5f 100644 --- a/custom_components/wemportal/wemportalapi.py +++ b/custom_components/wemportal/wemportalapi.py @@ -131,10 +131,8 @@ def fetch_data(self): # Get data using API if self.last_scraping_update is not None: - try: - self.get_data() - except Exception as exc: - _LOGGER.error(exc) + self.get_data() + # Return data return self.data From eca0fbf2d87227ddfa6a002248d74f3eac619ada Mon Sep 17 00:00:00 2001 From: Erik Kastelec Date: Sat, 15 Apr 2023 07:31:57 +0200 Subject: [PATCH 23/26] Fixed select platform bug --- custom_components/wemportal/select.py | 8 +++----- custom_components/wemportal/wemportalapi.py | 3 +++ 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/custom_components/wemportal/select.py b/custom_components/wemportal/select.py index a36f3ac..28a236a 100644 --- a/custom_components/wemportal/select.py +++ b/custom_components/wemportal/select.py @@ -78,7 +78,7 @@ def __init__( self._options_names = entity_data["optionsNames"] self._module_index = entity_data["ModuleIndex"] self._module_type = entity_data["ModuleType"] - self._attr_current_option = entity_data["value"] + self._attr_current_option = self._options_names[self._options.index( entity_data["value"])] async def async_select_option(self, option: str) -> None: """Call the API to change the parameter value""" @@ -91,7 +91,7 @@ async def async_select_option(self, option: str) -> None: self._options[self._options_names.index(option)], ) - self._attr_current_option = option + self._attr_current_option = self._options[self._options_names.index(option)] self.async_write_ha_state() @@ -136,9 +136,7 @@ def _handle_coordinator_update(self) -> None: """Handle updated data from the coordinator.""" try: - self._attr_current_option = self.coordinator.data[self._device_id][ - self._attr_name - ]["value"] + self._attr_current_option = self._options_names[self._options.index( self.coordinator.data[self._device_id][self._attr_name]["value"])] except KeyError: self._attr_current_option = None _LOGGER.warning("Can't find %s", self._attr_unique_id) diff --git a/custom_components/wemportal/wemportalapi.py b/custom_components/wemportal/wemportalapi.py index d912b5f..50d58cd 100644 --- a/custom_components/wemportal/wemportalapi.py +++ b/custom_components/wemportal/wemportalapi.py @@ -826,6 +826,9 @@ def scrape_pages(self, response): else: unit = None + if(name.endswith('leistungsanforderung')): + unit = '%' + icon_mapper = defaultdict(lambda: "mdi:flash") icon_mapper["°C"] = "mdi:thermometer" From 1e4dd12d0a4f0e43be1485b5d4cc09a17ce431be Mon Sep 17 00:00:00 2001 From: Erik Kastelec Date: Mon, 24 Apr 2023 14:11:45 +0200 Subject: [PATCH 24/26] Bug fix for select platform --- custom_components/wemportal/select.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/wemportal/select.py b/custom_components/wemportal/select.py index 28a236a..74f1d5c 100644 --- a/custom_components/wemportal/select.py +++ b/custom_components/wemportal/select.py @@ -91,7 +91,7 @@ async def async_select_option(self, option: str) -> None: self._options[self._options_names.index(option)], ) - self._attr_current_option = self._options[self._options_names.index(option)] + self._attr_current_option = option self.async_write_ha_state() From e55922653ccc13c3aee33437b9de80e6feeceedc Mon Sep 17 00:00:00 2001 From: Erik Kastelec Date: Mon, 24 Apr 2023 14:19:12 +0200 Subject: [PATCH 25/26] Updated README --- README.md | 8 ++------ custom_components/wemportal/manifest.json | 2 +- info.md | 7 +------ 3 files changed, 4 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 17edad3..ea83a75 100644 --- a/README.md +++ b/README.md @@ -57,9 +57,5 @@ Configuration variables: ## Troubleshooting Please set your logging for the custom_component to debug: -```yaml -logger: - default: warn - logs: - custom_components.wemportal: debug -``` + +Go to `Settings > Devices&Services `, find WEM Portal and click on `three dots` at the bottom of the card. Click on `Enable debug logging`. \ No newline at end of file diff --git a/custom_components/wemportal/manifest.json b/custom_components/wemportal/manifest.json index dd4aa23..86c544c 100644 --- a/custom_components/wemportal/manifest.json +++ b/custom_components/wemportal/manifest.json @@ -4,7 +4,7 @@ "documentation": "https://github.com/erikkastelec/hass-WEM-Portal", "issue_tracker": "https://github.com/erikkastelec/hass-WEM-Portal/issues", "dependencies": [], - "version": "1.5.2", + "version": "1.5.0", "codeowners": [ "@erikkastelec" ], diff --git a/info.md b/info.md index b7a9732..501b322 100644 --- a/info.md +++ b/info.md @@ -37,9 +37,4 @@ Configuration variables: Please set your logging for the custom_component to debug: -```yaml -logger: - default: warn - logs: - custom_components.wemportal: debug -``` +Go to `Settings > Devices&Services `, find WEM Portal and click on `three dots` at the bottom of the card. Click on `Enable debug logging`. \ No newline at end of file From 18d0f8b9f66719c3eae00f7af361677513d8c948 Mon Sep 17 00:00:00 2001 From: Erik Kastelec Date: Mon, 24 Apr 2023 14:24:52 +0200 Subject: [PATCH 26/26] Fix improper merge --- custom_components/wemportal/wemportalapi.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/custom_components/wemportal/wemportalapi.py b/custom_components/wemportal/wemportalapi.py index 274ef61..50d58cd 100644 --- a/custom_components/wemportal/wemportalapi.py +++ b/custom_components/wemportal/wemportalapi.py @@ -829,9 +829,6 @@ def scrape_pages(self, response): if(name.endswith('leistungsanforderung')): unit = '%' - if(name.endswith('leistungsanforderung')): - unit = '%' - icon_mapper = defaultdict(lambda: "mdi:flash") icon_mapper["°C"] = "mdi:thermometer"