diff --git a/homeassistant/components/modbus/__init__.py b/homeassistant/components/modbus/__init__.py index a4e0c21ec5f62b..ee55b8e2161def 100644 --- a/homeassistant/components/modbus/__init__.py +++ b/homeassistant/components/modbus/__init__.py @@ -52,8 +52,12 @@ CALL_TYPE_REGISTER_HOLDING, CALL_TYPE_REGISTER_INPUT, CONF_BAUDRATE, + CONF_BIT_NUMBER, + CONF_BIT_SENSORS, + CONF_BIT_SWITCHES, CONF_BYTESIZE, CONF_CLIMATES, + CONF_COMMAND_BIT_NUMBER, CONF_CURRENT_TEMP, CONF_CURRENT_TEMP_REGISTER_TYPE, CONF_DATA_COUNT, @@ -72,6 +76,7 @@ CONF_STATE_ON, CONF_STATE_OPEN, CONF_STATE_OPENING, + CONF_STATUS_BIT_NUMBER, CONF_STATUS_REGISTER, CONF_STATUS_REGISTER_TYPE, CONF_STEP, @@ -183,6 +188,21 @@ def number(value: Any) -> Union[int, float]: } ) +BIT_SWITCH_SCHEMA = BASE_COMPONENT_SCHEMA.extend( + { + vol.Required(CONF_ADDRESS): cv.positive_int, + vol.Required(CONF_COMMAND_BIT_NUMBER): cv.positive_int, + vol.Optional(CONF_STATUS_BIT_NUMBER): cv.positive_int, + vol.Optional(CONF_DEVICE_CLASS): SWITCH_DEVICE_CLASSES_SCHEMA, + vol.Optional(CONF_INPUT_TYPE, default=CALL_TYPE_REGISTER_HOLDING): vol.In( + [CALL_TYPE_REGISTER_HOLDING, CALL_TYPE_REGISTER_INPUT] + ), + vol.Optional(CONF_VERIFY_REGISTER): cv.positive_int, + vol.Optional(CONF_VERIFY_STATE, default=True): cv.boolean, + } +) + + SENSOR_SCHEMA = BASE_COMPONENT_SCHEMA.extend( { vol.Required(CONF_ADDRESS): cv.positive_int, @@ -209,6 +229,18 @@ def number(value: Any) -> Union[int, float]: } ) +BIT_SENSOR_SCHEMA = BASE_COMPONENT_SCHEMA.extend( + { + vol.Required(CONF_ADDRESS): cv.positive_int, + vol.Required(CONF_BIT_NUMBER): cv.positive_int, + vol.Optional(CONF_COUNT, default=1): cv.positive_int, + vol.Optional(CONF_DEVICE_CLASS): SENSOR_DEVICE_CLASSES_SCHEMA, + vol.Optional(CONF_INPUT_TYPE, default=CALL_TYPE_REGISTER_HOLDING): vol.In( + [CALL_TYPE_REGISTER_HOLDING, CALL_TYPE_REGISTER_INPUT] + ), + } +) + BINARY_SENSOR_SCHEMA = BASE_COMPONENT_SCHEMA.extend( { vol.Required(CONF_ADDRESS): cv.positive_int, @@ -231,6 +263,8 @@ def number(value: Any) -> Union[int, float]: vol.Optional(CONF_COVERS): vol.All(cv.ensure_list, [COVERS_SCHEMA]), vol.Optional(CONF_SENSORS): vol.All(cv.ensure_list, [SENSOR_SCHEMA]), vol.Optional(CONF_SWITCHES): vol.All(cv.ensure_list, [SWITCH_SCHEMA]), + vol.Optional(CONF_BIT_SENSORS): vol.All(cv.ensure_list, [BIT_SENSOR_SCHEMA]), + vol.Optional(CONF_BIT_SWITCHES): vol.All(cv.ensure_list, [BIT_SWITCH_SCHEMA]), } ) diff --git a/homeassistant/components/modbus/binary_sensor.py b/homeassistant/components/modbus/binary_sensor.py index e422eb7528ea80..c78542f5d375b6 100644 --- a/homeassistant/components/modbus/binary_sensor.py +++ b/homeassistant/components/modbus/binary_sensor.py @@ -29,6 +29,7 @@ HomeAssistantType, ) +from .bit_sensor import setup_bit_sensors from .const import ( CALL_TYPE_COIL, CALL_TYPE_DISCRETE, @@ -91,7 +92,9 @@ async def async_setup_platform( } config = None - for entry in discovery_info[CONF_BINARY_SENSORS]: + sensors.extend(setup_bit_sensors(hass, discovery_info)) + + for entry in discovery_info.get(CONF_BINARY_SENSORS, []): if CONF_HUB in entry: # from old config! discovery_info[CONF_NAME] = entry[CONF_HUB] diff --git a/homeassistant/components/modbus/bit_sensor.py b/homeassistant/components/modbus/bit_sensor.py new file mode 100644 index 00000000000000..b487e3458d88ae --- /dev/null +++ b/homeassistant/components/modbus/bit_sensor.py @@ -0,0 +1,223 @@ +"""Support for Modbus Bit sensors.""" +from __future__ import annotations + +from datetime import timedelta +from functools import lru_cache, partial +import logging +import time + +from pymodbus.exceptions import ConnectionException, ModbusException +from pymodbus.pdu import ExceptionResponse + +from homeassistant.components.binary_sensor import BinarySensorEntity +from homeassistant.const import ( + CONF_ADDRESS, + CONF_COUNT, + CONF_DEVICE_CLASS, + CONF_NAME, + CONF_SCAN_INTERVAL, + CONF_SLAVE, + STATE_ON, +) +from homeassistant.helpers.event import async_track_time_interval +from homeassistant.helpers.restore_state import RestoreEntity +from homeassistant.helpers.typing import DiscoveryInfoType, HomeAssistantType + +from .const import ( + CALL_TYPE_REGISTER_INPUT, + CONF_BIT_NUMBER, + CONF_BIT_SENSORS, + CONF_INPUT_TYPE, + MODBUS_DOMAIN, +) +from .modbus import ModbusHub + +_LOGGER = logging.getLogger(__name__) + + +@lru_cache(maxsize=32) +def _read_cached(hub, method, ttl_bucket, *args, **kvargs): + """Return cached or invoke the Hub read_* method.""" + return getattr(hub, method)(*args, **kvargs) + + +class ModbusReadCache: + """Wraps Modbus Hub and provide cached methods.""" + + CACHED_METHODS = ["read_input_registers", "read_holding_registers"] + CACHE_RESET_METHODS = "write_" + + def __init__(self, hub): + """Init the read cache.""" + self._hub = hub + + def __getattr__(self, attr): + """Forward calls to the Hub object or use cached.""" + if attr.startswith(ModbusReadCache.CACHE_RESET_METHODS): + _read_cached.cache_clear() + if attr not in ModbusReadCache.CACHED_METHODS: + return getattr(self._hub, attr) + + return partial(_read_cached, self._hub, attr, int(time.time())) + + +def setup_bit_sensors( + hass: HomeAssistantType, + discovery_info: DiscoveryInfoType | None = None, +) -> [BinarySensorEntity]: + """Set up the Modbus Bit sensors.""" + sensors = [] + + if discovery_info is None: + return sensors + + for entry in discovery_info.get(CONF_BIT_SENSORS, []): + words_count = int(entry[CONF_COUNT]) + bit_number = int(entry[CONF_BIT_NUMBER]) + + if bit_number >= words_count * 16: + _LOGGER.error( + "Bit number for the %s sensor is out of range", + entry[CONF_NAME], + ) + continue + + hub: ModbusHub = hass.data[MODBUS_DOMAIN][discovery_info[CONF_NAME]] + sensors.append( + ModbusBitSensor( + hub, + entry[CONF_NAME], + entry.get(CONF_SLAVE), + entry[CONF_ADDRESS], + entry[CONF_INPUT_TYPE], + bit_number, + words_count, + entry.get(CONF_DEVICE_CLASS), + entry[CONF_SCAN_INTERVAL], + ) + ) + + return sensors + + +class ModbusBitSensorBase(RestoreEntity, BinarySensorEntity): + """Base class for the Modbus sensor.""" + + def __init__( + self, + hub, + name, + slave, + register, + register_type, + count, + device_class, + scan_interval, + ): + """Initialize the modbus sensor.""" + self._hub = hub + self._name = name + self._slave = int(slave) if slave else None + self._register = int(register) + self._count = count + self._device_class = device_class + self._register_type = register_type + self._value = None + self._available = True + self._scan_interval = timedelta(seconds=scan_interval) + + @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 should_poll(self): + """Return True if entity has to be polled for state. + + False if entity pushes its state to HA. + """ + + # Handle polling directly in this entity + return False + + @property + def device_class(self) -> str | None: + """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 + + +class ModbusBitSensor(ModbusBitSensorBase): + """Modbus bit sensor.""" + + def __init__( + self, + hub, + name, + slave, + register, + register_type, + bit_number, + count, + device_class, + scan_interval, + ): + """Initialize the modbus bit sensor.""" + super().__init__( + ModbusReadCache(hub), + name, + slave, + register, + register_type, + count, + device_class, + scan_interval, + ) + self._bit_number = int(bit_number) + + async def async_added_to_hass(self): + """Handle entity which will be added.""" + state = await self.async_get_last_state() + if state: + self._value = state.state == STATE_ON + + async_track_time_interval( + self.hass, lambda arg: self._update(), self._scan_interval + ) + + def _update(self): + """Update the state of the sensor.""" + 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 + self.schedule_update_ha_state() + return + if isinstance(result, (ModbusException, ExceptionResponse)): + self._available = False + self.schedule_update_ha_state() + return + + register_index = self._bit_number // 16 + register_bit_mask = 1 << (self._bit_number % 16) + self._value = bool(result.registers[register_index] & register_bit_mask) + self._available = True + self.schedule_update_ha_state() diff --git a/homeassistant/components/modbus/bit_switch.py b/homeassistant/components/modbus/bit_switch.py new file mode 100644 index 00000000000000..b65e307df6e209 --- /dev/null +++ b/homeassistant/components/modbus/bit_switch.py @@ -0,0 +1,201 @@ +"""Support for Modbus switches.""" +from __future__ import annotations + +from abc import ABC, abstractmethod +from datetime import timedelta +import logging +from typing import Any + +from pymodbus.exceptions import ConnectionException, ModbusException +from pymodbus.pdu import ExceptionResponse + +from homeassistant.components.switch import SwitchEntity +from homeassistant.const import ( + CONF_ADDRESS, + CONF_NAME, + CONF_SCAN_INTERVAL, + CONF_SLAVE, + STATE_ON, +) +from homeassistant.helpers.event import async_track_time_interval +from homeassistant.helpers.restore_state import RestoreEntity +from homeassistant.helpers.typing import DiscoveryInfoType, HomeAssistantType + +from .bit_sensor import ModbusReadCache +from .const import ( + CALL_TYPE_REGISTER_HOLDING, + CALL_TYPE_REGISTER_INPUT, + CONF_BIT_SWITCHES, + CONF_COMMAND_BIT_NUMBER, + CONF_INPUT_TYPE, + CONF_STATUS_BIT_NUMBER, + CONF_VERIFY_REGISTER, + CONF_VERIFY_STATE, + MODBUS_DOMAIN, +) +from .modbus import ModbusHub + +_LOGGER = logging.getLogger(__name__) + + +def setup_bit_swithes( + hass: HomeAssistantType, + discovery_info: DiscoveryInfoType | None = None, +) -> [SwitchEntity]: + """Modbus Bit Switches setup.""" + switches = [] + + if discovery_info is None: + return switches + + for entry in discovery_info.get(CONF_BIT_SWITCHES, []): + hub: ModbusHub = hass.data[MODBUS_DOMAIN][discovery_info[CONF_NAME]] + switches.append(ModbusRegisterBitSwitch(hub, entry)) + + return switches + + +class ModbusBaseSwitch(SwitchEntity, RestoreEntity, ABC): + """Base class representing a Modbus switch.""" + + def __init__(self, hub: ModbusHub, config: dict[str, Any]): + """Initialize the switch.""" + self._hub: ModbusHub = hub + self._name = config[CONF_NAME] + self._slave = config.get(CONF_SLAVE) + self._register_type = config[CONF_INPUT_TYPE] + self._is_on = None + self._available = True + self._scan_interval = timedelta(seconds=config[CONF_SCAN_INTERVAL]) + + async def async_added_to_hass(self): + """Handle entity which will be added.""" + state = await self.async_get_last_state() + if state: + self._is_on = state.state == STATE_ON + + async_track_time_interval( + self.hass, lambda arg: self._update(), self._scan_interval + ) + + @abstractmethod + def _update(self): + """Update the entity state.""" + + @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 should_poll(self): + """Return True if entity has to be polled for state. + + False if entity pushes its state to HA. + """ + + # Handle polling directly in this entity + return False + + @property + def available(self) -> bool: + """Return True if entity is available.""" + return self._available + + def _read_register(self, address) -> int | None: + try: + if self._register_type == CALL_TYPE_REGISTER_INPUT: + result = self._hub.read_input_registers(self._slave, address, 1) + else: + result = self._hub.read_holding_registers(self._slave, address, 1) + + except ConnectionException: + self._available = False + return + + if isinstance(result, (ModbusException, ExceptionResponse)): + self._available = False + return + + self._available = True + return int(result.registers[0]) + + def _write_register(self, address, value): + """Write holding register or coil using the Modbus hub slave.""" + try: + self._hub.write_register(self._slave, address, value) + except ConnectionException: + self._available = False + return False + + self._available = True + return True + + +class ModbusRegisterBitSwitch(ModbusBaseSwitch, SwitchEntity): + """Representation of a Modbus register switch.""" + + def __init__(self, hub: ModbusHub, config: dict[str, Any]): + """Initialize the register switch.""" + super().__init__(ModbusReadCache(hub), config) + self._register = config[CONF_ADDRESS] + self._verify_state = config[CONF_VERIFY_STATE] + self._verify_register = config.get(CONF_VERIFY_REGISTER, self._register) + self._register_type = config[CONF_INPUT_TYPE] + + command_bit_numer = int(config[CONF_COMMAND_BIT_NUMBER]) + status_bit_numer = int(config.get(CONF_STATUS_BIT_NUMBER, command_bit_numer)) + assert 0 <= command_bit_numer < 16 + assert 0 <= status_bit_numer < 16 + + self._command_bit_mask = 1 << command_bit_numer + self._status_bit_mask = 1 << status_bit_numer + + self._is_on = None + + def turn_on(self, **kwargs): + """Set switch on.""" + # Only holding register is writable + if self._register_type != CALL_TYPE_REGISTER_HOLDING: + return + register_value = self._read_register(self._verify_register) + if register_value is None: + self._available = False + return + if self._write_register( + self._register, register_value | self._command_bit_mask + ): + self._is_on = True + self.schedule_update_ha_state() + + def turn_off(self, **kwargs): + """Set switch off.""" + # Only holding register is writable + if self._register_type != CALL_TYPE_REGISTER_HOLDING: + return + register_value = self._read_register(self._verify_register) + if register_value is None: + self._available = False + return + if self._write_register( + self._register, register_value & ~self._command_bit_mask + ): + self._is_on = False + self.schedule_update_ha_state() + + def _update(self): + """Update the state of the switch.""" + if not self._verify_state: + return + value = self._read_register(self._verify_register) + if value is not None: + self._is_on = bool(value & self._status_bit_mask) + self._available = True + else: + self._available = False + self.schedule_update_ha_state() diff --git a/homeassistant/components/modbus/const.py b/homeassistant/components/modbus/const.py index ffe89757ef127d..44bde6cb21fa8b 100644 --- a/homeassistant/components/modbus/const.py +++ b/homeassistant/components/modbus/const.py @@ -3,10 +3,14 @@ # configuration names CONF_BAUDRATE = "baudrate" CONF_BINARY_SENSOR = "binary_sensor" +CONF_BIT_SWITCHES = "bit_switches" +CONF_BIT_SENSORS = "bit_sensors" +CONF_BIT_NUMBER = "bit_number" CONF_BYTESIZE = "bytesize" CONF_CLIMATE = "climate" CONF_CLIMATES = "climates" CONF_COILS = "coils" +CONF_COMMAND_BIT_NUMBER = "command_bit_number" CONF_COVER = "cover" CONF_CURRENT_TEMP = "current_temp_register" CONF_CURRENT_TEMP_REGISTER_TYPE = "current_temp_register_type" @@ -31,6 +35,7 @@ CONF_STATE_ON = "state_on" CONF_STATE_OPEN = "state_open" CONF_STATE_OPENING = "state_opening" +CONF_STATUS_BIT_NUMBER = "status_bit_number" CONF_STATUS_REGISTER = "status_register" CONF_STATUS_REGISTER_TYPE = "status_register_type" CONF_STEP = "temp_step" diff --git a/homeassistant/components/modbus/modbus.py b/homeassistant/components/modbus/modbus.py index 0a5422ff6be0eb..ed11cd8e424294 100644 --- a/homeassistant/components/modbus/modbus.py +++ b/homeassistant/components/modbus/modbus.py @@ -29,6 +29,8 @@ ATTR_VALUE, CONF_BAUDRATE, CONF_BINARY_SENSOR, + CONF_BIT_SENSORS, + CONF_BIT_SWITCHES, CONF_BYTESIZE, CONF_CLIMATE, CONF_CLIMATES, @@ -63,8 +65,10 @@ def modbus_setup( (CONF_CLIMATE, CONF_CLIMATES), (CONF_COVER, CONF_COVERS), (CONF_BINARY_SENSOR, CONF_BINARY_SENSORS), + (CONF_BINARY_SENSOR, CONF_BIT_SENSORS), (CONF_SENSOR, CONF_SENSORS), (CONF_SWITCH, CONF_SWITCHES), + (CONF_SWITCH, CONF_BIT_SWITCHES), ): if conf_key in conf_hub: load_platform(hass, component, DOMAIN, conf_hub, config) diff --git a/homeassistant/components/modbus/sensor.py b/homeassistant/components/modbus/sensor.py index 21069d8642773c..ceb7cda989a3ba 100644 --- a/homeassistant/components/modbus/sensor.py +++ b/homeassistant/components/modbus/sensor.py @@ -125,7 +125,7 @@ async def async_setup_platform( """Set up the Modbus sensors.""" sensors = [] - #  check for old config: + # check for old config: if discovery_info is None: _LOGGER.warning( "Sensor configuration is deprecated, will be removed in a future release" diff --git a/homeassistant/components/modbus/switch.py b/homeassistant/components/modbus/switch.py index 2985d8b2c05915..a91b510970fadf 100644 --- a/homeassistant/components/modbus/switch.py +++ b/homeassistant/components/modbus/switch.py @@ -26,6 +26,7 @@ from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.typing import ConfigType, HomeAssistantType +from .bit_switch import setup_bit_swithes from .const import ( CALL_TYPE_COIL, CALL_TYPE_REGISTER_HOLDING, @@ -93,7 +94,7 @@ async def async_setup_platform( """Read configuration and create Modbus switches.""" switches = [] - #  check for old config: + # check for old config: if discovery_info is None: _LOGGER.warning( "Switch configuration is deprecated, will be removed in a future release" @@ -121,7 +122,9 @@ async def async_setup_platform( entry[CONF_SCAN_INTERVAL] = DEFAULT_SCAN_INTERVAL config = None - for entry in discovery_info[CONF_SWITCHES]: + switches.extend(setup_bit_swithes(hass, discovery_info)) + + for entry in discovery_info.get(CONF_SWITCHES, []): if CONF_HUB in entry: # from old config! discovery_info[CONF_NAME] = entry[CONF_HUB] diff --git a/tests/components/modbus/conftest.py b/tests/components/modbus/conftest.py index 2c83f40546f3d8..e1ec33539a325c 100644 --- a/tests/components/modbus/conftest.py +++ b/tests/components/modbus/conftest.py @@ -38,7 +38,7 @@ async def base_test( entity_domain, array_name_discovery, array_name_old_config, - register_words, + register_words_or_exception, expected, method_discovery=False, check_config_only=False, @@ -58,6 +58,7 @@ async def base_test( } mock_sync = mock.MagicMock() + # Setup inputs for the sensor with mock.patch( "homeassistant.components.modbus.modbus.ModbusTcpClient", return_value=mock_sync ), mock.patch( @@ -67,8 +68,11 @@ async def base_test( "homeassistant.components.modbus.modbus.ModbusUdpClient", return_value=mock_sync ): - # Setup inputs for the sensor - read_result = ReadResult(register_words) + read_result = ( + register_words_or_exception + if isinstance(register_words_or_exception, Exception) + else ReadResult(register_words_or_exception) + ) mock_sync.read_coils.return_value = read_result mock_sync.read_discrete_inputs.return_value = read_result mock_sync.read_input_registers.return_value = read_result @@ -118,7 +122,6 @@ async def base_test( async_fire_time_changed(hass, now) await hass.async_block_till_done() - # Check state entity_id = f"{entity_domain}.{device_name}" return hass.states.get(entity_id).state diff --git a/tests/components/modbus/test_modbus_bit_sensor.py b/tests/components/modbus/test_modbus_bit_sensor.py new file mode 100644 index 00000000000000..3630b796d4744f --- /dev/null +++ b/tests/components/modbus/test_modbus_bit_sensor.py @@ -0,0 +1,337 @@ +"""The tests for the Modbus sensor component.""" +from collections import namedtuple +from unittest import mock + +from pymodbus.exceptions import ConnectionException, ModbusException +import pytest + +from homeassistant.components.binary_sensor import DOMAIN as SENSOR_DOMAIN +from homeassistant.components.modbus.bit_sensor import ( + ModbusReadCache, + setup_bit_sensors, +) +from homeassistant.components.modbus.const import ( + CALL_TYPE_COIL, + CALL_TYPE_REGISTER_HOLDING, + CALL_TYPE_REGISTER_INPUT, + CONF_BIT_NUMBER, + CONF_BIT_SENSORS, + CONF_INPUT_TYPE, + CONF_INPUTS, +) +from homeassistant.components.modbus.modbus import ModbusHub +from homeassistant.const import ( + CONF_ADDRESS, + CONF_COUNT, + CONF_DEVICE_CLASS, + CONF_NAME, + CONF_SLAVE, + STATE_OFF, + STATE_ON, + STATE_UNAVAILABLE, +) +from homeassistant.helpers.restore_state import RestoreEntity + +from .conftest import base_config_test, base_test + + +@pytest.mark.parametrize( + "discovery_info,call_count", + [ + (None, 0), + ( + { + CONF_BIT_SENSORS: [ + {CONF_COUNT: 1, CONF_BIT_NUMBER: 16, CONF_NAME: "test"} + ] + }, + 1, + ), + ], +) +@mock.patch("homeassistant.components.modbus.bit_sensor._LOGGER") +def test_setup_bit_sensors(mock_logger, discovery_info, call_count): + """Test setup bit sensor.""" + setup_bit_sensors(mock.MagicMock(), discovery_info) + assert mock_logger.error.call_count == call_count + + +@pytest.mark.parametrize( + "method_discovery, do_config", + [ + ( + True, + { + CONF_ADDRESS: 51, + CONF_BIT_NUMBER: 5, + }, + ), + ( + True, + { + CONF_ADDRESS: 51, + CONF_COUNT: 1, + CONF_BIT_NUMBER: 17, + }, + ), + ( + True, + { + CONF_ADDRESS: 51, + CONF_BIT_NUMBER: 5, + CONF_SLAVE: 10, + CONF_COUNT: 1, + CONF_INPUT_TYPE: CALL_TYPE_REGISTER_HOLDING, + CONF_DEVICE_CLASS: "battery", + }, + ), + ( + True, + { + CONF_ADDRESS: 51, + CONF_BIT_NUMBER: 5, + CONF_SLAVE: 10, + CONF_COUNT: 1, + CONF_INPUT_TYPE: CALL_TYPE_REGISTER_INPUT, + CONF_DEVICE_CLASS: "battery", + }, + ), + ( + False, + { + CONF_ADDRESS: 51, + CONF_INPUT_TYPE: CALL_TYPE_COIL, + CONF_SLAVE: 10, + CONF_DEVICE_CLASS: "battery", + }, + ), + ], +) +async def test_config_sensor(hass, method_discovery, do_config): + """Run test for sensor.""" + sensor_name = "test_sensor" + config_sensor = { + CONF_NAME: sensor_name, + **do_config, + } + await base_config_test( + hass, + config_sensor, + sensor_name, + SENSOR_DOMAIN, + CONF_BIT_SENSORS, + CONF_INPUTS, + method_discovery=method_discovery, + ) + + +@pytest.mark.parametrize( + "cfg,regs,expected", + [ + ( + {}, + [0], + STATE_OFF, + ), + ( + {}, + ModbusException("Modbus Exception"), + STATE_UNAVAILABLE, + ), + ( + {}, + [0x20], + STATE_ON, + ), + ( + {}, + [0xFF], + STATE_ON, + ), + ( + {}, + [0xDF], + STATE_OFF, + ), + ( + {CONF_BIT_NUMBER: 15}, + [0x8000], + STATE_ON, + ), + ( + {CONF_BIT_NUMBER: 15}, + [0x7FFF], + STATE_OFF, + ), + ( + {CONF_BIT_NUMBER: 31, CONF_COUNT: 2}, + [0x0000, 0x8000], + STATE_ON, + ), + ( + {CONF_BIT_NUMBER: 63, CONF_COUNT: 4}, + [0x0000, 0x0000, 0x0000, 0x8000], + STATE_ON, + ), + ( + {CONF_BIT_NUMBER: 28, CONF_COUNT: 4}, + [0x0000, 0x1000, 0x0000, 0x0000], + STATE_ON, + ), + ], +) +async def test_all_bit_sensor(hass, cfg, regs, expected): + """Run test for sensor.""" + sensor_name = "modbus_test_sensor" + state = await base_test( + hass, + { + CONF_NAME: sensor_name, + CONF_ADDRESS: 1234, + CONF_BIT_NUMBER: 5, + **cfg, + }, + sensor_name, + SENSOR_DOMAIN, + CONF_BIT_SENSORS, + CONF_INPUTS, + regs, + expected, + method_discovery=True, + scan_interval=5, + ) + assert state == expected + + +Registers = namedtuple("Registers", ["registers"]) + + +@pytest.mark.parametrize( + "read_data,last_state,expected", + [ + (ConnectionException("Modbus Exception"), STATE_ON, STATE_UNAVAILABLE), + ( + lambda _1, _2, _3: Registers([0x00]), + STATE_ON, + STATE_OFF, + ), + ( + lambda _1, _2, _3: Registers([0xFF]), + STATE_OFF, + STATE_ON, + ), + ], +) +@mock.patch.object(ModbusHub, "read_holding_registers") +async def test_register_sensor_last_state( + mock_read, hass, read_data, last_state, expected +): + """Run test for given config.""" + mock_read.side_effect = read_data + switch_name = "modbus_test_switch" + + with mock.patch.object(RestoreEntity, "async_get_last_state") as last_state_mock: + last_state_mock.return_value = mock.MagicMock() + last_state_mock.return_value.state = last_state + state = await base_test( + hass, + { + CONF_NAME: switch_name, + CONF_ADDRESS: 1234, + CONF_BIT_NUMBER: 5, + }, + switch_name, + SENSOR_DOMAIN, + CONF_BIT_SENSORS, + CONF_INPUTS, + [0x00], + STATE_UNAVAILABLE, + method_discovery=True, + scan_interval=5, + ) + assert state == expected + + +@pytest.fixture +def hub(): + """Hub fixture.""" + return mock.MagicMock() + + +@pytest.fixture +def cache(hub): + """Cache fixture.""" + return ModbusReadCache(hub) + + +@mock.patch("time.time", return_value=1) +def test_consecutive_calls(_, cache, hub): + """Test consecutive calls reads only once.""" + + # First register read, put the value in the cache + hub.read_holding_registers.return_value = [0] + assert cache.read_holding_registers(50, 70, 1) == [0] + + # update an actual value after the first read + hub.read_holding_registers.return_value = [1] + + # should keep reading old value from the cache + assert cache.read_holding_registers(50, 70, 1) == [0] + assert cache.read_holding_registers(50, 70, 1) == [0] + + # underlying hub.read_holding_registers should be called only once + assert hub.read_holding_registers.call_count == 1 + # .. with the correct input arguments + hub.read_holding_registers.assert_called_once_with(50, 70, 1) + + # make a second call with the extra named argument, read the updated value + assert cache.read_holding_registers(50, 70, 1, extra=1) == [1] + + # expect 2 calls to the hub method + assert hub.read_holding_registers.call_count == 2 + hub.read_holding_registers.assert_called_with(50, 70, 1, extra=1) + + +@mock.patch("time.time") +def test_cache_expire_in_one_second(mock_time, cache, hub): + """Test consecutive reads made one second apart ignore the cache.""" + hub.read_holding_registers.return_value = [0] + + current_time = 1615633800.799 + mock_time.return_value = current_time + + assert cache.read_holding_registers(50, 70, 1) == [0] + + # update an actual value after the first read + hub.read_holding_registers.return_value = [1] + assert cache.read_holding_registers(50, 70, 1) == [0] + + # read at the same time, should use cached value + assert hub.read_holding_registers.call_count == 1 + + # advance current time to 200ms, should still use cached value + mock_time.return_value = current_time + 0.2 + assert cache.read_holding_registers(50, 70, 1) == [0] + assert cache.read_holding_registers(50, 70, 1) == [0] + assert hub.read_holding_registers.call_count == 1 + + # advance current time to 1000ms, should read a new value from the hub + mock_time.return_value = current_time + 1.0 + assert cache.read_holding_registers(50, 70, 1) == [1] + assert hub.read_holding_registers.call_count == 2 + + +def test_pass_through_non_cached(cache, hub): + """Test non cached calls works as usual.""" + hub.write_holding_registers.return_value = [0] + assert cache.write_holding_registers(50, 70, 1) == [0] + + hub.write_holding_registers.return_value = [1] + assert cache.write_holding_registers(50, 70, 1) == [1] + + hub.write_holding_registers.return_value = [2] + assert cache.write_holding_registers(50, 70, 1) == [2] + + # no caching involved + assert hub.write_holding_registers.call_count == 3 + hub.write_holding_registers.assert_called_with(50, 70, 1) diff --git a/tests/components/modbus/test_modbus_bit_switch.py b/tests/components/modbus/test_modbus_bit_switch.py new file mode 100644 index 00000000000000..9d25f6d388441c --- /dev/null +++ b/tests/components/modbus/test_modbus_bit_switch.py @@ -0,0 +1,436 @@ +"""The tests for the Modbus switch component.""" +from collections import namedtuple +from datetime import timedelta +from unittest import mock + +from pymodbus.exceptions import ConnectionException, ModbusException +import pytest + +from homeassistant.components.modbus.bit_switch import setup_bit_swithes +from homeassistant.components.modbus.const import ( + CALL_TYPE_REGISTER_HOLDING, + CALL_TYPE_REGISTER_INPUT, + CONF_BIT_SWITCHES, + CONF_COMMAND_BIT_NUMBER, + CONF_INPUT_TYPE, + CONF_REGISTER, + CONF_REGISTER_TYPE, + CONF_REGISTERS, + CONF_STATUS_BIT_NUMBER, + CONF_VERIFY_REGISTER, + CONF_VERIFY_STATE, +) +from homeassistant.components.modbus.modbus import ModbusHub +from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN +from homeassistant.const import ( + ATTR_ENTITY_ID, + CONF_ADDRESS, + CONF_COMMAND_OFF, + CONF_COMMAND_ON, + CONF_DEVICE_CLASS, + CONF_NAME, + CONF_SLAVE, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, + STATE_OFF, + STATE_ON, + STATE_UNAVAILABLE, +) +from homeassistant.helpers.restore_state import RestoreEntity +import homeassistant.util.dt as dt_util + +from .conftest import base_config_test, base_test + +from tests.common import async_fire_time_changed + + +@pytest.mark.parametrize( + "discovery_info,call_count", + [(None, 0)], +) +@mock.patch("homeassistant.components.modbus.bit_switch._LOGGER") +def test_setup_bit_swithes(mock_logger, discovery_info, call_count): + """Test setup bit switch.""" + setup_bit_swithes(mock.MagicMock(), discovery_info) + assert mock_logger.error.call_count == call_count + + +@pytest.mark.parametrize( + "array_type,do_config", + [ + ( + None, + { + CONF_ADDRESS: 1234, + CONF_COMMAND_BIT_NUMBER: 5, + }, + ), + ( + None, + { + CONF_ADDRESS: 1234, + CONF_INPUT_TYPE: CALL_TYPE_REGISTER_HOLDING, + CONF_COMMAND_BIT_NUMBER: 5, + }, + ), + ( + None, + { + CONF_ADDRESS: 1234, + CONF_SLAVE: 1, + CONF_VERIFY_REGISTER: 1235, + CONF_VERIFY_STATE: False, + CONF_COMMAND_BIT_NUMBER: 5, + CONF_DEVICE_CLASS: "switch", + CONF_INPUT_TYPE: CALL_TYPE_REGISTER_HOLDING, + }, + ), + ( + None, + { + CONF_ADDRESS: 1234, + CONF_SLAVE: 1, + CONF_COMMAND_BIT_NUMBER: 5, + CONF_VERIFY_REGISTER: 1235, + CONF_VERIFY_STATE: True, + CONF_STATUS_BIT_NUMBER: 6, + CONF_DEVICE_CLASS: "switch", + CONF_INPUT_TYPE: CALL_TYPE_REGISTER_INPUT, + }, + ), + ( + None, + { + CONF_ADDRESS: 1234, + CONF_INPUT_TYPE: CALL_TYPE_REGISTER_INPUT, + CONF_COMMAND_BIT_NUMBER: 5, + CONF_SLAVE: 1, + CONF_DEVICE_CLASS: "switch", + }, + ), + ( + CONF_REGISTERS, + { + CONF_REGISTER: 1234, + CONF_REGISTER_TYPE: CALL_TYPE_REGISTER_INPUT, + CONF_SLAVE: 1, + CONF_COMMAND_OFF: 0x00, + CONF_COMMAND_ON: 0x01, + }, + ), + ], +) +async def test_config_switch(hass, array_type, do_config): + """Run test for switch.""" + device_name = "test_switch" + + device_config = { + CONF_NAME: device_name, + **do_config, + } + + await base_config_test( + hass, + device_config, + device_name, + SWITCH_DOMAIN, + CONF_BIT_SWITCHES, + array_type, + method_discovery=(array_type is None), + ) + + +@pytest.mark.parametrize( + "regs,expected", + [ + ( + [0x00], + STATE_OFF, + ), + ( + [0x80], + STATE_OFF, + ), + ( + [0xFE], + STATE_ON, + ), + ( + [0xFF], + STATE_ON, + ), + ( + [0x01], + STATE_OFF, + ), + ( + [0x20], + STATE_ON, + ), + (ModbusException("Modbus Exception"), STATE_UNAVAILABLE), + (ConnectionException("Modbus Exception"), STATE_UNAVAILABLE), + ], +) +async def test_register_switch(hass, regs, expected): + """Run test for given config.""" + switch_name = "modbus_test_switch" + state = await base_test( + hass, + { + CONF_NAME: switch_name, + CONF_INPUT_TYPE: CALL_TYPE_REGISTER_INPUT, + CONF_ADDRESS: 1234, + CONF_SLAVE: 1, + CONF_COMMAND_BIT_NUMBER: 5, + }, + switch_name, + SWITCH_DOMAIN, + CONF_BIT_SWITCHES, + CONF_REGISTERS, + regs, + expected, + method_discovery=True, + scan_interval=5, + ) + assert state == expected + + +WriteRegisterSuccess = lambda *_: True # noqa: E731 +WriteRegisterFailure = ConnectionException("write_register failed") + + +@pytest.mark.parametrize( + "input_type,regs,service,verify_state,write_side_effect,expected", + [ + # Write register succeeded, optimistically set state to ON and schedule update + ( + CALL_TYPE_REGISTER_HOLDING, + [0x00], + SERVICE_TURN_ON, + True, + WriteRegisterSuccess, + STATE_ON, + ), + # Write register failed, schedule state update and return unavailable state + ( + CALL_TYPE_REGISTER_HOLDING, + [0x00], + SERVICE_TURN_ON, + True, + WriteRegisterFailure, + STATE_UNAVAILABLE, + ), + # CONF_VERIFY_STATE False immediately update the state + ( + CALL_TYPE_REGISTER_HOLDING, + [0x00], + SERVICE_TURN_ON, + False, + WriteRegisterSuccess, + STATE_ON, + ), + ( + CALL_TYPE_REGISTER_HOLDING, + [0x41], + SERVICE_TURN_ON, + # 0x41 yields OFF state since CONF_STATUS_BIT_NUMBER is 5 + # however, SERVICE_TURN_ON set bit 6 which is ON already + True, + WriteRegisterSuccess, + STATE_ON, + ), + ( + CALL_TYPE_REGISTER_HOLDING, + [0x60], + SERVICE_TURN_OFF, + True, + WriteRegisterSuccess, + STATE_OFF, + ), + ( + CALL_TYPE_REGISTER_HOLDING, + [0x60], + SERVICE_TURN_OFF, + False, + WriteRegisterSuccess, + STATE_OFF, + ), + # Verify state is off, can turn state off for the CALL_TYPE_REGISTER_INPUT + # Since CALL_TYPE_REGISTER_INPUT does not call write_register, WriteRegisterFailure should + # not affect the state + ( + CALL_TYPE_REGISTER_INPUT, + [0xFF], + SERVICE_TURN_OFF, + False, + WriteRegisterFailure, + STATE_OFF, + ), + # Verify state is on, can not turn the state off + ( + CALL_TYPE_REGISTER_INPUT, + [0xFF], + SERVICE_TURN_OFF, + True, + WriteRegisterFailure, + STATE_ON, + ), + ( + CALL_TYPE_REGISTER_INPUT, + [0x00], + SERVICE_TURN_ON, + False, + WriteRegisterFailure, + STATE_OFF, + ), + ( + CALL_TYPE_REGISTER_INPUT, + [0x00], + SERVICE_TURN_ON, + True, + WriteRegisterFailure, + STATE_OFF, + ), + ( + CALL_TYPE_REGISTER_HOLDING, + ModbusException("Modbus Exception"), + SERVICE_TURN_OFF, + True, + WriteRegisterSuccess, + STATE_UNAVAILABLE, + ), + ], +) +async def test_register_switch_service( + hass, input_type, regs, service, verify_state, write_side_effect, expected +): + """Run test for given config.""" + switch_name = "modbus_test_switch" + with mock.patch.object(ModbusHub, "write_register") as mock_write: + mock_write.side_effect = write_side_effect + await base_test( + hass, + { + CONF_NAME: switch_name, + CONF_INPUT_TYPE: input_type, + CONF_ADDRESS: 1234, + CONF_SLAVE: 1, + CONF_COMMAND_BIT_NUMBER: 6, + CONF_STATUS_BIT_NUMBER: 5, + CONF_VERIFY_STATE: verify_state, + }, + switch_name, + SWITCH_DOMAIN, + CONF_BIT_SWITCHES, + CONF_REGISTERS, + regs, + expected, + method_discovery=True, + scan_interval=5, + ) + + entity_id = f"{SWITCH_DOMAIN}.{switch_name}" + await hass.services.async_call( + SWITCH_DOMAIN, + service, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + + assert hass.states.get(entity_id).state == expected + + +Registers = namedtuple("Registers", ["registers"]) + + +@pytest.mark.parametrize( + "read_data,last_state,init_expected,expected", + [ + ( + [ + ConnectionException("Modbus Exception"), + ConnectionException("Modbus Exception"), + ], + STATE_ON, + STATE_UNAVAILABLE, + STATE_UNAVAILABLE, + ), + ( + [ + lambda _1, _2, _3: Registers([0x00]), + lambda _1, _2, _3: Registers([0x00]), + ], + STATE_ON, + STATE_OFF, + STATE_OFF, + ), + ( + [ + lambda _1, _2, _3: Registers([0xFF]), + lambda _1, _2, _3: Registers([0x00]), + ], + STATE_ON, + STATE_ON, + STATE_OFF, + ), + ( + [ + lambda _1, _2, _3: Registers([0x00]), + lambda _1, _2, _3: Registers([0x00]), + ], + STATE_OFF, + STATE_OFF, + STATE_OFF, + ), + ], +) +@mock.patch.object(ModbusHub, "read_holding_registers") +async def test_register_switch_last_state( + mock_read, hass, read_data, last_state, init_expected, expected +): + """Run test for given config.""" + mock_read.side_effect = read_data[0] + switch_name = "modbus_test_switch" + scan_interval = 1 + with mock.patch.object(RestoreEntity, "async_get_last_state") as last_state_mock: + last_state_mock.return_value = mock.MagicMock() + last_state_mock.return_value.state = last_state + + state = await base_test( + hass, + { + CONF_NAME: switch_name, + CONF_INPUT_TYPE: CALL_TYPE_REGISTER_HOLDING, + CONF_ADDRESS: 1234, + CONF_SLAVE: 1, + CONF_COMMAND_BIT_NUMBER: 6, + CONF_STATUS_BIT_NUMBER: 5, + CONF_VERIFY_STATE: True, + }, + switch_name, + SWITCH_DOMAIN, + CONF_BIT_SWITCHES, + CONF_REGISTERS, + [0x00], + STATE_UNAVAILABLE, + method_discovery=True, + scan_interval=scan_interval, + ) + assert state == init_expected, "Init state mismatch" + + mock_read.side_effect = read_data[1] + + entity_id = f"{SWITCH_DOMAIN}.{switch_name}" + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + now = dt_util.utcnow() + now = now + timedelta(seconds=scan_interval + 120) + with mock.patch("homeassistant.helpers.event.dt_util.utcnow", return_value=now): + async_fire_time_changed(hass, now) + await hass.async_block_till_done() + + assert hass.states.get(entity_id).state == expected