diff --git a/.strict-typing b/.strict-typing index 07a96a3d69274..88b174b845ffe 100644 --- a/.strict-typing +++ b/.strict-typing @@ -135,6 +135,7 @@ homeassistant.components.climate.* homeassistant.components.cloud.* homeassistant.components.co2signal.* homeassistant.components.command_line.* +homeassistant.components.compit.* homeassistant.components.config.* homeassistant.components.configurator.* homeassistant.components.cookidoo.* diff --git a/CODEOWNERS b/CODEOWNERS index d83c796872ab8..484fdc224def9 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -276,6 +276,8 @@ build.json @home-assistant/supervisor /tests/components/command_line/ @gjohansson-ST /homeassistant/components/compensation/ @Petro31 /tests/components/compensation/ @Petro31 +/homeassistant/components/compit/ @Przemko92 +/tests/components/compit/ @Przemko92 /homeassistant/components/config/ @home-assistant/core /tests/components/config/ @home-assistant/core /homeassistant/components/configurator/ @home-assistant/core diff --git a/homeassistant/components/compit/__init__.py b/homeassistant/components/compit/__init__.py new file mode 100644 index 0000000000000..b8ff2cb1d8c65 --- /dev/null +++ b/homeassistant/components/compit/__init__.py @@ -0,0 +1,65 @@ +"""The Compit integration.""" + +from __future__ import annotations + +import logging + +from compit_inext_api import ( + CannotConnect, + CompitAPI, + DeviceDefinitionsLoader, + InvalidAuth, + SystemInfo, +) + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import PLATFORMS +from .coordinator import CompitDataUpdateCoordinator + +_LOGGER: logging.Logger = logging.getLogger(__package__) + +type CompitConfigEntry = ConfigEntry[CompitDataUpdateCoordinator] + + +async def async_setup_entry(hass: HomeAssistant, entry: CompitConfigEntry) -> bool: + """Set up Compit from a config entry.""" + + session = async_get_clientsession(hass) + api = CompitAPI(entry.data["email"], entry.data["password"], session) + try: + system_info = await api.authenticate() + except CannotConnect as e: + raise ConfigEntryNotReady(f"Error while connecting to Compit: {e}") from e + except InvalidAuth as e: + raise ConfigEntryAuthFailed( + f"Invalid credentials for {entry.data["email"]}" + ) from e + + if isinstance(system_info, SystemInfo): + try: + device_definitions = await DeviceDefinitionsLoader.get_device_definitions( + hass.config.language + ) + except ValueError as e: + _LOGGER.warning("Value error: %s", e) + return False + + coordinator = CompitDataUpdateCoordinator( + hass, system_info.gates, api, device_definitions + ) + await coordinator.async_config_entry_first_refresh() + entry.runtime_data = coordinator + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + return True + + _LOGGER.error("Authentication API error") + return False + + +async def async_unload_entry(hass: HomeAssistant, entry: CompitConfigEntry) -> bool: + """Unload an entry for the Compit integration.""" + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/compit/climate.py b/homeassistant/components/compit/climate.py new file mode 100644 index 0000000000000..c4f08baf4b4b1 --- /dev/null +++ b/homeassistant/components/compit/climate.py @@ -0,0 +1,323 @@ +"""Module contains the CompitClimate class for controlling climate entities.""" + +from typing import Any + +from compit_inext_api import Device, Parameter + +from homeassistant.components.climate import ( + ClimateEntity, + ClimateEntityFeature, + HVACMode, +) +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 homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN, MANURFACER_NAME +from .coordinator import CompitDataUpdateCoordinator + +type CompitConfigEntry = ConfigEntry[CompitDataUpdateCoordinator] + + +async def async_setup_entry( + hass: HomeAssistant, + entry: CompitConfigEntry, + async_add_devices: AddEntitiesCallback, +) -> None: + """Set up the CompitClimate platform from a config entry.""" + + coordinator: CompitDataUpdateCoordinator = entry.runtime_data + + async_add_devices( + [ + CompitClimate( + coordinator, + device, + device_definition.parameters, + device_definition.name, + ) + for gates in coordinator.gates + for device in gates.devices + if ( + device_definition := next( + ( + definition + for definition in coordinator.device_definitions.devices + if definition.code == device.type + ), + None, + ) + ) + is not None + if (device_definition.device_class == 10) + ] + ) + + +class CompitClimate(CoordinatorEntity[CompitDataUpdateCoordinator], ClimateEntity): + """Representation of a Compit climate device.""" + + def __init__( + self, + coordinator: CompitDataUpdateCoordinator, + device: Device, + parameters: list[Parameter], + device_name: str, + ) -> None: + """Initialize the climate device.""" + super().__init__(coordinator) + self.coordinator = coordinator + self._attr_unique_id = f"{device.label}_{device.id}" + self._attr_name = device.label + self._attr_has_entity_name = True + self._attr_temperature_unit = UnitOfTemperature.CELSIUS + self.parameters = { + parameter.parameter_code: parameter for parameter in parameters + } + self.device = device + self.available_presets: Parameter | None = self.parameters.get( + "__trybpracytermostatu" + ) + self.available_fan_modes: Parameter | None = self.parameters.get("__trybaero") + self.available_hvac_modes: Parameter | None = self.parameters.get( + "__trybpracyinstalacji" + ) + self.device_name = device_name + self._temperature: float | None = None + self._preset_mode: int | None = None + self._fan_mode: int | None = None + self._hvac_mode: HVACMode | None = None + self.set_initial_values() + + def set_initial_values(self) -> None: + """Set initial values for the climate device.""" + + preset_mode = self.coordinator.data[self.device.id].state.get_parameter_value( + "__trybpracytermostatu" + ) + if preset_mode and self.available_presets and self.available_presets.details: + preset = next( + ( + item + for item in self.available_presets.details + if item is not None and item.state == preset_mode.value + ), + None, + ) + self._preset_mode = preset.state if preset is not None else None + else: + self._preset_mode = None + fan_mode = self.coordinator.data[self.device.id].state.get_parameter_value( + "__trybaero" + ) + if fan_mode and self.available_fan_modes and self.available_fan_modes.details: + fan = next( + ( + item + for item in self.available_fan_modes.details + if item is not None and item.state == fan_mode.value + ), + None, + ) + self._fan_mode = fan.state if fan is not None else None + else: + self._fan_mode = None + + hvac_mode = self.coordinator.data[self.device.id].state.get_parameter_value( + "__trybpracyinstalacji" + ) + if hvac_mode is not None: + if hvac_mode.value == 0: + self._hvac_mode = HVACMode.HEAT + if hvac_mode.value == 1: + self._hvac_mode = HVACMode.OFF + if hvac_mode.value == 2: + self._hvac_mode = HVACMode.COOL + else: + self._hvac_mode = None + + @property + def device_info(self) -> DeviceInfo | None: + """Return device information about this climate device.""" + + return { + "identifiers": {(DOMAIN, str(self.device.id))}, + "name": self.device.label, + "manufacturer": MANURFACER_NAME, + "model": self.device_name, + "sw_version": "1.0", + } + + @property + def current_temperature(self) -> float | None: + """Return the current temperature.""" + value = self.coordinator.data[self.device.id].state.get_parameter_value( + "__tpokojowa" + ) + if value is None: + return None + return float(value.value) if value is not None else None + + @property + def target_temperature(self) -> float | None: + """Return the temperature we try to reach.""" + value = self.coordinator.data[self.device.id].state.get_parameter_value( + "__tpokzadana" + ) + if value is None: + return None + return float(value.value) if value is not None else None + + @property + def supported_features(self) -> ClimateEntityFeature: + """Return the list of supported features.""" + return ( + ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.FAN_MODE + | ClimateEntityFeature.PRESET_MODE + ) + + @property + def preset_modes(self) -> list[str] | None: + """Return the available preset modes.""" + if self.available_presets is None or self.available_presets.details is None: + return [] + return [ + item.description + for item in self.available_presets.details + if item is not None + ] + + @property + def fan_modes(self) -> list[str] | None: + """Return the available fan modes.""" + if self.available_fan_modes is None or self.available_fan_modes.details is None: + return [] + return [ + item.description + for item in self.available_fan_modes.details + if item is not None + ] + + @property + def hvac_modes(self) -> list[HVACMode]: + """Return the available HVAC modes.""" + return [HVACMode.HEAT, HVACMode.COOL, HVACMode.OFF] + + @property + def preset_mode(self) -> str | None: + """Return the current preset mode.""" + if ( + self._preset_mode is None + or self.available_presets is None + or self.available_presets.details is None + ): + return None + + val = next( + ( + item + for item in self.available_presets.details + if item is not None and item.state == self._preset_mode + ), + None, + ) + if val is None: + return None + return str(val.description) + + @property + def fan_mode(self) -> str | None: + """Return the current fan mode.""" + if ( + self._fan_mode is None + or self.available_fan_modes is None + or self.available_fan_modes.details is None + ): + return None + + val = next( + ( + item + for item in self.available_fan_modes.details + if item is not None and item.state == self._fan_mode + ), + None, + ) + if val is None: + return None + return str(val.description) + + @property + def hvac_mode(self) -> HVACMode | None: + """Return the current HVAC mode.""" + return self._hvac_mode + + async def async_set_temperature(self, **kwargs: Any) -> None: + """Set new target temperature.""" + temp = kwargs.get("temperature") + if temp is None: + return + self._temperature = temp + await self.async_call_api("__tempzadpracareczna", temp) + + async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: + """Set new target HVAC mode.""" + value = 0 + if hvac_mode == HVACMode.HEAT: + value = 0 + elif hvac_mode == HVACMode.OFF: + value = 1 + elif hvac_mode == HVACMode.COOL: + value = 2 + self._hvac_mode = hvac_mode + await self.async_call_api("__trybpracyinstalacji", value) + + async def async_set_preset_mode(self, preset_mode: str) -> None: + """Set new target preset mode.""" + if self.available_presets is None or self.available_presets.details is None: + return + value = next( + ( + item + for item in self.available_presets.details + if item is not None and item.description == preset_mode + ), + None, + ) + if value is None: + return + self._preset_mode = value.state + await self.async_call_api("__trybpracytermostatu", value.state) + + async def async_set_fan_mode(self, fan_mode: str) -> None: + """Set new target fan mode.""" + if self.available_fan_modes is None or self.available_fan_modes.details is None: + return + value = next( + ( + item + for item in self.available_fan_modes.details + if item is not None and item.description == fan_mode + ), + None, + ) + if value is None: + return + self._fan_mode = value.state + await self.async_call_api("__trybaero", value.state) + + async def async_call_api(self, parameter: str, value: int) -> None: + """Call the API to set a parameter to a new value.""" + + if ( + await self.coordinator.api.update_device_parameter( + self.device.id, parameter, value + ) + is not False + ): + await self.coordinator.async_request_refresh() + self.async_write_ha_state() diff --git a/homeassistant/components/compit/config_flow.py b/homeassistant/components/compit/config_flow.py new file mode 100644 index 0000000000000..7adf1f22af367 --- /dev/null +++ b/homeassistant/components/compit/config_flow.py @@ -0,0 +1,102 @@ +"""Config flow for Compit integration.""" + +from __future__ import annotations + +from collections.abc import Mapping +import logging +from typing import Any + +from compit_inext_api import CannotConnect, CompitAPI, InvalidAuth, SystemInfo +import voluptuous as vol + +from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_EMAIL, CONF_PASSWORD +from homeassistant.helpers.aiohttp_client import async_create_clientsession + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + +STEP_USER_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_EMAIL): str, + vol.Required(CONF_PASSWORD): str, + } +) + +STEP_REAUTH_SCHEMA = vol.Schema( + { + vol.Required(CONF_PASSWORD): str, + } +) + + +class CompitConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for Compit.""" + + VERSION = 1 + + async def async_step_user( + self, + user_input: dict[str, Any] | None = None, + ) -> ConfigFlowResult: + """Handle the initial step.""" + errors: dict[str, str] = {} + if user_input is not None: + session = async_create_clientsession(self.hass) + api = CompitAPI(user_input[CONF_EMAIL], user_input[CONF_PASSWORD], session) + system_info = None + try: + system_info = await api.authenticate() + except CannotConnect: + errors["base"] = "cannot_connect" + except InvalidAuth: + errors["base"] = "invalid_auth" + except Exception: + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + + if ( + system_info + and isinstance(system_info, SystemInfo) + and system_info.gates is not None + ): + await self.async_set_unique_id(user_input[CONF_EMAIL]) + + if self.source == SOURCE_REAUTH: + self._abort_if_unique_id_mismatch() + return self.async_update_reload_and_abort( + self._get_reauth_entry(), data_updates=user_input + ) + self._abort_if_unique_id_configured() + return self.async_create_entry(title="Compit", data=user_input) + + return self.async_show_form( + step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors + ) + + async def async_step_reauth(self, data: Mapping[str, Any]) -> ConfigFlowResult: + """Handle re-auth.""" + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Confirm re-authentication.""" + errors: dict[str, str] = {} + reauth_entry = self._get_reauth_entry() + reauth_entry_data = reauth_entry.data + + if user_input: + return await self.async_step_user( + { + CONF_EMAIL: reauth_entry_data[CONF_EMAIL], + CONF_PASSWORD: user_input[CONF_PASSWORD], + } + ) + return self.async_show_form( + step_id="reauth_confirm", + data_schema=STEP_REAUTH_SCHEMA, + description_placeholders={CONF_EMAIL: reauth_entry_data[CONF_EMAIL]}, + errors=errors, + ) diff --git a/homeassistant/components/compit/const.py b/homeassistant/components/compit/const.py new file mode 100644 index 0000000000000..31d5bbb2fbaaf --- /dev/null +++ b/homeassistant/components/compit/const.py @@ -0,0 +1,10 @@ +"""Constants for the Compit integration.""" + +from homeassistant.const import Platform + +DOMAIN = "compit" +MANURFACER_NAME = "Compit" + +PLATFORMS = [ + Platform.CLIMATE, +] diff --git a/homeassistant/components/compit/coordinator.py b/homeassistant/components/compit/coordinator.py new file mode 100644 index 0000000000000..8681e42310faa --- /dev/null +++ b/homeassistant/components/compit/coordinator.py @@ -0,0 +1,83 @@ +"""Define an object to manage fetching Compit data.""" + +from datetime import timedelta +import logging + +from compit_inext_api import ( + CompitAPI, + DeviceDefinitions, + DeviceInstance, + DeviceState, + Gate, +) + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DOMAIN + +SCAN_INTERVAL = timedelta(seconds=30) +_LOGGER: logging.Logger = logging.getLogger(__package__) + + +class CompitDataUpdateCoordinator(DataUpdateCoordinator[dict[int, DeviceInstance]]): + """Class to manage fetching data from the API.""" + + def __init__( + self, + hass: HomeAssistant, + gates: list[Gate], + api: CompitAPI, + device_definitions: DeviceDefinitions, + ) -> None: + """Initialize.""" + self.devices: dict[int, DeviceInstance] = {} + self.api = api + self.gates = gates + self.device_definitions = device_definitions + + super().__init__(hass, _LOGGER, name=DOMAIN, update_interval=SCAN_INTERVAL) + + async def _async_update_data(self) -> dict[int, DeviceInstance]: + """Update data via library.""" + + for gate in self.gates: + _LOGGER.debug("Gate: %s, Code: %s", gate.label, gate.code) + for device in gate.devices: + if device.id not in self.devices: + device_definition = next( + ( + item + for item in self.device_definitions.devices + if item.device_class == device.device_class + and item.code == device.type + ), + None, + ) + if device_definition is None: + _LOGGER.warning( + "Device definition not found for device %s, class: %s, type: %s", + device.label, + device.device_class, + device.type, + ) + continue + self.devices[device.id] = DeviceInstance(device_definition) + + _LOGGER.debug( + "Device: %s, id: %s, class: %s, type: %s", + device.label, + device.id, + device.device_class, + device.type, + ) + try: + state = await self.api.get_state(device.id) + + if state and isinstance(state, DeviceState): + self.devices[device.id].state = state + else: + _LOGGER.error("Failed to get state for device %s", device.id) + except ValueError as exception: + raise UpdateFailed from exception + return self.devices diff --git a/homeassistant/components/compit/manifest.json b/homeassistant/components/compit/manifest.json new file mode 100644 index 0000000000000..e656f5c34557b --- /dev/null +++ b/homeassistant/components/compit/manifest.json @@ -0,0 +1,10 @@ +{ + "domain": "compit", + "name": "Compit", + "codeowners": ["@Przemko92"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/compit", + "iot_class": "cloud_polling", + "quality_scale": "bronze", + "requirements": ["compit-inext-api==0.1.10"] +} diff --git a/homeassistant/components/compit/quality_scale.yaml b/homeassistant/components/compit/quality_scale.yaml new file mode 100644 index 0000000000000..2ef63e0fdabc9 --- /dev/null +++ b/homeassistant/components/compit/quality_scale.yaml @@ -0,0 +1,83 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: | + This integration does not provide additional actions. + appropriate-polling: done + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: + status: exempt + comment: | + This integration does not provide additional actions. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: + status: exempt + comment: | + Entities of this integration does not explicitly subscribe to events. + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + action-exceptions: + status: exempt + comment: | + This integration does not provide additional actions. + config-entry-unloading: done + docs-configuration-parameters: + status: exempt + comment: | + This integration does not have an options flow. + docs-installation-parameters: done + entity-unavailable: todo + integration-owner: done + log-when-unavailable: todo + parallel-updates: todo + reauthentication-flow: done + test-coverage: todo + + # Gold + devices: done + diagnostics: todo + discovery-update-info: + status: exempt + comment: | + This integration is connecting to a cloud service. + discovery: todo + docs-data-update: todo + docs-examples: todo + docs-known-limitations: todo + docs-supported-devices: done + docs-supported-functions: todo + docs-troubleshooting: todo + docs-use-cases: todo + dynamic-devices: todo + entity-category: done + entity-device-class: done + entity-disabled-by-default: + status: exempt + comment: | + This integration does not have any entities that should disabled by default. + entity-translations: done + exception-translations: todo + icon-translations: + status: exempt + comment: | + There is no need for icon translations. + reconfiguration-flow: todo + repair-issues: todo + stale-devices: todo + # Platinum + async-dependency: done + inject-websession: todo + strict-typing: done diff --git a/homeassistant/components/compit/strings.json b/homeassistant/components/compit/strings.json new file mode 100644 index 0000000000000..5114c08e67ad9 --- /dev/null +++ b/homeassistant/components/compit/strings.json @@ -0,0 +1,24 @@ +{ + "config": { + "step": { + "user": { + "description": "Use your https://inext.compit.pl/ account", + "title": "Please enter your credentials.", + "data": { + "email": "[%key:common::config_flow::data::email%]", + "password": "[%key:common::config_flow::data::password%]" + } + }, + "reauth_confirm": { + "description": "Please update your password for {email}", + "data": { + "password": "[%key:common::config_flow::data::password%]" + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]" + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 599cc43c08b70..edeeed6c56b90 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -112,6 +112,7 @@ "coinbase", "color_extractor", "comelit", + "compit", "control4", "cookidoo", "coolmaster", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 005fb7f694f1b..46a04875b0896 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -1027,6 +1027,12 @@ "config_flow": false, "iot_class": "calculated" }, + "compit": { + "name": "Compit", + "integration_type": "hub", + "config_flow": true, + "iot_class": "cloud_polling" + }, "concord232": { "name": "Concord232", "integration_type": "hub", diff --git a/mypy.ini b/mypy.ini index f0d024b6b681b..d58b65f7e8104 100644 --- a/mypy.ini +++ b/mypy.ini @@ -1105,6 +1105,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.compit.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.config.*] check_untyped_defs = true disallow_incomplete_defs = true diff --git a/requirements_all.txt b/requirements_all.txt index b1107bde2c5f4..0f4869eb974e6 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -694,6 +694,9 @@ colorlog==6.8.2 # homeassistant.components.color_extractor colorthief==0.2.1 +# homeassistant.components.compit +compit-inext-api==0.1.10 + # homeassistant.components.concord232 concord232==0.15.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 284574e0a2c99..ec6451f7bfc11 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -596,6 +596,9 @@ colorlog==6.8.2 # homeassistant.components.color_extractor colorthief==0.2.1 +# homeassistant.components.compit +compit-inext-api==0.1.10 + # homeassistant.components.xiaomi_miio construct==2.10.68 diff --git a/tests/components/compit/__init__.py b/tests/components/compit/__init__.py new file mode 100644 index 0000000000000..a817df77ad01d --- /dev/null +++ b/tests/components/compit/__init__.py @@ -0,0 +1 @@ +"""Tests for the compit component.""" diff --git a/tests/components/compit/conftest.py b/tests/components/compit/conftest.py new file mode 100644 index 0000000000000..7cd6faa089e43 --- /dev/null +++ b/tests/components/compit/conftest.py @@ -0,0 +1,15 @@ +"""Common fixtures for the Compit tests.""" + +from collections.abc import Generator +from unittest.mock import AsyncMock, patch + +import pytest + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.compit.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry diff --git a/tests/components/compit/test_config_flow.py b/tests/components/compit/test_config_flow.py new file mode 100644 index 0000000000000..4e9df9eb79171 --- /dev/null +++ b/tests/components/compit/test_config_flow.py @@ -0,0 +1,191 @@ +"""Test the Compit config flow.""" + +from unittest.mock import Mock, patch + +from compit_inext_api import Gate, SystemInfo +import pytest + +from homeassistant import config_entries +from homeassistant.components.compit.config_flow import ( + CannotConnect, + CompitConfigFlow, + InvalidAuth, +) +from homeassistant.components.compit.const import DOMAIN +from homeassistant.const import CONF_EMAIL, CONF_PASSWORD +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + + +async def test_async_step_user_success(hass: HomeAssistant) -> None: + """Test user step with successful authentication.""" + flow = CompitConfigFlow() + flow.hass = hass + + with ( + patch( + "homeassistant.components.compit.config_flow.CompitAPI.authenticate", + return_value=SystemInfo( + gates=[Gate(label="Test", code="1", devices=[], id=1)] + ), + ), + patch.object(flow, "async_set_unique_id", return_value=None), + patch.object(flow, "_abort_if_unique_id_configured", return_value=None), + ): + result = await flow.async_step_user( + {CONF_EMAIL: "test@example.com", CONF_PASSWORD: "password"} + ) + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == "Compit" + assert result["data"] == {CONF_EMAIL: "test@example.com", CONF_PASSWORD: "password"} + + +async def test_async_step_user_invalid_auth(hass: HomeAssistant) -> None: + """Test user step with invalid authentication.""" + flow = CompitConfigFlow() + flow.hass = hass + + with patch( + "homeassistant.components.compit.config_flow.CompitAPI.authenticate", + side_effect=InvalidAuth, + ): + result = await flow.async_step_user( + {CONF_EMAIL: "test@example.com", CONF_PASSWORD: "password"} + ) + + assert result["type"] == FlowResultType.FORM + assert result["errors"] == {"base": "invalid_auth"} + + +async def test_async_step_user_cannot_connect(hass: HomeAssistant) -> None: + """Test user step with connection error.""" + flow = CompitConfigFlow() + flow.hass = hass + + with patch( + "homeassistant.components.compit.config_flow.CompitAPI.authenticate", + side_effect=CannotConnect, + ): + result = await flow.async_step_user( + {CONF_EMAIL: "test@example.com", CONF_PASSWORD: "password"} + ) + + assert result["type"] == FlowResultType.FORM + assert result["errors"] == {"base": "cannot_connect"} + + +async def test_async_step_user_unknown_error(hass: HomeAssistant) -> None: + """Test user step with unknown error.""" + flow = CompitConfigFlow() + flow.hass = hass + + with patch( + "homeassistant.components.compit.config_flow.CompitAPI.authenticate", + side_effect=Exception, + ): + result = await flow.async_step_user( + {CONF_EMAIL: "test@example.com", CONF_PASSWORD: "password"} + ) + + assert result["type"] == FlowResultType.FORM + assert result["errors"] == {"base": "unknown"} + + +@pytest.fixture +def mock_reauth_entry(): + """Return a mock config entry.""" + return config_entries.ConfigEntry( + version=1, + domain=DOMAIN, + title="Compit", + data={CONF_EMAIL: "test@example.com"}, + source=config_entries.SOURCE_REAUTH, + entry_id="1", + unique_id="compit_test@example.com", + discovery_keys=None, + minor_version=0, + options={}, + ) + + +async def test_async_step_reauth_confirm_success( + hass: HomeAssistant, mock_reauth_entry: config_entries.ConfigEntry +) -> None: + """Test reauth confirm step with successful authentication.""" + hass.config_entries._entries[mock_reauth_entry.entry_id] = mock_reauth_entry + + flow = CompitConfigFlow() + flow.hass = hass + flow._get_reauth_entry = Mock(return_value=mock_reauth_entry) + flow.context = {"source": config_entries.SOURCE_REAUTH} + + with ( + patch( + "homeassistant.components.compit.config_flow.CompitAPI.authenticate", + return_value=SystemInfo(gates=[]), + ), + patch.object(flow, "async_set_unique_id", return_value=None), + patch.object(flow, "_abort_if_unique_id_mismatch", return_value=None), + patch.object(flow, "_abort_if_unique_id_configured", return_value=None), + ): + result = await flow.async_step_reauth_confirm( + {CONF_PASSWORD: "new_password", CONF_EMAIL: "test@example.com"} + ) + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + + +async def test_async_step_reauth_confirm_invalid_auth( + hass: HomeAssistant, mock_reauth_entry: config_entries.ConfigEntry +) -> None: + """Test reauth confirm step with invalid authentication.""" + flow = CompitConfigFlow() + flow.hass = hass + flow._get_reauth_entry = Mock(return_value=mock_reauth_entry) + + with patch( + "homeassistant.components.compit.config_flow.CompitAPI.authenticate", + side_effect=InvalidAuth, + ): + result = await flow.async_step_reauth_confirm({CONF_PASSWORD: "new_password"}) + + assert result["type"] == FlowResultType.FORM + assert result["errors"] == {"base": "invalid_auth"} + + +async def test_async_step_reauth_confirm_cannot_connect( + hass: HomeAssistant, mock_reauth_entry: config_entries.ConfigEntry +) -> None: + """Test reauth confirm step with connection error.""" + flow = CompitConfigFlow() + flow.hass = hass + flow._get_reauth_entry = Mock(return_value=mock_reauth_entry) + + with patch( + "homeassistant.components.compit.config_flow.CompitAPI.authenticate", + side_effect=CannotConnect, + ): + result = await flow.async_step_reauth_confirm({CONF_PASSWORD: "new_password"}) + + assert result["type"] == FlowResultType.FORM + assert result["errors"] == {"base": "cannot_connect"} + + +async def test_async_step_reauth_confirm_unknown_error( + hass: HomeAssistant, mock_reauth_entry: config_entries.ConfigEntry +) -> None: + """Test reauth confirm step with unknown error.""" + flow = CompitConfigFlow() + flow.hass = hass + flow._get_reauth_entry = Mock(return_value=mock_reauth_entry) + + with patch( + "homeassistant.components.compit.config_flow.CompitAPI.authenticate", + side_effect=Exception, + ): + result = await flow.async_step_reauth_confirm({CONF_PASSWORD: "new_password"}) + + assert result["type"] == FlowResultType.FORM + assert result["errors"] == {"base": "unknown"}