From 1d41f024cf1bb24af34c73c7d018a4808fe686c5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vladim=C3=ADr=20Z=C3=A1hradn=C3=ADk?= <vladimir@zahradnik.io> Date: Sat, 26 Sep 2020 18:11:51 +0200 Subject: [PATCH] Add Modbus cover (#33642) * Add Modbus cover * Fix improper commands written for Modbus cover via coil * Make changes per review comments * Fix default hub not defined Since support for multiple hubs was added, the default hub option was not implemented correctly. Now I added necessary logic to make it work. First hub in a list will be used as a default hub. * Move Cover config under Modbus section * Revert setting up a default hub alias * Make hub mandatory for Cover * Add default scan interval * Read scan_interval from discovery info * Fix linter error * Use default scan interval from Cover platform * Handle polling for Modbus cover directly inside entity * Move covers under hub config * Fix for review comment * Call update() from Cover actuator methods * Fix time validation --- CODEOWNERS | 2 +- homeassistant/components/modbus/__init__.py | 75 +++++- homeassistant/components/modbus/const.py | 10 + homeassistant/components/modbus/cover.py | 244 ++++++++++++++++++ homeassistant/components/modbus/manifest.json | 2 +- 5 files changed, 323 insertions(+), 10 deletions(-) create mode 100644 homeassistant/components/modbus/cover.py diff --git a/CODEOWNERS b/CODEOWNERS index 05c3dcf5087..cfaae6bc496 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -262,7 +262,7 @@ homeassistant/components/min_max/* @fabaff homeassistant/components/minecraft_server/* @elmurato homeassistant/components/minio/* @tkislan homeassistant/components/mobile_app/* @robbiet480 -homeassistant/components/modbus/* @adamchengtkc @janiversen +homeassistant/components/modbus/* @adamchengtkc @janiversen @vzahradnik homeassistant/components/monoprice/* @etsinko @OnFreund homeassistant/components/moon/* @fabaff homeassistant/components/mpd/* @fabaff diff --git a/homeassistant/components/modbus/__init__.py b/homeassistant/components/modbus/__init__.py index 0a7ea08543a..822000cb56a 100644 --- a/homeassistant/components/modbus/__init__.py +++ b/homeassistant/components/modbus/__init__.py @@ -6,29 +6,50 @@ from pymodbus.client.sync import ModbusSerialClient, ModbusTcpClient, ModbusUdpC from pymodbus.transaction import ModbusRtuFramer import voluptuous as vol +from homeassistant.components.cover import ( + DEVICE_CLASSES_SCHEMA as COVER_DEVICE_CLASSES_SCHEMA, +) from homeassistant.const import ( ATTR_STATE, + CONF_COVERS, CONF_DELAY, + CONF_DEVICE_CLASS, CONF_HOST, CONF_METHOD, CONF_NAME, CONF_PORT, + CONF_SCAN_INTERVAL, + CONF_SLAVE, CONF_TIMEOUT, CONF_TYPE, EVENT_HOMEASSISTANT_STOP, ) import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.discovery import load_platform from .const import ( ATTR_ADDRESS, ATTR_HUB, ATTR_UNIT, ATTR_VALUE, + CALL_TYPE_COIL, + CALL_TYPE_REGISTER_HOLDING, + CALL_TYPE_REGISTER_INPUT, CONF_BAUDRATE, CONF_BYTESIZE, + CONF_INPUT_TYPE, CONF_PARITY, + CONF_REGISTER, + CONF_STATE_CLOSED, + CONF_STATE_CLOSING, + CONF_STATE_OPEN, + CONF_STATE_OPENING, + CONF_STATUS_REGISTER, + CONF_STATUS_REGISTER_TYPE, CONF_STOPBITS, DEFAULT_HUB, + DEFAULT_SCAN_INTERVAL, + DEFAULT_SLAVE, MODBUS_DOMAIN as DOMAIN, SERVICE_WRITE_COIL, SERVICE_WRITE_REGISTER, @@ -36,9 +57,33 @@ from .const import ( _LOGGER = logging.getLogger(__name__) - BASE_SCHEMA = vol.Schema({vol.Optional(CONF_NAME, default=DEFAULT_HUB): cv.string}) +COVERS_SCHEMA = vol.All( + cv.has_at_least_one_key(CALL_TYPE_COIL, CONF_REGISTER), + vol.Schema( + { + vol.Required(CONF_NAME): cv.string, + vol.Optional(CONF_SCAN_INTERVAL, default=DEFAULT_SCAN_INTERVAL): vol.All( + cv.time_period, lambda value: value.total_seconds() + ), + vol.Optional(CONF_DEVICE_CLASS): COVER_DEVICE_CLASSES_SCHEMA, + vol.Optional(CONF_SLAVE, default=DEFAULT_SLAVE): cv.positive_int, + vol.Optional(CONF_STATE_CLOSED, default=0): cv.positive_int, + vol.Optional(CONF_STATE_CLOSING, default=3): cv.positive_int, + vol.Optional(CONF_STATE_OPEN, default=1): cv.positive_int, + vol.Optional(CONF_STATE_OPENING, default=2): cv.positive_int, + vol.Optional(CONF_STATUS_REGISTER): cv.positive_int, + vol.Optional( + CONF_STATUS_REGISTER_TYPE, + default=CALL_TYPE_REGISTER_HOLDING, + ): vol.In([CALL_TYPE_REGISTER_HOLDING, CALL_TYPE_REGISTER_INPUT]), + vol.Exclusive(CALL_TYPE_COIL, CONF_INPUT_TYPE): cv.positive_int, + vol.Exclusive(CONF_REGISTER, CONF_INPUT_TYPE): cv.positive_int, + } + ), +) + SERIAL_SCHEMA = BASE_SCHEMA.extend( { vol.Required(CONF_BAUDRATE): cv.positive_int, @@ -49,6 +94,7 @@ SERIAL_SCHEMA = BASE_SCHEMA.extend( vol.Required(CONF_STOPBITS): vol.Any(1, 2), vol.Required(CONF_TYPE): "serial", vol.Optional(CONF_TIMEOUT, default=3): cv.socket_timeout, + vol.Optional(CONF_COVERS): vol.All(cv.ensure_list, [COVERS_SCHEMA]), } ) @@ -59,14 +105,10 @@ ETHERNET_SCHEMA = BASE_SCHEMA.extend( vol.Required(CONF_TYPE): vol.Any("tcp", "udp", "rtuovertcp"), vol.Optional(CONF_TIMEOUT, default=3): cv.socket_timeout, vol.Optional(CONF_DELAY, default=0): cv.positive_int, + vol.Optional(CONF_COVERS): vol.All(cv.ensure_list, [COVERS_SCHEMA]), } ) -CONFIG_SCHEMA = vol.Schema( - {DOMAIN: vol.All(cv.ensure_list, [vol.Any(SERIAL_SCHEMA, ETHERNET_SCHEMA)])}, - extra=vol.ALLOW_EXTRA, -) - SERVICE_WRITE_REGISTER_SCHEMA = vol.Schema( { vol.Optional(ATTR_HUB, default=DEFAULT_HUB): cv.string, @@ -87,13 +129,30 @@ SERVICE_WRITE_COIL_SCHEMA = vol.Schema( } ) +CONFIG_SCHEMA = vol.Schema( + { + DOMAIN: vol.All( + cv.ensure_list, + [ + vol.Any(SERIAL_SCHEMA, ETHERNET_SCHEMA), + ], + ), + }, + extra=vol.ALLOW_EXTRA, +) + def setup(hass, config): """Set up Modbus component.""" hass.data[DOMAIN] = hub_collect = {} - for client_config in config[DOMAIN]: - hub_collect[client_config[CONF_NAME]] = ModbusHub(client_config) + for conf_hub in config[DOMAIN]: + hub_collect[conf_hub[CONF_NAME]] = ModbusHub(conf_hub) + + # load platforms + for component, conf_key in (("cover", CONF_COVERS),): + if conf_key in conf_hub: + load_platform(hass, component, DOMAIN, conf_hub, config) def stop_modbus(event): """Stop Modbus service.""" diff --git a/homeassistant/components/modbus/const.py b/homeassistant/components/modbus/const.py index c12c50cdc07..dc29dd626ae 100644 --- a/homeassistant/components/modbus/const.py +++ b/homeassistant/components/modbus/const.py @@ -46,6 +46,7 @@ ATTR_UNIT = "unit" ATTR_VALUE = "value" SERVICE_WRITE_COIL = "write_coil" SERVICE_WRITE_REGISTER = "write_register" +DEFAULT_SCAN_INTERVAL = 15 # seconds # binary_sensor.py CONF_INPUTS = "inputs" @@ -71,3 +72,12 @@ CONF_UNIT = "temperature_unit" CONF_MAX_TEMP = "max_temp" CONF_MIN_TEMP = "min_temp" CONF_STEP = "temp_step" + +# cover.py +CONF_STATE_OPEN = "state_open" +CONF_STATE_CLOSED = "state_closed" +CONF_STATE_OPENING = "state_opening" +CONF_STATE_CLOSING = "state_closing" +CONF_STATUS_REGISTER = "status_register" +CONF_STATUS_REGISTER_TYPE = "status_register_type" +DEFAULT_SLAVE = 1 diff --git a/homeassistant/components/modbus/cover.py b/homeassistant/components/modbus/cover.py new file mode 100644 index 00000000000..a7c9c301ac5 --- /dev/null +++ b/homeassistant/components/modbus/cover.py @@ -0,0 +1,244 @@ +"""Support for Modbus covers.""" +from datetime import timedelta +import logging +from typing import Any, Dict, Optional + +from pymodbus.exceptions import ConnectionException, ModbusException +from pymodbus.pdu import ExceptionResponse + +from homeassistant.components.cover import SUPPORT_CLOSE, SUPPORT_OPEN, CoverEntity +from homeassistant.const import ( + CONF_COVERS, + CONF_DEVICE_CLASS, + CONF_NAME, + CONF_SCAN_INTERVAL, + CONF_SLAVE, +) +from homeassistant.helpers.event import async_track_time_interval +from homeassistant.helpers.restore_state import RestoreEntity +from homeassistant.helpers.typing import ( + ConfigType, + DiscoveryInfoType, + HomeAssistantType, +) + +from . import ModbusHub +from .const import ( + CALL_TYPE_COIL, + CALL_TYPE_REGISTER_HOLDING, + CALL_TYPE_REGISTER_INPUT, + CONF_REGISTER, + CONF_STATE_CLOSED, + CONF_STATE_CLOSING, + CONF_STATE_OPEN, + CONF_STATE_OPENING, + CONF_STATUS_REGISTER, + CONF_STATUS_REGISTER_TYPE, + MODBUS_DOMAIN, +) + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_platform( + hass: HomeAssistantType, + config: ConfigType, + async_add_entities, + discovery_info: Optional[DiscoveryInfoType] = None, +): + """Read configuration and create Modbus cover.""" + if discovery_info is None: + return + + covers = [] + for cover in discovery_info[CONF_COVERS]: + hub: ModbusHub = hass.data[MODBUS_DOMAIN][discovery_info[CONF_NAME]] + covers.append(ModbusCover(hub, cover)) + + async_add_entities(covers) + + +class ModbusCover(CoverEntity, RestoreEntity): + """Representation of a Modbus cover.""" + + def __init__( + self, + hub: ModbusHub, + config: Dict[str, Any], + ): + """Initialize the modbus cover.""" + self._hub: ModbusHub = hub + self._coil = config.get(CALL_TYPE_COIL) + self._device_class = config.get(CONF_DEVICE_CLASS) + self._name = config[CONF_NAME] + self._register = config.get(CONF_REGISTER) + self._slave = config[CONF_SLAVE] + self._state_closed = config[CONF_STATE_CLOSED] + self._state_closing = config[CONF_STATE_CLOSING] + self._state_open = config[CONF_STATE_OPEN] + self._state_opening = config[CONF_STATE_OPENING] + self._status_register = config.get(CONF_STATUS_REGISTER) + self._status_register_type = config[CONF_STATUS_REGISTER_TYPE] + self._scan_interval = timedelta(seconds=config[CONF_SCAN_INTERVAL]) + self._value = None + self._available = True + + # If we read cover status from coil, and not from optional status register, + # we interpret boolean value False as closed cover, and value True as open cover. + # Intermediate states are not supported in such a setup. + if self._coil is not None and self._status_register is None: + self._state_closed = False + self._state_open = True + self._state_closing = None + self._state_opening = None + + # If we read cover status from the main register (i.e., an optional + # status register is not specified), we need to make sure the register_type + # is set to "holding". + if self._register is not None and self._status_register is None: + self._status_register = self._register + self._status_register_type = CALL_TYPE_REGISTER_HOLDING + + async def async_added_to_hass(self): + """Handle entity which will be added.""" + state = await self.async_get_last_state() + if not state: + return + self._value = state.state + + async_track_time_interval( + self.hass, lambda arg: self._update(), self._scan_interval + ) + + @property + def device_class(self) -> Optional[str]: + """Return the device class of the sensor.""" + return self._device_class + + @property + def name(self): + """Return the name of the switch.""" + return self._name + + @property + def supported_features(self): + """Flag supported features.""" + return SUPPORT_OPEN | SUPPORT_CLOSE + + @property + def available(self) -> bool: + """Return True if entity is available.""" + return self._available + + @property + def is_opening(self): + """Return if the cover is opening or not.""" + return self._value == self._state_opening + + @property + def is_closing(self): + """Return if the cover is closing or not.""" + return self._value == self._state_closing + + @property + def is_closed(self): + """Return if the cover is closed or not.""" + return self._value == self._state_closed + + @property + def should_poll(self): + """Return True if entity has to be polled for state. + + False if entity pushes its state to HA. + """ + + # Handle polling directly in this entity + return False + + def open_cover(self, **kwargs: Any) -> None: + """Open cover.""" + if self._coil is not None: + self._write_coil(True) + else: + self._write_register(self._state_open) + + self._update() + + def close_cover(self, **kwargs: Any) -> None: + """Close cover.""" + if self._coil is not None: + self._write_coil(False) + else: + self._write_register(self._state_closed) + + self._update() + + def _update(self): + """Update the state of the cover.""" + if self._coil is not None and self._status_register is None: + self._value = self._read_coil() + else: + self._value = self._read_status_register() + + self.schedule_update_ha_state() + + def _read_status_register(self) -> Optional[int]: + """Read status register using the Modbus hub slave.""" + try: + if self._status_register_type == CALL_TYPE_REGISTER_INPUT: + result = self._hub.read_input_registers( + self._slave, self._status_register, 1 + ) + else: + result = self._hub.read_holding_registers( + self._slave, self._status_register, 1 + ) + except ConnectionException: + self._available = False + return + + if isinstance(result, (ModbusException, ExceptionResponse)): + self._available = False + return + + value = int(result.registers[0]) + self._available = True + + return value + + def _write_register(self, value): + """Write holding register using the Modbus hub slave.""" + try: + self._hub.write_register(self._slave, self._register, value) + except ConnectionException: + self._available = False + return + + self._available = True + + def _read_coil(self) -> Optional[bool]: + """Read coil using the Modbus hub slave.""" + try: + result = self._hub.read_coils(self._slave, self._coil, 1) + except ConnectionException: + self._available = False + return + + if isinstance(result, (ModbusException, ExceptionResponse)): + self._available = False + return + + value = bool(result.bits[0]) + self._available = True + + return value + + def _write_coil(self, value): + """Write coil using the Modbus hub slave.""" + try: + self._hub.write_coil(self._slave, self._coil, value) + except ConnectionException: + self._available = False + return + + self._available = True diff --git a/homeassistant/components/modbus/manifest.json b/homeassistant/components/modbus/manifest.json index a9155c7b628..05e9c39c4b5 100644 --- a/homeassistant/components/modbus/manifest.json +++ b/homeassistant/components/modbus/manifest.json @@ -3,5 +3,5 @@ "name": "Modbus", "documentation": "https://www.home-assistant.io/integrations/modbus", "requirements": ["pymodbus==2.3.0"], - "codeowners": ["@adamchengtkc", "@janiversen"] + "codeowners": ["@adamchengtkc", "@janiversen", "@vzahradnik"] } -- GitLab