From 4cab773bab1d7f06f8d33b54fbbe81ad712c2af2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=86=D0=BB=D0=BB=D1=8F=20=D0=9F=D1=96=D1=81=D0=BA=D1=83?= =?UTF-8?q?=D1=80=D1=8C=D0=BE=D0=B2?= <74793674+illia-piskurov@users.noreply.github.com> Date: Sat, 1 Feb 2025 21:15:20 +0200 Subject: [PATCH] Enable Modbus Climate / HVAC on/off to use the coil instead of the register(s) (#135657) --- homeassistant/components/modbus/__init__.py | 4 +- homeassistant/components/modbus/climate.py | 32 ++++ homeassistant/components/modbus/const.py | 1 + tests/components/modbus/test_climate.py | 183 ++++++++++++++++++++ 4 files changed, 219 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/modbus/__init__.py b/homeassistant/components/modbus/__init__.py index 5b1b78a5aef..61df7206402 100644 --- a/homeassistant/components/modbus/__init__.py +++ b/homeassistant/components/modbus/__init__.py @@ -90,6 +90,7 @@ from .const import ( CONF_HVAC_MODE_VALUES, CONF_HVAC_OFF_VALUE, CONF_HVAC_ON_VALUE, + CONF_HVAC_ONOFF_COIL, CONF_HVAC_ONOFF_REGISTER, CONF_INPUT_TYPE, CONF_MAX_TEMP, @@ -258,7 +259,8 @@ CLIMATE_SCHEMA = vol.All( vol.Optional(CONF_MIN_TEMP, default=5): vol.Coerce(float), 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.Exclusive(CONF_HVAC_ONOFF_COIL, "hvac_onoff_type"): cv.positive_int, + vol.Exclusive(CONF_HVAC_ONOFF_REGISTER, "hvac_onoff_type"): cv.positive_int, vol.Optional( CONF_HVAC_ON_VALUE, default=DEFAULT_HVAC_ON_VALUE ): cv.positive_int, diff --git a/homeassistant/components/modbus/climate.py b/homeassistant/components/modbus/climate.py index e1a2688048d..fca1b94611a 100644 --- a/homeassistant/components/modbus/climate.py +++ b/homeassistant/components/modbus/climate.py @@ -43,7 +43,9 @@ from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from . import get_hub from .const import ( + CALL_TYPE_COIL, CALL_TYPE_REGISTER_HOLDING, + CALL_TYPE_WRITE_COIL, CALL_TYPE_WRITE_REGISTER, CALL_TYPE_WRITE_REGISTERS, CONF_CLIMATES, @@ -70,6 +72,7 @@ from .const import ( CONF_HVAC_MODE_VALUES, CONF_HVAC_OFF_VALUE, CONF_HVAC_ON_VALUE, + CONF_HVAC_ONOFF_COIL, CONF_HVAC_ONOFF_REGISTER, CONF_MAX_TEMP, CONF_MIN_TEMP, @@ -254,6 +257,13 @@ class ModbusThermostat(BaseStructPlatform, RestoreEntity, ClimateEntity): else: self._hvac_onoff_register = None + if CONF_HVAC_ONOFF_COIL in config: + self._hvac_onoff_coil = config[CONF_HVAC_ONOFF_COIL] + if HVACMode.OFF not in self._attr_hvac_modes: + self._attr_hvac_modes.append(HVACMode.OFF) + else: + self._hvac_onoff_coil = None + async def async_added_to_hass(self) -> None: """Handle entity which will be added.""" await self.async_base_added_to_hass() @@ -287,6 +297,15 @@ class ModbusThermostat(BaseStructPlatform, RestoreEntity, ClimateEntity): CALL_TYPE_WRITE_REGISTER, ) + if self._hvac_onoff_coil is not None: + # Turn HVAC Off by writing 0 to the On/Off coil, or 1 otherwise. + await self._hub.async_pb_call( + self._slave, + self._hvac_onoff_coil, + 0 if hvac_mode == HVACMode.OFF else 1, + CALL_TYPE_WRITE_COIL, + ) + 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: @@ -484,6 +503,11 @@ class ModbusThermostat(BaseStructPlatform, RestoreEntity, ClimateEntity): if onoff == self._hvac_off_value: self._attr_hvac_mode = HVACMode.OFF + if self._hvac_onoff_coil is not None: + onoff = await self._async_read_coil(self._hvac_onoff_coil) + if onoff == 0: + self._attr_hvac_mode = HVACMode.OFF + async def _async_read_register( self, register_type: str, register: int, raw: bool | None = False ) -> float | None: @@ -508,3 +532,11 @@ class ModbusThermostat(BaseStructPlatform, RestoreEntity, ClimateEntity): return None self._attr_available = True return float(self._value) + + async def _async_read_coil(self, address: int) -> int | None: + result = await self._hub.async_pb_call(self._slave, address, 1, CALL_TYPE_COIL) + if result is not None and result.bits is not None: + self._attr_available = True + return int(result.bits[0]) + self._attr_available = False + return None diff --git a/homeassistant/components/modbus/const.py b/homeassistant/components/modbus/const.py index e11e15fff20..5926569040d 100644 --- a/homeassistant/components/modbus/const.py +++ b/homeassistant/components/modbus/const.py @@ -62,6 +62,7 @@ CONF_HVAC_MODE_REGISTER = "hvac_mode_register" 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_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 b5bc9b02808..3c30efe9dce 100644 --- a/tests/components/modbus/test_climate.py +++ b/tests/components/modbus/test_climate.py @@ -58,6 +58,7 @@ from homeassistant.components.modbus.const import ( CONF_HVAC_MODE_VALUES, CONF_HVAC_OFF_VALUE, CONF_HVAC_ON_VALUE, + CONF_HVAC_ONOFF_COIL, CONF_HVAC_ONOFF_REGISTER, CONF_MAX_TEMP, CONF_MIN_TEMP, @@ -366,6 +367,29 @@ async def test_config_hvac_onoff_register(hass: HomeAssistant, mock_modbus) -> N assert HVACMode.AUTO 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_coil(hass: HomeAssistant, mock_modbus) -> None: + """Run configuration test for On/Off coil.""" + 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", [ @@ -407,6 +431,45 @@ async def test_hvac_onoff_values(hass: HomeAssistant, mock_modbus) -> None: mock_modbus.write_register.assert_called_with(11, value=0xFF, slave=10) +@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_COIL: 11, + } + ], + }, + ], +) +async def test_hvac_onoff_coil(hass: HomeAssistant, mock_modbus) -> None: + """Run configuration test for On/Off coil values.""" + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: ENTITY_ID}, + blocking=True, + ) + await hass.async_block_till_done() + + mock_modbus.write_coil.assert_called_with(11, value=1, slave=10) + + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: ENTITY_ID}, + blocking=True, + ) + await hass.async_block_till_done() + + mock_modbus.write_coil.assert_called_with(11, value=0, slave=10) + + @pytest.mark.parametrize( "do_config", [ @@ -562,6 +625,126 @@ async def test_service_climate_update( assert hass.states.get(ENTITY_ID).state == result +@pytest.mark.parametrize( + ("do_config", "result", "register_words", "coil_value"), + [ + ( + { + CONF_CLIMATES: [ + { + CONF_NAME: TEST_ENTITY_NAME, + CONF_TARGET_TEMP: [130, 131, 132, 133, 134, 135, 136], + 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: { + CONF_HVAC_MODE_COOL: 0, + CONF_HVAC_MODE_HEAT: 1, + CONF_HVAC_MODE_DRY: 2, + }, + }, + CONF_HVAC_ONOFF_COIL: 11, + }, + ] + }, + HVACMode.COOL, + [0x00], + [0x01], + ), + ( + { + CONF_CLIMATES: [ + { + CONF_NAME: TEST_ENTITY_NAME, + CONF_TARGET_TEMP: 119, + 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: { + CONF_HVAC_MODE_COOL: 0, + CONF_HVAC_MODE_HEAT: 1, + CONF_HVAC_MODE_DRY: 2, + }, + }, + CONF_HVAC_ONOFF_COIL: 11, + }, + ] + }, + HVACMode.HEAT, + [0x01], + [0x01], + ), + ( + { + CONF_CLIMATES: [ + { + CONF_NAME: TEST_ENTITY_NAME, + CONF_TARGET_TEMP: 120, + 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: { + CONF_HVAC_MODE_COOL: 0, + CONF_HVAC_MODE_HEAT: 2, + CONF_HVAC_MODE_DRY: 3, + }, + }, + CONF_HVAC_ONOFF_COIL: 11, + }, + ] + }, + HVACMode.OFF, + [0x00], + [0x00], + ), + ( + { + CONF_CLIMATES: [ + { + CONF_NAME: TEST_ENTITY_NAME, + CONF_TARGET_TEMP: 120, + CONF_ADDRESS: 117, + CONF_SLAVE: 10, + CONF_SCAN_INTERVAL: 0, + CONF_DATA_TYPE: DataType.INT32, + CONF_HVAC_ONOFF_COIL: 11, + }, + ] + }, + "unavailable", + [0x00], + None, + ), + ], +) +async def test_hvac_onoff_coil_update( + hass: HomeAssistant, mock_modbus_ha, result, register_words, coil_value +) -> None: + """Test climate update based on On/Off coil values.""" + mock_modbus_ha.read_holding_registers.return_value = ReadResult(register_words) + mock_modbus_ha.read_coils.return_value = ReadResult(coil_value) + + await hass.services.async_call( + HOMEASSISTANT_DOMAIN, + SERVICE_UPDATE_ENTITY, + {ATTR_ENTITY_ID: ENTITY_ID}, + blocking=True, + ) + await hass.async_block_till_done() + + state = hass.states.get(ENTITY_ID) + assert state.state == result + + @pytest.mark.parametrize( ("do_config", "result", "register_words"), [