diff --git a/.coveragerc b/.coveragerc index 9e5044dff64..c012c8e686e 100644 --- a/.coveragerc +++ b/.coveragerc @@ -903,6 +903,9 @@ omit = homeassistant/components/opple/light.py homeassistant/components/oru/* homeassistant/components/orvibo/switch.py + homeassistant/components/osoenergy/__init__.py + homeassistant/components/osoenergy/const.py + homeassistant/components/osoenergy/water_heater.py homeassistant/components/osramlightify/light.py homeassistant/components/otp/sensor.py homeassistant/components/overkiz/__init__.py diff --git a/CODEOWNERS b/CODEOWNERS index e618db415c6..9bcc3daac17 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -930,6 +930,8 @@ build.json @home-assistant/supervisor /homeassistant/components/oralb/ @bdraco @Lash-L /tests/components/oralb/ @bdraco @Lash-L /homeassistant/components/oru/ @bvlaicu +/homeassistant/components/osoenergy/ @osohotwateriot +/tests/components/osoenergy/ @osohotwateriot /homeassistant/components/otbr/ @home-assistant/core /tests/components/otbr/ @home-assistant/core /homeassistant/components/ourgroceries/ @OnFreund diff --git a/homeassistant/components/osoenergy/__init__.py b/homeassistant/components/osoenergy/__init__.py new file mode 100644 index 00000000000..f0b89eaea90 --- /dev/null +++ b/homeassistant/components/osoenergy/__init__.py @@ -0,0 +1,81 @@ +"""Support for the OSO Energy devices and services.""" +from typing import Any, Generic, TypeVar + +from aiohttp.web_exceptions import HTTPException +from apyosoenergyapi import OSOEnergy +from apyosoenergyapi.helper.const import ( + OSOEnergyBinarySensorData, + OSOEnergySensorData, + OSOEnergyWaterHeaterData, +) +from apyosoenergyapi.helper.osoenergy_exceptions import OSOEnergyReauthRequired + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_API_KEY, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady +from homeassistant.helpers import aiohttp_client +from homeassistant.helpers.entity import Entity + +from .const import DOMAIN + +_T = TypeVar( + "_T", OSOEnergyBinarySensorData, OSOEnergySensorData, OSOEnergyWaterHeaterData +) + +PLATFORMS = [ + Platform.WATER_HEATER, +] +PLATFORM_LOOKUP = { + Platform.WATER_HEATER: "water_heater", +} + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up OSO Energy from a config entry.""" + subscription_key = entry.data[CONF_API_KEY] + websession = aiohttp_client.async_get_clientsession(hass) + osoenergy = OSOEnergy(subscription_key, websession) + + osoenergy_config = dict(entry.data) + + hass.data.setdefault(DOMAIN, {}) + + try: + devices: Any = await osoenergy.session.start_session(osoenergy_config) + except HTTPException as error: + raise ConfigEntryNotReady() from error + except OSOEnergyReauthRequired as err: + raise ConfigEntryAuthFailed from err + + hass.data[DOMAIN][entry.entry_id] = osoenergy + + platforms = set() + for ha_type, oso_type in PLATFORM_LOOKUP.items(): + device_list = devices.get(oso_type, []) + if device_list: + platforms.add(ha_type) + if platforms: + await hass.config_entries.async_forward_entry_setups(entry, platforms) + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + if unload_ok: + hass.data[DOMAIN].pop(entry.entry_id) + + return unload_ok + + +class OSOEnergyEntity(Entity, Generic[_T]): + """Initiate OSO Energy Base Class.""" + + _attr_has_entity_name = True + + def __init__(self, osoenergy: OSOEnergy, osoenergy_device: _T) -> None: + """Initialize the instance.""" + self.osoenergy = osoenergy + self.device = osoenergy_device + self._attr_unique_id = osoenergy_device.device_id diff --git a/homeassistant/components/osoenergy/config_flow.py b/homeassistant/components/osoenergy/config_flow.py new file mode 100644 index 00000000000..a7632b19bcb --- /dev/null +++ b/homeassistant/components/osoenergy/config_flow.py @@ -0,0 +1,75 @@ +"""Config Flow for OSO Energy.""" +from collections.abc import Mapping +import logging +from typing import Any + +from apyosoenergyapi import OSOEnergy +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_API_KEY +from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers import aiohttp_client + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) +_SCHEMA_STEP_USER = vol.Schema({vol.Required(CONF_API_KEY): str}) + + +class OSOEnergyFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a OSO Energy config flow.""" + + VERSION = 1 + CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL + + def __init__(self) -> None: + """Initialize.""" + self.entry: ConfigEntry | None = None + + async def async_step_user(self, user_input=None) -> FlowResult: + """Handle a flow initialized by the user.""" + errors = {} + + if user_input is not None: + # Verify Subscription key + if user_email := await self.get_user_email(user_input[CONF_API_KEY]): + await self.async_set_unique_id(user_email) + + if ( + self.context["source"] == config_entries.SOURCE_REAUTH + and self.entry + ): + self.hass.config_entries.async_update_entry( + self.entry, title=user_email, data=user_input + ) + await self.hass.config_entries.async_reload(self.entry.entry_id) + return self.async_abort(reason="reauth_successful") + + self._abort_if_unique_id_configured() + return self.async_create_entry(title=user_email, data=user_input) + + errors["base"] = "invalid_auth" + + return self.async_show_form( + step_id="user", + data_schema=_SCHEMA_STEP_USER, + errors=errors, + ) + + async def get_user_email(self, subscription_key: str) -> str | None: + """Return the user email for the provided subscription key.""" + try: + websession = aiohttp_client.async_get_clientsession(self.hass) + client = OSOEnergy(subscription_key, websession) + return await client.get_user_email() + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unknown error occurred") + return None + + async def async_step_reauth(self, user_input: Mapping[str, Any]) -> FlowResult: + """Re Authenticate a user.""" + self.entry = self.hass.config_entries.async_get_entry(self.context["entry_id"]) + data = {CONF_API_KEY: user_input[CONF_API_KEY]} + return await self.async_step_user(data) diff --git a/homeassistant/components/osoenergy/const.py b/homeassistant/components/osoenergy/const.py new file mode 100644 index 00000000000..c3925f5259b --- /dev/null +++ b/homeassistant/components/osoenergy/const.py @@ -0,0 +1,3 @@ +"""Constants for OSO Energy.""" + +DOMAIN = "osoenergy" diff --git a/homeassistant/components/osoenergy/manifest.json b/homeassistant/components/osoenergy/manifest.json new file mode 100644 index 00000000000..d6813108242 --- /dev/null +++ b/homeassistant/components/osoenergy/manifest.json @@ -0,0 +1,9 @@ +{ + "domain": "osoenergy", + "name": "OSO Energy", + "codeowners": ["@osohotwateriot"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/osoenergy", + "iot_class": "cloud_polling", + "requirements": ["pyosoenergyapi==1.1.3"] +} diff --git a/homeassistant/components/osoenergy/strings.json b/homeassistant/components/osoenergy/strings.json new file mode 100644 index 00000000000..a45482bf030 --- /dev/null +++ b/homeassistant/components/osoenergy/strings.json @@ -0,0 +1,29 @@ +{ + "config": { + "step": { + "user": { + "title": "OSO Energy Auth", + "description": "Enter the generated 'Subscription Key' for your account at 'https://portal.osoenergy.no/'", + "data": { + "api_key": "[%key:common::config_flow::data::api_key%]" + } + }, + "reauth": { + "title": "OSO Energy Auth", + "description": "Generate and enter a new 'Subscription Key' for your account at 'https://portal.osoenergy.no/'.", + "data": { + "api_key": "[%key:common::config_flow::data::api_key%]" + } + } + }, + "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%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" + } + } +} diff --git a/homeassistant/components/osoenergy/water_heater.py b/homeassistant/components/osoenergy/water_heater.py new file mode 100644 index 00000000000..4b2ad7c48d6 --- /dev/null +++ b/homeassistant/components/osoenergy/water_heater.py @@ -0,0 +1,142 @@ +"""Support for OSO Energy water heaters.""" +from typing import Any + +from apyosoenergyapi.helper.const import OSOEnergyWaterHeaterData + +from homeassistant.components.water_heater import ( + STATE_ECO, + STATE_ELECTRIC, + STATE_HIGH_DEMAND, + STATE_OFF, + WaterHeaterEntity, + WaterHeaterEntityFeature, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import UnitOfTemperature +from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import OSOEnergyEntity +from .const import DOMAIN + +CURRENT_OPERATION_MAP: dict[str, Any] = { + "default": { + "off": STATE_OFF, + "powersave": STATE_OFF, + "extraenergy": STATE_HIGH_DEMAND, + }, + "oso": { + "auto": STATE_ECO, + "off": STATE_OFF, + "powersave": STATE_OFF, + "extraenergy": STATE_HIGH_DEMAND, + }, +} +HEATER_MIN_TEMP = 10 +HEATER_MAX_TEMP = 80 +MANUFACTURER = "OSO Energy" + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up OSO Energy heater based on a config entry.""" + osoenergy = hass.data[DOMAIN][entry.entry_id] + devices = osoenergy.session.device_list.get("water_heater") + entities = [] + if devices: + for dev in devices: + entities.append(OSOEnergyWaterHeater(osoenergy, dev)) + async_add_entities(entities, True) + + +class OSOEnergyWaterHeater( + OSOEnergyEntity[OSOEnergyWaterHeaterData], WaterHeaterEntity +): + """OSO Energy Water Heater Device.""" + + _attr_name = None + _attr_supported_features = WaterHeaterEntityFeature.TARGET_TEMPERATURE + _attr_temperature_unit = UnitOfTemperature.CELSIUS + + @property + def device_info(self) -> DeviceInfo: + """Return device information.""" + return DeviceInfo( + identifiers={(DOMAIN, self.device.device_id)}, + manufacturer=MANUFACTURER, + model=self.device.device_type, + name=self.device.device_name, + ) + + @property + def available(self) -> bool: + """Return if the device is available.""" + return self.device.available + + @property + def current_operation(self) -> str: + """Return current operation.""" + status = self.device.current_operation + if status == "off": + return STATE_OFF + + optimization_mode = self.device.optimization_mode.lower() + heater_mode = self.device.heater_mode.lower() + if optimization_mode in CURRENT_OPERATION_MAP: + return CURRENT_OPERATION_MAP[optimization_mode].get( + heater_mode, STATE_ELECTRIC + ) + + return CURRENT_OPERATION_MAP["default"].get(heater_mode, STATE_ELECTRIC) + + @property + def current_temperature(self) -> float: + """Return the current temperature of the heater.""" + return self.device.current_temperature + + @property + def target_temperature(self) -> float: + """Return the temperature we try to reach.""" + return self.device.target_temperature + + @property + def target_temperature_high(self) -> float: + """Return the temperature we try to reach.""" + return self.device.target_temperature_high + + @property + def target_temperature_low(self) -> float: + """Return the temperature we try to reach.""" + return self.device.target_temperature_low + + @property + def min_temp(self) -> float: + """Return the minimum temperature.""" + return self.device.min_temperature + + @property + def max_temp(self) -> float: + """Return the maximum temperature.""" + return self.device.max_temperature + + async def async_turn_on(self, **kwargs) -> None: + """Turn on hotwater.""" + await self.osoenergy.hotwater.turn_on(self.device, True) + + async def async_turn_off(self, **kwargs) -> None: + """Turn off hotwater.""" + await self.osoenergy.hotwater.turn_off(self.device, True) + + async def async_set_temperature(self, **kwargs: Any) -> None: + """Set new target temperature.""" + target_temperature = int(kwargs.get("temperature", self.target_temperature)) + profile = [target_temperature] * 24 + + await self.osoenergy.hotwater.set_profile(self.device, profile) + + async def async_update(self) -> None: + """Update all Node data from Hive.""" + await self.osoenergy.session.update_data() + self.device = await self.osoenergy.hotwater.get_water_heater(self.device) diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index aa13faaf501..6315d2db46e 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -348,6 +348,7 @@ FLOWS = { "openweathermap", "opower", "oralb", + "osoenergy", "otbr", "ourgroceries", "overkiz", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 36aae4f799b..652fc11411e 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -4150,6 +4150,12 @@ "config_flow": false, "iot_class": "local_push" }, + "osoenergy": { + "name": "OSO Energy", + "integration_type": "hub", + "config_flow": true, + "iot_class": "cloud_polling" + }, "osramlightify": { "name": "Osramlightify", "integration_type": "hub", diff --git a/requirements_all.txt b/requirements_all.txt index 935f5f78075..77fe9c3801a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1955,6 +1955,9 @@ pyopnsense==0.4.0 # homeassistant.components.opple pyoppleio-legacy==1.0.8 +# homeassistant.components.osoenergy +pyosoenergyapi==1.1.3 + # homeassistant.components.opentherm_gw pyotgw==2.1.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 741b40b5ee4..cdc63c097f7 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1478,6 +1478,9 @@ pyopenuv==2023.02.0 # homeassistant.components.opnsense pyopnsense==0.4.0 +# homeassistant.components.osoenergy +pyosoenergyapi==1.1.3 + # homeassistant.components.opentherm_gw pyotgw==2.1.3 diff --git a/tests/components/osoenergy/__init__.py b/tests/components/osoenergy/__init__.py new file mode 100644 index 00000000000..76d134ef0f5 --- /dev/null +++ b/tests/components/osoenergy/__init__.py @@ -0,0 +1 @@ +"""Tests for the OSO Hotwater integration.""" diff --git a/tests/components/osoenergy/test_config_flow.py b/tests/components/osoenergy/test_config_flow.py new file mode 100644 index 00000000000..d7250356ebe --- /dev/null +++ b/tests/components/osoenergy/test_config_flow.py @@ -0,0 +1,164 @@ +"""Test the OSO Energy config flow.""" +from unittest.mock import patch + +from apyosoenergyapi.helper import osoenergy_exceptions + +from homeassistant import config_entries, data_entry_flow +from homeassistant.components.osoenergy.const import DOMAIN +from homeassistant.const import CONF_API_KEY +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + +SUBSCRIPTION_KEY = "valid subscription key" +SCAN_INTERVAL = 120 +TEST_USER_EMAIL = "test_user_email@domain.com" +UPDATED_SCAN_INTERVAL = 60 + + +async def test_user_flow(hass: HomeAssistant) -> None: + """Test the user flow.""" + 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( + "homeassistant.components.osoenergy.config_flow.OSOEnergy.get_user_email", + return_value=TEST_USER_EMAIL, + ), patch( + "homeassistant.components.osoenergy.async_setup_entry", return_value=True + ) as mock_setup_entry: + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_API_KEY: SUBSCRIPTION_KEY}, + ) + await hass.async_block_till_done() + + assert result2["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result2["title"] == TEST_USER_EMAIL + assert result2["data"] == { + CONF_API_KEY: SUBSCRIPTION_KEY, + } + + assert len(mock_setup_entry.mock_calls) == 1 + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + + +async def test_reauth_flow(hass: HomeAssistant) -> None: + """Test the reauth flow.""" + mock_config = MockConfigEntry( + domain=DOMAIN, + unique_id=TEST_USER_EMAIL, + data={CONF_API_KEY: SUBSCRIPTION_KEY}, + ) + mock_config.add_to_hass(hass) + + with patch( + "homeassistant.components.osoenergy.config_flow.OSOEnergy.get_user_email", + return_value=None, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": config_entries.SOURCE_REAUTH, + "unique_id": mock_config.unique_id, + "entry_id": mock_config.entry_id, + }, + data=mock_config.data, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["errors"] == {"base": "invalid_auth"} + + with patch( + "homeassistant.components.osoenergy.config_flow.OSOEnergy.get_user_email", + return_value=TEST_USER_EMAIL, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_API_KEY: SUBSCRIPTION_KEY, + }, + ) + await hass.async_block_till_done() + + assert mock_config.data.get(CONF_API_KEY) == SUBSCRIPTION_KEY + assert result2["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result2["reason"] == "reauth_successful" + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + + +async def test_abort_if_existing_entry(hass: HomeAssistant) -> None: + """Check flow abort when an entry already exist.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + unique_id=TEST_USER_EMAIL, + data={CONF_API_KEY: SUBSCRIPTION_KEY}, + ) + config_entry.add_to_hass(hass) + + with patch( + "homeassistant.components.osoenergy.config_flow.OSOEnergy.get_user_email", + return_value=TEST_USER_EMAIL, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + data={ + CONF_API_KEY: SUBSCRIPTION_KEY, + }, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "already_configured" + + +async def test_user_flow_invalid_subscription_key(hass: HomeAssistant) -> None: + """Test user flow with invalid username.""" + 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( + "homeassistant.components.osoenergy.config_flow.OSOEnergy.get_user_email", + return_value=None, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_API_KEY: SUBSCRIPTION_KEY}, + ) + + assert result2["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result2["step_id"] == "user" + assert result2["errors"] == {"base": "invalid_auth"} + + +async def test_user_flow_exception_on_subscription_key_check( + hass: HomeAssistant, +) -> None: + """Test user flow with invalid username.""" + 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( + "homeassistant.components.osoenergy.config_flow.OSOEnergy.get_user_email", + side_effect=osoenergy_exceptions.OSOEnergyReauthRequired(), + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_API_KEY: SUBSCRIPTION_KEY}, + ) + + assert result2["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result2["step_id"] == "user" + assert result2["errors"] == {"base": "invalid_auth"}