From f1fd8aa51faac0181a6ed1995489f344526e970b Mon Sep 17 00:00:00 2001 From: "David F. Mulcahey" Date: Mon, 10 Aug 2020 08:19:38 -0400 Subject: [PATCH] Add support for Flo by Moen water shutoff devices (#38171) --- CODEOWNERS | 1 + homeassistant/components/flo/__init__.py | 76 +++++ homeassistant/components/flo/config_flow.py | 67 ++++ homeassistant/components/flo/const.py | 3 + homeassistant/components/flo/device.py | 156 +++++++++ homeassistant/components/flo/entity.py | 70 ++++ homeassistant/components/flo/manifest.json | 12 + homeassistant/components/flo/sensor.py | 158 +++++++++ homeassistant/components/flo/strings.json | 22 ++ .../components/flo/translations/en.json | 22 ++ homeassistant/generated/config_flows.py | 1 + requirements_all.txt | 3 + requirements_test_all.txt | 3 + tests/components/flo/__init__.py | 1 + tests/components/flo/common.py | 12 + tests/components/flo/conftest.py | 83 +++++ tests/components/flo/test_config_flow.py | 46 +++ tests/components/flo/test_device.py | 50 +++ tests/components/flo/test_init.py | 16 + tests/components/flo/test_sensor.py | 24 ++ tests/fixtures/flo/device_info_response.json | 238 ++++++++++++++ .../flo/location_info_base_response.json | 89 +++++ ...location_info_expand_devices_response.json | 308 ++++++++++++++++++ .../fixtures/flo/user_info_base_response.json | 34 ++ .../user_info_expand_locations_response.json | 120 +++++++ .../flo/water_consumption_info_response.json | 34 ++ tests/test_util/aiohttp.py | 2 +- 27 files changed, 1650 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/flo/__init__.py create mode 100644 homeassistant/components/flo/config_flow.py create mode 100644 homeassistant/components/flo/const.py create mode 100644 homeassistant/components/flo/device.py create mode 100644 homeassistant/components/flo/entity.py create mode 100644 homeassistant/components/flo/manifest.json create mode 100644 homeassistant/components/flo/sensor.py create mode 100644 homeassistant/components/flo/strings.json create mode 100644 homeassistant/components/flo/translations/en.json create mode 100644 tests/components/flo/__init__.py create mode 100644 tests/components/flo/common.py create mode 100644 tests/components/flo/conftest.py create mode 100644 tests/components/flo/test_config_flow.py create mode 100644 tests/components/flo/test_device.py create mode 100644 tests/components/flo/test_init.py create mode 100644 tests/components/flo/test_sensor.py create mode 100644 tests/fixtures/flo/device_info_response.json create mode 100644 tests/fixtures/flo/location_info_base_response.json create mode 100644 tests/fixtures/flo/location_info_expand_devices_response.json create mode 100644 tests/fixtures/flo/user_info_base_response.json create mode 100644 tests/fixtures/flo/user_info_expand_locations_response.json create mode 100644 tests/fixtures/flo/water_consumption_info_response.json diff --git a/CODEOWNERS b/CODEOWNERS index 3a8bfaa4842..c2198123382 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -134,6 +134,7 @@ homeassistant/components/filter/* @dgomes homeassistant/components/firmata/* @DaAwesomeP homeassistant/components/fixer/* @fabaff homeassistant/components/flick_electric/* @ZephireNZ +homeassistant/components/flo/* @dmulcahey homeassistant/components/flock/* @fabaff homeassistant/components/flume/* @ChrisMandich @bdraco homeassistant/components/flunearyou/* @bachya diff --git a/homeassistant/components/flo/__init__.py b/homeassistant/components/flo/__init__.py new file mode 100644 index 00000000000..2c267addb0c --- /dev/null +++ b/homeassistant/components/flo/__init__.py @@ -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 diff --git a/homeassistant/components/flo/config_flow.py b/homeassistant/components/flo/config_flow.py new file mode 100644 index 00000000000..1f8e5fc08bd --- /dev/null +++ b/homeassistant/components/flo/config_flow.py @@ -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.""" diff --git a/homeassistant/components/flo/const.py b/homeassistant/components/flo/const.py new file mode 100644 index 00000000000..edeb469380b --- /dev/null +++ b/homeassistant/components/flo/const.py @@ -0,0 +1,3 @@ +"""Constants for the flo integration.""" + +DOMAIN = "flo" diff --git a/homeassistant/components/flo/device.py b/homeassistant/components/flo/device.py new file mode 100644 index 00000000000..ad7973f4c9a --- /dev/null +++ b/homeassistant/components/flo/device.py @@ -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) diff --git a/homeassistant/components/flo/entity.py b/homeassistant/components/flo/entity.py new file mode 100644 index 00000000000..10ffa835454 --- /dev/null +++ b/homeassistant/components/flo/entity.py @@ -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)) diff --git a/homeassistant/components/flo/manifest.json b/homeassistant/components/flo/manifest.json new file mode 100644 index 00000000000..cfcb6db1c5f --- /dev/null +++ b/homeassistant/components/flo/manifest.json @@ -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"] +} diff --git a/homeassistant/components/flo/sensor.py b/homeassistant/components/flo/sensor.py new file mode 100644 index 00000000000..2cbc43e8cd8 --- /dev/null +++ b/homeassistant/components/flo/sensor.py @@ -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 diff --git a/homeassistant/components/flo/strings.json b/homeassistant/components/flo/strings.json new file mode 100644 index 00000000000..7da0d2df2be --- /dev/null +++ b/homeassistant/components/flo/strings.json @@ -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%]" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/flo/translations/en.json b/homeassistant/components/flo/translations/en.json new file mode 100644 index 00000000000..9434a81b3e6 --- /dev/null +++ b/homeassistant/components/flo/translations/en.json @@ -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" +} \ No newline at end of file diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 1bf776f0849..f6171eb9f47 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -51,6 +51,7 @@ FLOWS = [ "enocean", "esphome", "flick_electric", + "flo", "flume", "flunearyou", "forked_daapd", diff --git a/requirements_all.txt b/requirements_all.txt index 61d276e060e..bcd39c115c7 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -160,6 +160,9 @@ aiodns==2.0.0 # homeassistant.components.esphome aioesphomeapi==2.6.1 +# homeassistant.components.flo +aioflo==0.4.0 + # homeassistant.components.freebox aiofreepybox==0.0.8 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f1e08cfd658..3c1a97e6bfd 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -88,6 +88,9 @@ aiodns==2.0.0 # homeassistant.components.esphome aioesphomeapi==2.6.1 +# homeassistant.components.flo +aioflo==0.4.0 + # homeassistant.components.freebox aiofreepybox==0.0.8 diff --git a/tests/components/flo/__init__.py b/tests/components/flo/__init__.py new file mode 100644 index 00000000000..a207193f500 --- /dev/null +++ b/tests/components/flo/__init__.py @@ -0,0 +1 @@ +"""Tests for the flo integration.""" diff --git a/tests/components/flo/common.py b/tests/components/flo/common.py new file mode 100644 index 00000000000..d4018aae090 --- /dev/null +++ b/tests/components/flo/common.py @@ -0,0 +1,12 @@ +"""Define common test utilities.""" +TEST_ACCOUNT_ID = "aabbccdd" +TEST_DEVICE_ID = "98765" +TEST_EMAIL_ADDRESS = "email@address.com" +TEST_FIRST_NAME = "Tom" +TEST_LAST_NAME = "Jones" +TEST_LOCATION_ID = "mmnnoopp" +TEST_MAC_ADDRESS = "12:34:56:ab:cd:ef" +TEST_PASSWORD = "password" +TEST_PHONE_NUMBER = "+1 123-456-7890" +TEST_TOKEN = "123abc" +TEST_USER_ID = "12345abcde" diff --git a/tests/components/flo/conftest.py b/tests/components/flo/conftest.py new file mode 100644 index 00000000000..5790d3d4eb3 --- /dev/null +++ b/tests/components/flo/conftest.py @@ -0,0 +1,83 @@ +"""Define fixtures available for all tests.""" +import json +import time + +import pytest + +from homeassistant.components.flo.const import DOMAIN as FLO_DOMAIN +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME + +from .common import TEST_EMAIL_ADDRESS, TEST_PASSWORD, TEST_TOKEN, TEST_USER_ID + +from tests.common import MockConfigEntry, load_fixture + + +@pytest.fixture +def config_entry(hass): + """Config entry version 1 fixture.""" + return MockConfigEntry( + domain=FLO_DOMAIN, + data={CONF_USERNAME: TEST_USER_ID, CONF_PASSWORD: TEST_PASSWORD}, + version=1, + ) + + +@pytest.fixture +def aioclient_mock_fixture(aioclient_mock): + """Fixture to provide a aioclient mocker.""" + + now = round(time.time()) + + # Mocks the login response for flo. + aioclient_mock.post( + "https://api.meetflo.com/api/v1/users/auth", + text=json.dumps( + { + "token": TEST_TOKEN, + "tokenPayload": { + "user": {"user_id": TEST_USER_ID, "email": TEST_EMAIL_ADDRESS}, + "timestamp": now, + }, + "tokenExpiration": 86400, + "timeNow": now, + } + ), + headers={"Content-Type": "application/json"}, + status=200, + ) + # Mocks the device for flo. + aioclient_mock.get( + "https://api-gw.meetflo.com/api/v2/devices/98765", + text=load_fixture("flo/device_info_response.json"), + status=200, + headers={"Content-Type": "application/json"}, + ) + # Mocks the water consumption for flo. + aioclient_mock.get( + "https://api-gw.meetflo.com/api/v2/water/consumption", + text=load_fixture("flo/water_consumption_info_response.json"), + status=200, + headers={"Content-Type": "application/json"}, + ) + # Mocks the location info for flo. + aioclient_mock.get( + "https://api-gw.meetflo.com/api/v2/locations/mmnnoopp", + text=load_fixture("flo/location_info_expand_devices_response.json"), + status=200, + headers={"Content-Type": "application/json"}, + ) + # Mocks the user info for flo. + aioclient_mock.get( + "https://api-gw.meetflo.com/api/v2/users/12345abcde", + text=load_fixture("flo/user_info_expand_locations_response.json"), + status=200, + headers={"Content-Type": "application/json"}, + params={"expand": "locations"}, + ) + # Mocks the user info for flo. + aioclient_mock.get( + "https://api-gw.meetflo.com/api/v2/users/12345abcde", + text=load_fixture("flo/user_info_expand_locations_response.json"), + status=200, + headers={"Content-Type": "application/json"}, + ) diff --git a/tests/components/flo/test_config_flow.py b/tests/components/flo/test_config_flow.py new file mode 100644 index 00000000000..bddea76e73c --- /dev/null +++ b/tests/components/flo/test_config_flow.py @@ -0,0 +1,46 @@ +"""Test the flo config flow.""" +from homeassistant import config_entries, setup +from homeassistant.components.flo.const import DOMAIN + +from tests.async_mock import patch + + +async def test_form(hass, aioclient_mock_fixture): + """Test we get the form.""" + await setup.async_setup_component(hass, "persistent_notification", {}) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == "form" + assert result["errors"] == {} + + with patch( + "homeassistant.components.flo.async_setup", return_value=True + ) as mock_setup, patch( + "homeassistant.components.flo.async_setup_entry", return_value=True + ) as mock_setup_entry: + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"username": "test-username", "password": "test-password"}, + ) + + assert result2["type"] == "create_entry" + assert result2["title"] == "Home" + assert result2["data"] == {"username": "test-username", "password": "test-password"} + await hass.async_block_till_done() + assert len(mock_setup.mock_calls) == 1 + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_form_cannot_connect(hass, aioclient_mock): + """Test we handle cannot connect error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], {"username": "test-username", "password": "test-password"} + ) + + assert result2["type"] == "form" + assert result2["errors"] == {"base": "cannot_connect"} diff --git a/tests/components/flo/test_device.py b/tests/components/flo/test_device.py new file mode 100644 index 00000000000..13f6cd5293a --- /dev/null +++ b/tests/components/flo/test_device.py @@ -0,0 +1,50 @@ +"""Define tests for device-related endpoints.""" +from datetime import timedelta + +from homeassistant.components.flo.const import DOMAIN as FLO_DOMAIN +from homeassistant.components.flo.device import FloDeviceDataUpdateCoordinator +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.setup import async_setup_component +from homeassistant.util import dt + +from .common import TEST_PASSWORD, TEST_USER_ID + +from tests.common import async_fire_time_changed + + +async def test_device(hass, config_entry, aioclient_mock_fixture, aioclient_mock): + """Test Flo by Moen device.""" + config_entry.add_to_hass(hass) + assert await async_setup_component( + hass, FLO_DOMAIN, {CONF_USERNAME: TEST_USER_ID, CONF_PASSWORD: TEST_PASSWORD} + ) + await hass.async_block_till_done() + assert len(hass.data[FLO_DOMAIN]["devices"]) == 1 + + device: FloDeviceDataUpdateCoordinator = hass.data[FLO_DOMAIN]["devices"][0] + assert device.api_client is not None + assert device.available + assert device.consumption_today == 3.674 + assert device.current_flow_rate == 0 + assert device.current_psi == 54.20000076293945 + assert device.current_system_mode == "home" + assert device.target_system_mode == "home" + assert device.firmware_version == "6.1.1" + assert device.device_type == "flo_device_v2" + assert device.id == "98765" + assert device.last_heard_from_time == "2020-07-24T12:45:00Z" + assert device.location_id == "mmnnoopp" + assert device.hass is not None + assert device.temperature == 70 + assert device.mac_address == "111111111111" + assert device.model == "flo_device_075_v2" + assert device.manufacturer == "Flo by Moen" + assert device.device_name == "Flo by Moen flo_device_075_v2" + assert device.rssi == -47 + + call_count = aioclient_mock.call_count + + async_fire_time_changed(hass, dt.utcnow() + timedelta(seconds=90)) + await hass.async_block_till_done() + + assert aioclient_mock.call_count == call_count + 2 diff --git a/tests/components/flo/test_init.py b/tests/components/flo/test_init.py new file mode 100644 index 00000000000..c0eaf535f35 --- /dev/null +++ b/tests/components/flo/test_init.py @@ -0,0 +1,16 @@ +"""Test init.""" +from homeassistant.components.flo.const import DOMAIN as FLO_DOMAIN +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.setup import async_setup_component + +from .common import TEST_PASSWORD, TEST_USER_ID + + +async def test_setup_entry(hass, config_entry, aioclient_mock_fixture): + """Test migration of config entry from v1.""" + config_entry.add_to_hass(hass) + assert await async_setup_component( + hass, FLO_DOMAIN, {CONF_USERNAME: TEST_USER_ID, CONF_PASSWORD: TEST_PASSWORD} + ) + await hass.async_block_till_done() + assert len(hass.data[FLO_DOMAIN]["devices"]) == 1 diff --git a/tests/components/flo/test_sensor.py b/tests/components/flo/test_sensor.py new file mode 100644 index 00000000000..5db1fdacfe1 --- /dev/null +++ b/tests/components/flo/test_sensor.py @@ -0,0 +1,24 @@ +"""Test Flo by Moen sensor entities.""" +from homeassistant.components.flo.const import DOMAIN as FLO_DOMAIN +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.setup import async_setup_component + +from .common import TEST_PASSWORD, TEST_USER_ID + + +async def test_sensors(hass, config_entry, aioclient_mock_fixture): + """Test Flo by Moen sensors.""" + config_entry.add_to_hass(hass) + assert await async_setup_component( + hass, FLO_DOMAIN, {CONF_USERNAME: TEST_USER_ID, CONF_PASSWORD: TEST_PASSWORD} + ) + await hass.async_block_till_done() + + assert len(hass.data[FLO_DOMAIN]["devices"]) == 1 + + # we should have 5 entities for the device + assert hass.states.get("sensor.current_system_mode").state == "home" + assert hass.states.get("sensor.today_s_water_usage").state == "3.7" + assert hass.states.get("sensor.water_flow_rate").state == "0" + assert hass.states.get("sensor.water_pressure").state == "54.2" + assert hass.states.get("sensor.water_temperature").state == "21.1" diff --git a/tests/fixtures/flo/device_info_response.json b/tests/fixtures/flo/device_info_response.json new file mode 100644 index 00000000000..24351c0e632 --- /dev/null +++ b/tests/fixtures/flo/device_info_response.json @@ -0,0 +1,238 @@ +{ + "isConnected": true, + "fwVersion": "6.1.1", + "lastHeardFromTime": "2020-07-24T12:45:00Z", + "fwProperties": { + "alarm_away_high_flow_rate_shut_off_enabled": true, + "alarm_away_high_water_use_shut_off_enabled": true, + "alarm_away_long_flow_event_shut_off_enabled": true, + "alarm_away_v2_shut_off_enabled": true, + "alarm_home_high_flow_rate_shut_off_deferment": 300, + "alarm_home_high_flow_rate_shut_off_enabled": true, + "alarm_home_high_water_use_shut_off_deferment": 300, + "alarm_home_high_water_use_shut_off_enabled": true, + "alarm_home_long_flow_event_shut_off_deferment": 300, + "alarm_home_long_flow_event_shut_off_enabled": true, + "alarm_shut_off_enabled": true, + "alarm_shutoff_id": "", + "alarm_shutoff_time_epoch_sec": -1, + "alarm_snooze_enabled": true, + "alarm_suppress_duplicate_duration": 300, + "alarm_suppress_until_event_end": false, + "data_flosense_force_retrain": 1, + "data_flosense_min_flodetect_sec": 0, + "data_flosense_min_irr_sec": 180, + "data_flosense_status_interval": 1200, + "data_flosense_verbosity": 1, + "device_data_free_mb": 1465, + "device_installed": true, + "device_mem_available_kb": 339456, + "device_rootfs_free_kb": 711504, + "device_uptime_sec": 867190, + "feature_mode": "default", + "flodetect_post_enabled": true, + "flodetect_post_frequency": 0, + "flodetect_storage_days": 60, + "flosense_action": "", + "flosense_deployment_result": "success", + "flosense_link": "", + "flosense_shut_off_enabled": true, + "flosense_shut_off_level": 3, + "flosense_state": "active", + "flosense_version_app": "2.5.3", + "flosense_version_model": "2.5.0", + "fw_ver": "6.1.1", + "fw_ver_a": "6.1.1", + "fw_ver_b": "6.0.3", + "heartbeat_frequency": 1800, + "ht_attempt_interval": 60000, + "ht_check_window_max_pressure_decay_limit": 0.1, + "ht_check_window_width": 30000, + "ht_controller": "ultima", + "ht_max_open_closed_pressure_decay_pct_limit": 2, + "ht_max_pressure_growth_limit": 3, + "ht_max_pressure_growth_pct_limit": 3, + "ht_max_valve_closures_per_24h": 0, + "ht_min_computable_point_limit": 3, + "ht_min_pressure_limit": 10, + "ht_min_r_squared_limit": 0.9, + "ht_min_slope_limit": -0.6, + "ht_phase_1_max_pressure_decay_limit": 6, + "ht_phase_1_max_pressure_decay_pct_limit": 10, + "ht_phase_1_time_index": 12000, + "ht_phase_2_max_pressure_decay_limit": 6, + "ht_phase_2_max_pressure_decay_pct_limit": 10, + "ht_phase_2_time_index": 30000, + "ht_phase_3_max_pressure_decay_limit": 3, + "ht_phase_3_max_pressure_decay_pct_limit": 5, + "ht_phase_3_time_index": 240000, + "ht_phase_4_max_pressure_decay_limit": 1.5, + "ht_phase_4_max_pressure_decay_pct_limit": 5, + "ht_phase_4_time_index": 480000, + "ht_pre_delay": 0, + "ht_recent_flow_event_cool_down": 1000, + "ht_retry_on_fail_interval": 900000, + "ht_scheduler": "flosense", + "ht_scheduler_end": "08:00", + "ht_scheduler_start": "06:00", + "ht_scheduler_ultima_allotted_time_1": "06:00", + "ht_scheduler_ultima_allotted_time_2": "07:00", + "ht_scheduler_ultima_allotted_time_3": "", + "ht_times_per_day": 1, + "log_bytes_sent": 0, + "log_enabled": true, + "log_frequency": 3600, + "log_send": false, + "mender_check": false, + "mender_host": "https://mender.flotech.co", + "mender_parts_link": "", + "mender_ping_delay": 300, + "mender_signature": "20200610", + "motor_delay_close": 175, + "motor_delay_open": 0, + "motor_retry_count": 2, + "motor_timeout": 5000, + "mqtt_host": "mqtt.flosecurecloud.com", + "mqtt_port": 8884, + "pes_away_max_duration": 1505, + "pes_away_max_pressure": 150, + "pes_away_max_temperature": 226, + "pes_away_max_volume": 91.8913240498193, + "pes_away_min_pressure": 20, + "pes_away_min_pressure_duration": 5, + "pes_away_min_temperature": 36, + "pes_away_min_temperature_duration": 10, + "pes_away_v1_high_flow_rate": 7.825131772346, + "pes_away_v1_high_flow_rate_duration": 5, + "pes_away_v2_high_flow_rate": 0.5, + "pes_away_v2_high_flow_rate_duration": 5, + "pes_home_high_flow_rate": 1000, + "pes_home_high_flow_rate_duration": 20, + "pes_home_max_duration": 7431, + "pes_home_max_pressure": 150, + "pes_home_max_temperature": 226, + "pes_home_max_volume": 185.56459045410156, + "pes_home_min_pressure": 20, + "pes_home_min_pressure_duration": 5, + "pes_home_min_temperature": 36, + "pes_home_min_temperature_duration": 10, + "pes_moderately_high_pressure": 80, + "pes_moderately_high_pressure_count": 43200, + "pes_moderately_high_pressure_delay": 300, + "pes_moderately_high_pressure_period": 10, + "player_action": "disabled", + "player_flow": 0, + "player_min_pressure": 40, + "player_pressure": 60, + "player_temperature": 50, + "power_downtime_last_24h": 91, + "power_downtime_last_7days": 91, + "power_downtime_last_reboot": 91, + "pt_state": "ok", + "reboot_count": 26, + "reboot_count_7days": 1, + "reboot_reason": "power_cycle", + "s3_bucket_host": "api-bulk.meetflo.com", + "serial_number": "111111111111", + "system_mode": 2, + "tag": "", + "telemetry_batched_enabled": true, + "telemetry_batched_hf_enabled": true, + "telemetry_batched_hf_interval": 10800, + "telemetry_batched_hf_poll_rate": 100, + "telemetry_batched_interval": 300, + "telemetry_batched_pending_storage": 30, + "telemetry_batched_sent_storage": 30, + "telemetry_flow_rate": 0, + "telemetry_pressure": 42.4, + "telemetry_realtime_change_gpm": 0, + "telemetry_realtime_change_psi": 0, + "telemetry_realtime_enabled": true, + "telemetry_realtime_interval": 1, + "telemetry_realtime_packet_uptime": 0, + "telemetry_realtime_session_last_epoch": 1595555701518, + "telemetry_realtime_sessions_7days": 25, + "telemetry_realtime_storage": 7, + "telemetry_realtime_timeout": 300, + "telemetry_temperature": 68, + "valve_actuation_count": 906, + "valve_actuation_timeout_count": 0, + "valve_state": 1, + "vpn_enabled": false, + "vpn_ip": "", + "water_event_enabled": false, + "water_event_min_duration": 2, + "water_event_min_gallons": 0.1, + "wifi_bytes_received": 24164, + "wifi_bytes_sent": 18319, + "wifi_disconnections": 76, + "wifi_rssi": -50, + "wifi_sta_enc": "psk2", + "wifi_sta_ip": "192.168.1.1", + "wifi_sta_ssid": "SOMESSID", + "zit_auto_count": 2363, + "zit_manual_count": 0 + }, + "id": "98765", + "macAddress": "111111111111", + "nickname": "Smart Water Shutoff", + "isPaired": true, + "deviceModel": "flo_device_075_v2", + "deviceType": "flo_device_v2", + "irrigationType": "sprinklers", + "systemMode": { + "isLocked": false, + "shouldInherit": true, + "lastKnown": "home", + "target": "home" + }, + "valve": { "target": "open", "lastKnown": "open" }, + "installStatus": { + "isInstalled": true, + "installDate": "2019-05-04T13:50:04.758Z" + }, + "learning": { "outOfLearningDate": "2019-05-10T21:45:48.916Z" }, + "notifications": { + "pending": { + "infoCount": 0, + "warningCount": 2, + "criticalCount": 0, + "alarmCount": [ + { "id": 30, "severity": "warning", "count": 1 }, + { "id": 31, "severity": "warning", "count": 1 } + ], + "info": { "count": 0, "devices": { "count": 0, "absolute": 0 } }, + "warning": { "count": 2, "devices": { "count": 1, "absolute": 1 } }, + "critical": { "count": 0, "devices": { "count": 0, "absolute": 0 } } + } + }, + "hardwareThresholds": { + "gpm": { "okMin": 0, "okMax": 29, "minValue": 0, "maxValue": 35 }, + "psi": { "okMin": 30, "okMax": 80, "minValue": 0, "maxValue": 100 }, + "lpm": { "okMin": 0, "okMax": 110, "minValue": 0, "maxValue": 130 }, + "kPa": { "okMin": 210, "okMax": 550, "minValue": 0, "maxValue": 700 }, + "tempF": { "okMin": 50, "okMax": 80, "minValue": 0, "maxValue": 100 }, + "tempC": { "okMin": 10, "okMax": 30, "minValue": 0, "maxValue": 40 } + }, + "serialNumber": "111111111111", + "connectivity": { "rssi": -47, "ssid": "SOMESSID" }, + "telemetry": { + "current": { + "gpm": 0, + "psi": 54.20000076293945, + "tempF": 70, + "updated": "2020-07-24T12:20:58Z" + } + }, + "healthTest": { + "config": { + "enabled": true, + "timesPerDay": 1, + "start": "02:00", + "end": "04:00" + } + }, + "shutoff": { "scheduledAt": "1970-01-01T00:00:00.000Z" }, + "actionRules": [], + "location": { "id": "mmnnoopp" } +} diff --git a/tests/fixtures/flo/location_info_base_response.json b/tests/fixtures/flo/location_info_base_response.json new file mode 100644 index 00000000000..f6840a0742b --- /dev/null +++ b/tests/fixtures/flo/location_info_base_response.json @@ -0,0 +1,89 @@ +{ + "id": "mmnnoopp", + "users": [ + { + "id": "12345abcde" + } + ], + "devices": [ + { + "id": "98765", + "macAddress": "123456abcdef" + } + ], + "userRoles": [ + { + "userId": "12345abcde", + "roles": [ + "owner" + ] + } + ], + "address": "123 Main Street", + "city": "Boston", + "state": "MA", + "country": "us", + "postalCode": "12345", + "timezone": "US/Easter", + "gallonsPerDayGoal": 240, + "occupants": 2, + "stories": 2, + "isProfileComplete": true, + "nickname": "Home", + "irrigationSchedule": { + "isEnabled": false + }, + "systemMode": { + "target": "home" + }, + "locationType": "sfh", + "locationSize": "lte_4000_sq_ft", + "waterShutoffKnown": "unsure", + "indoorAmenities": [], + "outdoorAmenities": [], + "plumbingAppliances": [ + "exp_tank" + ], + "notifications": { + "pending": { + "infoCount": 0, + "warningCount": 1, + "criticalCount": 0, + "alarmCount": [ + { + "id": 57, + "severity": "warning", + "count": 1 + } + ] + } + }, + "areas": { + "default": [ + { + "id": "xxxxx", + "name": "Attic" + }, + { + "id": "xxxxx", + "name": "Basement" + }, + { + "id": "xxxxx", + "name": "Garage" + }, + { + "id": "xxxxx", + "name": "Main Floor" + }, + { + "id": "xxxxx", + "name": "Upstairs" + } + ], + "custom": [] + }, + "account": { + "id": "aabbccdd" + } +} diff --git a/tests/fixtures/flo/location_info_expand_devices_response.json b/tests/fixtures/flo/location_info_expand_devices_response.json new file mode 100644 index 00000000000..138de88db25 --- /dev/null +++ b/tests/fixtures/flo/location_info_expand_devices_response.json @@ -0,0 +1,308 @@ +{ + "id": "mmnnoopp", + "users": [ + { + "id": "12345abcde" + } + ], + "devices": [ + { + "isConnected": true, + "fwVersion": "4.2.4", + "lastHeardFromTime": "2020-01-16T19:42:06Z", + "fwProperties": { + "alarm_home_high_flow_rate_shut_off_deferment": 300, + "alarm_home_high_water_use_shut_off_deferment": 300, + "alarm_home_long_flow_event_shut_off_deferment": 300, + "alarm_shutoff_time_epoch_sec": -1, + "alarm_snooze_enabled": true, + "alarm_suppress_duplicate_duration": 300, + "alarm_suppress_until_event_end": false, + "data_flosense_force_retrain": 0, + "data_flosense_status_interval": 1200, + "data_flosense_verbosity": 1, + "device_data_free_mb": 1464, + "device_installed": true, + "device_mem_available_kb": 292780, + "device_rootfs_free_kb": 802604, + "device_uptime_sec": 334862, + "flosense_action": "start", + "flosense_deployment_result": "success", + "flosense_link": "", + "flosense_shut_off_enabled": true, + "flosense_shut_off_level": 2, + "flosense_state": "active", + "flosense_version_app": "2.0.0", + "flosense_version_model": "2.0.0", + "fw_ver": "4.2.4", + "fw_ver_a": "4.1.5", + "fw_ver_b": "4.2.4", + "ht_attempt_interval": 60000, + "ht_check_window_max_pressure_decay_limit": 0.1, + "ht_check_window_width": 30000, + "ht_max_open_closed_pressure_decay_pct_limit": 2, + "ht_max_pressure_growth_limit": 3, + "ht_max_pressure_growth_pct_limit": 3, + "ht_min_computable_point_limit": 3, + "ht_min_pressure_limit": 10, + "ht_min_r_squared_limit": 0.9, + "ht_min_slope_limit": -0.6, + "ht_phase_1_max_pressure_decay_limit": 6, + "ht_phase_1_max_pressure_decay_pct_limit": 10, + "ht_phase_1_time_index": 12000, + "ht_phase_2_max_pressure_decay_limit": 6, + "ht_phase_2_max_pressure_decay_pct_limit": 10, + "ht_phase_2_time_index": 30000, + "ht_phase_3_max_pressure_decay_limit": 3, + "ht_phase_3_max_pressure_decay_pct_limit": 5, + "ht_phase_3_time_index": 240000, + "ht_phase_4_max_pressure_decay_limit": 1.5, + "ht_phase_4_max_pressure_decay_pct_limit": 5, + "ht_phase_4_time_index": 480000, + "ht_pre_delay": 0, + "ht_recent_flow_event_cool_down": 1000, + "ht_retry_on_fail_interval": 900000, + "ht_times_per_day": 1, + "log_bytes_sent": 176255, + "log_frequency": 3600, + "mender_host": "https://mender.flotech.co", + "motor_delay_close": 175, + "motor_delay_open": 0, + "motor_retry_count": 2, + "motor_timeout": 5000, + "pes_away_max_duration": 3600, + "pes_away_max_pressure": 150, + "pes_away_max_temperature": 226, + "pes_away_max_volume": 50, + "pes_away_min_pressure": 20, + "pes_away_min_temperature": 36, + "pes_away_v1_high_flow_rate": 8, + "pes_away_v1_high_flow_rate_duration": 5, + "pes_away_v2_high_flow_rate": 0.5, + "pes_away_v2_high_flow_rate_duration": 5, + "pes_home_high_flow_rate": 9.902778339386035, + "pes_home_high_flow_rate_duration": 20, + "pes_home_max_duration": 1738, + "pes_home_max_pressure": 150, + "pes_home_max_temperature": 226, + "pes_home_max_volume": 33.851015281677256, + "pes_home_min_pressure": 20, + "pes_home_min_temperature": 36, + "pes_moderately_high_pressure": 80, + "pes_moderately_high_pressure_count": 43200, + "pes_moderately_high_pressure_delay": 300, + "pes_moderately_high_pressure_period": 10, + "player_action": "disabled", + "player_flow": 0, + "player_min_pressure": 40, + "player_pressure": 60, + "player_temperature": 50, + "power_downtime_last_24h": 0, + "power_downtime_last_7days": 69, + "power_downtime_last_reboot": 0, + "reboot_count": 27, + "reboot_count_7days": 2, + "reboot_reason": "power_cycle", + "s3_bucket_host": "api-bulk.meetflo.com", + "serial_number": "294215640115", + "system_mode": 2, + "telemetry_batched_enabled": true, + "telemetry_batched_interval": 300, + "telemetry_batched_pending_storage": 30, + "telemetry_batched_sent_storage": 30, + "telemetry_flow_rate": 0, + "telemetry_pressure": 78.07500375373304, + "telemetry_realtime_change_gpm": 0, + "telemetry_realtime_change_psi": 0, + "telemetry_realtime_interval": 1, + "telemetry_realtime_session_last_epoch": 0, + "telemetry_realtime_sessions_7days": 0, + "telemetry_realtime_storage": 7, + "telemetry_realtime_timeout": 299, + "telemetry_temperature": 57.00000047232966, + "valve_actuation_count": 3465, + "valve_actuation_timeout_count": 0, + "valve_state": 1, + "wifi_bytes_received": 145018827, + "wifi_bytes_sent": 80891494, + "wifi_disconnections": 423, + "wifi_rssi": -61, + "wifi_sta_enc": "psk2", + "wifi_sta_ssid": "IP freely", + "zit_auto_count": 233, + "zit_manual_count": 0 + }, + "id": "98765", + "macAddress": "123456abcdef", + "nickname": "Smart Water Shutoff", + "isPaired": true, + "deviceModel": "flo_device_075_v2", + "deviceType": "flo_device_v2", + "irrigationType": "sprinklers", + "systemMode": { + "isLocked": false, + "shouldInherit": true, + "lastKnown": "home", + "target": "home" + }, + "valve": { + "target": "open", + "lastKnown": "open" + }, + "installStatus": { + "isInstalled": true, + "installDate": "2018-08-16T02:07:39.483Z" + }, + "learning": { + "outOfLearningDate": "2018-08-16T02:07:39.483Z" + }, + "notifications": { + "pending": { + "infoCount": 0, + "warningCount": 1, + "criticalCount": 0, + "alarmCount": [ + { + "id": 57, + "severity": "warning", + "count": 1 + } + ] + } + }, + "hardwareThresholds": { + "gpm": { + "okMin": 0, + "okMax": 29, + "minValue": 0, + "maxValue": 35 + }, + "psi": { + "okMin": 30, + "okMax": 80, + "minValue": 0, + "maxValue": 100 + }, + "lpm": { + "okMin": 0, + "okMax": 110, + "minValue": 0, + "maxValue": 130 + }, + "kPa": { + "okMin": 210, + "okMax": 550, + "minValue": 0, + "maxValue": 700 + }, + "tempF": { + "okMin": 50, + "okMax": 80, + "minValue": 0, + "maxValue": 100 + }, + "tempC": { + "okMin": 10, + "okMax": 30, + "minValue": 0, + "maxValue": 40 + } + }, + "serialNumber": "xxxxx", + "connectivity": { + "rssi": -61, + "ssid": "IP freely" + }, + "telemetry": { + "current": { + "gpm": 0, + "psi": 78.9000015258789, + "tempF": 57, + "updated": "2020-01-16T19:01:59Z" + } + }, + "shutoff": { + "scheduledAt": "1970-01-01T00:00:00.000Z" + }, + "actionRules": [], + "location": { + "id": "mmnnoopp" + } + } + ], + "userRoles": [ + { + "userId": "12345abcde", + "roles": [ + "owner" + ] + } + ], + "address": "123 Main Street", + "city": "Boston", + "state": "MA", + "country": "us", + "postalCode": "12345", + "timezone": "US/Eastern", + "gallonsPerDayGoal": 240, + "occupants": 2, + "stories": 2, + "isProfileComplete": true, + "nickname": "Home", + "irrigationSchedule": { + "isEnabled": false + }, + "systemMode": { + "target": "home" + }, + "locationType": "sfh", + "locationSize": "lte_4000_sq_ft", + "waterShutoffKnown": "unsure", + "indoorAmenities": [], + "outdoorAmenities": [], + "plumbingAppliances": [ + "exp_tank" + ], + "notifications": { + "pending": { + "infoCount": 0, + "warningCount": 1, + "criticalCount": 0, + "alarmCount": [ + { + "id": 57, + "severity": "warning", + "count": 1 + } + ] + } + }, + "areas": { + "default": [ + { + "id": "xxxx", + "name": "Attic" + }, + { + "id": "xxxx", + "name": "Basement" + }, + { + "id": "xxxx", + "name": "Garage" + }, + { + "id": "xxxx", + "name": "Main Floor" + }, + { + "id": "xxxx", + "name": "Upstairs" + } + ], + "custom": [] + }, + "account": { + "id": "aabbccdd" + } +} diff --git a/tests/fixtures/flo/user_info_base_response.json b/tests/fixtures/flo/user_info_base_response.json new file mode 100644 index 00000000000..646b62ee834 --- /dev/null +++ b/tests/fixtures/flo/user_info_base_response.json @@ -0,0 +1,34 @@ +{ + "id": "12345abcde", + "email": "email@address.com", + "isActive": true, + "firstName": "Tom", + "lastName": "Jones", + "unitSystem": "imperial_us", + "phoneMobile": "+1 123-456-7890", + "locale": "en-US", + "locations": [ + { + "id": "mmnnoopp" + } + ], + "alarmSettings": [], + "locationRoles": [ + { + "locationId": "mmnnoopp", + "roles": [ + "owner" + ] + } + ], + "accountRole": { + "accountId": "aabbccdd", + "roles": [ + "owner" + ] + }, + "account": { + "id": "aabbccdd" + }, + "enabledFeatures": [] +} diff --git a/tests/fixtures/flo/user_info_expand_locations_response.json b/tests/fixtures/flo/user_info_expand_locations_response.json new file mode 100644 index 00000000000..829596b6849 --- /dev/null +++ b/tests/fixtures/flo/user_info_expand_locations_response.json @@ -0,0 +1,120 @@ +{ + "id": "12345abcde", + "email": "email@address.com", + "isActive": true, + "firstName": "Tom", + "lastName": "Jones", + "unitSystem": "imperial_us", + "phoneMobile": "+1 123-456-7890", + "locale": "en-US", + "locations": [ + { + "id": "mmnnoopp", + "users": [ + { + "id": "12345abcde" + } + ], + "devices": [ + { + "id": "98765", + "macAddress": "606405c11e10" + } + ], + "userRoles": [ + { + "userId": "12345abcde", + "roles": [ + "owner" + ] + } + ], + "address": "123 Main Stree", + "city": "Boston", + "state": "MA", + "country": "us", + "postalCode": "12345", + "timezone": "US/Easter", + "gallonsPerDayGoal": 240, + "occupants": 2, + "stories": 2, + "isProfileComplete": true, + "nickname": "Home", + "irrigationSchedule": { + "isEnabled": false + }, + "systemMode": { + "target": "home" + }, + "locationType": "sfh", + "locationSize": "lte_4000_sq_ft", + "waterShutoffKnown": "unsure", + "indoorAmenities": [], + "outdoorAmenities": [], + "plumbingAppliances": [ + "exp_tank" + ], + "notifications": { + "pending": { + "infoCount": 0, + "warningCount": 1, + "criticalCount": 0, + "alarmCount": [ + { + "id": 57, + "severity": "warning", + "count": 1 + } + ] + } + }, + "areas": { + "default": [ + { + "id": "xxxxx", + "name": "Attic" + }, + { + "id": "xxxxx", + "name": "Basement" + }, + { + "id": "xxxxx", + "name": "Garage" + }, + { + "id": "xxxxx", + "name": "Main Floor" + }, + { + "id": "xxxxx", + "name": "Upstairs" + } + ], + "custom": [] + }, + "account": { + "id": "aabbccdd" + } + } + ], + "alarmSettings": [], + "locationRoles": [ + { + "locationId": "mmnnoopp", + "roles": [ + "owner" + ] + } + ], + "accountRole": { + "accountId": "aabbccdd", + "roles": [ + "owner" + ] + }, + "account": { + "id": "aabbccdd" + }, + "enabledFeatures": [] +} diff --git a/tests/fixtures/flo/water_consumption_info_response.json b/tests/fixtures/flo/water_consumption_info_response.json new file mode 100644 index 00000000000..ea173da98ea --- /dev/null +++ b/tests/fixtures/flo/water_consumption_info_response.json @@ -0,0 +1,34 @@ +{ + "params": { + "startDate": "2020-01-16T07:00:00.000Z", + "endDate": "2020-01-17T06:59:59.999Z", + "interval": "1h", + "tz": "US/Mountain", + "locationId": "mmnnoopp" + }, + "aggregations": { + "sumTotalGallonsConsumed": 3.674 + }, + "items": [ + { + "time": "2020-01-16T00:00:00-07:00", + "gallonsConsumed": 0.04 + }, + { + "time": "2020-01-16T01:00:00-07:00", + "gallonsConsumed": 0.477 + }, + { + "time": "2020-01-16T03:00:00-07:00", + "gallonsConsumed": 0.442 + }, + { + "time": "2020-01-16T07:00:00-07:00", + "gallonsConsumed": 1.216 + }, + { + "time": "2020-01-16T08:00:00-07:00", + "gallonsConsumed": 1.499 + } + ] +} diff --git a/tests/test_util/aiohttp.py b/tests/test_util/aiohttp.py index 9b2a4380cae..71358cdd973 100644 --- a/tests/test_util/aiohttp.py +++ b/tests/test_util/aiohttp.py @@ -249,7 +249,7 @@ class AiohttpClientMockResponse: """Return mock response as a string.""" return self.response.decode(encoding) - async def json(self, encoding="utf-8"): + async def json(self, encoding="utf-8", content_type=None): """Return mock response as a json.""" return _json.loads(self.response.decode(encoding))