diff --git a/.coveragerc b/.coveragerc index 1b48888ac412..654e1a3e4d7f 100644 --- a/.coveragerc +++ b/.coveragerc @@ -722,6 +722,7 @@ omit = homeassistant/components/mjpeg/util.py homeassistant/components/mochad/* homeassistant/components/modbus/climate.py + homeassistant/components/modbus/binary_sensor.py homeassistant/components/modem_callerid/sensor.py homeassistant/components/moehlenhoff_alpha2/__init__.py homeassistant/components/moehlenhoff_alpha2/climate.py diff --git a/homeassistant/components/modbus/__init__.py b/homeassistant/components/modbus/__init__.py index 4d33e819a8f0..a5ad05a47119 100644 --- a/homeassistant/components/modbus/__init__.py +++ b/homeassistant/components/modbus/__init__.py @@ -74,6 +74,7 @@ from .const import ( CONF_RETRY_ON_EMPTY, CONF_REVERSE_ORDER, CONF_SCALE, + CONF_SLAVE_COUNT, CONF_STATE_CLOSED, CONF_STATE_CLOSING, CONF_STATE_OFF, @@ -270,6 +271,7 @@ BINARY_SENSOR_SCHEMA = BASE_COMPONENT_SCHEMA.extend( vol.Optional(CONF_INPUT_TYPE, default=CALL_TYPE_COIL): vol.In( [CALL_TYPE_COIL, CALL_TYPE_DISCRETE] ), + vol.Optional(CONF_SLAVE_COUNT, default=0): cv.positive_int, } ) diff --git a/homeassistant/components/modbus/binary_sensor.py b/homeassistant/components/modbus/binary_sensor.py index 07756b0f2078..f65d0ad03489 100644 --- a/homeassistant/components/modbus/binary_sensor.py +++ b/homeassistant/components/modbus/binary_sensor.py @@ -2,16 +2,31 @@ from __future__ import annotations from datetime import datetime +import logging +from typing import Any from homeassistant.components.binary_sensor import BinarySensorEntity -from homeassistant.const import CONF_BINARY_SENSORS, CONF_NAME, STATE_ON -from homeassistant.core import HomeAssistant +from homeassistant.const import ( + CONF_BINARY_SENSORS, + CONF_DEVICE_CLASS, + CONF_NAME, + STATE_ON, +) +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from homeassistant.helpers.update_coordinator import ( + CoordinatorEntity, + DataUpdateCoordinator, +) from . import get_hub from .base_platform import BasePlatform +from .const import CONF_SLAVE_COUNT +from .modbus import ModbusHub + +_LOGGER = logging.getLogger(__name__) PARALLEL_UPDATES = 1 @@ -23,21 +38,51 @@ async def async_setup_platform( discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the Modbus binary sensors.""" - sensors = [] if discovery_info is None: # pragma: no cover return + sensors: list[ModbusBinarySensor | SlaveSensor] = [] + hub = get_hub(hass, discovery_info[CONF_NAME]) for entry in discovery_info[CONF_BINARY_SENSORS]: - hub = get_hub(hass, discovery_info[CONF_NAME]) - sensors.append(ModbusBinarySensor(hub, entry)) - + slave_count = entry.get(CONF_SLAVE_COUNT, 0) + sensor = ModbusBinarySensor(hub, entry, slave_count) + if slave_count > 0: + sensors.extend(await sensor.async_setup_slaves(hass, slave_count, entry)) + sensors.append(sensor) async_add_entities(sensors) class ModbusBinarySensor(BasePlatform, RestoreEntity, BinarySensorEntity): """Modbus binary sensor.""" + def __init__(self, hub: ModbusHub, entry: dict[str, Any], slave_count: int) -> None: + """Initialize the Modbus binary sensor.""" + self._count = slave_count + 1 + self._coordinator: DataUpdateCoordinator[Any] | None = None + self._result = None + super().__init__(hub, entry) + + async def async_setup_slaves( + self, hass: HomeAssistant, slave_count: int, entry: dict[str, Any] + ) -> list[SlaveSensor]: + """Add slaves as needed (1 read for multiple sensors).""" + + # Add a dataCoordinator for each sensor that have slaves + # this ensures that idx = bit position of value in result + # polling is done with the base class + name = self._attr_name if self._attr_name else "modbus_sensor" + self._coordinator = DataUpdateCoordinator( + hass, + _LOGGER, + name=name, + ) + + slaves: list[SlaveSensor] = [] + for idx in range(0, slave_count): + slaves.append(SlaveSensor(self._coordinator, idx, entry)) + return slaves + async def async_added_to_hass(self) -> None: """Handle entity which will be added.""" await self.async_base_added_to_hass() @@ -52,7 +97,7 @@ class ModbusBinarySensor(BasePlatform, RestoreEntity, BinarySensorEntity): return self._call_active = True result = await self._hub.async_pymodbus_call( - self._slave, self._address, 1, self._input_type + self._slave, self._address, self._count, self._input_type ) self._call_active = False if result is None: @@ -61,10 +106,44 @@ class ModbusBinarySensor(BasePlatform, RestoreEntity, BinarySensorEntity): return self._lazy_errors = self._lazy_error_count self._attr_available = False - self.async_write_ha_state() - return + self._result = None + else: + self._lazy_errors = self._lazy_error_count + self._attr_is_on = result.bits[0] & 1 + self._attr_available = True + self._result = result - self._lazy_errors = self._lazy_error_count - self._attr_is_on = result.bits[0] & 1 - self._attr_available = True self.async_write_ha_state() + if self._coordinator: + self._coordinator.async_set_updated_data(self._result) + + +class SlaveSensor(CoordinatorEntity, RestoreEntity, BinarySensorEntity): + """Modbus slave binary sensor.""" + + def __init__( + self, coordinator: DataUpdateCoordinator[Any], idx: int, entry: dict[str, Any] + ) -> None: + """Initialize the Modbus binary sensor.""" + idx += 1 + self._attr_name = f"{entry[CONF_NAME]}_{idx}" + self._attr_device_class = entry.get(CONF_DEVICE_CLASS) + self._attr_available = False + self._result_inx = int(idx / 8) + self._result_bit = 2 ** (idx % 8) + super().__init__(coordinator) + + async def async_added_to_hass(self) -> None: + """Handle entity which will be added.""" + if state := await self.async_get_last_state(): + self._attr_is_on = state.state == STATE_ON + self.async_write_ha_state() + await super().async_added_to_hass() + + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + result = self.coordinator.data + if result: + self._attr_is_on = result.bits[self._result_inx] & self._result_bit + super()._handle_coordinator_update() diff --git a/homeassistant/components/modbus/const.py b/homeassistant/components/modbus/const.py index d4f0fa6d9eae..934d14012f81 100644 --- a/homeassistant/components/modbus/const.py +++ b/homeassistant/components/modbus/const.py @@ -37,6 +37,7 @@ CONF_RETRY_ON_EMPTY = "retry_on_empty" CONF_REVERSE_ORDER = "reverse_order" CONF_PRECISION = "precision" CONF_SCALE = "scale" +CONF_SLAVE_COUNT = "slave_count" CONF_STATE_CLOSED = "state_closed" CONF_STATE_CLOSING = "state_closing" CONF_STATE_OFF = "state_off" diff --git a/tests/components/modbus/test_binary_sensor.py b/tests/components/modbus/test_binary_sensor.py index 5127bd55ad1c..fbe0003b78a7 100644 --- a/tests/components/modbus/test_binary_sensor.py +++ b/tests/components/modbus/test_binary_sensor.py @@ -7,6 +7,7 @@ from homeassistant.components.modbus.const import ( CALL_TYPE_DISCRETE, CONF_INPUT_TYPE, CONF_LAZY_ERROR, + CONF_SLAVE_COUNT, ) from homeassistant.const import ( CONF_ADDRESS, @@ -188,9 +189,17 @@ async def test_service_binary_sensor_update(hass, mock_modbus, mock_ha): assert hass.states.get(ENTITY_ID).state == STATE_ON +ENTITY_ID2 = f"{ENTITY_ID}_1" + + @pytest.mark.parametrize( "mock_test_state", - [(State(ENTITY_ID, STATE_ON),)], + [ + ( + State(ENTITY_ID, STATE_ON), + State(ENTITY_ID2, STATE_OFF), + ) + ], indirect=True, ) @pytest.mark.parametrize( @@ -202,6 +211,7 @@ async def test_service_binary_sensor_update(hass, mock_modbus, mock_ha): CONF_NAME: TEST_ENTITY_NAME, CONF_ADDRESS: 51, CONF_SCAN_INTERVAL: 0, + CONF_SLAVE_COUNT: 1, } ] }, @@ -210,3 +220,100 @@ async def test_service_binary_sensor_update(hass, mock_modbus, mock_ha): async def test_restore_state_binary_sensor(hass, mock_test_state, mock_modbus): """Run test for binary sensor restore state.""" assert hass.states.get(ENTITY_ID).state == mock_test_state[0].state + assert hass.states.get(ENTITY_ID2).state == mock_test_state[1].state + + +TEST_NAME = "test_sensor" + + +@pytest.mark.parametrize( + "do_config", + [ + { + CONF_BINARY_SENSORS: [ + { + CONF_NAME: TEST_ENTITY_NAME, + CONF_ADDRESS: 51, + CONF_SLAVE_COUNT: 3, + } + ] + }, + ], +) +async def test_config_slave_binary_sensor(hass, mock_modbus): + """Run config test for binary sensor.""" + assert SENSOR_DOMAIN in hass.config.components + + for addon in ["", "_1", "_2", "_3"]: + entity_id = f"{SENSOR_DOMAIN}.{TEST_ENTITY_NAME}{addon}" + assert hass.states.get(entity_id) is not None + + +@pytest.mark.parametrize( + "do_config", + [ + { + CONF_BINARY_SENSORS: [ + { + CONF_NAME: TEST_ENTITY_NAME, + CONF_ADDRESS: 51, + CONF_SLAVE_COUNT: 8, + } + ] + }, + ], +) +@pytest.mark.parametrize( + "register_words,expected, slaves", + [ + ( + [0x01, 0x00], + STATE_ON, + [ + STATE_OFF, + STATE_OFF, + STATE_OFF, + STATE_OFF, + STATE_OFF, + STATE_OFF, + STATE_OFF, + STATE_OFF, + ], + ), + ( + [0x02, 0x00], + STATE_OFF, + [ + STATE_ON, + STATE_OFF, + STATE_OFF, + STATE_OFF, + STATE_OFF, + STATE_OFF, + STATE_OFF, + STATE_OFF, + ], + ), + ( + [0x01, 0x01], + STATE_ON, + [ + STATE_OFF, + STATE_OFF, + STATE_OFF, + STATE_OFF, + STATE_OFF, + STATE_OFF, + STATE_OFF, + STATE_ON, + ], + ), + ], +) +async def test_slave_binary_sensor(hass, expected, slaves, mock_do_cycle): + """Run test for given config.""" + assert hass.states.get(ENTITY_ID).state == expected + + for i in range(8): + entity_id = f"{SENSOR_DOMAIN}.{TEST_ENTITY_NAME}_{i+1}" + assert hass.states.get(entity_id).state == slaves[i]