From 95afebceb49f85c80d8e7356373b30b59908e348 Mon Sep 17 00:00:00 2001 From: Lincoln Kirchoff Date: Tue, 11 Mar 2025 10:27:47 -0500 Subject: [PATCH] Add modbus climate hvac action (#139864) * Added the hvac action attribute for modbus climate entities. * Fixed issue in hvac action unit test, was incorrectly referencing the hvac mode attribute. * Fixed the modbus climate test for hvac action, it now correctly checks that hvac actions in the config match HVACActions. * Made changes recommended by @crug80 to remove dead code and to add ability to use input or holding register for hvac action. * Moved action test case in test_climate.py * Updated comment for `test_service_climate_action_update` * Fixed ruff formatting error. * Addressed request to update labels from `state_*` to `action_*` --- homeassistant/components/modbus/__init__.py | 49 +++++++ homeassistant/components/modbus/climate.py | 55 +++++++- homeassistant/components/modbus/const.py | 10 ++ tests/components/modbus/test_climate.py | 138 ++++++++++++++++++++ 4 files changed, 251 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/modbus/__init__.py b/homeassistant/components/modbus/__init__.py index 61df7206402..52642cc32e3 100644 --- a/homeassistant/components/modbus/__init__.py +++ b/homeassistant/components/modbus/__init__.py @@ -79,6 +79,16 @@ from .const import ( CONF_FAN_MODE_TOP, CONF_FAN_MODE_VALUES, CONF_FANS, + CONF_HVAC_ACTION_COOLING, + CONF_HVAC_ACTION_DEFROSTING, + CONF_HVAC_ACTION_DRYING, + CONF_HVAC_ACTION_FAN, + CONF_HVAC_ACTION_HEATING, + CONF_HVAC_ACTION_IDLE, + CONF_HVAC_ACTION_OFF, + CONF_HVAC_ACTION_PREHEATING, + CONF_HVAC_ACTION_REGISTER, + CONF_HVAC_ACTION_VALUES, CONF_HVAC_MODE_AUTO, CONF_HVAC_MODE_COOL, CONF_HVAC_MODE_DRY, @@ -297,6 +307,45 @@ CLIMATE_SCHEMA = vol.All( vol.Optional(CONF_WRITE_REGISTERS, default=False): cv.boolean, } ), + vol.Optional(CONF_HVAC_ACTION_REGISTER): vol.Maybe( + { + CONF_ADDRESS: cv.positive_int, + CONF_HVAC_ACTION_VALUES: { + vol.Optional(CONF_HVAC_ACTION_COOLING): vol.Any( + cv.positive_int, [cv.positive_int] + ), + vol.Optional(CONF_HVAC_ACTION_DEFROSTING): vol.Any( + cv.positive_int, [cv.positive_int] + ), + vol.Optional(CONF_HVAC_ACTION_DRYING): vol.Any( + cv.positive_int, [cv.positive_int] + ), + vol.Optional(CONF_HVAC_ACTION_FAN): vol.Any( + cv.positive_int, [cv.positive_int] + ), + vol.Optional(CONF_HVAC_ACTION_HEATING): vol.Any( + cv.positive_int, [cv.positive_int] + ), + vol.Optional(CONF_HVAC_ACTION_IDLE): vol.Any( + cv.positive_int, [cv.positive_int] + ), + vol.Optional(CONF_HVAC_ACTION_OFF): vol.Any( + cv.positive_int, [cv.positive_int] + ), + vol.Optional(CONF_HVAC_ACTION_PREHEATING): vol.Any( + cv.positive_int, [cv.positive_int] + ), + }, + vol.Optional( + CONF_INPUT_TYPE, default=CALL_TYPE_REGISTER_HOLDING + ): vol.In( + [ + CALL_TYPE_REGISTER_HOLDING, + CALL_TYPE_REGISTER_INPUT, + ] + ), + } + ), vol.Optional(CONF_FAN_MODE_REGISTER): vol.Maybe( vol.All( { diff --git a/homeassistant/components/modbus/climate.py b/homeassistant/components/modbus/climate.py index fca1b94611a..be10a9495c6 100644 --- a/homeassistant/components/modbus/climate.py +++ b/homeassistant/components/modbus/climate.py @@ -24,6 +24,7 @@ from homeassistant.components.climate import ( SWING_VERTICAL, ClimateEntity, ClimateEntityFeature, + HVACAction, HVACMode, ) from homeassistant.const import ( @@ -61,6 +62,16 @@ from .const import ( CONF_FAN_MODE_REGISTER, CONF_FAN_MODE_TOP, CONF_FAN_MODE_VALUES, + CONF_HVAC_ACTION_COOLING, + CONF_HVAC_ACTION_DEFROSTING, + CONF_HVAC_ACTION_DRYING, + CONF_HVAC_ACTION_FAN, + CONF_HVAC_ACTION_HEATING, + CONF_HVAC_ACTION_IDLE, + CONF_HVAC_ACTION_OFF, + CONF_HVAC_ACTION_PREHEATING, + CONF_HVAC_ACTION_REGISTER, + CONF_HVAC_ACTION_VALUES, CONF_HVAC_MODE_AUTO, CONF_HVAC_MODE_COOL, CONF_HVAC_MODE_DRY, @@ -74,6 +85,7 @@ from .const import ( CONF_HVAC_ON_VALUE, CONF_HVAC_ONOFF_COIL, CONF_HVAC_ONOFF_REGISTER, + CONF_INPUT_TYPE, CONF_MAX_TEMP, CONF_MIN_TEMP, CONF_STEP, @@ -188,6 +200,34 @@ class ModbusThermostat(BaseStructPlatform, RestoreEntity, ClimateEntity): self._attr_hvac_mode = HVACMode.AUTO self._attr_hvac_modes = [HVACMode.AUTO] + if CONF_HVAC_ACTION_REGISTER in config: + action_config = config[CONF_HVAC_ACTION_REGISTER] + self._hvac_action_register = action_config[CONF_ADDRESS] + self._hvac_action_type = action_config[CONF_INPUT_TYPE] + + self._attr_hvac_action = None + self._hvac_action_mapping: list[tuple[int, HVACAction]] = [] + action_value_config = action_config[CONF_HVAC_ACTION_VALUES] + + for hvac_action_kw, hvac_action in ( + (CONF_HVAC_ACTION_COOLING, HVACAction.COOLING), + (CONF_HVAC_ACTION_DEFROSTING, HVACAction.DEFROSTING), + (CONF_HVAC_ACTION_DRYING, HVACAction.DRYING), + (CONF_HVAC_ACTION_FAN, HVACAction.FAN), + (CONF_HVAC_ACTION_HEATING, HVACAction.HEATING), + (CONF_HVAC_ACTION_IDLE, HVACAction.IDLE), + (CONF_HVAC_ACTION_OFF, HVACAction.OFF), + (CONF_HVAC_ACTION_PREHEATING, HVACAction.PREHEATING), + ): + if hvac_action_kw in action_value_config: + values = action_value_config[hvac_action_kw] + if not isinstance(values, list): + values = [values] + for value in values: + self._hvac_action_mapping.append((value, hvac_action)) + else: + self._hvac_action_register = None + if CONF_FAN_MODE_REGISTER in config: self._attr_supported_features = ( self._attr_supported_features | ClimateEntityFeature.FAN_MODE @@ -216,7 +256,6 @@ class ModbusThermostat(BaseStructPlatform, RestoreEntity, ClimateEntity): self._fan_mode_mapping_from_modbus[value] = fan_mode self._fan_mode_mapping_to_modbus[fan_mode] = value self._attr_fan_modes.append(fan_mode) - else: # No FAN modes defined self._fan_mode_register = None @@ -457,6 +496,20 @@ class ModbusThermostat(BaseStructPlatform, RestoreEntity, ClimateEntity): self._attr_hvac_mode = mode break + # Read the HVAC action register if defined + if self._hvac_action_register is not None: + hvac_action = await self._async_read_register( + self._hvac_action_type, self._hvac_action_register, raw=True + ) + + # Translate the value received + if hvac_action is not None: + self._attr_hvac_action = None + for value, action in self._hvac_action_mapping: + if hvac_action == value: + self._attr_hvac_action = action + break + # Read the Fan mode register if defined if self._fan_mode_register is not None: fan_mode = await self._async_read_register( diff --git a/homeassistant/components/modbus/const.py b/homeassistant/components/modbus/const.py index 5926569040d..634637a6b08 100644 --- a/homeassistant/components/modbus/const.py +++ b/homeassistant/components/modbus/const.py @@ -63,6 +63,16 @@ CONF_HVAC_ONOFF_REGISTER = "hvac_onoff_register" CONF_HVAC_ON_VALUE = "hvac_on_value" CONF_HVAC_OFF_VALUE = "hvac_off_value" CONF_HVAC_ONOFF_COIL = "hvac_onoff_coil" +CONF_HVAC_ACTION_REGISTER = "hvac_action_register" +CONF_HVAC_ACTION_COOLING = "action_cooling" +CONF_HVAC_ACTION_DEFROSTING = "action_defrosting" +CONF_HVAC_ACTION_DRYING = "action_drying" +CONF_HVAC_ACTION_FAN = "action_fan" +CONF_HVAC_ACTION_HEATING = "action_heating" +CONF_HVAC_ACTION_IDLE = "action_idle" +CONF_HVAC_ACTION_OFF = "action_off" +CONF_HVAC_ACTION_PREHEATING = "action_preheating" +CONF_HVAC_ACTION_VALUES = "values" CONF_HVAC_MODE_OFF = "state_off" CONF_HVAC_MODE_HEAT = "state_heat" CONF_HVAC_MODE_COOL = "state_cool" diff --git a/tests/components/modbus/test_climate.py b/tests/components/modbus/test_climate.py index 3c30efe9dce..54d4c5f6666 100644 --- a/tests/components/modbus/test_climate.py +++ b/tests/components/modbus/test_climate.py @@ -5,6 +5,7 @@ import pytest from homeassistant.components.climate import ( ATTR_FAN_MODE, ATTR_FAN_MODES, + ATTR_HVAC_ACTION, ATTR_HVAC_MODE, ATTR_HVAC_MODES, ATTR_SWING_MODE, @@ -31,6 +32,7 @@ from homeassistant.components.climate import ( SWING_OFF, SWING_ON, SWING_VERTICAL, + HVACAction, HVACMode, ) from homeassistant.components.homeassistant import SERVICE_UPDATE_ENTITY @@ -47,6 +49,16 @@ from homeassistant.components.modbus.const import ( CONF_FAN_MODE_REGISTER, CONF_FAN_MODE_TOP, CONF_FAN_MODE_VALUES, + CONF_HVAC_ACTION_COOLING, + CONF_HVAC_ACTION_DEFROSTING, + CONF_HVAC_ACTION_DRYING, + CONF_HVAC_ACTION_FAN, + CONF_HVAC_ACTION_HEATING, + CONF_HVAC_ACTION_IDLE, + CONF_HVAC_ACTION_OFF, + CONF_HVAC_ACTION_PREHEATING, + CONF_HVAC_ACTION_REGISTER, + CONF_HVAC_ACTION_VALUES, CONF_HVAC_MODE_AUTO, CONF_HVAC_MODE_COOL, CONF_HVAC_MODE_DRY, @@ -224,6 +236,43 @@ 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_HVAC_MODE_REGISTER: { + CONF_ADDRESS: 11, + CONF_WRITE_REGISTERS: True, + CONF_HVAC_MODE_VALUES: { + CONF_HVAC_MODE_OFF: 0, + CONF_HVAC_MODE_HEAT: 1, + CONF_HVAC_MODE_COOL: 2, + CONF_HVAC_MODE_HEAT_COOL: 3, + CONF_HVAC_MODE_DRY: 4, + CONF_HVAC_MODE_FAN_ONLY: 5, + CONF_HVAC_MODE_AUTO: 6, + }, + }, + CONF_HVAC_ACTION_REGISTER: { + CONF_ADDRESS: 14, + CONF_HVAC_ACTION_VALUES: { + CONF_HVAC_ACTION_COOLING: 0, + CONF_HVAC_ACTION_DEFROSTING: 1, + CONF_HVAC_ACTION_DRYING: 2, + CONF_HVAC_ACTION_FAN: 3, + CONF_HVAC_ACTION_HEATING: 4, + CONF_HVAC_ACTION_IDLE: 5, + CONF_HVAC_ACTION_OFF: 6, + CONF_HVAC_ACTION_PREHEATING: 7, + }, + }, + } + ], + }, ], ) async def test_config_climate(hass: HomeAssistant, mock_modbus) -> None: @@ -745,6 +794,95 @@ async def test_hvac_onoff_coil_update( assert state.state == result +@pytest.mark.parametrize( + ("do_config", "result", "register_words"), + [ + ( + { + CONF_CLIMATES: [ + { + CONF_NAME: TEST_ENTITY_NAME, + CONF_TARGET_TEMP: 116, + CONF_ADDRESS: 117, + CONF_SLAVE: 10, + CONF_SCAN_INTERVAL: 0, + CONF_DATA_TYPE: DataType.INT32, + CONF_HVAC_ACTION_REGISTER: { + CONF_ADDRESS: 118, + CONF_HVAC_ACTION_VALUES: { + CONF_HVAC_ACTION_IDLE: 0, + CONF_HVAC_ACTION_HEATING: 1, + }, + }, + }, + ] + }, + HVACAction.HEATING, + [0x01], + ), + ( + { + CONF_CLIMATES: [ + { + CONF_NAME: TEST_ENTITY_NAME, + CONF_TARGET_TEMP: 116, + CONF_ADDRESS: 117, + CONF_SLAVE: 10, + CONF_SCAN_INTERVAL: 0, + CONF_DATA_TYPE: DataType.INT32, + CONF_HVAC_ACTION_REGISTER: { + CONF_ADDRESS: 118, + CONF_HVAC_ACTION_VALUES: { + CONF_HVAC_ACTION_COOLING: 0, + CONF_HVAC_ACTION_HEATING: 1, + }, + }, + }, + ] + }, + HVACAction.COOLING, + [0x00], + ), + ( + { + CONF_CLIMATES: [ + { + CONF_NAME: TEST_ENTITY_NAME, + CONF_TARGET_TEMP: 116, + CONF_ADDRESS: 117, + CONF_SLAVE: 10, + CONF_SCAN_INTERVAL: 0, + CONF_DATA_TYPE: DataType.INT32, + CONF_HVAC_ACTION_REGISTER: { + CONF_ADDRESS: 118, + CONF_HVAC_ACTION_VALUES: { + CONF_HVAC_ACTION_OFF: 0, + CONF_HVAC_ACTION_DRYING: 1, + }, + }, + }, + ] + }, + HVACAction.DRYING, + [0x01], + ), + ], +) +async def test_service_climate_action_update( + hass: HomeAssistant, mock_modbus_ha, result, register_words +) -> None: + """Test HVAC action updates.""" + mock_modbus_ha.read_holding_registers.return_value = ReadResult(register_words) + await hass.services.async_call( + HOMEASSISTANT_DOMAIN, + SERVICE_UPDATE_ENTITY, + {ATTR_ENTITY_ID: ENTITY_ID}, + blocking=True, + ) + await hass.async_block_till_done() + assert hass.states.get(ENTITY_ID).attributes[ATTR_HVAC_ACTION] == result + + @pytest.mark.parametrize( ("do_config", "result", "register_words"), [