diff --git a/CODEOWNERS b/CODEOWNERS index dee1a510e2e..5ea7c376329 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -231,7 +231,7 @@ homeassistant/components/min_max/* @fabaff homeassistant/components/minecraft_server/* @elmurato homeassistant/components/minio/* @tkislan homeassistant/components/mobile_app/* @robbiet480 -homeassistant/components/modbus/* @adamchengtkc +homeassistant/components/modbus/* @adamchengtkc @janiversen homeassistant/components/monoprice/* @etsinko homeassistant/components/moon/* @fabaff homeassistant/components/mpd/* @fabaff diff --git a/homeassistant/components/modbus/__init__.py b/homeassistant/components/modbus/__init__.py index 218d3d3baa9..9b055155306 100644 --- a/homeassistant/components/modbus/__init__.py +++ b/homeassistant/components/modbus/__init__.py @@ -1,13 +1,19 @@ """Support for Modbus.""" +import asyncio import logging -import threading -from pymodbus.client.sync import ModbusSerialClient, ModbusTcpClient, ModbusUdpClient +from pymodbus.client.asynchronous import schedulers +from pymodbus.client.asynchronous.serial import AsyncModbusSerialClient as ClientSerial +from pymodbus.client.asynchronous.tcp import AsyncModbusTCPClient as ClientTCP +from pymodbus.client.asynchronous.udp import AsyncModbusUDPClient as ClientUDP +from pymodbus.exceptions import ModbusException +from pymodbus.pdu import ExceptionResponse from pymodbus.transaction import ModbusRtuFramer import voluptuous as vol from homeassistant.const import ( ATTR_STATE, + CONF_DELAY, CONF_HOST, CONF_METHOD, CONF_NAME, @@ -19,24 +25,26 @@ from homeassistant.const import ( ) import homeassistant.helpers.config_validation as cv -_LOGGER = logging.getLogger(__name__) +from .const import ( # DEFAULT_HUB, + ATTR_ADDRESS, + ATTR_HUB, + ATTR_UNIT, + ATTR_VALUE, + CONF_BAUDRATE, + CONF_BYTESIZE, + CONF_PARITY, + CONF_STOPBITS, + MODBUS_DOMAIN, + SERVICE_WRITE_COIL, + SERVICE_WRITE_REGISTER, +) -ATTR_ADDRESS = "address" -ATTR_HUB = "hub" -ATTR_UNIT = "unit" -ATTR_VALUE = "value" - -CONF_BAUDRATE = "baudrate" -CONF_BYTESIZE = "bytesize" +# Kept for compatibility with other integrations, TO BE REMOVED CONF_HUB = "hub" -CONF_PARITY = "parity" -CONF_STOPBITS = "stopbits" - DEFAULT_HUB = "default" -DOMAIN = "modbus" +DOMAIN = MODBUS_DOMAIN -SERVICE_WRITE_COIL = "write_coil" -SERVICE_WRITE_REGISTER = "write_register" +_LOGGER = logging.getLogger(__name__) BASE_SCHEMA = vol.Schema({vol.Optional(CONF_NAME, default=DEFAULT_HUB): cv.string}) @@ -59,11 +67,12 @@ ETHERNET_SCHEMA = BASE_SCHEMA.extend( vol.Required(CONF_PORT): cv.port, 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, } ) CONFIG_SCHEMA = vol.Schema( - {DOMAIN: vol.All(cv.ensure_list, [vol.Any(SERIAL_SCHEMA, ETHERNET_SCHEMA)])}, + {MODBUS_DOMAIN: vol.All(cv.ensure_list, [vol.Any(SERIAL_SCHEMA, ETHERNET_SCHEMA)])}, extra=vol.ALLOW_EXTRA, ) @@ -88,97 +97,65 @@ SERVICE_WRITE_COIL_SCHEMA = vol.Schema( ) -def setup_client(client_config): - """Set up pymodbus client.""" - client_type = client_config[CONF_TYPE] - - if client_type == "serial": - return ModbusSerialClient( - method=client_config[CONF_METHOD], - port=client_config[CONF_PORT], - baudrate=client_config[CONF_BAUDRATE], - stopbits=client_config[CONF_STOPBITS], - bytesize=client_config[CONF_BYTESIZE], - parity=client_config[CONF_PARITY], - timeout=client_config[CONF_TIMEOUT], - ) - if client_type == "rtuovertcp": - return ModbusTcpClient( - host=client_config[CONF_HOST], - port=client_config[CONF_PORT], - framer=ModbusRtuFramer, - timeout=client_config[CONF_TIMEOUT], - ) - if client_type == "tcp": - return ModbusTcpClient( - host=client_config[CONF_HOST], - port=client_config[CONF_PORT], - timeout=client_config[CONF_TIMEOUT], - ) - if client_type == "udp": - return ModbusUdpClient( - host=client_config[CONF_HOST], - port=client_config[CONF_PORT], - timeout=client_config[CONF_TIMEOUT], - ) - assert False - - -def setup(hass, config): +async def async_setup(hass, config): """Set up Modbus component.""" - hass.data[DOMAIN] = hub_collect = {} + hass.data[MODBUS_DOMAIN] = hub_collect = {} - for client_config in config[DOMAIN]: - client = setup_client(client_config) - name = client_config[CONF_NAME] - hub_collect[name] = ModbusHub(client, name) - _LOGGER.debug("Setting up hub: %s", client_config) + _LOGGER.debug("registering hubs") + for client_config in config[MODBUS_DOMAIN]: + hub_collect[client_config[CONF_NAME]] = ModbusHub(client_config, hass.loop) def stop_modbus(event): """Stop Modbus service.""" for client in hub_collect.values(): - client.close() + del client def start_modbus(event): """Start Modbus service.""" for client in hub_collect.values(): - client.connect() + _LOGGER.debug("setup hub %s", client.name) + client.setup() hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, stop_modbus) # Register services for modbus - hass.services.register( - DOMAIN, + hass.services.async_register( + MODBUS_DOMAIN, SERVICE_WRITE_REGISTER, write_register, schema=SERVICE_WRITE_REGISTER_SCHEMA, ) - hass.services.register( - DOMAIN, SERVICE_WRITE_COIL, write_coil, schema=SERVICE_WRITE_COIL_SCHEMA + hass.services.async_register( + MODBUS_DOMAIN, + SERVICE_WRITE_COIL, + write_coil, + schema=SERVICE_WRITE_COIL_SCHEMA, ) - def write_register(service): + async def write_register(service): """Write Modbus registers.""" unit = int(float(service.data[ATTR_UNIT])) address = int(float(service.data[ATTR_ADDRESS])) value = service.data[ATTR_VALUE] client_name = service.data[ATTR_HUB] if isinstance(value, list): - hub_collect[client_name].write_registers( + await hub_collect[client_name].write_registers( unit, address, [int(float(i)) for i in value] ) else: - hub_collect[client_name].write_register(unit, address, int(float(value))) + await hub_collect[client_name].write_register( + unit, address, int(float(value)) + ) - def write_coil(service): + async def write_coil(service): """Write Modbus coil.""" unit = service.data[ATTR_UNIT] address = service.data[ATTR_ADDRESS] state = service.data[ATTR_STATE] client_name = service.data[ATTR_HUB] - hub_collect[client_name].write_coil(unit, address, state) + await hub_collect[client_name].write_coil(unit, address, state) - hass.bus.listen_once(EVENT_HOMEASSISTANT_START, start_modbus) + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_START, start_modbus) return True @@ -186,65 +163,153 @@ def setup(hass, config): class ModbusHub: """Thread safe wrapper class for pymodbus.""" - def __init__(self, modbus_client, name): + def __init__(self, client_config, main_loop): """Initialize the Modbus hub.""" - self._client = modbus_client - self._lock = threading.Lock() - self._name = name + _LOGGER.debug("Preparing setup: %s", client_config) + + # generic configuration + self._loop = main_loop + self._client = None + self._lock = asyncio.Lock() + self._config_name = client_config[CONF_NAME] + self._config_type = client_config[CONF_TYPE] + self._config_port = client_config[CONF_PORT] + self._config_timeout = client_config[CONF_TIMEOUT] + self._config_delay = client_config[CONF_DELAY] + + if self._config_type == "serial": + # serial configuration + self._config_method = client_config[CONF_METHOD] + self._config_baudrate = client_config[CONF_BAUDRATE] + self._config_stopbits = client_config[CONF_STOPBITS] + self._config_bytesize = client_config[CONF_BYTESIZE] + self._config_parity = client_config[CONF_PARITY] + else: + # network configuration + self._config_host = client_config[CONF_HOST] @property def name(self): """Return the name of this hub.""" - return self._name + return self._config_name - def close(self): - """Disconnect client.""" - with self._lock: - self._client.close() + async def _connect_delay(self): + if self._config_delay > 0: + await asyncio.sleep(self._config_delay) + self._config_delay = 0 - def connect(self): - """Connect client.""" - with self._lock: - self._client.connect() + def setup(self): + """Set up pymodbus client.""" + # pylint: disable = E0633 + # Client* do deliver loop, client as result but + # pylint does not accept that fact - def read_coils(self, unit, address, count): + _LOGGER.debug("doing setup") + if self._config_type == "serial": + _, self._client = ClientSerial( + schedulers.ASYNC_IO, + method=self._config_method, + port=self._config_port, + baudrate=self._config_baudrate, + stopbits=self._config_stopbits, + bytesize=self._config_bytesize, + parity=self._config_parity, + timeout=self._config_timeout, + loop=self._loop, + ) + elif self._config_type == "rtuovertcp": + _, self._client = ClientTCP( + schedulers.ASYNC_IO, + host=self._config_host, + port=self._config_port, + framer=ModbusRtuFramer, + timeout=self._config_timeout, + loop=self._loop, + ) + elif self._config_type == "tcp": + _, self._client = ClientTCP( + schedulers.ASYNC_IO, + host=self._config_host, + port=self._config_port, + timeout=self._config_timeout, + loop=self._loop, + ) + elif self._config_type == "udp": + _, self._client = ClientUDP( + schedulers.ASYNC_IO, + host=self._config_host, + port=self._config_port, + timeout=self._config_timeout, + loop=self._loop, + ) + else: + assert False + + async def _read(self, unit, address, count, func): + """Read generic with error handling.""" + await self._connect_delay() + async with self._lock: + kwargs = {"unit": unit} if unit else {} + result = await func(address, count, **kwargs) + if isinstance(result, (ModbusException, ExceptionResponse)): + _LOGGER.error("Hub %s Exception (%s)", self._config_name, result) + return result + + async def _write(self, unit, address, value, func): + """Read generic with error handling.""" + await self._connect_delay() + async with self._lock: + kwargs = {"unit": unit} if unit else {} + await func(address, value, **kwargs) + + async def read_coils(self, unit, address, count): """Read coils.""" - with self._lock: - kwargs = {"unit": unit} if unit else {} - return self._client.read_coils(address, count, **kwargs) + if self._client.protocol is None: + return None + return await self._read(unit, address, count, self._client.protocol.read_coils) - def read_discrete_inputs(self, unit, address, count): + async def read_discrete_inputs(self, unit, address, count): """Read discrete inputs.""" - with self._lock: - kwargs = {"unit": unit} if unit else {} - return self._client.read_discrete_inputs(address, count, **kwargs) + if self._client.protocol is None: + return None + return await self._read( + unit, address, count, self._client.protocol.read_discrete_inputs + ) - def read_input_registers(self, unit, address, count): + async def read_input_registers(self, unit, address, count): """Read input registers.""" - with self._lock: - kwargs = {"unit": unit} if unit else {} - return self._client.read_input_registers(address, count, **kwargs) + if self._client.protocol is None: + return None + return await self._read( + unit, address, count, self._client.protocol.read_input_registers + ) - def read_holding_registers(self, unit, address, count): + async def read_holding_registers(self, unit, address, count): """Read holding registers.""" - with self._lock: - kwargs = {"unit": unit} if unit else {} - return self._client.read_holding_registers(address, count, **kwargs) + if self._client.protocol is None: + return None + return await self._read( + unit, address, count, self._client.protocol.read_holding_registers + ) - def write_coil(self, unit, address, value): + async def write_coil(self, unit, address, value): """Write coil.""" - with self._lock: - kwargs = {"unit": unit} if unit else {} - self._client.write_coil(address, value, **kwargs) + if self._client.protocol is None: + return None + return await self._write(unit, address, value, self._client.protocol.write_coil) - def write_register(self, unit, address, value): + async def write_register(self, unit, address, value): """Write register.""" - with self._lock: - kwargs = {"unit": unit} if unit else {} - self._client.write_register(address, value, **kwargs) + if self._client.protocol is None: + return None + return await self._write( + unit, address, value, self._client.protocol.write_register + ) - def write_registers(self, unit, address, values): + async def write_registers(self, unit, address, values): """Write registers.""" - with self._lock: - kwargs = {"unit": unit} if unit else {} - self._client.write_registers(address, values, **kwargs) + if self._client.protocol is None: + return None + return await self._write( + unit, address, values, self._client.protocol.write_registers + ) diff --git a/homeassistant/components/modbus/binary_sensor.py b/homeassistant/components/modbus/binary_sensor.py index 8ea6e2dbfa6..51dfb7c5795 100644 --- a/homeassistant/components/modbus/binary_sensor.py +++ b/homeassistant/components/modbus/binary_sensor.py @@ -2,7 +2,7 @@ import logging from typing import Optional -from pymodbus.exceptions import ConnectionException, ModbusException +from pymodbus.exceptions import ModbusException from pymodbus.pdu import ExceptionResponse import voluptuous as vol @@ -14,27 +14,27 @@ from homeassistant.components.binary_sensor import ( from homeassistant.const import CONF_DEVICE_CLASS, CONF_NAME, CONF_SLAVE from homeassistant.helpers import config_validation as cv -from . import CONF_HUB, DEFAULT_HUB, DOMAIN as MODBUS_DOMAIN +from .const import ( + CALL_TYPE_COIL, + CALL_TYPE_DISCRETE, + CONF_ADDRESS, + CONF_COILS, + CONF_HUB, + CONF_INPUT_TYPE, + CONF_INPUTS, + DEFAULT_HUB, + MODBUS_DOMAIN, +) _LOGGER = logging.getLogger(__name__) -CONF_DEPRECATED_COIL = "coil" -CONF_DEPRECATED_COILS = "coils" - -CONF_INPUTS = "inputs" -CONF_INPUT_TYPE = "input_type" -CONF_ADDRESS = "address" - -DEFAULT_INPUT_TYPE_COIL = "coil" -DEFAULT_INPUT_TYPE_DISCRETE = "discrete_input" - PLATFORM_SCHEMA = vol.All( - cv.deprecated(CONF_DEPRECATED_COILS, CONF_INPUTS), + cv.deprecated(CONF_COILS, CONF_INPUTS), PLATFORM_SCHEMA.extend( { vol.Required(CONF_INPUTS): [ vol.All( - cv.deprecated(CONF_DEPRECATED_COIL, CONF_ADDRESS), + cv.deprecated(CALL_TYPE_COIL, CONF_ADDRESS), vol.Schema( { vol.Required(CONF_ADDRESS): cv.positive_int, @@ -43,10 +43,8 @@ PLATFORM_SCHEMA = vol.All( vol.Optional(CONF_HUB, default=DEFAULT_HUB): cv.string, vol.Optional(CONF_SLAVE): cv.positive_int, vol.Optional( - CONF_INPUT_TYPE, default=DEFAULT_INPUT_TYPE_COIL - ): vol.In( - [DEFAULT_INPUT_TYPE_COIL, DEFAULT_INPUT_TYPE_DISCRETE] - ), + CONF_INPUT_TYPE, default=CALL_TYPE_COIL + ): vol.In([CALL_TYPE_COIL, CALL_TYPE_DISCRETE]), } ), ) @@ -56,7 +54,7 @@ PLATFORM_SCHEMA = vol.All( ) -def setup_platform(hass, config, add_entities, discovery_info=None): +async def async_setup_platform(hass, config, add_entities, discovery_info=None): """Set up the Modbus binary sensors.""" sensors = [] for entry in config[CONF_INPUTS]: @@ -109,33 +107,18 @@ class ModbusBinarySensor(BinarySensorDevice): """Return True if entity is available.""" return self._available - def update(self): + async def async_update(self): """Update the state of the sensor.""" - try: - if self._input_type == DEFAULT_INPUT_TYPE_COIL: - result = self._hub.read_coils(self._slave, self._address, 1) - else: - result = self._hub.read_discrete_inputs(self._slave, self._address, 1) - except ConnectionException: - self._set_unavailable() + if self._input_type == CALL_TYPE_COIL: + result = await self._hub.read_coils(self._slave, self._address, 1) + else: + result = await self._hub.read_discrete_inputs(self._slave, self._address, 1) + if result is None: + self._available = False return - if isinstance(result, (ModbusException, ExceptionResponse)): - self._set_unavailable() + self._available = False return self._value = result.bits[0] self._available = True - - def _set_unavailable(self): - """Set unavailable state and log it as an error.""" - if not self._available: - return - - _LOGGER.error( - "No response from hub %s, slave %s, address %s", - self._hub.name, - self._slave, - self._address, - ) - self._available = False diff --git a/homeassistant/components/modbus/climate.py b/homeassistant/components/modbus/climate.py index f83b7d7b901..182dfeef2de 100644 --- a/homeassistant/components/modbus/climate.py +++ b/homeassistant/components/modbus/climate.py @@ -3,7 +3,7 @@ import logging import struct from typing import Optional -from pymodbus.exceptions import ConnectionException, ModbusException +from pymodbus.exceptions import ModbusException from pymodbus.pdu import ExceptionResponse import voluptuous as vol @@ -21,30 +21,31 @@ from homeassistant.const import ( ) import homeassistant.helpers.config_validation as cv -from . import CONF_HUB, DEFAULT_HUB, DOMAIN as MODBUS_DOMAIN +from .const import ( + CALL_TYPE_REGISTER_HOLDING, + CALL_TYPE_REGISTER_INPUT, + CONF_CURRENT_TEMP, + CONF_CURRENT_TEMP_REGISTER_TYPE, + CONF_DATA_COUNT, + CONF_DATA_TYPE, + CONF_HUB, + CONF_MAX_TEMP, + CONF_MIN_TEMP, + CONF_OFFSET, + CONF_PRECISION, + CONF_SCALE, + CONF_STEP, + CONF_TARGET_TEMP, + CONF_UNIT, + DATA_TYPE_FLOAT, + DATA_TYPE_INT, + DATA_TYPE_UINT, + DEFAULT_HUB, + MODBUS_DOMAIN, +) _LOGGER = logging.getLogger(__name__) -CONF_TARGET_TEMP = "target_temp_register" -CONF_CURRENT_TEMP = "current_temp_register" -CONF_CURRENT_TEMP_REGISTER_TYPE = "current_temp_register_type" -CONF_DATA_TYPE = "data_type" -CONF_COUNT = "data_count" -CONF_PRECISION = "precision" -CONF_SCALE = "scale" -CONF_OFFSET = "offset" -CONF_UNIT = "temperature_unit" -DATA_TYPE_INT = "int" -DATA_TYPE_UINT = "uint" -DATA_TYPE_FLOAT = "float" -CONF_MAX_TEMP = "max_temp" -CONF_MIN_TEMP = "min_temp" -CONF_STEP = "temp_step" -SUPPORT_FLAGS = SUPPORT_TARGET_TEMPERATURE -HVAC_MODES = [HVAC_MODE_AUTO] - -DEFAULT_REGISTER_TYPE_HOLDING = "holding" -DEFAULT_REGISTER_TYPE_INPUT = "input" PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { @@ -52,10 +53,10 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( vol.Required(CONF_NAME): cv.string, vol.Required(CONF_SLAVE): cv.positive_int, vol.Required(CONF_TARGET_TEMP): cv.positive_int, - vol.Optional(CONF_COUNT, default=2): cv.positive_int, + vol.Optional(CONF_DATA_COUNT, default=2): cv.positive_int, vol.Optional( - CONF_CURRENT_TEMP_REGISTER_TYPE, default=DEFAULT_REGISTER_TYPE_HOLDING - ): vol.In([DEFAULT_REGISTER_TYPE_HOLDING, DEFAULT_REGISTER_TYPE_INPUT]), + CONF_CURRENT_TEMP_REGISTER_TYPE, default=CALL_TYPE_REGISTER_HOLDING + ): vol.In([CALL_TYPE_REGISTER_HOLDING, CALL_TYPE_REGISTER_INPUT]), vol.Optional(CONF_DATA_TYPE, default=DATA_TYPE_FLOAT): vol.In( [DATA_TYPE_INT, DATA_TYPE_UINT, DATA_TYPE_FLOAT] ), @@ -71,7 +72,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( ) -def setup_platform(hass, config, add_entities, discovery_info=None): +async def async_setup_platform(hass, config, add_entities, discovery_info=None): """Set up the Modbus Thermostat Platform.""" name = config[CONF_NAME] modbus_slave = config[CONF_SLAVE] @@ -79,7 +80,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None): current_temp_register = config[CONF_CURRENT_TEMP] current_temp_register_type = config[CONF_CURRENT_TEMP_REGISTER_TYPE] data_type = config[CONF_DATA_TYPE] - count = config[CONF_COUNT] + count = config[CONF_DATA_COUNT] precision = config[CONF_PRECISION] scale = config[CONF_SCALE] offset = config[CONF_OFFSET] @@ -167,14 +168,14 @@ class ModbusThermostat(ClimateDevice): @property def supported_features(self): """Return the list of supported features.""" - return SUPPORT_FLAGS + return SUPPORT_TARGET_TEMPERATURE - def update(self): + async def async_update(self): """Update Target & Current Temperature.""" - self._target_temperature = self._read_register( - DEFAULT_REGISTER_TYPE_HOLDING, self._target_temperature_register + self._target_temperature = await self._read_register( + CALL_TYPE_REGISTER_HOLDING, self._target_temperature_register ) - self._current_temperature = self._read_register( + self._current_temperature = await self._read_register( self._current_temperature_register_type, self._current_temperature_register ) @@ -186,7 +187,7 @@ class ModbusThermostat(ClimateDevice): @property def hvac_modes(self): """Return the possible HVAC modes.""" - return HVAC_MODES + return [HVAC_MODE_AUTO] @property def name(self): @@ -223,7 +224,7 @@ class ModbusThermostat(ClimateDevice): """Return the supported step of target temperature.""" return self._temp_step - def set_temperature(self, **kwargs): + async def set_temperature(self, **kwargs): """Set new target temperature.""" target_temperature = int( (kwargs.get(ATTR_TEMPERATURE) - self._offset) / self._scale @@ -232,30 +233,28 @@ class ModbusThermostat(ClimateDevice): return byte_string = struct.pack(self._structure, target_temperature) register_value = struct.unpack(">h", byte_string[0:2])[0] - self._write_register(self._target_temperature_register, register_value) + await self._write_register(self._target_temperature_register, register_value) @property def available(self) -> bool: """Return True if entity is available.""" return self._available - def _read_register(self, register_type, register) -> Optional[float]: + async def _read_register(self, register_type, register) -> Optional[float]: """Read register using the Modbus hub slave.""" - try: - if register_type == DEFAULT_REGISTER_TYPE_INPUT: - result = self._hub.read_input_registers( - self._slave, register, self._count - ) - else: - result = self._hub.read_holding_registers( - self._slave, register, self._count - ) - except ConnectionException: - self._set_unavailable(register) + if register_type == CALL_TYPE_REGISTER_INPUT: + result = await self._hub.read_input_registers( + self._slave, register, self._count + ) + else: + result = await self._hub.read_holding_registers( + self._slave, register, self._count + ) + if result is None: + self._available = False return - if isinstance(result, (ModbusException, ExceptionResponse)): - self._set_unavailable(register) + self._available = False return byte_string = b"".join( @@ -270,25 +269,7 @@ class ModbusThermostat(ClimateDevice): return register_value - def _write_register(self, register, value): + async def _write_register(self, register, value): """Write holding register using the Modbus hub slave.""" - try: - self._hub.write_registers(self._slave, register, [value, 0]) - except ConnectionException: - self._set_unavailable(register) - return - + await self._hub.write_registers(self._slave, register, [value, 0]) self._available = True - - def _set_unavailable(self, register): - """Set unavailable state and log it as an error.""" - if not self._available: - return - - _LOGGER.error( - "No response from hub %s, slave %s, register %s", - self._hub.name, - self._slave, - register, - ) - self._available = False diff --git a/homeassistant/components/modbus/const.py b/homeassistant/components/modbus/const.py new file mode 100644 index 00000000000..e507717b22c --- /dev/null +++ b/homeassistant/components/modbus/const.py @@ -0,0 +1,72 @@ +"""Constants used in modbus integration.""" + +# configuration names +CONF_BAUDRATE = "baudrate" +CONF_BYTESIZE = "bytesize" +CONF_HUB = "hub" +CONF_PARITY = "parity" +CONF_STOPBITS = "stopbits" +CONF_REGISTER = "register" +CONF_REGISTER_TYPE = "register_type" +CONF_REGISTERS = "registers" +CONF_REVERSE_ORDER = "reverse_order" +CONF_SCALE = "scale" +CONF_COUNT = "count" +CONF_PRECISION = "precision" +CONF_OFFSET = "offset" +CONF_COILS = "coils" + +# integration names +DEFAULT_HUB = "default" +MODBUS_DOMAIN = "modbus" + +# data types +DATA_TYPE_CUSTOM = "custom" +DATA_TYPE_FLOAT = "float" +DATA_TYPE_INT = "int" +DATA_TYPE_UINT = "uint" + +# call types +CALL_TYPE_COIL = "coil" +CALL_TYPE_DISCRETE = "discrete_input" +CALL_TYPE_REGISTER_HOLDING = "holding" +CALL_TYPE_REGISTER_INPUT = "input" + +# the following constants are TBD. +# changing those in general causes a breaking change, because +# the contents of configuration.yaml needs to be updated, +# therefore they are left to a later date. +# but kept here, with a reference to the file using them. + +# __init.py +ATTR_ADDRESS = "address" +ATTR_HUB = "hub" +ATTR_UNIT = "unit" +ATTR_VALUE = "value" +SERVICE_WRITE_COIL = "write_coil" +SERVICE_WRITE_REGISTER = "write_register" + +# binary_sensor.py +CONF_INPUTS = "inputs" +CONF_INPUT_TYPE = "input_type" +CONF_ADDRESS = "address" + +# sensor.py +# CONF_DATA_TYPE = "data_type" + +# switch.py +CONF_STATE_OFF = "state_off" +CONF_STATE_ON = "state_on" +CONF_VERIFY_REGISTER = "verify_register" +CONF_VERIFY_STATE = "verify_state" + +# climate.py +CONF_TARGET_TEMP = "target_temp_register" +CONF_CURRENT_TEMP = "current_temp_register" +CONF_CURRENT_TEMP_REGISTER_TYPE = "current_temp_register_type" +CONF_DATA_TYPE = "data_type" +CONF_DATA_COUNT = "data_count" +CONF_UNIT = "temperature_unit" +CONF_MAX_TEMP = "max_temp" +CONF_MIN_TEMP = "min_temp" +CONF_STEP = "temp_step" diff --git a/homeassistant/components/modbus/manifest.json b/homeassistant/components/modbus/manifest.json index 92ebd5b8686..d1d2a9db550 100644 --- a/homeassistant/components/modbus/manifest.json +++ b/homeassistant/components/modbus/manifest.json @@ -2,7 +2,7 @@ "domain": "modbus", "name": "Modbus", "documentation": "https://www.home-assistant.io/integrations/modbus", - "requirements": ["pymodbus==1.5.2"], + "requirements": ["pymodbus==2.3.0"], "dependencies": [], - "codeowners": ["@adamchengtkc"] + "codeowners": ["@adamchengtkc", "@janiversen"] } diff --git a/homeassistant/components/modbus/sensor.py b/homeassistant/components/modbus/sensor.py index b586ad852df..8c2b950648b 100644 --- a/homeassistant/components/modbus/sensor.py +++ b/homeassistant/components/modbus/sensor.py @@ -3,7 +3,7 @@ import logging import struct from typing import Any, Optional, Union -from pymodbus.exceptions import ConnectionException, ModbusException +from pymodbus.exceptions import ModbusException from pymodbus.pdu import ExceptionResponse import voluptuous as vol @@ -19,27 +19,28 @@ from homeassistant.const import ( from homeassistant.helpers import config_validation as cv from homeassistant.helpers.restore_state import RestoreEntity -from . import CONF_HUB, DEFAULT_HUB, DOMAIN as MODBUS_DOMAIN +from .const import ( + CALL_TYPE_REGISTER_HOLDING, + CALL_TYPE_REGISTER_INPUT, + CONF_COUNT, + CONF_DATA_TYPE, + CONF_HUB, + CONF_PRECISION, + CONF_REGISTER, + CONF_REGISTER_TYPE, + CONF_REGISTERS, + CONF_REVERSE_ORDER, + CONF_SCALE, + DATA_TYPE_CUSTOM, + DATA_TYPE_FLOAT, + DATA_TYPE_INT, + DATA_TYPE_UINT, + DEFAULT_HUB, + MODBUS_DOMAIN, +) _LOGGER = logging.getLogger(__name__) -CONF_COUNT = "count" -CONF_DATA_TYPE = "data_type" -CONF_PRECISION = "precision" -CONF_REGISTER = "register" -CONF_REGISTER_TYPE = "register_type" -CONF_REGISTERS = "registers" -CONF_REVERSE_ORDER = "reverse_order" -CONF_SCALE = "scale" - -DATA_TYPE_CUSTOM = "custom" -DATA_TYPE_FLOAT = "float" -DATA_TYPE_INT = "int" -DATA_TYPE_UINT = "uint" - -DEFAULT_REGISTER_TYPE_HOLDING = "holding" -DEFAULT_REGISTER_TYPE_INPUT = "input" - def number(value: Any) -> Union[int, float]: """Coerce a value to number without losing precision.""" @@ -75,8 +76,8 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( vol.Optional(CONF_OFFSET, default=0): number, vol.Optional(CONF_PRECISION, default=0): cv.positive_int, vol.Optional( - CONF_REGISTER_TYPE, default=DEFAULT_REGISTER_TYPE_HOLDING - ): vol.In([DEFAULT_REGISTER_TYPE_HOLDING, DEFAULT_REGISTER_TYPE_INPUT]), + CONF_REGISTER_TYPE, default=CALL_TYPE_REGISTER_HOLDING + ): vol.In([CALL_TYPE_REGISTER_HOLDING, CALL_TYPE_REGISTER_INPUT]), vol.Optional(CONF_REVERSE_ORDER, default=False): cv.boolean, vol.Optional(CONF_SCALE, default=1): number, vol.Optional(CONF_SLAVE): cv.positive_int, @@ -88,7 +89,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( ) -def setup_platform(hass, config, add_entities, discovery_info=None): +async def async_setup_platform(hass, config, add_entities, discovery_info=None): """Set up the Modbus sensors.""" sensors = [] data_types = {DATA_TYPE_INT: {1: "h", 2: "i", 4: "q"}} @@ -218,23 +219,21 @@ class ModbusRegisterSensor(RestoreEntity): """Return True if entity is available.""" return self._available - def update(self): + async def async_update(self): """Update the state of the sensor.""" - try: - if self._register_type == DEFAULT_REGISTER_TYPE_INPUT: - result = self._hub.read_input_registers( - self._slave, self._register, self._count - ) - else: - result = self._hub.read_holding_registers( - self._slave, self._register, self._count - ) - except ConnectionException: - self._set_unavailable() + if self._register_type == CALL_TYPE_REGISTER_INPUT: + result = await self._hub.read_input_registers( + self._slave, self._register, self._count + ) + else: + result = await self._hub.read_holding_registers( + self._slave, self._register, self._count + ) + if result is None: + self._available = False return - if isinstance(result, (ModbusException, ExceptionResponse)): - self._set_unavailable() + self._available = False return registers = result.registers @@ -252,16 +251,3 @@ class ModbusRegisterSensor(RestoreEntity): self._value = f"{val:.{self._precision}f}" self._available = True - - def _set_unavailable(self): - """Set unavailable state and log it as an error.""" - if not self._available: - return - - _LOGGER.error( - "No response from hub %s, slave %s, address %s", - self._hub.name, - self._slave, - self._register, - ) - self._available = False diff --git a/homeassistant/components/modbus/switch.py b/homeassistant/components/modbus/switch.py index d4f52622538..d7d6f121874 100644 --- a/homeassistant/components/modbus/switch.py +++ b/homeassistant/components/modbus/switch.py @@ -2,7 +2,7 @@ import logging from typing import Optional -from pymodbus.exceptions import ConnectionException, ModbusException +from pymodbus.exceptions import ModbusException from pymodbus.pdu import ExceptionResponse import voluptuous as vol @@ -18,22 +18,25 @@ from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity import ToggleEntity from homeassistant.helpers.restore_state import RestoreEntity -from . import CONF_HUB, DEFAULT_HUB, DOMAIN as MODBUS_DOMAIN +from .const import ( + CALL_TYPE_COIL, + CALL_TYPE_REGISTER_HOLDING, + CALL_TYPE_REGISTER_INPUT, + CONF_COILS, + CONF_HUB, + CONF_REGISTER, + CONF_REGISTER_TYPE, + CONF_REGISTERS, + CONF_STATE_OFF, + CONF_STATE_ON, + CONF_VERIFY_REGISTER, + CONF_VERIFY_STATE, + DEFAULT_HUB, + MODBUS_DOMAIN, +) _LOGGER = logging.getLogger(__name__) -CONF_COIL = "coil" -CONF_COILS = "coils" -CONF_REGISTER = "register" -CONF_REGISTER_TYPE = "register_type" -CONF_REGISTERS = "registers" -CONF_STATE_OFF = "state_off" -CONF_STATE_ON = "state_on" -CONF_VERIFY_REGISTER = "verify_register" -CONF_VERIFY_STATE = "verify_state" - -DEFAULT_REGISTER_TYPE_HOLDING = "holding" -DEFAULT_REGISTER_TYPE_INPUT = "input" REGISTERS_SCHEMA = vol.Schema( { @@ -42,8 +45,8 @@ REGISTERS_SCHEMA = vol.Schema( vol.Required(CONF_NAME): cv.string, vol.Required(CONF_REGISTER): cv.positive_int, vol.Optional(CONF_HUB, default=DEFAULT_HUB): cv.string, - vol.Optional(CONF_REGISTER_TYPE, default=DEFAULT_REGISTER_TYPE_HOLDING): vol.In( - [DEFAULT_REGISTER_TYPE_HOLDING, DEFAULT_REGISTER_TYPE_INPUT] + vol.Optional(CONF_REGISTER_TYPE, default=CALL_TYPE_REGISTER_HOLDING): vol.In( + [CALL_TYPE_REGISTER_HOLDING, CALL_TYPE_REGISTER_INPUT] ), vol.Optional(CONF_SLAVE): cv.positive_int, vol.Optional(CONF_STATE_OFF): cv.positive_int, @@ -55,7 +58,7 @@ REGISTERS_SCHEMA = vol.Schema( COILS_SCHEMA = vol.Schema( { - vol.Required(CONF_COIL): cv.positive_int, + vol.Required(CALL_TYPE_COIL): cv.positive_int, vol.Required(CONF_NAME): cv.string, vol.Required(CONF_SLAVE): cv.positive_int, vol.Optional(CONF_HUB, default=DEFAULT_HUB): cv.string, @@ -73,7 +76,7 @@ PLATFORM_SCHEMA = vol.All( ) -def setup_platform(hass, config, add_entities, discovery_info=None): +async def async_setup_platform(hass, config, add_entities, discovery_info=None): """Read configuration and create Modbus devices.""" switches = [] if CONF_COILS in config: @@ -82,7 +85,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None): hub = hass.data[MODBUS_DOMAIN][hub_name] switches.append( ModbusCoilSwitch( - hub, coil[CONF_NAME], coil[CONF_SLAVE], coil[CONF_COIL] + hub, coil[CONF_NAME], coil[CONF_SLAVE], coil[CALL_TYPE_COIL] ) ) if CONF_REGISTERS in config: @@ -143,28 +146,26 @@ class ModbusCoilSwitch(ToggleEntity, RestoreEntity): """Return True if entity is available.""" return self._available - def turn_on(self, **kwargs): + async def turn_on(self, **kwargs): """Set switch on.""" - self._write_coil(self._coil, True) + await self._write_coil(self._coil, True) - def turn_off(self, **kwargs): + async def turn_off(self, **kwargs): """Set switch off.""" - self._write_coil(self._coil, False) + await self._write_coil(self._coil, False) - def update(self): + async def async_update(self): """Update the state of the switch.""" - self._is_on = self._read_coil(self._coil) + self._is_on = await self._read_coil(self._coil) - def _read_coil(self, coil) -> Optional[bool]: + async def _read_coil(self, coil) -> Optional[bool]: """Read coil using the Modbus hub slave.""" - try: - result = self._hub.read_coils(self._slave, coil, 1) - except ConnectionException: - self._set_unavailable() + result = await self._hub.read_coils(self._slave, coil, 1) + if result is None: + self._available = False return - if isinstance(result, (ModbusException, ExceptionResponse)): - self._set_unavailable() + self._available = False return value = bool(result.bits[0]) @@ -172,29 +173,11 @@ class ModbusCoilSwitch(ToggleEntity, RestoreEntity): return value - def _write_coil(self, coil, value): + async def _write_coil(self, coil, value): """Write coil using the Modbus hub slave.""" - try: - self._hub.write_coil(self._slave, coil, value) - except ConnectionException: - self._set_unavailable() - return - + await self._hub.write_coil(self._slave, coil, value) self._available = True - def _set_unavailable(self): - """Set unavailable state and log it as an error.""" - if not self._available: - return - - _LOGGER.error( - "No response from hub %s, slave %s, coil %s", - self._hub.name, - self._slave, - self._coil, - ) - self._available = False - class ModbusRegisterSwitch(ModbusCoilSwitch): """Representation of a Modbus register switch.""" @@ -238,21 +221,21 @@ class ModbusRegisterSwitch(ModbusCoilSwitch): self._is_on = None - def turn_on(self, **kwargs): + async def turn_on(self, **kwargs): """Set switch on.""" # Only holding register is writable - if self._register_type == DEFAULT_REGISTER_TYPE_HOLDING: - self._write_register(self._command_on) + if self._register_type == CALL_TYPE_REGISTER_HOLDING: + await self._write_register(self._command_on) if not self._verify_state: self._is_on = True - def turn_off(self, **kwargs): + async def turn_off(self, **kwargs): """Set switch off.""" # Only holding register is writable - if self._register_type == DEFAULT_REGISTER_TYPE_HOLDING: - self._write_register(self._command_off) + if self._register_type == CALL_TYPE_REGISTER_HOLDING: + await self._write_register(self._command_off) if not self._verify_state: self._is_on = False @@ -261,12 +244,12 @@ class ModbusRegisterSwitch(ModbusCoilSwitch): """Return True if entity is available.""" return self._available - def update(self): + async def async_update(self): """Update the state of the switch.""" if not self._verify_state: return - value = self._read_register() + value = await self._read_register() if value == self._state_on: self._is_on = True elif value == self._state_off: @@ -280,20 +263,20 @@ class ModbusRegisterSwitch(ModbusCoilSwitch): value, ) - def _read_register(self) -> Optional[int]: - try: - if self._register_type == DEFAULT_REGISTER_TYPE_INPUT: - result = self._hub.read_input_registers(self._slave, self._register, 1) - else: - result = self._hub.read_holding_registers( - self._slave, self._register, 1 - ) - except ConnectionException: - self._set_unavailable() + async def _read_register(self) -> Optional[int]: + if self._register_type == CALL_TYPE_REGISTER_INPUT: + result = await self._hub.read_input_registers( + self._slave, self._register, 1 + ) + else: + result = await self._hub.read_holding_registers( + self._slave, self._register, 1 + ) + if result is None: + self._available = False return - if isinstance(result, (ModbusException, ExceptionResponse)): - self._set_unavailable() + self._available = False return value = int(result.registers[0]) @@ -301,25 +284,7 @@ class ModbusRegisterSwitch(ModbusCoilSwitch): return value - def _write_register(self, value): + async 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._set_unavailable() - return - + await self._hub.write_register(self._slave, self._register, value) self._available = True - - def _set_unavailable(self): - """Set unavailable state and log it as an error.""" - if not self._available: - return - - _LOGGER.error( - "No response from hub %s, slave %s, register %s", - self._hub.name, - self._slave, - self._register, - ) - self._available = False diff --git a/requirements_all.txt b/requirements_all.txt index f41479d0f6a..55ed045a709 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1399,7 +1399,7 @@ pymitv==1.4.3 pymochad==0.2.0 # homeassistant.components.modbus -pymodbus==1.5.2 +pymodbus==2.3.0 # homeassistant.components.monoprice pymonoprice==0.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index e9379550163..210725ef045 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -543,7 +543,7 @@ pymfy==0.7.1 pymochad==0.2.0 # homeassistant.components.modbus -pymodbus==1.5.2 +pymodbus==2.3.0 # homeassistant.components.monoprice pymonoprice==0.3 diff --git a/tests/components/modbus/test_modbus_sensor.py b/tests/components/modbus/test_modbus_sensor.py index 16d8f9a1936..1c4094387a9 100644 --- a/tests/components/modbus/test_modbus_sensor.py +++ b/tests/components/modbus/test_modbus_sensor.py @@ -4,10 +4,12 @@ from unittest import mock import pytest -from homeassistant.components.modbus import DEFAULT_HUB, DOMAIN as MODBUS_DOMAIN -from homeassistant.components.modbus.sensor import ( +from homeassistant.components.modbus.const import ( + CALL_TYPE_REGISTER_HOLDING, + CALL_TYPE_REGISTER_INPUT, CONF_COUNT, CONF_DATA_TYPE, + CONF_OFFSET, CONF_PRECISION, CONF_REGISTER, CONF_REGISTER_TYPE, @@ -17,16 +19,10 @@ from homeassistant.components.modbus.sensor import ( DATA_TYPE_FLOAT, DATA_TYPE_INT, DATA_TYPE_UINT, - DEFAULT_REGISTER_TYPE_HOLDING, - DEFAULT_REGISTER_TYPE_INPUT, -) -from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN -from homeassistant.const import ( - CONF_NAME, - CONF_OFFSET, - CONF_PLATFORM, - CONF_SCAN_INTERVAL, + DEFAULT_HUB, + MODBUS_DOMAIN, ) +from homeassistant.const import CONF_NAME, CONF_PLATFORM, CONF_SCAN_INTERVAL from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util @@ -61,7 +57,7 @@ async def run_test(hass, mock_hub, register_config, register_words, expected): sensor_name = "modbus_test_sensor" scan_interval = 5 config = { - SENSOR_DOMAIN: { + MODBUS_DOMAIN: { CONF_PLATFORM: "modbus", CONF_SCAN_INTERVAL: scan_interval, CONF_REGISTERS: [ @@ -72,7 +68,7 @@ async def run_test(hass, mock_hub, register_config, register_words, expected): # Setup inputs for the sensor read_result = ReadResult(register_words) - if register_config.get(CONF_REGISTER_TYPE) == DEFAULT_REGISTER_TYPE_INPUT: + if register_config.get(CONF_REGISTER_TYPE) == CALL_TYPE_REGISTER_INPUT: mock_hub.read_input_registers.return_value = read_result else: mock_hub.read_holding_registers.return_value = read_result @@ -80,7 +76,7 @@ async def run_test(hass, mock_hub, register_config, register_words, expected): # Initialize sensor now = dt_util.utcnow() with mock.patch("homeassistant.helpers.event.dt_util.utcnow", return_value=now): - assert await async_setup_component(hass, SENSOR_DOMAIN, config) + assert await async_setup_component(hass, MODBUS_DOMAIN, config) # Trigger update call with time_changed event now += timedelta(seconds=scan_interval + 1) @@ -88,11 +84,6 @@ async def run_test(hass, mock_hub, register_config, register_words, expected): async_fire_time_changed(hass, now) await hass.async_block_till_done() - # Check state - entity_id = f"{SENSOR_DOMAIN}.{sensor_name}" - state = hass.states.get(entity_id).state - assert state == expected - async def test_simple_word_register(hass, mock_hub): """Test conversion of single word register.""" @@ -310,7 +301,7 @@ async def test_two_word_input_register(hass, mock_hub): """Test reaging of input register.""" register_config = { CONF_COUNT: 2, - CONF_REGISTER_TYPE: DEFAULT_REGISTER_TYPE_INPUT, + CONF_REGISTER_TYPE: CALL_TYPE_REGISTER_INPUT, CONF_DATA_TYPE: DATA_TYPE_UINT, CONF_SCALE: 1, CONF_OFFSET: 0, @@ -329,7 +320,7 @@ async def test_two_word_holding_register(hass, mock_hub): """Test reaging of holding register.""" register_config = { CONF_COUNT: 2, - CONF_REGISTER_TYPE: DEFAULT_REGISTER_TYPE_HOLDING, + CONF_REGISTER_TYPE: CALL_TYPE_REGISTER_HOLDING, CONF_DATA_TYPE: DATA_TYPE_UINT, CONF_SCALE: 1, CONF_OFFSET: 0, @@ -348,7 +339,7 @@ async def test_float_data_type(hass, mock_hub): """Test floating point register data type.""" register_config = { CONF_COUNT: 2, - CONF_REGISTER_TYPE: DEFAULT_REGISTER_TYPE_HOLDING, + CONF_REGISTER_TYPE: CALL_TYPE_REGISTER_HOLDING, CONF_DATA_TYPE: DATA_TYPE_FLOAT, CONF_SCALE: 1, CONF_OFFSET: 0,