Add gree climate integration (#37498)

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
This commit is contained in:
Clifford Roche
2020-10-14 09:15:56 -04:00
committed by GitHub
parent 9ee97cb213
commit 839b9f226e
18 changed files with 1483 additions and 0 deletions

View File

@ -164,6 +164,7 @@ homeassistant/components/google_assistant/* @home-assistant/cloud
homeassistant/components/google_cloud/* @lufton
homeassistant/components/google_translate/* @awarecan
homeassistant/components/gpsd/* @fabaff
homeassistant/components/gree/* @cmroche
homeassistant/components/greeneye_monitor/* @jkeljo
homeassistant/components/griddy/* @bdraco
homeassistant/components/group/* @home-assistant/core

View File

@ -0,0 +1,62 @@
"""The Gree Climate integration."""
import logging
from homeassistant.components.climate import DOMAIN as CLIMATE_DOMAIN
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from .bridge import CannotConnect, DeviceHelper
from .const import DOMAIN
_LOGGER = logging.getLogger(__name__)
async def async_setup(hass: HomeAssistant, config: dict):
"""Set up the Gree Climate component."""
hass.data[DOMAIN] = {}
return True
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry):
"""Set up Gree Climate from a config entry."""
devices = []
# First we'll grab as many devices as we can find on the network
# it's necessary to bind static devices anyway
_LOGGER.debug("Scanning network for Gree devices")
for device_info in await DeviceHelper.find_devices():
try:
device = await DeviceHelper.try_bind_device(device_info)
except CannotConnect:
_LOGGER.error("Unable to bind to gree device: %s", device_info)
continue
_LOGGER.debug(
"Adding Gree device at %s:%i (%s)",
device.device_info.ip,
device.device_info.port,
device.device_info.name,
)
devices.append(device)
hass.data[DOMAIN]["devices"] = devices
hass.data[DOMAIN]["pending"] = devices
hass.async_create_task(
hass.config_entries.async_forward_entry_setup(entry, CLIMATE_DOMAIN)
)
return True
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry):
"""Unload a config entry."""
unload_ok = await hass.config_entries.async_forward_entry_unload(
entry, CLIMATE_DOMAIN
)
if unload_ok:
hass.data[DOMAIN].pop("devices", None)
hass.data[DOMAIN].pop("pending", None)
return unload_ok

View File

@ -0,0 +1,38 @@
"""Helper and wrapper classes for Gree module."""
import logging
from typing import List
from greeclimate.device import Device, DeviceInfo
from greeclimate.discovery import Discovery
from greeclimate.exceptions import DeviceNotBoundError
from homeassistant import exceptions
_LOGGER = logging.getLogger(__name__)
class DeviceHelper:
"""Device search and bind wrapper for Gree platform."""
@staticmethod
async def try_bind_device(device_info: DeviceInfo) -> Device:
"""Try and bing with a discovered device.
Note the you must bind with the device very quickly after it is discovered, or the
process may not be completed correctly, raising a `CannotConnect` error.
"""
device = Device(device_info)
try:
await device.bind()
except DeviceNotBoundError as exception:
raise CannotConnect from exception
return device
@staticmethod
async def find_devices() -> List[DeviceInfo]:
"""Gather a list of device infos from the local network."""
return await Discovery.search_devices()
class CannotConnect(exceptions.HomeAssistantError):
"""Error to indicate we cannot connect."""

View File

@ -0,0 +1,398 @@
"""Support for interface with a Gree climate systems."""
from datetime import timedelta
import logging
from typing import List
from greeclimate.device import (
FanSpeed,
HorizontalSwing,
Mode,
TemperatureUnits,
VerticalSwing,
)
from greeclimate.exceptions import DeviceTimeoutError
from homeassistant.components.climate import ClimateEntity
from homeassistant.components.climate.const import (
FAN_AUTO,
FAN_HIGH,
FAN_LOW,
FAN_MEDIUM,
HVAC_MODE_AUTO,
HVAC_MODE_COOL,
HVAC_MODE_DRY,
HVAC_MODE_FAN_ONLY,
HVAC_MODE_HEAT,
HVAC_MODE_OFF,
PRESET_AWAY,
PRESET_BOOST,
PRESET_ECO,
PRESET_NONE,
PRESET_SLEEP,
SUPPORT_FAN_MODE,
SUPPORT_PRESET_MODE,
SUPPORT_SWING_MODE,
SUPPORT_TARGET_TEMPERATURE,
SWING_BOTH,
SWING_HORIZONTAL,
SWING_OFF,
SWING_VERTICAL,
)
from homeassistant.const import (
ATTR_TEMPERATURE,
PRECISION_WHOLE,
TEMP_CELSIUS,
TEMP_FAHRENHEIT,
)
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC
from .const import (
DOMAIN,
FAN_MEDIUM_HIGH,
FAN_MEDIUM_LOW,
MAX_ERRORS,
MAX_TEMP,
MIN_TEMP,
TARGET_TEMPERATURE_STEP,
)
_LOGGER = logging.getLogger(__name__)
SCAN_INTERVAL = timedelta(seconds=60)
PARALLEL_UPDATES = 0
HVAC_MODES = {
Mode.Auto: HVAC_MODE_AUTO,
Mode.Cool: HVAC_MODE_COOL,
Mode.Dry: HVAC_MODE_DRY,
Mode.Fan: HVAC_MODE_FAN_ONLY,
Mode.Heat: HVAC_MODE_HEAT,
}
HVAC_MODES_REVERSE = {v: k for k, v in HVAC_MODES.items()}
PRESET_MODES = [
PRESET_ECO, # Power saving mode
PRESET_AWAY, # Steady heat, or 8C mode on gree units
PRESET_BOOST, # Turbo mode
PRESET_NONE, # Default operating mode
PRESET_SLEEP, # Sleep mode
]
FAN_MODES = {
FanSpeed.Auto: FAN_AUTO,
FanSpeed.Low: FAN_LOW,
FanSpeed.MediumLow: FAN_MEDIUM_LOW,
FanSpeed.Medium: FAN_MEDIUM,
FanSpeed.MediumHigh: FAN_MEDIUM_HIGH,
FanSpeed.High: FAN_HIGH,
}
FAN_MODES_REVERSE = {v: k for k, v in FAN_MODES.items()}
SWING_MODES = [SWING_OFF, SWING_VERTICAL, SWING_HORIZONTAL, SWING_BOTH]
SUPPORTED_FEATURES = (
SUPPORT_TARGET_TEMPERATURE
| SUPPORT_FAN_MODE
| SUPPORT_PRESET_MODE
| SUPPORT_SWING_MODE
)
async def async_setup_entry(hass, config_entry, async_add_entities):
"""Set up the Gree HVAC device from a config entry."""
async_add_entities(
GreeClimateEntity(device) for device in hass.data[DOMAIN].pop("pending")
)
class GreeClimateEntity(ClimateEntity):
"""Representation of a Gree HVAC device."""
def __init__(self, device):
"""Initialize the Gree device."""
self._device = device
self._name = device.device_info.name
self._mac = device.device_info.mac
self._available = False
self._error_count = 0
async def async_update(self):
"""Update the state of the device."""
try:
await self._device.update_state()
if not self._available and self._error_count:
_LOGGER.warning(
"Device is available: %s (%s)",
self._name,
str(self._device.device_info),
)
self._available = True
self._error_count = 0
except DeviceTimeoutError:
self._error_count += 1
# Under normal conditions GREE units timeout every once in a while
if self._available and self._error_count >= MAX_ERRORS:
self._available = False
_LOGGER.warning(
"Device is unavailable: %s (%s)",
self._name,
self._device.device_info,
)
except Exception: # pylint: disable=broad-except
# Under normal conditions GREE units timeout every once in a while
if self._available:
self._available = False
_LOGGER.exception(
"Unknown exception caught during update by gree device: %s (%s)",
self._name,
self._device.device_info,
)
async def _push_state_update(self):
"""Send state updates to the physical device."""
try:
return await self._device.push_state_update()
except DeviceTimeoutError:
self._error_count += 1
# Under normal conditions GREE units timeout every once in a while
if self._available and self._error_count >= MAX_ERRORS:
self._available = False
_LOGGER.warning(
"Device timedout while sending state update: %s (%s)",
self._name,
self._device.device_info,
)
except Exception: # pylint: disable=broad-except
# Under normal conditions GREE units timeout every once in a while
if self._available:
self._available = False
_LOGGER.exception(
"Unknown exception caught while sending state update to: %s (%s)",
self._name,
self._device.device_info,
)
@property
def available(self) -> bool:
"""Return if the device is available."""
return self._available
@property
def name(self) -> str:
"""Return the name of the device."""
return self._name
@property
def unique_id(self) -> str:
"""Return a unique id for the device."""
return self._mac
@property
def device_info(self):
"""Return device specific attributes."""
return {
"name": self._name,
"identifiers": {(DOMAIN, self._mac)},
"manufacturer": "Gree",
"connections": {(CONNECTION_NETWORK_MAC, self._mac)},
}
@property
def temperature_unit(self) -> str:
"""Return the temperature units for the device."""
units = self._device.temperature_units
return TEMP_CELSIUS if units == TemperatureUnits.C else TEMP_FAHRENHEIT
@property
def precision(self) -> float:
"""Return the precision of temperature for the device."""
return PRECISION_WHOLE
@property
def current_temperature(self) -> float:
"""Return the target temperature, gree devices don't provide internal temp."""
return self.target_temperature
@property
def target_temperature(self) -> float:
"""Return the target temperature for the device."""
return self._device.target_temperature
async def async_set_temperature(self, **kwargs):
"""Set new target temperature."""
if ATTR_TEMPERATURE not in kwargs:
raise ValueError(f"Missing parameter {ATTR_TEMPERATURE}")
temperature = kwargs[ATTR_TEMPERATURE]
_LOGGER.debug(
"Setting temperature to %d for %s",
temperature,
self._name,
)
self._device.target_temperature = round(temperature)
await self._push_state_update()
@property
def min_temp(self) -> float:
"""Return the minimum temperature supported by the device."""
return MIN_TEMP
@property
def max_temp(self) -> float:
"""Return the maximum temperature supported by the device."""
return MAX_TEMP
@property
def target_temperature_step(self) -> float:
"""Return the target temperature step support by the device."""
return TARGET_TEMPERATURE_STEP
@property
def hvac_mode(self) -> str:
"""Return the current HVAC mode for the device."""
if not self._device.power:
return HVAC_MODE_OFF
return HVAC_MODES.get(self._device.mode)
async def async_set_hvac_mode(self, hvac_mode):
"""Set new target hvac mode."""
if hvac_mode not in self.hvac_modes:
raise ValueError(f"Invalid hvac_mode: {hvac_mode}")
_LOGGER.debug(
"Setting HVAC mode to %s for device %s",
hvac_mode,
self._name,
)
if hvac_mode == HVAC_MODE_OFF:
self._device.power = False
await self._push_state_update()
return
if not self._device.power:
self._device.power = True
self._device.mode = HVAC_MODES_REVERSE.get(hvac_mode)
await self._push_state_update()
@property
def hvac_modes(self) -> List[str]:
"""Return the HVAC modes support by the device."""
modes = [*HVAC_MODES_REVERSE]
modes.append(HVAC_MODE_OFF)
return modes
@property
def preset_mode(self) -> str:
"""Return the current preset mode for the device."""
if self._device.steady_heat:
return PRESET_AWAY
if self._device.power_save:
return PRESET_ECO
if self._device.sleep:
return PRESET_SLEEP
if self._device.turbo:
return PRESET_BOOST
return PRESET_NONE
async def async_set_preset_mode(self, preset_mode):
"""Set new preset mode."""
if preset_mode not in PRESET_MODES:
raise ValueError(f"Invalid preset mode: {preset_mode}")
_LOGGER.debug(
"Setting preset mode to %s for device %s",
preset_mode,
self._name,
)
self._device.steady_heat = False
self._device.power_save = False
self._device.turbo = False
self._device.sleep = False
if preset_mode == PRESET_AWAY:
self._device.steady_heat = True
elif preset_mode == PRESET_ECO:
self._device.power_save = True
elif preset_mode == PRESET_BOOST:
self._device.turbo = True
elif preset_mode == PRESET_SLEEP:
self._device.sleep = True
await self._push_state_update()
@property
def preset_modes(self) -> List[str]:
"""Return the preset modes support by the device."""
return PRESET_MODES
@property
def fan_mode(self) -> str:
"""Return the current fan mode for the device."""
speed = self._device.fan_speed
return FAN_MODES.get(speed)
async def async_set_fan_mode(self, fan_mode):
"""Set new target fan mode."""
if fan_mode not in FAN_MODES_REVERSE:
raise ValueError(f"Invalid fan mode: {fan_mode}")
self._device.fan_speed = FAN_MODES_REVERSE.get(fan_mode)
await self._push_state_update()
@property
def fan_modes(self) -> List[str]:
"""Return the fan modes support by the device."""
return [*FAN_MODES_REVERSE]
@property
def swing_mode(self) -> str:
"""Return the current swing mode for the device."""
h_swing = self._device.horizontal_swing == HorizontalSwing.FullSwing
v_swing = self._device.vertical_swing == VerticalSwing.FullSwing
if h_swing and v_swing:
return SWING_BOTH
if h_swing:
return SWING_HORIZONTAL
if v_swing:
return SWING_VERTICAL
return SWING_OFF
async def async_set_swing_mode(self, swing_mode):
"""Set new target swing operation."""
if swing_mode not in SWING_MODES:
raise ValueError(f"Invalid swing mode: {swing_mode}")
_LOGGER.debug(
"Setting swing mode to %s for device %s",
swing_mode,
self._name,
)
self._device.horizontal_swing = HorizontalSwing.Center
self._device.vertical_swing = VerticalSwing.FixedMiddle
if swing_mode in (SWING_BOTH, SWING_HORIZONTAL):
self._device.horizontal_swing = HorizontalSwing.FullSwing
if swing_mode in (SWING_BOTH, SWING_VERTICAL):
self._device.vertical_swing = VerticalSwing.FullSwing
await self._push_state_update()
@property
def swing_modes(self) -> List[str]:
"""Return the swing modes currently supported for this device."""
return SWING_MODES
@property
def supported_features(self) -> int:
"""Return the supported features for this device integration."""
return SUPPORTED_FEATURES

View File

@ -0,0 +1,17 @@
"""Config flow for Gree."""
from homeassistant import config_entries
from homeassistant.helpers import config_entry_flow
from .bridge import DeviceHelper
from .const import DOMAIN
async def _async_has_devices(hass) -> bool:
"""Return if there are devices that can be discovered."""
devices = await DeviceHelper.find_devices()
return len(devices) > 0
config_entry_flow.register_discovery_flow(
DOMAIN, "Gree Climate", _async_has_devices, config_entries.CONN_CLASS_LOCAL_POLL
)

View File

@ -0,0 +1,13 @@
"""Constants for the Gree Climate integration."""
DOMAIN = "gree"
FAN_MEDIUM_LOW = "medium low"
FAN_MEDIUM_HIGH = "medium high"
MIN_TEMP = 16
MAX_TEMP = 30
MAX_ERRORS = 2
TARGET_TEMPERATURE_STEP = 1

View File

@ -0,0 +1,8 @@
{
"domain": "gree",
"name": "Gree Climate",
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/gree",
"requirements": ["greeclimate==0.9.0"],
"codeowners": ["@cmroche"]
}

View File

@ -0,0 +1,14 @@
{
"title": "Gree Climate",
"config": {
"step": {
"confirm": {
"description": "[%key:common::config_flow::description::confirm_setup%]"
}
},
"abort": {
"single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]",
"no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]"
}
}
}

View File

@ -0,0 +1,14 @@
{
"config": {
"abort": {
"no_devices_found": "No devices found on the network",
"single_instance_allowed": "Already configured. Only a single configuration possible."
},
"step": {
"confirm": {
"description": "Do you want to start set up?"
}
}
},
"title": "Gree Climate"
}

View File

@ -72,6 +72,7 @@ FLOWS = [
"goalzero",
"gogogate2",
"gpslogger",
"gree",
"griddy",
"guardian",
"hangouts",

View File

@ -698,6 +698,9 @@ gpiozero==1.5.1
# homeassistant.components.gpsd
gps3==0.33.3
# homeassistant.components.gree
greeclimate==0.9.0
# homeassistant.components.greeneye_monitor
greeneye_monitor==2.1

View File

@ -351,6 +351,9 @@ google-api-python-client==1.6.4
# homeassistant.components.google_pubsub
google-cloud-pubsub==0.39.1
# homeassistant.components.gree
greeclimate==0.9.0
# homeassistant.components.griddy
griddypower==0.1.0

View File

@ -0,0 +1 @@
"""Tests for the Gree Climate integration."""

View File

@ -0,0 +1,35 @@
"""Common helpers for gree test cases."""
from tests.async_mock import AsyncMock, Mock
def build_device_info_mock(
name="fake-device-1", ipAddress="1.1.1.1", mac="aabbcc112233"
):
"""Build mock device info structure."""
mock = Mock(ip=ipAddress, port=7000, mac=mac)
mock.name = name
return mock
def build_device_mock(name="fake-device-1", ipAddress="1.1.1.1", mac="aabbcc112233"):
"""Build mock device object."""
mock = Mock(
device_info=build_device_info_mock(name, ipAddress, mac),
name=name,
bind=AsyncMock(),
update_state=AsyncMock(),
push_state_update=AsyncMock(),
temperature_units=0,
mode=0,
fan_speed=0,
horizontal_swing=0,
vertical_swing=0,
target_temperature=25,
power=False,
sleep=False,
quiet=False,
turbo=False,
power_save=False,
steady_heat=False,
)
return mock

View File

@ -0,0 +1,36 @@
"""Pytest module configuration."""
import pytest
from .common import build_device_info_mock, build_device_mock
from tests.async_mock import AsyncMock, patch
@pytest.fixture(name="discovery")
def discovery_fixture():
"""Patch the discovery service."""
with patch(
"homeassistant.components.gree.bridge.Discovery.search_devices",
new_callable=AsyncMock,
return_value=[build_device_info_mock()],
) as mock:
yield mock
@pytest.fixture(name="device")
def device_fixture():
"""Path the device search and bind."""
with patch(
"homeassistant.components.gree.bridge.Device",
return_value=build_device_mock(),
) as mock:
yield mock
@pytest.fixture(name="setup")
def setup_fixture():
"""Patch the climate setup."""
with patch(
"homeassistant.components.gree.climate.async_setup_entry", return_value=True
) as setup:
yield setup

View File

@ -0,0 +1,781 @@
"""Tests for gree component."""
from datetime import timedelta
from greeclimate.device import HorizontalSwing, VerticalSwing
from greeclimate.exceptions import DeviceNotBoundError, DeviceTimeoutError
import pytest
from homeassistant.components.climate.const import (
ATTR_FAN_MODE,
ATTR_HVAC_MODE,
ATTR_PRESET_MODE,
ATTR_SWING_MODE,
DOMAIN,
FAN_AUTO,
FAN_HIGH,
FAN_LOW,
FAN_MEDIUM,
HVAC_MODE_AUTO,
HVAC_MODE_COOL,
HVAC_MODE_DRY,
HVAC_MODE_FAN_ONLY,
HVAC_MODE_HEAT,
HVAC_MODE_OFF,
PRESET_AWAY,
PRESET_BOOST,
PRESET_ECO,
PRESET_NONE,
PRESET_SLEEP,
SERVICE_SET_FAN_MODE,
SERVICE_SET_HVAC_MODE,
SERVICE_SET_PRESET_MODE,
SERVICE_SET_SWING_MODE,
SERVICE_SET_TEMPERATURE,
SWING_BOTH,
SWING_HORIZONTAL,
SWING_OFF,
SWING_VERTICAL,
)
from homeassistant.components.gree.climate import (
FAN_MODES_REVERSE,
HVAC_MODES_REVERSE,
SUPPORTED_FEATURES,
)
from homeassistant.components.gree.const import (
DOMAIN as GREE_DOMAIN,
FAN_MEDIUM_HIGH,
FAN_MEDIUM_LOW,
)
from homeassistant.const import (
ATTR_ENTITY_ID,
ATTR_FRIENDLY_NAME,
ATTR_SUPPORTED_FEATURES,
ATTR_TEMPERATURE,
STATE_UNAVAILABLE,
)
from homeassistant.setup import async_setup_component
import homeassistant.util.dt as dt_util
from .common import build_device_mock
from tests.async_mock import DEFAULT as DEFAULT_MOCK, AsyncMock, patch
from tests.common import MockConfigEntry, async_fire_time_changed
ENTITY_ID = f"{DOMAIN}.fake_device_1"
@pytest.fixture
def mock_now():
"""Fixture for dtutil.now."""
return dt_util.utcnow()
async def async_setup_gree(hass):
"""Set up the gree platform."""
MockConfigEntry(domain=GREE_DOMAIN).add_to_hass(hass)
await async_setup_component(hass, GREE_DOMAIN, {GREE_DOMAIN: {"climate": {}}})
await hass.async_block_till_done()
async def test_discovery_called_once(hass, discovery, device):
"""Test discovery is only ever called once."""
await async_setup_gree(hass)
assert discovery.call_count == 1
await async_setup_gree(hass)
assert discovery.call_count == 1
async def test_discovery_setup(hass, discovery, device):
"""Test setup of platform."""
MockDevice1 = build_device_mock(
name="fake-device-1", ipAddress="1.1.1.1", mac="aabbcc112233"
)
MockDevice2 = build_device_mock(
name="fake-device-2", ipAddress="2.2.2.2", mac="bbccdd223344"
)
discovery.return_value = [MockDevice1.device_info, MockDevice2.device_info]
device.side_effect = [MockDevice1, MockDevice2]
await async_setup_gree(hass)
await hass.async_block_till_done()
assert discovery.call_count == 1
assert len(hass.states.async_all(DOMAIN)) == 2
async def test_discovery_setup_connection_error(hass, discovery, device):
"""Test gree integration is setup."""
MockDevice1 = build_device_mock(name="fake-device-1")
MockDevice1.bind = AsyncMock(side_effect=DeviceNotBoundError)
MockDevice2 = build_device_mock(name="fake-device-2")
MockDevice2.bind = AsyncMock(side_effect=DeviceNotBoundError)
device.side_effect = [MockDevice1, MockDevice2]
await async_setup_gree(hass)
await hass.async_block_till_done()
assert discovery.call_count == 1
assert not hass.states.async_all(DOMAIN)
async def test_update_connection_failure(hass, discovery, device, mock_now):
"""Testing update hvac connection failure exception."""
device().update_state.side_effect = [
DEFAULT_MOCK,
DeviceTimeoutError,
DeviceTimeoutError,
]
await async_setup_gree(hass)
next_update = mock_now + timedelta(minutes=5)
with patch("homeassistant.util.dt.utcnow", return_value=next_update):
async_fire_time_changed(hass, next_update)
await hass.async_block_till_done()
# First update to make the device available
state = hass.states.get(ENTITY_ID)
assert state.name == "fake-device-1"
assert state.state != STATE_UNAVAILABLE
next_update = mock_now + timedelta(minutes=10)
with patch("homeassistant.util.dt.utcnow", return_value=next_update):
async_fire_time_changed(hass, next_update)
await hass.async_block_till_done()
next_update = mock_now + timedelta(minutes=15)
with patch("homeassistant.util.dt.utcnow", return_value=next_update):
async_fire_time_changed(hass, next_update)
await hass.async_block_till_done()
# Then two more update failures to make the device unavailable
state = hass.states.get(ENTITY_ID)
assert state.name == "fake-device-1"
assert state.state == STATE_UNAVAILABLE
async def test_update_connection_failure_recovery(hass, discovery, device, mock_now):
"""Testing update hvac connection failure recovery."""
device().update_state.side_effect = [DeviceTimeoutError, DEFAULT_MOCK]
await async_setup_gree(hass)
next_update = mock_now + timedelta(minutes=5)
with patch("homeassistant.util.dt.utcnow", return_value=next_update):
async_fire_time_changed(hass, next_update)
await hass.async_block_till_done()
state = hass.states.get(ENTITY_ID)
assert state.name == "fake-device-1"
assert state.state == STATE_UNAVAILABLE
next_update = mock_now + timedelta(minutes=10)
with patch("homeassistant.util.dt.utcnow", return_value=next_update):
async_fire_time_changed(hass, next_update)
await hass.async_block_till_done()
state = hass.states.get(ENTITY_ID)
assert state.name == "fake-device-1"
assert state.state != STATE_UNAVAILABLE
async def test_update_unhandled_exception(hass, discovery, device, mock_now):
"""Testing update hvac connection unhandled response exception."""
device().update_state.side_effect = [DEFAULT_MOCK, Exception]
await async_setup_gree(hass)
next_update = mock_now + timedelta(minutes=5)
with patch("homeassistant.util.dt.utcnow", return_value=next_update):
async_fire_time_changed(hass, next_update)
await hass.async_block_till_done()
state = hass.states.get(ENTITY_ID)
assert state.name == "fake-device-1"
assert state.state != STATE_UNAVAILABLE
next_update = mock_now + timedelta(minutes=10)
with patch("homeassistant.util.dt.utcnow", return_value=next_update):
async_fire_time_changed(hass, next_update)
await hass.async_block_till_done()
state = hass.states.get(ENTITY_ID)
assert state.name == "fake-device-1"
assert state.state == STATE_UNAVAILABLE
async def test_send_command_device_timeout(hass, discovery, device, mock_now):
"""Test for sending power on command to the device with a device timeout."""
await async_setup_gree(hass)
# First update to make the device available
next_update = mock_now + timedelta(minutes=5)
with patch("homeassistant.util.dt.utcnow", return_value=next_update):
async_fire_time_changed(hass, next_update)
await hass.async_block_till_done()
state = hass.states.get(ENTITY_ID)
assert state.name == "fake-device-1"
assert state.state != STATE_UNAVAILABLE
device().update_state.side_effect = DeviceTimeoutError
device().push_state_update.side_effect = DeviceTimeoutError
# Second update to make an initial error (device is still available)
next_update = mock_now + timedelta(minutes=5)
with patch("homeassistant.util.dt.utcnow", return_value=next_update):
async_fire_time_changed(hass, next_update)
await hass.async_block_till_done()
state = hass.states.get(ENTITY_ID)
assert state is not None
assert state.name == "fake-device-1"
assert state.state != STATE_UNAVAILABLE
# Second attempt should make the device unavailable
assert await hass.services.async_call(
DOMAIN,
SERVICE_SET_HVAC_MODE,
{ATTR_ENTITY_ID: ENTITY_ID, ATTR_HVAC_MODE: HVAC_MODE_AUTO},
blocking=True,
)
await hass.async_block_till_done()
state = hass.states.get(ENTITY_ID)
assert state is not None
assert state.state == STATE_UNAVAILABLE
async def test_send_command_device_unknown_error(hass, discovery, device, mock_now):
"""Test for sending power on command to the device with a device timeout."""
device().update_state.side_effect = [DEFAULT_MOCK, Exception]
device().push_state_update.side_effect = Exception
await async_setup_gree(hass)
next_update = mock_now + timedelta(minutes=5)
with patch("homeassistant.util.dt.utcnow", return_value=next_update):
async_fire_time_changed(hass, next_update)
await hass.async_block_till_done()
# First update to make the device available
state = hass.states.get(ENTITY_ID)
assert state.name == "fake-device-1"
assert state.state != STATE_UNAVAILABLE
assert await hass.services.async_call(
DOMAIN,
SERVICE_SET_HVAC_MODE,
{ATTR_ENTITY_ID: ENTITY_ID, ATTR_HVAC_MODE: HVAC_MODE_AUTO},
blocking=True,
)
state = hass.states.get(ENTITY_ID)
assert state is not None
assert state.state == STATE_UNAVAILABLE
async def test_send_power_on(hass, discovery, device, mock_now):
"""Test for sending power on command to the device."""
await async_setup_gree(hass)
next_update = mock_now + timedelta(minutes=5)
with patch("homeassistant.util.dt.utcnow", return_value=next_update):
async_fire_time_changed(hass, next_update)
await hass.async_block_till_done()
assert await hass.services.async_call(
DOMAIN,
SERVICE_SET_HVAC_MODE,
{ATTR_ENTITY_ID: ENTITY_ID, ATTR_HVAC_MODE: HVAC_MODE_AUTO},
blocking=True,
)
state = hass.states.get(ENTITY_ID)
assert state is not None
assert state.state == HVAC_MODE_AUTO
async def test_send_power_on_device_timeout(hass, discovery, device, mock_now):
"""Test for sending power on command to the device with a device timeout."""
device().push_state_update.side_effect = DeviceTimeoutError
await async_setup_gree(hass)
next_update = mock_now + timedelta(minutes=5)
with patch("homeassistant.util.dt.utcnow", return_value=next_update):
async_fire_time_changed(hass, next_update)
await hass.async_block_till_done()
assert await hass.services.async_call(
DOMAIN,
SERVICE_SET_HVAC_MODE,
{ATTR_ENTITY_ID: ENTITY_ID, ATTR_HVAC_MODE: HVAC_MODE_AUTO},
blocking=True,
)
state = hass.states.get(ENTITY_ID)
assert state is not None
assert state.state == HVAC_MODE_AUTO
async def test_send_target_temperature(hass, discovery, device, mock_now):
"""Test for sending target temperature command to the device."""
await async_setup_gree(hass)
next_update = mock_now + timedelta(minutes=5)
with patch("homeassistant.util.dt.utcnow", return_value=next_update):
async_fire_time_changed(hass, next_update)
await hass.async_block_till_done()
assert await hass.services.async_call(
DOMAIN,
SERVICE_SET_TEMPERATURE,
{ATTR_ENTITY_ID: ENTITY_ID, ATTR_TEMPERATURE: 25.1},
blocking=True,
)
state = hass.states.get(ENTITY_ID)
assert state is not None
assert state.attributes.get(ATTR_TEMPERATURE) == 25
async def test_send_target_temperature_device_timeout(
hass, discovery, device, mock_now
):
"""Test for sending target temperature command to the device with a device timeout."""
device().push_state_update.side_effect = DeviceTimeoutError
await async_setup_gree(hass)
next_update = mock_now + timedelta(minutes=5)
with patch("homeassistant.util.dt.utcnow", return_value=next_update):
async_fire_time_changed(hass, next_update)
await hass.async_block_till_done()
assert await hass.services.async_call(
DOMAIN,
SERVICE_SET_TEMPERATURE,
{ATTR_ENTITY_ID: ENTITY_ID, ATTR_TEMPERATURE: 25.1},
blocking=True,
)
state = hass.states.get(ENTITY_ID)
assert state is not None
assert state.attributes.get(ATTR_TEMPERATURE) == 25
async def test_update_target_temperature(hass, discovery, device, mock_now):
"""Test for updating target temperature from the device."""
device().target_temperature = 32
await async_setup_gree(hass)
next_update = mock_now + timedelta(minutes=5)
with patch("homeassistant.util.dt.utcnow", return_value=next_update):
async_fire_time_changed(hass, next_update)
await hass.async_block_till_done()
state = hass.states.get(ENTITY_ID)
assert state is not None
assert state.attributes.get(ATTR_TEMPERATURE) == 32
@pytest.mark.parametrize(
"preset", (PRESET_AWAY, PRESET_ECO, PRESET_SLEEP, PRESET_BOOST, PRESET_NONE)
)
async def test_send_preset_mode(hass, discovery, device, mock_now, preset):
"""Test for sending preset mode command to the device."""
await async_setup_gree(hass)
next_update = mock_now + timedelta(minutes=5)
with patch("homeassistant.util.dt.utcnow", return_value=next_update):
async_fire_time_changed(hass, next_update)
await hass.async_block_till_done()
assert await hass.services.async_call(
DOMAIN,
SERVICE_SET_PRESET_MODE,
{ATTR_ENTITY_ID: ENTITY_ID, ATTR_PRESET_MODE: preset},
blocking=True,
)
state = hass.states.get(ENTITY_ID)
assert state is not None
assert state.attributes.get(ATTR_PRESET_MODE) == preset
async def test_send_invalid_preset_mode(hass, discovery, device, mock_now):
"""Test for sending preset mode command to the device."""
await async_setup_gree(hass)
next_update = mock_now + timedelta(minutes=5)
with patch("homeassistant.util.dt.utcnow", return_value=next_update):
async_fire_time_changed(hass, next_update)
await hass.async_block_till_done()
with pytest.raises(ValueError):
await hass.services.async_call(
DOMAIN,
SERVICE_SET_PRESET_MODE,
{ATTR_ENTITY_ID: ENTITY_ID, ATTR_PRESET_MODE: "invalid"},
blocking=True,
)
state = hass.states.get(ENTITY_ID)
assert state is not None
assert state.attributes.get(ATTR_PRESET_MODE) != "invalid"
@pytest.mark.parametrize(
"preset", (PRESET_AWAY, PRESET_ECO, PRESET_SLEEP, PRESET_BOOST, PRESET_NONE)
)
async def test_send_preset_mode_device_timeout(
hass, discovery, device, mock_now, preset
):
"""Test for sending preset mode command to the device with a device timeout."""
device().push_state_update.side_effect = DeviceTimeoutError
await async_setup_gree(hass)
next_update = mock_now + timedelta(minutes=5)
with patch("homeassistant.util.dt.utcnow", return_value=next_update):
async_fire_time_changed(hass, next_update)
await hass.async_block_till_done()
assert await hass.services.async_call(
DOMAIN,
SERVICE_SET_PRESET_MODE,
{ATTR_ENTITY_ID: ENTITY_ID, ATTR_PRESET_MODE: preset},
blocking=True,
)
state = hass.states.get(ENTITY_ID)
assert state is not None
assert state.attributes.get(ATTR_PRESET_MODE) == preset
@pytest.mark.parametrize(
"preset", (PRESET_AWAY, PRESET_ECO, PRESET_SLEEP, PRESET_BOOST, PRESET_NONE)
)
async def test_update_preset_mode(hass, discovery, device, mock_now, preset):
"""Test for updating preset mode from the device."""
device().steady_heat = preset == PRESET_AWAY
device().power_save = preset == PRESET_ECO
device().sleep = preset == PRESET_SLEEP
device().turbo = preset == PRESET_BOOST
await async_setup_gree(hass)
next_update = mock_now + timedelta(minutes=5)
with patch("homeassistant.util.dt.utcnow", return_value=next_update):
async_fire_time_changed(hass, next_update)
await hass.async_block_till_done()
state = hass.states.get(ENTITY_ID)
assert state is not None
assert state.attributes.get(ATTR_PRESET_MODE) == preset
@pytest.mark.parametrize(
"hvac_mode",
(
HVAC_MODE_OFF,
HVAC_MODE_AUTO,
HVAC_MODE_COOL,
HVAC_MODE_DRY,
HVAC_MODE_FAN_ONLY,
HVAC_MODE_HEAT,
),
)
async def test_send_hvac_mode(hass, discovery, device, mock_now, hvac_mode):
"""Test for sending hvac mode command to the device."""
await async_setup_gree(hass)
next_update = mock_now + timedelta(minutes=5)
with patch("homeassistant.util.dt.utcnow", return_value=next_update):
async_fire_time_changed(hass, next_update)
await hass.async_block_till_done()
assert await hass.services.async_call(
DOMAIN,
SERVICE_SET_HVAC_MODE,
{ATTR_ENTITY_ID: ENTITY_ID, ATTR_HVAC_MODE: hvac_mode},
blocking=True,
)
state = hass.states.get(ENTITY_ID)
assert state is not None
assert state.state == hvac_mode
@pytest.mark.parametrize(
"hvac_mode",
(HVAC_MODE_AUTO, HVAC_MODE_COOL, HVAC_MODE_DRY, HVAC_MODE_FAN_ONLY, HVAC_MODE_HEAT),
)
async def test_send_hvac_mode_device_timeout(
hass, discovery, device, mock_now, hvac_mode
):
"""Test for sending hvac mode command to the device with a device timeout."""
device().push_state_update.side_effect = DeviceTimeoutError
await async_setup_gree(hass)
next_update = mock_now + timedelta(minutes=5)
with patch("homeassistant.util.dt.utcnow", return_value=next_update):
async_fire_time_changed(hass, next_update)
await hass.async_block_till_done()
assert await hass.services.async_call(
DOMAIN,
SERVICE_SET_HVAC_MODE,
{ATTR_ENTITY_ID: ENTITY_ID, ATTR_HVAC_MODE: hvac_mode},
blocking=True,
)
state = hass.states.get(ENTITY_ID)
assert state is not None
assert state.state == hvac_mode
@pytest.mark.parametrize(
"hvac_mode",
(
HVAC_MODE_OFF,
HVAC_MODE_AUTO,
HVAC_MODE_COOL,
HVAC_MODE_DRY,
HVAC_MODE_FAN_ONLY,
HVAC_MODE_HEAT,
),
)
async def test_update_hvac_mode(hass, discovery, device, mock_now, hvac_mode):
"""Test for updating hvac mode from the device."""
device().power = hvac_mode != HVAC_MODE_OFF
device().mode = HVAC_MODES_REVERSE.get(hvac_mode)
await async_setup_gree(hass)
next_update = mock_now + timedelta(minutes=5)
with patch("homeassistant.util.dt.utcnow", return_value=next_update):
async_fire_time_changed(hass, next_update)
await hass.async_block_till_done()
state = hass.states.get(ENTITY_ID)
assert state is not None
assert state.state == hvac_mode
@pytest.mark.parametrize(
"fan_mode",
(FAN_AUTO, FAN_LOW, FAN_MEDIUM_LOW, FAN_MEDIUM, FAN_MEDIUM_HIGH, FAN_HIGH),
)
async def test_send_fan_mode(hass, discovery, device, mock_now, fan_mode):
"""Test for sending fan mode command to the device."""
await async_setup_gree(hass)
next_update = mock_now + timedelta(minutes=5)
with patch("homeassistant.util.dt.utcnow", return_value=next_update):
async_fire_time_changed(hass, next_update)
await hass.async_block_till_done()
assert await hass.services.async_call(
DOMAIN,
SERVICE_SET_FAN_MODE,
{ATTR_ENTITY_ID: ENTITY_ID, ATTR_FAN_MODE: fan_mode},
blocking=True,
)
state = hass.states.get(ENTITY_ID)
assert state is not None
assert state.attributes.get(ATTR_FAN_MODE) == fan_mode
async def test_send_invalid_fan_mode(hass, discovery, device, mock_now):
"""Test for sending fan mode command to the device."""
await async_setup_gree(hass)
next_update = mock_now + timedelta(minutes=5)
with patch("homeassistant.util.dt.utcnow", return_value=next_update):
async_fire_time_changed(hass, next_update)
await hass.async_block_till_done()
with pytest.raises(ValueError):
await hass.services.async_call(
DOMAIN,
SERVICE_SET_FAN_MODE,
{ATTR_ENTITY_ID: ENTITY_ID, ATTR_FAN_MODE: "invalid"},
blocking=True,
)
state = hass.states.get(ENTITY_ID)
assert state is not None
assert state.attributes.get(ATTR_FAN_MODE) != "invalid"
@pytest.mark.parametrize(
"fan_mode",
(FAN_AUTO, FAN_LOW, FAN_MEDIUM_LOW, FAN_MEDIUM, FAN_MEDIUM_HIGH, FAN_HIGH),
)
async def test_send_fan_mode_device_timeout(
hass, discovery, device, mock_now, fan_mode
):
"""Test for sending fan mode command to the device with a device timeout."""
device().push_state_update.side_effect = DeviceTimeoutError
await async_setup_gree(hass)
next_update = mock_now + timedelta(minutes=5)
with patch("homeassistant.util.dt.utcnow", return_value=next_update):
async_fire_time_changed(hass, next_update)
await hass.async_block_till_done()
assert await hass.services.async_call(
DOMAIN,
SERVICE_SET_FAN_MODE,
{ATTR_ENTITY_ID: ENTITY_ID, ATTR_FAN_MODE: fan_mode},
blocking=True,
)
state = hass.states.get(ENTITY_ID)
assert state is not None
assert state.attributes.get(ATTR_FAN_MODE) == fan_mode
@pytest.mark.parametrize(
"fan_mode",
(FAN_AUTO, FAN_LOW, FAN_MEDIUM_LOW, FAN_MEDIUM, FAN_MEDIUM_HIGH, FAN_HIGH),
)
async def test_update_fan_mode(hass, discovery, device, mock_now, fan_mode):
"""Test for updating fan mode from the device."""
device().fan_speed = FAN_MODES_REVERSE.get(fan_mode)
await async_setup_gree(hass)
next_update = mock_now + timedelta(minutes=5)
with patch("homeassistant.util.dt.utcnow", return_value=next_update):
async_fire_time_changed(hass, next_update)
await hass.async_block_till_done()
state = hass.states.get(ENTITY_ID)
assert state is not None
assert state.attributes.get(ATTR_FAN_MODE) == fan_mode
@pytest.mark.parametrize(
"swing_mode", (SWING_OFF, SWING_BOTH, SWING_VERTICAL, SWING_HORIZONTAL)
)
async def test_send_swing_mode(hass, discovery, device, mock_now, swing_mode):
"""Test for sending swing mode command to the device."""
await async_setup_gree(hass)
next_update = mock_now + timedelta(minutes=5)
with patch("homeassistant.util.dt.utcnow", return_value=next_update):
async_fire_time_changed(hass, next_update)
await hass.async_block_till_done()
assert await hass.services.async_call(
DOMAIN,
SERVICE_SET_SWING_MODE,
{ATTR_ENTITY_ID: ENTITY_ID, ATTR_SWING_MODE: swing_mode},
blocking=True,
)
state = hass.states.get(ENTITY_ID)
assert state is not None
assert state.attributes.get(ATTR_SWING_MODE) == swing_mode
async def test_send_invalid_swing_mode(hass, discovery, device, mock_now):
"""Test for sending swing mode command to the device."""
await async_setup_gree(hass)
next_update = mock_now + timedelta(minutes=5)
with patch("homeassistant.util.dt.utcnow", return_value=next_update):
async_fire_time_changed(hass, next_update)
await hass.async_block_till_done()
with pytest.raises(ValueError):
await hass.services.async_call(
DOMAIN,
SERVICE_SET_SWING_MODE,
{ATTR_ENTITY_ID: ENTITY_ID, ATTR_SWING_MODE: "invalid"},
blocking=True,
)
state = hass.states.get(ENTITY_ID)
assert state is not None
assert state.attributes.get(ATTR_SWING_MODE) != "invalid"
@pytest.mark.parametrize(
"swing_mode", (SWING_OFF, SWING_BOTH, SWING_VERTICAL, SWING_HORIZONTAL)
)
async def test_send_swing_mode_device_timeout(
hass, discovery, device, mock_now, swing_mode
):
"""Test for sending swing mode command to the device with a device timeout."""
device().push_state_update.side_effect = DeviceTimeoutError
await async_setup_gree(hass)
next_update = mock_now + timedelta(minutes=5)
with patch("homeassistant.util.dt.utcnow", return_value=next_update):
async_fire_time_changed(hass, next_update)
await hass.async_block_till_done()
assert await hass.services.async_call(
DOMAIN,
SERVICE_SET_SWING_MODE,
{ATTR_ENTITY_ID: ENTITY_ID, ATTR_SWING_MODE: swing_mode},
blocking=True,
)
state = hass.states.get(ENTITY_ID)
assert state is not None
assert state.attributes.get(ATTR_SWING_MODE) == swing_mode
@pytest.mark.parametrize(
"swing_mode", (SWING_OFF, SWING_BOTH, SWING_VERTICAL, SWING_HORIZONTAL)
)
async def test_update_swing_mode(hass, discovery, device, mock_now, swing_mode):
"""Test for updating swing mode from the device."""
device().horizontal_swing = (
HorizontalSwing.FullSwing
if swing_mode in (SWING_BOTH, SWING_HORIZONTAL)
else HorizontalSwing.Default
)
device().vertical_swing = (
VerticalSwing.FullSwing
if swing_mode in (SWING_BOTH, SWING_VERTICAL)
else VerticalSwing.Default
)
await async_setup_gree(hass)
next_update = mock_now + timedelta(minutes=5)
with patch("homeassistant.util.dt.utcnow", return_value=next_update):
async_fire_time_changed(hass, next_update)
await hass.async_block_till_done()
state = hass.states.get(ENTITY_ID)
assert state is not None
assert state.attributes.get(ATTR_SWING_MODE) == swing_mode
async def test_name(hass, discovery, device):
"""Test for name property."""
await async_setup_gree(hass)
state = hass.states.get(ENTITY_ID)
assert state.attributes[ATTR_FRIENDLY_NAME] == "fake-device-1"
async def test_supported_features_with_turnon(hass, discovery, device):
"""Test for supported_features property."""
await async_setup_gree(hass)
state = hass.states.get(ENTITY_ID)
assert state.attributes[ATTR_SUPPORTED_FEATURES] == SUPPORTED_FEATURES

View File

@ -0,0 +1,20 @@
"""Tests for the Gree Integration."""
from homeassistant import config_entries, data_entry_flow
from homeassistant.components.gree.const import DOMAIN as GREE_DOMAIN
async def test_creating_entry_sets_up_climate(hass, discovery, device, setup):
"""Test setting up Gree creates the climate components."""
result = await hass.config_entries.flow.async_init(
GREE_DOMAIN, context={"source": config_entries.SOURCE_USER}
)
# Confirmation form
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
result = await hass.config_entries.flow.async_configure(result["flow_id"], {})
assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
await hass.async_block_till_done()
assert len(setup.mock_calls) == 1

View File

@ -0,0 +1,38 @@
"""Tests for the Gree Integration."""
from homeassistant.components.gree.const import DOMAIN as GREE_DOMAIN
from homeassistant.config_entries import ENTRY_STATE_LOADED, ENTRY_STATE_NOT_LOADED
from homeassistant.setup import async_setup_component
from tests.async_mock import patch
from tests.common import MockConfigEntry
async def test_setup_simple(hass):
"""Test gree integration is setup."""
await async_setup_component(hass, GREE_DOMAIN, {})
await hass.async_block_till_done()
# No flows started
assert len(hass.config_entries.flow.async_progress()) == 0
async def test_unload_config_entry(hass):
"""Test that the async_unload_entry works."""
# As we have currently no configuration, we just to pass the domain here.
entry = MockConfigEntry(domain=GREE_DOMAIN)
entry.add_to_hass(hass)
with patch(
"homeassistant.components.gree.climate.async_setup_entry",
return_value=True,
) as climate_setup:
assert await async_setup_component(hass, GREE_DOMAIN, {})
await hass.async_block_till_done()
assert len(climate_setup.mock_calls) == 1
assert entry.state == ENTRY_STATE_LOADED
await hass.config_entries.async_unload(entry.entry_id)
assert entry.state == ENTRY_STATE_NOT_LOADED