Enable Modbus Climate / HVAC on/off to use the coil instead of the register(s) (#135657)

This commit is contained in:
Ілля Піскурьов 2025-02-01 21:15:20 +02:00 committed by GitHub
parent d3da3b3470
commit 4cab773bab
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 219 additions and 1 deletions

View File

@ -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,

View File

@ -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

View File

@ -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"

View File

@ -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"),
[