Add service to stop/restart modbus (#55599)

* Add service to stop/restart modbus.

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
This commit is contained in:
jan iversen 2021-09-14 09:42:50 +02:00 committed by GitHub
parent 31623368c8
commit aaa62dadec
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 180 additions and 28 deletions

View file

@ -369,6 +369,11 @@ SERVICE_WRITE_COIL_SCHEMA = vol.Schema(
), ),
} }
) )
SERVICE_STOP_START_SCHEMA = vol.Schema(
{
vol.Required(ATTR_HUB): cv.string,
}
)
def get_hub(hass: HomeAssistant, name: str) -> ModbusHub: def get_hub(hass: HomeAssistant, name: str) -> ModbusHub:
@ -379,5 +384,9 @@ def get_hub(hass: HomeAssistant, name: str) -> ModbusHub:
async def async_setup(hass, config): async def async_setup(hass, config):
"""Set up Modbus component.""" """Set up Modbus component."""
return await async_modbus_setup( return await async_modbus_setup(
hass, config, SERVICE_WRITE_REGISTER_SCHEMA, SERVICE_WRITE_COIL_SCHEMA hass,
config,
SERVICE_WRITE_REGISTER_SCHEMA,
SERVICE_WRITE_COIL_SCHEMA,
SERVICE_STOP_START_SCHEMA,
) )

View file

@ -5,7 +5,7 @@ from abc import abstractmethod
from datetime import timedelta from datetime import timedelta
import logging import logging
import struct import struct
from typing import Any from typing import Any, Callable
from homeassistant.const import ( from homeassistant.const import (
CONF_ADDRESS, CONF_ADDRESS,
@ -21,6 +21,8 @@ from homeassistant.const import (
CONF_STRUCTURE, CONF_STRUCTURE,
STATE_ON, STATE_ON,
) )
from homeassistant.core import callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity import Entity, ToggleEntity from homeassistant.helpers.entity import Entity, ToggleEntity
from homeassistant.helpers.event import async_call_later, async_track_time_interval from homeassistant.helpers.event import async_call_later, async_track_time_interval
from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.restore_state import RestoreEntity
@ -50,6 +52,8 @@ from .const import (
CONF_VERIFY, CONF_VERIFY,
CONF_WRITE_TYPE, CONF_WRITE_TYPE,
DATA_TYPE_STRING, DATA_TYPE_STRING,
SIGNAL_START_ENTITY,
SIGNAL_STOP_ENTITY,
) )
from .modbus import ModbusHub from .modbus import ModbusHub
@ -73,6 +77,7 @@ class BasePlatform(Entity):
self._value = None self._value = None
self._scan_interval = int(entry[CONF_SCAN_INTERVAL]) self._scan_interval = int(entry[CONF_SCAN_INTERVAL])
self._call_active = False self._call_active = False
self._cancel_timer: Callable[[], None] | None = None
self._attr_name = entry[CONF_NAME] self._attr_name = entry[CONF_NAME]
self._attr_should_poll = False self._attr_should_poll = False
@ -86,13 +91,35 @@ class BasePlatform(Entity):
async def async_update(self, now=None): async def async_update(self, now=None):
"""Virtual function to be overwritten.""" """Virtual function to be overwritten."""
async def async_base_added_to_hass(self): @callback
"""Handle entity which will be added.""" def async_remote_start(self) -> None:
"""Remote start entity."""
if self._cancel_timer:
self._cancel_timer()
self._cancel_timer = None
if self._scan_interval > 0: if self._scan_interval > 0:
cancel_func = async_track_time_interval( self._cancel_timer = async_track_time_interval(
self.hass, self.async_update, timedelta(seconds=self._scan_interval) self.hass, self.async_update, timedelta(seconds=self._scan_interval)
) )
self._hub.entity_timers.append(cancel_func) self._attr_available = True
self.async_write_ha_state()
@callback
def async_remote_stop(self) -> None:
"""Remote stop entity."""
if self._cancel_timer:
self._cancel_timer()
self._cancel_timer = None
self._attr_available = False
self.async_write_ha_state()
async def async_base_added_to_hass(self):
"""Handle entity which will be added."""
self.async_remote_start()
async_dispatcher_connect(self.hass, SIGNAL_STOP_ENTITY, self.async_remote_stop)
async_dispatcher_connect(
self.hass, SIGNAL_START_ENTITY, self.async_remote_start
)
class BaseStructPlatform(BasePlatform, RestoreEntity): class BaseStructPlatform(BasePlatform, RestoreEntity):

