Simplify maxcube integration (#48403)

* Simplify maxcube integration

Device objects returned by maxcube-api dependency are stable, so
we do not need to resolve from the device address every time.

Also, refactor and unify how maxcube integration sets temperature & mode.

* Raise ValueError if missing parameters for set_temperature method

Raise a ValueError exception If set_temperature does not receive
a temperature parameter.

Also, document properly _set_target method.

* Use Type | None instead of Optional[Type] annotation

* Protect set_hvac_mode and set_preset_mode from unsupported parameters
This commit is contained in:
Unai 2021-03-28 00:21:20 +01:00 committed by GitHub
parent ffdfc521b9
commit 0706ae70dc
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 134 additions and 129 deletions

View File

@ -11,13 +11,10 @@ def setup_platform(hass, config, add_entities, discovery_info=None):
"""Iterate through all MAX! Devices and add window shutters.""" """Iterate through all MAX! Devices and add window shutters."""
devices = [] devices = []
for handler in hass.data[DATA_KEY].values(): for handler in hass.data[DATA_KEY].values():
cube = handler.cube for device in handler.cube.devices:
for device in cube.devices:
name = f"{cube.room_by_id(device.room_id).name} {device.name}"
# Only add Window Shutters # Only add Window Shutters
if device.is_windowshutter(): if device.is_windowshutter():
devices.append(MaxCubeShutter(handler, name, device.rf_address)) devices.append(MaxCubeShutter(handler, device))
if devices: if devices:
add_entities(devices) add_entities(devices)
@ -26,13 +23,12 @@ def setup_platform(hass, config, add_entities, discovery_info=None):
class MaxCubeShutter(BinarySensorEntity): class MaxCubeShutter(BinarySensorEntity):
"""Representation of a MAX! Cube Binary Sensor device.""" """Representation of a MAX! Cube Binary Sensor device."""
def __init__(self, handler, name, rf_address): def __init__(self, handler, device):
"""Initialize MAX! Cube BinarySensorEntity.""" """Initialize MAX! Cube BinarySensorEntity."""
self._name = name room = handler.cube.room_by_id(device.room_id)
self._sensor_type = DEVICE_CLASS_WINDOW self._name = f"{room.name} {device.name}"
self._rf_address = rf_address
self._cubehandle = handler self._cubehandle = handler
self._state = None self._device = device
@property @property
def name(self): def name(self):
@ -42,15 +38,13 @@ class MaxCubeShutter(BinarySensorEntity):
@property @property
def device_class(self): def device_class(self):
"""Return the class of this sensor.""" """Return the class of this sensor."""
return self._sensor_type return DEVICE_CLASS_WINDOW
@property @property
def is_on(self): def is_on(self):
"""Return true if the binary sensor is on/open.""" """Return true if the binary sensor is on/open."""
return self._state return self._device.is_open
def update(self): def update(self):
"""Get latest data from MAX! Cube.""" """Get latest data from MAX! Cube."""
self._cubehandle.update() self._cubehandle.update()
device = self._cubehandle.cube.device_by_rf(self._rf_address)
self._state = device.is_open

View File

@ -1,4 +1,6 @@
"""Support for MAX! Thermostats via MAX! Cube.""" """Support for MAX! Thermostats via MAX! Cube."""
from __future__ import annotations
import logging import logging
import socket import socket
@ -47,31 +49,14 @@ MAX_TEMPERATURE = 30.0
SUPPORT_FLAGS = SUPPORT_TARGET_TEMPERATURE | SUPPORT_PRESET_MODE SUPPORT_FLAGS = SUPPORT_TARGET_TEMPERATURE | SUPPORT_PRESET_MODE
HASS_PRESET_TO_MAX_MODE = {
PRESET_AWAY: MAX_DEVICE_MODE_VACATION,
PRESET_BOOST: MAX_DEVICE_MODE_BOOST,
PRESET_NONE: MAX_DEVICE_MODE_AUTOMATIC,
PRESET_ON: MAX_DEVICE_MODE_MANUAL,
}
MAX_MODE_TO_HASS_PRESET = {
MAX_DEVICE_MODE_AUTOMATIC: PRESET_NONE,
MAX_DEVICE_MODE_BOOST: PRESET_BOOST,
MAX_DEVICE_MODE_MANUAL: PRESET_NONE,
MAX_DEVICE_MODE_VACATION: PRESET_AWAY,
}
def setup_platform(hass, config, add_entities, discovery_info=None): def setup_platform(hass, config, add_entities, discovery_info=None):
"""Iterate through all MAX! Devices and add thermostats.""" """Iterate through all MAX! Devices and add thermostats."""
devices = [] devices = []
for handler in hass.data[DATA_KEY].values(): for handler in hass.data[DATA_KEY].values():
cube = handler.cube for device in handler.cube.devices:
for device in cube.devices:
name = f"{cube.room_by_id(device.room_id).name} {device.name}"
if device.is_thermostat() or device.is_wallthermostat(): if device.is_thermostat() or device.is_wallthermostat():
devices.append(MaxCubeClimate(handler, name, device.rf_address)) devices.append(MaxCubeClimate(handler, device))
if devices: if devices:
add_entities(devices) add_entities(devices)
@ -80,11 +65,12 @@ def setup_platform(hass, config, add_entities, discovery_info=None):
class MaxCubeClimate(ClimateEntity): class MaxCubeClimate(ClimateEntity):
"""MAX! Cube ClimateEntity.""" """MAX! Cube ClimateEntity."""
def __init__(self, handler, name, rf_address): def __init__(self, handler, device):
"""Initialize MAX! Cube ClimateEntity.""" """Initialize MAX! Cube ClimateEntity."""
self._name = name room = handler.cube.room_by_id(device.room_id)
self._rf_address = rf_address self._name = f"{room.name} {device.name}"
self._cubehandle = handler self._cubehandle = handler
self._device = device
@property @property
def supported_features(self): def supported_features(self):
@ -104,20 +90,15 @@ class MaxCubeClimate(ClimateEntity):
@property @property
def min_temp(self): def min_temp(self):
"""Return the minimum temperature.""" """Return the minimum temperature."""
device = self._cubehandle.cube.device_by_rf(self._rf_address) temp = self._device.min_temperature or MIN_TEMPERATURE
if device.min_temperature is None:
return MIN_TEMPERATURE
# OFF_TEMPERATURE (always off) a is valid temperature to maxcube but not to Home Assistant. # OFF_TEMPERATURE (always off) a is valid temperature to maxcube but not to Home Assistant.
# We use HVAC_MODE_OFF instead to represent a turned off thermostat. # We use HVAC_MODE_OFF instead to represent a turned off thermostat.
return max(device.min_temperature, MIN_TEMPERATURE) return max(temp, MIN_TEMPERATURE)
@property @property
def max_temp(self): def max_temp(self):
"""Return the maximum temperature.""" """Return the maximum temperature."""
device = self._cubehandle.cube.device_by_rf(self._rf_address) return self._device.max_temperature or MAX_TEMPERATURE
if device.max_temperature is None:
return MAX_TEMPERATURE
return device.max_temperature
@property @property
def temperature_unit(self): def temperature_unit(self):
@ -127,18 +108,17 @@ class MaxCubeClimate(ClimateEntity):
@property @property
def current_temperature(self): def current_temperature(self):
"""Return the current temperature.""" """Return the current temperature."""
device = self._cubehandle.cube.device_by_rf(self._rf_address) return self._device.actual_temperature
return device.actual_temperature
@property @property
def hvac_mode(self): def hvac_mode(self):
"""Return current operation mode.""" """Return current operation mode."""
device = self._cubehandle.cube.device_by_rf(self._rf_address) mode = self._device.mode
if device.mode in [MAX_DEVICE_MODE_AUTOMATIC, MAX_DEVICE_MODE_BOOST]: if mode in [MAX_DEVICE_MODE_AUTOMATIC, MAX_DEVICE_MODE_BOOST]:
return HVAC_MODE_AUTO return HVAC_MODE_AUTO
if ( if (
device.mode == MAX_DEVICE_MODE_MANUAL mode == MAX_DEVICE_MODE_MANUAL
and device.target_temperature == OFF_TEMPERATURE and self._device.target_temperature == OFF_TEMPERATURE
): ):
return HVAC_MODE_OFF return HVAC_MODE_OFF
@ -151,37 +131,46 @@ class MaxCubeClimate(ClimateEntity):
def set_hvac_mode(self, hvac_mode: str): def set_hvac_mode(self, hvac_mode: str):
"""Set new target hvac mode.""" """Set new target hvac mode."""
device = self._cubehandle.cube.device_by_rf(self._rf_address)
temp = device.target_temperature
mode = MAX_DEVICE_MODE_MANUAL
if hvac_mode == HVAC_MODE_OFF: if hvac_mode == HVAC_MODE_OFF:
temp = OFF_TEMPERATURE self._set_target(MAX_DEVICE_MODE_MANUAL, OFF_TEMPERATURE)
elif hvac_mode == HVAC_MODE_HEAT: elif hvac_mode == HVAC_MODE_HEAT:
temp = max(temp, self.min_temp) temp = max(self._device.target_temperature, self.min_temp)
self._set_target(MAX_DEVICE_MODE_MANUAL, temp)
elif hvac_mode == HVAC_MODE_AUTO:
self._set_target(MAX_DEVICE_MODE_AUTOMATIC, None)
else: else:
temp = None raise ValueError(f"unsupported HVAC mode {hvac_mode}")
mode = MAX_DEVICE_MODE_AUTOMATIC
cube = self._cubehandle.cube def _set_target(self, mode: int | None, temp: float | None) -> None:
"""
Set the mode and/or temperature of the thermostat.
@param mode: this is the mode to change to.
@param temp: the temperature to target.
Both parameters are optional. When mode is undefined, it keeps
the previous mode. When temp is undefined, it fetches the
temperature from the weekly schedule when mode is
MAX_DEVICE_MODE_AUTOMATIC and keeps the previous
temperature otherwise.
"""
with self._cubehandle.mutex: with self._cubehandle.mutex:
try: try:
cube.set_temperature_mode(device, temp, mode) self._cubehandle.cube.set_temperature_mode(self._device, temp, mode)
except (socket.timeout, OSError): except (socket.timeout, OSError):
_LOGGER.error("Setting HVAC mode failed") _LOGGER.error("Setting HVAC mode failed")
return
@property @property
def hvac_action(self): def hvac_action(self):
"""Return the current running hvac operation if supported.""" """Return the current running hvac operation if supported."""
cube = self._cubehandle.cube
device = cube.device_by_rf(self._rf_address)
valve = 0 valve = 0
if device.is_thermostat(): if self._device.is_thermostat():
valve = device.valve_position valve = self._device.valve_position
elif device.is_wallthermostat(): elif self._device.is_wallthermostat():
for device in cube.devices_by_room(cube.room_by_id(device.room_id)): cube = self._cubehandle.cube
room = cube.room_by_id(self._device.room_id)
for device in cube.devices_by_room(room):
if device.is_thermostat() and device.valve_position > 0: if device.is_thermostat() and device.valve_position > 0:
valve = device.valve_position valve = device.valve_position
break break
@ -199,50 +188,36 @@ class MaxCubeClimate(ClimateEntity):
@property @property
def target_temperature(self): def target_temperature(self):
"""Return the temperature we try to reach.""" """Return the temperature we try to reach."""
device = self._cubehandle.cube.device_by_rf(self._rf_address) temp = self._device.target_temperature
if ( if temp is None or temp < self.min_temp or temp > self.max_temp:
device.target_temperature is None
or device.target_temperature < self.min_temp
or device.target_temperature > self.max_temp
):
return None return None
return device.target_temperature return temp
def set_temperature(self, **kwargs): def set_temperature(self, **kwargs):
"""Set new target temperatures.""" """Set new target temperatures."""
if kwargs.get(ATTR_TEMPERATURE) is None: temp = kwargs.get(ATTR_TEMPERATURE)
return False if temp is None:
raise ValueError(
target_temperature = kwargs.get(ATTR_TEMPERATURE) f"No {ATTR_TEMPERATURE} parameter passed to set_temperature method."
device = self._cubehandle.cube.device_by_rf(self._rf_address) )
self._set_target(None, temp)
cube = self._cubehandle.cube
with self._cubehandle.mutex:
try:
cube.set_target_temperature(device, target_temperature)
except (socket.timeout, OSError):
_LOGGER.error("Setting target temperature failed")
return False
@property @property
def preset_mode(self): def preset_mode(self):
"""Return the current preset mode.""" """Return the current preset mode."""
device = self._cubehandle.cube.device_by_rf(self._rf_address) if self._device.mode == MAX_DEVICE_MODE_MANUAL:
if self.hvac_mode == HVAC_MODE_OFF: if self._device.target_temperature == self._device.comfort_temperature:
return PRESET_NONE
if device.mode == MAX_DEVICE_MODE_MANUAL:
if device.target_temperature == device.comfort_temperature:
return PRESET_COMFORT return PRESET_COMFORT
if device.target_temperature == device.eco_temperature: if self._device.target_temperature == self._device.eco_temperature:
return PRESET_ECO return PRESET_ECO
if device.target_temperature == ON_TEMPERATURE: if self._device.target_temperature == ON_TEMPERATURE:
return PRESET_ON return PRESET_ON
elif self._device.mode == MAX_DEVICE_MODE_BOOST:
return PRESET_BOOST
elif self._device.mode == MAX_DEVICE_MODE_VACATION:
return PRESET_AWAY
return PRESET_NONE return PRESET_NONE
return MAX_MODE_TO_HASS_PRESET[device.mode]
@property @property
def preset_modes(self): def preset_modes(self):
"""Return available preset modes.""" """Return available preset modes."""
@ -257,37 +232,27 @@ class MaxCubeClimate(ClimateEntity):
def set_preset_mode(self, preset_mode): def set_preset_mode(self, preset_mode):
"""Set new operation mode.""" """Set new operation mode."""
device = self._cubehandle.cube.device_by_rf(self._rf_address)
temp = None
mode = MAX_DEVICE_MODE_AUTOMATIC
if preset_mode in [PRESET_COMFORT, PRESET_ECO, PRESET_ON]:
mode = MAX_DEVICE_MODE_MANUAL
if preset_mode == PRESET_COMFORT: if preset_mode == PRESET_COMFORT:
temp = device.comfort_temperature self._set_target(MAX_DEVICE_MODE_MANUAL, self._device.comfort_temperature)
elif preset_mode == PRESET_ECO: elif preset_mode == PRESET_ECO:
temp = device.eco_temperature self._set_target(MAX_DEVICE_MODE_MANUAL, self._device.eco_temperature)
elif preset_mode == PRESET_ON:
self._set_target(MAX_DEVICE_MODE_MANUAL, ON_TEMPERATURE)
elif preset_mode == PRESET_AWAY:
self._set_target(MAX_DEVICE_MODE_VACATION, None)
elif preset_mode == PRESET_BOOST:
self._set_target(MAX_DEVICE_MODE_BOOST, None)
elif preset_mode == PRESET_NONE:
self._set_target(MAX_DEVICE_MODE_AUTOMATIC, None)
else: else:
temp = ON_TEMPERATURE raise ValueError(f"unsupported preset mode {preset_mode}")
else:
mode = HASS_PRESET_TO_MAX_MODE[preset_mode] or MAX_DEVICE_MODE_AUTOMATIC
with self._cubehandle.mutex:
try:
self._cubehandle.cube.set_temperature_mode(device, temp, mode)
except (socket.timeout, OSError):
_LOGGER.error("Setting operation mode failed")
return
@property @property
def extra_state_attributes(self): def extra_state_attributes(self):
"""Return the optional state attributes.""" """Return the optional state attributes."""
cube = self._cubehandle.cube if not self._device.is_thermostat():
device = cube.device_by_rf(self._rf_address)
if not device.is_thermostat():
return {} return {}
return {ATTR_VALVE_POSITION: device.valve_position} return {ATTR_VALVE_POSITION: self._device.valve_position}
def update(self): def update(self):
"""Get latest data from MAX! Cube.""" """Get latest data from MAX! Cube."""

View File

@ -102,7 +102,6 @@ async def cube(hass, hass_config, room, thermostat, wallthermostat, windowshutte
cube.devices = [thermostat, wallthermostat, windowshutter] cube.devices = [thermostat, wallthermostat, windowshutter]
cube.room_by_id.return_value = room cube.room_by_id.return_value = room
cube.devices_by_room.return_value = [thermostat, wallthermostat, windowshutter] cube.devices_by_room.return_value = [thermostat, wallthermostat, windowshutter]
cube.device_by_rf.side_effect = {d.rf_address: d for d in cube.devices}.get
assert await async_setup_component(hass, DOMAIN, hass_config) assert await async_setup_component(hass, DOMAIN, hass_config)
await hass.async_block_till_done() await hass.async_block_till_done()
gateway = hass_config[DOMAIN]["gateways"][0] gateway = hass_config[DOMAIN]["gateways"][0]

View File

@ -20,9 +20,6 @@ ENTITY_ID = "binary_sensor.testroom_testshutter"
async def test_window_shuttler(hass, cube: MaxCube, windowshutter: MaxWindowShutter): async def test_window_shuttler(hass, cube: MaxCube, windowshutter: MaxWindowShutter):
"""Test a successful setup with a shuttler device.""" """Test a successful setup with a shuttler device."""
async_fire_time_changed(hass, utcnow() + timedelta(minutes=5))
await hass.async_block_till_done()
state = hass.states.get(ENTITY_ID) state = hass.states.get(ENTITY_ID)
assert state is not None assert state is not None
assert state.state == STATE_ON assert state.state == STATE_ON

View File

@ -10,6 +10,7 @@ from maxcube.device import (
) )
from maxcube.thermostat import MaxThermostat from maxcube.thermostat import MaxThermostat
from maxcube.wallthermostat import MaxWallThermostat from maxcube.wallthermostat import MaxWallThermostat
import pytest
from homeassistant.components.climate.const import ( from homeassistant.components.climate.const import (
ATTR_CURRENT_TEMPERATURE, ATTR_CURRENT_TEMPERATURE,
@ -20,11 +21,14 @@ from homeassistant.components.climate.const import (
ATTR_MIN_TEMP, ATTR_MIN_TEMP,
ATTR_PRESET_MODE, ATTR_PRESET_MODE,
ATTR_PRESET_MODES, ATTR_PRESET_MODES,
ATTR_TARGET_TEMP_HIGH,
ATTR_TARGET_TEMP_LOW,
CURRENT_HVAC_HEAT, CURRENT_HVAC_HEAT,
CURRENT_HVAC_IDLE, CURRENT_HVAC_IDLE,
CURRENT_HVAC_OFF, CURRENT_HVAC_OFF,
DOMAIN as CLIMATE_DOMAIN, DOMAIN as CLIMATE_DOMAIN,
HVAC_MODE_AUTO, HVAC_MODE_AUTO,
HVAC_MODE_DRY,
HVAC_MODE_HEAT, HVAC_MODE_HEAT,
HVAC_MODE_OFF, HVAC_MODE_OFF,
PRESET_AWAY, PRESET_AWAY,
@ -155,6 +159,20 @@ async def test_thermostat_set_hvac_mode_heat(
assert state.state == HVAC_MODE_HEAT assert state.state == HVAC_MODE_HEAT
async def test_thermostat_set_invalid_hvac_mode(
hass, cube: MaxCube, thermostat: MaxThermostat
):
"""Set hvac mode to heat."""
with pytest.raises(ValueError):
await hass.services.async_call(
CLIMATE_DOMAIN,
SERVICE_SET_HVAC_MODE,
{ATTR_ENTITY_ID: ENTITY_ID, ATTR_HVAC_MODE: HVAC_MODE_DRY},
blocking=True,
)
cube.set_temperature_mode.assert_not_called()
async def test_thermostat_set_temperature( async def test_thermostat_set_temperature(
hass, cube: MaxCube, thermostat: MaxThermostat hass, cube: MaxCube, thermostat: MaxThermostat
): ):
@ -165,7 +183,7 @@ async def test_thermostat_set_temperature(
{ATTR_ENTITY_ID: ENTITY_ID, ATTR_TEMPERATURE: 10.0}, {ATTR_ENTITY_ID: ENTITY_ID, ATTR_TEMPERATURE: 10.0},
blocking=True, blocking=True,
) )
cube.set_target_temperature.assert_called_once_with(thermostat, 10.0) cube.set_temperature_mode.assert_called_once_with(thermostat, 10.0, None)
thermostat.target_temperature = 10.0 thermostat.target_temperature = 10.0
thermostat.valve_position = 0 thermostat.valve_position = 0
@ -178,6 +196,24 @@ async def test_thermostat_set_temperature(
assert state.attributes.get(ATTR_HVAC_ACTION) == CURRENT_HVAC_IDLE assert state.attributes.get(ATTR_HVAC_ACTION) == CURRENT_HVAC_IDLE
async def test_thermostat_set_no_temperature(
hass, cube: MaxCube, thermostat: MaxThermostat
):
"""Set hvac mode to heat."""
with pytest.raises(ValueError):
await hass.services.async_call(
CLIMATE_DOMAIN,
SERVICE_SET_TEMPERATURE,
{
ATTR_ENTITY_ID: ENTITY_ID,
ATTR_TARGET_TEMP_HIGH: 29.0,
ATTR_TARGET_TEMP_LOW: 10.0,
},
blocking=True,
)
cube.set_temperature_mode.assert_not_called()
async def test_thermostat_set_preset_on(hass, cube: MaxCube, thermostat: MaxThermostat): async def test_thermostat_set_preset_on(hass, cube: MaxCube, thermostat: MaxThermostat):
"""Set preset mode to on.""" """Set preset mode to on."""
await hass.services.async_call( await hass.services.async_call(
@ -317,6 +353,20 @@ async def test_thermostat_set_preset_none(
) )
async def test_thermostat_set_invalid_preset(
hass, cube: MaxCube, thermostat: MaxThermostat
):
"""Set hvac mode to heat."""
with pytest.raises(ValueError):
await hass.services.async_call(
CLIMATE_DOMAIN,
SERVICE_SET_PRESET_MODE,
{ATTR_ENTITY_ID: ENTITY_ID, ATTR_PRESET_MODE: "invalid"},
blocking=True,
)
cube.set_temperature_mode.assert_not_called()
async def test_wallthermostat_set_hvac_mode_heat( async def test_wallthermostat_set_hvac_mode_heat(
hass, cube: MaxCube, wallthermostat: MaxWallThermostat hass, cube: MaxCube, wallthermostat: MaxWallThermostat
): ):