New integration for Comelit SimpleHome (#96552)

* New integration for Comelit SimpleHome

* Address first review comments

* cleanup

* aiocomelit bump and coordinator cleanup

* address review comments

* Fix some review comments

* Use config_entry.unique_id as last resort

* review comments

* Add config_flow tests

* fix pre-commit missing checks

* test_conflig_flow coverage to 100%

* fix tests

* address latest review comments

* new ruff rule

* address review comments

* simplify unique_id
This commit is contained in:
Simone Chemelli 2023-08-18 08:40:23 +02:00 committed by GitHub
parent 9fdad592c2
commit ab9d6ce61a
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
16 changed files with 544 additions and 0 deletions

View file

@ -168,6 +168,10 @@ omit =
homeassistant/components/cmus/media_player.py homeassistant/components/cmus/media_player.py
homeassistant/components/coinbase/sensor.py homeassistant/components/coinbase/sensor.py
homeassistant/components/comed_hourly_pricing/sensor.py homeassistant/components/comed_hourly_pricing/sensor.py
homeassistant/components/comelit/__init__.py
homeassistant/components/comelit/const.py
homeassistant/components/comelit/coordinator.py
homeassistant/components/comelit/light.py
homeassistant/components/comfoconnect/fan.py homeassistant/components/comfoconnect/fan.py
homeassistant/components/concord232/alarm_control_panel.py homeassistant/components/concord232/alarm_control_panel.py
homeassistant/components/concord232/binary_sensor.py homeassistant/components/concord232/binary_sensor.py

View file

@ -209,6 +209,8 @@ build.json @home-assistant/supervisor
/tests/components/coinbase/ @tombrien /tests/components/coinbase/ @tombrien
/homeassistant/components/color_extractor/ @GenericStudent /homeassistant/components/color_extractor/ @GenericStudent
/tests/components/color_extractor/ @GenericStudent /tests/components/color_extractor/ @GenericStudent
/homeassistant/components/comelit/ @chemelli74
/tests/components/comelit/ @chemelli74
/homeassistant/components/comfoconnect/ @michaelarnauts /homeassistant/components/comfoconnect/ @michaelarnauts
/tests/components/comfoconnect/ @michaelarnauts /tests/components/comfoconnect/ @michaelarnauts
/homeassistant/components/command_line/ @gjohansson-ST /homeassistant/components/command_line/ @gjohansson-ST

View file

@ -0,0 +1,34 @@
"""Comelit integration."""
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_HOST, CONF_PIN, Platform
from homeassistant.core import HomeAssistant
from .const import DOMAIN
from .coordinator import ComelitSerialBridge
PLATFORMS = [Platform.LIGHT]
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up Comelit platform."""
coordinator = ComelitSerialBridge(hass, entry.data[CONF_HOST], entry.data[CONF_PIN])
await coordinator.async_config_entry_first_refresh()
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator
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."""
if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
coordinator: ComelitSerialBridge = hass.data[DOMAIN][entry.entry_id]
await coordinator.api.logout()
await coordinator.api.close()
hass.data[DOMAIN].pop(entry.entry_id)
return unload_ok

View file

@ -0,0 +1,145 @@
"""Config flow for Comelit integration."""
from __future__ import annotations
from collections.abc import Mapping
from typing import Any
from aiocomelit import ComeliteSerialBridgeAPi, exceptions as aiocomelit_exceptions
import voluptuous as vol
from homeassistant import core, exceptions
from homeassistant.config_entries import ConfigEntry, ConfigFlow
from homeassistant.const import CONF_HOST, CONF_PIN
from homeassistant.data_entry_flow import FlowResult
from .const import _LOGGER, DOMAIN
DEFAULT_HOST = "192.168.1.252"
DEFAULT_PIN = "111111"
def user_form_schema(user_input: dict[str, Any] | None) -> vol.Schema:
"""Return user form schema."""
user_input = user_input or {}
return vol.Schema(
{
vol.Optional(CONF_HOST, default=DEFAULT_HOST): str,
vol.Optional(CONF_PIN, default=DEFAULT_PIN): str,
}
)
STEP_REAUTH_DATA_SCHEMA = vol.Schema({vol.Required(CONF_PIN): str})
async def validate_input(
hass: core.HomeAssistant, data: dict[str, Any]
) -> dict[str, str]:
"""Validate the user input allows us to connect."""
api = ComeliteSerialBridgeAPi(data[CONF_HOST], data[CONF_PIN])
try:
await api.login()
except aiocomelit_exceptions.CannotConnect as err:
raise CannotConnect from err
except aiocomelit_exceptions.CannotAuthenticate as err:
raise InvalidAuth from err
finally:
await api.logout()
await api.close()
return {"title": data[CONF_HOST]}
class ComelitConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Comelit."""
VERSION = 1
_reauth_entry: ConfigEntry | None
_reauth_host: str
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
"""Handle the initial step."""
if user_input is None:
return self.async_show_form(
step_id="user", data_schema=user_form_schema(user_input)
)
self._async_abort_entries_match({CONF_HOST: user_input[CONF_HOST]})
errors = {}
try:
info = await validate_input(self.hass, user_input)
except CannotConnect:
errors["base"] = "cannot_connect"
except InvalidAuth:
errors["base"] = "invalid_auth"
except Exception: # pylint: disable=broad-except
_LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
else:
return self.async_create_entry(title=info["title"], data=user_input)
return self.async_show_form(
step_id="user", data_schema=user_form_schema(user_input), errors=errors
)
async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult:
"""Handle reauth flow."""
self._reauth_entry = self.hass.config_entries.async_get_entry(
self.context["entry_id"]
)
self._reauth_host = entry_data[CONF_HOST]
self.context["title_placeholders"] = {"host": self._reauth_host}
return await self.async_step_reauth_confirm()
async def async_step_reauth_confirm(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
"""Handle reauth confirm."""
assert self._reauth_entry
errors = {}
if user_input is not None:
try:
await validate_input(
self.hass, {CONF_HOST: self._reauth_host} | user_input
)
except CannotConnect:
errors["base"] = "cannot_connect"
except InvalidAuth:
errors["base"] = "invalid_auth"
except Exception: # pylint: disable=broad-except
_LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
else:
self.hass.config_entries.async_update_entry(
self._reauth_entry,
data={
CONF_HOST: self._reauth_host,
CONF_PIN: user_input[CONF_PIN],
},
)
self.hass.async_create_task(
self.hass.config_entries.async_reload(self._reauth_entry.entry_id)
)
return self.async_abort(reason="reauth_successful")
return self.async_show_form(
step_id="reauth_confirm",
description_placeholders={CONF_HOST: self._reauth_entry.data[CONF_HOST]},
data_schema=STEP_REAUTH_DATA_SCHEMA,
errors=errors,
)
class CannotConnect(exceptions.HomeAssistantError):
"""Error to indicate we cannot connect."""
class InvalidAuth(exceptions.HomeAssistantError):
"""Error to indicate there is invalid auth."""

View file

@ -0,0 +1,6 @@
"""Comelit constants."""
import logging
_LOGGER = logging.getLogger(__package__)
DOMAIN = "comelit"

View file

@ -0,0 +1,50 @@
"""Support for Comelit."""
import asyncio
from datetime import timedelta
from typing import Any
from aiocomelit import ComeliteSerialBridgeAPi
import aiohttp
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import _LOGGER, DOMAIN
class ComelitSerialBridge(DataUpdateCoordinator):
"""Queries Comelit Serial Bridge."""
def __init__(self, hass: HomeAssistant, host: str, pin: int) -> None:
"""Initialize the scanner."""
self._host = host
self._pin = pin
self.api = ComeliteSerialBridgeAPi(host, pin)
super().__init__(
hass=hass,
logger=_LOGGER,
name=f"{DOMAIN}-{host}-coordinator",
update_interval=timedelta(seconds=5),
)
async def _async_update_data(self) -> dict[str, Any]:
"""Update router data."""
_LOGGER.debug("Polling Comelit Serial Bridge host: %s", self._host)
try:
logged = await self.api.login()
except (asyncio.exceptions.TimeoutError, aiohttp.ClientConnectorError) as err:
_LOGGER.warning("Connection error for %s", self._host)
raise UpdateFailed(f"Error fetching data: {repr(err)}") from err
if not logged:
raise ConfigEntryAuthFailed
devices_data = await self.api.get_all_devices()
alarm_data = await self.api.get_alarm_config()
await self.api.logout()
return devices_data | alarm_data

View file

@ -0,0 +1,78 @@
"""Support for lights."""
from __future__ import annotations
from typing import Any
from aiocomelit import ComelitSerialBridgeObject
from aiocomelit.const import LIGHT, LIGHT_OFF, LIGHT_ON
from homeassistant.components.light import LightEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN
from .coordinator import ComelitSerialBridge
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up Comelit lights."""
coordinator: ComelitSerialBridge = hass.data[DOMAIN][config_entry.entry_id]
# Use config_entry.entry_id as base for unique_id because no serial number or mac is available
async_add_entities(
ComelitLightEntity(coordinator, device, config_entry.entry_id)
for device in coordinator.data[LIGHT].values()
)
class ComelitLightEntity(CoordinatorEntity[ComelitSerialBridge], LightEntity):
"""Light device."""
_attr_has_entity_name = True
_attr_name = None
def __init__(
self,
coordinator: ComelitSerialBridge,
device: ComelitSerialBridgeObject,
config_entry_unique_id: str | None,
) -> None:
"""Init light entity."""
self._api = coordinator.api
self._device = device
super().__init__(coordinator)
self._attr_unique_id = f"{config_entry_unique_id}-{device.index}"
self._attr_device_info = DeviceInfo(
identifiers={
(DOMAIN, self._attr_unique_id),
},
manufacturer="Comelit",
model="Serial Bridge",
name=device.name,
)
async def _light_set_state(self, state: int) -> None:
"""Set desired light state."""
await self.coordinator.api.light_switch(self._device.index, state)
await self.coordinator.async_request_refresh()
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn the light on."""
await self._light_set_state(LIGHT_ON)
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn the entity off."""
await self._light_set_state(LIGHT_OFF)
@property
def is_on(self) -> bool:
"""Return True if entity is on."""
return self.coordinator.data[LIGHT][self._device.index].status == LIGHT_ON

View file

@ -0,0 +1,10 @@
{
"domain": "comelit",
"name": "Comelit SimpleHome",
"codeowners": ["@chemelli74"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/comelit",
"iot_class": "local_polling",
"loggers": ["aiocomelit"],
"requirements": ["aiocomelit==0.0.5"]
}

View file

@ -0,0 +1,31 @@
{
"config": {
"flow_title": "{host}",
"step": {
"reauth_confirm": {
"description": "Please enter the correct PIN for VEDO system: {host}",
"data": {
"pin": "[%key:common::config_flow::data::pin%]"
}
},
"user": {
"data": {
"host": "[%key:common::config_flow::data::host%]",
"pin": "[%key:common::config_flow::data::pin%]"
}
}
},
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_service%]",
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
"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%]"
},
"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%]"
}
}
}

