Add Melnor Bluetooth valve watering Integration (#70457)

This commit is contained in:
Justin Vanderhooft 2022-08-30 17:06:44 -04:00 committed by GitHub
parent f43f440739
commit 8d94c8f74a
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
16 changed files with 620 additions and 0 deletions

View file

@ -720,6 +720,10 @@ omit =
homeassistant/components/melcloud/const.py
homeassistant/components/melcloud/sensor.py
homeassistant/components/melcloud/water_heater.py
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

View file

@ -653,6 +653,8 @@ build.json @home-assistant/supervisor
/tests/components/melcloud/ @vilppuvuorinen
/homeassistant/components/melissa/ @kennedyshead
/tests/components/melissa/ @kennedyshead
/homeassistant/components/melnor/ @vanstinator
/tests/components/melnor/ @vanstinator
/homeassistant/components/met/ @danielhiversen @thimic
/tests/components/met/ @danielhiversen @thimic
/homeassistant/components/met_eireann/ @DylanGore

View file

@ -0,0 +1,74 @@
"""The melnor integration."""
from __future__ import annotations
from melnor_bluetooth.device import Device
from homeassistant.components import bluetooth
from homeassistant.components.bluetooth.match import BluetoothCallbackMatcher
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_ADDRESS, Platform
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import ConfigEntryNotReady
from .const import DOMAIN
from .models import MelnorDataUpdateCoordinator
PLATFORMS: list[Platform] = [Platform.SWITCH]
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up melnor from a config entry."""
hass.data.setdefault(DOMAIN, {}).setdefault(entry.entry_id, {})
ble_device = bluetooth.async_ble_device_from_address(hass, entry.data[CONF_ADDRESS])
if not ble_device:
raise ConfigEntryNotReady(
f"Couldn't find a nearby device for address: {entry.data[CONF_ADDRESS]}"
)
# Create the device and connect immediately so we can pull down
# required attributes before building out our entities
device = Device(ble_device)
await device.connect(retry_attempts=4)
if not device.is_connected:
raise ConfigEntryNotReady(f"Failed to connect to: {device.mac}")
@callback
def _async_update_ble(
service_info: bluetooth.BluetoothServiceInfoBleak,
change: bluetooth.BluetoothChange,
) -> None:
"""Update from a ble callback."""
device.update_ble_device(service_info.device)
bluetooth.async_register_callback(
hass,
_async_update_ble,
BluetoothCallbackMatcher(address=device.mac),
bluetooth.BluetoothScanningMode.PASSIVE,
)
coordinator = MelnorDataUpdateCoordinator(hass, device)
await coordinator.async_config_entry_first_refresh()
hass.data[DOMAIN][entry.entry_id] = coordinator
hass.config_entries.async_setup_platforms(entry, PLATFORMS)
return True
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
device: Device = hass.data[DOMAIN][entry.entry_id].data
await device.disconnect()
if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
hass.data[DOMAIN].pop(entry.entry_id)
return unload_ok

View file

@ -0,0 +1,116 @@
"""Config flow for melnor."""
from __future__ import annotations
from typing import Any
import voluptuous as vol
from homeassistant import config_entries
from homeassistant.components.bluetooth import async_discovered_service_info
from homeassistant.components.bluetooth.models import BluetoothServiceInfoBleak
from homeassistant.const import CONF_ADDRESS
from homeassistant.data_entry_flow import FlowResult
from .const import DOMAIN, MANUFACTURER_DATA_START, MANUFACTURER_ID
class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
"""Handle a config flow for melnor."""
VERSION = 1
def __init__(self) -> None:
"""Initialize the config flow."""
self._discovered_address: str
self._discovered_addresses: list[str] = []
def _create_entry(self, address: str) -> FlowResult:
"""Create an entry for a discovered device."""
return self.async_create_entry(
title=address,
data={
CONF_ADDRESS: address,
},
)
async def async_step_bluetooth_confirm(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
"""Handle user-confirmation of discovered device."""
if user_input is not None:
return self._create_entry(self._discovered_address)
return self.async_show_form(
step_id="bluetooth_confirm",
description_placeholders={"name": self._discovered_address},
)
async def async_step_bluetooth(
self, discovery_info: BluetoothServiceInfoBleak
) -> FlowResult:
"""Handle a flow initialized by Bluetooth discovery."""
address = discovery_info.address
await self.async_set_unique_id(address)
self._abort_if_unique_id_configured(updates={CONF_ADDRESS: address})
self._discovered_address = address
self.context["title_placeholders"] = {"name": address}
return await self.async_step_bluetooth_confirm()
async def async_step_pick_device(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
"""Handle the step to pick discovered device."""
if user_input is not None:
address = user_input[CONF_ADDRESS]
await self.async_set_unique_id(address, raise_on_progress=False)
self._abort_if_unique_id_configured()
return self._create_entry(address)
current_addresses = self._async_current_ids()
for discovery_info in async_discovered_service_info(
self.hass, connectable=True
):
if discovery_info.manufacturer_id == MANUFACTURER_ID and any(
manufacturer_data.startswith(MANUFACTURER_DATA_START)
for manufacturer_data in discovery_info.manufacturer_data.values()
):
address = discovery_info.address
if (
address not in current_addresses
and address not in self._discovered_addresses
):
self._discovered_addresses.append(address)
addresses = {
address
for address in self._discovered_addresses
if address not in current_addresses
}
# Check if there is at least one device
if not addresses:
return self.async_abort(reason="no_devices_found")
return self.async_show_form(
step_id="pick_device",
data_schema=vol.Schema({vol.Required(CONF_ADDRESS): vol.In(addresses)}),
)
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
"""Handle a flow initialized by the user."""
return await self.async_step_pick_device()

View file

@ -0,0 +1,8 @@
"""Constants for the melnor integration."""
DOMAIN = "melnor"
DEFAULT_NAME = "Melnor Bluetooth"
MANUFACTURER_ID = 13
MANUFACTURER_DATA_START = bytearray([89])

View file

@ -0,0 +1,16 @@
{
"after_dependencies": ["bluetooth"],
"bluetooth": [
{
"manufacturer_data_start": [89],
"manufacturer_id": 13
}
],
"codeowners": ["@vanstinator"],
"config_flow": true,
"domain": "melnor",
"documentation": "https://www.home-assistant.io/integrations/melnor",
"iot_class": "local_polling",
"name": "Melnor Bluetooth",
"requirements": ["melnor-bluetooth==0.0.13"]
}

View file

@ -0,0 +1,74 @@
"""Melnor integration models."""
from datetime import timedelta
import logging
from melnor_bluetooth.device import Device
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity import DeviceInfo
from homeassistant.helpers.update_coordinator import (
CoordinatorEntity,
DataUpdateCoordinator,
)
from .const import DOMAIN
_LOGGER = logging.getLogger(__name__)
class MelnorDataUpdateCoordinator(DataUpdateCoordinator[Device]):
"""Melnor data update coordinator."""
_device: Device
def __init__(self, hass: HomeAssistant, device: Device) -> None:
"""Initialize my coordinator."""
super().__init__(
hass,
_LOGGER,
name="Melnor Bluetooth",
update_interval=timedelta(seconds=5),
)
self._device = device
async def _async_update_data(self):
"""Update the device state."""
await self._device.fetch_state()
return self._device
class MelnorBluetoothBaseEntity(CoordinatorEntity[MelnorDataUpdateCoordinator]):
"""Base class for melnor entities."""
_device: Device
def __init__(
self,
coordinator: MelnorDataUpdateCoordinator,
) -> None:
"""Initialize a melnor base entity."""
super().__init__(coordinator)
self._device = coordinator.data
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, self._device.mac)},
manufacturer="Melnor",
model=self._device.model,
name=self._device.name,
)
self._attr_name = self._device.name
self._attr_unique_id = self._device.mac
@callback
def _handle_coordinator_update(self) -> None:
"""Handle updated data from the coordinator."""
self._device = self.coordinator.data
self.async_write_ha_state()
@property
def available(self) -> bool:
"""Return True if entity is available."""
return self._device.is_connected

View file

@ -0,0 +1,13 @@
{
"config": {
"abort": {
"no_devices_found": "There aren't any Melnor Bluetooth devices nearby."
},
"step": {
"bluetooth_confirm": {
"description": "Do you want to add the Melnor Bluetooth valve `{name}` to Home Assistant?",
"title": "Discovered Melnor Bluetooth valve"
}
}
}
}

View file

@ -0,0 +1,75 @@
"""Support for Melnor RainCloud sprinkler water timer."""
from __future__ import annotations
from typing import Any, cast
from melnor_bluetooth.device import Valve
from homeassistant.components.switch import SwitchEntity
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
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
async_add_devices: AddEntitiesCallback,
) -> None:
"""Set up the switch platform."""
switches = []
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))
async_add_devices(switches, True)
class MelnorSwitch(MelnorBluetoothBaseEntity, SwitchEntity):
"""A switch implementation for a melnor device."""
_valve_index: int
_attr_icon = "mdi:sprinkler"
def __init__(
self,
coordinator: MelnorDataUpdateCoordinator,
valve_index: int,
) -> None:
"""Initialize a switch for a melnor device."""
super().__init__(coordinator)
self._valve_index = valve_index
self._attr_unique_id = (
f"switch-{self._attr_unique_id}-zone{self._valve().id}-manual"
)
self._attr_name = f"{self._device.name} Zone {self._valve().id+1}"
@property
def is_on(self) -> bool:
"""Return true if device is on."""
return self._valve().is_watering
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn the device on."""
self._valve().is_watering = 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
await self._device.push_state()
self.async_write_ha_state()
def _valve(self) -> Valve:
return cast(Valve, self._device[f"zone{self._valve_index}"])

View file

@ -0,0 +1,13 @@
{
"config": {
"abort": {
"no_devices_found": "There aren't any Melnor Bluetooth devices nearby."
},
"step": {
"bluetooth_confirm": {
"description": "Do you want to add the Melnor Bluetooth valve `{name}` to Home Assistant?",
"title": "Discovered Melnor Bluetooth valve"
}
}
}
}

View file

@ -138,6 +138,13 @@ BLUETOOTH: list[dict[str, bool | str | int | list[int]]] = [
"domain": "led_ble",
"local_name": "LEDBlue*"
},
{
"domain": "melnor",
"manufacturer_data_start": [
89
],
"manufacturer_id": 13
},
{
"domain": "moat",
"local_name": "Moat_S*",

View file

@ -218,6 +218,7 @@ FLOWS = {
"mazda",
"meater",
"melcloud",
"melnor",
"met",
"met_eireann",
"meteo_france",

View file

@ -1033,6 +1033,9 @@ mcstatus==6.0.0
# homeassistant.components.meater
meater-python==0.0.8
# homeassistant.components.melnor
melnor-bluetooth==0.0.13
# homeassistant.components.message_bird
messagebird==1.2.0

View file

@ -738,6 +738,9 @@ mcstatus==6.0.0
# homeassistant.components.meater
meater-python==0.0.8
# homeassistant.components.melnor
melnor-bluetooth==0.0.13
# homeassistant.components.meteo_france
meteofrance-api==1.0.2

View file

@ -0,0 +1,64 @@
"""Tests for the melnor integration."""
from __future__ import annotations
from unittest.mock import patch
from bleak.backends.device import BLEDevice
from bleak.backends.scanner import AdvertisementData
from homeassistant.components.bluetooth.models import BluetoothServiceInfoBleak
FAKE_ADDRESS_1 = "FAKE-ADDRESS-1"
FAKE_ADDRESS_2 = "FAKE-ADDRESS-2"
FAKE_SERVICE_INFO_1 = BluetoothServiceInfoBleak(
name="YM_TIMER%",
address=FAKE_ADDRESS_1,
rssi=-63,
manufacturer_data={
13: b"Y\x08\x02\x8f\x00\x00\x00\x00\x00\x00\xf0\x00\x00\xf0\x00\x00\xf0\x00\x00\xf0*\x9b\xcf\xbc"
},
service_uuids=[],
service_data={},
source="local",
device=BLEDevice(FAKE_ADDRESS_1, None),
advertisement=AdvertisementData(local_name=""),
time=0,
connectable=True,
)
FAKE_SERVICE_INFO_2 = BluetoothServiceInfoBleak(
name="YM_TIMER%",
address=FAKE_ADDRESS_2,
rssi=-63,
manufacturer_data={
13: b"Y\x08\x02\x8f\x00\x00\x00\x00\x00\x00\xf0\x00\x00\xf0\x00\x00\xf0\x00\x00\xf0*\x9b\xcf\xbc"
},
service_uuids=[],
service_data={},
source="local",
device=BLEDevice(FAKE_ADDRESS_2, None),
advertisement=AdvertisementData(local_name=""),
time=0,
connectable=True,
)
def patch_async_setup_entry(return_value=True):
"""Patch async setup entry to return True."""
return patch(
"homeassistant.components.melnor.async_setup_entry",
return_value=return_value,
)
def patch_async_discovered_service_info(
return_value: list[BluetoothServiceInfoBleak] = [FAKE_SERVICE_INFO_1],
):
"""Patch async_discovered_service_info a mocked device info."""
return patch(
"homeassistant.components.melnor.config_flow.async_discovered_service_info",
return_value=return_value,
)

View file

@ -0,0 +1,147 @@
"""Test the melnor config flow."""
import pytest
import voluptuous as vol
from homeassistant import config_entries
from homeassistant.components.melnor.const import DOMAIN
from homeassistant.const import CONF_ADDRESS, CONF_MAC
from homeassistant.data_entry_flow import FlowResultType
from . import (
FAKE_ADDRESS_1,
FAKE_SERVICE_INFO_1,
FAKE_SERVICE_INFO_2,
patch_async_discovered_service_info,
patch_async_setup_entry,
)
async def test_user_step_no_devices(hass):
"""Test we handle no devices found."""
with patch_async_setup_entry() as mock_setup_entry, patch_async_discovered_service_info(
[]
):
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_USER},
)
assert result["type"] == FlowResultType.ABORT
assert result["reason"] == "no_devices_found"
assert len(mock_setup_entry.mock_calls) == 0
async def test_user_step_discovered_devices(hass):
"""Test we properly handle device picking."""
with patch_async_setup_entry() as mock_setup_entry, patch_async_discovered_service_info():
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_USER},
)
assert result["type"] == FlowResultType.FORM
assert result["step_id"] == "pick_device"
with pytest.raises(vol.MultipleInvalid):
await hass.config_entries.flow.async_configure(
result["flow_id"], user_input={CONF_ADDRESS: "wrong_address"}
)
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input={CONF_ADDRESS: FAKE_ADDRESS_1}
)
assert result2["type"] == FlowResultType.CREATE_ENTRY
assert result2["data"] == {CONF_ADDRESS: FAKE_ADDRESS_1}
assert len(mock_setup_entry.mock_calls) == 1
async def test_user_step_with_existing_device(hass):
"""Test we properly handle device picking."""
with patch_async_setup_entry() as mock_setup_entry, patch_async_discovered_service_info(
[FAKE_SERVICE_INFO_1, FAKE_SERVICE_INFO_2]
):
# Create the config flow
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={
"source": config_entries.SOURCE_BLUETOOTH,
"step_id": "bluetooth_confirm",
"user_input": {CONF_MAC: FAKE_ADDRESS_1},
},
data=FAKE_SERVICE_INFO_1,
)
# And create an entry
await hass.config_entries.flow.async_configure(result["flow_id"], user_input={})
mock_setup_entry.reset_mock()
# Now open the picker and validate the current address isn't valid
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_USER},
)
assert result["type"] == FlowResultType.FORM
with pytest.raises(vol.MultipleInvalid):
await hass.config_entries.flow.async_configure(
result["flow_id"], user_input={CONF_ADDRESS: FAKE_ADDRESS_1}
)
assert len(mock_setup_entry.mock_calls) == 0
async def test_bluetooth_discovered(hass):
"""Test we short circuit to config entry creation."""
with patch_async_setup_entry() as mock_setup_entry:
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_BLUETOOTH},
data=FAKE_SERVICE_INFO_1,
)
assert result["type"] == FlowResultType.FORM
assert result["step_id"] == "bluetooth_confirm"
assert result["description_placeholders"] == {"name": FAKE_ADDRESS_1}
assert len(mock_setup_entry.mock_calls) == 0
async def test_bluetooth_confirm(hass):
"""Test we short circuit to config entry creation."""
with patch_async_setup_entry() as mock_setup_entry:
# Create the config flow
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={
"source": config_entries.SOURCE_BLUETOOTH,
"step_id": "bluetooth_confirm",
"user_input": {CONF_MAC: FAKE_ADDRESS_1},
},
data=FAKE_SERVICE_INFO_1,
)
# Interact with it like a user would
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input={}
)
assert result2["type"] == FlowResultType.CREATE_ENTRY
assert result2["title"] == FAKE_ADDRESS_1
assert result2["data"] == {CONF_ADDRESS: FAKE_ADDRESS_1}
assert len(mock_setup_entry.mock_calls) == 1