Add Moehlenhoff Alpha2 underfloor heating system integration (#42771)

* Add Moehlenhoff Alpha2 underfloor heating system integration

* isort changes

* flake8 changes

* Do not exclude config_flow.py

* pylint changes

* Add config_flow test

* correct requirements_test_all.txt

* more tests

* Update test description

* Test connection and catch TimeoutError in async_setup_entry

* Add version to manifest file

* Remove version from manifest file

* Replace tests.async_mock.patch by unittest.mock.patch

* Update moehlenhoff-alpha2 to version 1.0.1

* Update requirements for moehlenhoff-alpha2 1.0.1

* Update moehlenhoff-alpha2 to 1.0.2

* Use async_setup_platforms

* Use async_unload_platforms

* Separate connection and devices for each entry_id

* Use async_track_time_interval to schedule updates

* Check if input is valid before checking uniqueness

* Move Exception handling to validate_input

* Catch aiohttp.client_exceptions.ClientConnectorError

* Remove translation files

* Mock TimeoutError

* Fix data update

* Replace current callback implementation with ha dispatcher

* Return False in should_poll

* Remove unused argument

* Remove CONNECTION_CLASS

* Use _async_current_entries

* Call async_schedule_update_ha_state after data update

* Remove unneeded async_setup

Co-authored-by: Milan Meulemans <milan.meulemans@live.be>

* Remove unneeded async_setup_platform

Co-authored-by: Milan Meulemans <milan.meulemans@live.be>

* Set Schema attribute host required

Co-authored-by: Milan Meulemans <milan.meulemans@live.be>

* Remove unused Exception class

Co-authored-by: Milan Meulemans <milan.meulemans@live.be>

* Update manifest.json

Co-authored-by: Milan Meulemans <milan.meulemans@live.be>

* pylint constructor return type None

* Replace properties by class variables

* use pass instead of return

* Remove unused sync update method

* remove property hvac_action

* remove pass

* rework exception handling

* Update homeassistant/components/moehlenhoff_alpha2/config_flow.py

Co-authored-by: Milan Meulemans <milan.meulemans@live.be>

* Correct indentation

* catch Exception in validate_input

* Replace HomeAssistantType with HomeAssistant

* Update to moehlenhoff-alpha2 1.0.3

* Allow to switch between heating and cooling mode

* Update moehlenhoff-alpha2 to version 1.0.4

* Update heatarea data after setting target temperature

* Support hvac_action

* Fix heatarea update with multiple bases

* Update data after setting preset mode

* Use custom preset modes like defined by device

* Fix config flow test

* Fix test_duplicate_error

* Rename property to extra_state_attributes

Rename property device_state_attributes to extra_state_attributes and
return lowercase keys in dict.

* Refactor using DataUpdateCoordinator

* Remove _attr_should_poll

* Raise HomeAssistantError on communication error

Catch HTTPError instead of broad except and reraise as HomeAssistantError

* Change DataUpdateCoordinator name to alpha2_base

* Refresh coordinator before setting data

* Raise ValueError on invalid heat area mode

* Rename heatarea to heat_area

* Set type annotation in class attribute

* Move coordinator to top

* Move exception handling to the coordinator

* Use heat_area_id directly

* Sore get_cooling() result into local var

* Add explanation of status attributes

and remove BLOCK_HC

* Fix pylint warnings

* from __future__ import annotations

* Use Platform Enum

* Move data handling to coordinator

* Remove property extra_state_attributes

* Add missing annotations

* Update moehlenhoff-alpha2 to version 1.1.2

* Rework tests based on the scaffold template

* Set also heat/cool/day/night temp with target temp

* Remove unneeded code from tests

Co-authored-by: Milan Meulemans <milan.meulemans@live.be>
This commit is contained in:
j-a-n
2022-02-10 08:28:52 +01:00
committed by GitHub
parent 543b49728a
commit 243d003acc
13 changed files with 500 additions and 0 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,6 @@
"""Constants for the Alpha2 integration."""
DOMAIN = "moehlenhoff_alpha2"
PRESET_AUTO = "auto"
PRESET_DAY = "day"
PRESET_NIGHT = "night"

View File

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

View File

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

View File

@ -199,6 +199,7 @@ FLOWS = [
"mobile_app",
"modem_callerid",
"modern_forms",
"moehlenhoff_alpha2",
"monoprice",
"motion_blinds",
"motioneye",

View File

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

View File

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

View File

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

View File

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