View file

@ -81,6 +81,7 @@ FLOWS = {
"cloudflare", "cloudflare",
"co2signal", "co2signal",
"coinbase", "coinbase",
"comelit",
"control4", "control4",
"coolmaster", "coolmaster",
"cpuspeed", "cpuspeed",

View file

@ -883,6 +883,12 @@
"config_flow": false, "config_flow": false,
"iot_class": "cloud_polling" "iot_class": "cloud_polling"
}, },
"comelit": {
"name": "Comelit SimpleHome",
"integration_type": "hub",
"config_flow": true,
"iot_class": "local_polling"
},
"comfoconnect": { "comfoconnect": {
"name": "Zehnder ComfoAir Q", "name": "Zehnder ComfoAir Q",
"integration_type": "hub", "integration_type": "hub",

View file

@ -208,6 +208,9 @@ aiobafi6==0.8.2
# homeassistant.components.aws # homeassistant.components.aws
aiobotocore==2.1.0 aiobotocore==2.1.0
# homeassistant.components.comelit
aiocomelit==0.0.5
# homeassistant.components.dhcp # homeassistant.components.dhcp
aiodiscover==1.4.16 aiodiscover==1.4.16

View file

@ -189,6 +189,9 @@ aiobafi6==0.8.2
# homeassistant.components.aws # homeassistant.components.aws
aiobotocore==2.1.0 aiobotocore==2.1.0
# homeassistant.components.comelit
aiocomelit==0.0.5
# homeassistant.components.dhcp # homeassistant.components.dhcp
aiodiscover==1.4.16 aiodiscover==1.4.16

View file

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

View file

@ -0,0 +1,16 @@
"""Common stuff for Comelit SimpleHome tests."""
from homeassistant.components.comelit.const import DOMAIN
from homeassistant.const import CONF_DEVICES, CONF_HOST, CONF_PIN
MOCK_CONFIG = {
DOMAIN: {
CONF_DEVICES: [
{
CONF_HOST: "fake_host",
CONF_PIN: "1234",
}
]
}
}
MOCK_USER_DATA = MOCK_CONFIG[DOMAIN][CONF_DEVICES][0]

View file

@ -0,0 +1,154 @@
"""Tests for Comelit SimpleHome config flow."""
from unittest.mock import patch
from aiocomelit import CannotAuthenticate, CannotConnect
import pytest
from homeassistant.components.comelit.const import DOMAIN
from homeassistant.config_entries import SOURCE_REAUTH, SOURCE_USER
from homeassistant.const import CONF_HOST, CONF_PIN
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
from .const import MOCK_USER_DATA
from tests.common import MockConfigEntry
async def test_user(hass: HomeAssistant) -> None:
"""Test starting a flow by user."""
with patch(
"aiocomelit.api.ComeliteSerialBridgeAPi.login",
), patch(
"aiocomelit.api.ComeliteSerialBridgeAPi.logout",
), patch(
"homeassistant.components.comelit.async_setup_entry"
) as mock_setup_entry, patch(
"requests.get"
) as mock_request_get:
mock_request_get.return_value.status_code = 200
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
assert result["type"] == FlowResultType.FORM
assert result["step_id"] == "user"
result = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input=MOCK_USER_DATA
)
assert result["type"] == FlowResultType.CREATE_ENTRY
assert result["data"][CONF_HOST] == "fake_host"
assert result["data"][CONF_PIN] == "1234"
assert not result["result"].unique_id
await hass.async_block_till_done()
assert mock_setup_entry.called
@pytest.mark.parametrize(
("side_effect", "error"),
[
(CannotConnect, "cannot_connect"),
(CannotAuthenticate, "invalid_auth"),
(ConnectionResetError, "unknown"),
],
)
async def test_exception_connection(hass: HomeAssistant, side_effect, error) -> None:
"""Test starting a flow by user with a connection error."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
assert result["type"] == FlowResultType.FORM
assert result["step_id"] == "user"
with patch(
"aiocomelit.api.ComeliteSerialBridgeAPi.login",
side_effect=side_effect,
):
result = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input=MOCK_USER_DATA
)
assert result["type"] == FlowResultType.FORM
assert result["step_id"] == "user"
assert result["errors"]["base"] == error
async def test_reauth_successful(hass: HomeAssistant) -> None:
"""Test starting a reauthentication flow."""
mock_config = MockConfigEntry(domain=DOMAIN, data=MOCK_USER_DATA)
mock_config.add_to_hass(hass)
with patch(
"aiocomelit.api.ComeliteSerialBridgeAPi.login",
), patch(
"aiocomelit.api.ComeliteSerialBridgeAPi.logout",
), patch("homeassistant.components.comelit.async_setup_entry"), patch(
"requests.get"
) as mock_request_get:
mock_request_get.return_value.status_code = 200
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_REAUTH, "entry_id": mock_config.entry_id},
data=mock_config.data,
)
assert result["type"] == FlowResultType.FORM
assert result["step_id"] == "reauth_confirm"
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input={
CONF_PIN: "other_fake_pin",
},
)
await hass.async_block_till_done()
assert result["type"] == FlowResultType.ABORT
assert result["reason"] == "reauth_successful"
@pytest.mark.parametrize(
("side_effect", "error"),
[
(CannotConnect, "cannot_connect"),
(CannotAuthenticate, "invalid_auth"),
(ConnectionResetError, "unknown"),
],
)
async def test_reauth_not_successful(hass: HomeAssistant, side_effect, error) -> None:
"""Test starting a reauthentication flow but no connection found."""
mock_config = MockConfigEntry(domain=DOMAIN, data=MOCK_USER_DATA)
mock_config.add_to_hass(hass)
with patch(
"aiocomelit.api.ComeliteSerialBridgeAPi.login", side_effect=side_effect
), patch(
"aiocomelit.api.ComeliteSerialBridgeAPi.logout",
), patch(
"homeassistant.components.comelit.async_setup_entry"
):
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_REAUTH, "entry_id": mock_config.entry_id},
data=mock_config.data,
)
assert result["type"] == FlowResultType.FORM
assert result["step_id"] == "reauth_confirm"
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input={
CONF_PIN: "other_fake_pin",
},
)
assert result["type"] == FlowResultType.FORM
assert result["step_id"] == "reauth_confirm"
assert result["errors"]["base"] == error