View file

@ -106,6 +106,12 @@ CALL_TYPE_X_REGISTER_HOLDINGS = "holdings"
# service calls # service calls
SERVICE_WRITE_COIL = "write_coil" SERVICE_WRITE_COIL = "write_coil"
SERVICE_WRITE_REGISTER = "write_register" SERVICE_WRITE_REGISTER = "write_register"
SERVICE_STOP = "stop"
SERVICE_RESTART = "restart"
# dispatcher signals
SIGNAL_STOP_ENTITY = "modbus.stop"
SIGNAL_START_ENTITY = "modbus.start"
# integration names # integration names
DEFAULT_HUB = "modbus_hub" DEFAULT_HUB = "modbus_hub"

View file

@ -74,6 +74,7 @@ class ModbusCover(BasePlatform, CoverEntity, RestoreEntity):
self._status_register_type = config[CONF_STATUS_REGISTER_TYPE] self._status_register_type = config[CONF_STATUS_REGISTER_TYPE]
self._attr_supported_features = SUPPORT_OPEN | SUPPORT_CLOSE self._attr_supported_features = SUPPORT_OPEN | SUPPORT_CLOSE
self._attr_is_closed = False
# If we read cover status from coil, and not from optional status register, # 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. # we interpret boolean value False as closed cover, and value True as open cover.

View file

