Add support for Flo by Moen water shutoff devices (#38171)

This commit is contained in:
David F. Mulcahey 2020-08-10 08:19:38 -04:00 committed by GitHub
parent 07de9deab6
commit f1fd8aa51f
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
27 changed files with 1650 additions and 1 deletions

View file

@ -0,0 +1,76 @@
"""The flo integration."""
import asyncio
import logging
from aioflo import async_get_api
from aioflo.errors import RequestError
import voluptuous as vol
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .const import DOMAIN
from .device import FloDeviceDataUpdateCoordinator
CONFIG_SCHEMA = vol.Schema({DOMAIN: vol.Schema({})}, extra=vol.ALLOW_EXTRA)
_LOGGER = logging.getLogger(__name__)
PLATFORMS = ["sensor"]
async def async_setup(hass: HomeAssistant, config: dict):
"""Set up the flo component."""
hass.data[DOMAIN] = {}
return True
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry):
"""Set up flo from a config entry."""
hass.data[DOMAIN][entry.entry_id] = {}
session = async_get_clientsession(hass)
try:
hass.data[DOMAIN][entry.entry_id]["client"] = client = await async_get_api(
entry.data[CONF_USERNAME], entry.data[CONF_PASSWORD], session=session
)
except RequestError:
raise ConfigEntryNotReady
user_info = await client.user.get_info(include_location_info=True)
_LOGGER.debug("Flo user information with locations: %s", user_info)
hass.data[DOMAIN]["devices"] = devices = [
FloDeviceDataUpdateCoordinator(hass, client, location["id"], device["id"])
for location in user_info["locations"]
for device in location["devices"]
]
tasks = [device.async_refresh() for device in devices]
await asyncio.gather(*tasks)
for component in PLATFORMS:
hass.async_create_task(
hass.config_entries.async_forward_entry_setup(entry, component)
)
return True
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry):
"""Unload a config entry."""
unload_ok = all(
await asyncio.gather(
*[
hass.config_entries.async_forward_entry_unload(entry, component)
for component in PLATFORMS
]
)
)
if unload_ok:
hass.data[DOMAIN].pop(entry.entry_id)
return unload_ok

View file

@ -0,0 +1,67 @@
"""Config flow for flo integration."""
import logging
from aioflo import async_get_api
from aioflo.errors import RequestError
import voluptuous as vol
from homeassistant import config_entries, core, exceptions
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .const import DOMAIN # pylint:disable=unused-import
_LOGGER = logging.getLogger(__name__)
DATA_SCHEMA = vol.Schema({"username": str, "password": str})
async def validate_input(hass: core.HomeAssistant, data):
"""Validate the user input allows us to connect.
Data has the keys from DATA_SCHEMA with values provided by the user.
"""
session = async_get_clientsession(hass)
try:
api = await async_get_api(
data[CONF_USERNAME], data[CONF_PASSWORD], session=session
)
except RequestError:
raise CannotConnect
except Exception: # pylint: disable=broad-except
raise CannotConnect
user_info = await api.user.get_info()
a_location_id = user_info["locations"][0]["id"]
location_info = await api.location.get_info(a_location_id)
return {"title": location_info["nickname"]}
class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
"""Handle a config flow for flo."""
VERSION = 1
CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL
async def async_step_user(self, user_input=None):
"""Handle the initial step."""
errors = {}
if user_input is not None:
try:
info = await validate_input(self.hass, user_input)
return self.async_create_entry(title=info["title"], data=user_input)
except CannotConnect:
errors["base"] = "cannot_connect"
except Exception: # pylint: disable=broad-except
_LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
return self.async_show_form(
step_id="user", data_schema=DATA_SCHEMA, errors=errors
)
class CannotConnect(exceptions.HomeAssistantError):
"""Error to indicate we cannot connect."""

View file

@ -0,0 +1,3 @@
"""Constants for the flo integration."""
DOMAIN = "flo"

