Complete modbus device response tests (#49633)

* Prepare test harness for new pymodbus return types.

Use pytest.fixture to mock pymodbus.
Use pytest.fixture to load modbus using mocked pymodbus
Add test of Exception/IllegalResponse/ExceptionResponse from pymodbus.

* Modbus.py is back at 100% test coverage.

* Added assert mock.called.

* add mock reset.
This commit is contained in:
jan iversen 2021-04-29 18:06:56 +02:00 committed by GitHub
parent 0379dee47e
commit 760caeed85
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
3 changed files with 285 additions and 117 deletions

View file

@ -620,7 +620,6 @@ omit =
homeassistant/components/mochad/* homeassistant/components/mochad/*
homeassistant/components/modbus/climate.py homeassistant/components/modbus/climate.py
homeassistant/components/modbus/cover.py homeassistant/components/modbus/cover.py
homeassistant/components/modbus/modbus.py
homeassistant/components/modbus/switch.py homeassistant/components/modbus/switch.py
homeassistant/components/modem_callerid/sensor.py homeassistant/components/modem_callerid/sensor.py
homeassistant/components/motion_blinds/__init__.py homeassistant/components/motion_blinds/__init__.py

View file

@ -20,9 +20,43 @@ import homeassistant.util.dt as dt_util
from tests.common import async_fire_time_changed from tests.common import async_fire_time_changed
TEST_MODBUS_NAME = "modbusTest"
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@pytest.fixture
def mock_pymodbus():
"""Mock pymodbus."""
mock_pb = mock.MagicMock()
with mock.patch(
"homeassistant.components.modbus.modbus.ModbusTcpClient", return_value=mock_pb
), mock.patch(
"homeassistant.components.modbus.modbus.ModbusSerialClient",
return_value=mock_pb,
), mock.patch(
"homeassistant.components.modbus.modbus.ModbusUdpClient", return_value=mock_pb
):
yield mock_pb
@pytest.fixture
async def mock_modbus(hass, mock_pymodbus):
"""Load integration modbus using mocked pymodbus."""
config = {
DOMAIN: [
{
CONF_TYPE: "tcp",
CONF_HOST: "modbusTestHost",
CONF_PORT: 5501,
CONF_NAME: TEST_MODBUS_NAME,
}
]
}
assert await async_setup_component(hass, DOMAIN, config) is True
await hass.async_block_till_done()
yield mock_pymodbus
class ReadResult: class ReadResult:
"""Storage class for register read results.""" """Storage class for register read results."""

View file

@ -1,11 +1,14 @@
"""The tests for the Modbus init.""" """The tests for the Modbus init."""
from datetime import timedelta
import logging import logging
from unittest import mock from unittest import mock
from pymodbus.exceptions import ModbusException from pymodbus.exceptions import ModbusException
from pymodbus.pdu import ExceptionResponse, IllegalFunctionRequest
import pytest import pytest
import voluptuous as vol import voluptuous as vol
from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN
from homeassistant.components.modbus import number from homeassistant.components.modbus import number
from homeassistant.components.modbus.const import ( from homeassistant.components.modbus.const import (
ATTR_ADDRESS, ATTR_ADDRESS,
@ -13,24 +16,43 @@ from homeassistant.components.modbus.const import (
ATTR_STATE, ATTR_STATE,
ATTR_UNIT, ATTR_UNIT,
ATTR_VALUE, ATTR_VALUE,
CALL_TYPE_COIL,
CALL_TYPE_DISCRETE,
CALL_TYPE_REGISTER_HOLDING,
CALL_TYPE_REGISTER_INPUT,
CONF_BAUDRATE, CONF_BAUDRATE,
CONF_BYTESIZE, CONF_BYTESIZE,
CONF_INPUT_TYPE,
CONF_PARITY, CONF_PARITY,
CONF_STOPBITS, CONF_STOPBITS,
DEFAULT_SCAN_INTERVAL,
MODBUS_DOMAIN as DOMAIN, MODBUS_DOMAIN as DOMAIN,
SERVICE_WRITE_COIL, SERVICE_WRITE_COIL,
SERVICE_WRITE_REGISTER, SERVICE_WRITE_REGISTER,
) )
from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN
from homeassistant.const import ( from homeassistant.const import (
CONF_ADDRESS,
CONF_BINARY_SENSORS,
CONF_DELAY, CONF_DELAY,
CONF_HOST, CONF_HOST,
CONF_METHOD, CONF_METHOD,
CONF_NAME, CONF_NAME,
CONF_PORT, CONF_PORT,
CONF_SENSORS,
CONF_TIMEOUT, CONF_TIMEOUT,
CONF_TYPE, CONF_TYPE,
STATE_ON,
STATE_UNAVAILABLE,
) )
from homeassistant.setup import async_setup_component from homeassistant.setup import async_setup_component
import homeassistant.util.dt as dt_util
from .conftest import TEST_MODBUS_NAME, ReadResult
from tests.common import async_fire_time_changed
TEST_SENSOR_NAME = "testSensor"
@pytest.mark.parametrize( @pytest.mark.parametrize(
@ -63,20 +85,16 @@ async def test_number_exception():
pytest.fail("Number not throwing exception") pytest.fail("Number not throwing exception")
async def _config_helper(hass, do_config): async def _config_helper(hass, do_config, caplog):
"""Run test for modbus.""" """Run test for modbus."""
config = {DOMAIN: do_config} config = {DOMAIN: do_config}
with mock.patch( caplog.set_level(logging.ERROR)
"homeassistant.components.modbus.modbus.ModbusTcpClient" assert await async_setup_component(hass, DOMAIN, config) is True
), mock.patch( await hass.async_block_till_done()
"homeassistant.components.modbus.modbus.ModbusSerialClient" assert DOMAIN in hass.config.components
), mock.patch( assert len(caplog.records) == 0
"homeassistant.components.modbus.modbus.ModbusUdpClient"
):
assert await async_setup_component(hass, DOMAIN, config) is True
await hass.async_block_till_done()
@pytest.mark.parametrize( @pytest.mark.parametrize(
@ -91,7 +109,7 @@ async def _config_helper(hass, do_config):
CONF_TYPE: "tcp", CONF_TYPE: "tcp",
CONF_HOST: "modbusTestHost", CONF_HOST: "modbusTestHost",
CONF_PORT: 5501, CONF_PORT: 5501,
CONF_NAME: "modbusTest", CONF_NAME: TEST_MODBUS_NAME,
CONF_TIMEOUT: 30, CONF_TIMEOUT: 30,
CONF_DELAY: 10, CONF_DELAY: 10,
}, },
@ -104,7 +122,7 @@ async def _config_helper(hass, do_config):
CONF_TYPE: "udp", CONF_TYPE: "udp",
CONF_HOST: "modbusTestHost", CONF_HOST: "modbusTestHost",
CONF_PORT: 5501, CONF_PORT: 5501,
CONF_NAME: "modbusTest", CONF_NAME: TEST_MODBUS_NAME,
CONF_TIMEOUT: 30, CONF_TIMEOUT: 30,
CONF_DELAY: 10, CONF_DELAY: 10,
}, },
@ -117,7 +135,7 @@ async def _config_helper(hass, do_config):
CONF_TYPE: "rtuovertcp", CONF_TYPE: "rtuovertcp",
CONF_HOST: "modbusTestHost", CONF_HOST: "modbusTestHost",
CONF_PORT: 5501, CONF_PORT: 5501,
CONF_NAME: "modbusTest", CONF_NAME: TEST_MODBUS_NAME,
CONF_TIMEOUT: 30, CONF_TIMEOUT: 30,
CONF_DELAY: 10, CONF_DELAY: 10,
}, },
@ -138,36 +156,31 @@ async def _config_helper(hass, do_config):
CONF_PORT: "usb01", CONF_PORT: "usb01",
CONF_PARITY: "E", CONF_PARITY: "E",
CONF_STOPBITS: 1, CONF_STOPBITS: 1,
CONF_NAME: "modbusTest", CONF_NAME: TEST_MODBUS_NAME,
CONF_TIMEOUT: 30, CONF_TIMEOUT: 30,
CONF_DELAY: 10, CONF_DELAY: 10,
}, },
], ],
) )
async def test_config_modbus(hass, caplog, do_config): async def test_config_modbus(hass, caplog, do_config, mock_pymodbus):
"""Run test for modbus.""" """Run test for modbus."""
await _config_helper(hass, do_config, caplog)
caplog.set_level(logging.ERROR)
await _config_helper(hass, do_config)
assert DOMAIN in hass.config.components
assert len(caplog.records) == 0
async def test_config_multiple_modbus(hass, caplog): async def test_config_multiple_modbus(hass, caplog, mock_pymodbus):
"""Run test for multiple modbus.""" """Run test for multiple modbus."""
do_config = [ do_config = [
{ {
CONF_TYPE: "tcp", CONF_TYPE: "tcp",
CONF_HOST: "modbusTestHost", CONF_HOST: "modbusTestHost",
CONF_PORT: 5501, CONF_PORT: 5501,
CONF_NAME: "modbusTest1", CONF_NAME: TEST_MODBUS_NAME,
}, },
{ {
CONF_TYPE: "tcp", CONF_TYPE: "tcp",
CONF_HOST: "modbusTestHost", CONF_HOST: "modbusTestHost",
CONF_PORT: 5501, CONF_PORT: 5501,
CONF_NAME: "modbusTest2", CONF_NAME: TEST_MODBUS_NAME + "2",
}, },
{ {
CONF_TYPE: "serial", CONF_TYPE: "serial",
@ -177,114 +190,240 @@ async def test_config_multiple_modbus(hass, caplog):
CONF_PORT: "usb01", CONF_PORT: "usb01",
CONF_PARITY: "E", CONF_PARITY: "E",
CONF_STOPBITS: 1, CONF_STOPBITS: 1,
CONF_NAME: "modbusTest3", CONF_NAME: TEST_MODBUS_NAME + "3",
}, },
] ]
caplog.set_level(logging.ERROR) await _config_helper(hass, do_config, caplog)
await _config_helper(hass, do_config)
assert DOMAIN in hass.config.components
assert len(caplog.records) == 0
async def test_pb_service_write_register(hass): async def test_pb_service_write_register(hass, caplog, mock_modbus):
"""Run test for service write_register.""" """Run test for service write_register."""
conf_name = "myModbus" # Pymodbus write single, response OK.
config = { data = {ATTR_HUB: TEST_MODBUS_NAME, ATTR_UNIT: 17, ATTR_ADDRESS: 16, ATTR_VALUE: 15}
DOMAIN: [ await hass.services.async_call(DOMAIN, SERVICE_WRITE_REGISTER, data, blocking=True)
{ assert mock_modbus.write_register.called
CONF_TYPE: "tcp", assert mock_modbus.write_register.call_args[0] == (
CONF_HOST: "modbusTestHost", data[ATTR_ADDRESS],
CONF_PORT: 5501, data[ATTR_VALUE],
CONF_NAME: conf_name, )
} mock_modbus.reset_mock()
]
}
mock_pb = mock.MagicMock() # Pymodbus write single, response error or exception
with mock.patch( caplog.set_level(logging.DEBUG)
"homeassistant.components.modbus.modbus.ModbusTcpClient", return_value=mock_pb mock_modbus.write_register.return_value = ExceptionResponse(0x06)
): await hass.services.async_call(DOMAIN, SERVICE_WRITE_REGISTER, data, blocking=True)
assert await async_setup_component(hass, DOMAIN, config) is True assert mock_modbus.write_register.called
await hass.async_block_till_done() assert caplog.messages[-1].startswith("Pymodbus:")
mock_modbus.reset_mock()
data = {ATTR_HUB: conf_name, ATTR_UNIT: 17, ATTR_ADDRESS: 16, ATTR_VALUE: 15} mock_modbus.write_register.return_value = IllegalFunctionRequest(0x06)
await hass.services.async_call( await hass.services.async_call(DOMAIN, SERVICE_WRITE_REGISTER, data, blocking=True)
DOMAIN, SERVICE_WRITE_REGISTER, data, blocking=True assert mock_modbus.write_register.called
) assert caplog.messages[-1].startswith("Pymodbus:")
assert mock_pb.write_register.called mock_modbus.reset_mock()
assert mock_pb.write_register.call_args[0] == (
data[ATTR_ADDRESS],
data[ATTR_VALUE],
)
mock_pb.write_register.side_effect = ModbusException("fail write_")
await hass.services.async_call(
DOMAIN, SERVICE_WRITE_REGISTER, data, blocking=True
)
data[ATTR_VALUE] = [1, 2, 3] mock_modbus.write_register.side_effect = ModbusException("fail write_")
await hass.services.async_call( await hass.services.async_call(DOMAIN, SERVICE_WRITE_REGISTER, data, blocking=True)
DOMAIN, SERVICE_WRITE_REGISTER, data, blocking=True assert mock_modbus.write_register.called
) assert caplog.messages[-1].startswith("Pymodbus:")
assert mock_pb.write_registers.called mock_modbus.reset_mock()
assert mock_pb.write_registers.call_args[0] == (
data[ATTR_ADDRESS], # Pymodbus write multiple, response OK.
data[ATTR_VALUE], data[ATTR_VALUE] = [1, 2, 3]
) await hass.services.async_call(DOMAIN, SERVICE_WRITE_REGISTER, data, blocking=True)
mock_pb.write_registers.side_effect = ModbusException("fail write_") assert mock_modbus.write_registers.called
await hass.services.async_call( assert mock_modbus.write_registers.call_args[0] == (
DOMAIN, SERVICE_WRITE_REGISTER, data, blocking=True data[ATTR_ADDRESS],
) data[ATTR_VALUE],
)
mock_modbus.reset_mock()
# Pymodbus write multiple, response error or exception
mock_modbus.write_registers.return_value = ExceptionResponse(0x06)
await hass.services.async_call(DOMAIN, SERVICE_WRITE_REGISTER, data, blocking=True)
assert mock_modbus.write_registers.called
assert caplog.messages[-1].startswith("Pymodbus:")
mock_modbus.reset_mock()
mock_modbus.write_registers.return_value = IllegalFunctionRequest(0x06)
await hass.services.async_call(DOMAIN, SERVICE_WRITE_REGISTER, data, blocking=True)
assert mock_modbus.write_registers.called
assert caplog.messages[-1].startswith("Pymodbus:")
mock_modbus.reset_mock()
mock_modbus.write_registers.side_effect = ModbusException("fail write_")
await hass.services.async_call(DOMAIN, SERVICE_WRITE_REGISTER, data, blocking=True)
assert mock_modbus.write_registers.called
assert caplog.messages[-1].startswith("Pymodbus:")
mock_modbus.reset_mock()
async def test_pb_service_write_coil(hass, caplog): async def test_pb_service_write_coil(hass, caplog, mock_modbus):
"""Run test for service write_coil.""" """Run test for service write_coil."""
conf_name = "myModbus" # Pymodbus write single, response OK.
data = {
ATTR_HUB: TEST_MODBUS_NAME,
ATTR_UNIT: 17,
ATTR_ADDRESS: 16,
ATTR_STATE: False,
}
await hass.services.async_call(DOMAIN, SERVICE_WRITE_COIL, data, blocking=True)
assert mock_modbus.write_coil.called
assert mock_modbus.write_coil.call_args[0] == (
data[ATTR_ADDRESS],
data[ATTR_STATE],
)
mock_modbus.reset_mock()
# Pymodbus write single, response error or exception
caplog.set_level(logging.DEBUG)
mock_modbus.write_coil.return_value = ExceptionResponse(0x06)
await hass.services.async_call(DOMAIN, SERVICE_WRITE_COIL, data, blocking=True)
assert mock_modbus.write_coil.called
assert caplog.messages[-1].startswith("Pymodbus:")
mock_modbus.reset_mock()
mock_modbus.write_coil.return_value = IllegalFunctionRequest(0x06)
await hass.services.async_call(DOMAIN, SERVICE_WRITE_COIL, data, blocking=True)
assert mock_modbus.write_coil.called
assert caplog.messages[-1].startswith("Pymodbus:")
mock_modbus.reset_mock()
mock_modbus.write_coil.side_effect = ModbusException("fail write_")
await hass.services.async_call(DOMAIN, SERVICE_WRITE_COIL, data, blocking=True)
assert mock_modbus.write_coil.called
assert caplog.messages[-1].startswith("Pymodbus:")
mock_modbus.reset_mock()
# Pymodbus write multiple, response OK.
data[ATTR_STATE] = [True, False, True]
await hass.services.async_call(DOMAIN, SERVICE_WRITE_COIL, data, blocking=True)
assert mock_modbus.write_coils.called
assert mock_modbus.write_coils.call_args[0] == (
data[ATTR_ADDRESS],
data[ATTR_STATE],
)
mock_modbus.reset_mock()
# Pymodbus write multiple, response error or exception
mock_modbus.write_coils.return_value = ExceptionResponse(0x06)
await hass.services.async_call(DOMAIN, SERVICE_WRITE_COIL, data, blocking=True)
assert mock_modbus.write_coils.called
assert caplog.messages[-1].startswith("Pymodbus:")
mock_modbus.reset_mock()
mock_modbus.write_coils.return_value = IllegalFunctionRequest(0x06)
await hass.services.async_call(DOMAIN, SERVICE_WRITE_COIL, data, blocking=True)
assert mock_modbus.write_coils.called
assert caplog.messages[-1].startswith("Pymodbus:")
mock_modbus.reset_mock()
mock_modbus.write_coils.side_effect = ModbusException("fail write_")
await hass.services.async_call(DOMAIN, SERVICE_WRITE_COIL, data, blocking=True)
assert mock_modbus.write_coils.called
assert caplog.messages[-1].startswith("Pymodbus:")
mock_modbus.reset_mock()
async def _read_helper(hass, do_group, do_type, do_return, do_exception, mock_pymodbus):
config = { config = {
DOMAIN: [ DOMAIN: [
{ {
CONF_TYPE: "tcp", CONF_TYPE: "tcp",
CONF_HOST: "modbusTestHost", CONF_HOST: "modbusTestHost",
CONF_PORT: 5501, CONF_PORT: 5501,
CONF_NAME: conf_name, CONF_NAME: TEST_MODBUS_NAME,
do_group: {
CONF_INPUT_TYPE: do_type,
CONF_NAME: TEST_SENSOR_NAME,
CONF_ADDRESS: 51,
},
} }
] ]
} }
mock_pymodbus.read_coils.side_effect = do_exception
mock_pb = mock.MagicMock() mock_pymodbus.read_discrete_inputs.side_effect = do_exception
with mock.patch( mock_pymodbus.read_input_registers.side_effect = do_exception
"homeassistant.components.modbus.modbus.ModbusTcpClient", return_value=mock_pb mock_pymodbus.read_holding_registers.side_effect = do_exception
): mock_pymodbus.read_coils.return_value = do_return
mock_pymodbus.read_discrete_inputs.return_value = do_return
mock_pymodbus.read_input_registers.return_value = do_return
mock_pymodbus.read_holding_registers.return_value = do_return
now = dt_util.utcnow()
with mock.patch("homeassistant.helpers.event.dt_util.utcnow", return_value=now):
assert await async_setup_component(hass, DOMAIN, config) is True assert await async_setup_component(hass, DOMAIN, config) is True
await hass.async_block_till_done() await hass.async_block_till_done()
now = now + timedelta(seconds=DEFAULT_SCAN_INTERVAL + 60)
with mock.patch("homeassistant.helpers.event.dt_util.utcnow", return_value=now):
async_fire_time_changed(hass, now)
await hass.async_block_till_done()
data = {ATTR_HUB: conf_name, ATTR_UNIT: 17, ATTR_ADDRESS: 16, ATTR_STATE: False}
await hass.services.async_call(DOMAIN, SERVICE_WRITE_COIL, data, blocking=True)
assert mock_pb.write_coil.called
assert mock_pb.write_coil.call_args[0] == (
data[ATTR_ADDRESS],
data[ATTR_STATE],
)
mock_pb.write_coil.side_effect = ModbusException("fail write_")
await hass.services.async_call(DOMAIN, SERVICE_WRITE_COIL, data, blocking=True)
data[ATTR_STATE] = [True, False, True] @pytest.mark.parametrize(
await hass.services.async_call(DOMAIN, SERVICE_WRITE_COIL, data, blocking=True) "do_return,do_exception,do_expect",
assert mock_pb.write_coils.called [
assert mock_pb.write_coils.call_args[0] == ( [ReadResult([7]), None, "7"],
data[ATTR_ADDRESS], [IllegalFunctionRequest(0x99), None, STATE_UNAVAILABLE],
data[ATTR_STATE], [ExceptionResponse(0x99), None, STATE_UNAVAILABLE],
) [ReadResult([7]), ModbusException("fail read_"), STATE_UNAVAILABLE],
],
)
@pytest.mark.parametrize(
"do_type",
[CALL_TYPE_REGISTER_HOLDING, CALL_TYPE_REGISTER_INPUT],
)
async def test_pb_read_value(
hass, caplog, do_type, do_return, do_exception, do_expect, mock_pymodbus
):
"""Run test for different read."""
caplog.set_level(logging.DEBUG) # the purpose of this test is to test the special
caplog.clear # return values from pymodbus:
mock_pb.write_coils.side_effect = ModbusException("fail write_") # ExceptionResponse, IllegalResponse
await hass.services.async_call(DOMAIN, SERVICE_WRITE_COIL, data, blocking=True) # and exceptions.
assert caplog.records[-1].levelname == "ERROR" # We "hijiack" binary_sensor and sensor in order
await hass.services.async_call(DOMAIN, SERVICE_WRITE_COIL, data, blocking=True) # to make a proper blackbox test.
assert caplog.records[-1].levelname == "DEBUG" await _read_helper(
hass, CONF_SENSORS, do_type, do_return, do_exception, mock_pymodbus
)
# Check state
entity_id = f"{SENSOR_DOMAIN}.{TEST_SENSOR_NAME}"
assert hass.states.get(entity_id).state
@pytest.mark.parametrize(
"do_return,do_exception,do_expect",
[
[ReadResult([0x01]), None, STATE_ON],
[IllegalFunctionRequest(0x99), None, STATE_UNAVAILABLE],
[ExceptionResponse(0x99), None, STATE_UNAVAILABLE],
[ReadResult([7]), ModbusException("fail read_"), STATE_UNAVAILABLE],
],
)
@pytest.mark.parametrize("do_type", [CALL_TYPE_DISCRETE, CALL_TYPE_COIL])
async def test_pb_read_state(
hass, caplog, do_type, do_return, do_exception, do_expect, mock_pymodbus
):
"""Run test for different read."""
# the purpose of this test is to test the special
# return values from pymodbus:
# ExceptionResponse, IllegalResponse
# and exceptions.
# We "hijiack" binary_sensor and sensor in order
# to make a proper blackbox test.
await _read_helper(
hass, CONF_BINARY_SENSORS, do_type, do_return, do_exception, mock_pymodbus
)
# Check state
entity_id = f"{BINARY_SENSOR_DOMAIN}.{TEST_SENSOR_NAME}"
state = hass.states.get(entity_id).state
assert state == do_expect
async def test_pymodbus_constructor_fail(hass, caplog): async def test_pymodbus_constructor_fail(hass, caplog):
@ -310,7 +449,7 @@ async def test_pymodbus_constructor_fail(hass, caplog):
assert mock_pb.called assert mock_pb.called
async def test_pymodbus_connect_fail(hass, caplog): async def test_pymodbus_connect_fail(hass, caplog, mock_pymodbus):
"""Run test for failing pymodbus constructor.""" """Run test for failing pymodbus constructor."""
config = { config = {
DOMAIN: [ DOMAIN: [
@ -321,14 +460,10 @@ async def test_pymodbus_connect_fail(hass, caplog):
} }
] ]
} }
mock_pb = mock.MagicMock() caplog.set_level(logging.ERROR)
with mock.patch( mock_pymodbus.connect.side_effect = ModbusException("test connect fail")
"homeassistant.components.modbus.modbus.ModbusTcpClient", return_value=mock_pb mock_pymodbus.close.side_effect = ModbusException("test connect fail")
): assert await async_setup_component(hass, DOMAIN, config) is True
caplog.set_level(logging.ERROR) await hass.async_block_till_done()
mock_pb.connect.side_effect = ModbusException("test connect fail") assert len(caplog.records) == 1
mock_pb.close.side_effect = ModbusException("test connect fail") assert caplog.records[0].levelname == "ERROR"
assert await async_setup_component(hass, DOMAIN, config) is True
await hass.async_block_till_done()
assert len(caplog.records) == 1
assert caplog.records[0].levelname == "ERROR"