From 8d572af77a3ddd987e554b7314ea5ce1b2ea3193 Mon Sep 17 00:00:00 2001 From: Dennis Schroer Date: Wed, 27 Jan 2021 15:53:25 +0100 Subject: [PATCH] Add Huisbaasje integration (#42716) Co-authored-by: Paulus Schoutsen Co-authored-by: Franck Nijhof --- CODEOWNERS | 1 + .../components/huisbaasje/__init__.py | 168 ++++++++++++++++++ .../components/huisbaasje/config_flow.py | 84 +++++++++ homeassistant/components/huisbaasje/const.py | 142 +++++++++++++++ .../components/huisbaasje/manifest.json | 10 ++ homeassistant/components/huisbaasje/sensor.py | 95 ++++++++++ .../components/huisbaasje/strings.json | 21 +++ homeassistant/generated/config_flows.py | 1 + requirements_all.txt | 3 + requirements_test_all.txt | 3 + tests/components/huisbaasje/__init__.py | 1 + .../components/huisbaasje/test_config_flow.py | 157 ++++++++++++++++ tests/components/huisbaasje/test_data.py | 79 ++++++++ tests/components/huisbaasje/test_init.py | 153 ++++++++++++++++ tests/components/huisbaasje/test_sensor.py | 120 +++++++++++++ 15 files changed, 1038 insertions(+) create mode 100644 homeassistant/components/huisbaasje/__init__.py create mode 100644 homeassistant/components/huisbaasje/config_flow.py create mode 100644 homeassistant/components/huisbaasje/const.py create mode 100644 homeassistant/components/huisbaasje/manifest.json create mode 100644 homeassistant/components/huisbaasje/sensor.py create mode 100644 homeassistant/components/huisbaasje/strings.json create mode 100644 tests/components/huisbaasje/__init__.py create mode 100644 tests/components/huisbaasje/test_config_flow.py create mode 100644 tests/components/huisbaasje/test_data.py create mode 100644 tests/components/huisbaasje/test_init.py create mode 100644 tests/components/huisbaasje/test_sensor.py diff --git a/CODEOWNERS b/CODEOWNERS index 8c4c88d2a7d..b8175614fb5 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -203,6 +203,7 @@ homeassistant/components/http/* @home-assistant/core homeassistant/components/huawei_lte/* @scop @fphammerle homeassistant/components/huawei_router/* @abmantis homeassistant/components/hue/* @balloob @frenck +homeassistant/components/huisbaasje/* @denniss17 homeassistant/components/humidifier/* @home-assistant/core @Shulyaka homeassistant/components/hunterdouglas_powerview/* @bdraco homeassistant/components/hvv_departures/* @vigonotion diff --git a/homeassistant/components/huisbaasje/__init__.py b/homeassistant/components/huisbaasje/__init__.py new file mode 100644 index 00000000000..23dc3cb7eda --- /dev/null +++ b/homeassistant/components/huisbaasje/__init__.py @@ -0,0 +1,168 @@ +"""The Huisbaasje integration.""" +from datetime import timedelta +import logging + +import async_timeout +from huisbaasje import Huisbaasje, HuisbaasjeException + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import ( + DATA_COORDINATOR, + DOMAIN, + FETCH_TIMEOUT, + POLLING_INTERVAL, + SENSOR_TYPE_RATE, + SENSOR_TYPE_THIS_DAY, + SENSOR_TYPE_THIS_MONTH, + SENSOR_TYPE_THIS_WEEK, + SENSOR_TYPE_THIS_YEAR, + SOURCE_TYPES, +) + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup(hass: HomeAssistant, config: dict): + """Set up the Huisbaasje component.""" + return True + + +async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry): + """Set up Huisbaasje from a config entry.""" + # Create the Huisbaasje client + huisbaasje = Huisbaasje( + username=config_entry.data[CONF_USERNAME], + password=config_entry.data[CONF_PASSWORD], + source_types=SOURCE_TYPES, + request_timeout=FETCH_TIMEOUT, + ) + + # Attempt authentication. If this fails, an exception is thrown + try: + await huisbaasje.authenticate() + except HuisbaasjeException as exception: + _LOGGER.error("Authentication failed: %s", str(exception)) + return False + + async def async_update_data(): + return await async_update_huisbaasje(huisbaasje) + + # Create a coordinator for polling updates + coordinator = DataUpdateCoordinator( + hass, + _LOGGER, + name="sensor", + update_method=async_update_data, + update_interval=timedelta(seconds=POLLING_INTERVAL), + ) + + await coordinator.async_refresh() + + if not coordinator.last_update_success: + raise ConfigEntryNotReady + + # Load the client in the data of home assistant + hass.data.setdefault(DOMAIN, {})[config_entry.entry_id] = { + DATA_COORDINATOR: coordinator + } + + # Offload the loading of entities to the platform + hass.async_create_task( + hass.config_entries.async_forward_entry_setup(config_entry, "sensor") + ) + + return True + + +async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry): + """Unload a config entry.""" + # Forward the unloading of the entry to the platform + unload_ok = await hass.config_entries.async_forward_entry_unload( + config_entry, "sensor" + ) + + # If successful, unload the Huisbaasje client + if unload_ok: + hass.data[DOMAIN].pop(config_entry.entry_id) + + return unload_ok + + +async def async_update_huisbaasje(huisbaasje): + """Update the data by performing a request to Huisbaasje.""" + try: + # Note: asyncio.TimeoutError and aiohttp.ClientError are already + # handled by the data update coordinator. + async with async_timeout.timeout(FETCH_TIMEOUT): + if not huisbaasje.is_authenticated(): + _LOGGER.warning("Huisbaasje is unauthenticated. Reauthenticating...") + await huisbaasje.authenticate() + + current_measurements = await huisbaasje.current_measurements() + + return { + source_type: { + SENSOR_TYPE_RATE: _get_measurement_rate( + current_measurements, source_type + ), + SENSOR_TYPE_THIS_DAY: _get_cumulative_value( + current_measurements, source_type, SENSOR_TYPE_THIS_DAY + ), + SENSOR_TYPE_THIS_WEEK: _get_cumulative_value( + current_measurements, source_type, SENSOR_TYPE_THIS_WEEK + ), + SENSOR_TYPE_THIS_MONTH: _get_cumulative_value( + current_measurements, source_type, SENSOR_TYPE_THIS_MONTH + ), + SENSOR_TYPE_THIS_YEAR: _get_cumulative_value( + current_measurements, source_type, SENSOR_TYPE_THIS_YEAR + ), + } + for source_type in SOURCE_TYPES + } + except HuisbaasjeException as exception: + raise UpdateFailed(f"Error communicating with API: {exception}") from exception + + +def _get_cumulative_value( + current_measurements: dict, + source_type: str, + period_type: str, +): + """ + Get the cumulative energy consumption for a certain period. + + :param current_measurements: The result from the Huisbaasje client + :param source_type: The source of energy (electricity or gas) + :param period_type: The period for which cumulative value should be given. + """ + if source_type in current_measurements.keys(): + if ( + period_type in current_measurements[source_type] + and current_measurements[source_type][period_type] is not None + ): + return current_measurements[source_type][period_type]["value"] + else: + _LOGGER.error( + "Source type %s not present in %s", source_type, current_measurements + ) + return None + + +def _get_measurement_rate(current_measurements: dict, source_type: str): + if source_type in current_measurements: + if ( + "measurement" in current_measurements[source_type] + and current_measurements[source_type]["measurement"] is not None + ): + return current_measurements[source_type]["measurement"]["rate"] + else: + _LOGGER.error( + "Source type %s not present in %s", source_type, current_measurements + ) + return None diff --git a/homeassistant/components/huisbaasje/config_flow.py b/homeassistant/components/huisbaasje/config_flow.py new file mode 100644 index 00000000000..59e4840529d --- /dev/null +++ b/homeassistant/components/huisbaasje/config_flow.py @@ -0,0 +1,84 @@ +"""Config flow for Huisbaasje integration.""" +import logging + +from huisbaasje import Huisbaasje, HuisbaasjeConnectionException, HuisbaasjeException +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_ID, CONF_PASSWORD, CONF_USERNAME +from homeassistant.data_entry_flow import AbortFlow + +from .const import DOMAIN # pylint: disable=unused-import + +_LOGGER = logging.getLogger(__name__) + +DATA_SCHEMA = vol.Schema( + {vol.Required(CONF_USERNAME): str, vol.Required(CONF_PASSWORD): str} +) + + +class HuisbaasjeConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for Huisbaasje.""" + + VERSION = 1 + CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL + + async def async_step_user(self, user_input=None): + """Handle a flow initiated by the user.""" + if user_input is None: + return await self._show_setup_form(user_input) + + errors = {} + + try: + user_id = await self._validate_input(user_input) + + _LOGGER.info("Input for Huisbaasje is valid!") + + # Set user id as unique id + await self.async_set_unique_id(user_id) + self._abort_if_unique_id_configured() + + # Create entry + return self.async_create_entry( + title=user_input[CONF_USERNAME], + data={ + CONF_ID: user_id, + CONF_USERNAME: user_input[CONF_USERNAME], + CONF_PASSWORD: user_input[CONF_PASSWORD], + }, + ) + except HuisbaasjeConnectionException as exception: + _LOGGER.warning(exception) + errors["base"] = "connection_exception" + except HuisbaasjeException as exception: + _LOGGER.warning(exception) + errors["base"] = "invalid_auth" + except AbortFlow as exception: + raise exception + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + + return await self._show_setup_form(user_input, errors) + + async def _show_setup_form(self, user_input, errors=None): + return self.async_show_form( + step_id="user", data_schema=DATA_SCHEMA, errors=errors or {} + ) + + async def _validate_input(self, user_input): + """Validate the user input allows us to connect. + + Data has the keys from DATA_SCHEMA with values provided by the user. + """ + + username = user_input[CONF_USERNAME] + password = user_input[CONF_PASSWORD] + + huisbaasje = Huisbaasje(username, password) + + # Attempt authentication. If this fails, an HuisbaasjeException will be thrown + await huisbaasje.authenticate() + + return huisbaasje.get_user_id() diff --git a/homeassistant/components/huisbaasje/const.py b/homeassistant/components/huisbaasje/const.py new file mode 100644 index 00000000000..07ad84567e5 --- /dev/null +++ b/homeassistant/components/huisbaasje/const.py @@ -0,0 +1,142 @@ +"""Constants for the Huisbaasje integration.""" +from huisbaasje.const import ( + SOURCE_TYPE_ELECTRICITY, + SOURCE_TYPE_ELECTRICITY_IN, + SOURCE_TYPE_ELECTRICITY_IN_LOW, + SOURCE_TYPE_ELECTRICITY_OUT, + SOURCE_TYPE_ELECTRICITY_OUT_LOW, + SOURCE_TYPE_GAS, +) + +from homeassistant.const import ( + DEVICE_CLASS_POWER, + ENERGY_KILO_WATT_HOUR, + TIME_HOURS, + VOLUME_CUBIC_METERS, +) + +DATA_COORDINATOR = "coordinator" + +DOMAIN = "huisbaasje" + +FLOW_CUBIC_METERS_PER_HOUR = f"{VOLUME_CUBIC_METERS}/{TIME_HOURS}" + +"""Interval in seconds between polls to huisbaasje.""" +POLLING_INTERVAL = 20 + +"""Timeout for fetching sensor data""" +FETCH_TIMEOUT = 10 + +SENSOR_TYPE_RATE = "rate" +SENSOR_TYPE_THIS_DAY = "thisDay" +SENSOR_TYPE_THIS_WEEK = "thisWeek" +SENSOR_TYPE_THIS_MONTH = "thisMonth" +SENSOR_TYPE_THIS_YEAR = "thisYear" + +SOURCE_TYPES = [ + SOURCE_TYPE_ELECTRICITY, + SOURCE_TYPE_ELECTRICITY_IN, + SOURCE_TYPE_ELECTRICITY_IN_LOW, + SOURCE_TYPE_ELECTRICITY_OUT, + SOURCE_TYPE_ELECTRICITY_OUT_LOW, + SOURCE_TYPE_GAS, +] + +SENSORS_INFO = [ + { + "name": "Huisbaasje Current Power", + "device_class": DEVICE_CLASS_POWER, + "source_type": SOURCE_TYPE_ELECTRICITY, + }, + { + "name": "Huisbaasje Current Power In", + "device_class": DEVICE_CLASS_POWER, + "source_type": SOURCE_TYPE_ELECTRICITY_IN, + }, + { + "name": "Huisbaasje Current Power In Low", + "device_class": DEVICE_CLASS_POWER, + "source_type": SOURCE_TYPE_ELECTRICITY_IN_LOW, + }, + { + "name": "Huisbaasje Current Power Out", + "device_class": DEVICE_CLASS_POWER, + "source_type": SOURCE_TYPE_ELECTRICITY_OUT, + }, + { + "name": "Huisbaasje Current Power Out Low", + "device_class": DEVICE_CLASS_POWER, + "source_type": SOURCE_TYPE_ELECTRICITY_OUT_LOW, + }, + { + "name": "Huisbaasje Energy Today", + "unit_of_measurement": ENERGY_KILO_WATT_HOUR, + "source_type": SOURCE_TYPE_ELECTRICITY, + "sensor_type": SENSOR_TYPE_THIS_DAY, + "icon": "mdi:counter", + "precision": 1, + }, + { + "name": "Huisbaasje Energy This Week", + "unit_of_measurement": ENERGY_KILO_WATT_HOUR, + "source_type": SOURCE_TYPE_ELECTRICITY, + "sensor_type": SENSOR_TYPE_THIS_WEEK, + "icon": "mdi:counter", + "precision": 1, + }, + { + "name": "Huisbaasje Energy This Month", + "unit_of_measurement": ENERGY_KILO_WATT_HOUR, + "source_type": SOURCE_TYPE_ELECTRICITY, + "sensor_type": SENSOR_TYPE_THIS_MONTH, + "icon": "mdi:counter", + "precision": 1, + }, + { + "name": "Huisbaasje Energy This Year", + "unit_of_measurement": ENERGY_KILO_WATT_HOUR, + "source_type": SOURCE_TYPE_ELECTRICITY, + "sensor_type": SENSOR_TYPE_THIS_YEAR, + "icon": "mdi:counter", + "precision": 1, + }, + { + "name": "Huisbaasje Current Gas", + "unit_of_measurement": FLOW_CUBIC_METERS_PER_HOUR, + "source_type": SOURCE_TYPE_GAS, + "icon": "mdi:fire", + "precision": 1, + }, + { + "name": "Huisbaasje Gas Today", + "unit_of_measurement": VOLUME_CUBIC_METERS, + "source_type": SOURCE_TYPE_GAS, + "sensor_type": SENSOR_TYPE_THIS_DAY, + "icon": "mdi:counter", + "precision": 1, + }, + { + "name": "Huisbaasje Gas This Week", + "unit_of_measurement": VOLUME_CUBIC_METERS, + "source_type": SOURCE_TYPE_GAS, + "sensor_type": SENSOR_TYPE_THIS_WEEK, + "icon": "mdi:counter", + "precision": 1, + }, + { + "name": "Huisbaasje Gas This Month", + "unit_of_measurement": VOLUME_CUBIC_METERS, + "source_type": SOURCE_TYPE_GAS, + "sensor_type": SENSOR_TYPE_THIS_MONTH, + "icon": "mdi:counter", + "precision": 1, + }, + { + "name": "Huisbaasje Gas This Year", + "unit_of_measurement": VOLUME_CUBIC_METERS, + "source_type": SOURCE_TYPE_GAS, + "sensor_type": SENSOR_TYPE_THIS_YEAR, + "icon": "mdi:counter", + "precision": 1, + }, +] diff --git a/homeassistant/components/huisbaasje/manifest.json b/homeassistant/components/huisbaasje/manifest.json new file mode 100644 index 00000000000..975adb52a22 --- /dev/null +++ b/homeassistant/components/huisbaasje/manifest.json @@ -0,0 +1,10 @@ +{ + "domain": "huisbaasje", + "name": "Huisbaasje", + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/huisbaasje", + "requirements": [ + "huisbaasje-client==0.1.0" + ], + "codeowners": ["@denniss17"] +} diff --git a/homeassistant/components/huisbaasje/sensor.py b/homeassistant/components/huisbaasje/sensor.py new file mode 100644 index 00000000000..e84052fe029 --- /dev/null +++ b/homeassistant/components/huisbaasje/sensor.py @@ -0,0 +1,95 @@ +"""Platform for sensor integration.""" +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_ID, POWER_WATT +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import ( + CoordinatorEntity, + DataUpdateCoordinator, +) + +from .const import DATA_COORDINATOR, DOMAIN, SENSOR_TYPE_RATE, SENSORS_INFO + + +async def async_setup_entry( + hass: HomeAssistant, config_entry: ConfigEntry, async_add_entities +): + """Set up the sensor platform.""" + coordinator = hass.data[DOMAIN][config_entry.entry_id][DATA_COORDINATOR] + user_id = config_entry.data[CONF_ID] + + async_add_entities( + HuisbaasjeSensor(coordinator, user_id=user_id, **sensor_info) + for sensor_info in SENSORS_INFO + ) + + +class HuisbaasjeSensor(CoordinatorEntity): + """Defines a Huisbaasje sensor.""" + + def __init__( + self, + coordinator: DataUpdateCoordinator, + user_id: str, + name: str, + source_type: str, + device_class: str = None, + sensor_type: str = SENSOR_TYPE_RATE, + unit_of_measurement: str = POWER_WATT, + icon: str = "mdi:lightning-bolt", + precision: int = 0, + ): + """Initialize the sensor.""" + super().__init__(coordinator) + self._user_id = user_id + self._name = name + self._device_class = device_class + self._unit_of_measurement = unit_of_measurement + self._source_type = source_type + self._sensor_type = sensor_type + self._icon = icon + self._precision = precision + + @property + def unique_id(self) -> str: + """Return an unique id for the sensor.""" + return f"{DOMAIN}_{self._user_id}_{self._source_type}_{self._sensor_type}" + + @property + def name(self) -> str: + """Return the name of the sensor.""" + return self._name + + @property + def device_class(self) -> str: + """Return the device class of the sensor.""" + return self._device_class + + @property + def icon(self) -> str: + """Return the icon to use for the sensor.""" + return self._icon + + @property + def state(self): + """Return the state of the sensor.""" + if self.coordinator.data[self._source_type][self._sensor_type] is not None: + return round( + self.coordinator.data[self._source_type][self._sensor_type], + self._precision, + ) + return None + + @property + def unit_of_measurement(self) -> str: + """Return the unit of measurement.""" + return self._unit_of_measurement + + @property + def available(self) -> bool: + """Return if entity is available.""" + return ( + super().available + and self.coordinator.data + and self._source_type in self.coordinator.data + and self.coordinator.data[self._source_type] + ) diff --git a/homeassistant/components/huisbaasje/strings.json b/homeassistant/components/huisbaasje/strings.json new file mode 100644 index 00000000000..f126ac0afff --- /dev/null +++ b/homeassistant/components/huisbaasje/strings.json @@ -0,0 +1,21 @@ +{ + "config": { + "step": { + "user": { + "data": { + "username": "[%key:common::config_flow::data::username%]", + "password": "[%key:common::config_flow::data::password%]" + } + } + }, + "error": { + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "unauthenticated_exception": "[%key:common::config_flow::error::invalid_auth%]", + "connection_exception": "[%key:common::config_flow::error::cannot_connect%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 7e273befb16..fb9ec0f2e1e 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -94,6 +94,7 @@ FLOWS = [ "homematicip_cloud", "huawei_lte", "hue", + "huisbaasje", "hunterdouglas_powerview", "hvv_departures", "hyperion", diff --git a/requirements_all.txt b/requirements_all.txt index 84daf58ccee..15a92bc0985 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -786,6 +786,9 @@ httplib2==0.18.1 # homeassistant.components.huawei_lte huawei-lte-api==1.4.17 +# homeassistant.components.huisbaasje +huisbaasje-client==0.1.0 + # homeassistant.components.hydrawise hydrawiser==0.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ed4f0bdac97..69f0a6b3f62 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -415,6 +415,9 @@ httplib2==0.18.1 # homeassistant.components.huawei_lte huawei-lte-api==1.4.17 +# homeassistant.components.huisbaasje +huisbaasje-client==0.1.0 + # homeassistant.components.hyperion hyperion-py==0.7.0 diff --git a/tests/components/huisbaasje/__init__.py b/tests/components/huisbaasje/__init__.py new file mode 100644 index 00000000000..8cf2749d8df --- /dev/null +++ b/tests/components/huisbaasje/__init__.py @@ -0,0 +1 @@ +"""Tests for the Huisbaasje integration.""" diff --git a/tests/components/huisbaasje/test_config_flow.py b/tests/components/huisbaasje/test_config_flow.py new file mode 100644 index 00000000000..245ac2f8ddb --- /dev/null +++ b/tests/components/huisbaasje/test_config_flow.py @@ -0,0 +1,157 @@ +"""Test the Huisbaasje config flow.""" +from unittest.mock import patch + +from homeassistant import config_entries, data_entry_flow, setup +from homeassistant.components.huisbaasje.config_flow import ( + HuisbaasjeConnectionException, + HuisbaasjeException, +) +from homeassistant.components.huisbaasje.const import DOMAIN + +from tests.common import MockConfigEntry + + +async def test_form(hass): + """Test we get the form.""" + await setup.async_setup_component(hass, "persistent_notification", {}) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["errors"] == {} + + with patch( + "huisbaasje.Huisbaasje.authenticate", return_value=None + ) as mock_authenticate, patch( + "huisbaasje.Huisbaasje.get_user_id", + return_value="test-id", + ) as mock_get_user_id, patch( + "homeassistant.components.huisbaasje.async_setup", return_value=True + ) as mock_setup, patch( + "homeassistant.components.huisbaasje.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + form_result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "username": "test-username", + "password": "test-password", + }, + ) + await hass.async_block_till_done() + + assert form_result["type"] == "create_entry" + assert form_result["title"] == "test-username" + assert form_result["data"] == { + "id": "test-id", + "username": "test-username", + "password": "test-password", + } + assert len(mock_authenticate.mock_calls) == 1 + assert len(mock_get_user_id.mock_calls) == 1 + assert len(mock_setup.mock_calls) == 1 + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_form_invalid_auth(hass): + """Test we handle invalid auth.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "huisbaasje.Huisbaasje.authenticate", + side_effect=HuisbaasjeException, + ): + form_result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "username": "test-username", + "password": "test-password", + }, + ) + + assert form_result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert form_result["errors"] == {"base": "invalid_auth"} + + +async def test_form_cannot_connect(hass): + """Test we handle cannot connect error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "huisbaasje.Huisbaasje.authenticate", + side_effect=HuisbaasjeConnectionException, + ): + form_result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "username": "test-username", + "password": "test-password", + }, + ) + + assert form_result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert form_result["errors"] == {"base": "connection_exception"} + + +async def test_form_unknown_error(hass): + """Test we handle an unknown error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "huisbaasje.Huisbaasje.authenticate", + side_effect=Exception, + ): + form_result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "username": "test-username", + "password": "test-password", + }, + ) + + assert form_result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert form_result["errors"] == {"base": "unknown"} + + +async def test_form_entry_exists(hass): + """Test we handle an already existing entry.""" + MockConfigEntry( + unique_id="test-id", + domain=DOMAIN, + data={ + "id": "test-id", + "username": "test-username", + "password": "test-password", + }, + title="test-username", + ).add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch("huisbaasje.Huisbaasje.authenticate", return_value=None), patch( + "huisbaasje.Huisbaasje.get_user_id", + return_value="test-id", + ), patch( + "homeassistant.components.huisbaasje.async_setup", return_value=True + ), patch( + "homeassistant.components.huisbaasje.async_setup_entry", + return_value=True, + ): + form_result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "username": "test-username", + "password": "test-password", + }, + ) + + assert form_result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert form_result["reason"] == "already_configured" diff --git a/tests/components/huisbaasje/test_data.py b/tests/components/huisbaasje/test_data.py new file mode 100644 index 00000000000..5752be11c51 --- /dev/null +++ b/tests/components/huisbaasje/test_data.py @@ -0,0 +1,79 @@ +"""Test data for the tests of the Huisbaasje integration.""" +MOCK_CURRENT_MEASUREMENTS = { + "electricity": { + "measurement": { + "time": "2020-11-18T15:17:24.000Z", + "rate": 1011.6666666666667, + "value": 0.0033333333333333335, + "costPerHour": 0.20233333333333337, + "counterValue": 409.17166666631937, + }, + "thisDay": {"value": 3.296665869, "cost": 0.6593331738}, + "thisWeek": {"value": 17.509996085, "cost": 3.5019992170000003}, + "thisMonth": {"value": 103.28830788, "cost": 20.657661576000002}, + "thisYear": {"value": 672.9781177300001, "cost": 134.595623546}, + }, + "electricityIn": { + "measurement": { + "time": "2020-11-18T15:17:24.000Z", + "rate": 1011.6666666666667, + "value": 0.0033333333333333335, + "costPerHour": 0.20233333333333337, + "counterValue": 409.17166666631937, + }, + "thisDay": {"value": 2.669999453, "cost": 0.5339998906}, + "thisWeek": {"value": 15.328330291, "cost": 3.0656660582}, + "thisMonth": {"value": 72.986651896, "cost": 14.5973303792}, + "thisYear": {"value": 409.214880212, "cost": 81.84297604240001}, + }, + "electricityInLow": { + "measurement": None, + "thisDay": {"value": 0.6266664160000001, "cost": 0.1253332832}, + "thisWeek": {"value": 2.181665794, "cost": 0.43633315880000006}, + "thisMonth": {"value": 30.301655984000003, "cost": 6.060331196800001}, + "thisYear": {"value": 263.76323751800004, "cost": 52.75264750360001}, + }, + "electricityOut": { + "measurement": None, + "thisDay": {"value": 0.0, "cost": 0.0}, + "thisWeek": {"value": 0.0, "cost": 0.0}, + "thisMonth": {"value": 0.0, "cost": 0.0}, + "thisYear": {"value": 0.0, "cost": 0.0}, + }, + "electricityOutLow": { + "measurement": None, + "thisDay": {"value": 0.0, "cost": 0.0}, + "thisWeek": {"value": 0.0, "cost": 0.0}, + "thisMonth": {"value": 0.0, "cost": 0.0}, + "thisYear": {"value": 0.0, "cost": 0.0}, + }, + "gas": { + "measurement": { + "time": "2020-11-18T15:17:29.000Z", + "rate": 0.0, + "value": 0.0, + "costPerHour": 0.0, + "counterValue": 116.73000000002281, + }, + "thisDay": {"value": 1.07, "cost": 0.642}, + "thisWeek": {"value": 5.634224386000001, "cost": 3.3805346316000007}, + "thisMonth": {"value": 39.14, "cost": 23.483999999999998}, + "thisYear": {"value": 116.73, "cost": 70.038}, + }, +} + +MOCK_LIMITED_CURRENT_MEASUREMENTS = { + "electricity": { + "measurement": { + "time": "2020-11-18T15:17:24.000Z", + "rate": 1011.6666666666667, + "value": 0.0033333333333333335, + "costPerHour": 0.20233333333333337, + "counterValue": 409.17166666631937, + }, + "thisDay": {"value": 3.296665869, "cost": 0.6593331738}, + "thisWeek": {"value": 17.509996085, "cost": 3.5019992170000003}, + "thisMonth": {"value": 103.28830788, "cost": 20.657661576000002}, + "thisYear": {"value": 672.9781177300001, "cost": 134.595623546}, + } +} diff --git a/tests/components/huisbaasje/test_init.py b/tests/components/huisbaasje/test_init.py new file mode 100644 index 00000000000..96be450f7e4 --- /dev/null +++ b/tests/components/huisbaasje/test_init.py @@ -0,0 +1,153 @@ +"""Test cases for the initialisation of the Huisbaasje integration.""" +from unittest.mock import patch + +from huisbaasje import HuisbaasjeException + +from homeassistant.components import huisbaasje +from homeassistant.config_entries import ( + CONN_CLASS_CLOUD_POLL, + ENTRY_STATE_LOADED, + ENTRY_STATE_NOT_LOADED, + ENTRY_STATE_SETUP_ERROR, + ConfigEntry, +) +from homeassistant.const import CONF_ID, CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from tests.components.huisbaasje.test_data import MOCK_CURRENT_MEASUREMENTS + + +async def test_setup(hass: HomeAssistant): + """Test for successfully setting up the platform.""" + assert await async_setup_component(hass, huisbaasje.DOMAIN, {}) + await hass.async_block_till_done() + assert huisbaasje.DOMAIN in hass.config.components + + +async def test_setup_entry(hass: HomeAssistant): + """Test for successfully setting a config entry.""" + with patch( + "huisbaasje.Huisbaasje.authenticate", return_value=None + ) as mock_authenticate, patch( + "huisbaasje.Huisbaasje.is_authenticated", return_value=True + ) as mock_is_authenticated, patch( + "huisbaasje.Huisbaasje.current_measurements", + return_value=MOCK_CURRENT_MEASUREMENTS, + ) as mock_current_measurements: + hass.config.components.add(huisbaasje.DOMAIN) + config_entry = ConfigEntry( + 1, + huisbaasje.DOMAIN, + "userId", + { + CONF_ID: "userId", + CONF_USERNAME: "username", + CONF_PASSWORD: "password", + }, + "test", + CONN_CLASS_CLOUD_POLL, + system_options={}, + ) + hass.config_entries._entries.append(config_entry) + + assert config_entry.state == ENTRY_STATE_NOT_LOADED + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + # Assert integration is loaded + assert config_entry.state == ENTRY_STATE_LOADED + assert huisbaasje.DOMAIN in hass.config.components + assert huisbaasje.DOMAIN in hass.data + assert config_entry.entry_id in hass.data[huisbaasje.DOMAIN] + + # Assert entities are loaded + entities = hass.states.async_entity_ids("sensor") + assert len(entities) == 14 + + # Assert mocks are called + assert len(mock_authenticate.mock_calls) == 1 + assert len(mock_is_authenticated.mock_calls) == 1 + assert len(mock_current_measurements.mock_calls) == 1 + + +async def test_setup_entry_error(hass: HomeAssistant): + """Test for successfully setting a config entry.""" + with patch( + "huisbaasje.Huisbaasje.authenticate", side_effect=HuisbaasjeException + ) as mock_authenticate: + hass.config.components.add(huisbaasje.DOMAIN) + config_entry = ConfigEntry( + 1, + huisbaasje.DOMAIN, + "userId", + { + CONF_ID: "userId", + CONF_USERNAME: "username", + CONF_PASSWORD: "password", + }, + "test", + CONN_CLASS_CLOUD_POLL, + system_options={}, + ) + hass.config_entries._entries.append(config_entry) + + assert config_entry.state == ENTRY_STATE_NOT_LOADED + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + # Assert integration is loaded with error + assert config_entry.state == ENTRY_STATE_SETUP_ERROR + assert huisbaasje.DOMAIN not in hass.data + + # Assert entities are not loaded + entities = hass.states.async_entity_ids("sensor") + assert len(entities) == 0 + + # Assert mocks are called + assert len(mock_authenticate.mock_calls) == 1 + + +async def test_unload_entry(hass: HomeAssistant): + """Test for successfully unloading the config entry.""" + with patch( + "huisbaasje.Huisbaasje.authenticate", return_value=None + ) as mock_authenticate, patch( + "huisbaasje.Huisbaasje.is_authenticated", return_value=True + ) as mock_is_authenticated, patch( + "huisbaasje.Huisbaasje.current_measurements", + return_value=MOCK_CURRENT_MEASUREMENTS, + ) as mock_current_measurements: + hass.config.components.add(huisbaasje.DOMAIN) + config_entry = ConfigEntry( + 1, + huisbaasje.DOMAIN, + "userId", + { + CONF_ID: "userId", + CONF_USERNAME: "username", + CONF_PASSWORD: "password", + }, + "test", + CONN_CLASS_CLOUD_POLL, + system_options={}, + ) + hass.config_entries._entries.append(config_entry) + + # Load config entry + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + assert config_entry.state == ENTRY_STATE_LOADED + entities = hass.states.async_entity_ids("sensor") + assert len(entities) == 14 + + # Unload config entry + await hass.config_entries.async_unload(config_entry.entry_id) + assert config_entry.state == ENTRY_STATE_NOT_LOADED + entities = hass.states.async_entity_ids("sensor") + assert len(entities) == 0 + + # Assert mocks are called + assert len(mock_authenticate.mock_calls) == 1 + assert len(mock_is_authenticated.mock_calls) == 1 + assert len(mock_current_measurements.mock_calls) == 1 diff --git a/tests/components/huisbaasje/test_sensor.py b/tests/components/huisbaasje/test_sensor.py new file mode 100644 index 00000000000..d1ffe565c84 --- /dev/null +++ b/tests/components/huisbaasje/test_sensor.py @@ -0,0 +1,120 @@ +"""Test cases for the sensors of the Huisbaasje integration.""" +from unittest.mock import patch + +from homeassistant.components import huisbaasje +from homeassistant.config_entries import CONN_CLASS_CLOUD_POLL, ConfigEntry +from homeassistant.const import CONF_ID, CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import HomeAssistant + +from tests.components.huisbaasje.test_data import ( + MOCK_CURRENT_MEASUREMENTS, + MOCK_LIMITED_CURRENT_MEASUREMENTS, +) + + +async def test_setup_entry(hass: HomeAssistant): + """Test for successfully loading sensor states.""" + with patch( + "huisbaasje.Huisbaasje.authenticate", return_value=None + ) as mock_authenticate, patch( + "huisbaasje.Huisbaasje.is_authenticated", return_value=True + ) as mock_is_authenticated, patch( + "huisbaasje.Huisbaasje.current_measurements", + return_value=MOCK_CURRENT_MEASUREMENTS, + ) as mock_current_measurements: + + hass.config.components.add(huisbaasje.DOMAIN) + config_entry = ConfigEntry( + 1, + huisbaasje.DOMAIN, + "userId", + { + CONF_ID: "userId", + CONF_USERNAME: "username", + CONF_PASSWORD: "password", + }, + "test", + CONN_CLASS_CLOUD_POLL, + system_options={}, + ) + hass.config_entries._entries.append(config_entry) + + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + # Assert data is loaded + assert hass.states.get("sensor.huisbaasje_current_power").state == "1012.0" + assert hass.states.get("sensor.huisbaasje_current_power_in").state == "1012.0" + assert ( + hass.states.get("sensor.huisbaasje_current_power_in_low").state == "unknown" + ) + assert hass.states.get("sensor.huisbaasje_current_power_out").state == "unknown" + assert ( + hass.states.get("sensor.huisbaasje_current_power_out_low").state + == "unknown" + ) + assert hass.states.get("sensor.huisbaasje_current_gas").state == "0.0" + assert hass.states.get("sensor.huisbaasje_energy_today").state == "3.3" + assert hass.states.get("sensor.huisbaasje_energy_this_week").state == "17.5" + assert hass.states.get("sensor.huisbaasje_energy_this_month").state == "103.3" + assert hass.states.get("sensor.huisbaasje_energy_this_year").state == "673.0" + assert hass.states.get("sensor.huisbaasje_gas_today").state == "1.1" + assert hass.states.get("sensor.huisbaasje_gas_this_week").state == "5.6" + assert hass.states.get("sensor.huisbaasje_gas_this_month").state == "39.1" + assert hass.states.get("sensor.huisbaasje_gas_this_year").state == "116.7" + + # Assert mocks are called + assert len(mock_authenticate.mock_calls) == 1 + assert len(mock_is_authenticated.mock_calls) == 1 + assert len(mock_current_measurements.mock_calls) == 1 + + +async def test_setup_entry_absent_measurement(hass: HomeAssistant): + """Test for successfully loading sensor states when response does not contain all measurements.""" + with patch( + "huisbaasje.Huisbaasje.authenticate", return_value=None + ) as mock_authenticate, patch( + "huisbaasje.Huisbaasje.is_authenticated", return_value=True + ) as mock_is_authenticated, patch( + "huisbaasje.Huisbaasje.current_measurements", + return_value=MOCK_LIMITED_CURRENT_MEASUREMENTS, + ) as mock_current_measurements: + + hass.config.components.add(huisbaasje.DOMAIN) + config_entry = ConfigEntry( + 1, + huisbaasje.DOMAIN, + "userId", + { + CONF_ID: "userId", + CONF_USERNAME: "username", + CONF_PASSWORD: "password", + }, + "test", + CONN_CLASS_CLOUD_POLL, + system_options={}, + ) + hass.config_entries._entries.append(config_entry) + + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + # Assert data is loaded + assert hass.states.get("sensor.huisbaasje_current_power").state == "1012.0" + assert hass.states.get("sensor.huisbaasje_current_power_in").state == "unknown" + assert ( + hass.states.get("sensor.huisbaasje_current_power_in_low").state == "unknown" + ) + assert hass.states.get("sensor.huisbaasje_current_power_out").state == "unknown" + assert ( + hass.states.get("sensor.huisbaasje_current_power_out_low").state + == "unknown" + ) + assert hass.states.get("sensor.huisbaasje_current_gas").state == "unknown" + assert hass.states.get("sensor.huisbaasje_energy_today").state == "3.3" + assert hass.states.get("sensor.huisbaasje_gas_today").state == "unknown" + + # Assert mocks are called + assert len(mock_authenticate.mock_calls) == 1 + assert len(mock_is_authenticated.mock_calls) == 1 + assert len(mock_current_measurements.mock_calls) == 1