diff --git a/homeassistant/components/modbus/__init__.py b/homeassistant/components/modbus/__init__.py index 1151a5f1f01..e5bb9e8bf38 100644 --- a/homeassistant/components/modbus/__init__.py +++ b/homeassistant/components/modbus/__init__.py @@ -134,7 +134,9 @@ from .const import ( # noqa: F401 from .modbus import ModbusHub, async_modbus_setup from .validators import ( check_config, + check_hvac_target_temp_registers, duplicate_fan_mode_validator, + hvac_fixedsize_reglist_validator, nan_validator, register_int_list_validator, struct_validator, @@ -239,7 +241,7 @@ BASE_SWITCH_SCHEMA = BASE_COMPONENT_SCHEMA.extend( CLIMATE_SCHEMA = vol.All( BASE_STRUCT_SCHEMA.extend( { - vol.Required(CONF_TARGET_TEMP): cv.positive_int, + vol.Required(CONF_TARGET_TEMP): hvac_fixedsize_reglist_validator, vol.Optional(CONF_TARGET_TEMP_WRITE_REGISTERS, default=False): cv.boolean, vol.Optional(CONF_MAX_TEMP, default=35): vol.Coerce(float), vol.Optional(CONF_MIN_TEMP, default=5): vol.Coerce(float), @@ -296,8 +298,9 @@ CLIMATE_SCHEMA = vol.All( duplicate_fan_mode_validator, ), ), - } + }, ), + check_hvac_target_temp_registers, ) COVERS_SCHEMA = BASE_COMPONENT_SCHEMA.extend( diff --git a/homeassistant/components/modbus/climate.py b/homeassistant/components/modbus/climate.py index d31323a27e9..a57fe53ada7 100644 --- a/homeassistant/components/modbus/climate.py +++ b/homeassistant/components/modbus/climate.py @@ -75,6 +75,17 @@ from .modbus import ModbusHub PARALLEL_UPDATES = 1 +HVACMODE_TO_TARG_TEMP_REG_INDEX_ARRAY = { + HVACMode.AUTO: 0, + HVACMode.COOL: 1, + HVACMode.DRY: 2, + HVACMode.FAN_ONLY: 3, + HVACMode.HEAT: 4, + HVACMode.HEAT_COOL: 5, + HVACMode.OFF: 6, + None: 0, +} + async def async_setup_platform( hass: HomeAssistant, @@ -117,7 +128,6 @@ class ModbusThermostat(BaseStructPlatform, RestoreEntity, ClimateEntity): CONF_TARGET_TEMP_WRITE_REGISTERS ] self._unit = config[CONF_TEMPERATURE_UNIT] - self._attr_current_temperature = None self._attr_target_temperature = None self._attr_temperature_unit = ( @@ -157,7 +167,6 @@ class ModbusThermostat(BaseStructPlatform, RestoreEntity, ClimateEntity): for value in values: self._hvac_mode_mapping.append((value, hvac_mode)) self._attr_hvac_modes.append(hvac_mode) - else: # No HVAC modes defined self._hvac_mode_register = None @@ -305,21 +314,27 @@ class ModbusThermostat(BaseStructPlatform, RestoreEntity, ClimateEntity): if self._target_temperature_write_registers: result = await self._hub.async_pb_call( self._slave, - self._target_temperature_register, + self._target_temperature_register[ + HVACMODE_TO_TARG_TEMP_REG_INDEX_ARRAY[self._attr_hvac_mode] + ], [int(float(registers[0]))], CALL_TYPE_WRITE_REGISTERS, ) else: result = await self._hub.async_pb_call( self._slave, - self._target_temperature_register, + self._target_temperature_register[ + HVACMODE_TO_TARG_TEMP_REG_INDEX_ARRAY[self._attr_hvac_mode] + ], int(float(registers[0])), CALL_TYPE_WRITE_REGISTER, ) else: result = await self._hub.async_pb_call( self._slave, - self._target_temperature_register, + self._target_temperature_register[ + HVACMODE_TO_TARG_TEMP_REG_INDEX_ARRAY[self._attr_hvac_mode] + ], [int(float(i)) for i in registers], CALL_TYPE_WRITE_REGISTERS, ) @@ -332,12 +347,15 @@ class ModbusThermostat(BaseStructPlatform, RestoreEntity, ClimateEntity): # async_track_time_interval self._attr_target_temperature = await self._async_read_register( - CALL_TYPE_REGISTER_HOLDING, self._target_temperature_register + CALL_TYPE_REGISTER_HOLDING, + self._target_temperature_register[ + HVACMODE_TO_TARG_TEMP_REG_INDEX_ARRAY[self._attr_hvac_mode] + ], ) + self._attr_current_temperature = await self._async_read_register( self._input_type, self._address ) - # Read the HVAC mode register if defined if self._hvac_mode_register is not None: hvac_mode = await self._async_read_register( diff --git a/homeassistant/components/modbus/validators.py b/homeassistant/components/modbus/validators.py index 37eae23ba82..bdf472e4f76 100644 --- a/homeassistant/components/modbus/validators.py +++ b/homeassistant/components/modbus/validators.py @@ -8,6 +8,7 @@ from typing import Any import voluptuous as vol +from homeassistant.components.climate import HVACMode from homeassistant.const import ( CONF_ADDRESS, CONF_COMMAND_OFF, @@ -29,6 +30,7 @@ from .const import ( CONF_FAN_MODE_REGISTER, CONF_FAN_MODE_VALUES, CONF_HVAC_MODE_REGISTER, + CONF_HVAC_ONOFF_REGISTER, CONF_INPUT_TYPE, CONF_SLAVE_COUNT, CONF_SWAP, @@ -172,6 +174,26 @@ def struct_validator(config: dict[str, Any]) -> dict[str, Any]: } +def hvac_fixedsize_reglist_validator(value: Any) -> list: + """Check the number of registers for target temp. and coerce it to a list, if valid.""" + if isinstance(value, int): + value = [value] * len(HVACMode) + return list(value) + + if len(value) == len(HVACMode): + _rv = True + for svalue in value: + if isinstance(svalue, int) is False: + _rv = False + break + if _rv is True: + return list(value) + + raise vol.Invalid( + f"Invalid target temp register. Required type: integer, allowed 1 or list of {len(HVACMode)} registers" + ) + + def nan_validator(value: Any) -> int: """Convert nan string to number (can be hex string or int).""" if isinstance(value, int): @@ -203,6 +225,34 @@ def duplicate_fan_mode_validator(config: dict[str, Any]) -> dict: return config +def check_hvac_target_temp_registers(config: dict) -> dict: + """Check conflicts among HVAC target temperature registers and HVAC ON/OFF, HVAC register, Fan Modes.""" + + if ( + CONF_HVAC_MODE_REGISTER in config + and config[CONF_HVAC_MODE_REGISTER][CONF_ADDRESS] in config[CONF_TARGET_TEMP] + ): + wrn = f"{CONF_HVAC_MODE_REGISTER} overlaps CONF_TARGET_TEMP register(s). {CONF_HVAC_MODE_REGISTER} is not loaded!" + _LOGGER.warning(wrn) + del config[CONF_HVAC_MODE_REGISTER] + if ( + CONF_HVAC_ONOFF_REGISTER in config + and config[CONF_HVAC_ONOFF_REGISTER] in config[CONF_TARGET_TEMP] + ): + wrn = f"{CONF_HVAC_ONOFF_REGISTER} overlaps CONF_TARGET_TEMP register(s). {CONF_HVAC_ONOFF_REGISTER} is not loaded!" + _LOGGER.warning(wrn) + del config[CONF_HVAC_ONOFF_REGISTER] + if ( + CONF_FAN_MODE_REGISTER in config + and config[CONF_FAN_MODE_REGISTER][CONF_ADDRESS] in config[CONF_TARGET_TEMP] + ): + wrn = f"{CONF_FAN_MODE_REGISTER} overlaps CONF_TARGET_TEMP register(s). {CONF_FAN_MODE_REGISTER} is not loaded!" + _LOGGER.warning(wrn) + del config[CONF_FAN_MODE_REGISTER] + + return config + + def register_int_list_validator(value: Any) -> Any: """Check if a register (CONF_ADRESS) is an int or a list having only 1 register.""" if isinstance(value, int) and value >= 0: diff --git a/tests/components/modbus/test_climate.py b/tests/components/modbus/test_climate.py index b885e6452d8..47d468ee1d8 100644 --- a/tests/components/modbus/test_climate.py +++ b/tests/components/modbus/test_climate.py @@ -129,7 +129,7 @@ ENTITY_ID = f"{CLIMATE_DOMAIN}.{TEST_ENTITY_NAME}".replace(" ", "_") CONF_CLIMATES: [ { CONF_NAME: TEST_ENTITY_NAME, - CONF_TARGET_TEMP: 117, + CONF_TARGET_TEMP: [130, 131, 132, 133, 135, 128, 129], CONF_ADDRESS: 117, CONF_SLAVE: 10, CONF_HVAC_ONOFF_REGISTER: 12, @@ -374,7 +374,7 @@ async def test_temperature_error(hass: HomeAssistant, expected, mock_do_cycle) - CONF_CLIMATES: [ { CONF_NAME: TEST_ENTITY_NAME, - CONF_TARGET_TEMP: 117, + CONF_TARGET_TEMP: [130, 131, 132, 133, 134, 135, 136], CONF_ADDRESS: 117, CONF_SLAVE: 10, CONF_SCAN_INTERVAL: 0, @@ -398,7 +398,7 @@ async def test_temperature_error(hass: HomeAssistant, expected, mock_do_cycle) - CONF_CLIMATES: [ { CONF_NAME: TEST_ENTITY_NAME, - CONF_TARGET_TEMP: 117, + CONF_TARGET_TEMP: 119, CONF_ADDRESS: 117, CONF_SLAVE: 10, CONF_SCAN_INTERVAL: 0, @@ -422,7 +422,7 @@ async def test_temperature_error(hass: HomeAssistant, expected, mock_do_cycle) - CONF_CLIMATES: [ { CONF_NAME: TEST_ENTITY_NAME, - CONF_TARGET_TEMP: 117, + CONF_TARGET_TEMP: 120, CONF_ADDRESS: 117, CONF_SLAVE: 10, CONF_SCAN_INTERVAL: 0, @@ -464,7 +464,7 @@ async def test_service_climate_update( CONF_CLIMATES: [ { CONF_NAME: TEST_ENTITY_NAME, - CONF_TARGET_TEMP: 117, + CONF_TARGET_TEMP: 116, CONF_ADDRESS: 117, CONF_SLAVE: 10, CONF_SCAN_INTERVAL: 0, @@ -488,7 +488,7 @@ async def test_service_climate_update( CONF_CLIMATES: [ { CONF_NAME: TEST_ENTITY_NAME, - CONF_TARGET_TEMP: 117, + CONF_TARGET_TEMP: 116, CONF_ADDRESS: 117, CONF_SLAVE: 10, CONF_SCAN_INTERVAL: 0, @@ -512,7 +512,7 @@ async def test_service_climate_update( CONF_CLIMATES: [ { CONF_NAME: TEST_ENTITY_NAME, - CONF_TARGET_TEMP: 117, + CONF_TARGET_TEMP: 116, CONF_ADDRESS: 117, CONF_SLAVE: 10, CONF_SCAN_INTERVAL: 0, @@ -641,7 +641,7 @@ async def test_service_climate_fan_update( CONF_CLIMATES: [ { CONF_NAME: TEST_ENTITY_NAME, - CONF_TARGET_TEMP: 117, + CONF_TARGET_TEMP: [150, 151, 152, 153, 154, 155, 156], CONF_ADDRESS: 117, CONF_SLAVE: 10, CONF_DATA_TYPE: DataType.INT16, @@ -767,6 +767,7 @@ async def test_service_set_hvac_mode( ) -> None: """Test set HVAC mode.""" mock_modbus.read_holding_registers.return_value = ReadResult(result) + await hass.services.async_call( CLIMATE_DOMAIN, "set_hvac_mode", diff --git a/tests/components/modbus/test_init.py b/tests/components/modbus/test_init.py index c5b12a112fd..5738268a593 100644 --- a/tests/components/modbus/test_init.py +++ b/tests/components/modbus/test_init.py @@ -50,9 +50,12 @@ from homeassistant.components.modbus.const import ( CONF_FAN_MODE_REGISTER, CONF_FAN_MODE_VALUES, CONF_HVAC_MODE_COOL, + CONF_HVAC_MODE_DRY, CONF_HVAC_MODE_HEAT, + CONF_HVAC_MODE_HEAT_COOL, CONF_HVAC_MODE_REGISTER, CONF_HVAC_MODE_VALUES, + CONF_HVAC_ONOFF_REGISTER, CONF_INPUT_TYPE, CONF_MSG_WAIT, CONF_PARITY, @@ -80,7 +83,9 @@ from homeassistant.components.modbus.const import ( ) from homeassistant.components.modbus.validators import ( check_config, + check_hvac_target_temp_registers, duplicate_fan_mode_validator, + hvac_fixedsize_reglist_validator, nan_validator, register_int_list_validator, struct_validator, @@ -137,6 +142,22 @@ async def mock_modbus_with_pymodbus_fixture(hass, caplog, do_config, mock_pymodb return mock_pymodbus +async def test_fixedRegList_validator() -> None: + """Test fixed temp registers validator.""" + + for value in ( + 15, + [30, 31, 32, 33, 34, 35, 36], + ): + assert isinstance(hvac_fixedsize_reglist_validator(value), list) + + with pytest.raises(vol.Invalid): + hvac_fixedsize_reglist_validator([15, "ab", 17, 18, 19, 20, 21]) + + with pytest.raises(vol.Invalid): + hvac_fixedsize_reglist_validator([15, 17]) + + async def test_register_int_list_validator() -> None: """Test conf address register validator.""" for value, vtype in ( @@ -536,7 +557,7 @@ async def test_check_config_sensor(do_config) -> None: CONF_NAME: TEST_ENTITY_NAME + " 2", CONF_ADDRESS: 118, CONF_SLAVE: 0, - CONF_TARGET_TEMP: 99, + CONF_TARGET_TEMP: [99], CONF_FAN_MODE_REGISTER: { CONF_ADDRESS: 120, CONF_FAN_MODE_VALUES: { @@ -570,9 +591,9 @@ async def test_check_config_sensor(do_config) -> None: }, { CONF_NAME: TEST_ENTITY_NAME + " 2", - CONF_ADDRESS: 118, + CONF_ADDRESS: 117, CONF_SLAVE: 0, - CONF_TARGET_TEMP: 117, + CONF_TARGET_TEMP: [117], CONF_FAN_MODE_REGISTER: { CONF_ADDRESS: [121], CONF_FAN_MODE_VALUES: { @@ -584,6 +605,61 @@ async def test_check_config_sensor(do_config) -> None: ], } ], + [ + { + CONF_NAME: TEST_MODBUS_NAME, + CONF_TYPE: TCP, + CONF_HOST: TEST_MODBUS_HOST, + CONF_PORT: TEST_PORT_TCP, + CONF_TIMEOUT: 3, + CONF_CLIMATES: [ + { + CONF_NAME: TEST_ENTITY_NAME, + CONF_ADDRESS: 117, + CONF_TARGET_TEMP: [130, 131, 132, 133, 134, 135, 136], + CONF_SLAVE: 0, + 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_REGISTER: 122, + CONF_FAN_MODE_REGISTER: { + CONF_ADDRESS: 120, + CONF_FAN_MODE_VALUES: { + CONF_FAN_MODE_ON: 0, + CONF_FAN_MODE_HIGH: 1, + }, + }, + }, + { + CONF_NAME: TEST_ENTITY_NAME + " 2", + CONF_ADDRESS: 118, + CONF_TARGET_TEMP: [130, 131, 132, 133, 134, 135, 136], + CONF_SLAVE: 0, + CONF_HVAC_MODE_REGISTER: { + CONF_ADDRESS: 130, + CONF_HVAC_MODE_VALUES: { + CONF_HVAC_MODE_COOL: 0, + CONF_HVAC_MODE_HEAT: 2, + CONF_HVAC_MODE_DRY: 3, + }, + }, + CONF_HVAC_ONOFF_REGISTER: 122, + CONF_FAN_MODE_REGISTER: { + CONF_ADDRESS: 120, + CONF_FAN_MODE_VALUES: { + CONF_FAN_MODE_ON: 0, + CONF_FAN_MODE_HIGH: 1, + }, + }, + }, + ], + } + ], ], ) async def test_check_config_climate(do_config) -> None: @@ -592,6 +668,59 @@ async def test_check_config_climate(do_config) -> None: assert len(do_config[0][CONF_CLIMATES]) == 1 +@pytest.mark.parametrize( + "do_config", + [ + [ + { + CONF_NAME: TEST_ENTITY_NAME, + CONF_ADDRESS: 1, + CONF_TARGET_TEMP: [117, 121, 119, 150, 151, 152, 156], + CONF_HVAC_MODE_REGISTER: { + CONF_ADDRESS: 119, + CONF_HVAC_MODE_VALUES: { + CONF_HVAC_MODE_COOL: 0, + CONF_HVAC_MODE_HEAT: 1, + CONF_HVAC_MODE_HEAT_COOL: 2, + CONF_HVAC_MODE_DRY: 3, + }, + }, + CONF_HVAC_ONOFF_REGISTER: 117, + CONF_FAN_MODE_REGISTER: { + CONF_ADDRESS: 121, + }, + }, + ], + [ + { + CONF_NAME: TEST_ENTITY_NAME, + CONF_ADDRESS: 1, + CONF_TARGET_TEMP: [117], + CONF_HVAC_MODE_REGISTER: { + CONF_ADDRESS: 117, + CONF_HVAC_MODE_VALUES: { + CONF_HVAC_MODE_COOL: 0, + CONF_HVAC_MODE_HEAT: 1, + CONF_HVAC_MODE_HEAT_COOL: 2, + CONF_HVAC_MODE_DRY: 3, + }, + }, + CONF_HVAC_ONOFF_REGISTER: 117, + CONF_FAN_MODE_REGISTER: { + CONF_ADDRESS: 117, + }, + }, + ], + ], +) +async def test_climate_conflict_addresses(do_config) -> None: + """Test conflicts among the addresses of target temp and other climate addresses.""" + check_hvac_target_temp_registers(do_config[0]) + assert CONF_HVAC_MODE_REGISTER not in do_config[0] + assert CONF_HVAC_ONOFF_REGISTER not in do_config[0] + assert CONF_FAN_MODE_REGISTER not in do_config[0] + + @pytest.mark.parametrize( "do_config", [