diff --git a/README.md b/README.md index d33ab99..ea83a75 100644 --- a/README.md +++ b/README.md @@ -27,23 +27,24 @@ 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`. + +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 ( @@ -53,24 +54,8 @@ 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: -```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/__init__.py b/custom_components/wemportal/__init__.py index 0329b64..6e29e95 100644 --- a/custom_components/wemportal/__init__.py +++ b/custom_components/wemportal/__init__.py @@ -12,59 +12,138 @@ from homeassistant.const import CONF_PASSWORD, CONF_SCAN_INTERVAL, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.helpers.typing import ConfigType - -from .const import CONF_LANGUAGE, CONF_MODE, CONF_SCAN_INTERVAL_API, DOMAIN, PLATFORMS +from homeassistant.config_entries import ConfigEntry +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 +import homeassistant.helpers.entity_registry as entity_registry -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 + + +# Migrate values from previous versions +async def migrate_unique_ids( + hass: HomeAssistant, config_entry: ConfigEntry, coordinator +): + 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] + + 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})." + ) + # 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 config[DOMAIN].get(CONF_MODE) == "web": - update_interval = config[DOMAIN].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 config[DOMAIN].get(CONF_MODE) == "api": - update_interval = config[DOMAIN].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( - config[DOMAIN].get(CONF_SCAN_INTERVAL), - config[DOMAIN].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(config[DOMAIN]) + + api = WemPortalApi( + entry.data.get(CONF_USERNAME), entry.data.get(CONF_PASSWORD), entry.options + ) # Create custom coordinator - coordinator = WemPortalDataUpdateCoordinator(hass, api, update_interval) + coordinator = WemPortalDataUpdateCoordinator( + hass, api, entry, timedelta(seconds=update_interval) + ) + + 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] = { + # Is there an on_update function that we can add listener to? + _LOGGER.info("Migrating entity names for wemportal") + await migrate_unique_ids(hass, entry, coordinator) + + hass.data[DOMAIN][entry.entry_id] = { "api": api, - "config": config[DOMAIN], + # "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)) - await coordinator.async_config_entry_first_refresh() + return True - # Initialize platforms - for platform in PLATFORMS: - hass.helpers.discovery.load_platform(platform, DOMAIN, {}, config) + +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.""" + _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"] + ) + 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..7dfee28 --- /dev/null +++ b/custom_components/wemportal/config_flow.py @@ -0,0 +1,132 @@ +"""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.""" + + # Create API object + api = WemPortalApi(data[CONF_USERNAME], data[CONF_PASSWORD]) + + # 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 data + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for wemportal.""" + + VERSION = 2 + + @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=self.config_entry.options.get(CONF_SCAN_INTERVAL, 1800), + ): config_validation.positive_int, + vol.Optional( + CONF_SCAN_INTERVAL_API, + default=self.config_entry.options.get( + CONF_SCAN_INTERVAL_API, 300 + ), + ): config_validation.positive_int, + 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 5d3e200..bfc97bd 100644 --- a/custom_components/wemportal/const.py +++ b/custom_components/wemportal/const.py @@ -3,12 +3,17 @@ 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 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 3599477..572db90 100644 --- a/custom_components/wemportal/coordinator.py +++ b/custom_components/wemportal/coordinator.py @@ -2,17 +2,28 @@ 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 - +from homeassistant.helpers.update_coordinator import ( + DataUpdateCoordinator, + UpdateFailed, +) +from .exceptions import ForbiddenError, ServerError, 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): + def __init__( + self, + hass: HomeAssistant, + api: WemPortalApi, + config_entry: ConfigEntry, + update_interval, + ) -> None: """Initialize DataUpdateCoordinator for the wemportal component""" super().__init__( hass, @@ -21,9 +32,39 @@ def __init__(self, hass: HomeAssistant, api: WemPortalApi, update_interval): update_interval=update_interval, ) self.api = api - self.has = hass + self.hass = hass + self.config_entry = config_entry 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: + # 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)): + 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: + raise UpdateFailed from exc + # 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 new file mode 100644 index 0000000..0fc770a --- /dev/null +++ b/custom_components/wemportal/exceptions.py @@ -0,0 +1,37 @@ +""" Exceptions for the wemportal component.""" + +from homeassistant.exceptions import HomeAssistantError + + +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 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/manifest.json b/custom_components/wemportal/manifest.json index ce6ea52..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.5", + "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..8674fe6 100644 --- a/custom_components/wemportal/number.py +++ b/custom_components/wemportal/number.py @@ -4,27 +4,54 @@ 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 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,19 +59,26 @@ 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 + ) -> None: """Initialize the sensor.""" super().__init__(coordinator) + self._config_entry = config_entry + self._device_id = device_id + 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._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._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"] @@ -52,75 +86,52 @@ 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._attr_native_value = value # type: ignore self.async_write_ha_state() @property - def should_poll(self): - """No need to poll. Coordinator notifies entity of updates.""" - return False + 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 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.coordinator.async_add_listener(self.async_write_ha_state) + # 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) + # ) - 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) + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" - @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 - - @property - def state(self): - """Return the state of the sensor.""" try: - state = self.coordinator.data[self._unique_id]["value"] - if state: - return state - return 0 + self._attr_native_value = self.coordinator.data[self._device_id][ + self._attr_name + ]["value"] except KeyError: - _LOGGER.error("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 8937f09..74f1d5c 100644 --- a/custom_components/wemportal/select.py +++ b/custom_components/wemportal/select.py @@ -4,11 +4,13 @@ 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 -from .const import _LOGGER, DOMAIN +from .const import DOMAIN, _LOGGER +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,69 +60,68 @@ 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 + ) -> None: """Initialize the sensor.""" super().__init__(coordinator) self._last_updated = None - self._name = _unique_id + self._config_entry = config_entry + self._device_id = device_id + 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._unique_id = _unique_id - 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 = 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""" await self.hass.async_add_executor_job( self.coordinator.api.change_value, + self._device_id, self._parameter_id, self._module_index, self._module_type, self._options[self._options_names.index(option)], ) - self.coordinator.data[self._unique_id]["value"] = self._options[ - self._options_names.index(option) - ] + self._attr_current_option = option self.async_write_ha_state() @property - def options(self) -> list[str]: - """Return list of available options.""" - return self._options_names + 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 current_option(self) -> str: - """Return the current option.""" - return self._options_names[ - self._options.index(self.coordinator.data[self._unique_id]["value"]) - ] - - 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) - - @property - def name(self): - """Return the name of the sensor.""" - return self._name + def available(self): + """Return if entity is available.""" + return self.coordinator.last_update_success @property - def unique_id(self): - """Return the unique ID of the binary sensor.""" - return self._unique_id + def options(self) -> list[str]: + """Return list of available options.""" + return self._options_names - @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): @@ -104,6 +131,19 @@ 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._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) + _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 549913c..e06bb84 100644 --- a/custom_components/wemportal/sensor.py +++ b/custom_components/wemportal/sensor.py @@ -9,11 +9,13 @@ ) 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 from .const import _LOGGER, DOMAIN +from . import get_wemportal_unique_id async def async_setup_platform( @@ -22,93 +24,111 @@ 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 + ) + ) async_add_entities(entities) 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 + ) -> None: """Initialize the sensor.""" super().__init__(coordinator) self._last_updated = None - self._name = _unique_id - self._unique_id = _unique_id + self._config_entry = config_entry + self._device_id = device_id + 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 should_poll(self): - """No need to poll. Coordinator notifies entity of updates.""" - return False + 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 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.coordinator.async_add_listener(self.async_write_ha_state) + # 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) + # ) - 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) + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" - @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 - - @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._unique_id]["value"] - if state: - return state - return 0 + self._attr_native_value = self.coordinator.data[self._device_id][ + self._attr_name + ]["value"] except KeyError: - _LOGGER.error("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 @@ -116,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/strings.json b/custom_components/wemportal/strings.json new file mode 100644 index 0000000..576c1b1 --- /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)", + "mode": "[%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..c36f97b 100644 --- a/custom_components/wemportal/switch.py +++ b/custom_components/wemportal/switch.py @@ -4,11 +4,13 @@ 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 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,54 +59,75 @@ 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: CoordinatorEntity, + config_entry: ConfigEntry, + device_id, + _unique_id, + entity_data, + ) -> None: """Initialize the sensor.""" super().__init__(coordinator) self._last_updated = None - self._name = _unique_id + self._config_entry = config_entry + self._device_id = device_id + 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._unique_id = _unique_id - 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"] + @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._attr_state = "on" # type: ignore 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, 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 @@ -87,60 +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.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) - - @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: - self._state = self.coordinator.data[self._unique_id]["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.error("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 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" + self.async_write_ha_state() @property def extra_state_attributes(self): diff --git a/custom_components/wemportal/translations/en.json b/custom_components/wemportal/translations/en.json new file mode 100644 index 0000000..9395d08 --- /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": "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)", + "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 718e916..50d58cd 100644 --- a/custom_components/wemportal/wemportalapi.py +++ b/custom_components/wemportal/wemportalapi.py @@ -12,29 +12,61 @@ import requests as reqs import scrapyscript from fuzzywuzzy import fuzz -from homeassistant.const import CONF_PASSWORD, CONF_SCAN_INTERVAL, CONF_USERNAME -from scrapy import FormRequest, Spider - -from .const import _LOGGER, CONF_LANGUAGE, CONF_MODE, CONF_SCAN_INTERVAL_API, START_URLS +from homeassistant.const import CONF_SCAN_INTERVAL +from scrapy import FormRequest, Spider, Request +from .exceptions import ( + AuthError, + ForbiddenError, + UnknownAuthError, + WemPortalError, + ExpiredSessionError, + ParameterChangeError, + ServerError, +) + +from .const import ( + _LOGGER, + CONF_LANGUAGE, + CONF_MODE, + 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={}) -> None: 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.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, 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 = config.get(CONF_SCAN_INTERVAL) - self.scan_interval_api = config.get(CONF_SCAN_INTERVAL_API) - self.language = config.get(CONF_LANGUAGE) - self.device_id = None + self.scan_interval_api = timedelta( + seconds=config.get( + 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 + self.webscraping_cookie = {} self.last_scraping_update = None # Headers used for all API calls self.headers = { @@ -42,56 +74,105 @@ def __init__(self, config): "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 + # Used to keep track of the number of times the spider consecutively fails + self.spider_retry_count = 0 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 - ): - self.fetch_webscraping_data() - self.fetch_api_data() - self.last_scraping_update = datetime.now() + try: + # Login and get device info + 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: - self.fetch_api_data() - return self.data + # 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 + ) + and self.spider_wait_interval == 0 + ): + # Get data by web scraping + 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) + + 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 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] - except IndexError: - _LOGGER.exception( - "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" - ) + data = processor.run([wemportal_job])[0] + except IndexError as exc: + self.spider_retry_count += 1 + if self.spider_retry_count == 2: + 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 = 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 = None + raise ExpiredSessionError( + "ExpiredSessionError: Session expired. Next update will try to login again." + ) from exc try: - if self.data["authErrorFlag"]: - _LOGGER.exception( - "AuthenticationError: Could not login with provided username and password. Check if " - "your config contains the right credentials" - ) + self.webscraping_cookie = data["cookie"] + del data["cookie"] except KeyError: - """If authentication was successful""" 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 + self.spider_retry_count = 0 + self.spider_wait_interval = 0 + return data def api_login(self): payload = { @@ -104,87 +185,148 @@ 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: + self.valid_login = False + response_status, response_message = self.get_response_details(response) + if response is None: + raise UnknownAuthError( + "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}" + ) from exc + else: + raise UnknownAuthError( + 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 = "" + 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( + self, url: str, headers=None, data=None, login_retry=False, delay=1 + ) -> reqs.Response: + response = None + 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 Exception as exc: + if response and response.status_code in (401, 403) and not login_retry: + self.api_login() + headers = headers or self.headers + time.sleep(delay) + response = self.make_api_call( + url, + headers=headers, + data=data, + login_retry=True, + delay=delay, + ) + else: + 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 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), @@ -198,178 +340,125 @@ 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, 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( - parameter_id, module_index, module_type, numeric_value, login=False + device_id, + parameter_id, + module_index, + module_type, + numeric_value, + login=False, ) - if response.status_code != 200: - _LOGGER.error("Error changing parameter %s value", parameter_id) - # _LOGGER.info("Error: %s", response.content) - # _LOGGER.info(response.__dict__) + 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, ) - try: - response = self.session.post( + values = self.make_api_call( "https://www.wemportal.com/app/DataAccess/Read", - headers=headers, - data=json.dumps(data), - ) - - if response and response.status_code == 200: - values = response.json() - else: - _LOGGER.debug(self.modules) - _LOGGER.debug(self.data) - if response: - _LOGGER.warning( - f"Non-200 status code received: {response.status_code}" + 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"] ) - else: - _LOGGER.error(f"No response from wemportal API server.") - return - - except reqs.exceptions.JSONDecodeError as exc: - _LOGGER.debug(self.modules) - _LOGGER.debug(self.data) - _LOGGER.error(f"Invalid JSON response received: {exc}") - _LOGGER.error(f"JSON response content: {response.content.decode('utf-8')}") - return - - # 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_data = module_data = self.modules[device_id][ (module["ModuleIndex"], module["ModuleType"]) - ]["parameters"][value["ParameterID"]]["IsWriteable"], - "DataType": self.modules[ + ]["parameters"][value["ParameterID"]] + data[name] = { + "friendlyName": self.translate( + self.language, + self.friendly_name_mapper(value["ParameterID"]), + ), + "ParameterID": value["ParameterID"], + "unit": value["Unit"], + "value": value["NumericValue"], + "IsWriteable": module_data["IsWriteable"], + "DataType": module_data["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] = { + if data[name]["IsWriteable"]: + # NUMBER PLATFORM + # Define common attributes + common_attrs = { "friendlyName": data[name]["friendlyName"], "ParameterID": value["ParameterID"], "unit": value["Unit"], @@ -378,74 +467,64 @@ def get_data(self): "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[ - (module["ModuleIndex"], module["ModuleType"]) - ]["parameters"][value["ParameterID"]]["MinValue"] - ) - == 0 - and int( - self.modules[ - (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", - } - else: - # Skip schedules + + # Process data based on platform type + + # NUMBER PLATFORM + if data[name]["DataType"] == -1 or data[name]["DataType"] == 3: + self.data[device_id][name] = { + **common_attrs, + "platform": "number", + "min_value": float(module_data["MinValue"]), + "max_value": float(module_data["MaxValue"]), + "step": 0.5 if data[name]["DataType"] == -1 else 1, + } + # SELECT PLATFORM + elif data[name]["DataType"] == 1: + self.data[device_id][name] = { + **common_attrs, + "platform": "select", + "options": [ + x["Value"] for x in module_data["EnumValues"] + ], + "optionsNames": [ + x["Name"] for x in module_data["EnumValues"] + ], + } + # SWITCH PLATFORM + elif data[name]["DataType"] == 2: + try: + if ( + int(module_data["MinValue"]) == 0 + and int(module_data["MaxValue"]) == 1 + ): + self.data[device_id][name] = { + **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 - if self.mode == "both": + + # Catches exception when converting to int when MinValur or MaxValue is Nones + except Exception: + continue + + for key, value in data.items(): + 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"]] + temp = self.scraping_mapper[value["ParameterID"]] except KeyError: - for scraped_entity in self.data.keys(): - scraped_entity_id = self.data[scraped_entity][ - "ParameterID" - ] + for scraped_entity in self.data[device_id].keys(): + scraped_entity_id = self.data[device_id][ + scraped_entity + ]["ParameterID"] try: if ( fuzz.ratio( @@ -454,49 +533,44 @@ def get_data(self): ) >= 90 ): - try: - self.scrapingMapper[ - value["ParameterID"] - ].append(scraped_entity_id) - except KeyError: - self.scrapingMapper[ - value["ParameterID"] - ] = [scraped_entity_id] + 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"] ]: - 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", - } - 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", - } + 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[key] = { + self.data[device_id][key] = { "value": value["value"], "ParameterID": value["ParameterID"], "unit": value["unit"], @@ -562,53 +636,101 @@ def translate(self, language, value): return out +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.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 + ): + 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 ): - _LOGGER.debug("Authentication failed") - self.authErrorFlag = True - form_data = {} - else: - _LOGGER.debug("Authentication successful") - form_data = self.generate_form_data(response) - _LOGGER.debug("Form data processed") + 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 + 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", @@ -644,10 +766,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( @@ -723,4 +844,6 @@ def scrape_pages(self, response): except IndexError: continue + output["cookie"] = self.cookie + yield output diff --git a/info.md b/info.md index 3a73d57..501b322 100644 --- a/info.md +++ b/info.md @@ -12,6 +12,10 @@ 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`. + +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 @@ -27,26 +31,10 @@ 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: -```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