Add support for controlling manual watering time on Melnor Bluetooth devices (#78653)

Co-authored-by: J. Nick Koston <nick@koston.org>
This commit is contained in:
Justin Vanderhooft 2022-09-19 07:56:34 -04:00 committed by GitHub
parent 3dc6db94ce
commit 3eab4a234b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 344 additions and 34 deletions

View File

@ -15,6 +15,7 @@ from .const import DOMAIN
from .models import MelnorDataUpdateCoordinator
PLATFORMS: list[Platform] = [
Platform.NUMBER,
Platform.SENSOR,
Platform.SWITCH,
]

View File

@ -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

View File

@ -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()

View File

@ -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)

View File

@ -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:

View File

@ -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."""

View File

@ -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

View File

@ -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."""

View File

@ -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