View file

@ -0,0 +1,156 @@
"""Flo device object."""
import asyncio
from datetime import datetime, timedelta
import logging
from typing import Any, Dict, Optional
from aioflo.api import API
from aioflo.errors import RequestError
from async_timeout import timeout
from homeassistant.helpers.typing import HomeAssistantType
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
import homeassistant.util.dt as dt_util
from .const import DOMAIN as FLO_DOMAIN
_LOGGER = logging.getLogger(__name__)
class FloDeviceDataUpdateCoordinator(DataUpdateCoordinator):
"""Flo device object."""
def __init__(
self, hass: HomeAssistantType, api_client: API, location_id: str, device_id: str
):
"""Initialize the device."""
self.hass: HomeAssistantType = hass
self.api_client: API = api_client
self._flo_location_id: str = location_id
self._flo_device_id: str = device_id
self._manufacturer: str = "Flo by Moen"
self._device_information: Optional[Dict[str, Any]] = None
self._water_usage: Optional[Dict[str, Any]] = None
super().__init__(
hass,
_LOGGER,
name=f"{FLO_DOMAIN}-{device_id}",
update_interval=timedelta(seconds=60),
)
async def _async_update_data(self):
"""Update data via library."""
try:
async with timeout(10):
await asyncio.gather(
*[self._update_device(), self._update_consumption_data()]
)
except (RequestError) as error:
raise UpdateFailed(error)
@property
def location_id(self) -> str:
"""Return Flo location id."""
return self._flo_location_id
@property
def id(self) -> str:
"""Return Flo device id."""
return self._flo_device_id
@property
def device_name(self) -> str:
"""Return device name."""
return f"{self.manufacturer} {self.model}"
@property
def manufacturer(self) -> str:
"""Return manufacturer for device."""
return self._manufacturer
@property
def mac_address(self) -> str:
"""Return ieee address for device."""
return self._device_information["macAddress"]
@property
def model(self) -> str:
"""Return model for device."""
return self._device_information["deviceModel"]
@property
def rssi(self) -> float:
"""Return rssi for device."""
return self._device_information["connectivity"]["rssi"]
@property
def last_heard_from_time(self) -> str:
"""Return lastHeardFromTime for device."""
return self._device_information["lastHeardFromTime"]
@property
def device_type(self) -> str:
"""Return the device type for the device."""
return self._device_information["deviceType"]
@property
def available(self) -> bool:
"""Return True if device is available."""
return self.last_update_success and self._device_information["isConnected"]
@property
def current_system_mode(self) -> str:
"""Return the current system mode."""
return self._device_information["systemMode"]["lastKnown"]
@property
def target_system_mode(self) -> str:
"""Return the target system mode."""
return self._device_information["systemMode"]["target"]
@property
def current_flow_rate(self) -> float:
"""Return current flow rate in gpm."""
return self._device_information["telemetry"]["current"]["gpm"]
@property
def current_psi(self) -> float:
"""Return the current pressure in psi."""
return self._device_information["telemetry"]["current"]["psi"]
@property
def temperature(self) -> float:
"""Return the current temperature in degrees F."""
return self._device_information["telemetry"]["current"]["tempF"]
@property
def consumption_today(self) -> float:
"""Return the current consumption for today in gallons."""
return self._water_usage["aggregations"]["sumTotalGallonsConsumed"]
@property
def firmware_version(self) -> str:
"""Return the firmware version for the device."""
return self._device_information["fwVersion"]
@property
def serial_number(self) -> str:
"""Return the serial number for the device."""
return self._device_information["serialNumber"]
async def _update_device(self, *_) -> None:
"""Update the device information from the API."""
self._device_information = await self.api_client.device.get_info(
self._flo_device_id
)
_LOGGER.debug("Flo device data: %s", self._device_information)
async def _update_consumption_data(self, *_) -> None:
"""Update water consumption data from the API."""
today = dt_util.now().date()
start_date = datetime(today.year, today.month, today.day, 0, 0)
end_date = datetime(today.year, today.month, today.day, 23, 59, 59, 999000)
self._water_usage = await self.api_client.water.get_consumption_info(
self._flo_location_id, start_date, end_date
)
_LOGGER.debug("Updated Flo consumption data: %s", self._water_usage)

