Modbus patch, to allow communication with "slow" equipment using tcp (#32557)

* modbus: bumb pymodbus version to 2.3.0

pymodbus version 1.5.2 did not support asyncio, and in general
the async handling have been improved a lot in version 2.3.0.

updated core/requirement*txt

* updated core/CODEOWNERS

committing result of 'python3 -m script.hassfest'.

* modbus: change core connection to async

change setup() --> async_setup and update() --> async_update()

Use async_setup_platform() to complete the async connection to core.

listen for EVENT_HOMEASSISTANT_START happens in async_setup()
so it needs to be async_listen.

But listen for EVENT_HOMEASSISTANT_STOP happens in start_modbus()
which is a sync. function so it continues to be listen().

* modbus: move setup of pymodbus into modbushub

setup of pymodbus is logically connected to the class modbushub,
therefore move it into the class.

Delay construction of pymodbus client until event
EVENT_HOMEASSISTANT_START arrives.

* modbus: use pymodbus async library

convert pymodbus calls to refer to the async library.

Remark: connect() is no longer needed, it is done when constructing
the client. There are also automatic reconnect.

* modbus: use async update for read/write

Use async functions for read/write from pymodbus.

change thread.Lock() to asyncio.Lock()

* Modbus: patch for slow tcp equipment

When connecting, via Modbus-TCP, so some equipment (like the
huawei sun2000 inverter), they need time to prepare the protocol.

Solution is to add a asyncio.sleep(x) after the connect() and before
sending the first message.

Add optional parameter "delay" to Modbus configuration.
Default is 0, which means do not execute asyncio.sleep().

* Modbus: silence pylint false positive

pylint does not accept that a class construction __new__
can return a tuple.

* Modbus: move constants to const.py

Create const.py with constants only used in
the modbus integration.

Duplicate entries are removed, but NOT any entry that would
lead to a configuration change.

Some entries were the same but with different names, in this
case renaming is done.

Also correct the tests.

* Modbus: move connection error handling to ModbusHub

Connection error handling depends on the hub, not the
entity, therefore it is logical to have the handling in
ModbusHub.

All pymodbus call are added to 2 generic functions (read/write)
in order not to duplicate the error handling code.

Added property "available" to signal if the hub is connected.

* Modbus: CI cleanup

Solve CI problems.

* Modbus: remove close of client

close() no longer exist in the pymodbus library, use
del client instead.

* Modbus: correct review comments

Adjust code based on review comments.

* Modbus: remove twister dependency

Pymodbus in asyncio mode do not use twister but still throws a
warning if twister is not installed, this warning goes into
homeassistant.log and can thus cause confusion among users.

However installing twister just to avoid the warning is not
the best solution, therefore removing dependency on twister.

* Modbus: review, remove comments.

remove commented out code.
This commit is contained in:
jan iversen 2020-03-29 19:39:30 +02:00 committed by GitHub
parent 188ca630de
commit dd3cd95954
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 435 additions and 392 deletions

View File

@ -231,7 +231,7 @@ homeassistant/components/min_max/* @fabaff
homeassistant/components/minecraft_server/* @elmurato
homeassistant/components/minio/* @tkislan
homeassistant/components/mobile_app/* @robbiet480
homeassistant/components/modbus/* @adamchengtkc
homeassistant/components/modbus/* @adamchengtkc @janiversen
homeassistant/components/monoprice/* @etsinko
homeassistant/components/moon/* @fabaff
homeassistant/components/mpd/* @fabaff

View File

@ -1,13 +1,19 @@
"""Support for Modbus."""
import asyncio
import logging
import threading
from pymodbus.client.sync import ModbusSerialClient, ModbusTcpClient, ModbusUdpClient
from pymodbus.client.asynchronous import schedulers
from pymodbus.client.asynchronous.serial import AsyncModbusSerialClient as ClientSerial
from pymodbus.client.asynchronous.tcp import AsyncModbusTCPClient as ClientTCP
from pymodbus.client.asynchronous.udp import AsyncModbusUDPClient as ClientUDP
from pymodbus.exceptions import ModbusException
from pymodbus.pdu import ExceptionResponse
from pymodbus.transaction import ModbusRtuFramer
import voluptuous as vol
from homeassistant.const import (
ATTR_STATE,
CONF_DELAY,
CONF_HOST,
CONF_METHOD,
CONF_NAME,
@ -19,24 +25,26 @@ from homeassistant.const import (
)
import homeassistant.helpers.config_validation as cv
_LOGGER = logging.getLogger(__name__)
from .const import ( # DEFAULT_HUB,
ATTR_ADDRESS,
ATTR_HUB,
ATTR_UNIT,
ATTR_VALUE,
CONF_BAUDRATE,
CONF_BYTESIZE,
CONF_PARITY,
CONF_STOPBITS,
MODBUS_DOMAIN,
SERVICE_WRITE_COIL,
SERVICE_WRITE_REGISTER,
)
ATTR_ADDRESS = "address"
ATTR_HUB = "hub"
ATTR_UNIT = "unit"
ATTR_VALUE = "value"
CONF_BAUDRATE = "baudrate"
CONF_BYTESIZE = "bytesize"
# Kept for compatibility with other integrations, TO BE REMOVED
CONF_HUB = "hub"
CONF_PARITY = "parity"
CONF_STOPBITS = "stopbits"
DEFAULT_HUB = "default"
DOMAIN = "modbus"
DOMAIN = MODBUS_DOMAIN
SERVICE_WRITE_COIL = "write_coil"
SERVICE_WRITE_REGISTER = "write_register"
_LOGGER = logging.getLogger(__name__)
BASE_SCHEMA = vol.Schema({vol.Optional(CONF_NAME, default=DEFAULT_HUB): cv.string})
@ -59,11 +67,12 @@ ETHERNET_SCHEMA = BASE_SCHEMA.extend(
vol.Required(CONF_PORT): cv.port,
vol.Required(CONF_TYPE): vol.Any("tcp", "udp", "rtuovertcp"),
vol.Optional(CONF_TIMEOUT, default=3): cv.socket_timeout,
vol.Optional(CONF_DELAY, default=0): cv.positive_int,
}
)
CONFIG_SCHEMA = vol.Schema(
{DOMAIN: vol.All(cv.ensure_list, [vol.Any(SERIAL_SCHEMA, ETHERNET_SCHEMA)])},
{MODBUS_DOMAIN: vol.All(cv.ensure_list, [vol.Any(SERIAL_SCHEMA, ETHERNET_SCHEMA)])},
extra=vol.ALLOW_EXTRA,
)
@ -88,97 +97,65 @@ SERVICE_WRITE_COIL_SCHEMA = vol.Schema(
)
def setup_client(client_config):
"""Set up pymodbus client."""
client_type = client_config[CONF_TYPE]
if client_type == "serial":
return ModbusSerialClient(
method=client_config[CONF_METHOD],
port=client_config[CONF_PORT],
baudrate=client_config[CONF_BAUDRATE],
stopbits=client_config[CONF_STOPBITS],
bytesize=client_config[CONF_BYTESIZE],
parity=client_config[CONF_PARITY],
timeout=client_config[CONF_TIMEOUT],
)
if client_type == "rtuovertcp":
return ModbusTcpClient(
host=client_config[CONF_HOST],
port=client_config[CONF_PORT],
framer=ModbusRtuFramer,
timeout=client_config[CONF_TIMEOUT],
)
if client_type == "tcp":
return ModbusTcpClient(
host=client_config[CONF_HOST],
port=client_config[CONF_PORT],
timeout=client_config[CONF_TIMEOUT],
)
if client_type == "udp":
return ModbusUdpClient(
host=client_config[CONF_HOST],
port=client_config[CONF_PORT],
timeout=client_config[CONF_TIMEOUT],
)
assert False
def setup(hass, config):
async def async_setup(hass, config):
"""Set up Modbus component."""
hass.data[DOMAIN] = hub_collect = {}
hass.data[MODBUS_DOMAIN] = hub_collect = {}
for client_config in config[DOMAIN]:
client = setup_client(client_config)
name = client_config[CONF_NAME]
hub_collect[name] = ModbusHub(client, name)
_LOGGER.debug("Setting up hub: %s", client_config)
_LOGGER.debug("registering hubs")
for client_config in config[MODBUS_DOMAIN]:
hub_collect[client_config[CONF_NAME]] = ModbusHub(client_config, hass.loop)
def stop_modbus(event):
"""Stop Modbus service."""
for client in hub_collect.values():
client.close()
del client
def start_modbus(event):
"""Start Modbus service."""
for client in hub_collect.values():
client.connect()
_LOGGER.debug("setup hub %s", client.name)
client.setup()
hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, stop_modbus)
# Register services for modbus
hass.services.register(
DOMAIN,
hass.services.async_register(
MODBUS_DOMAIN,
SERVICE_WRITE_REGISTER,
write_register,
schema=SERVICE_WRITE_REGISTER_SCHEMA,
)
hass.services.register(
DOMAIN, SERVICE_WRITE_COIL, write_coil, schema=SERVICE_WRITE_COIL_SCHEMA
hass.services.async_register(
MODBUS_DOMAIN,
SERVICE_WRITE_COIL,
write_coil,
schema=SERVICE_WRITE_COIL_SCHEMA,
)
def write_register(service):
async def write_register(service):
"""Write Modbus registers."""
unit = int(float(service.data[ATTR_UNIT]))
address = int(float(service.data[ATTR_ADDRESS]))
value = service.data[ATTR_VALUE]
client_name = service.data[ATTR_HUB]
if isinstance(value, list):
hub_collect[client_name].write_registers(
await hub_collect[client_name].write_registers(
unit, address, [int(float(i)) for i in value]
)
else:
hub_collect[client_name].write_register(unit, address, int(float(value)))
await hub_collect[client_name].write_register(
unit, address, int(float(value))
)
def write_coil(service):
async def write_coil(service):
"""Write Modbus coil."""
unit = service.data[ATTR_UNIT]
address = service.data[ATTR_ADDRESS]
state = service.data[ATTR_STATE]
client_name = service.data[ATTR_HUB]
hub_collect[client_name].write_coil(unit, address, state)
await hub_collect[client_name].write_coil(unit, address, state)
hass.bus.listen_once(EVENT_HOMEASSISTANT_START, start_modbus)
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_START, start_modbus)
return True
@ -186,65 +163,153 @@ def setup(hass, config):
class ModbusHub:
"""Thread safe wrapper class for pymodbus."""
def __init__(self, modbus_client, name):
def __init__(self, client_config, main_loop):
"""Initialize the Modbus hub."""
self._client = modbus_client
self._lock = threading.Lock()
self._name = name
_LOGGER.debug("Preparing setup: %s", client_config)
# generic configuration
self._loop = main_loop
self._client = None
self._lock = asyncio.Lock()
self._config_name = client_config[CONF_NAME]
self._config_type = client_config[CONF_TYPE]
self._config_port = client_config[CONF_PORT]
self._config_timeout = client_config[CONF_TIMEOUT]
self._config_delay = client_config[CONF_DELAY]
if self._config_type == "serial":
# serial configuration
self._config_method = client_config[CONF_METHOD]
self._config_baudrate = client_config[CONF_BAUDRATE]
self._config_stopbits = client_config[CONF_STOPBITS]
self._config_bytesize = client_config[CONF_BYTESIZE]
self._config_parity = client_config[CONF_PARITY]
else:
# network configuration
self._config_host = client_config[CONF_HOST]
@property
def name(self):
"""Return the name of this hub."""
return self._name
return self._config_name
def close(self):
"""Disconnect client."""
with self._lock:
self._client.close()
async def _connect_delay(self):
if self._config_delay > 0:
await asyncio.sleep(self._config_delay)
self._config_delay = 0
def connect(self):
"""Connect client."""
with self._lock:
self._client.connect()
def setup(self):
"""Set up pymodbus client."""
# pylint: disable = E0633
# Client* do deliver loop, client as result but
# pylint does not accept that fact
def read_coils(self, unit, address, count):
_LOGGER.debug("doing setup")
if self._config_type == "serial":
_, self._client = ClientSerial(
schedulers.ASYNC_IO,
method=self._config_method,
port=self._config_port,
baudrate=self._config_baudrate,
stopbits=self._config_stopbits,
bytesize=self._config_bytesize,
parity=self._config_parity,
timeout=self._config_timeout,
loop=self._loop,
)
elif self._config_type == "rtuovertcp":
_, self._client = ClientTCP(
schedulers.ASYNC_IO,
host=self._config_host,
port=self._config_port,
framer=ModbusRtuFramer,
timeout=self._config_timeout,
loop=self._loop,
)
elif self._config_type == "tcp":
_, self._client = ClientTCP(
schedulers.ASYNC_IO,
host=self._config_host,
port=self._config_port,
timeout=self._config_timeout,
loop=self._loop,
)
elif self._config_type == "udp":
_, self._client = ClientUDP(
schedulers.ASYNC_IO,
host=self._config_host,
port=self._config_port,
timeout=self._config_timeout,
loop=self._loop,
)
else:
assert False
async def _read(self, unit, address, count, func):
"""Read generic with error handling."""
await self._connect_delay()
async with self._lock:
kwargs = {"unit": unit} if unit else {}
result = await func(address, count, **kwargs)
if isinstance(result, (ModbusException, ExceptionResponse)):
_LOGGER.error("Hub %s Exception (%s)", self._config_name, result)
return result
async def _write(self, unit, address, value, func):
"""Read generic with error handling."""
await self._connect_delay()
async with self._lock:
kwargs = {"unit": unit} if unit else {}
await func(address, value, **kwargs)
async def read_coils(self, unit, address, count):
"""Read coils."""
with self._lock:
kwargs = {"unit": unit} if unit else {}
return self._client.read_coils(address, count, **kwargs)
if self._client.protocol is None:
return None
return await self._read(unit, address, count, self._client.protocol.read_coils)
def read_discrete_inputs(self, unit, address, count):
async def read_discrete_inputs(self, unit, address, count):
"""Read discrete inputs."""
with self._lock:
kwargs = {"unit": unit} if unit else {}
return self._client.read_discrete_inputs(address, count, **kwargs)
if self._client.protocol is None:
return None
return await self._read(
unit, address, count, self._client.protocol.read_discrete_inputs
)
def read_input_registers(self, unit, address, count):
async def read_input_registers(self, unit, address, count):
"""Read input registers."""
with self._lock:
kwargs = {"unit": unit} if unit else {}
return self._client.read_input_registers(address, count, **kwargs)
if self._client.protocol is None:
return None
return await self._read(
unit, address, count, self._client.protocol.read_input_registers
)
def read_holding_registers(self, unit, address, count):
async def read_holding_registers(self, unit, address, count):
"""Read holding registers."""
with self._lock:
kwargs = {"unit": unit} if unit else {}
return self._client.read_holding_registers(address, count, **kwargs)
if self._client.protocol is None:
return None
return await self._read(
unit, address, count, self._client.protocol.read_holding_registers
)
def write_coil(self, unit, address, value):
async def write_coil(self, unit, address, value):
"""Write coil."""
with self._lock:
kwargs = {"unit": unit} if unit else {}
self._client.write_coil(address, value, **kwargs)
if self._client.protocol is None:
return None
return await self._write(unit, address, value, self._client.protocol.write_coil)
def write_register(self, unit, address, value):
async def write_register(self, unit, address, value):
"""Write register."""
with self._lock:
kwargs = {"unit": unit} if unit else {}
self._client.write_register(address, value, **kwargs)
if self._client.protocol is None:
return None
return await self._write(
unit, address, value, self._client.protocol.write_register
)
def write_registers(self, unit, address, values):
async def write_registers(self, unit, address, values):
"""Write registers."""
with self._lock:
kwargs = {"unit": unit} if unit else {}
self._client.write_registers(address, values, **kwargs)
if self._client.protocol is None:
return None
return await self._write(
unit, address, values, self._client.protocol.write_registers
)

View File

@ -2,7 +2,7 @@
import logging
from typing import Optional
from pymodbus.exceptions import ConnectionException, ModbusException
from pymodbus.exceptions import ModbusException
from pymodbus.pdu import ExceptionResponse
import voluptuous as vol
@ -14,27 +14,27 @@ from homeassistant.components.binary_sensor import (
from homeassistant.const import CONF_DEVICE_CLASS, CONF_NAME, CONF_SLAVE
from homeassistant.helpers import config_validation as cv
from . import CONF_HUB, DEFAULT_HUB, DOMAIN as MODBUS_DOMAIN
from .const import (
CALL_TYPE_COIL,
CALL_TYPE_DISCRETE,
CONF_ADDRESS,
CONF_COILS,
CONF_HUB,
CONF_INPUT_TYPE,
CONF_INPUTS,
DEFAULT_HUB,
MODBUS_DOMAIN,
)
_LOGGER = logging.getLogger(__name__)
CONF_DEPRECATED_COIL = "coil"
CONF_DEPRECATED_COILS = "coils"
CONF_INPUTS = "inputs"
CONF_INPUT_TYPE = "input_type"
CONF_ADDRESS = "address"
DEFAULT_INPUT_TYPE_COIL = "coil"
DEFAULT_INPUT_TYPE_DISCRETE = "discrete_input"
PLATFORM_SCHEMA = vol.All(
cv.deprecated(CONF_DEPRECATED_COILS, CONF_INPUTS),
cv.deprecated(CONF_COILS, CONF_INPUTS),
PLATFORM_SCHEMA.extend(
{
vol.Required(CONF_INPUTS): [
vol.All(
cv.deprecated(CONF_DEPRECATED_COIL, CONF_ADDRESS),
cv.deprecated(CALL_TYPE_COIL, CONF_ADDRESS),
vol.Schema(
{
vol.Required(CONF_ADDRESS): cv.positive_int,
@ -43,10 +43,8 @@ PLATFORM_SCHEMA = vol.All(
vol.Optional(CONF_HUB, default=DEFAULT_HUB): cv.string,
vol.Optional(CONF_SLAVE): cv.positive_int,
vol.Optional(
CONF_INPUT_TYPE, default=DEFAULT_INPUT_TYPE_COIL
): vol.In(
[DEFAULT_INPUT_TYPE_COIL, DEFAULT_INPUT_TYPE_DISCRETE]
),
CONF_INPUT_TYPE, default=CALL_TYPE_COIL
): vol.In([CALL_TYPE_COIL, CALL_TYPE_DISCRETE]),
}
),
)
@ -56,7 +54,7 @@ PLATFORM_SCHEMA = vol.All(
)
def setup_platform(hass, config, add_entities, discovery_info=None):
async def async_setup_platform(hass, config, add_entities, discovery_info=None):
"""Set up the Modbus binary sensors."""
sensors = []
for entry in config[CONF_INPUTS]:
@ -109,33 +107,18 @@ class ModbusBinarySensor(BinarySensorDevice):
"""Return True if entity is available."""
return self._available
def update(self):
async def async_update(self):
"""Update the state of the sensor."""
try:
if self._input_type == DEFAULT_INPUT_TYPE_COIL:
result = self._hub.read_coils(self._slave, self._address, 1)
else:
result = self._hub.read_discrete_inputs(self._slave, self._address, 1)
except ConnectionException:
self._set_unavailable()
if self._input_type == CALL_TYPE_COIL:
result = await self._hub.read_coils(self._slave, self._address, 1)
else:
result = await self._hub.read_discrete_inputs(self._slave, self._address, 1)
if result is None:
self._available = False
return
if isinstance(result, (ModbusException, ExceptionResponse)):
self._set_unavailable()
self._available = False
return
self._value = result.bits[0]
self._available = True
def _set_unavailable(self):
"""Set unavailable state and log it as an error."""
if not self._available:
return
_LOGGER.error(
"No response from hub %s, slave %s, address %s",
self._hub.name,
self._slave,
self._address,
)
self._available = False

View File

@ -3,7 +3,7 @@ import logging
import struct
from typing import Optional
from pymodbus.exceptions import ConnectionException, ModbusException
from pymodbus.exceptions import ModbusException
from pymodbus.pdu import ExceptionResponse
import voluptuous as vol
@ -21,30 +21,31 @@ from homeassistant.const import (
)
import homeassistant.helpers.config_validation as cv
from . import CONF_HUB, DEFAULT_HUB, DOMAIN as MODBUS_DOMAIN
from .const import (
CALL_TYPE_REGISTER_HOLDING,
CALL_TYPE_REGISTER_INPUT,
CONF_CURRENT_TEMP,
CONF_CURRENT_TEMP_REGISTER_TYPE,
CONF_DATA_COUNT,
CONF_DATA_TYPE,
CONF_HUB,
CONF_MAX_TEMP,
CONF_MIN_TEMP,
CONF_OFFSET,
CONF_PRECISION,
CONF_SCALE,
CONF_STEP,
CONF_TARGET_TEMP,
CONF_UNIT,
DATA_TYPE_FLOAT,
DATA_TYPE_INT,
DATA_TYPE_UINT,
DEFAULT_HUB,
MODBUS_DOMAIN,
)
_LOGGER = logging.getLogger(__name__)
CONF_TARGET_TEMP = "target_temp_register"
CONF_CURRENT_TEMP = "current_temp_register"
CONF_CURRENT_TEMP_REGISTER_TYPE = "current_temp_register_type"
CONF_DATA_TYPE = "data_type"
CONF_COUNT = "data_count"
CONF_PRECISION = "precision"
CONF_SCALE = "scale"
CONF_OFFSET = "offset"
CONF_UNIT = "temperature_unit"
DATA_TYPE_INT = "int"
DATA_TYPE_UINT = "uint"
DATA_TYPE_FLOAT = "float"
CONF_MAX_TEMP = "max_temp"
CONF_MIN_TEMP = "min_temp"
CONF_STEP = "temp_step"
SUPPORT_FLAGS = SUPPORT_TARGET_TEMPERATURE
HVAC_MODES = [HVAC_MODE_AUTO]
DEFAULT_REGISTER_TYPE_HOLDING = "holding"
DEFAULT_REGISTER_TYPE_INPUT = "input"
PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend(
{
@ -52,10 +53,10 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend(
vol.Required(CONF_NAME): cv.string,
vol.Required(CONF_SLAVE): cv.positive_int,
vol.Required(CONF_TARGET_TEMP): cv.positive_int,
vol.Optional(CONF_COUNT, default=2): cv.positive_int,
vol.Optional(CONF_DATA_COUNT, default=2): cv.positive_int,
vol.Optional(
CONF_CURRENT_TEMP_REGISTER_TYPE, default=DEFAULT_REGISTER_TYPE_HOLDING
): vol.In([DEFAULT_REGISTER_TYPE_HOLDING, DEFAULT_REGISTER_TYPE_INPUT]),
CONF_CURRENT_TEMP_REGISTER_TYPE, default=CALL_TYPE_REGISTER_HOLDING
): vol.In([CALL_TYPE_REGISTER_HOLDING, CALL_TYPE_REGISTER_INPUT]),
vol.Optional(CONF_DATA_TYPE, default=DATA_TYPE_FLOAT): vol.In(
[DATA_TYPE_INT, DATA_TYPE_UINT, DATA_TYPE_FLOAT]
),
@ -71,7 +72,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend(
)
def setup_platform(hass, config, add_entities, discovery_info=None):
async def async_setup_platform(hass, config, add_entities, discovery_info=None):
"""Set up the Modbus Thermostat Platform."""
name = config[CONF_NAME]
modbus_slave = config[CONF_SLAVE]
@ -79,7 +80,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None):
current_temp_register = config[CONF_CURRENT_TEMP]
current_temp_register_type = config[CONF_CURRENT_TEMP_REGISTER_TYPE]
data_type = config[CONF_DATA_TYPE]
count = config[CONF_COUNT]
count = config[CONF_DATA_COUNT]
precision = config[CONF_PRECISION]
scale = config[CONF_SCALE]
offset = config[CONF_OFFSET]
@ -167,14 +168,14 @@ class ModbusThermostat(ClimateDevice):
@property
def supported_features(self):
"""Return the list of supported features."""
return SUPPORT_FLAGS
return SUPPORT_TARGET_TEMPERATURE
def update(self):
async def async_update(self):
"""Update Target & Current Temperature."""
self._target_temperature = self._read_register(
DEFAULT_REGISTER_TYPE_HOLDING, self._target_temperature_register
self._target_temperature = await self._read_register(
CALL_TYPE_REGISTER_HOLDING, self._target_temperature_register
)
self._current_temperature = self._read_register(
self._current_temperature = await self._read_register(
self._current_temperature_register_type, self._current_temperature_register
)
@ -186,7 +187,7 @@ class ModbusThermostat(ClimateDevice):
@property
def hvac_modes(self):
"""Return the possible HVAC modes."""
return HVAC_MODES
return [HVAC_MODE_AUTO]
@property
def name(self):
@ -223,7 +224,7 @@ class ModbusThermostat(ClimateDevice):
"""Return the supported step of target temperature."""
return self._temp_step
def set_temperature(self, **kwargs):
async def set_temperature(self, **kwargs):
"""Set new target temperature."""
target_temperature = int(
(kwargs.get(ATTR_TEMPERATURE) - self._offset) / self._scale
@ -232,30 +233,28 @@ class ModbusThermostat(ClimateDevice):
return
byte_string = struct.pack(self._structure, target_temperature)
register_value = struct.unpack(">h", byte_string[0:2])[0]
self._write_register(self._target_temperature_register, register_value)
await self._write_register(self._target_temperature_register, register_value)
@property
def available(self) -> bool:
"""Return True if entity is available."""
return self._available
def _read_register(self, register_type, register) -> Optional[float]:
async def _read_register(self, register_type, register) -> Optional[float]:
"""Read register using the Modbus hub slave."""
try:
if register_type == DEFAULT_REGISTER_TYPE_INPUT:
result = self._hub.read_input_registers(
self._slave, register, self._count
)
else:
result = self._hub.read_holding_registers(
self._slave, register, self._count
)
except ConnectionException:
self._set_unavailable(register)
if register_type == CALL_TYPE_REGISTER_INPUT:
result = await self._hub.read_input_registers(
self._slave, register, self._count
)
else:
result = await self._hub.read_holding_registers(
self._slave, register, self._count
)
if result is None:
self._available = False
return
if isinstance(result, (ModbusException, ExceptionResponse)):
self._set_unavailable(register)
self._available = False
return
byte_string = b"".join(
@ -270,25 +269,7 @@ class ModbusThermostat(ClimateDevice):
return register_value
def _write_register(self, register, value):
async def _write_register(self, register, value):
"""Write holding register using the Modbus hub slave."""
try:
self._hub.write_registers(self._slave, register, [value, 0])
except ConnectionException:
self._set_unavailable(register)
return
await self._hub.write_registers(self._slave, register, [value, 0])
self._available = True
def _set_unavailable(self, register):
"""Set unavailable state and log it as an error."""
if not self._available:
return
_LOGGER.error(
"No response from hub %s, slave %s, register %s",
self._hub.name,
self._slave,
register,
)
self._available = False

View File

@ -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"

View File

@ -2,7 +2,7 @@
"domain": "modbus",
"name": "Modbus",
"documentation": "https://www.home-assistant.io/integrations/modbus",
"requirements": ["pymodbus==1.5.2"],
"requirements": ["pymodbus==2.3.0"],
"dependencies": [],
"codeowners": ["@adamchengtkc"]
"codeowners": ["@adamchengtkc", "@janiversen"]
}

View File

@ -3,7 +3,7 @@ import logging
import struct
from typing import Any, Optional, Union
from pymodbus.exceptions import ConnectionException, ModbusException
from pymodbus.exceptions import ModbusException
from pymodbus.pdu import ExceptionResponse
import voluptuous as vol
@ -19,27 +19,28 @@ from homeassistant.const import (
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.restore_state import RestoreEntity
from . import CONF_HUB, DEFAULT_HUB, DOMAIN as MODBUS_DOMAIN
from .const import (
CALL_TYPE_REGISTER_HOLDING,
CALL_TYPE_REGISTER_INPUT,
CONF_COUNT,
CONF_DATA_TYPE,
CONF_HUB,
CONF_PRECISION,
CONF_REGISTER,
CONF_REGISTER_TYPE,
CONF_REGISTERS,
CONF_REVERSE_ORDER,
CONF_SCALE,
DATA_TYPE_CUSTOM,
DATA_TYPE_FLOAT,
DATA_TYPE_INT,
DATA_TYPE_UINT,
DEFAULT_HUB,
MODBUS_DOMAIN,
)
_LOGGER = logging.getLogger(__name__)
CONF_COUNT = "count"
CONF_DATA_TYPE = "data_type"
CONF_PRECISION = "precision"
CONF_REGISTER = "register"
CONF_REGISTER_TYPE = "register_type"
CONF_REGISTERS = "registers"
CONF_REVERSE_ORDER = "reverse_order"
CONF_SCALE = "scale"
DATA_TYPE_CUSTOM = "custom"
DATA_TYPE_FLOAT = "float"
DATA_TYPE_INT = "int"
DATA_TYPE_UINT = "uint"
DEFAULT_REGISTER_TYPE_HOLDING = "holding"
DEFAULT_REGISTER_TYPE_INPUT = "input"
def number(value: Any) -> Union[int, float]:
"""Coerce a value to number without losing precision."""
@ -75,8 +76,8 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend(
vol.Optional(CONF_OFFSET, default=0): number,
vol.Optional(CONF_PRECISION, default=0): cv.positive_int,
vol.Optional(
CONF_REGISTER_TYPE, default=DEFAULT_REGISTER_TYPE_HOLDING
): vol.In([DEFAULT_REGISTER_TYPE_HOLDING, DEFAULT_REGISTER_TYPE_INPUT]),
CONF_REGISTER_TYPE, default=CALL_TYPE_REGISTER_HOLDING
): vol.In([CALL_TYPE_REGISTER_HOLDING, CALL_TYPE_REGISTER_INPUT]),
vol.Optional(CONF_REVERSE_ORDER, default=False): cv.boolean,
vol.Optional(CONF_SCALE, default=1): number,
vol.Optional(CONF_SLAVE): cv.positive_int,
@ -88,7 +89,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend(
)
def setup_platform(hass, config, add_entities, discovery_info=None):
async def async_setup_platform(hass, config, add_entities, discovery_info=None):
"""Set up the Modbus sensors."""
sensors = []
data_types = {DATA_TYPE_INT: {1: "h", 2: "i", 4: "q"}}
@ -218,23 +219,21 @@ class ModbusRegisterSensor(RestoreEntity):
"""Return True if entity is available."""
return self._available
def update(self):
async def async_update(self):
"""Update the state of the sensor."""
try:
if self._register_type == DEFAULT_REGISTER_TYPE_INPUT:
result = self._hub.read_input_registers(
self._slave, self._register, self._count
)
else:
result = self._hub.read_holding_registers(
self._slave, self._register, self._count
)
except ConnectionException:
self._set_unavailable()
if self._register_type == CALL_TYPE_REGISTER_INPUT:
result = await self._hub.read_input_registers(
self._slave, self._register, self._count
)
else:
result = await self._hub.read_holding_registers(
self._slave, self._register, self._count
)
if result is None:
self._available = False
return
if isinstance(result, (ModbusException, ExceptionResponse)):
self._set_unavailable()
self._available = False
return
registers = result.registers
@ -252,16 +251,3 @@ class ModbusRegisterSensor(RestoreEntity):
self._value = f"{val:.{self._precision}f}"
self._available = True
def _set_unavailable(self):
"""Set unavailable state and log it as an error."""
if not self._available:
return
_LOGGER.error(
"No response from hub %s, slave %s, address %s",
self._hub.name,
self._slave,
self._register,
)
self._available = False

View File

@ -2,7 +2,7 @@
import logging
from typing import Optional
from pymodbus.exceptions import ConnectionException, ModbusException
from pymodbus.exceptions import ModbusException
from pymodbus.pdu import ExceptionResponse
import voluptuous as vol
@ -18,22 +18,25 @@ from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity import ToggleEntity
from homeassistant.helpers.restore_state import RestoreEntity
from . import CONF_HUB, DEFAULT_HUB, DOMAIN as MODBUS_DOMAIN
from .const import (
CALL_TYPE_COIL,
CALL_TYPE_REGISTER_HOLDING,
CALL_TYPE_REGISTER_INPUT,
CONF_COILS,
CONF_HUB,
CONF_REGISTER,
CONF_REGISTER_TYPE,
CONF_REGISTERS,
CONF_STATE_OFF,
CONF_STATE_ON,
CONF_VERIFY_REGISTER,
CONF_VERIFY_STATE,
DEFAULT_HUB,
MODBUS_DOMAIN,
)
_LOGGER = logging.getLogger(__name__)
CONF_COIL = "coil"
CONF_COILS = "coils"
CONF_REGISTER = "register"
CONF_REGISTER_TYPE = "register_type"
CONF_REGISTERS = "registers"
CONF_STATE_OFF = "state_off"
CONF_STATE_ON = "state_on"
CONF_VERIFY_REGISTER = "verify_register"
CONF_VERIFY_STATE = "verify_state"
DEFAULT_REGISTER_TYPE_HOLDING = "holding"
DEFAULT_REGISTER_TYPE_INPUT = "input"
REGISTERS_SCHEMA = vol.Schema(
{
@ -42,8 +45,8 @@ REGISTERS_SCHEMA = vol.Schema(
vol.Required(CONF_NAME): cv.string,
vol.Required(CONF_REGISTER): cv.positive_int,
vol.Optional(CONF_HUB, default=DEFAULT_HUB): cv.string,
vol.Optional(CONF_REGISTER_TYPE, default=DEFAULT_REGISTER_TYPE_HOLDING): vol.In(
[DEFAULT_REGISTER_TYPE_HOLDING, DEFAULT_REGISTER_TYPE_INPUT]
vol.Optional(CONF_REGISTER_TYPE, default=CALL_TYPE_REGISTER_HOLDING): vol.In(
[CALL_TYPE_REGISTER_HOLDING, CALL_TYPE_REGISTER_INPUT]
),
vol.Optional(CONF_SLAVE): cv.positive_int,
vol.Optional(CONF_STATE_OFF): cv.positive_int,
@ -55,7 +58,7 @@ REGISTERS_SCHEMA = vol.Schema(
COILS_SCHEMA = vol.Schema(
{
vol.Required(CONF_COIL): cv.positive_int,
vol.Required(CALL_TYPE_COIL): cv.positive_int,
vol.Required(CONF_NAME): cv.string,
vol.Required(CONF_SLAVE): cv.positive_int,
vol.Optional(CONF_HUB, default=DEFAULT_HUB): cv.string,
@ -73,7 +76,7 @@ PLATFORM_SCHEMA = vol.All(
)
def setup_platform(hass, config, add_entities, discovery_info=None):
async def async_setup_platform(hass, config, add_entities, discovery_info=None):
"""Read configuration and create Modbus devices."""
switches = []
if CONF_COILS in config:
@ -82,7 +85,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None):
hub = hass.data[MODBUS_DOMAIN][hub_name]
switches.append(
ModbusCoilSwitch(
hub, coil[CONF_NAME], coil[CONF_SLAVE], coil[CONF_COIL]
hub, coil[CONF_NAME], coil[CONF_SLAVE], coil[CALL_TYPE_COIL]
)
)
if CONF_REGISTERS in config:
@ -143,28 +146,26 @@ class ModbusCoilSwitch(ToggleEntity, RestoreEntity):
"""Return True if entity is available."""
return self._available
def turn_on(self, **kwargs):
async def turn_on(self, **kwargs):
"""Set switch on."""
self._write_coil(self._coil, True)
await self._write_coil(self._coil, True)
def turn_off(self, **kwargs):
async def turn_off(self, **kwargs):
"""Set switch off."""
self._write_coil(self._coil, False)
await self._write_coil(self._coil, False)
def update(self):
async def async_update(self):
"""Update the state of the switch."""
self._is_on = self._read_coil(self._coil)
self._is_on = await self._read_coil(self._coil)
def _read_coil(self, coil) -> Optional[bool]:
async def _read_coil(self, coil) -> Optional[bool]:
"""Read coil using the Modbus hub slave."""
try:
result = self._hub.read_coils(self._slave, coil, 1)
except ConnectionException:
self._set_unavailable()
result = await self._hub.read_coils(self._slave, coil, 1)
if result is None:
self._available = False
return
if isinstance(result, (ModbusException, ExceptionResponse)):
self._set_unavailable()
self._available = False
return
value = bool(result.bits[0])
@ -172,29 +173,11 @@ class ModbusCoilSwitch(ToggleEntity, RestoreEntity):
return value
def _write_coil(self, coil, value):
async def _write_coil(self, coil, value):
"""Write coil using the Modbus hub slave."""
try:
self._hub.write_coil(self._slave, coil, value)
except ConnectionException:
self._set_unavailable()
return
await self._hub.write_coil(self._slave, coil, value)
self._available = True
def _set_unavailable(self):
"""Set unavailable state and log it as an error."""
if not self._available:
return
_LOGGER.error(
"No response from hub %s, slave %s, coil %s",
self._hub.name,
self._slave,
self._coil,
)
self._available = False
class ModbusRegisterSwitch(ModbusCoilSwitch):
"""Representation of a Modbus register switch."""
@ -238,21 +221,21 @@ class ModbusRegisterSwitch(ModbusCoilSwitch):
self._is_on = None
def turn_on(self, **kwargs):
async def turn_on(self, **kwargs):
"""Set switch on."""
# Only holding register is writable
if self._register_type == DEFAULT_REGISTER_TYPE_HOLDING:
self._write_register(self._command_on)
if self._register_type == CALL_TYPE_REGISTER_HOLDING:
await self._write_register(self._command_on)
if not self._verify_state:
self._is_on = True
def turn_off(self, **kwargs):
async def turn_off(self, **kwargs):
"""Set switch off."""
# Only holding register is writable
if self._register_type == DEFAULT_REGISTER_TYPE_HOLDING:
self._write_register(self._command_off)
if self._register_type == CALL_TYPE_REGISTER_HOLDING:
await self._write_register(self._command_off)
if not self._verify_state:
self._is_on = False
@ -261,12 +244,12 @@ class ModbusRegisterSwitch(ModbusCoilSwitch):
"""Return True if entity is available."""
return self._available
def update(self):
async def async_update(self):
"""Update the state of the switch."""
if not self._verify_state:
return
value = self._read_register()
value = await self._read_register()
if value == self._state_on:
self._is_on = True
elif value == self._state_off:
@ -280,20 +263,20 @@ class ModbusRegisterSwitch(ModbusCoilSwitch):
value,
)
def _read_register(self) -> Optional[int]:
try:
if self._register_type == DEFAULT_REGISTER_TYPE_INPUT:
result = self._hub.read_input_registers(self._slave, self._register, 1)
else:
result = self._hub.read_holding_registers(
self._slave, self._register, 1
)
except ConnectionException:
self._set_unavailable()
async def _read_register(self) -> Optional[int]:
if self._register_type == CALL_TYPE_REGISTER_INPUT:
result = await self._hub.read_input_registers(
self._slave, self._register, 1
)
else:
result = await self._hub.read_holding_registers(
self._slave, self._register, 1
)
if result is None:
self._available = False
return
if isinstance(result, (ModbusException, ExceptionResponse)):
self._set_unavailable()
self._available = False
return
value = int(result.registers[0])
@ -301,25 +284,7 @@ class ModbusRegisterSwitch(ModbusCoilSwitch):
return value
def _write_register(self, value):
async def _write_register(self, value):
"""Write holding register using the Modbus hub slave."""
try:
self._hub.write_register(self._slave, self._register, value)
except ConnectionException:
self._set_unavailable()
return
await self._hub.write_register(self._slave, self._register, value)
self._available = True
def _set_unavailable(self):
"""Set unavailable state and log it as an error."""
if not self._available:
return
_LOGGER.error(
"No response from hub %s, slave %s, register %s",
self._hub.name,
self._slave,
self._register,
)
self._available = False

View File

@ -1399,7 +1399,7 @@ pymitv==1.4.3
pymochad==0.2.0
# homeassistant.components.modbus
pymodbus==1.5.2
pymodbus==2.3.0
# homeassistant.components.monoprice
pymonoprice==0.3

View File

@ -543,7 +543,7 @@ pymfy==0.7.1
pymochad==0.2.0
# homeassistant.components.modbus
pymodbus==1.5.2
pymodbus==2.3.0
# homeassistant.components.monoprice
pymonoprice==0.3

View File

@ -4,10 +4,12 @@ from unittest import mock
import pytest
from homeassistant.components.modbus import DEFAULT_HUB, DOMAIN as MODBUS_DOMAIN
from homeassistant.components.modbus.sensor import (
from homeassistant.components.modbus.const import (
CALL_TYPE_REGISTER_HOLDING,
CALL_TYPE_REGISTER_INPUT,
CONF_COUNT,
CONF_DATA_TYPE,
CONF_OFFSET,
CONF_PRECISION,
CONF_REGISTER,
CONF_REGISTER_TYPE,
@ -17,16 +19,10 @@ from homeassistant.components.modbus.sensor import (
DATA_TYPE_FLOAT,
DATA_TYPE_INT,
DATA_TYPE_UINT,
DEFAULT_REGISTER_TYPE_HOLDING,
DEFAULT_REGISTER_TYPE_INPUT,
)
from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN
from homeassistant.const import (
CONF_NAME,
CONF_OFFSET,
CONF_PLATFORM,
CONF_SCAN_INTERVAL,
DEFAULT_HUB,
MODBUS_DOMAIN,
)
from homeassistant.const import CONF_NAME, CONF_PLATFORM, CONF_SCAN_INTERVAL
from homeassistant.setup import async_setup_component
import homeassistant.util.dt as dt_util
@ -61,7 +57,7 @@ async def run_test(hass, mock_hub, register_config, register_words, expected):
sensor_name = "modbus_test_sensor"
scan_interval = 5
config = {
SENSOR_DOMAIN: {
MODBUS_DOMAIN: {
CONF_PLATFORM: "modbus",
CONF_SCAN_INTERVAL: scan_interval,
CONF_REGISTERS: [
@ -72,7 +68,7 @@ async def run_test(hass, mock_hub, register_config, register_words, expected):
# Setup inputs for the sensor
read_result = ReadResult(register_words)
if register_config.get(CONF_REGISTER_TYPE) == DEFAULT_REGISTER_TYPE_INPUT:
if register_config.get(CONF_REGISTER_TYPE) == CALL_TYPE_REGISTER_INPUT:
mock_hub.read_input_registers.return_value = read_result
else:
mock_hub.read_holding_registers.return_value = read_result
@ -80,7 +76,7 @@ async def run_test(hass, mock_hub, register_config, register_words, expected):
# Initialize sensor
now = dt_util.utcnow()
with mock.patch("homeassistant.helpers.event.dt_util.utcnow", return_value=now):
assert await async_setup_component(hass, SENSOR_DOMAIN, config)
assert await async_setup_component(hass, MODBUS_DOMAIN, config)
# Trigger update call with time_changed event
now += timedelta(seconds=scan_interval + 1)
@ -88,11 +84,6 @@ async def run_test(hass, mock_hub, register_config, register_words, expected):
async_fire_time_changed(hass, now)
await hass.async_block_till_done()
# Check state
entity_id = f"{SENSOR_DOMAIN}.{sensor_name}"
state = hass.states.get(entity_id).state
assert state == expected
async def test_simple_word_register(hass, mock_hub):
"""Test conversion of single word register."""
@ -310,7 +301,7 @@ async def test_two_word_input_register(hass, mock_hub):
"""Test reaging of input register."""
register_config = {
CONF_COUNT: 2,
CONF_REGISTER_TYPE: DEFAULT_REGISTER_TYPE_INPUT,
CONF_REGISTER_TYPE: CALL_TYPE_REGISTER_INPUT,
CONF_DATA_TYPE: DATA_TYPE_UINT,
CONF_SCALE: 1,
CONF_OFFSET: 0,
@ -329,7 +320,7 @@ async def test_two_word_holding_register(hass, mock_hub):
"""Test reaging of holding register."""
register_config = {
CONF_COUNT: 2,
CONF_REGISTER_TYPE: DEFAULT_REGISTER_TYPE_HOLDING,
CONF_REGISTER_TYPE: CALL_TYPE_REGISTER_HOLDING,
CONF_DATA_TYPE: DATA_TYPE_UINT,
CONF_SCALE: 1,
CONF_OFFSET: 0,
@ -348,7 +339,7 @@ async def test_float_data_type(hass, mock_hub):
"""Test floating point register data type."""
register_config = {
CONF_COUNT: 2,
CONF_REGISTER_TYPE: DEFAULT_REGISTER_TYPE_HOLDING,
CONF_REGISTER_TYPE: CALL_TYPE_REGISTER_HOLDING,
CONF_DATA_TYPE: DATA_TYPE_FLOAT,
CONF_SCALE: 1,
CONF_OFFSET: 0,