Add Modbus fan integration (#48558)
* Add Modbus fan entity * Update to PR. * Pylint. Co-authored-by: jan Iversen <jancasacondor@gmail.com>pull/50923/head
parent
80d172140f
commit
c979101a02
|
@ -63,6 +63,7 @@ from .const import (
|
|||
CONF_CURRENT_TEMP_REGISTER_TYPE,
|
||||
CONF_DATA_COUNT,
|
||||
CONF_DATA_TYPE,
|
||||
CONF_FANS,
|
||||
CONF_INPUT_TYPE,
|
||||
CONF_MAX_TEMP,
|
||||
CONF_MIN_TEMP,
|
||||
|
@ -266,6 +267,32 @@ LIGHT_SCHEMA = BASE_COMPONENT_SCHEMA.extend(
|
|||
}
|
||||
)
|
||||
|
||||
FAN_SCHEMA = BASE_COMPONENT_SCHEMA.extend(
|
||||
{
|
||||
vol.Required(CONF_ADDRESS): cv.positive_int,
|
||||
vol.Optional(CONF_WRITE_TYPE, default=CALL_TYPE_REGISTER_HOLDING): vol.In(
|
||||
[CALL_TYPE_REGISTER_HOLDING, CALL_TYPE_COIL]
|
||||
),
|
||||
vol.Optional(CONF_COMMAND_OFF, default=0x00): cv.positive_int,
|
||||
vol.Optional(CONF_COMMAND_ON, default=0x01): cv.positive_int,
|
||||
vol.Optional(CONF_VERIFY): vol.Maybe(
|
||||
{
|
||||
vol.Optional(CONF_ADDRESS): cv.positive_int,
|
||||
vol.Optional(CONF_INPUT_TYPE): vol.In(
|
||||
[
|
||||
CALL_TYPE_REGISTER_HOLDING,
|
||||
CALL_TYPE_DISCRETE,
|
||||
CALL_TYPE_REGISTER_INPUT,
|
||||
CALL_TYPE_COIL,
|
||||
]
|
||||
),
|
||||
vol.Optional(CONF_STATE_OFF): cv.positive_int,
|
||||
vol.Optional(CONF_STATE_ON): cv.positive_int,
|
||||
}
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
SENSOR_SCHEMA = BASE_COMPONENT_SCHEMA.extend(
|
||||
{
|
||||
vol.Required(CONF_ADDRESS): cv.positive_int,
|
||||
|
@ -319,6 +346,7 @@ MODBUS_SCHEMA = vol.Schema(
|
|||
vol.Optional(CONF_LIGHTS): vol.All(cv.ensure_list, [LIGHT_SCHEMA]),
|
||||
vol.Optional(CONF_SENSORS): vol.All(cv.ensure_list, [SENSOR_SCHEMA]),
|
||||
vol.Optional(CONF_SWITCHES): vol.All(cv.ensure_list, [SWITCH_SCHEMA]),
|
||||
vol.Optional(CONF_FANS): vol.All(cv.ensure_list, [FAN_SCHEMA]),
|
||||
}
|
||||
)
|
||||
|
||||
|
|
|
@ -3,6 +3,7 @@
|
|||
from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN
|
||||
from homeassistant.components.climate.const import DOMAIN as CLIMATE_DOMAIN
|
||||
from homeassistant.components.cover import DOMAIN as COVER_DOMAIN
|
||||
from homeassistant.components.fan import DOMAIN as FAN_DOMAIN
|
||||
from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN
|
||||
from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN
|
||||
from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN
|
||||
|
@ -24,6 +25,7 @@ CONF_CURRENT_TEMP = "current_temp_register"
|
|||
CONF_CURRENT_TEMP_REGISTER_TYPE = "current_temp_register_type"
|
||||
CONF_DATA_COUNT = "data_count"
|
||||
CONF_DATA_TYPE = "data_type"
|
||||
CONF_FANS = "fans"
|
||||
CONF_HUB = "hub"
|
||||
CONF_INPUTS = "inputs"
|
||||
CONF_INPUT_TYPE = "input_type"
|
||||
|
@ -105,6 +107,7 @@ PLATFORMS = (
|
|||
(CLIMATE_DOMAIN, CONF_CLIMATES),
|
||||
(COVER_DOMAIN, CONF_COVERS),
|
||||
(LIGHT_DOMAIN, CONF_LIGHTS),
|
||||
(FAN_DOMAIN, CONF_FANS),
|
||||
(SENSOR_DOMAIN, CONF_SENSORS),
|
||||
(SWITCH_DOMAIN, CONF_SWITCHES),
|
||||
)
|
||||
|
|
|
@ -0,0 +1,167 @@
|
|||
"""Support for Modbus fans."""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from homeassistant.components.fan import FanEntity
|
||||
from homeassistant.const import (
|
||||
CONF_ADDRESS,
|
||||
CONF_COMMAND_OFF,
|
||||
CONF_COMMAND_ON,
|
||||
CONF_NAME,
|
||||
STATE_ON,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.restore_state import RestoreEntity
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
|
||||
from .base_platform import BasePlatform
|
||||
from .const import (
|
||||
CALL_TYPE_COIL,
|
||||
CALL_TYPE_WRITE_COIL,
|
||||
CALL_TYPE_WRITE_REGISTER,
|
||||
CONF_FANS,
|
||||
CONF_INPUT_TYPE,
|
||||
CONF_STATE_OFF,
|
||||
CONF_STATE_ON,
|
||||
CONF_VERIFY,
|
||||
CONF_WRITE_TYPE,
|
||||
MODBUS_DOMAIN,
|
||||
)
|
||||
from .modbus import ModbusHub
|
||||
|
||||
PARALLEL_UPDATES = 1
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def async_setup_platform(
|
||||
hass: HomeAssistant, config: ConfigType, async_add_entities, discovery_info=None
|
||||
):
|
||||
"""Read configuration and create Modbus fans."""
|
||||
if discovery_info is None:
|
||||
return
|
||||
fans = []
|
||||
|
||||
for entry in discovery_info[CONF_FANS]:
|
||||
hub: ModbusHub = hass.data[MODBUS_DOMAIN][discovery_info[CONF_NAME]]
|
||||
fans.append(ModbusFan(hub, entry))
|
||||
async_add_entities(fans)
|
||||
|
||||
|
||||
class ModbusFan(BasePlatform, FanEntity, RestoreEntity):
|
||||
"""Base class representing a Modbus fan."""
|
||||
|
||||
def __init__(self, hub: ModbusHub, config: dict) -> None:
|
||||
"""Initialize the fan."""
|
||||
config[CONF_INPUT_TYPE] = ""
|
||||
super().__init__(hub, config)
|
||||
self._is_on: bool = False
|
||||
if config[CONF_WRITE_TYPE] == CALL_TYPE_COIL:
|
||||
self._write_type = CALL_TYPE_WRITE_COIL
|
||||
else:
|
||||
self._write_type = CALL_TYPE_WRITE_REGISTER
|
||||
self._command_on = config[CONF_COMMAND_ON]
|
||||
self._command_off = config[CONF_COMMAND_OFF]
|
||||
if CONF_VERIFY in config:
|
||||
if config[CONF_VERIFY] is None:
|
||||
config[CONF_VERIFY] = {}
|
||||
self._verify_active = True
|
||||
self._verify_address = config[CONF_VERIFY].get(
|
||||
CONF_ADDRESS, config[CONF_ADDRESS]
|
||||
)
|
||||
self._verify_type = config[CONF_VERIFY].get(
|
||||
CONF_INPUT_TYPE, config[CONF_WRITE_TYPE]
|
||||
)
|
||||
self._state_on = config[CONF_VERIFY].get(CONF_STATE_ON, self._command_on)
|
||||
self._state_off = config[CONF_VERIFY].get(CONF_STATE_OFF, self._command_off)
|
||||
else:
|
||||
self._verify_active = False
|
||||
|
||||
async def async_added_to_hass(self):
|
||||
"""Handle entity which will be added."""
|
||||
await self.async_base_added_to_hass()
|
||||
state = await self.async_get_last_state()
|
||||
if state:
|
||||
self._is_on = state.state == STATE_ON
|
||||
|
||||
@property
|
||||
def is_on(self):
|
||||
"""Return true if fan is on."""
|
||||
return self._is_on
|
||||
|
||||
async def async_turn_on(
|
||||
self,
|
||||
speed: str | None = None,
|
||||
percentage: int | None = None,
|
||||
preset_mode: str | None = None,
|
||||
**kwargs,
|
||||
) -> None:
|
||||
"""Set fan on."""
|
||||
|
||||
result = await self._hub.async_pymodbus_call(
|
||||
self._slave, self._address, self._command_on, self._write_type
|
||||
)
|
||||
if result is None:
|
||||
self._available = False
|
||||
self.async_write_ha_state()
|
||||
return
|
||||
|
||||
self._available = True
|
||||
if self._verify_active:
|
||||
await self.async_update()
|
||||
return
|
||||
|
||||
self._is_on = True
|
||||
self.async_write_ha_state()
|
||||
|
||||
async def async_turn_off(self, **kwargs):
|
||||
"""Set fan off."""
|
||||
result = await self._hub.async_pymodbus_call(
|
||||
self._slave, self._address, self._command_off, self._write_type
|
||||
)
|
||||
if result is None:
|
||||
self._available = False
|
||||
self.async_write_ha_state()
|
||||
else:
|
||||
self._available = True
|
||||
if self._verify_active:
|
||||
await self.async_update()
|
||||
else:
|
||||
self._is_on = False
|
||||
self.async_write_ha_state()
|
||||
|
||||
async def async_update(self, now=None):
|
||||
"""Update the entity state."""
|
||||
# remark "now" is a dummy parameter to avoid problems with
|
||||
# async_track_time_interval
|
||||
if not self._verify_active:
|
||||
self._available = True
|
||||
self.async_write_ha_state()
|
||||
return
|
||||
|
||||
result = await self._hub.async_pymodbus_call(
|
||||
self._slave, self._verify_address, 1, self._verify_type
|
||||
)
|
||||
if result is None:
|
||||
self._available = False
|
||||
self.async_write_ha_state()
|
||||
return
|
||||
|
||||
self._available = True
|
||||
if self._verify_type == CALL_TYPE_COIL:
|
||||
self._is_on = bool(result.bits[0] & 1)
|
||||
else:
|
||||
value = int(result.registers[0])
|
||||
if value == self._state_on:
|
||||
self._is_on = True
|
||||
elif value == self._state_off:
|
||||
self._is_on = False
|
||||
elif value is not None:
|
||||
_LOGGER.error(
|
||||
"Unexpected response from hub %s, slave %s register %s, got 0x%2x",
|
||||
self._hub.name,
|
||||
self._slave,
|
||||
self._verify_address,
|
||||
value,
|
||||
)
|
||||
self.async_write_ha_state()
|
|
@ -0,0 +1,289 @@
|
|||
"""The tests for the Modbus fan component."""
|
||||
from pymodbus.exceptions import ModbusException
|
||||
import pytest
|
||||
|
||||
from homeassistant.components.fan import DOMAIN as FAN_DOMAIN
|
||||
from homeassistant.components.modbus.const import (
|
||||
CALL_TYPE_COIL,
|
||||
CALL_TYPE_DISCRETE,
|
||||
CALL_TYPE_REGISTER_HOLDING,
|
||||
CALL_TYPE_REGISTER_INPUT,
|
||||
CONF_FANS,
|
||||
CONF_INPUT_TYPE,
|
||||
CONF_STATE_OFF,
|
||||
CONF_STATE_ON,
|
||||
CONF_VERIFY,
|
||||
CONF_WRITE_TYPE,
|
||||
MODBUS_DOMAIN,
|
||||
)
|
||||
from homeassistant.const import (
|
||||
CONF_ADDRESS,
|
||||
CONF_COMMAND_OFF,
|
||||
CONF_COMMAND_ON,
|
||||
CONF_HOST,
|
||||
CONF_NAME,
|
||||
CONF_PORT,
|
||||
CONF_SLAVE,
|
||||
CONF_TYPE,
|
||||
STATE_OFF,
|
||||
STATE_ON,
|
||||
STATE_UNAVAILABLE,
|
||||
)
|
||||
from homeassistant.core import State
|
||||
from homeassistant.setup import async_setup_component
|
||||
|
||||
from .conftest import ReadResult, base_config_test, base_test, prepare_service_update
|
||||
|
||||
from tests.common import mock_restore_cache
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"do_config",
|
||||
[
|
||||
{
|
||||
CONF_ADDRESS: 1234,
|
||||
},
|
||||
{
|
||||
CONF_ADDRESS: 1234,
|
||||
CONF_WRITE_TYPE: CALL_TYPE_COIL,
|
||||
},
|
||||
{
|
||||
CONF_ADDRESS: 1234,
|
||||
CONF_SLAVE: 1,
|
||||
CONF_COMMAND_OFF: 0x00,
|
||||
CONF_COMMAND_ON: 0x01,
|
||||
CONF_VERIFY: {
|
||||
CONF_INPUT_TYPE: CALL_TYPE_REGISTER_HOLDING,
|
||||
CONF_ADDRESS: 1235,
|
||||
CONF_STATE_OFF: 0,
|
||||
CONF_STATE_ON: 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
CONF_ADDRESS: 1234,
|
||||
CONF_SLAVE: 1,
|
||||
CONF_COMMAND_OFF: 0x00,
|
||||
CONF_COMMAND_ON: 0x01,
|
||||
CONF_VERIFY: {
|
||||
CONF_INPUT_TYPE: CALL_TYPE_REGISTER_INPUT,
|
||||
CONF_ADDRESS: 1235,
|
||||
CONF_STATE_OFF: 0,
|
||||
CONF_STATE_ON: 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
CONF_ADDRESS: 1234,
|
||||
CONF_SLAVE: 1,
|
||||
CONF_COMMAND_OFF: 0x00,
|
||||
CONF_COMMAND_ON: 0x01,
|
||||
CONF_VERIFY: {
|
||||
CONF_INPUT_TYPE: CALL_TYPE_DISCRETE,
|
||||
CONF_ADDRESS: 1235,
|
||||
CONF_STATE_OFF: 0,
|
||||
CONF_STATE_ON: 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
CONF_ADDRESS: 1234,
|
||||
CONF_SLAVE: 1,
|
||||
CONF_COMMAND_OFF: 0x00,
|
||||
CONF_COMMAND_ON: 0x01,
|
||||
CONF_VERIFY: None,
|
||||
},
|
||||
],
|
||||
)
|
||||
async def test_config_fan(hass, do_config):
|
||||
"""Run test for fan."""
|
||||
device_name = "test_fan"
|
||||
|
||||
device_config = {
|
||||
CONF_NAME: device_name,
|
||||
**do_config,
|
||||
}
|
||||
|
||||
await base_config_test(
|
||||
hass,
|
||||
device_config,
|
||||
device_name,
|
||||
FAN_DOMAIN,
|
||||
CONF_FANS,
|
||||
None,
|
||||
method_discovery=True,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("call_type", [CALL_TYPE_COIL, CALL_TYPE_REGISTER_HOLDING])
|
||||
@pytest.mark.parametrize(
|
||||
"regs,verify,expected",
|
||||
[
|
||||
(
|
||||
[0x00],
|
||||
{CONF_VERIFY: {}},
|
||||
STATE_OFF,
|
||||
),
|
||||
(
|
||||
[0x01],
|
||||
{CONF_VERIFY: {}},
|
||||
STATE_ON,
|
||||
),
|
||||
(
|
||||
[0xFE],
|
||||
{CONF_VERIFY: {}},
|
||||
STATE_OFF,
|
||||
),
|
||||
(
|
||||
None,
|
||||
{CONF_VERIFY: {}},
|
||||
STATE_UNAVAILABLE,
|
||||
),
|
||||
(
|
||||
None,
|
||||
{},
|
||||
STATE_OFF,
|
||||
),
|
||||
],
|
||||
)
|
||||
async def test_all_fan(hass, call_type, regs, verify, expected):
|
||||
"""Run test for given config."""
|
||||
fan_name = "modbus_test_fan"
|
||||
state = await base_test(
|
||||
hass,
|
||||
{
|
||||
CONF_NAME: fan_name,
|
||||
CONF_ADDRESS: 1234,
|
||||
CONF_SLAVE: 1,
|
||||
CONF_WRITE_TYPE: call_type,
|
||||
**verify,
|
||||
},
|
||||
fan_name,
|
||||
FAN_DOMAIN,
|
||||
CONF_FANS,
|
||||
None,
|
||||
regs,
|
||||
expected,
|
||||
method_discovery=True,
|
||||
scan_interval=5,
|
||||
)
|
||||
assert state == expected
|
||||
|
||||
|
||||
async def test_restore_state_fan(hass):
|
||||
"""Run test for fan restore state."""
|
||||
|
||||
fan_name = "test_fan"
|
||||
entity_id = f"{FAN_DOMAIN}.{fan_name}"
|
||||
test_value = STATE_ON
|
||||
config_fan = {CONF_NAME: fan_name, CONF_ADDRESS: 17}
|
||||
mock_restore_cache(
|
||||
hass,
|
||||
(State(f"{entity_id}", test_value),),
|
||||
)
|
||||
await base_config_test(
|
||||
hass,
|
||||
config_fan,
|
||||
fan_name,
|
||||
FAN_DOMAIN,
|
||||
CONF_FANS,
|
||||
None,
|
||||
method_discovery=True,
|
||||
)
|
||||
assert hass.states.get(entity_id).state == test_value
|
||||
|
||||
|
||||
async def test_fan_service_turn(hass, caplog, mock_pymodbus):
|
||||
"""Run test for service turn_on/turn_off."""
|
||||
|
||||
entity_id1 = f"{FAN_DOMAIN}.fan1"
|
||||
entity_id2 = f"{FAN_DOMAIN}.fan2"
|
||||
config = {
|
||||
MODBUS_DOMAIN: {
|
||||
CONF_TYPE: "tcp",
|
||||
CONF_HOST: "modbusTestHost",
|
||||
CONF_PORT: 5501,
|
||||
CONF_FANS: [
|
||||
{
|
||||
CONF_NAME: "fan1",
|
||||
CONF_ADDRESS: 17,
|
||||
CONF_WRITE_TYPE: CALL_TYPE_REGISTER_HOLDING,
|
||||
},
|
||||
{
|
||||
CONF_NAME: "fan2",
|
||||
CONF_ADDRESS: 17,
|
||||
CONF_WRITE_TYPE: CALL_TYPE_REGISTER_HOLDING,
|
||||
CONF_VERIFY: {},
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
assert await async_setup_component(hass, MODBUS_DOMAIN, config) is True
|
||||
await hass.async_block_till_done()
|
||||
assert MODBUS_DOMAIN in hass.config.components
|
||||
|
||||
assert hass.states.get(entity_id1).state == STATE_OFF
|
||||
await hass.services.async_call(
|
||||
"fan", "turn_on", service_data={"entity_id": entity_id1}
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
assert hass.states.get(entity_id1).state == STATE_ON
|
||||
await hass.services.async_call(
|
||||
"fan", "turn_off", service_data={"entity_id": entity_id1}
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
assert hass.states.get(entity_id1).state == STATE_OFF
|
||||
|
||||
mock_pymodbus.read_holding_registers.return_value = ReadResult([0x01])
|
||||
assert hass.states.get(entity_id2).state == STATE_OFF
|
||||
await hass.services.async_call(
|
||||
"fan", "turn_on", service_data={"entity_id": entity_id2}
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
assert hass.states.get(entity_id2).state == STATE_ON
|
||||
mock_pymodbus.read_holding_registers.return_value = ReadResult([0x00])
|
||||
await hass.services.async_call(
|
||||
"fan", "turn_off", service_data={"entity_id": entity_id2}
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
assert hass.states.get(entity_id2).state == STATE_OFF
|
||||
|
||||
mock_pymodbus.write_register.side_effect = ModbusException("fail write_")
|
||||
await hass.services.async_call(
|
||||
"fan", "turn_on", service_data={"entity_id": entity_id2}
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
assert hass.states.get(entity_id2).state == STATE_UNAVAILABLE
|
||||
mock_pymodbus.write_coil.side_effect = ModbusException("fail write_")
|
||||
await hass.services.async_call(
|
||||
"fan", "turn_off", service_data={"entity_id": entity_id1}
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
assert hass.states.get(entity_id1).state == STATE_UNAVAILABLE
|
||||
|
||||
|
||||
async def test_service_fan_update(hass, mock_pymodbus):
|
||||
"""Run test for service homeassistant.update_entity."""
|
||||
|
||||
entity_id = "fan.test"
|
||||
config = {
|
||||
CONF_FANS: [
|
||||
{
|
||||
CONF_NAME: "test",
|
||||
CONF_ADDRESS: 1234,
|
||||
CONF_WRITE_TYPE: CALL_TYPE_COIL,
|
||||
CONF_VERIFY: {},
|
||||
}
|
||||
]
|
||||
}
|
||||
mock_pymodbus.read_discrete_inputs.return_value = ReadResult([0x01])
|
||||
await prepare_service_update(
|
||||
hass,
|
||||
config,
|
||||
)
|
||||
await hass.services.async_call(
|
||||
"homeassistant", "update_entity", {"entity_id": entity_id}, blocking=True
|
||||
)
|
||||
assert hass.states.get(entity_id).state == STATE_ON
|
||||
mock_pymodbus.read_coils.return_value = ReadResult([0x00])
|
||||
await hass.services.async_call(
|
||||
"homeassistant", "update_entity", {"entity_id": entity_id}, blocking=True
|
||||
)
|
||||
assert hass.states.get(entity_id).state == STATE_OFF
|
Loading…
Reference in New Issue