From 9480d5926771dee131e149daf5651d89a76a0032 Mon Sep 17 00:00:00 2001 From: jan Iversen Date: Wed, 15 Apr 2020 21:24:19 +0200 Subject: [PATCH 1/2] Rollback modbus to version 0.107.7 Update manifest to not use async. Rollback entities to sync version. Keep newer modifications apart from async. Rollback __init__ to sync version but keep the new functionality. add async sub directory Adding the current (not working) version in a sub directory, to allow easy sharing with a few alfa testers. The async version are to be updated to use the serial/tcp already available instead of the flaky pymodbus version. pymodbus is still needed to encode/decode the messagess. Update test cases to reflect sync implementation, but keep the new functionality like e.g. conftest.py. --- homeassistant/components/modbus/__init__.py | 221 ++++------- .../components/modbus/async/__init__.py | 326 ++++++++++++++++ .../components/modbus/async/binary_sensor.py | 124 ++++++ .../components/modbus/async/climate.py | 275 ++++++++++++++ .../components/modbus/async/const.py | 72 ++++ .../components/modbus/async/sensor.py | 253 ++++++++++++ .../components/modbus/async/switch.py | 290 ++++++++++++++ .../components/modbus/binary_sensor.py | 20 +- homeassistant/components/modbus/climate.py | 47 ++- homeassistant/components/modbus/manifest.json | 3 +- homeassistant/components/modbus/sensor.py | 30 +- homeassistant/components/modbus/switch.py | 76 ++-- requirements_all.txt | 1 - requirements_test_all.txt | 4 - tests/components/modbus/async/conftest.py | 90 +++++ .../modbus/async/test_modbus_sensor.py | 359 ++++++++++++++++++ tests/components/modbus/conftest.py | 12 +- 17 files changed, 1971 insertions(+), 232 deletions(-) create mode 100644 homeassistant/components/modbus/async/__init__.py create mode 100644 homeassistant/components/modbus/async/binary_sensor.py create mode 100644 homeassistant/components/modbus/async/climate.py create mode 100644 homeassistant/components/modbus/async/const.py create mode 100644 homeassistant/components/modbus/async/sensor.py create mode 100644 homeassistant/components/modbus/async/switch.py create mode 100644 tests/components/modbus/async/conftest.py create mode 100644 tests/components/modbus/async/test_modbus_sensor.py diff --git a/homeassistant/components/modbus/__init__.py b/homeassistant/components/modbus/__init__.py index ad0330b56a0acb..34a396758cb567 100644 --- a/homeassistant/components/modbus/__init__.py +++ b/homeassistant/components/modbus/__init__.py @@ -1,23 +1,9 @@ """Support for Modbus.""" -import asyncio import logging +import threading -from async_timeout import timeout -from pymodbus.client.asynchronous.asyncio import ( - AsyncioModbusSerialClient, - ModbusClientProtocol, - init_tcp_client, - init_udp_client, -) -from pymodbus.exceptions import ModbusException -from pymodbus.factory import ClientDecoder -from pymodbus.pdu import ExceptionResponse -from pymodbus.transaction import ( - ModbusAsciiFramer, - ModbusBinaryFramer, - ModbusRtuFramer, - ModbusSocketFramer, -) +from pymodbus.client.sync import ModbusSerialClient, ModbusTcpClient, ModbusUdpClient +from pymodbus.transaction import ModbusRtuFramer import voluptuous as vol from homeassistant.const import ( @@ -50,6 +36,7 @@ _LOGGER = logging.getLogger(__name__) + BASE_SCHEMA = vol.Schema({vol.Optional(CONF_NAME, default=DEFAULT_HUB): cv.string}) SERIAL_SCHEMA = BASE_SCHEMA.extend( @@ -101,57 +88,55 @@ ) -async def async_setup(hass, config): +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, hass.loop) + hub_collect[client_config[CONF_NAME]] = ModbusHub(client_config) def stop_modbus(event): """Stop Modbus service.""" for client in hub_collect.values(): - del client + client.close() - async def write_register(service): + 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): - await hub_collect[client_name].write_registers( + hub_collect[client_name].write_registers( unit, address, [int(float(i)) for i in value] ) else: - await hub_collect[client_name].write_register( - unit, address, int(float(value)) - ) + hub_collect[client_name].write_register(unit, address, int(float(value))) - async def write_coil(service): + 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] - await hub_collect[client_name].write_coil(unit, address, state) + hub_collect[client_name].write_coil(unit, address, state) # do not wait for EVENT_HOMEASSISTANT_START, activate pymodbus now for client in hub_collect.values(): - await client.setup(hass) + client.setup() # register function to gracefully stop modbus hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, stop_modbus) # Register services for modbus - hass.services.async_register( + hass.services.register( DOMAIN, SERVICE_WRITE_REGISTER, write_register, schema=SERVICE_WRITE_REGISTER_SCHEMA, ) - hass.services.async_register( - DOMAIN, SERVICE_WRITE_COIL, write_coil, schema=SERVICE_WRITE_COIL_SCHEMA, + hass.services.register( + DOMAIN, SERVICE_WRITE_COIL, write_coil, schema=SERVICE_WRITE_COIL_SCHEMA ) return True @@ -159,13 +144,12 @@ async def write_coil(service): class ModbusHub: """Thread safe wrapper class for pymodbus.""" - def __init__(self, client_config, main_loop): + def __init__(self, client_config): """Initialize the Modbus hub.""" # generic configuration - self._loop = main_loop self._client = None - self._lock = asyncio.Lock() + self._lock = threading.Lock() self._config_name = client_config[CONF_NAME] self._config_type = client_config[CONF_TYPE] self._config_port = client_config[CONF_PORT] @@ -183,144 +167,101 @@ def __init__(self, client_config, main_loop): # network configuration self._config_host = client_config[CONF_HOST] self._config_delay = client_config[CONF_DELAY] + if self._config_delay > 0: + _LOGGER.warning( + "Parameter delay is accepted but not used in this version" + ) @property def name(self): """Return the name of this hub.""" return self._config_name - async def _connect_delay(self): - if self._config_delay > 0: - await asyncio.sleep(self._config_delay) - self._config_delay = 0 - - @staticmethod - def _framer(method): - if method == "ascii": - framer = ModbusAsciiFramer(ClientDecoder()) - elif method == "rtu": - framer = ModbusRtuFramer(ClientDecoder()) - elif method == "binary": - framer = ModbusBinaryFramer(ClientDecoder()) - elif method == "socket": - framer = ModbusSocketFramer(ClientDecoder()) - else: - framer = None - return framer - - async def setup(self, hass): + def setup(self): """Set up pymodbus client.""" if self._config_type == "serial": - # reconnect ?? - framer = self._framer(self._config_method) - - # just a class creation no IO or other slow items - self._client = AsyncioModbusSerialClient( - self._config_port, - protocol_class=ModbusClientProtocol, - framer=framer, - loop=self._loop, + self._client = ModbusSerialClient( + method=self._config_method, + port=self._config_port, baudrate=self._config_baudrate, + stopbits=self._config_stopbits, bytesize=self._config_bytesize, parity=self._config_parity, - stopbits=self._config_stopbits, + timeout=self._config_timeout, ) - await self._client.connect() elif self._config_type == "rtuovertcp": - # framer ModbusRtuFramer ?? - # timeout ?? - self._client = await init_tcp_client( - None, self._loop, self._config_host, self._config_port + self._client = ModbusTcpClient( + host=self._config_host, + port=self._config_port, + framer=ModbusRtuFramer, + timeout=self._config_timeout, ) elif self._config_type == "tcp": - # framer ?? - # timeout ?? - self._client = await init_tcp_client( - None, self._loop, self._config_host, self._config_port + self._client = ModbusTcpClient( + host=self._config_host, + port=self._config_port, + timeout=self._config_timeout, ) elif self._config_type == "udp": - # framer ?? - # timeout ?? - self._client = await init_udp_client( - None, self._loop, self._config_host, self._config_port + self._client = ModbusUdpClient( + host=self._config_host, + port=self._config_port, + timeout=self._config_timeout, ) 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 {} - try: - async with timeout(self._config_timeout): - result = await func(address, count, **kwargs) - except asyncio.TimeoutError: - result = None - - 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 {} - try: - async with timeout(self._config_timeout): - func(address, value, **kwargs) - except asyncio.TimeoutError: - return + # Connect device + self.connect() - async def read_coils(self, unit, address, count): + def close(self): + """Disconnect client.""" + with self._lock: + self._client.close() + + def connect(self): + """Connect client.""" + with self._lock: + self._client.connect() + + def read_coils(self, unit, address, count): """Read coils.""" - if self._client.protocol is None: - return None - return await self._read(unit, address, count, self._client.protocol.read_coils) + with self._lock: + kwargs = {"unit": unit} if unit else {} + return self._client.read_coils(address, count, **kwargs) - async def read_discrete_inputs(self, unit, address, count): + def read_discrete_inputs(self, unit, address, count): """Read discrete inputs.""" - if self._client.protocol is None: - return None - return await self._read( - unit, address, count, self._client.protocol.read_discrete_inputs - ) + with self._lock: + kwargs = {"unit": unit} if unit else {} + return self._client.read_discrete_inputs(address, count, **kwargs) - async def read_input_registers(self, unit, address, count): + def read_input_registers(self, unit, address, count): """Read input registers.""" - if self._client.protocol is None: - return None - return await self._read( - unit, address, count, self._client.protocol.read_input_registers - ) + with self._lock: + kwargs = {"unit": unit} if unit else {} + return self._client.read_input_registers(address, count, **kwargs) - async def read_holding_registers(self, unit, address, count): + def read_holding_registers(self, unit, address, count): """Read holding registers.""" - if self._client.protocol is None: - return None - return await self._read( - unit, address, count, self._client.protocol.read_holding_registers - ) + with self._lock: + kwargs = {"unit": unit} if unit else {} + return self._client.read_holding_registers(address, count, **kwargs) - async def write_coil(self, unit, address, value): + def write_coil(self, unit, address, value): """Write coil.""" - if self._client.protocol is None: - return None - return await self._write(unit, address, value, self._client.protocol.write_coil) + with self._lock: + kwargs = {"unit": unit} if unit else {} + self._client.write_coil(address, value, **kwargs) - async def write_register(self, unit, address, value): + def write_register(self, unit, address, value): """Write register.""" - if self._client.protocol is None: - return None - return await self._write( - unit, address, value, self._client.protocol.write_register - ) + with self._lock: + kwargs = {"unit": unit} if unit else {} + self._client.write_register(address, value, **kwargs) - async def write_registers(self, unit, address, values): + def write_registers(self, unit, address, values): """Write registers.""" - if self._client.protocol is None: - return None - return await self._write( - unit, address, values, self._client.protocol.write_registers - ) + with self._lock: + kwargs = {"unit": unit} if unit else {} + self._client.write_registers(address, values, **kwargs) diff --git a/homeassistant/components/modbus/async/__init__.py b/homeassistant/components/modbus/async/__init__.py new file mode 100644 index 00000000000000..ad0330b56a0acb --- /dev/null +++ b/homeassistant/components/modbus/async/__init__.py @@ -0,0 +1,326 @@ +"""Support for Modbus.""" +import asyncio +import logging + +from async_timeout import timeout +from pymodbus.client.asynchronous.asyncio import ( + AsyncioModbusSerialClient, + ModbusClientProtocol, + init_tcp_client, + init_udp_client, +) +from pymodbus.exceptions import ModbusException +from pymodbus.factory import ClientDecoder +from pymodbus.pdu import ExceptionResponse +from pymodbus.transaction import ( + ModbusAsciiFramer, + ModbusBinaryFramer, + ModbusRtuFramer, + ModbusSocketFramer, +) +import voluptuous as vol + +from homeassistant.const import ( + ATTR_STATE, + CONF_DELAY, + CONF_HOST, + CONF_METHOD, + CONF_NAME, + CONF_PORT, + CONF_TIMEOUT, + CONF_TYPE, + EVENT_HOMEASSISTANT_STOP, +) +import homeassistant.helpers.config_validation as cv + +from .const import ( + ATTR_ADDRESS, + ATTR_HUB, + ATTR_UNIT, + ATTR_VALUE, + CONF_BAUDRATE, + CONF_BYTESIZE, + CONF_PARITY, + CONF_STOPBITS, + DEFAULT_HUB, + MODBUS_DOMAIN as DOMAIN, + SERVICE_WRITE_COIL, + SERVICE_WRITE_REGISTER, +) + +_LOGGER = logging.getLogger(__name__) + +BASE_SCHEMA = vol.Schema({vol.Optional(CONF_NAME, default=DEFAULT_HUB): cv.string}) + +SERIAL_SCHEMA = BASE_SCHEMA.extend( + { + vol.Required(CONF_BAUDRATE): cv.positive_int, + vol.Required(CONF_BYTESIZE): vol.Any(5, 6, 7, 8), + vol.Required(CONF_METHOD): vol.Any("rtu", "ascii"), + vol.Required(CONF_PORT): cv.string, + vol.Required(CONF_PARITY): vol.Any("E", "O", "N"), + vol.Required(CONF_STOPBITS): vol.Any(1, 2), + vol.Required(CONF_TYPE): "serial", + vol.Optional(CONF_TIMEOUT, default=3): cv.socket_timeout, + } +) + +ETHERNET_SCHEMA = BASE_SCHEMA.extend( + { + vol.Required(CONF_HOST): cv.string, + 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)])}, + extra=vol.ALLOW_EXTRA, +) + +SERVICE_WRITE_REGISTER_SCHEMA = vol.Schema( + { + vol.Optional(ATTR_HUB, default=DEFAULT_HUB): cv.string, + vol.Required(ATTR_UNIT): cv.positive_int, + vol.Required(ATTR_ADDRESS): cv.positive_int, + vol.Required(ATTR_VALUE): vol.Any( + cv.positive_int, vol.All(cv.ensure_list, [cv.positive_int]) + ), + } +) + +SERVICE_WRITE_COIL_SCHEMA = vol.Schema( + { + vol.Optional(ATTR_HUB, default=DEFAULT_HUB): cv.string, + vol.Required(ATTR_UNIT): cv.positive_int, + vol.Required(ATTR_ADDRESS): cv.positive_int, + vol.Required(ATTR_STATE): cv.boolean, + } +) + + +async def async_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, hass.loop) + + def stop_modbus(event): + """Stop Modbus service.""" + for client in hub_collect.values(): + del client + + 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): + await hub_collect[client_name].write_registers( + unit, address, [int(float(i)) for i in value] + ) + else: + await hub_collect[client_name].write_register( + unit, address, int(float(value)) + ) + + 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] + await hub_collect[client_name].write_coil(unit, address, state) + + # do not wait for EVENT_HOMEASSISTANT_START, activate pymodbus now + for client in hub_collect.values(): + await client.setup(hass) + + # register function to gracefully stop modbus + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, stop_modbus) + + # Register services for modbus + hass.services.async_register( + DOMAIN, + SERVICE_WRITE_REGISTER, + write_register, + schema=SERVICE_WRITE_REGISTER_SCHEMA, + ) + hass.services.async_register( + DOMAIN, SERVICE_WRITE_COIL, write_coil, schema=SERVICE_WRITE_COIL_SCHEMA, + ) + return True + + +class ModbusHub: + """Thread safe wrapper class for pymodbus.""" + + def __init__(self, client_config, main_loop): + """Initialize the Modbus hub.""" + + # 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 = 0 + + 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] + self._config_delay = client_config[CONF_DELAY] + + @property + def name(self): + """Return the name of this hub.""" + return self._config_name + + async def _connect_delay(self): + if self._config_delay > 0: + await asyncio.sleep(self._config_delay) + self._config_delay = 0 + + @staticmethod + def _framer(method): + if method == "ascii": + framer = ModbusAsciiFramer(ClientDecoder()) + elif method == "rtu": + framer = ModbusRtuFramer(ClientDecoder()) + elif method == "binary": + framer = ModbusBinaryFramer(ClientDecoder()) + elif method == "socket": + framer = ModbusSocketFramer(ClientDecoder()) + else: + framer = None + return framer + + async def setup(self, hass): + """Set up pymodbus client.""" + if self._config_type == "serial": + # reconnect ?? + framer = self._framer(self._config_method) + + # just a class creation no IO or other slow items + self._client = AsyncioModbusSerialClient( + self._config_port, + protocol_class=ModbusClientProtocol, + framer=framer, + loop=self._loop, + baudrate=self._config_baudrate, + bytesize=self._config_bytesize, + parity=self._config_parity, + stopbits=self._config_stopbits, + ) + await self._client.connect() + elif self._config_type == "rtuovertcp": + # framer ModbusRtuFramer ?? + # timeout ?? + self._client = await init_tcp_client( + None, self._loop, self._config_host, self._config_port + ) + elif self._config_type == "tcp": + # framer ?? + # timeout ?? + self._client = await init_tcp_client( + None, self._loop, self._config_host, self._config_port + ) + elif self._config_type == "udp": + # framer ?? + # timeout ?? + self._client = await init_udp_client( + None, self._loop, self._config_host, self._config_port + ) + 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 {} + try: + async with timeout(self._config_timeout): + result = await func(address, count, **kwargs) + except asyncio.TimeoutError: + result = None + + 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 {} + try: + async with timeout(self._config_timeout): + func(address, value, **kwargs) + except asyncio.TimeoutError: + return + + async def read_coils(self, unit, address, count): + """Read coils.""" + if self._client.protocol is None: + return None + return await self._read(unit, address, count, self._client.protocol.read_coils) + + async def read_discrete_inputs(self, unit, address, count): + """Read discrete inputs.""" + if self._client.protocol is None: + return None + return await self._read( + unit, address, count, self._client.protocol.read_discrete_inputs + ) + + async def read_input_registers(self, unit, address, count): + """Read input registers.""" + if self._client.protocol is None: + return None + return await self._read( + unit, address, count, self._client.protocol.read_input_registers + ) + + async def read_holding_registers(self, unit, address, count): + """Read holding registers.""" + if self._client.protocol is None: + return None + return await self._read( + unit, address, count, self._client.protocol.read_holding_registers + ) + + async def write_coil(self, unit, address, value): + """Write coil.""" + if self._client.protocol is None: + return None + return await self._write(unit, address, value, self._client.protocol.write_coil) + + async def write_register(self, unit, address, value): + """Write register.""" + if self._client.protocol is None: + return None + return await self._write( + unit, address, value, self._client.protocol.write_register + ) + + async def write_registers(self, unit, address, values): + """Write registers.""" + 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/async/binary_sensor.py b/homeassistant/components/modbus/async/binary_sensor.py new file mode 100644 index 00000000000000..9989b9d530acb4 --- /dev/null +++ b/homeassistant/components/modbus/async/binary_sensor.py @@ -0,0 +1,124 @@ +"""Support for Modbus Coil and Discrete Input sensors.""" +import logging +from typing import Optional + +from pymodbus.exceptions import ModbusException +from pymodbus.pdu import ExceptionResponse +import voluptuous as vol + +from homeassistant.components.binary_sensor import ( + DEVICE_CLASSES_SCHEMA, + PLATFORM_SCHEMA, + BinarySensorDevice, +) +from homeassistant.const import CONF_DEVICE_CLASS, CONF_NAME, CONF_SLAVE +from homeassistant.helpers import config_validation as cv + +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__) + +PLATFORM_SCHEMA = vol.All( + cv.deprecated(CONF_COILS, CONF_INPUTS), + PLATFORM_SCHEMA.extend( + { + vol.Required(CONF_INPUTS): [ + vol.All( + cv.deprecated(CALL_TYPE_COIL, CONF_ADDRESS), + vol.Schema( + { + vol.Required(CONF_ADDRESS): cv.positive_int, + vol.Required(CONF_NAME): cv.string, + vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA, + vol.Optional(CONF_HUB, default=DEFAULT_HUB): cv.string, + vol.Optional(CONF_SLAVE): cv.positive_int, + vol.Optional( + CONF_INPUT_TYPE, default=CALL_TYPE_COIL + ): vol.In([CALL_TYPE_COIL, CALL_TYPE_DISCRETE]), + } + ), + ) + ] + } + ), +) + + +async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): + """Set up the Modbus binary sensors.""" + sensors = [] + for entry in config[CONF_INPUTS]: + hub = hass.data[MODBUS_DOMAIN][entry[CONF_HUB]] + sensors.append( + ModbusBinarySensor( + hub, + entry[CONF_NAME], + entry.get(CONF_SLAVE), + entry[CONF_ADDRESS], + entry.get(CONF_DEVICE_CLASS), + entry[CONF_INPUT_TYPE], + ) + ) + + async_add_entities(sensors) + + +class ModbusBinarySensor(BinarySensorDevice): + """Modbus binary sensor.""" + + def __init__(self, hub, name, slave, address, device_class, input_type): + """Initialize the Modbus binary sensor.""" + self._hub = hub + self._name = name + self._slave = int(slave) if slave else None + self._address = int(address) + self._device_class = device_class + self._input_type = input_type + self._value = None + self._available = True + + @property + def name(self): + """Return the name of the sensor.""" + return self._name + + @property + def is_on(self): + """Return the state of the sensor.""" + return self._value + + @property + def device_class(self) -> Optional[str]: + """Return the device class of the sensor.""" + return self._device_class + + @property + def available(self) -> bool: + """Return True if entity is available.""" + return self._available + + async def async_update(self): + """Update the state of the sensor.""" + 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._available = False + return + + self._value = result.bits[0] + self._available = True diff --git a/homeassistant/components/modbus/async/climate.py b/homeassistant/components/modbus/async/climate.py new file mode 100644 index 00000000000000..e5fbcf4d42179a --- /dev/null +++ b/homeassistant/components/modbus/async/climate.py @@ -0,0 +1,275 @@ +"""Support for Generic Modbus Thermostats.""" +import logging +import struct +from typing import Optional + +from pymodbus.exceptions import ModbusException +from pymodbus.pdu import ExceptionResponse +import voluptuous as vol + +from homeassistant.components.climate import PLATFORM_SCHEMA, ClimateDevice +from homeassistant.components.climate.const import ( + HVAC_MODE_AUTO, + SUPPORT_TARGET_TEMPERATURE, +) +from homeassistant.const import ( + ATTR_TEMPERATURE, + CONF_NAME, + CONF_SLAVE, + TEMP_CELSIUS, + TEMP_FAHRENHEIT, +) +import homeassistant.helpers.config_validation as cv + +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__) + + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( + { + vol.Required(CONF_CURRENT_TEMP): cv.positive_int, + vol.Required(CONF_NAME): cv.string, + vol.Required(CONF_SLAVE): cv.positive_int, + vol.Required(CONF_TARGET_TEMP): cv.positive_int, + vol.Optional(CONF_DATA_COUNT, default=2): cv.positive_int, + vol.Optional( + 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] + ), + vol.Optional(CONF_HUB, default=DEFAULT_HUB): cv.string, + vol.Optional(CONF_PRECISION, default=1): cv.positive_int, + vol.Optional(CONF_SCALE, default=1): vol.Coerce(float), + vol.Optional(CONF_OFFSET, default=0): vol.Coerce(float), + vol.Optional(CONF_MAX_TEMP, default=35): cv.positive_int, + vol.Optional(CONF_MIN_TEMP, default=5): cv.positive_int, + vol.Optional(CONF_STEP, default=0.5): vol.Coerce(float), + vol.Optional(CONF_UNIT, default="C"): cv.string, + } +) + + +async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): + """Set up the Modbus Thermostat Platform.""" + name = config[CONF_NAME] + modbus_slave = config[CONF_SLAVE] + target_temp_register = config[CONF_TARGET_TEMP] + 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_DATA_COUNT] + precision = config[CONF_PRECISION] + scale = config[CONF_SCALE] + offset = config[CONF_OFFSET] + unit = config[CONF_UNIT] + max_temp = config[CONF_MAX_TEMP] + min_temp = config[CONF_MIN_TEMP] + temp_step = config[CONF_STEP] + hub_name = config[CONF_HUB] + hub = hass.data[MODBUS_DOMAIN][hub_name] + + async_add_entities( + [ + ModbusThermostat( + hub, + name, + modbus_slave, + target_temp_register, + current_temp_register, + current_temp_register_type, + data_type, + count, + precision, + scale, + offset, + unit, + max_temp, + min_temp, + temp_step, + ) + ], + True, + ) + + +class ModbusThermostat(ClimateDevice): + """Representation of a Modbus Thermostat.""" + + def __init__( + self, + hub, + name, + modbus_slave, + target_temp_register, + current_temp_register, + current_temp_register_type, + data_type, + count, + precision, + scale, + offset, + unit, + max_temp, + min_temp, + temp_step, + ): + """Initialize the unit.""" + self._hub = hub + self._name = name + self._slave = modbus_slave + self._target_temperature_register = target_temp_register + self._current_temperature_register = current_temp_register + self._current_temperature_register_type = current_temp_register_type + self._target_temperature = None + self._current_temperature = None + self._data_type = data_type + self._count = int(count) + self._precision = precision + self._scale = scale + self._offset = offset + self._unit = unit + self._max_temp = max_temp + self._min_temp = min_temp + self._temp_step = temp_step + self._structure = ">f" + self._available = True + + data_types = { + DATA_TYPE_INT: {1: "h", 2: "i", 4: "q"}, + DATA_TYPE_UINT: {1: "H", 2: "I", 4: "Q"}, + DATA_TYPE_FLOAT: {1: "e", 2: "f", 4: "d"}, + } + + self._structure = f">{data_types[self._data_type][self._count]}" + + @property + def supported_features(self): + """Return the list of supported features.""" + return SUPPORT_TARGET_TEMPERATURE + + async def async_update(self): + """Update Target & Current Temperature.""" + self._target_temperature = await self._read_register( + CALL_TYPE_REGISTER_HOLDING, self._target_temperature_register + ) + self._current_temperature = await self._read_register( + self._current_temperature_register_type, self._current_temperature_register + ) + + @property + def hvac_mode(self): + """Return the current HVAC mode.""" + return HVAC_MODE_AUTO + + @property + def hvac_modes(self): + """Return the possible HVAC modes.""" + return [HVAC_MODE_AUTO] + + @property + def name(self): + """Return the name of the climate device.""" + return self._name + + @property + def current_temperature(self): + """Return the current temperature.""" + return self._current_temperature + + @property + def target_temperature(self): + """Return the target temperature.""" + return self._target_temperature + + @property + def temperature_unit(self): + """Return the unit of measurement.""" + return TEMP_FAHRENHEIT if self._unit == "F" else TEMP_CELSIUS + + @property + def min_temp(self): + """Return the minimum temperature.""" + return self._min_temp + + @property + def max_temp(self): + """Return the maximum temperature.""" + return self._max_temp + + @property + def target_temperature_step(self): + """Return the supported step of target temperature.""" + return self._temp_step + + async def set_temperature(self, **kwargs): + """Set new target temperature.""" + target_temperature = int( + (kwargs.get(ATTR_TEMPERATURE) - self._offset) / self._scale + ) + if target_temperature is None: + return + byte_string = struct.pack(self._structure, target_temperature) + register_value = struct.unpack(">h", byte_string[0:2])[0] + await self._write_register(self._target_temperature_register, register_value) + + @property + def available(self) -> bool: + """Return True if entity is available.""" + return self._available + + async def _read_register(self, register_type, register) -> Optional[float]: + """Read register using the Modbus hub slave.""" + 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._available = False + return + + byte_string = b"".join( + [x.to_bytes(2, byteorder="big") for x in result.registers] + ) + val = struct.unpack(self._structure, byte_string)[0] + register_value = format( + (self._scale * val) + self._offset, f".{self._precision}f" + ) + register_value = float(register_value) + self._available = True + + return register_value + + async def _write_register(self, register, value): + """Write holding register using the Modbus hub slave.""" + await self._hub.write_registers(self._slave, register, [value, 0]) + self._available = True diff --git a/homeassistant/components/modbus/async/const.py b/homeassistant/components/modbus/async/const.py new file mode 100644 index 00000000000000..e507717b22c5b8 --- /dev/null +++ b/homeassistant/components/modbus/async/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/async/sensor.py b/homeassistant/components/modbus/async/sensor.py new file mode 100644 index 00000000000000..be3bc4c52c6c70 --- /dev/null +++ b/homeassistant/components/modbus/async/sensor.py @@ -0,0 +1,253 @@ +"""Support for Modbus Register sensors.""" +import logging +import struct +from typing import Any, Optional, Union + +from pymodbus.exceptions import ModbusException +from pymodbus.pdu import ExceptionResponse +import voluptuous as vol + +from homeassistant.components.sensor import DEVICE_CLASSES_SCHEMA, PLATFORM_SCHEMA +from homeassistant.const import ( + CONF_DEVICE_CLASS, + CONF_NAME, + CONF_OFFSET, + CONF_SLAVE, + CONF_STRUCTURE, + CONF_UNIT_OF_MEASUREMENT, +) +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.restore_state import RestoreEntity + +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__) + + +def number(value: Any) -> Union[int, float]: + """Coerce a value to number without losing precision.""" + if isinstance(value, int): + return value + + if isinstance(value, str): + try: + value = int(value) + return value + except (TypeError, ValueError): + pass + + try: + value = float(value) + return value + except (TypeError, ValueError): + raise vol.Invalid(f"invalid number {value}") + + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( + { + vol.Required(CONF_REGISTERS): [ + { + vol.Required(CONF_NAME): cv.string, + vol.Required(CONF_REGISTER): cv.positive_int, + vol.Optional(CONF_COUNT, default=1): cv.positive_int, + vol.Optional(CONF_DATA_TYPE, default=DATA_TYPE_INT): vol.In( + [DATA_TYPE_INT, DATA_TYPE_UINT, DATA_TYPE_FLOAT, DATA_TYPE_CUSTOM] + ), + vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA, + vol.Optional(CONF_HUB, default=DEFAULT_HUB): cv.string, + vol.Optional(CONF_OFFSET, default=0): number, + vol.Optional(CONF_PRECISION, default=0): cv.positive_int, + vol.Optional( + 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, + vol.Optional(CONF_STRUCTURE): cv.string, + vol.Optional(CONF_UNIT_OF_MEASUREMENT): cv.string, + } + ] + } +) + + +async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): + """Set up the Modbus sensors.""" + sensors = [] + data_types = {DATA_TYPE_INT: {1: "h", 2: "i", 4: "q"}} + data_types[DATA_TYPE_UINT] = {1: "H", 2: "I", 4: "Q"} + data_types[DATA_TYPE_FLOAT] = {1: "e", 2: "f", 4: "d"} + + for register in config[CONF_REGISTERS]: + structure = ">i" + if register[CONF_DATA_TYPE] != DATA_TYPE_CUSTOM: + try: + structure = ( + f">{data_types[register[CONF_DATA_TYPE]][register[CONF_COUNT]]}" + ) + except KeyError: + _LOGGER.error( + "Unable to detect data type for %s sensor, try a custom type", + register[CONF_NAME], + ) + continue + else: + structure = register.get(CONF_STRUCTURE) + + try: + size = struct.calcsize(structure) + except struct.error as err: + _LOGGER.error("Error in sensor %s structure: %s", register[CONF_NAME], err) + continue + + if register[CONF_COUNT] * 2 != size: + _LOGGER.error( + "Structure size (%d bytes) mismatch registers count (%d words)", + size, + register[CONF_COUNT], + ) + continue + + hub_name = register[CONF_HUB] + hub = hass.data[MODBUS_DOMAIN][hub_name] + sensors.append( + ModbusRegisterSensor( + hub, + register[CONF_NAME], + register.get(CONF_SLAVE), + register[CONF_REGISTER], + register[CONF_REGISTER_TYPE], + register.get(CONF_UNIT_OF_MEASUREMENT), + register[CONF_COUNT], + register[CONF_REVERSE_ORDER], + register[CONF_SCALE], + register[CONF_OFFSET], + structure, + register[CONF_PRECISION], + register.get(CONF_DEVICE_CLASS), + ) + ) + + if not sensors: + return False + async_add_entities(sensors) + + +class ModbusRegisterSensor(RestoreEntity): + """Modbus register sensor.""" + + def __init__( + self, + hub, + name, + slave, + register, + register_type, + unit_of_measurement, + count, + reverse_order, + scale, + offset, + structure, + precision, + device_class, + ): + """Initialize the modbus register sensor.""" + self._hub = hub + self._name = name + self._slave = int(slave) if slave else None + self._register = int(register) + self._register_type = register_type + self._unit_of_measurement = unit_of_measurement + self._count = int(count) + self._reverse_order = reverse_order + self._scale = scale + self._offset = offset + self._precision = precision + self._structure = structure + self._device_class = device_class + self._value = None + self._available = True + + 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 + + @property + def state(self): + """Return the state of the sensor.""" + return self._value + + @property + def name(self): + """Return the name of the sensor.""" + return self._name + + @property + def unit_of_measurement(self): + """Return the unit of measurement.""" + return self._unit_of_measurement + + @property + def device_class(self) -> Optional[str]: + """Return the device class of the sensor.""" + return self._device_class + + @property + def available(self) -> bool: + """Return True if entity is available.""" + return self._available + + async def async_update(self): + """Update the state of the sensor.""" + 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._available = False + return + + registers = result.registers + if self._reverse_order: + registers.reverse() + + byte_string = b"".join([x.to_bytes(2, byteorder="big") for x in registers]) + val = struct.unpack(self._structure, byte_string)[0] + val = self._scale * val + self._offset + if isinstance(val, int): + self._value = str(val) + if self._precision > 0: + self._value += f".{'0' * self._precision}" + else: + self._value = f"{val:.{self._precision}f}" + + self._available = True diff --git a/homeassistant/components/modbus/async/switch.py b/homeassistant/components/modbus/async/switch.py new file mode 100644 index 00000000000000..e4ec6a004fbc4f --- /dev/null +++ b/homeassistant/components/modbus/async/switch.py @@ -0,0 +1,290 @@ +"""Support for Modbus switches.""" +import logging +from typing import Optional + +from pymodbus.exceptions import ModbusException +from pymodbus.pdu import ExceptionResponse +import voluptuous as vol + +from homeassistant.components.switch import PLATFORM_SCHEMA +from homeassistant.const import ( + CONF_COMMAND_OFF, + CONF_COMMAND_ON, + CONF_NAME, + CONF_SLAVE, + STATE_ON, +) +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.entity import ToggleEntity +from homeassistant.helpers.restore_state import RestoreEntity + +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__) + + +REGISTERS_SCHEMA = vol.Schema( + { + vol.Required(CONF_COMMAND_OFF): cv.positive_int, + vol.Required(CONF_COMMAND_ON): cv.positive_int, + 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=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, + vol.Optional(CONF_STATE_ON): cv.positive_int, + vol.Optional(CONF_VERIFY_REGISTER): cv.positive_int, + vol.Optional(CONF_VERIFY_STATE, default=True): cv.boolean, + } +) + +COILS_SCHEMA = vol.Schema( + { + 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, + } +) + +PLATFORM_SCHEMA = vol.All( + cv.has_at_least_one_key(CONF_COILS, CONF_REGISTERS), + PLATFORM_SCHEMA.extend( + { + vol.Optional(CONF_COILS): [COILS_SCHEMA], + vol.Optional(CONF_REGISTERS): [REGISTERS_SCHEMA], + } + ), +) + + +async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): + """Read configuration and create Modbus devices.""" + switches = [] + if CONF_COILS in config: + for coil in config[CONF_COILS]: + hub_name = coil[CONF_HUB] + hub = hass.data[MODBUS_DOMAIN][hub_name] + switches.append( + ModbusCoilSwitch( + hub, coil[CONF_NAME], coil[CONF_SLAVE], coil[CALL_TYPE_COIL] + ) + ) + if CONF_REGISTERS in config: + for register in config[CONF_REGISTERS]: + hub_name = register[CONF_HUB] + hub = hass.data[MODBUS_DOMAIN][hub_name] + + switches.append( + ModbusRegisterSwitch( + hub, + register[CONF_NAME], + register.get(CONF_SLAVE), + register[CONF_REGISTER], + register[CONF_COMMAND_ON], + register[CONF_COMMAND_OFF], + register[CONF_VERIFY_STATE], + register.get(CONF_VERIFY_REGISTER), + register[CONF_REGISTER_TYPE], + register.get(CONF_STATE_ON), + register.get(CONF_STATE_OFF), + ) + ) + + async_add_entities(switches) + + +class ModbusCoilSwitch(ToggleEntity, RestoreEntity): + """Representation of a Modbus coil switch.""" + + def __init__(self, hub, name, slave, coil): + """Initialize the coil switch.""" + self._hub = hub + self._name = name + self._slave = int(slave) if slave else None + self._coil = int(coil) + self._is_on = None + self._available = True + + 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._is_on = state.state == STATE_ON + + @property + def is_on(self): + """Return true if switch is on.""" + return self._is_on + + @property + def name(self): + """Return the name of the switch.""" + return self._name + + @property + def available(self) -> bool: + """Return True if entity is available.""" + return self._available + + async def turn_on(self, **kwargs): + """Set switch on.""" + await self._write_coil(self._coil, True) + + async def turn_off(self, **kwargs): + """Set switch off.""" + await self._write_coil(self._coil, False) + + async def async_update(self): + """Update the state of the switch.""" + self._is_on = await self._read_coil(self._coil) + + async def _read_coil(self, coil) -> Optional[bool]: + """Read coil using the Modbus hub slave.""" + result = await self._hub.read_coils(self._slave, coil, 1) + if result is None: + self._available = False + return + if isinstance(result, (ModbusException, ExceptionResponse)): + self._available = False + return + + value = bool(result.bits[0]) + self._available = True + + return value + + async def _write_coil(self, coil, value): + """Write coil using the Modbus hub slave.""" + await self._hub.write_coil(self._slave, coil, value) + self._available = True + + +class ModbusRegisterSwitch(ModbusCoilSwitch): + """Representation of a Modbus register switch.""" + + # pylint: disable=super-init-not-called + def __init__( + self, + hub, + name, + slave, + register, + command_on, + command_off, + verify_state, + verify_register, + register_type, + state_on, + state_off, + ): + """Initialize the register switch.""" + self._hub = hub + self._name = name + self._slave = slave + self._register = register + self._command_on = command_on + self._command_off = command_off + self._verify_state = verify_state + self._verify_register = verify_register if verify_register else self._register + self._register_type = register_type + self._available = True + + if state_on is not None: + self._state_on = state_on + else: + self._state_on = self._command_on + + if state_off is not None: + self._state_off = state_off + else: + self._state_off = self._command_off + + self._is_on = None + + async def turn_on(self, **kwargs): + """Set switch on.""" + + # Only holding register is writable + if self._register_type == CALL_TYPE_REGISTER_HOLDING: + await self._write_register(self._command_on) + if not self._verify_state: + self._is_on = True + + async def turn_off(self, **kwargs): + """Set switch off.""" + + # Only holding register is writable + if self._register_type == CALL_TYPE_REGISTER_HOLDING: + await self._write_register(self._command_off) + if not self._verify_state: + self._is_on = False + + @property + def available(self) -> bool: + """Return True if entity is available.""" + return self._available + + async def async_update(self): + """Update the state of the switch.""" + if not self._verify_state: + return + + value = await self._read_register() + 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._register, + value, + ) + + 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._available = False + return + + value = int(result.registers[0]) + self._available = True + + return value + + async def _write_register(self, value): + """Write holding register using the Modbus hub slave.""" + await self._hub.write_register(self._slave, self._register, value) + self._available = True diff --git a/homeassistant/components/modbus/binary_sensor.py b/homeassistant/components/modbus/binary_sensor.py index 9989b9d530acb4..5f80813d108b06 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 ModbusException +from pymodbus.exceptions import ConnectionException, ModbusException from pymodbus.pdu import ExceptionResponse import voluptuous as vol @@ -54,7 +54,7 @@ ) -async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): +def setup_platform(hass, config, add_entities, discovery_info=None): """Set up the Modbus binary sensors.""" sensors = [] for entry in config[CONF_INPUTS]: @@ -70,7 +70,7 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= ) ) - async_add_entities(sensors) + add_entities(sensors) class ModbusBinarySensor(BinarySensorDevice): @@ -107,15 +107,17 @@ def available(self) -> bool: """Return True if entity is available.""" return self._available - async def async_update(self): + def update(self): """Update the state of the sensor.""" - 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: + try: + if self._input_type == CALL_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._available = False return + if isinstance(result, (ModbusException, ExceptionResponse)): self._available = False return diff --git a/homeassistant/components/modbus/climate.py b/homeassistant/components/modbus/climate.py index e5fbcf4d42179a..5cfd9c36967c56 100644 --- a/homeassistant/components/modbus/climate.py +++ b/homeassistant/components/modbus/climate.py @@ -3,7 +3,7 @@ import struct from typing import Optional -from pymodbus.exceptions import ModbusException +from pymodbus.exceptions import ConnectionException, ModbusException from pymodbus.pdu import ExceptionResponse import voluptuous as vol @@ -72,7 +72,7 @@ ) -async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): +def setup_platform(hass, config, add_entities, discovery_info=None): """Set up the Modbus Thermostat Platform.""" name = config[CONF_NAME] modbus_slave = config[CONF_SLAVE] @@ -91,7 +91,7 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= hub_name = config[CONF_HUB] hub = hass.data[MODBUS_DOMAIN][hub_name] - async_add_entities( + add_entities( [ ModbusThermostat( hub, @@ -170,12 +170,12 @@ def supported_features(self): """Return the list of supported features.""" return SUPPORT_TARGET_TEMPERATURE - async def async_update(self): + def update(self): """Update Target & Current Temperature.""" - self._target_temperature = await self._read_register( + self._target_temperature = self._read_register( CALL_TYPE_REGISTER_HOLDING, self._target_temperature_register ) - self._current_temperature = await self._read_register( + self._current_temperature = self._read_register( self._current_temperature_register_type, self._current_temperature_register ) @@ -224,7 +224,7 @@ def target_temperature_step(self): """Return the supported step of target temperature.""" return self._temp_step - async def set_temperature(self, **kwargs): + def set_temperature(self, **kwargs): """Set new target temperature.""" target_temperature = int( (kwargs.get(ATTR_TEMPERATURE) - self._offset) / self._scale @@ -233,26 +233,28 @@ async def set_temperature(self, **kwargs): return byte_string = struct.pack(self._structure, target_temperature) register_value = struct.unpack(">h", byte_string[0:2])[0] - await self._write_register(self._target_temperature_register, register_value) + self._write_register(self._target_temperature_register, register_value) @property def available(self) -> bool: """Return True if entity is available.""" return self._available - async def _read_register(self, register_type, register) -> Optional[float]: + def _read_register(self, register_type, register) -> Optional[float]: """Read register using the Modbus hub slave.""" - 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: + try: + if register_type == CALL_TYPE_REGISTER_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._available = False return + if isinstance(result, (ModbusException, ExceptionResponse)): self._available = False return @@ -269,7 +271,12 @@ async def _read_register(self, register_type, register) -> Optional[float]: return register_value - async def _write_register(self, register, value): + def _write_register(self, register, value): """Write holding register using the Modbus hub slave.""" - await self._hub.write_registers(self._slave, register, [value, 0]) + try: + self._hub.write_registers(self._slave, register, [value, 0]) + except ConnectionException: + self._available = False + return + self._available = True diff --git a/homeassistant/components/modbus/manifest.json b/homeassistant/components/modbus/manifest.json index 2cdc8fe027cffc..a9155c7b628d3f 100644 --- a/homeassistant/components/modbus/manifest.json +++ b/homeassistant/components/modbus/manifest.json @@ -2,7 +2,6 @@ "domain": "modbus", "name": "Modbus", "documentation": "https://www.home-assistant.io/integrations/modbus", - "requirements": ["pymodbus==2.3.0", - "pyserial-asyncio==0.4"], + "requirements": ["pymodbus==2.3.0"], "codeowners": ["@adamchengtkc", "@janiversen"] } diff --git a/homeassistant/components/modbus/sensor.py b/homeassistant/components/modbus/sensor.py index be3bc4c52c6c70..8c475a114eb440 100644 --- a/homeassistant/components/modbus/sensor.py +++ b/homeassistant/components/modbus/sensor.py @@ -3,7 +3,7 @@ import struct from typing import Any, Optional, Union -from pymodbus.exceptions import ModbusException +from pymodbus.exceptions import ConnectionException, ModbusException from pymodbus.pdu import ExceptionResponse import voluptuous as vol @@ -89,7 +89,7 @@ def number(value: Any) -> Union[int, float]: ) -async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): +def 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"}} @@ -148,7 +148,7 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= if not sensors: return False - async_add_entities(sensors) + add_entities(sensors) class ModbusRegisterSensor(RestoreEntity): @@ -219,19 +219,21 @@ def available(self) -> bool: """Return True if entity is available.""" return self._available - async def async_update(self): + def update(self): """Update the state of the sensor.""" - 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: + try: + if self._register_type == CALL_TYPE_REGISTER_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._available = False return + if isinstance(result, (ModbusException, ExceptionResponse)): self._available = False return @@ -246,7 +248,7 @@ async def async_update(self): if isinstance(val, int): self._value = str(val) if self._precision > 0: - self._value += f".{'0' * self._precision}" + self._value += "." + "0" * self._precision else: self._value = f"{val:.{self._precision}f}" diff --git a/homeassistant/components/modbus/switch.py b/homeassistant/components/modbus/switch.py index e4ec6a004fbc4f..97a5d00a30f436 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 ModbusException +from pymodbus.exceptions import ConnectionException, ModbusException from pymodbus.pdu import ExceptionResponse import voluptuous as vol @@ -76,7 +76,7 @@ ) -async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): +def setup_platform(hass, config, add_entities, discovery_info=None): """Read configuration and create Modbus devices.""" switches = [] if CONF_COILS in config: @@ -109,7 +109,7 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= ) ) - async_add_entities(switches) + add_entities(switches) class ModbusCoilSwitch(ToggleEntity, RestoreEntity): @@ -146,24 +146,26 @@ def available(self) -> bool: """Return True if entity is available.""" return self._available - async def turn_on(self, **kwargs): + def turn_on(self, **kwargs): """Set switch on.""" - await self._write_coil(self._coil, True) + self._write_coil(self._coil, True) - async def turn_off(self, **kwargs): + def turn_off(self, **kwargs): """Set switch off.""" - await self._write_coil(self._coil, False) + self._write_coil(self._coil, False) - async def async_update(self): + def update(self): """Update the state of the switch.""" - self._is_on = await self._read_coil(self._coil) + self._is_on = self._read_coil(self._coil) - async def _read_coil(self, coil) -> Optional[bool]: + def _read_coil(self, coil) -> Optional[bool]: """Read coil using the Modbus hub slave.""" - result = await self._hub.read_coils(self._slave, coil, 1) - if result is None: + try: + result = self._hub.read_coils(self._slave, coil, 1) + except ConnectionException: self._available = False return + if isinstance(result, (ModbusException, ExceptionResponse)): self._available = False return @@ -173,9 +175,14 @@ async def _read_coil(self, coil) -> Optional[bool]: return value - async def _write_coil(self, coil, value): + def _write_coil(self, coil, value): """Write coil using the Modbus hub slave.""" - await self._hub.write_coil(self._slave, coil, value) + try: + self._hub.write_coil(self._slave, coil, value) + except ConnectionException: + self._available = False + return + self._available = True @@ -221,21 +228,21 @@ def __init__( self._is_on = None - async def turn_on(self, **kwargs): + def turn_on(self, **kwargs): """Set switch on.""" # Only holding register is writable if self._register_type == CALL_TYPE_REGISTER_HOLDING: - await self._write_register(self._command_on) + self._write_register(self._command_on) if not self._verify_state: self._is_on = True - async def turn_off(self, **kwargs): + def turn_off(self, **kwargs): """Set switch off.""" # Only holding register is writable if self._register_type == CALL_TYPE_REGISTER_HOLDING: - await self._write_register(self._command_off) + self._write_register(self._command_off) if not self._verify_state: self._is_on = False @@ -244,12 +251,12 @@ def available(self) -> bool: """Return True if entity is available.""" return self._available - async def async_update(self): + def update(self): """Update the state of the switch.""" if not self._verify_state: return - value = await self._read_register() + value = self._read_register() if value == self._state_on: self._is_on = True elif value == self._state_off: @@ -263,18 +270,18 @@ async def async_update(self): value, ) - 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: + def _read_register(self) -> Optional[int]: + try: + if self._register_type == CALL_TYPE_REGISTER_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._available = False return + if isinstance(result, (ModbusException, ExceptionResponse)): self._available = False return @@ -284,7 +291,12 @@ async def _read_register(self) -> Optional[int]: return value - async def _write_register(self, value): + def _write_register(self, value): """Write holding register using the Modbus hub slave.""" - await self._hub.write_register(self._slave, self._register, value) + try: + self._hub.write_register(self._slave, self._register, value) + except ConnectionException: + self._available = False + return + self._available = True diff --git a/requirements_all.txt b/requirements_all.txt index 38133b1a4760e6..1adbf28820086c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1528,7 +1528,6 @@ pysdcp==1 # homeassistant.components.sensibo pysensibo==1.0.3 -# homeassistant.components.modbus # homeassistant.components.serial pyserial-asyncio==0.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 6f013a52da050b..40110b7b474bdb 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -600,10 +600,6 @@ pyps4-2ndscreen==1.0.7 # homeassistant.components.qwikswitch pyqwikswitch==0.93 -# homeassistant.components.modbus -# homeassistant.components.serial -pyserial-asyncio==0.4 - # homeassistant.components.signal_messenger pysignalclirestapi==0.2.4 diff --git a/tests/components/modbus/async/conftest.py b/tests/components/modbus/async/conftest.py new file mode 100644 index 00000000000000..d9cd62313b4737 --- /dev/null +++ b/tests/components/modbus/async/conftest.py @@ -0,0 +1,90 @@ +"""The tests for the Modbus sensor component.""" +from datetime import timedelta +import logging +from unittest import mock + +import pytest + +from homeassistant.components.modbus.const import ( + CALL_TYPE_REGISTER_INPUT, + CONF_REGISTER, + CONF_REGISTER_TYPE, + CONF_REGISTERS, + DEFAULT_HUB, + MODBUS_DOMAIN as 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 + +from tests.common import MockModule, async_fire_time_changed, mock_integration + +_LOGGER = logging.getLogger(__name__) + + +@pytest.fixture() +def mock_hub(hass): + """Mock hub.""" + mock_integration(hass, MockModule(DOMAIN)) + hub = mock.MagicMock() + hub.name = "hub" + hass.data[DOMAIN] = {DEFAULT_HUB: hub} + return hub + + +class ReadResult: + """Storage class for register read results.""" + + def __init__(self, register_words): + """Init.""" + self.registers = register_words + + +read_result = None + + +async def run_test( + hass, use_mock_hub, register_config, entity_domain, register_words, expected +): + """Run test for given config and check that sensor outputs expected result.""" + + async def simulate_read_registers(unit, address, count): + """Simulate modbus register read.""" + del unit, address, count # not used in simulation, but in real connection + return read_result + + # Full sensor configuration + sensor_name = "modbus_test_sensor" + scan_interval = 5 + config = { + entity_domain: { + CONF_PLATFORM: "modbus", + CONF_SCAN_INTERVAL: scan_interval, + CONF_REGISTERS: [ + dict(**{CONF_NAME: sensor_name, CONF_REGISTER: 1234}, **register_config) + ], + } + } + + # Setup inputs for the sensor + read_result = ReadResult(register_words) + if register_config.get(CONF_REGISTER_TYPE) == CALL_TYPE_REGISTER_INPUT: + use_mock_hub.read_input_registers = simulate_read_registers + else: + use_mock_hub.read_holding_registers = simulate_read_registers + + # Initialize sensor + now = dt_util.utcnow() + with mock.patch("homeassistant.helpers.event.dt_util.utcnow", return_value=now): + assert await async_setup_component(hass, entity_domain, config) + + # Trigger update call with time_changed event + now += timedelta(seconds=scan_interval + 1) + with mock.patch("homeassistant.helpers.event.dt_util.utcnow", return_value=now): + async_fire_time_changed(hass, now) + await hass.async_block_till_done() + + # Check state + entity_id = f"{entity_domain}.{sensor_name}" + state = hass.states.get(entity_id).state + assert state == expected diff --git a/tests/components/modbus/async/test_modbus_sensor.py b/tests/components/modbus/async/test_modbus_sensor.py new file mode 100644 index 00000000000000..6207a36393722d --- /dev/null +++ b/tests/components/modbus/async/test_modbus_sensor.py @@ -0,0 +1,359 @@ +"""The tests for the Modbus sensor component.""" +import logging + +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_TYPE, + CONF_REVERSE_ORDER, + CONF_SCALE, + DATA_TYPE_FLOAT, + DATA_TYPE_INT, + DATA_TYPE_UINT, +) +from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN + +from .conftest import run_test + +_LOGGER = logging.getLogger(__name__) + + +async def test_simple_word_register(hass, mock_hub): + """Test conversion of single word register.""" + register_config = { + CONF_COUNT: 1, + CONF_DATA_TYPE: DATA_TYPE_INT, + CONF_SCALE: 1, + CONF_OFFSET: 0, + CONF_PRECISION: 0, + } + await run_test( + hass, + mock_hub, + register_config, + SENSOR_DOMAIN, + register_words=[0], + expected="0", + ) + + +async def test_optional_conf_keys(hass, mock_hub): + """Test handling of optional configuration keys.""" + register_config = {} + await run_test( + hass, + mock_hub, + register_config, + SENSOR_DOMAIN, + register_words=[0x8000], + expected="-32768", + ) + + +async def test_offset(hass, mock_hub): + """Test offset calculation.""" + register_config = { + CONF_COUNT: 1, + CONF_DATA_TYPE: DATA_TYPE_INT, + CONF_SCALE: 1, + CONF_OFFSET: 13, + CONF_PRECISION: 0, + } + await run_test( + hass, + mock_hub, + register_config, + SENSOR_DOMAIN, + register_words=[7], + expected="20", + ) + + +async def test_scale_and_offset(hass, mock_hub): + """Test handling of scale and offset.""" + register_config = { + CONF_COUNT: 1, + CONF_DATA_TYPE: DATA_TYPE_INT, + CONF_SCALE: 3, + CONF_OFFSET: 13, + CONF_PRECISION: 0, + } + await run_test( + hass, + mock_hub, + register_config, + SENSOR_DOMAIN, + register_words=[7], + expected="34", + ) + + +async def test_ints_can_have_precision(hass, mock_hub): + """Test precision can be specified event if using integer values only.""" + register_config = { + CONF_COUNT: 1, + CONF_DATA_TYPE: DATA_TYPE_UINT, + CONF_SCALE: 3, + CONF_OFFSET: 13, + CONF_PRECISION: 4, + } + await run_test( + hass, + mock_hub, + register_config, + SENSOR_DOMAIN, + register_words=[7], + expected="34.0000", + ) + + +async def test_floats_get_rounded_correctly(hass, mock_hub): + """Test that floating point values get rounded correctly.""" + register_config = { + CONF_COUNT: 1, + CONF_DATA_TYPE: DATA_TYPE_INT, + CONF_SCALE: 1.5, + CONF_OFFSET: 0, + CONF_PRECISION: 0, + } + await run_test( + hass, + mock_hub, + register_config, + SENSOR_DOMAIN, + register_words=[1], + expected="2", + ) + + +async def test_parameters_as_strings(hass, mock_hub): + """Test that scale, offset and precision can be given as strings.""" + register_config = { + CONF_COUNT: 1, + CONF_DATA_TYPE: DATA_TYPE_INT, + CONF_SCALE: "1.5", + CONF_OFFSET: "5", + CONF_PRECISION: "1", + } + await run_test( + hass, + mock_hub, + register_config, + SENSOR_DOMAIN, + register_words=[9], + expected="18.5", + ) + + +async def test_floating_point_scale(hass, mock_hub): + """Test use of floating point scale.""" + register_config = { + CONF_COUNT: 1, + CONF_DATA_TYPE: DATA_TYPE_INT, + CONF_SCALE: 2.4, + CONF_OFFSET: 0, + CONF_PRECISION: 2, + } + await run_test( + hass, + mock_hub, + register_config, + SENSOR_DOMAIN, + register_words=[1], + expected="2.40", + ) + + +async def test_floating_point_offset(hass, mock_hub): + """Test use of floating point scale.""" + register_config = { + CONF_COUNT: 1, + CONF_DATA_TYPE: DATA_TYPE_INT, + CONF_SCALE: 1, + CONF_OFFSET: -10.3, + CONF_PRECISION: 1, + } + await run_test( + hass, + mock_hub, + register_config, + SENSOR_DOMAIN, + register_words=[2], + expected="-8.3", + ) + + +async def test_signed_two_word_register(hass, mock_hub): + """Test reading of signed register with two words.""" + register_config = { + CONF_COUNT: 2, + CONF_DATA_TYPE: DATA_TYPE_INT, + CONF_SCALE: 1, + CONF_OFFSET: 0, + CONF_PRECISION: 0, + } + await run_test( + hass, + mock_hub, + register_config, + SENSOR_DOMAIN, + register_words=[0x89AB, 0xCDEF], + expected="-1985229329", + ) + + +async def test_unsigned_two_word_register(hass, mock_hub): + """Test reading of unsigned register with two words.""" + register_config = { + CONF_COUNT: 2, + CONF_DATA_TYPE: DATA_TYPE_UINT, + CONF_SCALE: 1, + CONF_OFFSET: 0, + CONF_PRECISION: 0, + } + await run_test( + hass, + mock_hub, + register_config, + SENSOR_DOMAIN, + register_words=[0x89AB, 0xCDEF], + expected=str(0x89ABCDEF), + ) + + +async def test_reversed(hass, mock_hub): + """Test handling of reversed register words.""" + register_config = { + CONF_COUNT: 2, + CONF_DATA_TYPE: DATA_TYPE_UINT, + CONF_REVERSE_ORDER: True, + } + await run_test( + hass, + mock_hub, + register_config, + SENSOR_DOMAIN, + register_words=[0x89AB, 0xCDEF], + expected=str(0xCDEF89AB), + ) + + +async def test_four_word_register(hass, mock_hub): + """Test reading of 64-bit register.""" + register_config = { + CONF_COUNT: 4, + CONF_DATA_TYPE: DATA_TYPE_UINT, + CONF_SCALE: 1, + CONF_OFFSET: 0, + CONF_PRECISION: 0, + } + await run_test( + hass, + mock_hub, + register_config, + SENSOR_DOMAIN, + register_words=[0x89AB, 0xCDEF, 0x0123, 0x4567], + expected="9920249030613615975", + ) + + +async def test_four_word_register_precision_is_intact_with_int_params(hass, mock_hub): + """Test that precision is not lost when doing integer arithmetic for 64-bit register.""" + register_config = { + CONF_COUNT: 4, + CONF_DATA_TYPE: DATA_TYPE_UINT, + CONF_SCALE: 2, + CONF_OFFSET: 3, + CONF_PRECISION: 0, + } + await run_test( + hass, + mock_hub, + register_config, + SENSOR_DOMAIN, + register_words=[0x0123, 0x4567, 0x89AB, 0xCDEF], + expected="163971058432973793", + ) + + +async def test_four_word_register_precision_is_lost_with_float_params(hass, mock_hub): + """Test that precision is affected when floating point conversion is done.""" + register_config = { + CONF_COUNT: 4, + CONF_DATA_TYPE: DATA_TYPE_UINT, + CONF_SCALE: 2.0, + CONF_OFFSET: 3.0, + CONF_PRECISION: 0, + } + await run_test( + hass, + mock_hub, + register_config, + SENSOR_DOMAIN, + register_words=[0x0123, 0x4567, 0x89AB, 0xCDEF], + expected="163971058432973792", + ) + + +async def test_two_word_input_register(hass, mock_hub): + """Test reaging of input register.""" + register_config = { + CONF_COUNT: 2, + CONF_REGISTER_TYPE: CALL_TYPE_REGISTER_INPUT, + CONF_DATA_TYPE: DATA_TYPE_UINT, + CONF_SCALE: 1, + CONF_OFFSET: 0, + CONF_PRECISION: 0, + } + await run_test( + hass, + mock_hub, + register_config, + SENSOR_DOMAIN, + register_words=[0x89AB, 0xCDEF], + expected=str(0x89ABCDEF), + ) + + +async def test_two_word_holding_register(hass, mock_hub): + """Test reaging of holding register.""" + register_config = { + CONF_COUNT: 2, + CONF_REGISTER_TYPE: CALL_TYPE_REGISTER_HOLDING, + CONF_DATA_TYPE: DATA_TYPE_UINT, + CONF_SCALE: 1, + CONF_OFFSET: 0, + CONF_PRECISION: 0, + } + await run_test( + hass, + mock_hub, + register_config, + SENSOR_DOMAIN, + register_words=[0x89AB, 0xCDEF], + expected=str(0x89ABCDEF), + ) + + +async def test_float_data_type(hass, mock_hub): + """Test floating point register data type.""" + register_config = { + CONF_COUNT: 2, + CONF_REGISTER_TYPE: CALL_TYPE_REGISTER_HOLDING, + CONF_DATA_TYPE: DATA_TYPE_FLOAT, + CONF_SCALE: 1, + CONF_OFFSET: 0, + CONF_PRECISION: 5, + } + await run_test( + hass, + mock_hub, + register_config, + SENSOR_DOMAIN, + register_words=[16286, 1617], + expected="1.23457", + ) diff --git a/tests/components/modbus/conftest.py b/tests/components/modbus/conftest.py index d9cd62313b4737..814e59e5571fb0 100644 --- a/tests/components/modbus/conftest.py +++ b/tests/components/modbus/conftest.py @@ -40,19 +40,11 @@ def __init__(self, register_words): self.registers = register_words -read_result = None - - async def run_test( hass, use_mock_hub, register_config, entity_domain, register_words, expected ): """Run test for given config and check that sensor outputs expected result.""" - async def simulate_read_registers(unit, address, count): - """Simulate modbus register read.""" - del unit, address, count # not used in simulation, but in real connection - return read_result - # Full sensor configuration sensor_name = "modbus_test_sensor" scan_interval = 5 @@ -69,9 +61,9 @@ async def simulate_read_registers(unit, address, count): # Setup inputs for the sensor read_result = ReadResult(register_words) if register_config.get(CONF_REGISTER_TYPE) == CALL_TYPE_REGISTER_INPUT: - use_mock_hub.read_input_registers = simulate_read_registers + use_mock_hub.read_input_registers.return_value = read_result else: - use_mock_hub.read_holding_registers = simulate_read_registers + use_mock_hub.read_holding_registers.return_value = read_result # Initialize sensor now = dt_util.utcnow() From 7c5506ff11bcc91274f2de5077b581925e9a5fac Mon Sep 17 00:00:00 2001 From: jan Iversen Date: Thu, 16 Apr 2020 16:45:31 +0200 Subject: [PATCH 2/2] do not publish async version The async version will be made available in a forked repo, until it is ready to replace the production code. --- .../components/modbus/async/__init__.py | 326 ---------------- .../components/modbus/async/binary_sensor.py | 124 ------ .../components/modbus/async/climate.py | 275 -------------- .../components/modbus/async/const.py | 72 ---- .../components/modbus/async/sensor.py | 253 ------------ .../components/modbus/async/switch.py | 290 -------------- tests/components/modbus/async/conftest.py | 90 ----- .../modbus/async/test_modbus_sensor.py | 359 ------------------ 8 files changed, 1789 deletions(-) delete mode 100644 homeassistant/components/modbus/async/__init__.py delete mode 100644 homeassistant/components/modbus/async/binary_sensor.py delete mode 100644 homeassistant/components/modbus/async/climate.py delete mode 100644 homeassistant/components/modbus/async/const.py delete mode 100644 homeassistant/components/modbus/async/sensor.py delete mode 100644 homeassistant/components/modbus/async/switch.py delete mode 100644 tests/components/modbus/async/conftest.py delete mode 100644 tests/components/modbus/async/test_modbus_sensor.py diff --git a/homeassistant/components/modbus/async/__init__.py b/homeassistant/components/modbus/async/__init__.py deleted file mode 100644 index ad0330b56a0acb..00000000000000 --- a/homeassistant/components/modbus/async/__init__.py +++ /dev/null @@ -1,326 +0,0 @@ -"""Support for Modbus.""" -import asyncio -import logging - -from async_timeout import timeout -from pymodbus.client.asynchronous.asyncio import ( - AsyncioModbusSerialClient, - ModbusClientProtocol, - init_tcp_client, - init_udp_client, -) -from pymodbus.exceptions import ModbusException -from pymodbus.factory import ClientDecoder -from pymodbus.pdu import ExceptionResponse -from pymodbus.transaction import ( - ModbusAsciiFramer, - ModbusBinaryFramer, - ModbusRtuFramer, - ModbusSocketFramer, -) -import voluptuous as vol - -from homeassistant.const import ( - ATTR_STATE, - CONF_DELAY, - CONF_HOST, - CONF_METHOD, - CONF_NAME, - CONF_PORT, - CONF_TIMEOUT, - CONF_TYPE, - EVENT_HOMEASSISTANT_STOP, -) -import homeassistant.helpers.config_validation as cv - -from .const import ( - ATTR_ADDRESS, - ATTR_HUB, - ATTR_UNIT, - ATTR_VALUE, - CONF_BAUDRATE, - CONF_BYTESIZE, - CONF_PARITY, - CONF_STOPBITS, - DEFAULT_HUB, - MODBUS_DOMAIN as DOMAIN, - SERVICE_WRITE_COIL, - SERVICE_WRITE_REGISTER, -) - -_LOGGER = logging.getLogger(__name__) - -BASE_SCHEMA = vol.Schema({vol.Optional(CONF_NAME, default=DEFAULT_HUB): cv.string}) - -SERIAL_SCHEMA = BASE_SCHEMA.extend( - { - vol.Required(CONF_BAUDRATE): cv.positive_int, - vol.Required(CONF_BYTESIZE): vol.Any(5, 6, 7, 8), - vol.Required(CONF_METHOD): vol.Any("rtu", "ascii"), - vol.Required(CONF_PORT): cv.string, - vol.Required(CONF_PARITY): vol.Any("E", "O", "N"), - vol.Required(CONF_STOPBITS): vol.Any(1, 2), - vol.Required(CONF_TYPE): "serial", - vol.Optional(CONF_TIMEOUT, default=3): cv.socket_timeout, - } -) - -ETHERNET_SCHEMA = BASE_SCHEMA.extend( - { - vol.Required(CONF_HOST): cv.string, - 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)])}, - extra=vol.ALLOW_EXTRA, -) - -SERVICE_WRITE_REGISTER_SCHEMA = vol.Schema( - { - vol.Optional(ATTR_HUB, default=DEFAULT_HUB): cv.string, - vol.Required(ATTR_UNIT): cv.positive_int, - vol.Required(ATTR_ADDRESS): cv.positive_int, - vol.Required(ATTR_VALUE): vol.Any( - cv.positive_int, vol.All(cv.ensure_list, [cv.positive_int]) - ), - } -) - -SERVICE_WRITE_COIL_SCHEMA = vol.Schema( - { - vol.Optional(ATTR_HUB, default=DEFAULT_HUB): cv.string, - vol.Required(ATTR_UNIT): cv.positive_int, - vol.Required(ATTR_ADDRESS): cv.positive_int, - vol.Required(ATTR_STATE): cv.boolean, - } -) - - -async def async_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, hass.loop) - - def stop_modbus(event): - """Stop Modbus service.""" - for client in hub_collect.values(): - del client - - 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): - await hub_collect[client_name].write_registers( - unit, address, [int(float(i)) for i in value] - ) - else: - await hub_collect[client_name].write_register( - unit, address, int(float(value)) - ) - - 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] - await hub_collect[client_name].write_coil(unit, address, state) - - # do not wait for EVENT_HOMEASSISTANT_START, activate pymodbus now - for client in hub_collect.values(): - await client.setup(hass) - - # register function to gracefully stop modbus - hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, stop_modbus) - - # Register services for modbus - hass.services.async_register( - DOMAIN, - SERVICE_WRITE_REGISTER, - write_register, - schema=SERVICE_WRITE_REGISTER_SCHEMA, - ) - hass.services.async_register( - DOMAIN, SERVICE_WRITE_COIL, write_coil, schema=SERVICE_WRITE_COIL_SCHEMA, - ) - return True - - -class ModbusHub: - """Thread safe wrapper class for pymodbus.""" - - def __init__(self, client_config, main_loop): - """Initialize the Modbus hub.""" - - # 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 = 0 - - 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] - self._config_delay = client_config[CONF_DELAY] - - @property - def name(self): - """Return the name of this hub.""" - return self._config_name - - async def _connect_delay(self): - if self._config_delay > 0: - await asyncio.sleep(self._config_delay) - self._config_delay = 0 - - @staticmethod - def _framer(method): - if method == "ascii": - framer = ModbusAsciiFramer(ClientDecoder()) - elif method == "rtu": - framer = ModbusRtuFramer(ClientDecoder()) - elif method == "binary": - framer = ModbusBinaryFramer(ClientDecoder()) - elif method == "socket": - framer = ModbusSocketFramer(ClientDecoder()) - else: - framer = None - return framer - - async def setup(self, hass): - """Set up pymodbus client.""" - if self._config_type == "serial": - # reconnect ?? - framer = self._framer(self._config_method) - - # just a class creation no IO or other slow items - self._client = AsyncioModbusSerialClient( - self._config_port, - protocol_class=ModbusClientProtocol, - framer=framer, - loop=self._loop, - baudrate=self._config_baudrate, - bytesize=self._config_bytesize, - parity=self._config_parity, - stopbits=self._config_stopbits, - ) - await self._client.connect() - elif self._config_type == "rtuovertcp": - # framer ModbusRtuFramer ?? - # timeout ?? - self._client = await init_tcp_client( - None, self._loop, self._config_host, self._config_port - ) - elif self._config_type == "tcp": - # framer ?? - # timeout ?? - self._client = await init_tcp_client( - None, self._loop, self._config_host, self._config_port - ) - elif self._config_type == "udp": - # framer ?? - # timeout ?? - self._client = await init_udp_client( - None, self._loop, self._config_host, self._config_port - ) - 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 {} - try: - async with timeout(self._config_timeout): - result = await func(address, count, **kwargs) - except asyncio.TimeoutError: - result = None - - 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 {} - try: - async with timeout(self._config_timeout): - func(address, value, **kwargs) - except asyncio.TimeoutError: - return - - async def read_coils(self, unit, address, count): - """Read coils.""" - if self._client.protocol is None: - return None - return await self._read(unit, address, count, self._client.protocol.read_coils) - - async def read_discrete_inputs(self, unit, address, count): - """Read discrete inputs.""" - if self._client.protocol is None: - return None - return await self._read( - unit, address, count, self._client.protocol.read_discrete_inputs - ) - - async def read_input_registers(self, unit, address, count): - """Read input registers.""" - if self._client.protocol is None: - return None - return await self._read( - unit, address, count, self._client.protocol.read_input_registers - ) - - async def read_holding_registers(self, unit, address, count): - """Read holding registers.""" - if self._client.protocol is None: - return None - return await self._read( - unit, address, count, self._client.protocol.read_holding_registers - ) - - async def write_coil(self, unit, address, value): - """Write coil.""" - if self._client.protocol is None: - return None - return await self._write(unit, address, value, self._client.protocol.write_coil) - - async def write_register(self, unit, address, value): - """Write register.""" - if self._client.protocol is None: - return None - return await self._write( - unit, address, value, self._client.protocol.write_register - ) - - async def write_registers(self, unit, address, values): - """Write registers.""" - 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/async/binary_sensor.py b/homeassistant/components/modbus/async/binary_sensor.py deleted file mode 100644 index 9989b9d530acb4..00000000000000 --- a/homeassistant/components/modbus/async/binary_sensor.py +++ /dev/null @@ -1,124 +0,0 @@ -"""Support for Modbus Coil and Discrete Input sensors.""" -import logging -from typing import Optional - -from pymodbus.exceptions import ModbusException -from pymodbus.pdu import ExceptionResponse -import voluptuous as vol - -from homeassistant.components.binary_sensor import ( - DEVICE_CLASSES_SCHEMA, - PLATFORM_SCHEMA, - BinarySensorDevice, -) -from homeassistant.const import CONF_DEVICE_CLASS, CONF_NAME, CONF_SLAVE -from homeassistant.helpers import config_validation as cv - -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__) - -PLATFORM_SCHEMA = vol.All( - cv.deprecated(CONF_COILS, CONF_INPUTS), - PLATFORM_SCHEMA.extend( - { - vol.Required(CONF_INPUTS): [ - vol.All( - cv.deprecated(CALL_TYPE_COIL, CONF_ADDRESS), - vol.Schema( - { - vol.Required(CONF_ADDRESS): cv.positive_int, - vol.Required(CONF_NAME): cv.string, - vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA, - vol.Optional(CONF_HUB, default=DEFAULT_HUB): cv.string, - vol.Optional(CONF_SLAVE): cv.positive_int, - vol.Optional( - CONF_INPUT_TYPE, default=CALL_TYPE_COIL - ): vol.In([CALL_TYPE_COIL, CALL_TYPE_DISCRETE]), - } - ), - ) - ] - } - ), -) - - -async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): - """Set up the Modbus binary sensors.""" - sensors = [] - for entry in config[CONF_INPUTS]: - hub = hass.data[MODBUS_DOMAIN][entry[CONF_HUB]] - sensors.append( - ModbusBinarySensor( - hub, - entry[CONF_NAME], - entry.get(CONF_SLAVE), - entry[CONF_ADDRESS], - entry.get(CONF_DEVICE_CLASS), - entry[CONF_INPUT_TYPE], - ) - ) - - async_add_entities(sensors) - - -class ModbusBinarySensor(BinarySensorDevice): - """Modbus binary sensor.""" - - def __init__(self, hub, name, slave, address, device_class, input_type): - """Initialize the Modbus binary sensor.""" - self._hub = hub - self._name = name - self._slave = int(slave) if slave else None - self._address = int(address) - self._device_class = device_class - self._input_type = input_type - self._value = None - self._available = True - - @property - def name(self): - """Return the name of the sensor.""" - return self._name - - @property - def is_on(self): - """Return the state of the sensor.""" - return self._value - - @property - def device_class(self) -> Optional[str]: - """Return the device class of the sensor.""" - return self._device_class - - @property - def available(self) -> bool: - """Return True if entity is available.""" - return self._available - - async def async_update(self): - """Update the state of the sensor.""" - 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._available = False - return - - self._value = result.bits[0] - self._available = True diff --git a/homeassistant/components/modbus/async/climate.py b/homeassistant/components/modbus/async/climate.py deleted file mode 100644 index e5fbcf4d42179a..00000000000000 --- a/homeassistant/components/modbus/async/climate.py +++ /dev/null @@ -1,275 +0,0 @@ -"""Support for Generic Modbus Thermostats.""" -import logging -import struct -from typing import Optional - -from pymodbus.exceptions import ModbusException -from pymodbus.pdu import ExceptionResponse -import voluptuous as vol - -from homeassistant.components.climate import PLATFORM_SCHEMA, ClimateDevice -from homeassistant.components.climate.const import ( - HVAC_MODE_AUTO, - SUPPORT_TARGET_TEMPERATURE, -) -from homeassistant.const import ( - ATTR_TEMPERATURE, - CONF_NAME, - CONF_SLAVE, - TEMP_CELSIUS, - TEMP_FAHRENHEIT, -) -import homeassistant.helpers.config_validation as cv - -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__) - - -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( - { - vol.Required(CONF_CURRENT_TEMP): cv.positive_int, - vol.Required(CONF_NAME): cv.string, - vol.Required(CONF_SLAVE): cv.positive_int, - vol.Required(CONF_TARGET_TEMP): cv.positive_int, - vol.Optional(CONF_DATA_COUNT, default=2): cv.positive_int, - vol.Optional( - 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] - ), - vol.Optional(CONF_HUB, default=DEFAULT_HUB): cv.string, - vol.Optional(CONF_PRECISION, default=1): cv.positive_int, - vol.Optional(CONF_SCALE, default=1): vol.Coerce(float), - vol.Optional(CONF_OFFSET, default=0): vol.Coerce(float), - vol.Optional(CONF_MAX_TEMP, default=35): cv.positive_int, - vol.Optional(CONF_MIN_TEMP, default=5): cv.positive_int, - vol.Optional(CONF_STEP, default=0.5): vol.Coerce(float), - vol.Optional(CONF_UNIT, default="C"): cv.string, - } -) - - -async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): - """Set up the Modbus Thermostat Platform.""" - name = config[CONF_NAME] - modbus_slave = config[CONF_SLAVE] - target_temp_register = config[CONF_TARGET_TEMP] - 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_DATA_COUNT] - precision = config[CONF_PRECISION] - scale = config[CONF_SCALE] - offset = config[CONF_OFFSET] - unit = config[CONF_UNIT] - max_temp = config[CONF_MAX_TEMP] - min_temp = config[CONF_MIN_TEMP] - temp_step = config[CONF_STEP] - hub_name = config[CONF_HUB] - hub = hass.data[MODBUS_DOMAIN][hub_name] - - async_add_entities( - [ - ModbusThermostat( - hub, - name, - modbus_slave, - target_temp_register, - current_temp_register, - current_temp_register_type, - data_type, - count, - precision, - scale, - offset, - unit, - max_temp, - min_temp, - temp_step, - ) - ], - True, - ) - - -class ModbusThermostat(ClimateDevice): - """Representation of a Modbus Thermostat.""" - - def __init__( - self, - hub, - name, - modbus_slave, - target_temp_register, - current_temp_register, - current_temp_register_type, - data_type, - count, - precision, - scale, - offset, - unit, - max_temp, - min_temp, - temp_step, - ): - """Initialize the unit.""" - self._hub = hub - self._name = name - self._slave = modbus_slave - self._target_temperature_register = target_temp_register - self._current_temperature_register = current_temp_register - self._current_temperature_register_type = current_temp_register_type - self._target_temperature = None - self._current_temperature = None - self._data_type = data_type - self._count = int(count) - self._precision = precision - self._scale = scale - self._offset = offset - self._unit = unit - self._max_temp = max_temp - self._min_temp = min_temp - self._temp_step = temp_step - self._structure = ">f" - self._available = True - - data_types = { - DATA_TYPE_INT: {1: "h", 2: "i", 4: "q"}, - DATA_TYPE_UINT: {1: "H", 2: "I", 4: "Q"}, - DATA_TYPE_FLOAT: {1: "e", 2: "f", 4: "d"}, - } - - self._structure = f">{data_types[self._data_type][self._count]}" - - @property - def supported_features(self): - """Return the list of supported features.""" - return SUPPORT_TARGET_TEMPERATURE - - async def async_update(self): - """Update Target & Current Temperature.""" - self._target_temperature = await self._read_register( - CALL_TYPE_REGISTER_HOLDING, self._target_temperature_register - ) - self._current_temperature = await self._read_register( - self._current_temperature_register_type, self._current_temperature_register - ) - - @property - def hvac_mode(self): - """Return the current HVAC mode.""" - return HVAC_MODE_AUTO - - @property - def hvac_modes(self): - """Return the possible HVAC modes.""" - return [HVAC_MODE_AUTO] - - @property - def name(self): - """Return the name of the climate device.""" - return self._name - - @property - def current_temperature(self): - """Return the current temperature.""" - return self._current_temperature - - @property - def target_temperature(self): - """Return the target temperature.""" - return self._target_temperature - - @property - def temperature_unit(self): - """Return the unit of measurement.""" - return TEMP_FAHRENHEIT if self._unit == "F" else TEMP_CELSIUS - - @property - def min_temp(self): - """Return the minimum temperature.""" - return self._min_temp - - @property - def max_temp(self): - """Return the maximum temperature.""" - return self._max_temp - - @property - def target_temperature_step(self): - """Return the supported step of target temperature.""" - return self._temp_step - - async def set_temperature(self, **kwargs): - """Set new target temperature.""" - target_temperature = int( - (kwargs.get(ATTR_TEMPERATURE) - self._offset) / self._scale - ) - if target_temperature is None: - return - byte_string = struct.pack(self._structure, target_temperature) - register_value = struct.unpack(">h", byte_string[0:2])[0] - await self._write_register(self._target_temperature_register, register_value) - - @property - def available(self) -> bool: - """Return True if entity is available.""" - return self._available - - async def _read_register(self, register_type, register) -> Optional[float]: - """Read register using the Modbus hub slave.""" - 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._available = False - return - - byte_string = b"".join( - [x.to_bytes(2, byteorder="big") for x in result.registers] - ) - val = struct.unpack(self._structure, byte_string)[0] - register_value = format( - (self._scale * val) + self._offset, f".{self._precision}f" - ) - register_value = float(register_value) - self._available = True - - return register_value - - async def _write_register(self, register, value): - """Write holding register using the Modbus hub slave.""" - await self._hub.write_registers(self._slave, register, [value, 0]) - self._available = True diff --git a/homeassistant/components/modbus/async/const.py b/homeassistant/components/modbus/async/const.py deleted file mode 100644 index e507717b22c5b8..00000000000000 --- a/homeassistant/components/modbus/async/const.py +++ /dev/null @@ -1,72 +0,0 @@ -"""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/async/sensor.py b/homeassistant/components/modbus/async/sensor.py deleted file mode 100644 index be3bc4c52c6c70..00000000000000 --- a/homeassistant/components/modbus/async/sensor.py +++ /dev/null @@ -1,253 +0,0 @@ -"""Support for Modbus Register sensors.""" -import logging -import struct -from typing import Any, Optional, Union - -from pymodbus.exceptions import ModbusException -from pymodbus.pdu import ExceptionResponse -import voluptuous as vol - -from homeassistant.components.sensor import DEVICE_CLASSES_SCHEMA, PLATFORM_SCHEMA -from homeassistant.const import ( - CONF_DEVICE_CLASS, - CONF_NAME, - CONF_OFFSET, - CONF_SLAVE, - CONF_STRUCTURE, - CONF_UNIT_OF_MEASUREMENT, -) -from homeassistant.helpers import config_validation as cv -from homeassistant.helpers.restore_state import RestoreEntity - -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__) - - -def number(value: Any) -> Union[int, float]: - """Coerce a value to number without losing precision.""" - if isinstance(value, int): - return value - - if isinstance(value, str): - try: - value = int(value) - return value - except (TypeError, ValueError): - pass - - try: - value = float(value) - return value - except (TypeError, ValueError): - raise vol.Invalid(f"invalid number {value}") - - -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( - { - vol.Required(CONF_REGISTERS): [ - { - vol.Required(CONF_NAME): cv.string, - vol.Required(CONF_REGISTER): cv.positive_int, - vol.Optional(CONF_COUNT, default=1): cv.positive_int, - vol.Optional(CONF_DATA_TYPE, default=DATA_TYPE_INT): vol.In( - [DATA_TYPE_INT, DATA_TYPE_UINT, DATA_TYPE_FLOAT, DATA_TYPE_CUSTOM] - ), - vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA, - vol.Optional(CONF_HUB, default=DEFAULT_HUB): cv.string, - vol.Optional(CONF_OFFSET, default=0): number, - vol.Optional(CONF_PRECISION, default=0): cv.positive_int, - vol.Optional( - 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, - vol.Optional(CONF_STRUCTURE): cv.string, - vol.Optional(CONF_UNIT_OF_MEASUREMENT): cv.string, - } - ] - } -) - - -async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): - """Set up the Modbus sensors.""" - sensors = [] - data_types = {DATA_TYPE_INT: {1: "h", 2: "i", 4: "q"}} - data_types[DATA_TYPE_UINT] = {1: "H", 2: "I", 4: "Q"} - data_types[DATA_TYPE_FLOAT] = {1: "e", 2: "f", 4: "d"} - - for register in config[CONF_REGISTERS]: - structure = ">i" - if register[CONF_DATA_TYPE] != DATA_TYPE_CUSTOM: - try: - structure = ( - f">{data_types[register[CONF_DATA_TYPE]][register[CONF_COUNT]]}" - ) - except KeyError: - _LOGGER.error( - "Unable to detect data type for %s sensor, try a custom type", - register[CONF_NAME], - ) - continue - else: - structure = register.get(CONF_STRUCTURE) - - try: - size = struct.calcsize(structure) - except struct.error as err: - _LOGGER.error("Error in sensor %s structure: %s", register[CONF_NAME], err) - continue - - if register[CONF_COUNT] * 2 != size: - _LOGGER.error( - "Structure size (%d bytes) mismatch registers count (%d words)", - size, - register[CONF_COUNT], - ) - continue - - hub_name = register[CONF_HUB] - hub = hass.data[MODBUS_DOMAIN][hub_name] - sensors.append( - ModbusRegisterSensor( - hub, - register[CONF_NAME], - register.get(CONF_SLAVE), - register[CONF_REGISTER], - register[CONF_REGISTER_TYPE], - register.get(CONF_UNIT_OF_MEASUREMENT), - register[CONF_COUNT], - register[CONF_REVERSE_ORDER], - register[CONF_SCALE], - register[CONF_OFFSET], - structure, - register[CONF_PRECISION], - register.get(CONF_DEVICE_CLASS), - ) - ) - - if not sensors: - return False - async_add_entities(sensors) - - -class ModbusRegisterSensor(RestoreEntity): - """Modbus register sensor.""" - - def __init__( - self, - hub, - name, - slave, - register, - register_type, - unit_of_measurement, - count, - reverse_order, - scale, - offset, - structure, - precision, - device_class, - ): - """Initialize the modbus register sensor.""" - self._hub = hub - self._name = name - self._slave = int(slave) if slave else None - self._register = int(register) - self._register_type = register_type - self._unit_of_measurement = unit_of_measurement - self._count = int(count) - self._reverse_order = reverse_order - self._scale = scale - self._offset = offset - self._precision = precision - self._structure = structure - self._device_class = device_class - self._value = None - self._available = True - - 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 - - @property - def state(self): - """Return the state of the sensor.""" - return self._value - - @property - def name(self): - """Return the name of the sensor.""" - return self._name - - @property - def unit_of_measurement(self): - """Return the unit of measurement.""" - return self._unit_of_measurement - - @property - def device_class(self) -> Optional[str]: - """Return the device class of the sensor.""" - return self._device_class - - @property - def available(self) -> bool: - """Return True if entity is available.""" - return self._available - - async def async_update(self): - """Update the state of the sensor.""" - 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._available = False - return - - registers = result.registers - if self._reverse_order: - registers.reverse() - - byte_string = b"".join([x.to_bytes(2, byteorder="big") for x in registers]) - val = struct.unpack(self._structure, byte_string)[0] - val = self._scale * val + self._offset - if isinstance(val, int): - self._value = str(val) - if self._precision > 0: - self._value += f".{'0' * self._precision}" - else: - self._value = f"{val:.{self._precision}f}" - - self._available = True diff --git a/homeassistant/components/modbus/async/switch.py b/homeassistant/components/modbus/async/switch.py deleted file mode 100644 index e4ec6a004fbc4f..00000000000000 --- a/homeassistant/components/modbus/async/switch.py +++ /dev/null @@ -1,290 +0,0 @@ -"""Support for Modbus switches.""" -import logging -from typing import Optional - -from pymodbus.exceptions import ModbusException -from pymodbus.pdu import ExceptionResponse -import voluptuous as vol - -from homeassistant.components.switch import PLATFORM_SCHEMA -from homeassistant.const import ( - CONF_COMMAND_OFF, - CONF_COMMAND_ON, - CONF_NAME, - CONF_SLAVE, - STATE_ON, -) -from homeassistant.helpers import config_validation as cv -from homeassistant.helpers.entity import ToggleEntity -from homeassistant.helpers.restore_state import RestoreEntity - -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__) - - -REGISTERS_SCHEMA = vol.Schema( - { - vol.Required(CONF_COMMAND_OFF): cv.positive_int, - vol.Required(CONF_COMMAND_ON): cv.positive_int, - 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=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, - vol.Optional(CONF_STATE_ON): cv.positive_int, - vol.Optional(CONF_VERIFY_REGISTER): cv.positive_int, - vol.Optional(CONF_VERIFY_STATE, default=True): cv.boolean, - } -) - -COILS_SCHEMA = vol.Schema( - { - 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, - } -) - -PLATFORM_SCHEMA = vol.All( - cv.has_at_least_one_key(CONF_COILS, CONF_REGISTERS), - PLATFORM_SCHEMA.extend( - { - vol.Optional(CONF_COILS): [COILS_SCHEMA], - vol.Optional(CONF_REGISTERS): [REGISTERS_SCHEMA], - } - ), -) - - -async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): - """Read configuration and create Modbus devices.""" - switches = [] - if CONF_COILS in config: - for coil in config[CONF_COILS]: - hub_name = coil[CONF_HUB] - hub = hass.data[MODBUS_DOMAIN][hub_name] - switches.append( - ModbusCoilSwitch( - hub, coil[CONF_NAME], coil[CONF_SLAVE], coil[CALL_TYPE_COIL] - ) - ) - if CONF_REGISTERS in config: - for register in config[CONF_REGISTERS]: - hub_name = register[CONF_HUB] - hub = hass.data[MODBUS_DOMAIN][hub_name] - - switches.append( - ModbusRegisterSwitch( - hub, - register[CONF_NAME], - register.get(CONF_SLAVE), - register[CONF_REGISTER], - register[CONF_COMMAND_ON], - register[CONF_COMMAND_OFF], - register[CONF_VERIFY_STATE], - register.get(CONF_VERIFY_REGISTER), - register[CONF_REGISTER_TYPE], - register.get(CONF_STATE_ON), - register.get(CONF_STATE_OFF), - ) - ) - - async_add_entities(switches) - - -class ModbusCoilSwitch(ToggleEntity, RestoreEntity): - """Representation of a Modbus coil switch.""" - - def __init__(self, hub, name, slave, coil): - """Initialize the coil switch.""" - self._hub = hub - self._name = name - self._slave = int(slave) if slave else None - self._coil = int(coil) - self._is_on = None - self._available = True - - 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._is_on = state.state == STATE_ON - - @property - def is_on(self): - """Return true if switch is on.""" - return self._is_on - - @property - def name(self): - """Return the name of the switch.""" - return self._name - - @property - def available(self) -> bool: - """Return True if entity is available.""" - return self._available - - async def turn_on(self, **kwargs): - """Set switch on.""" - await self._write_coil(self._coil, True) - - async def turn_off(self, **kwargs): - """Set switch off.""" - await self._write_coil(self._coil, False) - - async def async_update(self): - """Update the state of the switch.""" - self._is_on = await self._read_coil(self._coil) - - async def _read_coil(self, coil) -> Optional[bool]: - """Read coil using the Modbus hub slave.""" - result = await self._hub.read_coils(self._slave, coil, 1) - if result is None: - self._available = False - return - if isinstance(result, (ModbusException, ExceptionResponse)): - self._available = False - return - - value = bool(result.bits[0]) - self._available = True - - return value - - async def _write_coil(self, coil, value): - """Write coil using the Modbus hub slave.""" - await self._hub.write_coil(self._slave, coil, value) - self._available = True - - -class ModbusRegisterSwitch(ModbusCoilSwitch): - """Representation of a Modbus register switch.""" - - # pylint: disable=super-init-not-called - def __init__( - self, - hub, - name, - slave, - register, - command_on, - command_off, - verify_state, - verify_register, - register_type, - state_on, - state_off, - ): - """Initialize the register switch.""" - self._hub = hub - self._name = name - self._slave = slave - self._register = register - self._command_on = command_on - self._command_off = command_off - self._verify_state = verify_state - self._verify_register = verify_register if verify_register else self._register - self._register_type = register_type - self._available = True - - if state_on is not None: - self._state_on = state_on - else: - self._state_on = self._command_on - - if state_off is not None: - self._state_off = state_off - else: - self._state_off = self._command_off - - self._is_on = None - - async def turn_on(self, **kwargs): - """Set switch on.""" - - # Only holding register is writable - if self._register_type == CALL_TYPE_REGISTER_HOLDING: - await self._write_register(self._command_on) - if not self._verify_state: - self._is_on = True - - async def turn_off(self, **kwargs): - """Set switch off.""" - - # Only holding register is writable - if self._register_type == CALL_TYPE_REGISTER_HOLDING: - await self._write_register(self._command_off) - if not self._verify_state: - self._is_on = False - - @property - def available(self) -> bool: - """Return True if entity is available.""" - return self._available - - async def async_update(self): - """Update the state of the switch.""" - if not self._verify_state: - return - - value = await self._read_register() - 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._register, - value, - ) - - 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._available = False - return - - value = int(result.registers[0]) - self._available = True - - return value - - async def _write_register(self, value): - """Write holding register using the Modbus hub slave.""" - await self._hub.write_register(self._slave, self._register, value) - self._available = True diff --git a/tests/components/modbus/async/conftest.py b/tests/components/modbus/async/conftest.py deleted file mode 100644 index d9cd62313b4737..00000000000000 --- a/tests/components/modbus/async/conftest.py +++ /dev/null @@ -1,90 +0,0 @@ -"""The tests for the Modbus sensor component.""" -from datetime import timedelta -import logging -from unittest import mock - -import pytest - -from homeassistant.components.modbus.const import ( - CALL_TYPE_REGISTER_INPUT, - CONF_REGISTER, - CONF_REGISTER_TYPE, - CONF_REGISTERS, - DEFAULT_HUB, - MODBUS_DOMAIN as 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 - -from tests.common import MockModule, async_fire_time_changed, mock_integration - -_LOGGER = logging.getLogger(__name__) - - -@pytest.fixture() -def mock_hub(hass): - """Mock hub.""" - mock_integration(hass, MockModule(DOMAIN)) - hub = mock.MagicMock() - hub.name = "hub" - hass.data[DOMAIN] = {DEFAULT_HUB: hub} - return hub - - -class ReadResult: - """Storage class for register read results.""" - - def __init__(self, register_words): - """Init.""" - self.registers = register_words - - -read_result = None - - -async def run_test( - hass, use_mock_hub, register_config, entity_domain, register_words, expected -): - """Run test for given config and check that sensor outputs expected result.""" - - async def simulate_read_registers(unit, address, count): - """Simulate modbus register read.""" - del unit, address, count # not used in simulation, but in real connection - return read_result - - # Full sensor configuration - sensor_name = "modbus_test_sensor" - scan_interval = 5 - config = { - entity_domain: { - CONF_PLATFORM: "modbus", - CONF_SCAN_INTERVAL: scan_interval, - CONF_REGISTERS: [ - dict(**{CONF_NAME: sensor_name, CONF_REGISTER: 1234}, **register_config) - ], - } - } - - # Setup inputs for the sensor - read_result = ReadResult(register_words) - if register_config.get(CONF_REGISTER_TYPE) == CALL_TYPE_REGISTER_INPUT: - use_mock_hub.read_input_registers = simulate_read_registers - else: - use_mock_hub.read_holding_registers = simulate_read_registers - - # Initialize sensor - now = dt_util.utcnow() - with mock.patch("homeassistant.helpers.event.dt_util.utcnow", return_value=now): - assert await async_setup_component(hass, entity_domain, config) - - # Trigger update call with time_changed event - now += timedelta(seconds=scan_interval + 1) - with mock.patch("homeassistant.helpers.event.dt_util.utcnow", return_value=now): - async_fire_time_changed(hass, now) - await hass.async_block_till_done() - - # Check state - entity_id = f"{entity_domain}.{sensor_name}" - state = hass.states.get(entity_id).state - assert state == expected diff --git a/tests/components/modbus/async/test_modbus_sensor.py b/tests/components/modbus/async/test_modbus_sensor.py deleted file mode 100644 index 6207a36393722d..00000000000000 --- a/tests/components/modbus/async/test_modbus_sensor.py +++ /dev/null @@ -1,359 +0,0 @@ -"""The tests for the Modbus sensor component.""" -import logging - -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_TYPE, - CONF_REVERSE_ORDER, - CONF_SCALE, - DATA_TYPE_FLOAT, - DATA_TYPE_INT, - DATA_TYPE_UINT, -) -from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN - -from .conftest import run_test - -_LOGGER = logging.getLogger(__name__) - - -async def test_simple_word_register(hass, mock_hub): - """Test conversion of single word register.""" - register_config = { - CONF_COUNT: 1, - CONF_DATA_TYPE: DATA_TYPE_INT, - CONF_SCALE: 1, - CONF_OFFSET: 0, - CONF_PRECISION: 0, - } - await run_test( - hass, - mock_hub, - register_config, - SENSOR_DOMAIN, - register_words=[0], - expected="0", - ) - - -async def test_optional_conf_keys(hass, mock_hub): - """Test handling of optional configuration keys.""" - register_config = {} - await run_test( - hass, - mock_hub, - register_config, - SENSOR_DOMAIN, - register_words=[0x8000], - expected="-32768", - ) - - -async def test_offset(hass, mock_hub): - """Test offset calculation.""" - register_config = { - CONF_COUNT: 1, - CONF_DATA_TYPE: DATA_TYPE_INT, - CONF_SCALE: 1, - CONF_OFFSET: 13, - CONF_PRECISION: 0, - } - await run_test( - hass, - mock_hub, - register_config, - SENSOR_DOMAIN, - register_words=[7], - expected="20", - ) - - -async def test_scale_and_offset(hass, mock_hub): - """Test handling of scale and offset.""" - register_config = { - CONF_COUNT: 1, - CONF_DATA_TYPE: DATA_TYPE_INT, - CONF_SCALE: 3, - CONF_OFFSET: 13, - CONF_PRECISION: 0, - } - await run_test( - hass, - mock_hub, - register_config, - SENSOR_DOMAIN, - register_words=[7], - expected="34", - ) - - -async def test_ints_can_have_precision(hass, mock_hub): - """Test precision can be specified event if using integer values only.""" - register_config = { - CONF_COUNT: 1, - CONF_DATA_TYPE: DATA_TYPE_UINT, - CONF_SCALE: 3, - CONF_OFFSET: 13, - CONF_PRECISION: 4, - } - await run_test( - hass, - mock_hub, - register_config, - SENSOR_DOMAIN, - register_words=[7], - expected="34.0000", - ) - - -async def test_floats_get_rounded_correctly(hass, mock_hub): - """Test that floating point values get rounded correctly.""" - register_config = { - CONF_COUNT: 1, - CONF_DATA_TYPE: DATA_TYPE_INT, - CONF_SCALE: 1.5, - CONF_OFFSET: 0, - CONF_PRECISION: 0, - } - await run_test( - hass, - mock_hub, - register_config, - SENSOR_DOMAIN, - register_words=[1], - expected="2", - ) - - -async def test_parameters_as_strings(hass, mock_hub): - """Test that scale, offset and precision can be given as strings.""" - register_config = { - CONF_COUNT: 1, - CONF_DATA_TYPE: DATA_TYPE_INT, - CONF_SCALE: "1.5", - CONF_OFFSET: "5", - CONF_PRECISION: "1", - } - await run_test( - hass, - mock_hub, - register_config, - SENSOR_DOMAIN, - register_words=[9], - expected="18.5", - ) - - -async def test_floating_point_scale(hass, mock_hub): - """Test use of floating point scale.""" - register_config = { - CONF_COUNT: 1, - CONF_DATA_TYPE: DATA_TYPE_INT, - CONF_SCALE: 2.4, - CONF_OFFSET: 0, - CONF_PRECISION: 2, - } - await run_test( - hass, - mock_hub, - register_config, - SENSOR_DOMAIN, - register_words=[1], - expected="2.40", - ) - - -async def test_floating_point_offset(hass, mock_hub): - """Test use of floating point scale.""" - register_config = { - CONF_COUNT: 1, - CONF_DATA_TYPE: DATA_TYPE_INT, - CONF_SCALE: 1, - CONF_OFFSET: -10.3, - CONF_PRECISION: 1, - } - await run_test( - hass, - mock_hub, - register_config, - SENSOR_DOMAIN, - register_words=[2], - expected="-8.3", - ) - - -async def test_signed_two_word_register(hass, mock_hub): - """Test reading of signed register with two words.""" - register_config = { - CONF_COUNT: 2, - CONF_DATA_TYPE: DATA_TYPE_INT, - CONF_SCALE: 1, - CONF_OFFSET: 0, - CONF_PRECISION: 0, - } - await run_test( - hass, - mock_hub, - register_config, - SENSOR_DOMAIN, - register_words=[0x89AB, 0xCDEF], - expected="-1985229329", - ) - - -async def test_unsigned_two_word_register(hass, mock_hub): - """Test reading of unsigned register with two words.""" - register_config = { - CONF_COUNT: 2, - CONF_DATA_TYPE: DATA_TYPE_UINT, - CONF_SCALE: 1, - CONF_OFFSET: 0, - CONF_PRECISION: 0, - } - await run_test( - hass, - mock_hub, - register_config, - SENSOR_DOMAIN, - register_words=[0x89AB, 0xCDEF], - expected=str(0x89ABCDEF), - ) - - -async def test_reversed(hass, mock_hub): - """Test handling of reversed register words.""" - register_config = { - CONF_COUNT: 2, - CONF_DATA_TYPE: DATA_TYPE_UINT, - CONF_REVERSE_ORDER: True, - } - await run_test( - hass, - mock_hub, - register_config, - SENSOR_DOMAIN, - register_words=[0x89AB, 0xCDEF], - expected=str(0xCDEF89AB), - ) - - -async def test_four_word_register(hass, mock_hub): - """Test reading of 64-bit register.""" - register_config = { - CONF_COUNT: 4, - CONF_DATA_TYPE: DATA_TYPE_UINT, - CONF_SCALE: 1, - CONF_OFFSET: 0, - CONF_PRECISION: 0, - } - await run_test( - hass, - mock_hub, - register_config, - SENSOR_DOMAIN, - register_words=[0x89AB, 0xCDEF, 0x0123, 0x4567], - expected="9920249030613615975", - ) - - -async def test_four_word_register_precision_is_intact_with_int_params(hass, mock_hub): - """Test that precision is not lost when doing integer arithmetic for 64-bit register.""" - register_config = { - CONF_COUNT: 4, - CONF_DATA_TYPE: DATA_TYPE_UINT, - CONF_SCALE: 2, - CONF_OFFSET: 3, - CONF_PRECISION: 0, - } - await run_test( - hass, - mock_hub, - register_config, - SENSOR_DOMAIN, - register_words=[0x0123, 0x4567, 0x89AB, 0xCDEF], - expected="163971058432973793", - ) - - -async def test_four_word_register_precision_is_lost_with_float_params(hass, mock_hub): - """Test that precision is affected when floating point conversion is done.""" - register_config = { - CONF_COUNT: 4, - CONF_DATA_TYPE: DATA_TYPE_UINT, - CONF_SCALE: 2.0, - CONF_OFFSET: 3.0, - CONF_PRECISION: 0, - } - await run_test( - hass, - mock_hub, - register_config, - SENSOR_DOMAIN, - register_words=[0x0123, 0x4567, 0x89AB, 0xCDEF], - expected="163971058432973792", - ) - - -async def test_two_word_input_register(hass, mock_hub): - """Test reaging of input register.""" - register_config = { - CONF_COUNT: 2, - CONF_REGISTER_TYPE: CALL_TYPE_REGISTER_INPUT, - CONF_DATA_TYPE: DATA_TYPE_UINT, - CONF_SCALE: 1, - CONF_OFFSET: 0, - CONF_PRECISION: 0, - } - await run_test( - hass, - mock_hub, - register_config, - SENSOR_DOMAIN, - register_words=[0x89AB, 0xCDEF], - expected=str(0x89ABCDEF), - ) - - -async def test_two_word_holding_register(hass, mock_hub): - """Test reaging of holding register.""" - register_config = { - CONF_COUNT: 2, - CONF_REGISTER_TYPE: CALL_TYPE_REGISTER_HOLDING, - CONF_DATA_TYPE: DATA_TYPE_UINT, - CONF_SCALE: 1, - CONF_OFFSET: 0, - CONF_PRECISION: 0, - } - await run_test( - hass, - mock_hub, - register_config, - SENSOR_DOMAIN, - register_words=[0x89AB, 0xCDEF], - expected=str(0x89ABCDEF), - ) - - -async def test_float_data_type(hass, mock_hub): - """Test floating point register data type.""" - register_config = { - CONF_COUNT: 2, - CONF_REGISTER_TYPE: CALL_TYPE_REGISTER_HOLDING, - CONF_DATA_TYPE: DATA_TYPE_FLOAT, - CONF_SCALE: 1, - CONF_OFFSET: 0, - CONF_PRECISION: 5, - } - await run_test( - hass, - mock_hub, - register_config, - SENSOR_DOMAIN, - register_words=[16286, 1617], - expected="1.23457", - )