View file

@ -0,0 +1,70 @@
"""Base entity class for Flo entities."""
from typing import Any, Dict
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC
from homeassistant.helpers.entity import Entity
from .const import DOMAIN as FLO_DOMAIN
from .device import FloDeviceDataUpdateCoordinator
class FloEntity(Entity):
"""A base class for Flo entities."""
def __init__(
self,
entity_type: str,
name: str,
device: FloDeviceDataUpdateCoordinator,
**kwargs,
):
"""Init Flo entity."""
self._unique_id: str = f"{device.mac_address}_{entity_type}"
self._name: str = name
self._device: FloDeviceDataUpdateCoordinator = device
self._state: Any = None
@property
def name(self) -> str:
"""Return Entity's default name."""
return self._name
@property
def unique_id(self) -> str:
"""Return a unique ID."""
return self._unique_id
@property
def device_info(self) -> Dict[str, Any]:
"""Return a device description for device registry."""
return {
"identifiers": {(FLO_DOMAIN, self._device.id)},
"connections": {(CONNECTION_NETWORK_MAC, self._device.mac_address)},
"manufacturer": self._device.manufacturer,
"model": self._device.model,
"name": self._device.device_name,
"sw_version": self._device.firmware_version,
}
@property
def available(self) -> bool:
"""Return True if device is available."""
return self._device.available
@property
def force_update(self) -> bool:
"""Force update this entity."""
return False
@property
def should_poll(self) -> bool:
"""Poll state from device."""
return True
async def async_update(self):
"""Update Flo entity."""
async def async_added_to_hass(self):
"""When entity is added to hass."""
self.async_on_remove(self._device.async_add_listener(self.async_write_ha_state))

View file

@ -0,0 +1,12 @@
{
"domain": "flo",
"name": "Flo",
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/flo",
"requirements": ["aioflo==0.4.0"],
"ssdp": [],
"zeroconf": [],
"homekit": {},
"dependencies": [],
"codeowners": ["@dmulcahey"]
}

View file

