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_*`
This commit is contained in:
Lincoln Kirchoff 2025-03-11 10:27:47 -05:00 committed by GitHub
parent ad126a745a
commit 95afebceb4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 251 additions and 1 deletions

View File

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

View File

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

View File

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

View File

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