From fa8a4d70986ba279a91cdc274aa42aad2f70627b Mon Sep 17 00:00:00 2001 From: Przemko92 <33545571+Przemko92@users.noreply.github.com> Date: Thu, 18 Sep 2025 22:53:49 +0200 Subject: [PATCH] Add Compit integration (#132164) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: AbĂ­lio Costa --- .strict-typing | 1 + CODEOWNERS | 2 + homeassistant/components/compit/__init__.py | 45 +++ homeassistant/components/compit/climate.py | 264 ++++++++++++++++++ .../components/compit/config_flow.py | 110 ++++++++ homeassistant/components/compit/const.py | 4 + .../components/compit/coordinator.py | 43 +++ homeassistant/components/compit/manifest.json | 12 + .../components/compit/quality_scale.yaml | 86 ++++++ homeassistant/components/compit/strings.json | 35 +++ homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 6 + mypy.ini | 10 + requirements_all.txt | 3 + requirements_test_all.txt | 3 + tests/components/compit/__init__.py | 1 + tests/components/compit/conftest.py | 41 +++ tests/components/compit/consts.py | 8 + tests/components/compit/test_config_flow.py | 158 +++++++++++ 19 files changed, 833 insertions(+) create mode 100644 homeassistant/components/compit/__init__.py create mode 100644 homeassistant/components/compit/climate.py create mode 100644 homeassistant/components/compit/config_flow.py create mode 100644 homeassistant/components/compit/const.py create mode 100644 homeassistant/components/compit/coordinator.py create mode 100644 homeassistant/components/compit/manifest.json create mode 100644 homeassistant/components/compit/quality_scale.yaml create mode 100644 homeassistant/components/compit/strings.json create mode 100644 tests/components/compit/__init__.py create mode 100644 tests/components/compit/conftest.py create mode 100644 tests/components/compit/consts.py create mode 100644 tests/components/compit/test_config_flow.py diff --git a/.strict-typing b/.strict-typing index 78203703d1a..a4152b78ca0 100644 --- a/.strict-typing +++ b/.strict-typing @@ -142,6 +142,7 @@ homeassistant.components.cloud.* homeassistant.components.co2signal.* homeassistant.components.comelit.* homeassistant.components.command_line.* +homeassistant.components.compit.* homeassistant.components.config.* homeassistant.components.configurator.* homeassistant.components.cookidoo.* diff --git a/CODEOWNERS b/CODEOWNERS index 3e984a58f6d..543ef798b1c 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -292,6 +292,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 00000000000..b4802181da9 --- /dev/null +++ b/homeassistant/components/compit/__init__.py @@ -0,0 +1,45 @@ +"""The Compit integration.""" + +from compit_inext_api import CannotConnect, CompitApiConnector, InvalidAuth + +from homeassistant.const import CONF_EMAIL, CONF_PASSWORD, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .coordinator import CompitConfigEntry, CompitDataUpdateCoordinator + +PLATFORMS = [ + Platform.CLIMATE, +] + + +async def async_setup_entry(hass: HomeAssistant, entry: CompitConfigEntry) -> bool: + """Set up Compit from a config entry.""" + + session = async_get_clientsession(hass) + connector = CompitApiConnector(session) + try: + connected = await connector.init( + entry.data[CONF_EMAIL], entry.data[CONF_PASSWORD], hass.config.language + ) + 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[CONF_EMAIL]}" + ) from e + + if not connected: + raise ConfigEntryAuthFailed("Authentication API error") + + coordinator = CompitDataUpdateCoordinator(hass, entry, connector) + await coordinator.async_config_entry_first_refresh() + entry.runtime_data = coordinator + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + return True + + +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 00000000000..40fae2b0de7 --- /dev/null +++ b/homeassistant/components/compit/climate.py @@ -0,0 +1,264 @@ +"""Module contains the CompitClimate class for controlling climate entities.""" + +import logging +from typing import Any + +from compit_inext_api import Param, Parameter +from compit_inext_api.consts import ( + CompitFanMode, + CompitHVACMode, + CompitParameter, + CompitPresetMode, +) +from propcache.api import cached_property + +from homeassistant.components.climate import ( + FAN_AUTO, + FAN_HIGH, + FAN_LOW, + FAN_MEDIUM, + FAN_OFF, + PRESET_AWAY, + PRESET_ECO, + PRESET_HOME, + PRESET_NONE, + ClimateEntity, + ClimateEntityFeature, + HVACMode, +) +from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN, MANUFACTURER_NAME +from .coordinator import CompitConfigEntry, CompitDataUpdateCoordinator + +_LOGGER: logging.Logger = logging.getLogger(__name__) + +# Device class for climate devices in Compit system +CLIMATE_DEVICE_CLASS = 10 +PARALLEL_UPDATES = 0 + +COMPIT_MODE_MAP = { + CompitHVACMode.COOL: HVACMode.COOL, + CompitHVACMode.HEAT: HVACMode.HEAT, + CompitHVACMode.OFF: HVACMode.OFF, +} + +COMPIT_FANSPEED_MAP = { + CompitFanMode.OFF: FAN_OFF, + CompitFanMode.AUTO: FAN_AUTO, + CompitFanMode.LOW: FAN_LOW, + CompitFanMode.MEDIUM: FAN_MEDIUM, + CompitFanMode.HIGH: FAN_HIGH, + CompitFanMode.HOLIDAY: FAN_AUTO, +} + +COMPIT_PRESET_MAP = { + CompitPresetMode.AUTO: PRESET_HOME, + CompitPresetMode.HOLIDAY: PRESET_ECO, + CompitPresetMode.MANUAL: PRESET_NONE, + CompitPresetMode.AWAY: PRESET_AWAY, +} + +HVAC_MODE_TO_COMPIT_MODE = {v: k for k, v in COMPIT_MODE_MAP.items()} +FAN_MODE_TO_COMPIT_FAN_MODE = {v: k for k, v in COMPIT_FANSPEED_MAP.items()} +PRESET_MODE_TO_COMPIT_PRESET_MODE = {v: k for k, v in COMPIT_PRESET_MAP.items()} + + +async def async_setup_entry( + hass: HomeAssistant, + entry: CompitConfigEntry, + async_add_devices: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the CompitClimate platform from a config entry.""" + + coordinator = entry.runtime_data + climate_entities = [] + for device_id in coordinator.connector.devices: + device = coordinator.connector.devices[device_id] + + if device.definition.device_class == CLIMATE_DEVICE_CLASS: + climate_entities.append( + CompitClimate( + coordinator, + device_id, + { + parameter.parameter_code: parameter + for parameter in device.definition.parameters + }, + device.definition.name, + ) + ) + + async_add_devices(climate_entities) + + +class CompitClimate(CoordinatorEntity[CompitDataUpdateCoordinator], ClimateEntity): + """Representation of a Compit climate device.""" + + _attr_temperature_unit = UnitOfTemperature.CELSIUS + _attr_hvac_modes = [*COMPIT_MODE_MAP.values()] + _attr_name = None + _attr_has_entity_name = True + _attr_supported_features = ( + ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.FAN_MODE + | ClimateEntityFeature.PRESET_MODE + ) + + def __init__( + self, + coordinator: CompitDataUpdateCoordinator, + device_id: int, + parameters: dict[str, Parameter], + device_name: str, + ) -> None: + """Initialize the climate device.""" + super().__init__(coordinator) + self._attr_unique_id = f"{device_name}_{device_id}" + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, str(device_id))}, + name=device_name, + manufacturer=MANUFACTURER_NAME, + model=device_name, + ) + + self.parameters = parameters + self.device_id = device_id + self.available_presets: Parameter | None = self.parameters.get( + CompitParameter.PRESET_MODE.value + ) + self.available_fan_modes: Parameter | None = self.parameters.get( + CompitParameter.FAN_MODE.value + ) + + @property + def available(self) -> bool: + """Return if entity is available.""" + return ( + super().available and self.device_id in self.coordinator.connector.devices + ) + + @property + def current_temperature(self) -> float | None: + """Return the current temperature.""" + value = self.get_parameter_value(CompitParameter.CURRENT_TEMPERATURE) + if value is None: + return None + return float(value.value) + + @property + def target_temperature(self) -> float | None: + """Return the temperature we try to reach.""" + value = self.get_parameter_value(CompitParameter.SET_TARGET_TEMPERATURE) + if value is None: + return None + return float(value.value) + + @cached_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 [] + + preset_modes = [] + for item in self.available_presets.details: + if item is not None: + ha_preset = COMPIT_PRESET_MAP.get(CompitPresetMode(item.state)) + if ha_preset and ha_preset not in preset_modes: + preset_modes.append(ha_preset) + + return preset_modes + + @cached_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 [] + + fan_modes = [] + for item in self.available_fan_modes.details: + if item is not None: + ha_fan_mode = COMPIT_FANSPEED_MAP.get(CompitFanMode(item.state)) + if ha_fan_mode and ha_fan_mode not in fan_modes: + fan_modes.append(ha_fan_mode) + + return fan_modes + + @property + def preset_mode(self) -> str | None: + """Return the current preset mode.""" + preset_mode = self.get_parameter_value(CompitParameter.PRESET_MODE) + + if preset_mode: + compit_preset_mode = CompitPresetMode(preset_mode.value) + return COMPIT_PRESET_MAP.get(compit_preset_mode) + return None + + @property + def fan_mode(self) -> str | None: + """Return the current fan mode.""" + fan_mode = self.get_parameter_value(CompitParameter.FAN_MODE) + if fan_mode: + compit_fan_mode = CompitFanMode(fan_mode.value) + return COMPIT_FANSPEED_MAP.get(compit_fan_mode) + return None + + @property + def hvac_mode(self) -> HVACMode | None: + """Return the current HVAC mode.""" + hvac_mode = self.get_parameter_value(CompitParameter.HVAC_MODE) + if hvac_mode: + compit_hvac_mode = CompitHVACMode(hvac_mode.value) + return COMPIT_MODE_MAP.get(compit_hvac_mode) + return None + + async def async_set_temperature(self, **kwargs: Any) -> None: + """Set new target temperature.""" + temp = kwargs.get(ATTR_TEMPERATURE) + if temp is None: + raise ServiceValidationError("Temperature argument missing") + await self.set_parameter_value(CompitParameter.SET_TARGET_TEMPERATURE, temp) + + async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: + """Set new target HVAC mode.""" + + if not (mode := HVAC_MODE_TO_COMPIT_MODE.get(hvac_mode)): + raise ServiceValidationError(f"Invalid hvac mode {hvac_mode}") + + await self.set_parameter_value(CompitParameter.HVAC_MODE, mode.value) + + async def async_set_preset_mode(self, preset_mode: str) -> None: + """Set new target preset mode.""" + + compit_preset = PRESET_MODE_TO_COMPIT_PRESET_MODE.get(preset_mode) + if compit_preset is None: + raise ServiceValidationError(f"Invalid preset mode: {preset_mode}") + + await self.set_parameter_value(CompitParameter.PRESET_MODE, compit_preset.value) + + async def async_set_fan_mode(self, fan_mode: str) -> None: + """Set new target fan mode.""" + + compit_fan_mode = FAN_MODE_TO_COMPIT_FAN_MODE.get(fan_mode) + if compit_fan_mode is None: + raise ServiceValidationError(f"Invalid fan mode: {fan_mode}") + + await self.set_parameter_value(CompitParameter.FAN_MODE, compit_fan_mode.value) + + async def set_parameter_value(self, parameter: CompitParameter, value: int) -> None: + """Call the API to set a parameter to a new value.""" + await self.coordinator.connector.set_device_parameter( + self.device_id, parameter, value + ) + self.async_write_ha_state() + + def get_parameter_value(self, parameter: CompitParameter) -> Param | None: + """Get the parameter value from the device state.""" + return self.coordinator.connector.get_device_parameter( + self.device_id, parameter + ) diff --git a/homeassistant/components/compit/config_flow.py b/homeassistant/components/compit/config_flow.py new file mode 100644 index 00000000000..3f41aec8f13 --- /dev/null +++ b/homeassistant/components/compit/config_flow.py @@ -0,0 +1,110 @@ +"""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, CompitApiConnector, InvalidAuth +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 = CompitApiConnector(session) + success = False + try: + success = await api.init( + user_input[CONF_EMAIL], + user_input[CONF_PASSWORD], + self.hass.config.language, + ) + except CannotConnect: + errors["base"] = "cannot_connect" + except InvalidAuth: + errors["base"] = "invalid_auth" + except Exception: + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + if not success: + # Api returned unexpected result but no exception + _LOGGER.error("Compit api returned unexpected result") + errors["base"] = "unknown" + else: + 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=user_input[CONF_EMAIL], 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: + # Reuse async_step_user with combined credentials + 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 00000000000..547012e706c --- /dev/null +++ b/homeassistant/components/compit/const.py @@ -0,0 +1,4 @@ +"""Constants for the Compit integration.""" + +DOMAIN = "compit" +MANUFACTURER_NAME = "Compit" diff --git a/homeassistant/components/compit/coordinator.py b/homeassistant/components/compit/coordinator.py new file mode 100644 index 00000000000..6eaf9618457 --- /dev/null +++ b/homeassistant/components/compit/coordinator.py @@ -0,0 +1,43 @@ +"""Define an object to manage fetching Compit data.""" + +from datetime import timedelta +import logging + +from compit_inext_api import CompitApiConnector, DeviceInstance + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +from .const import DOMAIN + +SCAN_INTERVAL = timedelta(seconds=30) +_LOGGER: logging.Logger = logging.getLogger(__name__) + +type CompitConfigEntry = ConfigEntry[CompitDataUpdateCoordinator] + + +class CompitDataUpdateCoordinator(DataUpdateCoordinator[dict[int, DeviceInstance]]): + """Class to manage fetching data from the API.""" + + def __init__( + self, + hass: HomeAssistant, + config_entry: ConfigEntry, + connector: CompitApiConnector, + ) -> None: + """Initialize.""" + self.connector = connector + + super().__init__( + hass, + _LOGGER, + name=DOMAIN, + update_interval=SCAN_INTERVAL, + config_entry=config_entry, + ) + + async def _async_update_data(self) -> dict[int, DeviceInstance]: + """Update data via library.""" + await self.connector.update_state(device_id=None) # Update all devices + return self.connector.devices diff --git a/homeassistant/components/compit/manifest.json b/homeassistant/components/compit/manifest.json new file mode 100644 index 00000000000..9a7aac81658 --- /dev/null +++ b/homeassistant/components/compit/manifest.json @@ -0,0 +1,12 @@ +{ + "domain": "compit", + "name": "Compit", + "codeowners": ["@Przemko92"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/compit", + "integration_type": "hub", + "iot_class": "cloud_polling", + "loggers": ["compit"], + "quality_scale": "bronze", + "requirements": ["compit-inext-api==0.2.1"] +} diff --git a/homeassistant/components/compit/quality_scale.yaml b/homeassistant/components/compit/quality_scale.yaml new file mode 100644 index 00000000000..88cdf4a47a4 --- /dev/null +++ b/homeassistant/components/compit/quality_scale.yaml @@ -0,0 +1,86 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: | + This integration does not provide additional actions. + appropriate-polling: done + brands: done + common-modules: + status: exempt + comment: | + This integration does not use any common modules. + 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: done + reauthentication-flow: done + test-coverage: todo + + # Gold + devices: done + diagnostics: todo + discovery-update-info: + status: exempt + comment: | + This integration is a cloud service and does not support discovery. + 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 00000000000..c043fe525f2 --- /dev/null +++ b/homeassistant/components/compit/strings.json @@ -0,0 +1,35 @@ +{ + "config": { + "step": { + "user": { + "description": "Please enter your https://inext.compit.pl/ credentials.", + "title": "Connect to Compit iNext", + "data": { + "email": "[%key:common::config_flow::data::email%]", + "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "email": "The email address of your inext.compit.pl account", + "password": "The password of your inext.compit.pl account" + } + }, + "reauth_confirm": { + "description": "Please update your password for {email}", + "data": { + "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "password": "[%key:component::compit::config::step::user::data_description::password%]" + } + } + }, + "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%]" + }, + "abort": { + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 9bf949f0714..55209291531 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -121,6 +121,7 @@ FLOWS = { "coinbase", "color_extractor", "comelit", + "compit", "control4", "cookidoo", "coolmaster", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 16d40ec5d9f..0591305fa08 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -1089,6 +1089,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 5b1f9d3eb0a..4bfe2a10063 100644 --- a/mypy.ini +++ b/mypy.ini @@ -1175,6 +1175,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 5d2968bc03c..1461d9ba552 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -735,6 +735,9 @@ colorlog==6.9.0 # homeassistant.components.color_extractor colorthief==0.2.1 +# homeassistant.components.compit +compit-inext-api==0.2.1 + # homeassistant.components.concord232 concord232==0.15.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f0c717e0fd8..8fbca067742 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -644,6 +644,9 @@ colorlog==6.9.0 # homeassistant.components.color_extractor colorthief==0.2.1 +# homeassistant.components.compit +compit-inext-api==0.2.1 + # 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 00000000000..a817df77ad0 --- /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 00000000000..e8e4b09d9be --- /dev/null +++ b/tests/components/compit/conftest.py @@ -0,0 +1,41 @@ +"""Common fixtures for the Compit tests.""" + +from collections.abc import Generator +from unittest.mock import AsyncMock, patch + +import pytest + +from homeassistant.components.compit.const import DOMAIN +from homeassistant.const import CONF_EMAIL + +from .consts import CONFIG_INPUT + +from tests.common import MockConfigEntry + + +@pytest.fixture +def mock_config_entry(): + """Return a mock config entry.""" + return MockConfigEntry( + domain=DOMAIN, + data=CONFIG_INPUT, + unique_id=CONFIG_INPUT[CONF_EMAIL], + ) + + +@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 + + +@pytest.fixture +def mock_compit_api() -> Generator[AsyncMock]: + """Mock CompitApiConnector.""" + with patch( + "homeassistant.components.compit.config_flow.CompitApiConnector.init", + ) as mock_api: + yield mock_api diff --git a/tests/components/compit/consts.py b/tests/components/compit/consts.py new file mode 100644 index 00000000000..4a8e3884fbd --- /dev/null +++ b/tests/components/compit/consts.py @@ -0,0 +1,8 @@ +"""Constants for the Compit component tests.""" + +from homeassistant.const import CONF_EMAIL, CONF_PASSWORD + +CONFIG_INPUT = { + CONF_EMAIL: "test@example.com", + CONF_PASSWORD: "password", +} diff --git a/tests/components/compit/test_config_flow.py b/tests/components/compit/test_config_flow.py new file mode 100644 index 00000000000..2305187e000 --- /dev/null +++ b/tests/components/compit/test_config_flow.py @@ -0,0 +1,158 @@ +"""Test the Compit config flow.""" + +from unittest.mock import AsyncMock + +import pytest + +from homeassistant import config_entries +from homeassistant.components.compit.config_flow import CannotConnect, 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 + +from .consts import CONFIG_INPUT + +from tests.common import MockConfigEntry + + +async def test_async_step_user_success( + hass: HomeAssistant, mock_compit_api: AsyncMock, mock_setup_entry: AsyncMock +) -> None: + """Test user step with successful authentication.""" + mock_compit_api.return_value = True + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == config_entries.SOURCE_USER + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], CONFIG_INPUT + ) + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == CONFIG_INPUT[CONF_EMAIL] + assert result["data"] == CONFIG_INPUT + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.parametrize( + ("exception", "expected_error"), + [ + (InvalidAuth(), "invalid_auth"), + (CannotConnect(), "cannot_connect"), + (Exception(), "unknown"), + (False, "unknown"), + ], +) +async def test_async_step_user_failed_auth( + hass: HomeAssistant, + exception: Exception, + expected_error: str, + mock_compit_api: AsyncMock, + mock_setup_entry: AsyncMock, +) -> None: + """Test user step with invalid authentication then success after error is cleared.""" + mock_compit_api.side_effect = [exception, True] + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == config_entries.SOURCE_USER + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], CONFIG_INPUT + ) + + assert result["type"] == FlowResultType.FORM + assert result["errors"] == {"base": expected_error} + + # Test success after error is cleared + result = await hass.config_entries.flow.async_configure( + result["flow_id"], CONFIG_INPUT + ) + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == CONFIG_INPUT[CONF_EMAIL] + assert result["data"] == CONFIG_INPUT + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_async_step_reauth_success( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_compit_api: AsyncMock, + mock_setup_entry: AsyncMock, +) -> None: + """Test reauth step with successful authentication.""" + mock_compit_api.return_value = True + + mock_config_entry.add_to_hass(hass) + + result = await mock_config_entry.start_reauth_flow(hass) + + assert result["step_id"] == "reauth_confirm" + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_PASSWORD: "new-password"} + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + assert mock_config_entry.data == { + CONF_EMAIL: CONFIG_INPUT[CONF_EMAIL], + CONF_PASSWORD: "new-password", + } + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.parametrize( + ("exception", "expected_error"), + [ + (InvalidAuth(), "invalid_auth"), + (CannotConnect(), "cannot_connect"), + (Exception(), "unknown"), + ], +) +async def test_async_step_reauth_confirm_failed_auth( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + exception: Exception, + expected_error: str, + mock_compit_api: AsyncMock, + mock_setup_entry: AsyncMock, +) -> None: + """Test reauth confirm step with invalid authentication then success after error is cleared.""" + mock_compit_api.side_effect = [exception, True] + + mock_config_entry.add_to_hass(hass) + + result = await mock_config_entry.start_reauth_flow(hass) + + assert result["step_id"] == "reauth_confirm" + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_PASSWORD: "new-password"} + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": expected_error} + + # Test success after error is cleared + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_EMAIL: CONFIG_INPUT[CONF_EMAIL], CONF_PASSWORD: "correct-password"}, + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + assert mock_config_entry.data == { + CONF_EMAIL: CONFIG_INPUT[CONF_EMAIL], + CONF_PASSWORD: "correct-password", + } + assert len(mock_setup_entry.mock_calls) == 1