diff --git a/homeassistant/components/modbus/__init__.py b/homeassistant/components/modbus/__init__.py index e6178ba405c..c6336739bf2 100644 --- a/homeassistant/components/modbus/__init__.py +++ b/homeassistant/components/modbus/__init__.py @@ -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]), } ) diff --git a/homeassistant/components/modbus/const.py b/homeassistant/components/modbus/const.py index 31ffb68cc5c..dfec0dbb50a 100644 --- a/homeassistant/components/modbus/const.py +++ b/homeassistant/components/modbus/const.py @@ -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), ) diff --git a/homeassistant/components/modbus/fan.py b/homeassistant/components/modbus/fan.py new file mode 100644 index 00000000000..0f75748472b --- /dev/null +++ b/homeassistant/components/modbus/fan.py @@ -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() diff --git a/tests/components/modbus/test_fan.py b/tests/components/modbus/test_fan.py new file mode 100644 index 00000000000..2a9414d2277 --- /dev/null +++ b/tests/components/modbus/test_fan.py @@ -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