diff --git a/homeassistant/components/melnor/__init__.py b/homeassistant/components/melnor/__init__.py index 5fd697b2088..433380a9ab9 100644 --- a/homeassistant/components/melnor/__init__.py +++ b/homeassistant/components/melnor/__init__.py @@ -14,7 +14,10 @@ from homeassistant.exceptions import ConfigEntryNotReady from .const import DOMAIN from .models import MelnorDataUpdateCoordinator -PLATFORMS: list[Platform] = [Platform.SWITCH] +PLATFORMS: list[Platform] = [ + Platform.SENSOR, + Platform.SWITCH, +] async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: diff --git a/homeassistant/components/melnor/models.py b/homeassistant/components/melnor/models.py index 4796bf601ff..c050c7f680b 100644 --- a/homeassistant/components/melnor/models.py +++ b/homeassistant/components/melnor/models.py @@ -43,6 +43,7 @@ class MelnorBluetoothBaseEntity(CoordinatorEntity[MelnorDataUpdateCoordinator]): """Base class for melnor entities.""" _device: Device + _attr_has_entity_name = True def __init__( self, @@ -59,8 +60,6 @@ class MelnorBluetoothBaseEntity(CoordinatorEntity[MelnorDataUpdateCoordinator]): model=self._device.model, name=self._device.name, ) - self._attr_name = self._device.name - self._attr_unique_id = self._device.mac @callback def _handle_coordinator_update(self) -> None: diff --git a/homeassistant/components/melnor/sensor.py b/homeassistant/components/melnor/sensor.py new file mode 100644 index 00000000000..567f9dc6f2c --- /dev/null +++ b/homeassistant/components/melnor/sensor.py @@ -0,0 +1,101 @@ +"""Support for Melnor RainCloud sprinkler water timer.""" +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from typing import Any + +from melnor_bluetooth.device import Device + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import PERCENTAGE, SIGNAL_STRENGTH_DECIBELS_MILLIWATT +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity import EntityCategory +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import StateType + +from .const import DOMAIN +from .models import MelnorBluetoothBaseEntity, MelnorDataUpdateCoordinator + + +@dataclass +class MelnorSensorEntityDescriptionMixin: + """Mixin for required keys.""" + + state_fn: Callable[[Device], Any] + + +@dataclass +class MelnorSensorEntityDescription( + SensorEntityDescription, MelnorSensorEntityDescriptionMixin +): + """Describes Melnor sensor entity.""" + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_devices: AddEntitiesCallback, +) -> None: + """Set up the sensor platform.""" + + coordinator: MelnorDataUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id] + + sensors: list[MelnorSensorEntityDescription] = [ + MelnorSensorEntityDescription( + device_class=SensorDeviceClass.BATTERY, + entity_category=EntityCategory.DIAGNOSTIC, + key="battery", + name="Battery", + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + state_fn=lambda device: device.battery_level, + ), + MelnorSensorEntityDescription( + device_class=SensorDeviceClass.SIGNAL_STRENGTH, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + key="rssi", + name="RSSI", + native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, + state_class=SensorStateClass.MEASUREMENT, + state_fn=lambda device: device.rssi, + ), + ] + + async_add_devices( + MelnorSensorEntity( + coordinator, + description, + ) + for description in sensors + ) + + +class MelnorSensorEntity(MelnorBluetoothBaseEntity, SensorEntity): + """Representation of a Melnor sensor.""" + + entity_description: MelnorSensorEntityDescription + + def __init__( + self, + coordinator: MelnorDataUpdateCoordinator, + entity_description: MelnorSensorEntityDescription, + ) -> None: + """Initialize a sensor for a Melnor device.""" + super().__init__(coordinator) + + self._attr_unique_id = f"{self._device.mac}-{entity_description.key}" + + self.entity_description = entity_description + + @property + def native_value(self) -> StateType: + """Return the battery level.""" + return self.entity_description.state_fn(self._device) diff --git a/homeassistant/components/melnor/switch.py b/homeassistant/components/melnor/switch.py index 7a615a8582d..125d3ffde8c 100644 --- a/homeassistant/components/melnor/switch.py +++ b/homeassistant/components/melnor/switch.py @@ -30,13 +30,12 @@ async def async_setup_entry( if coordinator.data[f"zone{i}"] is not None: switches.append(MelnorSwitch(coordinator, i)) - async_add_devices(switches, True) + async_add_devices(switches) class MelnorSwitch(MelnorBluetoothBaseEntity, SwitchEntity): """A switch implementation for a melnor device.""" - _valve_index: int _attr_icon = "mdi:sprinkler" def __init__( @@ -48,8 +47,9 @@ class MelnorSwitch(MelnorBluetoothBaseEntity, SwitchEntity): super().__init__(coordinator) self._valve_index = valve_index - self._attr_unique_id = f"{self._attr_unique_id}-zone{self._valve().id}-manual" - self._attr_name = f"{self._device.name} Zone {self._valve().id+1}" + valve_id = self._valve().id + self._attr_name = f"Zone {valve_id+1}" + self._attr_unique_id = f"{self._device.mac}-zone{valve_id}-manual" @property def is_on(self) -> bool: diff --git a/tests/components/melnor/__init__.py b/tests/components/melnor/__init__.py index 7af59d55a11..7f460e7848d 100644 --- a/tests/components/melnor/__init__.py +++ b/tests/components/melnor/__init__.py @@ -1,64 +1 @@ """Tests for the melnor integration.""" - -from __future__ import annotations - -from unittest.mock import patch - -from bleak.backends.device import BLEDevice -from bleak.backends.scanner import AdvertisementData - -from homeassistant.components.bluetooth.models import BluetoothServiceInfoBleak - -FAKE_ADDRESS_1 = "FAKE-ADDRESS-1" -FAKE_ADDRESS_2 = "FAKE-ADDRESS-2" - - -FAKE_SERVICE_INFO_1 = BluetoothServiceInfoBleak( - name="YM_TIMER%", - address=FAKE_ADDRESS_1, - rssi=-63, - manufacturer_data={ - 13: b"Y\x08\x02\x8f\x00\x00\x00\x00\x00\x00\xf0\x00\x00\xf0\x00\x00\xf0\x00\x00\xf0*\x9b\xcf\xbc" - }, - service_uuids=[], - service_data={}, - source="local", - device=BLEDevice(FAKE_ADDRESS_1, None), - advertisement=AdvertisementData(local_name=""), - time=0, - connectable=True, -) - -FAKE_SERVICE_INFO_2 = BluetoothServiceInfoBleak( - name="YM_TIMER%", - address=FAKE_ADDRESS_2, - rssi=-63, - manufacturer_data={ - 13: b"Y\x08\x02\x8f\x00\x00\x00\x00\x00\x00\xf0\x00\x00\xf0\x00\x00\xf0\x00\x00\xf0*\x9b\xcf\xbc" - }, - service_uuids=[], - service_data={}, - source="local", - device=BLEDevice(FAKE_ADDRESS_2, None), - advertisement=AdvertisementData(local_name=""), - time=0, - connectable=True, -) - - -def patch_async_setup_entry(return_value=True): - """Patch async setup entry to return True.""" - return patch( - "homeassistant.components.melnor.async_setup_entry", - return_value=return_value, - ) - - -def patch_async_discovered_service_info( - return_value: list[BluetoothServiceInfoBleak] = [FAKE_SERVICE_INFO_1], -): - """Patch async_discovered_service_info a mocked device info.""" - return patch( - "homeassistant.components.melnor.config_flow.async_discovered_service_info", - return_value=return_value, - ) diff --git a/tests/components/melnor/conftest.py b/tests/components/melnor/conftest.py new file mode 100644 index 00000000000..403ae83bb67 --- /dev/null +++ b/tests/components/melnor/conftest.py @@ -0,0 +1,139 @@ +"""Tests for the melnor integration.""" + +from __future__ import annotations + +from unittest.mock import AsyncMock, Mock, patch + +from bleak.backends.device import BLEDevice +from bleak.backends.scanner import AdvertisementData +from melnor_bluetooth.device import Device, Valve + +from homeassistant.components.bluetooth.models import BluetoothServiceInfoBleak +from homeassistant.components.melnor.const import DOMAIN +from homeassistant.const import CONF_ADDRESS +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + +FAKE_ADDRESS_1 = "FAKE-ADDRESS-1" +FAKE_ADDRESS_2 = "FAKE-ADDRESS-2" + + +FAKE_SERVICE_INFO_1 = BluetoothServiceInfoBleak( + name="YM_TIMER%", + address=FAKE_ADDRESS_1, + rssi=-63, + manufacturer_data={ + 13: b"Y\x08\x02\x8f\x00\x00\x00\x00\x00\x00\xf0\x00\x00\xf0\x00\x00\xf0\x00\x00\xf0*\x9b\xcf\xbc" + }, + service_uuids=[], + service_data={}, + source="local", + device=BLEDevice(FAKE_ADDRESS_1, None), + advertisement=AdvertisementData(local_name=""), + time=0, + connectable=True, +) + +FAKE_SERVICE_INFO_2 = BluetoothServiceInfoBleak( + name="YM_TIMER%", + address=FAKE_ADDRESS_2, + rssi=-63, + manufacturer_data={ + 13: b"Y\x08\x02\x8f\x00\x00\x00\x00\x00\x00\xf0\x00\x00\xf0\x00\x00\xf0\x00\x00\xf0*\x9b\xcf\xbc" + }, + service_uuids=[], + service_data={}, + source="local", + device=BLEDevice(FAKE_ADDRESS_2, None), + advertisement=AdvertisementData(local_name=""), + time=0, + connectable=True, +) + + +def mock_config_entry(hass: HomeAssistant): + """Return a mock config entry.""" + + entry = MockConfigEntry( + domain=DOMAIN, + unique_id=FAKE_ADDRESS_1, + data={CONF_ADDRESS: FAKE_ADDRESS_1}, + ) + entry.add_to_hass(hass) + + return entry + + +def mock_melnor_valve(identifier: int): + """Return a mocked Melnor valve.""" + valve = Mock(spec=Valve) + valve.id = identifier + + return valve + + +def mock_melnor_device(): + """Return a mocked Melnor device.""" + + with patch("melnor_bluetooth.device.Device") as mock: + + device = mock.return_value + + device.connect = AsyncMock(return_value=True) + device.disconnect = AsyncMock(return_value=True) + device.fetch_state = AsyncMock(return_value=device) + + device.battery_level = 80 + device.mac = FAKE_ADDRESS_1 + device.model = "test_model" + device.name = "test_melnor" + device.rssi = -50 + + device.zone1 = mock_melnor_valve(1) + device.zone2 = mock_melnor_valve(2) + device.zone3 = mock_melnor_valve(3) + device.zone4 = mock_melnor_valve(4) + + device.__getitem__.side_effect = lambda key: getattr(device, key) + + return device + + +def patch_async_setup_entry(return_value=True): + """Patch async setup entry to return True.""" + return patch( + "homeassistant.components.melnor.async_setup_entry", + return_value=return_value, + ) + + +# pylint: disable=dangerous-default-value +def patch_async_discovered_service_info( + return_value: list[BluetoothServiceInfoBleak] = [FAKE_SERVICE_INFO_1], +): + """Patch async_discovered_service_info a mocked device info.""" + return patch( + "homeassistant.components.melnor.config_flow.async_discovered_service_info", + return_value=return_value, + ) + + +def patch_async_ble_device_from_address( + return_value: BluetoothServiceInfoBleak | None = FAKE_SERVICE_INFO_1, +): + """Patch async_ble_device_from_address to return a mocked BluetoothServiceInfoBleak.""" + return patch( + "homeassistant.components.bluetooth.async_ble_device_from_address", + return_value=return_value, + ) + + +def patch_melnor_device(device: Device = mock_melnor_device()): + """Patch melnor_bluetooth.device to return a mocked Melnor device.""" + return patch("homeassistant.components.melnor.Device", return_value=device) + + +def patch_async_register_callback(): + """Patch async_register_callback to return True.""" + return patch("homeassistant.components.bluetooth.async_register_callback") diff --git a/tests/components/melnor/test_config_flow.py b/tests/components/melnor/test_config_flow.py index 3b550fba3f7..364531a314a 100644 --- a/tests/components/melnor/test_config_flow.py +++ b/tests/components/melnor/test_config_flow.py @@ -8,7 +8,7 @@ from homeassistant.components.melnor.const import DOMAIN from homeassistant.const import CONF_ADDRESS, CONF_MAC from homeassistant.data_entry_flow import FlowResultType -from . import ( +from .conftest import ( FAKE_ADDRESS_1, FAKE_SERVICE_INFO_1, FAKE_SERVICE_INFO_2, diff --git a/tests/components/melnor/test_sensor.py b/tests/components/melnor/test_sensor.py new file mode 100644 index 00000000000..bef2bba35e0 --- /dev/null +++ b/tests/components/melnor/test_sensor.py @@ -0,0 +1,69 @@ +"""Test the Melnor sensors.""" + +from __future__ import annotations + +from homeassistant.components.sensor import SensorDeviceClass, SensorStateClass +from homeassistant.const import PERCENTAGE, SIGNAL_STRENGTH_DECIBELS_MILLIWATT +from homeassistant.helpers import entity_registry + +from .conftest import ( + mock_config_entry, + mock_melnor_device, + patch_async_ble_device_from_address, + patch_async_register_callback, + patch_melnor_device, +) + + +async def test_battery_sensor(hass): + """Test the battery sensor.""" + + entry = mock_config_entry(hass) + + with patch_async_ble_device_from_address(), patch_melnor_device(), patch_async_register_callback(): + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + battery_sensor = hass.states.get("sensor.test_melnor_battery") + assert battery_sensor.state == "80" + assert battery_sensor.attributes["unit_of_measurement"] == PERCENTAGE + assert battery_sensor.attributes["device_class"] == SensorDeviceClass.BATTERY + assert battery_sensor.attributes["state_class"] == SensorStateClass.MEASUREMENT + + +async def test_rssi_sensor(hass): + """Test the rssi sensor.""" + + entry = mock_config_entry(hass) + + device = mock_melnor_device() + + with patch_async_ble_device_from_address(), patch_melnor_device( + device + ), patch_async_register_callback(): + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + entity_id = f"sensor.{device.name}_rssi" + + # Ensure the entity is disabled by default by checking the registry + ent_registry = entity_registry.async_get(hass) + + rssi_registry_entry = ent_registry.async_get(entity_id) + + assert rssi_registry_entry is not None + assert rssi_registry_entry.disabled_by is not None + + # Enable the entity and assert everything else is working as expected + ent_registry.async_update_entity(entity_id, disabled_by=None) + + await hass.config_entries.async_reload(entry.entry_id) + await hass.async_block_till_done() + + rssi = hass.states.get(entity_id) + + assert ( + rssi.attributes["unit_of_measurement"] == SIGNAL_STRENGTH_DECIBELS_MILLIWATT + ) + assert rssi.attributes["device_class"] == SensorDeviceClass.SIGNAL_STRENGTH + assert rssi.attributes["state_class"] == SensorStateClass.MEASUREMENT