@ -0,0 +1,158 @@
"""Support for Flo Water Monitor sensors."""
from typing import List, Optional
from homeassistant.const import (
DEVICE_CLASS_PRESSURE,
DEVICE_CLASS_TEMPERATURE,
PRESSURE_PSI,
TEMP_CELSIUS,
VOLUME_GALLONS,
)
from homeassistant.util.temperature import fahrenheit_to_celsius
from .const import DOMAIN as FLO_DOMAIN
from .device import FloDeviceDataUpdateCoordinator
from .entity import FloEntity
DEPENDENCIES = ["flo"]
WATER_ICON = "mdi:water"
GAUGE_ICON = "mdi:gauge"
NAME_DAILY_USAGE = "Today's Water Usage"
NAME_CURRENT_SYSTEM_MODE = "Current System Mode"
NAME_FLOW_RATE = "Water Flow Rate"
NAME_TEMPERATURE = "Water Temperature"
NAME_WATER_PRESSURE = "Water Pressure"
async def async_setup_entry(hass, config_entry, async_add_entities):
"""Set up the Flo sensors from config entry."""
devices: List[FloDeviceDataUpdateCoordinator] = hass.data[FLO_DOMAIN]["devices"]
entities = []
entities.extend([FloDailyUsageSensor(device) for device in devices])
entities.extend([FloSystemModeSensor(device) for device in devices])
entities.extend([FloCurrentFlowRateSensor(device) for device in devices])
entities.extend([FloTemperatureSensor(device) for device in devices])
entities.extend([FloPressureSensor(device) for device in devices])
async_add_entities(entities, True)
class FloDailyUsageSensor(FloEntity):
"""Monitors the daily water usage."""
def __init__(self, device):
"""Initialize the daily water usage sensor."""
super().__init__("daily_consumption", NAME_DAILY_USAGE, device)
self._state: float = None
@property
def icon(self) -> str:
"""Return the daily usage icon."""
return WATER_ICON
@property
def state(self) -> Optional[float]:
"""Return the current daily usage."""
if self._device.consumption_today is None:
return None
return round(self._device.consumption_today, 1)
@property
def unit_of_measurement(self) -> str:
"""Return gallons as the unit measurement for water."""
return VOLUME_GALLONS
class FloSystemModeSensor(FloEntity):
"""Monitors the current Flo system mode."""
def __init__(self, device):
"""Initialize the system mode sensor."""
super().__init__("current_system_mode", NAME_CURRENT_SYSTEM_MODE, device)
self._state: str = None
@property
def state(self) -> Optional[str]:
"""Return the current system mode."""
if not self._device.current_system_mode:
return None
return self._device.current_system_mode
class FloCurrentFlowRateSensor(FloEntity):
"""Monitors the current water flow rate."""
def __init__(self, device):
"""Initialize the flow rate sensor."""
super().__init__("current_flow_rate", NAME_FLOW_RATE, device)
self._state: float = None
@property
def icon(self) -> str:
"""Return the daily usage icon."""
return GAUGE_ICON
@property
def state(self) -> Optional[float]:
"""Return the current flow rate."""
if self._device.current_flow_rate is None:
return None
return round(self._device.current_flow_rate, 1)
@property
def unit_of_measurement(self) -> str:
"""Return the unit measurement."""
return "gpm"
class FloTemperatureSensor(FloEntity):
"""Monitors the temperature."""
def __init__(self, device):
"""Initialize the temperature sensor."""
super().__init__("temperature", NAME_TEMPERATURE, device)
self._state: float = None
@property
def state(self) -> Optional[float]:
"""Return the current temperature."""
if self._device.temperature is None:
return None
return round(fahrenheit_to_celsius(self._device.temperature), 1)
@property
def unit_of_measurement(self) -> str:
"""Return gallons as the unit measurement for water."""
return TEMP_CELSIUS
@property
def device_class(self) -> Optional[str]:
"""Return the device class for this sensor."""
return DEVICE_CLASS_TEMPERATURE
class FloPressureSensor(FloEntity):
"""Monitors the water pressure."""
def __init__(self, device):
"""Initialize the pressure sensor."""
super().__init__("water_pressure", NAME_WATER_PRESSURE, device)
self._state: float = None
@property
def state(self) -> Optional[float]:
"""Return the current water pressure."""
if self._device.current_psi is None:
return None
return round(self._device.current_psi, 1)
@property
def unit_of_measurement(self) -> str:
"""Return gallons as the unit measurement for water."""
return PRESSURE_PSI
@property
def device_class(self) -> Optional[str]:
"""Return the device class for this sensor."""
return DEVICE_CLASS_PRESSURE

View file

@ -0,0 +1,22 @@
{
"title": "flo",
"config": {
"step": {
"user": {
"data": {
"host": "[%key:common::config_flow::data::host%]",
"username": "[%key:common::config_flow::data::username%]",
"password": "[%key:common::config_flow::data::password%]"
}
}
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
}
}
}

View file

@ -0,0 +1,22 @@
{
"config": {
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"step": {
"user": {
"data": {
"host": "[%key:common::config_flow::data::host%]",
"password": "[%key:common::config_flow::data::password%]",
"username": "[%key:common::config_flow::data::username%]"
}
}
}
},
"title": "flo"
}

View file

@ -51,6 +51,7 @@ FLOWS = [
"enocean",
"esphome",
"flick_electric",
"flo",
"flume",
"flunearyou",
"forked_daapd",