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 <robert@resch.dev>

* 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 cc5b9916b7.

* Apply PR feedback

* Apply PR Feedback

* Remove scan internal parameter

* Update homeassistant/components/ccm15/coordinator.py

Co-authored-by: Robert Resch <robert@resch.dev>

* 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 <joostlek@outlook.com>

* 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 <joostlek@outlook.com>

* 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 <joostlek@outlook.com>

* Update tests/components/ccm15/test_config_flow.py

Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>

* 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 <joostlek@outlook.com>

* Update homeassistant/components/ccm15/climate.py

Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>

* Update homeassistant/components/ccm15/config_flow.py

Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>

* 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 <joostlek@outlook.com>

* Use snapshots to simulate netwrok failure or power failure

* Remove not needed test

* Use walrus

---------

Co-authored-by: Robert Resch <robert@resch.dev>
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
This commit is contained in:
Oscar Calvo 2023-12-23 14:24:52 -06:00 committed by GitHub
parent 83e1ba338a
commit b2caf15434
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
18 changed files with 1121 additions and 0 deletions

View File

@ -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

View File

@ -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

View File

@ -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)

View File

@ -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
)

View File

@ -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()}

View File

@ -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)

View File

@ -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"]
}

View File

@ -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%]"
}
}
}

View File

@ -83,6 +83,7 @@ FLOWS = {
"caldav",
"canary",
"cast",
"ccm15",
"cert_expiry",
"cloudflare",
"co2signal",

View File

@ -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,

View File

@ -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

View File

@ -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

View File

@ -0,0 +1 @@
"""Tests for the Midea ccm15 AC Controller integration."""

View File

@ -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

View File

@ -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([
<HVACMode.OFF: 'off'>,
<HVACMode.HEAT: 'heat'>,
<HVACMode.COOL: 'cool'>,
<HVACMode.DRY: 'dry'>,
<HVACMode.AUTO: 'auto'>,
]),
'max_temp': 35,
'min_temp': 7,
'swing_modes': list([
'off',
'on',
]),
'target_temp_step': 1,
}),
'config_entry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'climate',
'entity_category': None,
'entity_id': 'climate.midea_0',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'name': None,
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': None,
'platform': 'ccm15',
'previous_unique_id': None,
'supported_features': <ClimateEntityFeature: 41>,
'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([
<HVACMode.OFF: 'off'>,
<HVACMode.HEAT: 'heat'>,
<HVACMode.COOL: 'cool'>,
<HVACMode.DRY: 'dry'>,
<HVACMode.AUTO: 'auto'>,
]),
'max_temp': 35,
'min_temp': 7,
'swing_modes': list([
'off',
'on',
]),
'target_temp_step': 1,
}),
'config_entry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'climate',
'entity_category': None,
'entity_id': 'climate.midea_1',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'name': None,
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': None,
'platform': 'ccm15',
'previous_unique_id': None,
'supported_features': <ClimateEntityFeature: 41>,
'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([
<HVACMode.OFF: 'off'>,
<HVACMode.HEAT: 'heat'>,
<HVACMode.COOL: 'cool'>,
<HVACMode.DRY: 'dry'>,
<HVACMode.AUTO: 'auto'>,
]),
'max_temp': 35,
'min_temp': 7,
'supported_features': <ClimateEntityFeature: 41>,
'swing_mode': 'off',
'swing_modes': list([
'off',
'on',
]),
'target_temp_step': 1,
'temperature': 23,
}),
'context': <ANY>,
'entity_id': 'climate.midea_0',
'last_changed': <ANY>,
'last_updated': <ANY>,
'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([
<HVACMode.OFF: 'off'>,
<HVACMode.HEAT: 'heat'>,
<HVACMode.COOL: 'cool'>,
<HVACMode.DRY: 'dry'>,
<HVACMode.AUTO: 'auto'>,
]),
'max_temp': 35,
'min_temp': 7,
'supported_features': <ClimateEntityFeature: 41>,
'swing_mode': 'off',
'swing_modes': list([
'off',
'on',
]),
'target_temp_step': 1,
'temperature': 24,
}),
'context': <ANY>,
'entity_id': 'climate.midea_1',
'last_changed': <ANY>,
'last_updated': <ANY>,
'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([
<HVACMode.OFF: 'off'>,
<HVACMode.HEAT: 'heat'>,
<HVACMode.COOL: 'cool'>,
<HVACMode.DRY: 'dry'>,
<HVACMode.AUTO: 'auto'>,
]),
'max_temp': 35,
'min_temp': 7,
'swing_modes': list([
'off',
'on',
]),
'target_temp_step': 1,
}),
'config_entry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'climate',
'entity_category': None,
'entity_id': 'climate.midea_0',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'name': None,
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': None,
'platform': 'ccm15',
'previous_unique_id': None,
'supported_features': <ClimateEntityFeature: 41>,
'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([
<HVACMode.OFF: 'off'>,
<HVACMode.HEAT: 'heat'>,
<HVACMode.COOL: 'cool'>,
<HVACMode.DRY: 'dry'>,
<HVACMode.AUTO: 'auto'>,
]),
'max_temp': 35,
'min_temp': 7,
'swing_modes': list([
'off',
'on',
]),
'target_temp_step': 1,
}),
'config_entry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'climate',
'entity_category': None,
'entity_id': 'climate.midea_1',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'name': None,
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': None,
'platform': 'ccm15',
'previous_unique_id': None,
'supported_features': <ClimateEntityFeature: 41>,
'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([
<HVACMode.OFF: 'off'>,
<HVACMode.HEAT: 'heat'>,
<HVACMode.COOL: 'cool'>,
<HVACMode.DRY: 'dry'>,
<HVACMode.AUTO: 'auto'>,
]),
'max_temp': 35,
'min_temp': 7,
'supported_features': <ClimateEntityFeature: 41>,
'swing_modes': list([
'off',
'on',
]),
'target_temp_step': 1,
}),
'context': <ANY>,
'entity_id': 'climate.midea_0',
'last_changed': <ANY>,
'last_updated': <ANY>,
'state': 'unavailable',
})
# ---
# name: test_climate_state.7
StateSnapshot({
'attributes': ReadOnlyDict({
'fan_modes': list([
'auto',
'low',
'medium',
'high',
]),
'friendly_name': 'Midea 1',
'hvac_modes': list([
<HVACMode.OFF: 'off'>,
<HVACMode.HEAT: 'heat'>,
<HVACMode.COOL: 'cool'>,
<HVACMode.DRY: 'dry'>,
<HVACMode.AUTO: 'auto'>,
]),
'max_temp': 35,
'min_temp': 7,
'supported_features': <ClimateEntityFeature: 41>,
'swing_modes': list([
'off',
'on',
]),
'target_temp_step': 1,
}),
'context': <ANY>,
'entity_id': 'climate.midea_1',
'last_changed': <ANY>,
'last_updated': <ANY>,
'state': 'unavailable',
})
# ---

View File

@ -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

View File

@ -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"

View File

@ -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