Add OSO Energy integration (#70365)

* Add OSO Energy integration

* Add min/max for v40 level and bump pyosoenergyapi to 1.0.2

* OSO Energy address review comments

* Bump pyosoenergyapi to 1.0.3 and remove scan interval

* Remove unnecessary code

* Update homeassistant/components/osoenergy/__init__.py

Co-authored-by: Daniel Hjelseth Høyer <mail@dahoiv.net>

* Fixes to latest version

* Add support to set temperature

* Update homeassistant/components/osoenergy/config_flow.py

Co-authored-by: Daniel Hjelseth Høyer <mail@dahoiv.net>

* Fixes after review

* Remove unused code

* Add support for translations and modify services

* Apply suggestions from code review

Co-authored-by: Robert Resch <robert@resch.dev>

* Refactor services and constants according to the PR suggestions

* Remove unnecessary code

* Remove unused import in constants

* Refactoring and support for multiple instances

* Apply suggestions from code review

Co-authored-by: Robert Resch <robert@resch.dev>

* Refactor code and apply review suggestions

* Bump pyosoenergyapi to 1.0.5

* Remove services to reduce initial PR

* Remove extra state attributes and make OSO Entity generic

---------

Co-authored-by: Daniel Hjelseth Høyer <github@dahoiv.net>
Co-authored-by: Daniel Hjelseth Høyer <mail@dahoiv.net>
Co-authored-by: Robert Resch <robert@resch.dev>
pull/105280/head
osohotwateriot 2023-12-08 09:51:59 +02:00 committed by GitHub
parent 43daeb2630
commit 664d2410d5
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 522 additions and 0 deletions

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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)

View File

@ -0,0 +1,3 @@
"""Constants for OSO Energy."""
DOMAIN = "osoenergy"

View File

@ -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"]
}

View File

@ -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%]"
}
}
}

View File

@ -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)

View File

@ -348,6 +348,7 @@ FLOWS = {
"openweathermap",
"opower",
"oralb",
"osoenergy",
"otbr",
"ourgroceries",
"overkiz",

View File

@ -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",

View File

@ -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

View File

@ -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

View File

@ -0,0 +1 @@
"""Tests for the OSO Hotwater integration."""

View File

@ -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"}