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 from .models import MelnorDataUpdateCoordinator
PLATFORMS: list[Platform] = [ PLATFORMS: list[Platform] = [
Platform.NUMBER,
Platform.SENSOR, Platform.SENSOR,
Platform.SWITCH, Platform.SWITCH,
] ]

View File

@ -1,10 +1,13 @@
"""Melnor integration models.""" """Melnor integration models."""
from collections.abc import Callable
from datetime import timedelta from datetime import timedelta
import logging import logging
from typing import TypeVar
from melnor_bluetooth.device import Device, Valve from melnor_bluetooth.device import Device, Valve
from homeassistant.components.number import EntityDescription
from homeassistant.core import HomeAssistant, callback from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity import DeviceInfo
from homeassistant.helpers.update_coordinator import ( from homeassistant.helpers.update_coordinator import (
@ -39,7 +42,7 @@ class MelnorDataUpdateCoordinator(DataUpdateCoordinator[Device]):
return self._device return self._device
class MelnorBluetoothBaseEntity(CoordinatorEntity[MelnorDataUpdateCoordinator]): class MelnorBluetoothEntity(CoordinatorEntity[MelnorDataUpdateCoordinator]):
"""Base class for melnor entities.""" """Base class for melnor entities."""
_device: Device _device: Device
@ -73,7 +76,7 @@ class MelnorBluetoothBaseEntity(CoordinatorEntity[MelnorDataUpdateCoordinator]):
return self._device.is_connected return self._device.is_connected
class MelnorZoneEntity(MelnorBluetoothBaseEntity): class MelnorZoneEntity(MelnorBluetoothEntity):
"""Base class for valves that define themselves as child devices.""" """Base class for valves that define themselves as child devices."""
_valve: Valve _valve: Valve
@ -81,11 +84,17 @@ class MelnorZoneEntity(MelnorBluetoothBaseEntity):
def __init__( def __init__(
self, self,
coordinator: MelnorDataUpdateCoordinator, coordinator: MelnorDataUpdateCoordinator,
entity_description: EntityDescription,
valve: Valve, valve: Valve,
) -> None: ) -> None:
"""Initialize a valve entity.""" """Initialize a valve entity."""
super().__init__(coordinator) 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._valve = valve
self._attr_device_info = DeviceInfo( self._attr_device_info = DeviceInfo(
@ -94,3 +103,28 @@ class MelnorZoneEntity(MelnorBluetoothBaseEntity):
name=f"Zone {valve.id + 1}", name=f"Zone {valve.id + 1}",
via_device=(DOMAIN, self._device.mac), 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 collections.abc import Callable
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime
from typing import Any from typing import Any
from melnor_bluetooth.device import Device from melnor_bluetooth.device import Device, Valve
from homeassistant.components.sensor import ( from homeassistant.components.sensor import (
SensorDeviceClass, SensorDeviceClass,
@ -19,9 +20,26 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity import EntityCategory from homeassistant.helpers.entity import EntityCategory
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import StateType from homeassistant.helpers.typing import StateType
from homeassistant.util import dt as dt_util
from .const import DOMAIN 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 @dataclass
@ -31,6 +49,20 @@ class MelnorSensorEntityDescriptionMixin:
state_fn: Callable[[Device], Any] 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 @dataclass
class MelnorSensorEntityDescription( class MelnorSensorEntityDescription(
SensorEntityDescription, MelnorSensorEntityDescriptionMixin SensorEntityDescription, MelnorSensorEntityDescriptionMixin
@ -38,7 +70,7 @@ class MelnorSensorEntityDescription(
"""Describes Melnor sensor entity.""" """Describes Melnor sensor entity."""
sensors = [ DEVICE_ENTITY_DESCRIPTIONS: list[MelnorSensorEntityDescription] = [
MelnorSensorEntityDescription( MelnorSensorEntityDescription(
device_class=SensorDeviceClass.BATTERY, device_class=SensorDeviceClass.BATTERY,
entity_category=EntityCategory.DIAGNOSTIC, 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( async def async_setup_entry(
hass: HomeAssistant, hass: HomeAssistant,
@ -70,16 +111,28 @@ async def async_setup_entry(
coordinator: MelnorDataUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id] coordinator: MelnorDataUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id]
# Device-level sensors
async_add_entities( async_add_entities(
MelnorSensorEntity( MelnorSensorEntity(
coordinator, coordinator,
description, 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.""" """Representation of a Melnor sensor."""
entity_description: MelnorSensorEntityDescription entity_description: MelnorSensorEntityDescription
@ -98,5 +151,25 @@ class MelnorSensorEntity(MelnorBluetoothBaseEntity, SensorEntity):
@property @property
def native_value(self) -> StateType: def native_value(self) -> StateType:
"""Return the battery level.""" """Return the sensor value."""
return self.entity_description.state_fn(self._device) 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 homeassistant.helpers.entity_platform import AddEntitiesCallback
from .const import DOMAIN from .const import DOMAIN
from .models import MelnorDataUpdateCoordinator, MelnorZoneEntity from .models import (
MelnorDataUpdateCoordinator,
MelnorZoneEntity,
get_entities_for_valves,
)
@dataclass @dataclass
@ -36,12 +40,11 @@ class MelnorSwitchEntityDescription(
"""Describes Melnor switch entity.""" """Describes Melnor switch entity."""
switches = [ ZONE_ENTITY_DESCRIPTIONS = [
MelnorSwitchEntityDescription( MelnorSwitchEntityDescription(
device_class=SwitchDeviceClass.SWITCH, device_class=SwitchDeviceClass.SWITCH,
icon="mdi:sprinkler", icon="mdi:sprinkler",
key="manual", key="manual",
name="Manual",
on_off_fn=lambda valve, bool: valve.set_is_watering(bool), on_off_fn=lambda valve, bool: valve.set_is_watering(bool),
state_fn=lambda valve: valve.is_watering, state_fn=lambda valve: valve.is_watering,
) )
@ -51,22 +54,21 @@ switches = [
async def async_setup_entry( async def async_setup_entry(
hass: HomeAssistant, hass: HomeAssistant,
config_entry: ConfigEntry, config_entry: ConfigEntry,
async_add_devices: AddEntitiesCallback, async_add_entities: AddEntitiesCallback,
) -> None: ) -> None:
"""Set up the switch platform.""" """Set up the switch platform."""
entities: list[MelnorZoneSwitch] = []
coordinator: MelnorDataUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id] 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 async_add_entities(
for i in range(1, 5): get_entities_for_valves(
valve = coordinator.data[f"zone{i}"] coordinator,
if valve is not None: ZONE_ENTITY_DESCRIPTIONS,
lambda valve, description: MelnorZoneSwitch(
for description in switches: coordinator, description, valve
entities.append(MelnorZoneSwitch(coordinator, valve, description)) ),
)
async_add_devices(entities) )
class MelnorZoneSwitch(MelnorZoneEntity, SwitchEntity): class MelnorZoneSwitch(MelnorZoneEntity, SwitchEntity):
@ -77,14 +79,11 @@ class MelnorZoneSwitch(MelnorZoneEntity, SwitchEntity):
def __init__( def __init__(
self, self,
coordinator: MelnorDataUpdateCoordinator, coordinator: MelnorDataUpdateCoordinator,
valve: Valve,
entity_description: MelnorSwitchEntityDescription, entity_description: MelnorSwitchEntityDescription,
valve: Valve,
) -> None: ) -> None:
"""Initialize a switch for a melnor device.""" """Initialize a switch for a melnor device."""
super().__init__(coordinator, valve) super().__init__(coordinator, entity_description, valve)
self._attr_unique_id = f"{self._device.mac}-zone{valve.id}-manual"
self.entity_description = entity_description
@property @property
def is_on(self) -> bool: def is_on(self) -> bool:

View File

@ -58,9 +58,11 @@ class MockedValve:
_id: int _id: int
_is_watering: bool _is_watering: bool
_manual_watering_minutes: int _manual_watering_minutes: int
_end_time: int
def __init__(self, identifier: int) -> None: def __init__(self, identifier: int) -> None:
"""Initialize a mocked valve.""" """Initialize a mocked valve."""
self._end_time = 0
self._id = identifier self._id = identifier
self._is_watering = False self._is_watering = False
self._manual_watering_minutes = 0 self._manual_watering_minutes = 0
@ -79,6 +81,20 @@ class MockedValve:
"""Set the valve to manual watering.""" """Set the valve to manual watering."""
self._is_watering = is_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): def mock_config_entry(hass: HomeAssistant):
"""Return a mock config entry.""" """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 __future__ import annotations
from freezegun import freeze_time
from homeassistant.components.sensor import SensorDeviceClass, SensorStateClass from homeassistant.components.sensor import SensorDeviceClass, SensorStateClass
from homeassistant.const import PERCENTAGE, SIGNAL_STRENGTH_DECIBELS_MILLIWATT from homeassistant.const import PERCENTAGE, SIGNAL_STRENGTH_DECIBELS_MILLIWATT
from homeassistant.helpers import entity_registry from homeassistant.helpers import entity_registry
import homeassistant.util.dt as dt_util
from .conftest import ( from .conftest import (
mock_config_entry, mock_config_entry,
@ -14,6 +17,8 @@ from .conftest import (
patch_melnor_device, patch_melnor_device,
) )
from tests.common import async_fire_time_changed
async def test_battery_sensor(hass): async def test_battery_sensor(hass):
"""Test the battery sensor.""" """Test the battery sensor."""
@ -31,6 +36,42 @@ async def test_battery_sensor(hass):
assert battery_sensor.attributes["state_class"] == SensorStateClass.MEASUREMENT 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): async def test_rssi_sensor(hass):
"""Test the rssi sensor.""" """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) assert await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done() 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["device_class"] == SwitchDeviceClass.SWITCH
assert switch.attributes["icon"] == "mdi:sprinkler" assert switch.attributes["icon"] == "mdi:sprinkler"
@ -32,30 +32,34 @@ async def test_manual_watering_switch_on_off(hass):
entry = mock_config_entry(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) assert await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done() 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 assert switch.state is STATE_OFF
await hass.services.async_call( await hass.services.async_call(
"switch", "switch",
"turn_on", "turn_on",
{"entity_id": "switch.zone_1_manual"}, {"entity_id": "switch.zone_1"},
blocking=True, blocking=True,
) )
switch = hass.states.get("switch.zone_1_manual") switch = hass.states.get("switch.zone_1")
assert switch.state is STATE_ON assert switch.state is STATE_ON
assert device.zone1.is_watering is True
await hass.services.async_call( await hass.services.async_call(
"switch", "switch",
"turn_off", "turn_off",
{"entity_id": "switch.zone_1_manual"}, {"entity_id": "switch.zone_1"},
blocking=True, blocking=True,
) )
switch = hass.states.get("switch.zone_1_manual") switch = hass.states.get("switch.zone_1")
assert switch.state is STATE_OFF assert switch.state is STATE_OFF
assert device.zone1.is_watering is False