diff --git a/.coveragerc b/.coveragerc index 7e3bd6c1e50..d3c5094d4b1 100644 --- a/.coveragerc +++ b/.coveragerc @@ -721,6 +721,9 @@ omit = homeassistant/components/mochad/* homeassistant/components/modbus/climate.py homeassistant/components/modem_callerid/sensor.py + homeassistant/components/moehlenhoff_alpha2/__init__.py + homeassistant/components/moehlenhoff_alpha2/climate.py + homeassistant/components/moehlenhoff_alpha2/const.py homeassistant/components/motion_blinds/__init__.py homeassistant/components/motion_blinds/const.py homeassistant/components/motion_blinds/cover.py diff --git a/CODEOWNERS b/CODEOWNERS index 1becdf7a502..20fe7d086e0 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -574,6 +574,8 @@ homeassistant/components/modem_callerid/* @tkdrob tests/components/modem_callerid/* @tkdrob homeassistant/components/modern_forms/* @wonderslug tests/components/modern_forms/* @wonderslug +homeassistant/components/moehlenhoff_alpha2/* @j-a-n +tests/components/moehlenhoff_alpha2/* @j-a-n homeassistant/components/monoprice/* @etsinko @OnFreund tests/components/monoprice/* @etsinko @OnFreund homeassistant/components/moon/* @fabaff diff --git a/homeassistant/components/moehlenhoff_alpha2/__init__.py b/homeassistant/components/moehlenhoff_alpha2/__init__.py new file mode 100644 index 00000000000..62e18917dc6 --- /dev/null +++ b/homeassistant/components/moehlenhoff_alpha2/__init__.py @@ -0,0 +1,159 @@ +"""Support for the Moehlenhoff Alpha2.""" +from __future__ import annotations + +from datetime import timedelta +import logging + +import aiohttp +from moehlenhoff_alpha2 import Alpha2Base + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + +PLATFORMS = [Platform.CLIMATE] + +UPDATE_INTERVAL = timedelta(seconds=60) + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up a config entry.""" + base = Alpha2Base(entry.data["host"]) + coordinator = Alpha2BaseCoordinator(hass, base) + + await coordinator.async_config_entry_first_refresh() + + hass.data.setdefault(DOMAIN, {}) + hass.data[DOMAIN][entry.entry_id] = coordinator + + hass.config_entries.async_setup_platforms(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 and entry.entry_id in hass.data[DOMAIN]: + hass.data[DOMAIN].pop(entry.entry_id) + + return unload_ok + + +async def update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Handle options update.""" + await hass.config_entries.async_reload(entry.entry_id) + + +class Alpha2BaseCoordinator(DataUpdateCoordinator[dict[str, dict]]): + """Keep the base instance in one place and centralize the update.""" + + def __init__(self, hass: HomeAssistant, base: Alpha2Base) -> None: + """Initialize Alpha2Base data updater.""" + self.base = base + super().__init__( + hass=hass, + logger=_LOGGER, + name="alpha2_base", + update_interval=UPDATE_INTERVAL, + ) + + async def _async_update_data(self) -> dict[str, dict]: + """Fetch the latest data from the source.""" + await self.base.update_data() + return {ha["ID"]: ha for ha in self.base.heat_areas if ha.get("ID")} + + def get_cooling(self) -> bool: + """Return if cooling mode is enabled.""" + return self.base.cooling + + async def async_set_cooling(self, enabled: bool) -> None: + """Enable or disable cooling mode.""" + await self.base.set_cooling(enabled) + for update_callback in self._listeners: + update_callback() + + async def async_set_target_temperature( + self, heat_area_id: str, target_temperature: float + ) -> None: + """Set the target temperature of the given heat area.""" + _LOGGER.debug( + "Setting target temperature of heat area %s to %0.1f", + heat_area_id, + target_temperature, + ) + + update_data = {"T_TARGET": target_temperature} + is_cooling = self.get_cooling() + heat_area_mode = self.data[heat_area_id]["HEATAREA_MODE"] + if heat_area_mode == 1: + if is_cooling: + update_data["T_COOL_DAY"] = target_temperature + else: + update_data["T_HEAT_DAY"] = target_temperature + elif heat_area_mode == 2: + if is_cooling: + update_data["T_COOL_NIGHT"] = target_temperature + else: + update_data["T_HEAT_NIGHT"] = target_temperature + + try: + await self.base.update_heat_area(heat_area_id, update_data) + except aiohttp.ClientError as http_err: + raise HomeAssistantError( + "Failed to set target temperature, communication error with alpha2 base" + ) from http_err + self.data[heat_area_id].update(update_data) + for update_callback in self._listeners: + update_callback() + + async def async_set_heat_area_mode( + self, heat_area_id: str, heat_area_mode: int + ) -> None: + """Set the mode of the given heat area.""" + # HEATAREA_MODE: 0=Auto, 1=Tag, 2=Nacht + if heat_area_mode not in (0, 1, 2): + ValueError(f"Invalid heat area mode: {heat_area_mode}") + _LOGGER.debug( + "Setting mode of heat area %s to %d", + heat_area_id, + heat_area_mode, + ) + try: + await self.base.update_heat_area( + heat_area_id, {"HEATAREA_MODE": heat_area_mode} + ) + except aiohttp.ClientError as http_err: + raise HomeAssistantError( + "Failed to set heat area mode, communication error with alpha2 base" + ) from http_err + + self.data[heat_area_id]["HEATAREA_MODE"] = heat_area_mode + is_cooling = self.get_cooling() + if heat_area_mode == 1: + if is_cooling: + self.data[heat_area_id]["T_TARGET"] = self.data[heat_area_id][ + "T_COOL_DAY" + ] + else: + self.data[heat_area_id]["T_TARGET"] = self.data[heat_area_id][ + "T_HEAT_DAY" + ] + elif heat_area_mode == 2: + if is_cooling: + self.data[heat_area_id]["T_TARGET"] = self.data[heat_area_id][ + "T_COOL_NIGHT" + ] + else: + self.data[heat_area_id]["T_TARGET"] = self.data[heat_area_id][ + "T_HEAT_NIGHT" + ] + for update_callback in self._listeners: + update_callback() diff --git a/homeassistant/components/moehlenhoff_alpha2/climate.py b/homeassistant/components/moehlenhoff_alpha2/climate.py new file mode 100644 index 00000000000..da536c4bd06 --- /dev/null +++ b/homeassistant/components/moehlenhoff_alpha2/climate.py @@ -0,0 +1,131 @@ +"""Support for Alpha2 room control unit via Alpha2 base.""" +import logging + +from homeassistant.components.climate import ClimateEntity +from homeassistant.components.climate.const import ( + CURRENT_HVAC_COOL, + CURRENT_HVAC_HEAT, + CURRENT_HVAC_IDLE, + HVAC_MODE_COOL, + HVAC_MODE_HEAT, + SUPPORT_PRESET_MODE, + SUPPORT_TARGET_TEMPERATURE, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ATTR_TEMPERATURE, TEMP_CELSIUS +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from . import Alpha2BaseCoordinator +from .const import DOMAIN, PRESET_AUTO, PRESET_DAY, PRESET_NIGHT + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Add Alpha2Climate entities from a config_entry.""" + + coordinator: Alpha2BaseCoordinator = hass.data[DOMAIN][config_entry.entry_id] + + async_add_entities( + Alpha2Climate(coordinator, heat_area_id) for heat_area_id in coordinator.data + ) + + +# https://developers.home-assistant.io/docs/core/entity/climate/ +class Alpha2Climate(CoordinatorEntity, ClimateEntity): + """Alpha2 ClimateEntity.""" + + coordinator: Alpha2BaseCoordinator + target_temperature_step = 0.2 + + _attr_supported_features = SUPPORT_TARGET_TEMPERATURE | SUPPORT_PRESET_MODE + _attr_hvac_modes = [HVAC_MODE_HEAT, HVAC_MODE_COOL] + _attr_temperature_unit = TEMP_CELSIUS + _attr_preset_modes = [PRESET_AUTO, PRESET_DAY, PRESET_NIGHT] + + def __init__(self, coordinator: Alpha2BaseCoordinator, heat_area_id: str) -> None: + """Initialize Alpha2 ClimateEntity.""" + super().__init__(coordinator) + self.heat_area_id = heat_area_id + + @property + def name(self) -> str: + """Return the name of the climate device.""" + return self.coordinator.data[self.heat_area_id]["HEATAREA_NAME"] + + @property + def min_temp(self) -> float: + """Return the minimum temperature.""" + return float(self.coordinator.data[self.heat_area_id].get("T_TARGET_MIN", 0.0)) + + @property + def max_temp(self) -> float: + """Return the maximum temperature.""" + return float(self.coordinator.data[self.heat_area_id].get("T_TARGET_MAX", 30.0)) + + @property + def current_temperature(self) -> float: + """Return the current temperature.""" + return float(self.coordinator.data[self.heat_area_id].get("T_ACTUAL", 0.0)) + + @property + def hvac_mode(self) -> str: + """Return current hvac mode.""" + if self.coordinator.get_cooling(): + return HVAC_MODE_COOL + return HVAC_MODE_HEAT + + async def async_set_hvac_mode(self, hvac_mode: str) -> None: + """Set new target hvac mode.""" + await self.coordinator.async_set_cooling(hvac_mode == HVAC_MODE_COOL) + + @property + def hvac_action(self) -> str: + """Return the current running hvac operation.""" + if not self.coordinator.data[self.heat_area_id]["_HEATCTRL_STATE"]: + return CURRENT_HVAC_IDLE + if self.coordinator.get_cooling(): + return CURRENT_HVAC_COOL + return CURRENT_HVAC_HEAT + + @property + def target_temperature(self) -> float: + """Return the temperature we try to reach.""" + return float(self.coordinator.data[self.heat_area_id].get("T_TARGET", 0.0)) + + async def async_set_temperature(self, **kwargs) -> None: + """Set new target temperatures.""" + target_temperature = kwargs.get(ATTR_TEMPERATURE) + if target_temperature is None: + return + + await self.coordinator.async_set_target_temperature( + self.heat_area_id, target_temperature + ) + + @property + def preset_mode(self) -> str: + """Return the current preset mode.""" + if self.coordinator.data[self.heat_area_id]["HEATAREA_MODE"] == 1: + return PRESET_DAY + if self.coordinator.data[self.heat_area_id]["HEATAREA_MODE"] == 2: + return PRESET_NIGHT + return PRESET_AUTO + + async def async_set_preset_mode(self, preset_mode: str) -> None: + """Set new operation mode.""" + heat_area_mode = 0 + if preset_mode == PRESET_DAY: + heat_area_mode = 1 + elif preset_mode == PRESET_NIGHT: + heat_area_mode = 2 + + await self.coordinator.async_set_heat_area_mode( + self.heat_area_id, heat_area_mode + ) diff --git a/homeassistant/components/moehlenhoff_alpha2/config_flow.py b/homeassistant/components/moehlenhoff_alpha2/config_flow.py new file mode 100644 index 00000000000..cafdca040b3 --- /dev/null +++ b/homeassistant/components/moehlenhoff_alpha2/config_flow.py @@ -0,0 +1,55 @@ +"""Alpha2 config flow.""" +import asyncio +import logging + +import aiohttp +from moehlenhoff_alpha2 import Alpha2Base +import voluptuous as vol + +from homeassistant import config_entries + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + +DATA_SCHEMA = vol.Schema({vol.Required("host"): str}) + + +async def validate_input(data): + """Validate the user input allows us to connect. + + Data has the keys from DATA_SCHEMA with values provided by the user. + """ + + base = Alpha2Base(data["host"]) + try: + await base.update_data() + except (aiohttp.client_exceptions.ClientConnectorError, asyncio.TimeoutError): + return {"error": "cannot_connect"} + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + return {"error": "unknown"} + + # Return info that you want to store in the config entry. + return {"title": base.name} + + +class Alpha2BaseConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Möhlenhoff Alpha2 config flow.""" + + VERSION = 1 + + async def async_step_user(self, user_input=None): + """Handle a flow initialized by the user.""" + errors = {} + if user_input is not None: + self._async_abort_entries_match({"host": user_input["host"]}) + result = await validate_input(user_input) + if result.get("error"): + errors["base"] = result["error"] + else: + return self.async_create_entry(title=result["title"], data=user_input) + + return self.async_show_form( + step_id="user", data_schema=DATA_SCHEMA, errors=errors + ) diff --git a/homeassistant/components/moehlenhoff_alpha2/const.py b/homeassistant/components/moehlenhoff_alpha2/const.py new file mode 100644 index 00000000000..268936982bd --- /dev/null +++ b/homeassistant/components/moehlenhoff_alpha2/const.py @@ -0,0 +1,6 @@ +"""Constants for the Alpha2 integration.""" + +DOMAIN = "moehlenhoff_alpha2" +PRESET_AUTO = "auto" +PRESET_DAY = "day" +PRESET_NIGHT = "night" diff --git a/homeassistant/components/moehlenhoff_alpha2/manifest.json b/homeassistant/components/moehlenhoff_alpha2/manifest.json new file mode 100644 index 00000000000..b755b28f826 --- /dev/null +++ b/homeassistant/components/moehlenhoff_alpha2/manifest.json @@ -0,0 +1,11 @@ +{ + "domain": "moehlenhoff_alpha2", + "name": "Möhlenhoff Alpha 2", + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/moehlenhoff_alpha2", + "requirements": ["moehlenhoff-alpha2==1.1.2"], + "iot_class": "local_push", + "codeowners": [ + "@j-a-n" + ] +} diff --git a/homeassistant/components/moehlenhoff_alpha2/strings.json b/homeassistant/components/moehlenhoff_alpha2/strings.json new file mode 100644 index 00000000000..3347b2f318c --- /dev/null +++ b/homeassistant/components/moehlenhoff_alpha2/strings.json @@ -0,0 +1,19 @@ +{ + "title": "Möhlenhoff Alpha2", + "config": { + "step": { + "user": { + "data": { + "host": "[%key:common::config_flow::data::host%]" + } + } + }, + "error": { + "cannot_connect": "[%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 2aab7c81481..be18826bedb 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -199,6 +199,7 @@ FLOWS = [ "mobile_app", "modem_callerid", "modern_forms", + "moehlenhoff_alpha2", "monoprice", "motion_blinds", "motioneye", diff --git a/requirements_all.txt b/requirements_all.txt index 5fa50cde842..01011b048b7 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1048,6 +1048,9 @@ minio==5.0.10 # homeassistant.components.mitemp_bt mitemp_bt==0.0.5 +# homeassistant.components.moehlenhoff_alpha2 +moehlenhoff-alpha2==1.1.2 + # homeassistant.components.motion_blinds motionblinds==0.5.11 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 4a74f1de59c..8f57bc2b05e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -657,6 +657,9 @@ millheater==0.9.0 # homeassistant.components.minio minio==5.0.10 +# homeassistant.components.moehlenhoff_alpha2 +moehlenhoff-alpha2==1.1.2 + # homeassistant.components.motion_blinds motionblinds==0.5.11 diff --git a/tests/components/moehlenhoff_alpha2/__init__.py b/tests/components/moehlenhoff_alpha2/__init__.py new file mode 100644 index 00000000000..76bd1fd00aa --- /dev/null +++ b/tests/components/moehlenhoff_alpha2/__init__.py @@ -0,0 +1 @@ +"""Tests for the moehlenhoff_alpha2 integration.""" diff --git a/tests/components/moehlenhoff_alpha2/test_config_flow.py b/tests/components/moehlenhoff_alpha2/test_config_flow.py new file mode 100644 index 00000000000..ccfa98718e5 --- /dev/null +++ b/tests/components/moehlenhoff_alpha2/test_config_flow.py @@ -0,0 +1,106 @@ +"""Test the moehlenhoff_alpha2 config flow.""" +import asyncio +from unittest.mock import patch + +from homeassistant import config_entries +from homeassistant.components.moehlenhoff_alpha2.const import DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import ( + RESULT_TYPE_ABORT, + RESULT_TYPE_CREATE_ENTRY, + RESULT_TYPE_FORM, +) + +from tests.common import MockConfigEntry + +MOCK_BASE_ID = "fake-base-id" +MOCK_BASE_NAME = "fake-base-name" +MOCK_BASE_HOST = "fake-base-host" + + +async def mock_update_data(self): + """Mock moehlenhoff_alpha2.Alpha2Base.update_data.""" + self.static_data = { + "Devices": { + "Device": {"ID": MOCK_BASE_ID, "NAME": MOCK_BASE_NAME, "HEATAREA": []} + } + } + + +async def test_form(hass: HomeAssistant) -> None: + """Test we get the form.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + ) + assert result["type"] == RESULT_TYPE_FORM + assert not result["errors"] + + with patch("moehlenhoff_alpha2.Alpha2Base.update_data", mock_update_data), patch( + "homeassistant.components.moehlenhoff_alpha2.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result2 = await hass.config_entries.flow.async_configure( + flow_id=result["flow_id"], + user_input={"host": MOCK_BASE_HOST}, + ) + await hass.async_block_till_done() + + assert result2["type"] == RESULT_TYPE_CREATE_ENTRY + assert result2["title"] == MOCK_BASE_NAME + assert result2["data"] == {"host": MOCK_BASE_HOST} + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_form_duplicate_error(hass: HomeAssistant) -> None: + """Test that errors are shown when duplicates are added.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + data={"host": MOCK_BASE_HOST}, + source=config_entries.SOURCE_USER, + ) + config_entry.add_to_hass(hass) + + assert config_entry.data["host"] == MOCK_BASE_HOST + + with patch("moehlenhoff_alpha2.Alpha2Base.update_data", mock_update_data): + + result = await hass.config_entries.flow.async_init( + DOMAIN, + data={"host": MOCK_BASE_HOST}, + context={"source": config_entries.SOURCE_USER}, + ) + assert result["type"] == RESULT_TYPE_ABORT + assert result["reason"] == "already_configured" + + +async def test_form_cannot_connect_error(hass: HomeAssistant) -> None: + """Test connection error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + with patch( + "moehlenhoff_alpha2.Alpha2Base.update_data", side_effect=asyncio.TimeoutError + ): + result2 = await hass.config_entries.flow.async_configure( + flow_id=result["flow_id"], + user_input={"host": MOCK_BASE_HOST}, + ) + + assert result2["type"] == RESULT_TYPE_FORM + assert result2["errors"] == {"base": "cannot_connect"} + + +async def test_form_unexpected_error(hass: HomeAssistant) -> None: + """Test unexpected error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + with patch("moehlenhoff_alpha2.Alpha2Base.update_data", side_effect=Exception): + result2 = await hass.config_entries.flow.async_configure( + flow_id=result["flow_id"], + user_input={"host": MOCK_BASE_HOST}, + ) + + assert result2["type"] == RESULT_TYPE_FORM + assert result2["errors"] == {"base": "unknown"}