@ -20,8 +20,9 @@ from homeassistant.const import (
CONF_TYPE, CONF_TYPE,
EVENT_HOMEASSISTANT_STOP, EVENT_HOMEASSISTANT_STOP,
) )
from homeassistant.core import CALLBACK_TYPE, callback from homeassistant.core import callback
from homeassistant.helpers.discovery import async_load_platform from homeassistant.helpers.discovery import async_load_platform
from homeassistant.helpers.dispatcher import async_dispatcher_send
from homeassistant.helpers.event import async_call_later from homeassistant.helpers.event import async_call_later
from .const import ( from .const import (
@ -51,8 +52,12 @@ from .const import (
PLATFORMS, PLATFORMS,
RTUOVERTCP, RTUOVERTCP,
SERIAL, SERIAL,
SERVICE_RESTART,
SERVICE_STOP,
SERVICE_WRITE_COIL, SERVICE_WRITE_COIL,
SERVICE_WRITE_REGISTER, SERVICE_WRITE_REGISTER,
SIGNAL_START_ENTITY,
SIGNAL_STOP_ENTITY,
TCP, TCP,
UDP, UDP,
) )
@ -107,7 +112,11 @@ PYMODBUS_CALL = [
async def async_modbus_setup( async def async_modbus_setup(
hass, config, service_write_register_schema, service_write_coil_schema hass,
config,
service_write_register_schema,
service_write_coil_schema,
service_stop_start_schema,
): ):
"""Set up Modbus component.""" """Set up Modbus component."""
@ -131,9 +140,9 @@ async def async_modbus_setup(
async def async_stop_modbus(event): async def async_stop_modbus(event):
"""Stop Modbus service.""" """Stop Modbus service."""
async_dispatcher_send(hass, SIGNAL_STOP_ENTITY)
for client in hub_collect.values(): for client in hub_collect.values():
await client.async_close() await client.async_close()
del client
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, async_stop_modbus) hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, async_stop_modbus)
@ -142,15 +151,15 @@ async def async_modbus_setup(
unit = int(float(service.data[ATTR_UNIT])) unit = int(float(service.data[ATTR_UNIT]))
address = int(float(service.data[ATTR_ADDRESS])) address = int(float(service.data[ATTR_ADDRESS]))
value = service.data[ATTR_VALUE] value = service.data[ATTR_VALUE]
client_name = ( hub = hub_collect[
service.data[ATTR_HUB] if ATTR_HUB in service.data else DEFAULT_HUB service.data[ATTR_HUB] if ATTR_HUB in service.data else DEFAULT_HUB
) ]
if isinstance(value, list): if isinstance(value, list):
await hub_collect[client_name].async_pymodbus_call( await hub.async_pymodbus_call(
unit, address, [int(float(i)) for i in value], CALL_TYPE_WRITE_REGISTERS unit, address, [int(float(i)) for i in value], CALL_TYPE_WRITE_REGISTERS
) )
else: else:
await hub_collect[client_name].async_pymodbus_call( await hub.async_pymodbus_call(
unit, address, int(float(value)), CALL_TYPE_WRITE_REGISTER unit, address, int(float(value)), CALL_TYPE_WRITE_REGISTER
) )
@ -166,35 +175,48 @@ async def async_modbus_setup(
unit = service.data[ATTR_UNIT] unit = service.data[ATTR_UNIT]
address = service.data[ATTR_ADDRESS] address = service.data[ATTR_ADDRESS]
state = service.data[ATTR_STATE] state = service.data[ATTR_STATE]
client_name = ( hub = hub_collect[
service.data[ATTR_HUB] if ATTR_HUB in service.data else DEFAULT_HUB service.data[ATTR_HUB] if ATTR_HUB in service.data else DEFAULT_HUB
) ]
if isinstance(state, list): if isinstance(state, list):
await hub_collect[client_name].async_pymodbus_call( await hub.async_pymodbus_call(unit, address, state, CALL_TYPE_WRITE_COILS)
unit, address, state, CALL_TYPE_WRITE_COILS
)
else: else:
await hub_collect[client_name].async_pymodbus_call( await hub.async_pymodbus_call(unit, address, state, CALL_TYPE_WRITE_COIL)
unit, address, state, CALL_TYPE_WRITE_COIL
)
hass.services.async_register( hass.services.async_register(
DOMAIN, SERVICE_WRITE_COIL, async_write_coil, schema=service_write_coil_schema DOMAIN, SERVICE_WRITE_COIL, async_write_coil, schema=service_write_coil_schema
) )
async def async_stop_hub(service):
"""Stop Modbus hub."""
async_dispatcher_send(hass, SIGNAL_STOP_ENTITY)
hub = hub_collect[service.data[ATTR_HUB]]
await hub.async_close()
hass.services.async_register(
DOMAIN, SERVICE_STOP, async_stop_hub, schema=service_stop_start_schema
)
async def async_restart_hub(service):
"""Restart Modbus hub."""
async_dispatcher_send(hass, SIGNAL_START_ENTITY)
hub = hub_collect[service.data[ATTR_HUB]]
await hub.async_restart()
hass.services.async_register(
DOMAIN, SERVICE_RESTART, async_restart_hub, schema=service_stop_start_schema
)
return True return True
class ModbusHub: class ModbusHub:
"""Thread safe wrapper class for pymodbus.""" """Thread safe wrapper class for pymodbus."""
name: str
def __init__(self, hass, client_config): def __init__(self, hass, client_config):
"""Initialize the Modbus hub.""" """Initialize the Modbus hub."""
# generic configuration # generic configuration
self._client = None self._client = None
self.entity_timers: list[CALLBACK_TYPE] = []
self._async_cancel_listener = None self._async_cancel_listener = None
self._in_error = False self._in_error = False
self._lock = asyncio.Lock() self._lock = asyncio.Lock()
@ -284,29 +306,40 @@ class ModbusHub:
self._async_cancel_listener = None self._async_cancel_listener = None
self._config_delay = 0 self._config_delay = 0
async def async_restart(self):
"""Reconnect client."""
if self._client:
await self.async_close()
await self.async_setup()
async def async_close(self): async def async_close(self):
"""Disconnect client.""" """Disconnect client."""
if self._async_cancel_listener: if self._async_cancel_listener:
self._async_cancel_listener() self._async_cancel_listener()
self._async_cancel_listener = None self._async_cancel_listener = None
for call in self.entity_timers:
call()
self.entity_timers = []
async with self._lock: async with self._lock:
if self._client: if self._client:
try: try:
self._client.close() self._client.close()
except ModbusException as exception_error: except ModbusException as exception_error:
self._log_error(str(exception_error)) self._log_error(str(exception_error))
del self._client
self._client = None self._client = None
message = f"modbus {self.name} communication closed"
_LOGGER.warning(message)
def _pymodbus_connect(self): def _pymodbus_connect(self):
"""Connect client.""" """Connect client."""
try: try:
return self._client.connect() self._client.connect()
except ModbusException as exception_error: except ModbusException as exception_error:
self._log_error(str(exception_error), error_state=False) self._log_error(str(exception_error), error_state=False)
return False return False
else:
message = f"modbus {self.name} communication open"
_LOGGER.warning(message)
return True
def _pymodbus_call(self, unit, address, value, use_call): def _pymodbus_call(self, unit, address, value, use_call):
"""Call sync. pymodbus.""" """Call sync. pymodbus."""

View file

@ -66,3 +66,25 @@ write_register:
default: "modbus_hub" default: "modbus_hub"
selector: selector:
text: text:
stop:
name: Stop
description: Stop modbus hub.
fields:
hub:
name: Hub
description: Modbus hub name.
example: "hub1"
default: "modbus_hub"
selector:
text:
restart:
name: Restart
description: Restart modbus hub (if running stop then start).
fields:
hub:
name: Hub
description: Modbus hub name.
example: "hub1"
default: "modbus_hub"
selector:
text:

View file

@ -53,6 +53,8 @@ from homeassistant.components.modbus.const import (
MODBUS_DOMAIN as DOMAIN, MODBUS_DOMAIN as DOMAIN,
RTUOVERTCP, RTUOVERTCP,
SERIAL, SERIAL,
SERVICE_RESTART,
SERVICE_STOP,
SERVICE_WRITE_COIL, SERVICE_WRITE_COIL,
SERVICE_WRITE_REGISTER, SERVICE_WRITE_REGISTER,
TCP, TCP,
@ -82,6 +84,7 @@ from homeassistant.const import (
EVENT_HOMEASSISTANT_STOP, EVENT_HOMEASSISTANT_STOP,
STATE_ON, STATE_ON,
STATE_UNAVAILABLE, STATE_UNAVAILABLE,
STATE_UNKNOWN,
) )
from homeassistant.setup import async_setup_component from homeassistant.setup import async_setup_component
import homeassistant.util.dt as dt_util import homeassistant.util.dt as dt_util
@ -715,3 +718,54 @@ async def test_shutdown(hass, caplog, mock_pymodbus, mock_modbus_with_pymodbus):
await hass.async_block_till_done() await hass.async_block_till_done()
assert mock_pymodbus.close.called assert mock_pymodbus.close.called
assert caplog.text == "" assert caplog.text == ""
@pytest.mark.parametrize(
"do_config",
[
{
CONF_SENSORS: [
{
CONF_NAME: TEST_ENTITY_NAME,
CONF_ADDRESS: 51,
}
]
},
],
)
async def test_stop_restart(hass, caplog, mock_modbus):
"""Run test for service stop."""
entity_id = f"{SENSOR_DOMAIN}.{TEST_ENTITY_NAME}"
assert hass.states.get(entity_id).state == STATE_UNKNOWN
hass.states.async_set(entity_id, 17)
await hass.async_block_till_done()
assert hass.states.get(entity_id).state == "17"
mock_modbus.reset_mock()
caplog.clear()
data = {
ATTR_HUB: TEST_MODBUS_NAME,
}
await hass.services.async_call(DOMAIN, SERVICE_STOP, data, blocking=True)
await hass.async_block_till_done()
assert hass.states.get(entity_id).state == STATE_UNAVAILABLE
assert mock_modbus.close.called
assert f"modbus {TEST_MODBUS_NAME} communication closed" in caplog.text
mock_modbus.reset_mock()
caplog.clear()
await hass.services.async_call(DOMAIN, SERVICE_RESTART, data, blocking=True)
await hass.async_block_till_done()
assert not mock_modbus.close.called
assert mock_modbus.connect.called
assert f"modbus {TEST_MODBUS_NAME} communication open" in caplog.text
mock_modbus.reset_mock()
caplog.clear()
await hass.services.async_call(DOMAIN, SERVICE_RESTART, data, blocking=True)
await hass.async_block_till_done()
assert mock_modbus.close.called
assert mock_modbus.connect.called
assert f"modbus {TEST_MODBUS_NAME} communication closed" in caplog.text
assert f"modbus {TEST_MODBUS_NAME} communication open" in caplog.text