From b2caf15434ccc11caf528ccbc7047b4e67725ad9 Mon Sep 17 00:00:00 2001 From: Oscar Calvo <2091582+ocalvo@users.noreply.github.com> Date: Sat, 23 Dec 2023 14:24:52 -0600 Subject: [PATCH] New integration Midea ccm15 climate (#94824) * Initial commit * Correct settings for config flow * Use scan interval * Store proper data * Remove circular dependency * Remove circular dependency * Integration can be initialized * Fix defaults * Add setup entry * Add setup entry * Dont block forever * Poll during async_setup_entry * Remove not needed async methods * Add debug info * Parse binary data * Parse binary data * Use data to update device * Use data to update device * Add CCM15DeviceState * Use DataCoordinator * Use DataCoordinator * Use DataCoordinator * Use CoordinatorEntity * Use CoordinatorEntity * Call update API * Call update API * Call update API * Call update API * Use dataclass * Use dataclass * Use dataclass * Use dataclass * Use dataclass * Use dataclass * Use dataclass * Use dataclass * Fix bugs * Implement swing * Support swing mode, read only * Add unit test * Swing should work * Set swing mode * Add DeviceInfo * Add error code * Add error code * Add error code * Add error code * Initial commit * Refactor * Remove comment code * Try remove circular ref * Try remove circular ref * Remove circular ref * Fix bug * Fix tests * Fix tests * Increase test coverage * Increase test coverage * Increase test coverrage * Add more unit tests * Increase coverage * Update coordinator.py * Fix ruff * Set unit of temperature * Add bounds check * Fix unit tests * Add test coverage * Use Py-ccm15 * Update tests * Upgrade dependency * Apply PR feedback * Upgrade dependency * Upgrade dependency * Upgrade dependency * Force ruff * Delete not needed consts * Fix mypy * Update homeassistant/components/ccm15/coordinator.py Co-authored-by: Robert Resch * Apply PR Feedback * Apply PR Feedback * Apply PR Feedback * Apply PR Feedback * Apply PR Feedback * Apply PR Feedback * Fix unit tests * Move climate instance * Revert "Move climate instance" This reverts commit cc5b9916b79e805b77cc0062da67aea61e22e7c5. * Apply PR feedback * Apply PR Feedback * Remove scan internal parameter * Update homeassistant/components/ccm15/coordinator.py Co-authored-by: Robert Resch * Remove empty keys * Fix tests * Use attr fields * Try refactor * Check for multiple hosts * Check for duplicates * Fix tests * Use PRECISION_WHOLE * Use str(ac_index) * Move {self._ac_host}.{self._ac_index} to construtor * Make it fancy * Update homeassistant/components/ccm15/coordinator.py Co-authored-by: Joost Lekkerkerker * Move const to class variables * Use actual config host * Move device info to construtor * Update homeassistant/components/ccm15/climate.py Co-authored-by: Joost Lekkerkerker * Set name to none, dont ask for poll * Undo name change * Dont use coordinator in config flow * Dont use coordinator in config flow * Check already configured * Apply PR comments * Move above * Use device info name * Update tests/components/ccm15/test_coordinator.py Co-authored-by: Joost Lekkerkerker * Update tests/components/ccm15/test_config_flow.py Co-authored-by: Joost Lekkerkerker * Apply feedback * Remove logger debug calls * Add new test to check for dupplicates * Test error * Use better name for test * Update homeassistant/components/ccm15/config_flow.py Co-authored-by: Joost Lekkerkerker * Update homeassistant/components/ccm15/climate.py Co-authored-by: Joost Lekkerkerker * Update homeassistant/components/ccm15/config_flow.py Co-authored-by: Joost Lekkerkerker * Use prop data for all getters * Fix tests * Improve tests * Improve tests, v2 * Replace log message by comment * No need to do bounds check * Update config_flow.py * Update test_config_flow.py * Update test_coordinator.py * Update test_coordinator.py * Create test_climate.py * Delete tests/components/ccm15/test_coordinator.py * Update coordinator.py * Update __init__.py * Create test_climate.ambr * Update conftest.py * Update test_climate.py * Create test_init.py * Update .coveragerc * Update __init__.py * We need to check bounds after all * Add more test coverage * Test is not None * Use better naming * fix tests * Add available property * Update homeassistant/components/ccm15/climate.py Co-authored-by: Joost Lekkerkerker * Use snapshots to simulate netwrok failure or power failure * Remove not needed test * Use walrus --------- Co-authored-by: Robert Resch Co-authored-by: Joost Lekkerkerker --- CODEOWNERS | 2 + homeassistant/components/ccm15/__init__.py | 34 ++ homeassistant/components/ccm15/climate.py | 160 ++++++++ homeassistant/components/ccm15/config_flow.py | 56 +++ homeassistant/components/ccm15/const.py | 26 ++ homeassistant/components/ccm15/coordinator.py | 76 ++++ homeassistant/components/ccm15/manifest.json | 9 + homeassistant/components/ccm15/strings.json | 19 + homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 6 + requirements_all.txt | 3 + requirements_test_all.txt | 3 + tests/components/ccm15/__init__.py | 1 + tests/components/ccm15/conftest.py | 41 ++ .../ccm15/snapshots/test_climate.ambr | 351 ++++++++++++++++++ tests/components/ccm15/test_climate.py | 130 +++++++ tests/components/ccm15/test_config_flow.py | 171 +++++++++ tests/components/ccm15/test_init.py | 32 ++ 18 files changed, 1121 insertions(+) create mode 100644 homeassistant/components/ccm15/__init__.py create mode 100644 homeassistant/components/ccm15/climate.py create mode 100644 homeassistant/components/ccm15/config_flow.py create mode 100644 homeassistant/components/ccm15/const.py create mode 100644 homeassistant/components/ccm15/coordinator.py create mode 100644 homeassistant/components/ccm15/manifest.json create mode 100644 homeassistant/components/ccm15/strings.json create mode 100644 tests/components/ccm15/__init__.py create mode 100644 tests/components/ccm15/conftest.py create mode 100644 tests/components/ccm15/snapshots/test_climate.ambr create mode 100644 tests/components/ccm15/test_climate.py create mode 100644 tests/components/ccm15/test_config_flow.py create mode 100644 tests/components/ccm15/test_init.py diff --git a/CODEOWNERS b/CODEOWNERS index c5ac30ea6df..e664d89a028 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -197,6 +197,8 @@ build.json @home-assistant/supervisor /tests/components/camera/ @home-assistant/core /homeassistant/components/cast/ @emontnemery /tests/components/cast/ @emontnemery +/homeassistant/components/ccm15/ @ocalvo +/tests/components/ccm15/ @ocalvo /homeassistant/components/cert_expiry/ @jjlawren /tests/components/cert_expiry/ @jjlawren /homeassistant/components/circuit/ @braam diff --git a/homeassistant/components/ccm15/__init__.py b/homeassistant/components/ccm15/__init__.py new file mode 100644 index 00000000000..ae48394c732 --- /dev/null +++ b/homeassistant/components/ccm15/__init__.py @@ -0,0 +1,34 @@ +"""The Midea ccm15 AC Controller integration.""" +from __future__ import annotations + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_HOST, CONF_PORT, Platform +from homeassistant.core import HomeAssistant + +from .const import DOMAIN +from .coordinator import CCM15Coordinator + +PLATFORMS: list[Platform] = [Platform.CLIMATE] + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Midea ccm15 AC Controller from a config entry.""" + + coordinator = CCM15Coordinator( + hass, + entry.data[CONF_HOST], + entry.data[CONF_PORT], + ) + 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): + hass.data[DOMAIN].pop(entry.entry_id) + + return unload_ok diff --git a/homeassistant/components/ccm15/climate.py b/homeassistant/components/ccm15/climate.py new file mode 100644 index 00000000000..30896d12299 --- /dev/null +++ b/homeassistant/components/ccm15/climate.py @@ -0,0 +1,160 @@ +"""Climate device for CCM15 coordinator.""" +import logging +from typing import Any + +from ccm15 import CCM15DeviceState + +from homeassistant.components.climate import ( + FAN_AUTO, + FAN_HIGH, + FAN_LOW, + FAN_MEDIUM, + PRECISION_WHOLE, + SWING_OFF, + SWING_ON, + ClimateEntity, + ClimateEntityFeature, + HVACMode, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ATTR_TEMPERATURE, 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 CONST_CMD_FAN_MAP, CONST_CMD_STATE_MAP, DOMAIN +from .coordinator import CCM15Coordinator + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up all climate.""" + coordinator: CCM15Coordinator = hass.data[DOMAIN][config_entry.entry_id] + + ac_data: CCM15DeviceState = coordinator.data + entities = [ + CCM15Climate(coordinator.get_host(), ac_index, coordinator) + for ac_index in ac_data.devices + ] + async_add_entities(entities) + + +class CCM15Climate(CoordinatorEntity[CCM15Coordinator], ClimateEntity): + """Climate device for CCM15 coordinator.""" + + _attr_temperature_unit = UnitOfTemperature.CELSIUS + _attr_has_entity_name = True + _attr_target_temperature_step = PRECISION_WHOLE + _attr_hvac_modes = [ + HVACMode.OFF, + HVACMode.HEAT, + HVACMode.COOL, + HVACMode.DRY, + HVACMode.AUTO, + ] + _attr_fan_modes = [FAN_AUTO, FAN_LOW, FAN_MEDIUM, FAN_HIGH] + _attr_swing_modes = [SWING_OFF, SWING_ON] + _attr_supported_features = ( + ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.FAN_MODE + | ClimateEntityFeature.SWING_MODE + ) + _attr_name = None + + def __init__( + self, ac_host: str, ac_index: int, coordinator: CCM15Coordinator + ) -> None: + """Create a climate device managed from a coordinator.""" + super().__init__(coordinator) + self._ac_index: int = ac_index + self._attr_unique_id = f"{ac_host}.{ac_index}" + self._attr_device_info = DeviceInfo( + identifiers={ + # Serial numbers are unique identifiers within a specific domain + (DOMAIN, f"{ac_host}.{ac_index}"), + }, + name=f"Midea {ac_index}", + manufacturer="Midea", + model="CCM15", + ) + + @property + def data(self) -> CCM15DeviceState | None: + """Return device data.""" + return self.coordinator.get_ac_data(self._ac_index) + + @property + def current_temperature(self) -> int | None: + """Return current temperature.""" + if (data := self.data) is not None: + return data.temperature + return None + + @property + def target_temperature(self) -> int | None: + """Return target temperature.""" + if (data := self.data) is not None: + return data.temperature_setpoint + return None + + @property + def hvac_mode(self) -> HVACMode | None: + """Return hvac mode.""" + if (data := self.data) is not None: + mode = data.ac_mode + return CONST_CMD_STATE_MAP[mode] + return None + + @property + def fan_mode(self) -> str | None: + """Return fan mode.""" + if (data := self.data) is not None: + mode = data.fan_mode + return CONST_CMD_FAN_MAP[mode] + return None + + @property + def swing_mode(self) -> str | None: + """Return swing mode.""" + if (data := self.data) is not None: + return SWING_ON if data.is_swing_on else SWING_OFF + return None + + @property + def available(self) -> bool: + """Return the avalability of the entity.""" + return self.data is not None + + @property + def extra_state_attributes(self) -> dict[str, Any]: + """Return the optional state attributes.""" + if (data := self.data) is not None: + return {"error_code": data.error_code} + return {} + + async def async_set_temperature(self, **kwargs: Any) -> None: + """Set the target temperature.""" + if (temperature := kwargs.get(ATTR_TEMPERATURE)) is not None: + await self.coordinator.async_set_temperature(self._ac_index, temperature) + + async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: + """Set the hvac mode.""" + await self.coordinator.async_set_hvac_mode(self._ac_index, hvac_mode) + + async def async_set_fan_mode(self, fan_mode: str) -> None: + """Set the fan mode.""" + await self.coordinator.async_set_fan_mode(self._ac_index, fan_mode) + + async def async_turn_off(self) -> None: + """Turn off.""" + await self.async_set_hvac_mode(HVACMode.OFF) + + async def async_turn_on(self) -> None: + """Turn on.""" + await self.async_set_hvac_mode(HVACMode.AUTO) diff --git a/homeassistant/components/ccm15/config_flow.py b/homeassistant/components/ccm15/config_flow.py new file mode 100644 index 00000000000..efde47b8d30 --- /dev/null +++ b/homeassistant/components/ccm15/config_flow.py @@ -0,0 +1,56 @@ +"""Config flow for Midea ccm15 AC Controller integration.""" +from __future__ import annotations + +import logging +from typing import Any + +from ccm15 import CCM15Device +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_HOST, CONF_PORT +from homeassistant.data_entry_flow import FlowResult +import homeassistant.helpers.config_validation as cv + +from .const import DEFAULT_TIMEOUT, DOMAIN + +_LOGGER = logging.getLogger(__name__) + +STEP_USER_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_HOST): str, + vol.Optional(CONF_PORT, default=80): cv.port, + } +) + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for Midea ccm15 AC Controller.""" + + VERSION = 1 + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle the initial step.""" + errors: dict[str, str] = {} + if user_input is not None: + self._async_abort_entries_match(user_input) + ccm15 = CCM15Device( + user_input[CONF_HOST], user_input[CONF_PORT], DEFAULT_TIMEOUT + ) + try: + if not await ccm15.async_test_connection(): + errors["base"] = "cannot_connect" + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + + if not errors: + return self.async_create_entry( + title=user_input[CONF_HOST], data=user_input + ) + + return self.async_show_form( + step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors + ) diff --git a/homeassistant/components/ccm15/const.py b/homeassistant/components/ccm15/const.py new file mode 100644 index 00000000000..5e8d1b82bd8 --- /dev/null +++ b/homeassistant/components/ccm15/const.py @@ -0,0 +1,26 @@ +"""Constants for the Midea ccm15 AC Controller integration.""" + +from homeassistant.components.climate import ( + FAN_AUTO, + FAN_HIGH, + FAN_LOW, + FAN_MEDIUM, + FAN_OFF, + HVACMode, +) + +DOMAIN = "ccm15" +DEFAULT_TIMEOUT = 10 +DEFAULT_INTERVAL = 30 + +CONST_STATE_CMD_MAP = { + HVACMode.COOL: 0, + HVACMode.HEAT: 1, + HVACMode.DRY: 2, + HVACMode.FAN_ONLY: 3, + HVACMode.OFF: 4, + HVACMode.AUTO: 5, +} +CONST_CMD_STATE_MAP = {v: k for k, v in CONST_STATE_CMD_MAP.items()} +CONST_FAN_CMD_MAP = {FAN_AUTO: 0, FAN_LOW: 2, FAN_MEDIUM: 3, FAN_HIGH: 4, FAN_OFF: 5} +CONST_CMD_FAN_MAP = {v: k for k, v in CONST_FAN_CMD_MAP.items()} diff --git a/homeassistant/components/ccm15/coordinator.py b/homeassistant/components/ccm15/coordinator.py new file mode 100644 index 00000000000..9d8a0281706 --- /dev/null +++ b/homeassistant/components/ccm15/coordinator.py @@ -0,0 +1,76 @@ +"""Climate device for CCM15 coordinator.""" +import datetime +import logging + +from ccm15 import CCM15Device, CCM15DeviceState, CCM15SlaveDevice +import httpx + +from homeassistant.components.climate import HVACMode +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import ( + CONST_FAN_CMD_MAP, + CONST_STATE_CMD_MAP, + DEFAULT_INTERVAL, + DEFAULT_TIMEOUT, +) + +_LOGGER = logging.getLogger(__name__) + + +class CCM15Coordinator(DataUpdateCoordinator[CCM15DeviceState]): + """Class to coordinate multiple CCM15Climate devices.""" + + def __init__(self, hass: HomeAssistant, host: str, port: int) -> None: + """Initialize the coordinator.""" + super().__init__( + hass, + _LOGGER, + name=host, + update_interval=datetime.timedelta(seconds=DEFAULT_INTERVAL), + ) + self._ccm15 = CCM15Device(host, port, DEFAULT_TIMEOUT) + self._host = host + + def get_host(self) -> str: + """Get the host.""" + return self._host + + async def _async_update_data(self) -> CCM15DeviceState: + """Fetch data from Rain Bird device.""" + try: + return await self._fetch_data() + except httpx.RequestError as err: # pragma: no cover + raise UpdateFailed("Error communicating with Device") from err + + async def _fetch_data(self) -> CCM15DeviceState: + """Get the current status of all AC devices.""" + return await self._ccm15.get_status_async() + + async def async_set_state(self, ac_index: int, state: str, value: int) -> None: + """Set new target states.""" + if await self._ccm15.async_set_state(ac_index, state, value): + await self.async_request_refresh() + + def get_ac_data(self, ac_index: int) -> CCM15SlaveDevice | None: + """Get ac data from the ac_index.""" + if ac_index < 0 or ac_index >= len(self.data.devices): + # Network latency may return an empty or incomplete array + return None + return self.data.devices[ac_index] + + async def async_set_hvac_mode(self, ac_index, hvac_mode: HVACMode) -> None: + """Set the hvac mode.""" + _LOGGER.debug("Set Hvac[%s]='%s'", ac_index, str(hvac_mode)) + await self.async_set_state(ac_index, "mode", CONST_STATE_CMD_MAP[hvac_mode]) + + async def async_set_fan_mode(self, ac_index, fan_mode: str) -> None: + """Set the fan mode.""" + _LOGGER.debug("Set Fan[%s]='%s'", ac_index, fan_mode) + await self.async_set_state(ac_index, "fan", CONST_FAN_CMD_MAP[fan_mode]) + + async def async_set_temperature(self, ac_index, temp) -> None: + """Set the target temperature mode.""" + _LOGGER.debug("Set Temp[%s]='%s'", ac_index, temp) + await self.async_set_state(ac_index, "temp", temp) diff --git a/homeassistant/components/ccm15/manifest.json b/homeassistant/components/ccm15/manifest.json new file mode 100644 index 00000000000..2d985d6148a --- /dev/null +++ b/homeassistant/components/ccm15/manifest.json @@ -0,0 +1,9 @@ +{ + "domain": "ccm15", + "name": "Midea ccm15 AC Controller", + "codeowners": ["@ocalvo"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/ccm15", + "iot_class": "local_polling", + "requirements": ["py-ccm15==0.0.9"] +} diff --git a/homeassistant/components/ccm15/strings.json b/homeassistant/components/ccm15/strings.json new file mode 100644 index 00000000000..1ac7a25e6f8 --- /dev/null +++ b/homeassistant/components/ccm15/strings.json @@ -0,0 +1,19 @@ +{ + "config": { + "step": { + "user": { + "data": { + "host": "[%key:common::config_flow::data::host%]", + "port": "[%key:common::config_flow::data::port%]" + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index eaeff88f5ed..9274593b86f 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -83,6 +83,7 @@ FLOWS = { "caldav", "canary", "cast", + "ccm15", "cert_expiry", "cloudflare", "co2signal", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 5c19a418853..9bd3de30b29 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -801,6 +801,12 @@ "config_flow": true, "iot_class": "cloud_polling" }, + "ccm15": { + "name": "Midea ccm15 AC Controller", + "integration_type": "hub", + "config_flow": true, + "iot_class": "local_polling" + }, "cert_expiry": { "integration_type": "hub", "config_flow": true, diff --git a/requirements_all.txt b/requirements_all.txt index 944cf160965..f802ee0cf2d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1554,6 +1554,9 @@ py-aosmith==1.0.1 # homeassistant.components.canary py-canary==0.5.3 +# homeassistant.components.ccm15 +py-ccm15==0.0.9 + # homeassistant.components.cpuspeed py-cpuinfo==9.0.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index e5483fce899..b55ea4e71d1 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1197,6 +1197,9 @@ py-aosmith==1.0.1 # homeassistant.components.canary py-canary==0.5.3 +# homeassistant.components.ccm15 +py-ccm15==0.0.9 + # homeassistant.components.cpuspeed py-cpuinfo==9.0.0 diff --git a/tests/components/ccm15/__init__.py b/tests/components/ccm15/__init__.py new file mode 100644 index 00000000000..fe6be699c4d --- /dev/null +++ b/tests/components/ccm15/__init__.py @@ -0,0 +1 @@ +"""Tests for the Midea ccm15 AC Controller integration.""" diff --git a/tests/components/ccm15/conftest.py b/tests/components/ccm15/conftest.py new file mode 100644 index 00000000000..910a74fa0bc --- /dev/null +++ b/tests/components/ccm15/conftest.py @@ -0,0 +1,41 @@ +"""Common fixtures for the Midea ccm15 AC Controller tests.""" +from collections.abc import Generator +from unittest.mock import AsyncMock, patch + +from ccm15 import CCM15DeviceState, CCM15SlaveDevice +import pytest + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock, None, None]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.ccm15.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture +def ccm15_device() -> Generator[AsyncMock, None, None]: + """Mock ccm15 device.""" + ccm15_devices = { + 0: CCM15SlaveDevice(bytes.fromhex("000000b0b8001b")), + 1: CCM15SlaveDevice(bytes.fromhex("00000041c0001a")), + } + device_state = CCM15DeviceState(devices=ccm15_devices) + with patch( + "homeassistant.components.ccm15.coordinator.CCM15Device.get_status_async", + return_value=device_state, + ): + yield + + +@pytest.fixture +def network_failure_ccm15_device() -> Generator[AsyncMock, None, None]: + """Mock empty set of ccm15 device.""" + device_state = CCM15DeviceState(devices={}) + with patch( + "homeassistant.components.ccm15.coordinator.CCM15Device.get_status_async", + return_value=device_state, + ): + yield diff --git a/tests/components/ccm15/snapshots/test_climate.ambr b/tests/components/ccm15/snapshots/test_climate.ambr new file mode 100644 index 00000000000..0d4ce32fb8b --- /dev/null +++ b/tests/components/ccm15/snapshots/test_climate.ambr @@ -0,0 +1,351 @@ +# serializer version: 1 +# name: test_climate_state + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'fan_modes': list([ + 'auto', + 'low', + 'medium', + 'high', + ]), + 'hvac_modes': list([ + , + , + , + , + , + ]), + 'max_temp': 35, + 'min_temp': 7, + 'swing_modes': list([ + 'off', + 'on', + ]), + 'target_temp_step': 1, + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.midea_0', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'ccm15', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '1.1.1.1.0', + 'unit_of_measurement': None, + }) +# --- +# name: test_climate_state.1 + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'fan_modes': list([ + 'auto', + 'low', + 'medium', + 'high', + ]), + 'hvac_modes': list([ + , + , + , + , + , + ]), + 'max_temp': 35, + 'min_temp': 7, + 'swing_modes': list([ + 'off', + 'on', + ]), + 'target_temp_step': 1, + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.midea_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'ccm15', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '1.1.1.1.1', + 'unit_of_measurement': None, + }) +# --- +# name: test_climate_state.2 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 27, + 'error_code': 0, + 'fan_mode': 'off', + 'fan_modes': list([ + 'auto', + 'low', + 'medium', + 'high', + ]), + 'friendly_name': 'Midea 0', + 'hvac_modes': list([ + , + , + , + , + , + ]), + 'max_temp': 35, + 'min_temp': 7, + 'supported_features': , + 'swing_mode': 'off', + 'swing_modes': list([ + 'off', + 'on', + ]), + 'target_temp_step': 1, + 'temperature': 23, + }), + 'context': , + 'entity_id': 'climate.midea_0', + 'last_changed': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_climate_state.3 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 26, + 'error_code': 0, + 'fan_mode': 'low', + 'fan_modes': list([ + 'auto', + 'low', + 'medium', + 'high', + ]), + 'friendly_name': 'Midea 1', + 'hvac_modes': list([ + , + , + , + , + , + ]), + 'max_temp': 35, + 'min_temp': 7, + 'supported_features': , + 'swing_mode': 'off', + 'swing_modes': list([ + 'off', + 'on', + ]), + 'target_temp_step': 1, + 'temperature': 24, + }), + 'context': , + 'entity_id': 'climate.midea_1', + 'last_changed': , + 'last_updated': , + 'state': 'cool', + }) +# --- +# name: test_climate_state.4 + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'fan_modes': list([ + 'auto', + 'low', + 'medium', + 'high', + ]), + 'hvac_modes': list([ + , + , + , + , + , + ]), + 'max_temp': 35, + 'min_temp': 7, + 'swing_modes': list([ + 'off', + 'on', + ]), + 'target_temp_step': 1, + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.midea_0', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'ccm15', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '1.1.1.1.0', + 'unit_of_measurement': None, + }) +# --- +# name: test_climate_state.5 + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'fan_modes': list([ + 'auto', + 'low', + 'medium', + 'high', + ]), + 'hvac_modes': list([ + , + , + , + , + , + ]), + 'max_temp': 35, + 'min_temp': 7, + 'swing_modes': list([ + 'off', + 'on', + ]), + 'target_temp_step': 1, + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.midea_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'ccm15', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '1.1.1.1.1', + 'unit_of_measurement': None, + }) +# --- +# name: test_climate_state.6 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'fan_modes': list([ + 'auto', + 'low', + 'medium', + 'high', + ]), + 'friendly_name': 'Midea 0', + 'hvac_modes': list([ + , + , + , + , + , + ]), + 'max_temp': 35, + 'min_temp': 7, + 'supported_features': , + 'swing_modes': list([ + 'off', + 'on', + ]), + 'target_temp_step': 1, + }), + 'context': , + 'entity_id': 'climate.midea_0', + 'last_changed': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_climate_state.7 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'fan_modes': list([ + 'auto', + 'low', + 'medium', + 'high', + ]), + 'friendly_name': 'Midea 1', + 'hvac_modes': list([ + , + , + , + , + , + ]), + 'max_temp': 35, + 'min_temp': 7, + 'supported_features': , + 'swing_modes': list([ + 'off', + 'on', + ]), + 'target_temp_step': 1, + }), + 'context': , + 'entity_id': 'climate.midea_1', + 'last_changed': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- diff --git a/tests/components/ccm15/test_climate.py b/tests/components/ccm15/test_climate.py new file mode 100644 index 00000000000..36a77aa15ab --- /dev/null +++ b/tests/components/ccm15/test_climate.py @@ -0,0 +1,130 @@ +"""Unit test for CCM15 coordinator component.""" +from datetime import timedelta +from unittest.mock import AsyncMock, patch + +from ccm15 import CCM15DeviceState +from freezegun.api import FrozenDateTimeFactory +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.ccm15.const import DOMAIN +from homeassistant.components.climate import ( + ATTR_FAN_MODE, + ATTR_HVAC_MODE, + ATTR_TEMPERATURE, + DOMAIN as CLIMATE_DOMAIN, + FAN_HIGH, + SERVICE_SET_FAN_MODE, + SERVICE_SET_HVAC_MODE, + SERVICE_SET_TEMPERATURE, + SERVICE_TURN_ON, + HVACMode, +) +from homeassistant.const import ATTR_ENTITY_ID, CONF_HOST, CONF_PORT, SERVICE_TURN_OFF +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry, async_fire_time_changed + + +async def test_climate_state( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + ccm15_device: AsyncMock, + freezer: FrozenDateTimeFactory, +) -> None: + """Test the coordinator.""" + entry = MockConfigEntry( + domain=DOMAIN, + unique_id="1.1.1.1", + data={ + CONF_HOST: "1.1.1.1", + CONF_PORT: 80, + }, + ) + entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert entity_registry.async_get("climate.midea_0") == snapshot + assert entity_registry.async_get("climate.midea_1") == snapshot + + assert hass.states.get("climate.midea_0") == snapshot + assert hass.states.get("climate.midea_1") == snapshot + + with patch( + "homeassistant.components.ccm15.coordinator.CCM15Device.async_set_state" + ) as mock_set_state: + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_FAN_MODE, + {ATTR_ENTITY_ID: ["climate.midea_0"], ATTR_FAN_MODE: FAN_HIGH}, + blocking=True, + ) + await hass.async_block_till_done() + mock_set_state.assert_called_once() + + with patch( + "homeassistant.components.ccm15.coordinator.CCM15Device.async_set_state" + ) as mock_set_state: + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_HVAC_MODE, + {ATTR_ENTITY_ID: ["climate.midea_0"], ATTR_HVAC_MODE: HVACMode.COOL}, + blocking=True, + ) + await hass.async_block_till_done() + mock_set_state.assert_called_once() + + with patch( + "homeassistant.components.ccm15.coordinator.CCM15Device.async_set_state" + ) as mock_set_state: + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_TEMPERATURE, + {ATTR_ENTITY_ID: ["climate.midea_0"], ATTR_TEMPERATURE: 25}, + blocking=True, + ) + await hass.async_block_till_done() + mock_set_state.assert_called_once() + + with patch( + "homeassistant.components.ccm15.coordinator.CCM15Device.async_set_state" + ) as mock_set_state: + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: ["climate.midea_0"]}, + blocking=True, + ) + await hass.async_block_till_done() + mock_set_state.assert_called_once() + + with patch( + "homeassistant.components.ccm15.coordinator.CCM15Device.async_set_state" + ) as mock_set_state: + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: ["climate.midea_0"]}, + blocking=True, + ) + await hass.async_block_till_done() + mock_set_state.assert_called_once() + + # Create an instance of the CCM15DeviceState class + device_state = CCM15DeviceState(devices={}) + with patch( + "ccm15.CCM15Device.CCM15Device.get_status_async", + return_value=device_state, + ): + freezer.tick(timedelta(minutes=15)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert entity_registry.async_get("climate.midea_0") == snapshot + assert entity_registry.async_get("climate.midea_1") == snapshot + + assert hass.states.get("climate.midea_0") == snapshot + assert hass.states.get("climate.midea_1") == snapshot diff --git a/tests/components/ccm15/test_config_flow.py b/tests/components/ccm15/test_config_flow.py new file mode 100644 index 00000000000..9b6314228cc --- /dev/null +++ b/tests/components/ccm15/test_config_flow.py @@ -0,0 +1,171 @@ +"""Test the Midea ccm15 AC Controller config flow.""" +from unittest.mock import AsyncMock, patch + +from homeassistant import config_entries +from homeassistant.components.ccm15.const import DOMAIN +from homeassistant.const import CONF_HOST, CONF_PORT +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from tests.common import MockConfigEntry + + +async def test_form(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None: + """Test we get the form.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == FlowResultType.FORM + assert result["errors"] == {} + + with patch( + "ccm15.CCM15Device.CCM15Device.async_test_connection", + return_value=True, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: "1.1.1.1", + }, + ) + await hass.async_block_till_done() + + assert result2["type"] == FlowResultType.CREATE_ENTRY + assert result2["title"] == "1.1.1.1" + assert result2["data"] == { + CONF_HOST: "1.1.1.1", + CONF_PORT: 80, + } + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_form_invalid_host( + hass: HomeAssistant, mock_setup_entry: AsyncMock +) -> None: + """Test we get the form.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == FlowResultType.FORM + assert result["errors"] == {} + + with patch( + "ccm15.CCM15Device.CCM15Device.async_test_connection", + return_value=False, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: "1.1.1.1", + }, + ) + await hass.async_block_till_done() + + assert result2["type"] == FlowResultType.FORM + assert result2["errors"] == {"base": "cannot_connect"} + assert len(mock_setup_entry.mock_calls) == 0 + + with patch( + "ccm15.CCM15Device.CCM15Device.async_test_connection", return_value=True + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: "1.0.0.1", + }, + ) + + assert result2["type"] == FlowResultType.CREATE_ENTRY + + +async def test_form_cannot_connect(hass: HomeAssistant) -> None: + """Test we handle cannot connect error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "ccm15.CCM15Device.CCM15Device.async_test_connection", return_value=False + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: "1.1.1.1", + }, + ) + + assert result2["type"] == FlowResultType.FORM + assert result2["errors"] == {"base": "cannot_connect"} + + with patch( + "ccm15.CCM15Device.CCM15Device.async_test_connection", return_value=True + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: "1.0.0.1", + }, + ) + + assert result2["type"] == FlowResultType.CREATE_ENTRY + + +async def test_form_unexpected_error(hass: HomeAssistant) -> None: + """Test we handle cannot connect error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "ccm15.CCM15Device.CCM15Device.async_test_connection", + side_effect=Exception(), + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: "1.1.1.1", + }, + ) + + assert result2["type"] == FlowResultType.FORM + assert result2["errors"] == {"base": "unknown"} + + with patch( + "ccm15.CCM15Device.CCM15Device.async_test_connection", return_value=True + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: "1.0.0.1", + }, + ) + + assert result2["type"] == FlowResultType.CREATE_ENTRY + + +async def test_duplicate_host(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None: + """Test we handle cannot connect error.""" + entry = MockConfigEntry( + domain=DOMAIN, + unique_id="1.1.1.1", + data={ + CONF_HOST: "1.1.1.1", + CONF_PORT: 80, + }, + ) + entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: "1.1.1.1", + CONF_PORT: 80, + }, + ) + + assert result2["type"] == FlowResultType.ABORT + assert result2["reason"] == "already_configured" diff --git a/tests/components/ccm15/test_init.py b/tests/components/ccm15/test_init.py new file mode 100644 index 00000000000..b65f170a656 --- /dev/null +++ b/tests/components/ccm15/test_init.py @@ -0,0 +1,32 @@ +"""Tests for the ccm15 component.""" +from unittest.mock import AsyncMock + +from homeassistant.components.ccm15.const import DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import CONF_HOST, CONF_PORT +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def test_load_unload(hass: HomeAssistant, ccm15_device: AsyncMock) -> None: + """Test options flow.""" + entry = MockConfigEntry( + domain=DOMAIN, + unique_id="1.1.1.1", + data={ + CONF_HOST: "1.1.1.1", + CONF_PORT: 80, + }, + ) + entry.add_to_hass(hass) + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert entry.state is ConfigEntryState.LOADED + + await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + + assert entry.state is ConfigEntryState.NOT_LOADED