diff --git a/.coveragerc b/.coveragerc index 2e5b3c96b41..7fded7dd8b2 100644 --- a/.coveragerc +++ b/.coveragerc @@ -728,7 +728,6 @@ omit = homeassistant/components/melnor/__init__.py homeassistant/components/melnor/const.py homeassistant/components/melnor/models.py - homeassistant/components/melnor/switch.py homeassistant/components/message_bird/notify.py homeassistant/components/met/weather.py homeassistant/components/met_eireann/__init__.py diff --git a/homeassistant/components/melnor/models.py b/homeassistant/components/melnor/models.py index c050c7f680b..e783f829c11 100644 --- a/homeassistant/components/melnor/models.py +++ b/homeassistant/components/melnor/models.py @@ -3,7 +3,7 @@ from datetime import timedelta import logging -from melnor_bluetooth.device import Device +from melnor_bluetooth.device import Device, Valve from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity import DeviceInfo @@ -71,3 +71,26 @@ class MelnorBluetoothBaseEntity(CoordinatorEntity[MelnorDataUpdateCoordinator]): def available(self) -> bool: """Return True if entity is available.""" return self._device.is_connected + + +class MelnorZoneEntity(MelnorBluetoothBaseEntity): + """Base class for valves that define themselves as child devices.""" + + _valve: Valve + + def __init__( + self, + coordinator: MelnorDataUpdateCoordinator, + valve: Valve, + ) -> None: + """Initialize a valve entity.""" + super().__init__(coordinator) + + self._valve = valve + + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, f"{self._device.mac}-zone{self._valve.id}")}, + manufacturer="Melnor", + name=f"Zone {valve.id + 1}", + via_device=(DOMAIN, self._device.mac), + ) diff --git a/homeassistant/components/melnor/sensor.py b/homeassistant/components/melnor/sensor.py index 567f9dc6f2c..bda70dfee3b 100644 --- a/homeassistant/components/melnor/sensor.py +++ b/homeassistant/components/melnor/sensor.py @@ -1,4 +1,4 @@ -"""Support for Melnor RainCloud sprinkler water timer.""" +"""Sensor support for Melnor Bluetooth water timer.""" from __future__ import annotations from collections.abc import Callable diff --git a/homeassistant/components/melnor/switch.py b/homeassistant/components/melnor/switch.py index 125d3ffde8c..34e0ca331b1 100644 --- a/homeassistant/components/melnor/switch.py +++ b/homeassistant/components/melnor/switch.py @@ -1,18 +1,44 @@ -"""Support for Melnor RainCloud sprinkler water timer.""" +"""Switch support for Melnor Bluetooth water timer.""" from __future__ import annotations -from typing import Any, cast +from collections.abc import Callable +from dataclasses import dataclass +from typing import Any from melnor_bluetooth.device import Valve -from homeassistant.components.switch import SwitchEntity +from homeassistant.components.switch import ( + SwitchDeviceClass, + SwitchEntity, + SwitchEntityDescription, +) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN -from .models import MelnorBluetoothBaseEntity, MelnorDataUpdateCoordinator +from .models import MelnorDataUpdateCoordinator, MelnorZoneEntity + + +def set_is_watering(valve: Valve, value: bool) -> None: + """Set the is_watering state of a valve.""" + valve.is_watering = value + + +@dataclass +class MelnorSwitchEntityDescriptionMixin: + """Mixin for required keys.""" + + on_off_fn: Callable[[Valve, bool], Any] + state_fn: Callable[[Valve], Any] + + +@dataclass +class MelnorSwitchEntityDescription( + SwitchEntityDescription, MelnorSwitchEntityDescriptionMixin +): + """Describes Melnor switch entity.""" async def async_setup_entry( @@ -21,52 +47,63 @@ async def async_setup_entry( async_add_devices: AddEntitiesCallback, ) -> None: """Set up the switch platform.""" - switches = [] + entities: list[MelnorZoneSwitch] = [] coordinator: MelnorDataUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id] # This device may not have 4 valves total, but the library will only expose the right number of valves for i in range(1, 5): - if coordinator.data[f"zone{i}"] is not None: - switches.append(MelnorSwitch(coordinator, i)) + valve = coordinator.data[f"zone{i}"] + if valve is not None: - async_add_devices(switches) + entities.append( + MelnorZoneSwitch( + coordinator, + valve, + MelnorSwitchEntityDescription( + device_class=SwitchDeviceClass.SWITCH, + icon="mdi:sprinkler", + key="manual", + name="Manual", + on_off_fn=set_is_watering, + state_fn=lambda valve: valve.is_watering, + ), + ) + ) + + async_add_devices(entities) -class MelnorSwitch(MelnorBluetoothBaseEntity, SwitchEntity): +class MelnorZoneSwitch(MelnorZoneEntity, SwitchEntity): """A switch implementation for a melnor device.""" - _attr_icon = "mdi:sprinkler" + entity_description: MelnorSwitchEntityDescription def __init__( self, coordinator: MelnorDataUpdateCoordinator, - valve_index: int, + valve: Valve, + entity_description: MelnorSwitchEntityDescription, ) -> None: """Initialize a switch for a melnor device.""" - super().__init__(coordinator) - self._valve_index = valve_index + super().__init__(coordinator, valve) - 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" + self._attr_unique_id = f"{self._device.mac}-zone{valve.id}-manual" + self.entity_description = entity_description @property def is_on(self) -> bool: """Return true if device is on.""" - return self._valve().is_watering + return self.entity_description.state_fn(self._valve) async def async_turn_on(self, **kwargs: Any) -> None: """Turn the device on.""" - self._valve().is_watering = True + self.entity_description.on_off_fn(self._valve, True) await self._device.push_state() self.async_write_ha_state() async def async_turn_off(self, **kwargs: Any) -> None: """Turn the device off.""" - self._valve().is_watering = False + self.entity_description.on_off_fn(self._valve, False) await self._device.push_state() self.async_write_ha_state() - - def _valve(self) -> Valve: - return cast(Valve, self._device[f"zone{self._valve_index}"]) diff --git a/tests/components/melnor/conftest.py b/tests/components/melnor/conftest.py index 403ae83bb67..5aee6264501 100644 --- a/tests/components/melnor/conftest.py +++ b/tests/components/melnor/conftest.py @@ -2,7 +2,7 @@ from __future__ import annotations -from unittest.mock import AsyncMock, Mock, patch +from unittest.mock import AsyncMock, patch from bleak.backends.device import BLEDevice from bleak.backends.scanner import AdvertisementData @@ -65,14 +65,6 @@ def mock_config_entry(hass: HomeAssistant): 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.""" @@ -83,6 +75,7 @@ def mock_melnor_device(): device.connect = AsyncMock(return_value=True) device.disconnect = AsyncMock(return_value=True) device.fetch_state = AsyncMock(return_value=device) + device.push_state = AsyncMock(return_value=None) device.battery_level = 80 device.mac = FAKE_ADDRESS_1 @@ -90,10 +83,10 @@ def mock_melnor_device(): 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.zone1 = Valve(0, device) + device.zone2 = Valve(1, device) + device.zone3 = Valve(2, device) + device.zone4 = Valve(3, device) device.__getitem__.side_effect = lambda key: getattr(device, key) diff --git a/tests/components/melnor/test_switch.py b/tests/components/melnor/test_switch.py new file mode 100644 index 00000000000..ffe043f53a8 --- /dev/null +++ b/tests/components/melnor/test_switch.py @@ -0,0 +1,61 @@ +"""Test the Melnor sensors.""" + +from __future__ import annotations + +from homeassistant.components.switch import SwitchDeviceClass +from homeassistant.const import STATE_OFF, STATE_ON + +from .conftest import ( + mock_config_entry, + patch_async_ble_device_from_address, + patch_async_register_callback, + patch_melnor_device, +) + + +async def test_manual_watering_switch_metadata(hass): + """Test the manual watering switch.""" + + 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() + + switch = hass.states.get("switch.zone_1_manual") + assert switch.attributes["device_class"] == SwitchDeviceClass.SWITCH + assert switch.attributes["icon"] == "mdi:sprinkler" + + +async def test_manual_watering_switch_on_off(hass): + """Test the manual watering switch.""" + + 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() + + switch = hass.states.get("switch.zone_1_manual") + assert switch.state is STATE_OFF + + await hass.services.async_call( + "switch", + "turn_on", + {"entity_id": "switch.zone_1_manual"}, + blocking=True, + ) + + switch = hass.states.get("switch.zone_1_manual") + assert switch.state is STATE_ON + + await hass.services.async_call( + "switch", + "turn_off", + {"entity_id": "switch.zone_1_manual"}, + blocking=True, + ) + + switch = hass.states.get("switch.zone_1_manual") + assert switch.state is STATE_OFF