diff --git a/.coveragerc b/.coveragerc index 71542ebad3a..93958a67973 100644 --- a/.coveragerc +++ b/.coveragerc @@ -168,6 +168,10 @@ omit = homeassistant/components/cmus/media_player.py homeassistant/components/coinbase/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/concord232/alarm_control_panel.py homeassistant/components/concord232/binary_sensor.py diff --git a/CODEOWNERS b/CODEOWNERS index bd1b8ed49f0..812caea4da5 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -209,6 +209,8 @@ build.json @home-assistant/supervisor /tests/components/coinbase/ @tombrien /homeassistant/components/color_extractor/ @GenericStudent /tests/components/color_extractor/ @GenericStudent +/homeassistant/components/comelit/ @chemelli74 +/tests/components/comelit/ @chemelli74 /homeassistant/components/comfoconnect/ @michaelarnauts /tests/components/comfoconnect/ @michaelarnauts /homeassistant/components/command_line/ @gjohansson-ST diff --git a/homeassistant/components/comelit/__init__.py b/homeassistant/components/comelit/__init__.py new file mode 100644 index 00000000000..2c73922582c --- /dev/null +++ b/homeassistant/components/comelit/__init__.py @@ -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 diff --git a/homeassistant/components/comelit/config_flow.py b/homeassistant/components/comelit/config_flow.py new file mode 100644 index 00000000000..dd6227a6583 --- /dev/null +++ b/homeassistant/components/comelit/config_flow.py @@ -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.""" diff --git a/homeassistant/components/comelit/const.py b/homeassistant/components/comelit/const.py new file mode 100644 index 00000000000..e08caa55f76 --- /dev/null +++ b/homeassistant/components/comelit/const.py @@ -0,0 +1,6 @@ +"""Comelit constants.""" +import logging + +_LOGGER = logging.getLogger(__package__) + +DOMAIN = "comelit" diff --git a/homeassistant/components/comelit/coordinator.py b/homeassistant/components/comelit/coordinator.py new file mode 100644 index 00000000000..beb7266c403 --- /dev/null +++ b/homeassistant/components/comelit/coordinator.py @@ -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 diff --git a/homeassistant/components/comelit/light.py b/homeassistant/components/comelit/light.py new file mode 100644 index 00000000000..9a893bd929c --- /dev/null +++ b/homeassistant/components/comelit/light.py @@ -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 diff --git a/homeassistant/components/comelit/manifest.json b/homeassistant/components/comelit/manifest.json new file mode 100644 index 00000000000..fc7f2a3fc12 --- /dev/null +++ b/homeassistant/components/comelit/manifest.json @@ -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"] +} diff --git a/homeassistant/components/comelit/strings.json b/homeassistant/components/comelit/strings.json new file mode 100644 index 00000000000..6508f58412e --- /dev/null +++ b/homeassistant/components/comelit/strings.json @@ -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%]" + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 7de32dc5071..0bfbf362eb3 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -81,6 +81,7 @@ FLOWS = { "cloudflare", "co2signal", "coinbase", + "comelit", "control4", "coolmaster", "cpuspeed", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index ed51bcc7dbf..40883ef3d7c 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -883,6 +883,12 @@ "config_flow": false, "iot_class": "cloud_polling" }, + "comelit": { + "name": "Comelit SimpleHome", + "integration_type": "hub", + "config_flow": true, + "iot_class": "local_polling" + }, "comfoconnect": { "name": "Zehnder ComfoAir Q", "integration_type": "hub", diff --git a/requirements_all.txt b/requirements_all.txt index 65a6ab08bb2..66e73e07026 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -208,6 +208,9 @@ aiobafi6==0.8.2 # homeassistant.components.aws aiobotocore==2.1.0 +# homeassistant.components.comelit +aiocomelit==0.0.5 + # homeassistant.components.dhcp aiodiscover==1.4.16 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 7df3a7172b4..36450cb31da 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -189,6 +189,9 @@ aiobafi6==0.8.2 # homeassistant.components.aws aiobotocore==2.1.0 +# homeassistant.components.comelit +aiocomelit==0.0.5 + # homeassistant.components.dhcp aiodiscover==1.4.16 diff --git a/tests/components/comelit/__init__.py b/tests/components/comelit/__init__.py new file mode 100644 index 00000000000..916a684de4b --- /dev/null +++ b/tests/components/comelit/__init__.py @@ -0,0 +1 @@ +"""Tests for the Comelit SimpleHome integration.""" diff --git a/tests/components/comelit/const.py b/tests/components/comelit/const.py new file mode 100644 index 00000000000..36955b0b0a9 --- /dev/null +++ b/tests/components/comelit/const.py @@ -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] diff --git a/tests/components/comelit/test_config_flow.py b/tests/components/comelit/test_config_flow.py new file mode 100644 index 00000000000..2fb9e836efb --- /dev/null +++ b/tests/components/comelit/test_config_flow.py @@ -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