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