diff --git a/homeassistant/components/melnor/manifest.json b/homeassistant/components/melnor/manifest.json index 4a95900aeb3..8d5d86a8bf7 100644 --- a/homeassistant/components/melnor/manifest.json +++ b/homeassistant/components/melnor/manifest.json @@ -12,5 +12,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/melnor", "iot_class": "local_polling", - "requirements": ["melnor-bluetooth==0.0.20"] + "requirements": ["melnor-bluetooth==0.0.21"] } diff --git a/homeassistant/components/melnor/number.py b/homeassistant/components/melnor/number.py index c750e07f7e8..79b80a6d7b5 100644 --- a/homeassistant/components/melnor/number.py +++ b/homeassistant/components/melnor/number.py @@ -8,9 +8,13 @@ from typing import Any from melnor_bluetooth.device import Valve -from homeassistant.components.number import NumberEntity, NumberEntityDescription +from homeassistant.components.number import ( + NumberEntity, + NumberEntityDescription, + NumberMode, +) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import EntityCategory +from homeassistant.const import EntityCategory, UnitOfTime from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -44,10 +48,33 @@ ZONE_ENTITY_DESCRIPTIONS: list[MelnorZoneNumberEntityDescription] = [ native_min_value=1, icon="mdi:timer-cog-outline", key="manual_minutes", - name="Manual Minutes", + name="Manual Duration", + native_unit_of_measurement=UnitOfTime.MINUTES, set_num_fn=lambda valve, value: valve.set_manual_watering_minutes(value), state_fn=lambda valve: valve.manual_watering_minutes, - ) + ), + MelnorZoneNumberEntityDescription( + entity_category=EntityCategory.CONFIG, + native_max_value=168, + native_min_value=1, + icon="mdi:calendar-refresh-outline", + key="frequency_interval_hours", + name="Schedule Interval", + native_unit_of_measurement=UnitOfTime.HOURS, + set_num_fn=lambda valve, value: valve.set_frequency_interval_hours(value), + state_fn=lambda valve: valve.frequency.interval_hours, + ), + MelnorZoneNumberEntityDescription( + entity_category=EntityCategory.CONFIG, + native_max_value=360, + native_min_value=1, + icon="mdi:timer-outline", + key="frequency_duration_minutes", + name="Schedule Duration", + native_unit_of_measurement=UnitOfTime.MINUTES, + set_num_fn=lambda valve, value: valve.set_frequency_duration_minutes(value), + state_fn=lambda valve: valve.frequency.duration_minutes, + ), ] @@ -75,6 +102,7 @@ class MelnorZoneNumber(MelnorZoneEntity, NumberEntity): """A number implementation for a melnor device.""" entity_description: MelnorZoneNumberEntityDescription + _attr_mode = NumberMode.BOX def __init__( self, @@ -88,7 +116,7 @@ class MelnorZoneNumber(MelnorZoneEntity, NumberEntity): @property def native_value(self) -> float | None: """Return the current value.""" - return self._valve.manual_watering_minutes + return self.entity_description.state_fn(self._valve) async def async_set_native_value(self, value: float) -> None: """Update the current value.""" diff --git a/homeassistant/components/melnor/sensor.py b/homeassistant/components/melnor/sensor.py index 1061d084ad1..b4a1d44a291 100644 --- a/homeassistant/components/melnor/sensor.py +++ b/homeassistant/components/melnor/sensor.py @@ -45,6 +45,15 @@ def watering_seconds_left(valve: Valve) -> datetime | None: return dt_util.utc_from_timestamp(valve.watering_end_time) +def next_cycle(valve: Valve) -> datetime | None: + """Return the value of the next_cycle date, only if the cycle is enabled.""" + + if valve.schedule_enabled is True: + return valve.next_cycle + + return None + + @dataclass class MelnorSensorEntityDescriptionMixin: """Mixin for required keys.""" @@ -102,6 +111,12 @@ ZONE_ENTITY_DESCRIPTIONS: list[MelnorZoneSensorEntityDescription] = [ name="Manual Cycle End", state_fn=watering_seconds_left, ), + MelnorZoneSensorEntityDescription( + device_class=SensorDeviceClass.TIMESTAMP, + key="next_cycle", + name="Next Cycle", + state_fn=next_cycle, + ), ] diff --git a/homeassistant/components/melnor/switch.py b/homeassistant/components/melnor/switch.py index eca6f1a98cf..a2854479abd 100644 --- a/homeassistant/components/melnor/switch.py +++ b/homeassistant/components/melnor/switch.py @@ -47,7 +47,15 @@ ZONE_ENTITY_DESCRIPTIONS = [ key="manual", on_off_fn=lambda valve, bool: valve.set_is_watering(bool), state_fn=lambda valve: valve.is_watering, - ) + ), + MelnorSwitchEntityDescription( + device_class=SwitchDeviceClass.SWITCH, + icon="mdi:calendar-sync-outline", + key="frequency", + name="Schedule", + on_off_fn=lambda valve, bool: valve.set_frequency_enabled(bool), + state_fn=lambda valve: valve.schedule_enabled, + ), ] diff --git a/requirements_all.txt b/requirements_all.txt index 9d0d65bf0de..ced1c1d6f33 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1121,7 +1121,7 @@ mcstatus==6.0.0 meater-python==0.0.8 # homeassistant.components.melnor -melnor-bluetooth==0.0.20 +melnor-bluetooth==0.0.21 # homeassistant.components.message_bird messagebird==1.2.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 3f9cce831c1..a34cc1a0970 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -847,7 +847,7 @@ mcstatus==6.0.0 meater-python==0.0.8 # homeassistant.components.melnor -melnor-bluetooth==0.0.20 +melnor-bluetooth==0.0.21 # homeassistant.components.meteo_france meteofrance-api==1.2.0 diff --git a/tests/components/melnor/conftest.py b/tests/components/melnor/conftest.py index 790301171cd..ab51bf44a57 100644 --- a/tests/components/melnor/conftest.py +++ b/tests/components/melnor/conftest.py @@ -2,6 +2,7 @@ from __future__ import annotations from collections.abc import Generator +from datetime import datetime, time, timedelta, timezone from unittest.mock import AsyncMock, patch from melnor_bluetooth.device import Device @@ -57,13 +58,78 @@ def mock_bluetooth(enable_bluetooth): """Auto mock bluetooth.""" -class MockedValve: +class MockFrequency: + """Mocked class for a Frequency.""" + + _duration: int + _interval: int + _is_watering: bool + _start_time: time + _next_run_time: datetime + + def __init__(self) -> None: + """Initialize a mocked frequency.""" + self._duration = 0 + self._interval = 0 + self._is_watering = False + self._start_time = time(12, 0) + self._next_run_time = datetime(2021, 1, 1, 12, 0, tzinfo=timezone.utc) + + @property + def duration_minutes(self) -> int: + """Return the duration in minutes.""" + return self._duration + + @duration_minutes.setter + def duration_minutes(self, duration: int) -> None: + """Set the duration in minutes.""" + self._duration = duration + + @property + def interval_hours(self) -> int: + """Return the interval in hours.""" + return self._interval + + @interval_hours.setter + def interval_hours(self, interval: int) -> None: + """Set the interval in hours.""" + self._interval = interval + + @property + def start_time(self) -> time: + """Return the start time.""" + return self._start_time + + @start_time.setter + def start_time(self, start_time: time) -> None: + """Set the start time.""" + self._start_time = start_time + + @property + def is_watering(self) -> bool: + """Return true if the frequency is currently watering.""" + return self._is_watering + + @property + def next_run_time(self) -> datetime: + """Return the next run time.""" + return self._next_run_time + + @property + def schedule_end_time(self) -> datetime: + """Return the schedule end time.""" + return self._next_run_time + timedelta(minutes=self._duration) + + +class MockValve: """Mocked class for a Valve.""" _id: int _is_watering: bool _manual_watering_minutes: int _end_time: int + _frequency: MockFrequency + _schedule_enabled: bool def __init__(self, identifier: int) -> None: """Initialize a mocked valve.""" @@ -71,35 +137,69 @@ class MockedValve: self._id = identifier self._is_watering = False self._manual_watering_minutes = 0 + self._schedule_enabled = False + + self._frequency = MockFrequency() @property def id(self) -> int: """Return the valve id.""" return self._id + @property + def frequency(self): + """Return the frequency.""" + return self._frequency + @property def is_watering(self): """Return true if the valve is currently watering.""" return self._is_watering - async def set_is_watering(self, is_watering: bool): - """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 next_cycle(self): + """Return the end time of the current watering cycle.""" + return self._frequency.next_run_time + + @property + def schedule_enabled(self) -> bool: + """Return true if the schedule is enabled.""" + return self._schedule_enabled @property def watering_end_time(self) -> int: """Return the end time of the current watering cycle.""" return self._end_time + async def set_is_watering(self, is_watering: bool): + """Set the valve to manual watering.""" + self._is_watering = is_watering + + async def set_manual_watering_minutes(self, minutes: int): + """Set the valve to manual watering.""" + self._manual_watering_minutes = minutes + + async def set_frequency_interval_hours(self, interval: int): + """Set the frequency interval in hours.""" + self._frequency.interval_hours = interval + + async def set_frequency_duration_minutes(self, duration: int): + """Set the frequency duration in minutes.""" + self._frequency.duration_minutes = duration + + async def set_frequency_enabled(self, enabled: bool): + """Set the frequency schedule enabled.""" + self._schedule_enabled = enabled + + async def set_frequency_start_time(self, value: time): + """Set the frequency schedule enabled.""" + self._frequency.start_time = value + def mock_config_entry(hass: HomeAssistant): """Return a mock config entry.""" @@ -131,10 +231,10 @@ def mock_melnor_device(): device.name = "test_melnor" device.rssi = -50 - device.zone1 = MockedValve(0) - device.zone2 = MockedValve(1) - device.zone3 = MockedValve(2) - device.zone4 = MockedValve(3) + device.zone1 = MockValve(0) + device.zone2 = MockValve(1) + device.zone3 = MockValve(2) + device.zone4 = MockValve(3) device.__getitem__.side_effect = lambda key: getattr(device, key) diff --git a/tests/components/melnor/test_number.py b/tests/components/melnor/test_number.py index e7d6780aa4c..a8d358c2ac2 100644 --- a/tests/components/melnor/test_number.py +++ b/tests/components/melnor/test_number.py @@ -12,7 +12,7 @@ from .conftest import ( async def test_manual_watering_minutes(hass: HomeAssistant) -> None: - """Test the manual watering switch.""" + """Test the manual watering duration number.""" entry = mock_config_entry(hass) @@ -22,8 +22,9 @@ async def test_manual_watering_minutes(hass: HomeAssistant) -> None: 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") + number = hass.states.get("number.zone_1_manual_duration") + assert number is not None assert number.state == "0" assert number.attributes["max"] == 360 assert number.attributes["min"] == 1 @@ -35,11 +36,84 @@ async def test_manual_watering_minutes(hass: HomeAssistant) -> None: await hass.services.async_call( "number", "set_value", - {"entity_id": "number.zone_1_manual_minutes", "value": 10}, + {"entity_id": "number.zone_1_manual_duration", "value": 10}, blocking=True, ) - number = hass.states.get("number.zone_1_manual_minutes") + number = hass.states.get("number.zone_1_manual_duration") + assert number is not None assert number.state == "10" assert device.zone1.manual_watering_minutes == 10 + + +async def test_frequency_interval_hours(hass: HomeAssistant) -> None: + """Test the interval hours number.""" + + 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_schedule_interval") + + assert number is not None + assert number.state == "0" + assert number.attributes["max"] == 168 + assert number.attributes["min"] == 1 + assert number.attributes["step"] == 1.0 + assert number.attributes["icon"] == "mdi:calendar-refresh-outline" + + assert device.zone1.frequency.interval_hours == 0 + + await hass.services.async_call( + "number", + "set_value", + {"entity_id": "number.zone_1_schedule_interval", "value": 10}, + blocking=True, + ) + + number = hass.states.get("number.zone_1_schedule_interval") + + assert number is not None + assert number.state == "10" + assert device.zone1.frequency.interval_hours == 10 + + +async def test_frequency_duration_minutes(hass: HomeAssistant) -> None: + """Test the duration minutes number.""" + + 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_schedule_duration") + + assert number is not None + 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-outline" + + assert device.zone1.frequency.duration_minutes == 0 + + await hass.services.async_call( + "number", + "set_value", + {"entity_id": "number.zone_1_schedule_duration", "value": 10}, + blocking=True, + ) + + number = hass.states.get("number.zone_1_schedule_duration") + + assert number is not None + assert number.state == "10" + assert device.zone1.frequency.duration_minutes == 10 diff --git a/tests/components/melnor/test_sensor.py b/tests/components/melnor/test_sensor.py index b525ec67b17..291115fae9d 100644 --- a/tests/components/melnor/test_sensor.py +++ b/tests/components/melnor/test_sensor.py @@ -30,6 +30,8 @@ async def test_battery_sensor(hass: HomeAssistant) -> None: await hass.async_block_till_done() battery_sensor = hass.states.get("sensor.test_melnor_battery") + + assert battery_sensor is not None assert battery_sensor.state == "80" assert battery_sensor.attributes["unit_of_measurement"] == PERCENTAGE assert battery_sensor.attributes["device_class"] == SensorDeviceClass.BATTERY @@ -58,6 +60,8 @@ async def test_minutes_remaining_sensor(hass: HomeAssistant) -> None: # Valve is off, report 0 minutes_sensor = hass.states.get("sensor.zone_1_manual_cycle_end") + + assert minutes_sensor is not None assert minutes_sensor.state == "unknown" assert minutes_sensor.attributes["device_class"] == SensorDeviceClass.TIMESTAMP @@ -69,9 +73,50 @@ async def test_minutes_remaining_sensor(hass: HomeAssistant) -> None: # Valve is on, report 10 minutes_remaining_sensor = hass.states.get("sensor.zone_1_manual_cycle_end") + + assert minutes_remaining_sensor is not None assert minutes_remaining_sensor.state == end_time.isoformat(timespec="seconds") +async def test_schedule_next_cycle_sensor(hass: HomeAssistant) -> None: + """Test the frequency next_cycle sensor.""" + + now = dt_util.utcnow() + + entry = mock_config_entry(hass) + device = mock_melnor_device() + + next_cycle = now + dt_util.dt.timedelta(minutes=10) + + # we control this mock + device.zone1.frequency._next_run_time = next_cycle + + 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_next_cycle") + + assert minutes_sensor is not None + assert minutes_sensor.state == "unknown" + assert minutes_sensor.attributes["device_class"] == SensorDeviceClass.TIMESTAMP + + # Turn valve on + device.zone1._schedule_enabled = True + + async_fire_time_changed(hass, now + dt_util.dt.timedelta(seconds=10)) + await hass.async_block_till_done() + + # Valve is on, report 10 + next_cycle_sensor = hass.states.get("sensor.zone_1_next_cycle") + + assert next_cycle_sensor is not None + assert next_cycle_sensor.state == next_cycle.isoformat(timespec="seconds") + + async def test_rssi_sensor( hass: HomeAssistant, entity_registry: er.EntityRegistry ) -> None: @@ -104,6 +149,7 @@ async def test_rssi_sensor( rssi = hass.states.get(entity_id) + assert rssi is not None assert ( rssi.attributes["unit_of_measurement"] == SIGNAL_STRENGTH_DECIBELS_MILLIWATT ) diff --git a/tests/components/melnor/test_switch.py b/tests/components/melnor/test_switch.py index 08a36182480..fdd5e8ad33e 100644 --- a/tests/components/melnor/test_switch.py +++ b/tests/components/melnor/test_switch.py @@ -23,6 +23,8 @@ async def test_manual_watering_switch_metadata(hass: HomeAssistant) -> None: await hass.async_block_till_done() switch = hass.states.get("switch.zone_1") + + assert switch is not None assert switch.attributes["device_class"] == SwitchDeviceClass.SWITCH assert switch.attributes["icon"] == "mdi:sprinkler" @@ -39,6 +41,8 @@ async def test_manual_watering_switch_on_off(hass: HomeAssistant) -> None: await hass.async_block_till_done() switch = hass.states.get("switch.zone_1") + + assert switch is not None assert switch.state is STATE_OFF await hass.services.async_call( @@ -49,6 +53,8 @@ async def test_manual_watering_switch_on_off(hass: HomeAssistant) -> None: ) switch = hass.states.get("switch.zone_1") + + assert switch is not None assert switch.state is STATE_ON assert device.zone1.is_watering is True @@ -60,5 +66,38 @@ async def test_manual_watering_switch_on_off(hass: HomeAssistant) -> None: ) switch = hass.states.get("switch.zone_1") + + assert switch is not None assert switch.state is STATE_OFF assert device.zone1.is_watering is False + + +async def test_schedule_enabled_switch_on_off(hass: HomeAssistant) -> None: + """Test the schedule enabled 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() + + switch = hass.states.get("switch.zone_1_schedule") + + assert switch is not None + assert switch.state is STATE_OFF + assert device.zone1.schedule_enabled is False + + await hass.services.async_call( + "switch", + "turn_on", + {"entity_id": "switch.zone_1_schedule"}, + blocking=True, + ) + + switch = hass.states.get("switch.zone_1_schedule") + + assert switch is not None + assert switch.state is STATE_ON + assert device.zone1.schedule_enabled is True