Add Modbus fan integration (#48558)

* Add Modbus fan entity

* Update to PR.

* Pylint.

Co-authored-by: jan Iversen <jancasacondor@gmail.com>
pull/50923/head
Vladimír Záhradník 2021-05-21 09:56:47 +02:00 committed by GitHub
parent 80d172140f
commit c979101a02
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 487 additions and 0 deletions

View File

@ -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]),
}
)

View File

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

View File

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

View File

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