From 3eab4a234b25f2e7540cb9587921dc0e4ba51d06 Mon Sep 17 00:00:00 2001 From: Justin Vanderhooft Date: Mon, 19 Sep 2022 07:56:34 -0400 Subject: [PATCH] Add support for controlling manual watering time on Melnor Bluetooth devices (#78653) Co-authored-by: J. Nick Koston --- homeassistant/components/melnor/__init__.py | 1 + homeassistant/components/melnor/models.py | 38 +++++++- homeassistant/components/melnor/number.py | 96 +++++++++++++++++++++ homeassistant/components/melnor/sensor.py | 85 ++++++++++++++++-- homeassistant/components/melnor/switch.py | 37 ++++---- tests/components/melnor/conftest.py | 16 ++++ tests/components/melnor/test_number.py | 46 ++++++++++ tests/components/melnor/test_sensor.py | 41 +++++++++ tests/components/melnor/test_switch.py | 18 ++-- 9 files changed, 344 insertions(+), 34 deletions(-) create mode 100644 homeassistant/components/melnor/number.py create mode 100644 tests/components/melnor/test_number.py diff --git a/homeassistant/components/melnor/__init__.py b/homeassistant/components/melnor/__init__.py index 433380a9ab9..93b8d11ab24 100644 --- a/homeassistant/components/melnor/__init__.py +++ b/homeassistant/components/melnor/__init__.py @@ -15,6 +15,7 @@ from .const import DOMAIN from .models import MelnorDataUpdateCoordinator PLATFORMS: list[Platform] = [ + Platform.NUMBER, Platform.SENSOR, Platform.SWITCH, ] diff --git a/homeassistant/components/melnor/models.py b/homeassistant/components/melnor/models.py index e783f829c11..8cbe5f80680 100644 --- a/homeassistant/components/melnor/models.py +++ b/homeassistant/components/melnor/models.py @@ -1,10 +1,13 @@ """Melnor integration models.""" +from collections.abc import Callable from datetime import timedelta import logging +from typing import TypeVar from melnor_bluetooth.device import Device, Valve +from homeassistant.components.number import EntityDescription from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.update_coordinator import ( @@ -39,7 +42,7 @@ class MelnorDataUpdateCoordinator(DataUpdateCoordinator[Device]): return self._device -class MelnorBluetoothBaseEntity(CoordinatorEntity[MelnorDataUpdateCoordinator]): +class MelnorBluetoothEntity(CoordinatorEntity[MelnorDataUpdateCoordinator]): """Base class for melnor entities.""" _device: Device @@ -73,7 +76,7 @@ class MelnorBluetoothBaseEntity(CoordinatorEntity[MelnorDataUpdateCoordinator]): return self._device.is_connected -class MelnorZoneEntity(MelnorBluetoothBaseEntity): +class MelnorZoneEntity(MelnorBluetoothEntity): """Base class for valves that define themselves as child devices.""" _valve: Valve @@ -81,11 +84,17 @@ class MelnorZoneEntity(MelnorBluetoothBaseEntity): def __init__( self, coordinator: MelnorDataUpdateCoordinator, + entity_description: EntityDescription, valve: Valve, ) -> None: """Initialize a valve entity.""" super().__init__(coordinator) + self._attr_unique_id = ( + f"{self._device.mac}-zone{valve.id}-{entity_description.key}" + ) + self.entity_description = entity_description + self._valve = valve self._attr_device_info = DeviceInfo( @@ -94,3 +103,28 @@ class MelnorZoneEntity(MelnorBluetoothBaseEntity): name=f"Zone {valve.id + 1}", via_device=(DOMAIN, self._device.mac), ) + + +T = TypeVar("T", bound=EntityDescription) + + +def get_entities_for_valves( + coordinator: MelnorDataUpdateCoordinator, + descriptions: list[T], + function: Callable[ + [Valve, T], + CoordinatorEntity[MelnorDataUpdateCoordinator], + ], +) -> list[CoordinatorEntity[MelnorDataUpdateCoordinator]]: + """Get descriptions for valves.""" + entities = [] + + # 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): + valve = coordinator.data[f"zone{i}"] + + if valve is not None: + for description in descriptions: + entities.append(function(valve, description)) + + return entities diff --git a/homeassistant/components/melnor/number.py b/homeassistant/components/melnor/number.py new file mode 100644 index 00000000000..3f29e7cf772 --- /dev/null +++ b/homeassistant/components/melnor/number.py @@ -0,0 +1,96 @@ +"""Number support for Melnor Bluetooth water timer.""" + +from __future__ import annotations + +from collections.abc import Callable, Coroutine +from dataclasses import dataclass +from typing import Any + +from melnor_bluetooth.device import Valve + +from homeassistant.components.number import NumberEntity, NumberEntityDescription +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity import EntityCategory +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN +from .models import ( + MelnorDataUpdateCoordinator, + MelnorZoneEntity, + get_entities_for_valves, +) + + +@dataclass +class MelnorZoneNumberEntityDescriptionMixin: + """Mixin for required keys.""" + + set_num_fn: Callable[[Valve, int], Coroutine[Any, Any, None]] + state_fn: Callable[[Valve], Any] + + +@dataclass +class MelnorZoneNumberEntityDescription( + NumberEntityDescription, MelnorZoneNumberEntityDescriptionMixin +): + """Describes Melnor number entity.""" + + +ZONE_ENTITY_DESCRIPTIONS: list[MelnorZoneNumberEntityDescription] = [ + MelnorZoneNumberEntityDescription( + entity_category=EntityCategory.CONFIG, + native_max_value=360, + native_min_value=1, + icon="mdi:timer-cog-outline", + key="manual_minutes", + name="Manual Minutes", + set_num_fn=lambda valve, value: valve.set_manual_watering_minutes(value), + state_fn=lambda valve: valve.manual_watering_minutes, + ) +] + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the number platform.""" + + coordinator: MelnorDataUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id] + + async_add_entities( + get_entities_for_valves( + coordinator, + ZONE_ENTITY_DESCRIPTIONS, + lambda valve, description: MelnorZoneNumber( + coordinator, description, valve + ), + ) + ) + + +class MelnorZoneNumber(MelnorZoneEntity, NumberEntity): + """A number implementation for a melnor device.""" + + entity_description: MelnorZoneNumberEntityDescription + + def __init__( + self, + coordinator: MelnorDataUpdateCoordinator, + entity_description: MelnorZoneNumberEntityDescription, + valve: Valve, + ) -> None: + """Initialize a number for a melnor device.""" + super().__init__(coordinator, entity_description, valve) + + @property + def native_value(self) -> float | None: + """Return the current value.""" + return self._valve.manual_watering_minutes + + async def async_set_native_value(self, value: float) -> None: + """Update the current value.""" + await self.entity_description.set_num_fn(self._valve, int(value)) + self._async_write_ha_state() diff --git a/homeassistant/components/melnor/sensor.py b/homeassistant/components/melnor/sensor.py index e642df4f9c3..42eb9e60c73 100644 --- a/homeassistant/components/melnor/sensor.py +++ b/homeassistant/components/melnor/sensor.py @@ -3,9 +3,10 @@ from __future__ import annotations from collections.abc import Callable from dataclasses import dataclass +from datetime import datetime from typing import Any -from melnor_bluetooth.device import Device +from melnor_bluetooth.device import Device, Valve from homeassistant.components.sensor import ( SensorDeviceClass, @@ -19,9 +20,26 @@ 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 homeassistant.util import dt as dt_util from .const import DOMAIN -from .models import MelnorBluetoothBaseEntity, MelnorDataUpdateCoordinator +from .models import ( + MelnorBluetoothEntity, + MelnorDataUpdateCoordinator, + MelnorZoneEntity, + get_entities_for_valves, +) + + +def watering_seconds_left(valve: Valve) -> datetime | None: + """Calculate the number of minutes left in the current watering cycle.""" + + if valve.is_watering is not True or dt_util.now() > dt_util.utc_from_timestamp( + valve.watering_end_time + ): + return None + + return dt_util.utc_from_timestamp(valve.watering_end_time) @dataclass @@ -31,6 +49,20 @@ class MelnorSensorEntityDescriptionMixin: state_fn: Callable[[Device], Any] +@dataclass +class MelnorZoneSensorEntityDescriptionMixin: + """Mixin for required keys.""" + + state_fn: Callable[[Valve], Any] + + +@dataclass +class MelnorZoneSensorEntityDescription( + SensorEntityDescription, MelnorZoneSensorEntityDescriptionMixin +): + """Describes Melnor sensor entity.""" + + @dataclass class MelnorSensorEntityDescription( SensorEntityDescription, MelnorSensorEntityDescriptionMixin @@ -38,7 +70,7 @@ class MelnorSensorEntityDescription( """Describes Melnor sensor entity.""" -sensors = [ +DEVICE_ENTITY_DESCRIPTIONS: list[MelnorSensorEntityDescription] = [ MelnorSensorEntityDescription( device_class=SensorDeviceClass.BATTERY, entity_category=EntityCategory.DIAGNOSTIC, @@ -60,6 +92,15 @@ sensors = [ ), ] +ZONE_ENTITY_DESCRIPTIONS: list[MelnorZoneSensorEntityDescription] = [ + MelnorZoneSensorEntityDescription( + device_class=SensorDeviceClass.TIMESTAMP, + key="manual_cycle_end", + name="Manual Cycle End", + state_fn=watering_seconds_left, + ), +] + async def async_setup_entry( hass: HomeAssistant, @@ -70,16 +111,28 @@ async def async_setup_entry( coordinator: MelnorDataUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id] + # Device-level sensors async_add_entities( MelnorSensorEntity( coordinator, description, ) - for description in sensors + for description in DEVICE_ENTITY_DESCRIPTIONS + ) + + # Valve/Zone-level sensors + async_add_entities( + get_entities_for_valves( + coordinator, + ZONE_ENTITY_DESCRIPTIONS, + lambda valve, description: MelnorZoneSensorEntity( + coordinator, description, valve + ), + ) ) -class MelnorSensorEntity(MelnorBluetoothBaseEntity, SensorEntity): +class MelnorSensorEntity(MelnorBluetoothEntity, SensorEntity): """Representation of a Melnor sensor.""" entity_description: MelnorSensorEntityDescription @@ -98,5 +151,25 @@ class MelnorSensorEntity(MelnorBluetoothBaseEntity, SensorEntity): @property def native_value(self) -> StateType: - """Return the battery level.""" + """Return the sensor value.""" return self.entity_description.state_fn(self._device) + + +class MelnorZoneSensorEntity(MelnorZoneEntity, SensorEntity): + """Representation of a Melnor sensor.""" + + entity_description: MelnorZoneSensorEntityDescription + + def __init__( + self, + coordinator: MelnorDataUpdateCoordinator, + entity_description: MelnorZoneSensorEntityDescription, + valve: Valve, + ) -> None: + """Initialize a sensor for a Melnor device.""" + super().__init__(coordinator, entity_description, valve) + + @property + def native_value(self) -> StateType: + """Return the sensor value.""" + return self.entity_description.state_fn(self._valve) diff --git a/homeassistant/components/melnor/switch.py b/homeassistant/components/melnor/switch.py index 20d95ad99a6..eca6f1a98cf 100644 --- a/homeassistant/components/melnor/switch.py +++ b/homeassistant/components/melnor/switch.py @@ -18,7 +18,11 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN -from .models import MelnorDataUpdateCoordinator, MelnorZoneEntity +from .models import ( + MelnorDataUpdateCoordinator, + MelnorZoneEntity, + get_entities_for_valves, +) @dataclass @@ -36,12 +40,11 @@ class MelnorSwitchEntityDescription( """Describes Melnor switch entity.""" -switches = [ +ZONE_ENTITY_DESCRIPTIONS = [ MelnorSwitchEntityDescription( device_class=SwitchDeviceClass.SWITCH, icon="mdi:sprinkler", key="manual", - name="Manual", on_off_fn=lambda valve, bool: valve.set_is_watering(bool), state_fn=lambda valve: valve.is_watering, ) @@ -51,22 +54,21 @@ switches = [ async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_devices: AddEntitiesCallback, + async_add_entities: AddEntitiesCallback, ) -> None: """Set up the switch platform.""" - 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): - valve = coordinator.data[f"zone{i}"] - if valve is not None: - - for description in switches: - entities.append(MelnorZoneSwitch(coordinator, valve, description)) - - async_add_devices(entities) + async_add_entities( + get_entities_for_valves( + coordinator, + ZONE_ENTITY_DESCRIPTIONS, + lambda valve, description: MelnorZoneSwitch( + coordinator, description, valve + ), + ) + ) class MelnorZoneSwitch(MelnorZoneEntity, SwitchEntity): @@ -77,14 +79,11 @@ class MelnorZoneSwitch(MelnorZoneEntity, SwitchEntity): def __init__( self, coordinator: MelnorDataUpdateCoordinator, - valve: Valve, entity_description: MelnorSwitchEntityDescription, + valve: Valve, ) -> None: """Initialize a switch for a melnor device.""" - super().__init__(coordinator, valve) - - self._attr_unique_id = f"{self._device.mac}-zone{valve.id}-manual" - self.entity_description = entity_description + super().__init__(coordinator, entity_description, valve) @property def is_on(self) -> bool: diff --git a/tests/components/melnor/conftest.py b/tests/components/melnor/conftest.py index 554349109ef..1b5af1f8abf 100644 --- a/tests/components/melnor/conftest.py +++ b/tests/components/melnor/conftest.py @@ -58,9 +58,11 @@ class MockedValve: _id: int _is_watering: bool _manual_watering_minutes: int + _end_time: int def __init__(self, identifier: int) -> None: """Initialize a mocked valve.""" + self._end_time = 0 self._id = identifier self._is_watering = False self._manual_watering_minutes = 0 @@ -79,6 +81,20 @@ class MockedValve: """Set the valve to manual watering.""" self._is_watering = is_watering + @property + def manual_watering_minutes(self): + """Return the number of minutes the valve is set to manual watering.""" + return self._manual_watering_minutes + + async def set_manual_watering_minutes(self, minutes: int): + """Set the valve to manual watering.""" + self._manual_watering_minutes = minutes + + @property + def watering_end_time(self) -> int: + """Return the end time of the current watering cycle.""" + return self._end_time + def mock_config_entry(hass: HomeAssistant): """Return a mock config entry.""" diff --git a/tests/components/melnor/test_number.py b/tests/components/melnor/test_number.py new file mode 100644 index 00000000000..77466ba50ed --- /dev/null +++ b/tests/components/melnor/test_number.py @@ -0,0 +1,46 @@ +"""Test the Melnor sensors.""" + +from __future__ import annotations + +from .conftest import ( + mock_config_entry, + patch_async_ble_device_from_address, + patch_async_register_callback, + patch_melnor_device, +) + + +async def test_manual_watering_minutes(hass): + """Test the manual watering switch.""" + + entry = mock_config_entry(hass) + + with patch_async_ble_device_from_address(), patch_melnor_device() as device_patch, patch_async_register_callback(): + + device = device_patch.return_value + + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + number = hass.states.get("number.zone_1_manual_minutes") + + print(number) + assert number.state == "0" + assert number.attributes["max"] == 360 + assert number.attributes["min"] == 1 + assert number.attributes["step"] == 1.0 + assert number.attributes["icon"] == "mdi:timer-cog-outline" + + assert device.zone1.manual_watering_minutes == 0 + + await hass.services.async_call( + "number", + "set_value", + {"entity_id": "number.zone_1_manual_minutes", "value": 10}, + blocking=True, + ) + + number = hass.states.get("number.zone_1_manual_minutes") + + assert number.state == "10" + assert device.zone1.manual_watering_minutes == 10 diff --git a/tests/components/melnor/test_sensor.py b/tests/components/melnor/test_sensor.py index bef2bba35e0..778acbc96d9 100644 --- a/tests/components/melnor/test_sensor.py +++ b/tests/components/melnor/test_sensor.py @@ -2,9 +2,12 @@ from __future__ import annotations +from freezegun import freeze_time + from homeassistant.components.sensor import SensorDeviceClass, SensorStateClass from homeassistant.const import PERCENTAGE, SIGNAL_STRENGTH_DECIBELS_MILLIWATT from homeassistant.helpers import entity_registry +import homeassistant.util.dt as dt_util from .conftest import ( mock_config_entry, @@ -14,6 +17,8 @@ from .conftest import ( patch_melnor_device, ) +from tests.common import async_fire_time_changed + async def test_battery_sensor(hass): """Test the battery sensor.""" @@ -31,6 +36,42 @@ async def test_battery_sensor(hass): assert battery_sensor.attributes["state_class"] == SensorStateClass.MEASUREMENT +async def test_minutes_remaining_sensor(hass): + """Test the minutes remaining sensor.""" + + now = dt_util.utcnow() + + entry = mock_config_entry(hass) + device = mock_melnor_device() + + end_time = now + dt_util.dt.timedelta(minutes=10) + + # we control this mock + # pylint: disable=protected-access + device.zone1._end_time = (end_time).timestamp() + + with freeze_time(now), 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() + + # Valve is off, report 0 + minutes_sensor = hass.states.get("sensor.zone_1_manual_cycle_end") + assert minutes_sensor.state == "unknown" + assert minutes_sensor.attributes["device_class"] == SensorDeviceClass.TIMESTAMP + + # Turn valve on + device.zone1._is_watering = True + + async_fire_time_changed(hass, now + dt_util.dt.timedelta(seconds=10)) + await hass.async_block_till_done() + + # Valve is on, report 10 + minutes_remaining_sensor = hass.states.get("sensor.zone_1_manual_cycle_end") + assert minutes_remaining_sensor.state == end_time.isoformat(timespec="seconds") + + async def test_rssi_sensor(hass): """Test the rssi sensor.""" diff --git a/tests/components/melnor/test_switch.py b/tests/components/melnor/test_switch.py index ffe043f53a8..9539b00d9c6 100644 --- a/tests/components/melnor/test_switch.py +++ b/tests/components/melnor/test_switch.py @@ -22,7 +22,7 @@ async def test_manual_watering_switch_metadata(hass): assert await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() - switch = hass.states.get("switch.zone_1_manual") + switch = hass.states.get("switch.zone_1") assert switch.attributes["device_class"] == SwitchDeviceClass.SWITCH assert switch.attributes["icon"] == "mdi:sprinkler" @@ -32,30 +32,34 @@ async def test_manual_watering_switch_on_off(hass): entry = mock_config_entry(hass) - with patch_async_ble_device_from_address(), patch_melnor_device(), patch_async_register_callback(): + with patch_async_ble_device_from_address(), patch_melnor_device() as device_patch, patch_async_register_callback(): + + device = device_patch.return_value assert await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() - switch = hass.states.get("switch.zone_1_manual") + switch = hass.states.get("switch.zone_1") assert switch.state is STATE_OFF await hass.services.async_call( "switch", "turn_on", - {"entity_id": "switch.zone_1_manual"}, + {"entity_id": "switch.zone_1"}, blocking=True, ) - switch = hass.states.get("switch.zone_1_manual") + switch = hass.states.get("switch.zone_1") assert switch.state is STATE_ON + assert device.zone1.is_watering is True await hass.services.async_call( "switch", "turn_off", - {"entity_id": "switch.zone_1_manual"}, + {"entity_id": "switch.zone_1"}, blocking=True, ) - switch = hass.states.get("switch.zone_1_manual") + switch = hass.states.get("switch.zone_1") assert switch.state is STATE_OFF + assert device.zone1.is_watering is False