From ae7eb9cef9c718e448d95b94fd4e1f62e3335292 Mon Sep 17 00:00:00 2001 From: Avishay Date: Thu, 20 Oct 2022 14:15:30 +0300 Subject: [PATCH] Add mode control for Modbus climate entities (#73906) * Add support for Modbus HVAC control registers --- homeassistant/components/modbus/__init__.py | 19 ++ homeassistant/components/modbus/climate.py | 94 ++++++- homeassistant/components/modbus/const.py | 3 + tests/components/modbus/test_climate.py | 257 ++++++++++++++++++-- 4 files changed, 351 insertions(+), 22 deletions(-) diff --git a/homeassistant/components/modbus/__init__.py b/homeassistant/components/modbus/__init__.py index 08834177e4e..8aa2903506f 100644 --- a/homeassistant/components/modbus/__init__.py +++ b/homeassistant/components/modbus/__init__.py @@ -9,6 +9,7 @@ import voluptuous as vol from homeassistant.components.binary_sensor import ( DEVICE_CLASSES_SCHEMA as BINARY_SENSOR_DEVICE_CLASSES_SCHEMA, ) +from homeassistant.components.climate import HVACMode from homeassistant.components.cover import ( DEVICE_CLASSES_SCHEMA as COVER_DEVICE_CLASSES_SCHEMA, ) @@ -65,6 +66,9 @@ from .const import ( # noqa: F401 CONF_DATA_TYPE, CONF_FANS, CONF_HUB, + CONF_HVAC_MODE_REGISTER, + CONF_HVAC_MODE_VALUES, + CONF_HVAC_ONOFF_REGISTER, CONF_INPUT_TYPE, CONF_LAZY_ERROR, CONF_MAX_TEMP, @@ -218,6 +222,21 @@ CLIMATE_SCHEMA = vol.All( vol.Optional(CONF_MIN_TEMP, default=5): cv.positive_int, vol.Optional(CONF_STEP, default=0.5): vol.Coerce(float), vol.Optional(CONF_TEMPERATURE_UNIT, default=DEFAULT_TEMP_UNIT): cv.string, + vol.Optional(CONF_HVAC_ONOFF_REGISTER): cv.positive_int, + vol.Optional(CONF_HVAC_MODE_REGISTER): vol.Maybe( + { + CONF_ADDRESS: cv.positive_int, + CONF_HVAC_MODE_VALUES: { + vol.Optional(HVACMode.OFF.value): cv.positive_int, + vol.Optional(HVACMode.HEAT.value): cv.positive_int, + vol.Optional(HVACMode.COOL.value): cv.positive_int, + vol.Optional(HVACMode.HEAT_COOL.value): cv.positive_int, + vol.Optional(HVACMode.AUTO.value): cv.positive_int, + vol.Optional(HVACMode.DRY.value): cv.positive_int, + vol.Optional(HVACMode.FAN_ONLY.value): cv.positive_int, + }, + } + ), } ), ) diff --git a/homeassistant/components/modbus/climate.py b/homeassistant/components/modbus/climate.py index 7d6376a5a42..92efcfb17d5 100644 --- a/homeassistant/components/modbus/climate.py +++ b/homeassistant/components/modbus/climate.py @@ -3,7 +3,7 @@ from __future__ import annotations from datetime import datetime import struct -from typing import Any +from typing import Any, cast from homeassistant.components.climate import ( ClimateEntity, @@ -12,6 +12,7 @@ from homeassistant.components.climate import ( ) from homeassistant.const import ( ATTR_TEMPERATURE, + CONF_ADDRESS, CONF_NAME, CONF_TEMPERATURE_UNIT, PRECISION_TENTHS, @@ -31,6 +32,9 @@ from .const import ( CALL_TYPE_WRITE_REGISTER, CALL_TYPE_WRITE_REGISTERS, CONF_CLIMATES, + CONF_HVAC_MODE_REGISTER, + CONF_HVAC_MODE_VALUES, + CONF_HVAC_ONOFF_REGISTER, CONF_MAX_TEMP, CONF_MIN_TEMP, CONF_STEP, @@ -63,8 +67,6 @@ async def async_setup_platform( class ModbusThermostat(BaseStructPlatform, RestoreEntity, ClimateEntity): """Representation of a Modbus Thermostat.""" - _attr_hvac_mode = HVACMode.AUTO - _attr_hvac_modes = [HVACMode.AUTO] _attr_supported_features = ClimateEntityFeature.TARGET_TEMPERATURE def __init__( @@ -90,6 +92,33 @@ class ModbusThermostat(BaseStructPlatform, RestoreEntity, ClimateEntity): self._attr_target_temperature_step = config[CONF_TARGET_TEMP] self._attr_target_temperature_step = config[CONF_STEP] + if CONF_HVAC_MODE_REGISTER in config: + mode_config = config[CONF_HVAC_MODE_REGISTER] + self._hvac_mode_register = mode_config[CONF_ADDRESS] + self._attr_hvac_modes = cast(list[HVACMode], []) + self._attr_hvac_mode = None + self._hvac_mode_mapping: list[tuple[int, HVACMode]] = [] + mode_value_config = mode_config[CONF_HVAC_MODE_VALUES] + for hvac_mode in HVACMode: + if hvac_mode.value in mode_value_config: + self._hvac_mode_mapping.append( + (mode_value_config[hvac_mode.value], hvac_mode) + ) + self._attr_hvac_modes.append(hvac_mode) + + else: + # No HVAC modes defined + self._hvac_mode_register = None + self._attr_hvac_mode = HVACMode.AUTO + self._attr_hvac_modes = [HVACMode.AUTO] + + if CONF_HVAC_ONOFF_REGISTER in config: + self._hvac_onoff_register = config[CONF_HVAC_ONOFF_REGISTER] + if HVACMode.OFF not in self._attr_hvac_modes: + self._attr_hvac_modes.append(HVACMode.OFF) + else: + self._hvac_onoff_register = None + async def async_added_to_hass(self) -> None: """Handle entity which will be added.""" await self.async_base_added_to_hass() @@ -99,8 +128,28 @@ class ModbusThermostat(BaseStructPlatform, RestoreEntity, ClimateEntity): async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: """Set new target hvac mode.""" - # Home Assistant expects this method. - # We'll keep it here to avoid getting exceptions. + if self._hvac_onoff_register is not None: + # Turn HVAC Off by writing 0 to the On/Off register, or 1 otherwise. + await self._hub.async_pymodbus_call( + self._slave, + self._hvac_onoff_register, + 0 if hvac_mode == HVACMode.OFF else 1, + CALL_TYPE_WRITE_REGISTER, + ) + + if self._hvac_mode_register is not None: + # Write a value to the mode register for the desired mode. + for value, mode in self._hvac_mode_mapping: + if mode == hvac_mode: + await self._hub.async_pymodbus_call( + self._slave, + self._hvac_mode_register, + value, + CALL_TYPE_WRITE_REGISTER, + ) + break + + await self.async_update() async def async_set_temperature(self, **kwargs: Any) -> None: """Set new target temperature.""" @@ -158,11 +207,36 @@ class ModbusThermostat(BaseStructPlatform, RestoreEntity, ClimateEntity): self._attr_current_temperature = await self._async_read_register( self._input_type, self._address ) + + # Read the mode register if defined + if self._hvac_mode_register is not None: + hvac_mode = await self._async_read_register( + CALL_TYPE_REGISTER_HOLDING, self._hvac_mode_register, raw=True + ) + + # Translate the value received + if hvac_mode is not None: + self._attr_hvac_mode = None + for value, mode in self._hvac_mode_mapping: + if hvac_mode == value: + self._attr_hvac_mode = mode + break + + # Read th on/off register if defined. If the value in this + # register is "OFF", it will take precedence over the value + # in the mode register. + if self._hvac_onoff_register is not None: + onoff = await self._async_read_register( + CALL_TYPE_REGISTER_HOLDING, self._hvac_onoff_register, raw=True + ) + if onoff == 0: + self._attr_hvac_mode = HVACMode.OFF + self._call_active = False self.async_write_ha_state() async def _async_read_register( - self, register_type: str, register: int + self, register_type: str, register: int, raw: bool | None = False ) -> float | None: """Read register using the Modbus hub slave.""" result = await self._hub.async_pymodbus_call( @@ -177,6 +251,14 @@ class ModbusThermostat(BaseStructPlatform, RestoreEntity, ClimateEntity): return -1 self._lazy_errors = self._lazy_error_count + + if raw: + # Return the raw value read from the register, do not change + # the object's state + self._attr_available = True + return int(result.registers[0]) + + # The regular handling of the value self._value = self.unpack_structure_result(result.registers) if not self._value: self._attr_available = False diff --git a/homeassistant/components/modbus/const.py b/homeassistant/components/modbus/const.py index b09d75f27e0..2ad36f908ce 100644 --- a/homeassistant/components/modbus/const.py +++ b/homeassistant/components/modbus/const.py @@ -53,6 +53,9 @@ CONF_SWAP_NONE = "none" CONF_SWAP_WORD = "word" CONF_SWAP_WORD_BYTE = "word_byte" CONF_TARGET_TEMP = "target_temp_register" +CONF_HVAC_MODE_REGISTER = "hvac_mode_register" +CONF_HVAC_MODE_VALUES = "values" +CONF_HVAC_ONOFF_REGISTER = "hvac_onoff_register" CONF_VERIFY = "verify" CONF_VERIFY_REGISTER = "verify_register" CONF_VERIFY_STATE = "verify_state" diff --git a/tests/components/modbus/test_climate.py b/tests/components/modbus/test_climate.py index 942f6997c21..e554160d5bb 100644 --- a/tests/components/modbus/test_climate.py +++ b/tests/components/modbus/test_climate.py @@ -1,10 +1,18 @@ """The tests for the Modbus climate component.""" import pytest -from homeassistant.components.climate import DOMAIN as CLIMATE_DOMAIN, HVACMode +from homeassistant.components.climate import DOMAIN as CLIMATE_DOMAIN +from homeassistant.components.climate.const import ( + ATTR_HVAC_MODE, + ATTR_HVAC_MODES, + HVACMode, +) from homeassistant.components.modbus.const import ( CONF_CLIMATES, CONF_DATA_TYPE, + CONF_HVAC_MODE_REGISTER, + CONF_HVAC_MODE_VALUES, + CONF_HVAC_ONOFF_REGISTER, CONF_LAZY_ERROR, CONF_TARGET_TEMP, MODBUS_DOMAIN, @@ -52,6 +60,40 @@ ENTITY_ID = f"{CLIMATE_DOMAIN}.{TEST_ENTITY_NAME}".replace(" ", "_") } ], }, + { + CONF_CLIMATES: [ + { + CONF_NAME: TEST_ENTITY_NAME, + CONF_TARGET_TEMP: 117, + CONF_ADDRESS: 117, + CONF_SLAVE: 10, + CONF_HVAC_ONOFF_REGISTER: 12, + } + ], + }, + { + CONF_CLIMATES: [ + { + CONF_NAME: TEST_ENTITY_NAME, + CONF_TARGET_TEMP: 117, + CONF_ADDRESS: 117, + CONF_SLAVE: 10, + CONF_HVAC_ONOFF_REGISTER: 12, + CONF_HVAC_MODE_REGISTER: { + CONF_ADDRESS: 11, + CONF_HVAC_MODE_VALUES: { + HVACMode.OFF.value: 0, + HVACMode.HEAT.value: 1, + HVACMode.COOL.value: 2, + HVACMode.HEAT_COOL.value: 3, + HVACMode.DRY.value: 4, + HVACMode.FAN_ONLY.value: 5, + HVACMode.AUTO.value: 6, + }, + }, + } + ], + }, ], ) async def test_config_climate(hass, mock_modbus): @@ -59,6 +101,62 @@ async def test_config_climate(hass, mock_modbus): assert CLIMATE_DOMAIN in hass.config.components +@pytest.mark.parametrize( + "do_config", + [ + { + CONF_CLIMATES: [ + { + CONF_NAME: TEST_ENTITY_NAME, + CONF_TARGET_TEMP: 117, + CONF_ADDRESS: 117, + CONF_SLAVE: 10, + CONF_HVAC_MODE_REGISTER: { + CONF_ADDRESS: 11, + CONF_HVAC_MODE_VALUES: { + HVACMode.OFF.value: 0, + HVACMode.HEAT.value: 1, + HVACMode.COOL.value: 2, + HVACMode.HEAT_COOL.value: 3, + }, + }, + } + ], + }, + ], +) +async def test_config_hvac_mode_register(hass, mock_modbus): + """Run configuration test for mode register.""" + state = hass.states.get(ENTITY_ID) + assert HVACMode.OFF in state.attributes[ATTR_HVAC_MODES] + assert HVACMode.HEAT in state.attributes[ATTR_HVAC_MODES] + assert HVACMode.COOL in state.attributes[ATTR_HVAC_MODES] + assert HVACMode.HEAT_COOL in state.attributes[ATTR_HVAC_MODES] + + +@pytest.mark.parametrize( + "do_config", + [ + { + CONF_CLIMATES: [ + { + CONF_NAME: TEST_ENTITY_NAME, + CONF_TARGET_TEMP: 117, + CONF_ADDRESS: 117, + CONF_SLAVE: 10, + CONF_HVAC_ONOFF_REGISTER: 11, + } + ], + }, + ], +) +async def test_config_hvac_onoff_register(hass, mock_modbus): + """Run configuration test for On/Off register.""" + state = hass.states.get(ENTITY_ID) + assert HVACMode.OFF in state.attributes[ATTR_HVAC_MODES] + assert HVACMode.AUTO in state.attributes[ATTR_HVAC_MODES] + + @pytest.mark.parametrize( "do_config", [ @@ -90,28 +188,93 @@ async def test_temperature_climate(hass, expected, mock_do_cycle): @pytest.mark.parametrize( - "do_config", + "do_config,result,register_words", [ - { - CONF_CLIMATES: [ - { - CONF_NAME: TEST_ENTITY_NAME, - CONF_TARGET_TEMP: 117, - CONF_ADDRESS: 117, - CONF_SLAVE: 10, - CONF_SCAN_INTERVAL: 0, - CONF_DATA_TYPE: DataType.INT32, - } - ] - }, + ( + { + CONF_CLIMATES: [ + { + CONF_NAME: TEST_ENTITY_NAME, + CONF_TARGET_TEMP: 117, + CONF_ADDRESS: 117, + CONF_SLAVE: 10, + CONF_SCAN_INTERVAL: 0, + CONF_DATA_TYPE: DataType.INT32, + CONF_HVAC_MODE_REGISTER: { + CONF_ADDRESS: 118, + CONF_HVAC_MODE_VALUES: { + HVACMode.COOL.value: 0, + HVACMode.HEAT.value: 1, + HVACMode.DRY.value: 2, + }, + }, + }, + ] + }, + HVACMode.COOL, + [0x00], + ), + ( + { + CONF_CLIMATES: [ + { + CONF_NAME: TEST_ENTITY_NAME, + CONF_TARGET_TEMP: 117, + CONF_ADDRESS: 117, + CONF_SLAVE: 10, + CONF_SCAN_INTERVAL: 0, + CONF_DATA_TYPE: DataType.INT32, + CONF_HVAC_MODE_REGISTER: { + CONF_ADDRESS: 118, + CONF_HVAC_MODE_VALUES: { + HVACMode.COOL.value: 0, + HVACMode.HEAT.value: 1, + HVACMode.DRY.value: 2, + }, + }, + }, + ] + }, + HVACMode.HEAT, + [0x01], + ), + ( + { + CONF_CLIMATES: [ + { + CONF_NAME: TEST_ENTITY_NAME, + CONF_TARGET_TEMP: 117, + CONF_ADDRESS: 117, + CONF_SLAVE: 10, + CONF_SCAN_INTERVAL: 0, + CONF_DATA_TYPE: DataType.INT32, + CONF_HVAC_MODE_REGISTER: { + CONF_ADDRESS: 118, + CONF_HVAC_MODE_VALUES: { + HVACMode.COOL.value: 0, + HVACMode.HEAT.value: 2, + HVACMode.DRY.value: 3, + }, + }, + CONF_HVAC_ONOFF_REGISTER: 119, + }, + ] + }, + HVACMode.OFF, + [0x00], + ), ], ) -async def test_service_climate_update(hass, mock_modbus, mock_ha): +async def test_service_climate_update( + hass, mock_modbus, mock_ha, result, register_words +): """Run test for service homeassistant.update_entity.""" + mock_modbus.read_holding_registers.return_value = ReadResult(register_words) await hass.services.async_call( "homeassistant", "update_entity", {"entity_id": ENTITY_ID}, blocking=True ) - assert hass.states.get(ENTITY_ID).state == "auto" + await hass.async_block_till_done() + assert hass.states.get(ENTITY_ID).state == result @pytest.mark.parametrize( @@ -195,6 +358,68 @@ async def test_service_climate_set_temperature( ) +@pytest.mark.parametrize( + "hvac_mode, result, do_config", + [ + ( + HVACMode.COOL, + [0x00], + { + CONF_CLIMATES: [ + { + CONF_NAME: TEST_ENTITY_NAME, + CONF_TARGET_TEMP: 117, + CONF_ADDRESS: 117, + CONF_SLAVE: 10, + CONF_HVAC_MODE_REGISTER: { + CONF_ADDRESS: 118, + CONF_HVAC_MODE_VALUES: { + HVACMode.COOL.value: 1, + HVACMode.HEAT.value: 2, + }, + }, + } + ] + }, + ), + ( + HVACMode.HEAT, + [0x00], + { + CONF_CLIMATES: [ + { + CONF_NAME: TEST_ENTITY_NAME, + CONF_TARGET_TEMP: 117, + CONF_ADDRESS: 117, + CONF_SLAVE: 10, + CONF_HVAC_MODE_REGISTER: { + CONF_ADDRESS: 118, + CONF_HVAC_MODE_VALUES: { + HVACMode.COOL.value: 1, + HVACMode.HEAT.value: 2, + }, + }, + CONF_HVAC_ONOFF_REGISTER: 119, + } + ] + }, + ), + ], +) +async def test_service_set_mode(hass, hvac_mode, result, mock_modbus, mock_ha): + """Test set mode.""" + mock_modbus.read_holding_registers.return_value = ReadResult(result) + await hass.services.async_call( + CLIMATE_DOMAIN, + "set_hvac_mode", + { + "entity_id": ENTITY_ID, + ATTR_HVAC_MODE: hvac_mode, + }, + blocking=True, + ) + + test_value = State(ENTITY_ID, 35) test_value.attributes = {ATTR_TEMPERATURE: 37}