From 2c3d9566cb5c51a9c572a5f55fe729165081cde3 Mon Sep 17 00:00:00 2001 From: Billy Stevenson Date: Fri, 1 Apr 2022 14:11:37 +0100 Subject: [PATCH] Add Meater integration (#44929) Co-authored-by: Alexei Chetroi Co-authored-by: Brian Rogers Co-authored-by: Franck Nijhof Co-authored-by: Erik --- .coveragerc | 3 + CODEOWNERS | 2 + homeassistant/components/meater/__init__.py | 89 ++++++++++++++ .../components/meater/config_flow.py | 57 +++++++++ homeassistant/components/meater/const.py | 3 + homeassistant/components/meater/manifest.json | 9 ++ homeassistant/components/meater/sensor.py | 112 ++++++++++++++++++ homeassistant/components/meater/strings.json | 18 +++ .../components/meater/translations/en.json | 18 +++ homeassistant/generated/config_flows.py | 1 + requirements_all.txt | 3 + requirements_test_all.txt | 3 + tests/components/meater/__init__.py | 1 + tests/components/meater/test_config_flow.py | 108 +++++++++++++++++ 14 files changed, 427 insertions(+) create mode 100644 homeassistant/components/meater/__init__.py create mode 100644 homeassistant/components/meater/config_flow.py create mode 100644 homeassistant/components/meater/const.py create mode 100644 homeassistant/components/meater/manifest.json create mode 100644 homeassistant/components/meater/sensor.py create mode 100644 homeassistant/components/meater/strings.json create mode 100644 homeassistant/components/meater/translations/en.json create mode 100644 tests/components/meater/__init__.py create mode 100644 tests/components/meater/test_config_flow.py diff --git a/.coveragerc b/.coveragerc index 73814938619..0d53cb2ea22 100644 --- a/.coveragerc +++ b/.coveragerc @@ -678,6 +678,9 @@ omit = homeassistant/components/map/* homeassistant/components/mastodon/notify.py homeassistant/components/matrix/* + homeassistant/components/meater/__init__.py + homeassistant/components/meater/const.py + homeassistant/components/meater/sensor.py homeassistant/components/media_extractor/* homeassistant/components/mediaroom/media_player.py homeassistant/components/melcloud/__init__.py diff --git a/CODEOWNERS b/CODEOWNERS index aac6f1986ea..1b9c9bfa69d 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -588,6 +588,8 @@ build.json @home-assistant/supervisor /homeassistant/components/matrix/ @tinloaf /homeassistant/components/mazda/ @bdr99 /tests/components/mazda/ @bdr99 +/homeassistant/components/meater/ @Sotolotl +/tests/components/meater/ @Sotolotl /homeassistant/components/media_player/ @home-assistant/core /tests/components/media_player/ @home-assistant/core /homeassistant/components/media_source/ @hunterjm diff --git a/homeassistant/components/meater/__init__.py b/homeassistant/components/meater/__init__.py new file mode 100644 index 00000000000..7210e74d45f --- /dev/null +++ b/homeassistant/components/meater/__init__.py @@ -0,0 +1,89 @@ +"""The Meater Temperature Probe integration.""" +from datetime import timedelta +import logging + +import async_timeout +from meater import ( + AuthenticationError, + MeaterApi, + ServiceUnavailableError, + TooManyRequestsError, +) + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DOMAIN + +PLATFORMS = [Platform.SENSOR] + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Meater Temperature Probe from a config entry.""" + # Store an API object to access + session = async_get_clientsession(hass) + meater_api = MeaterApi(session) + + # Add the credentials + try: + _LOGGER.debug("Authenticating with the Meater API") + await meater_api.authenticate( + entry.data[CONF_USERNAME], entry.data[CONF_PASSWORD] + ) + except (ServiceUnavailableError, TooManyRequestsError) as err: + raise ConfigEntryNotReady from err + except AuthenticationError as err: + _LOGGER.error("Unable to authenticate with the Meater API: %s", err) + return False + + async def async_update_data(): + """Fetch data from API endpoint.""" + try: + # Note: asyncio.TimeoutError and aiohttp.ClientError are already + # handled by the data update coordinator. + async with async_timeout.timeout(10): + devices = await meater_api.get_all_devices() + except AuthenticationError as err: + raise UpdateFailed("The API call wasn't authenticated") from err + except TooManyRequestsError as err: + raise UpdateFailed( + "Too many requests have been made to the API, rate limiting is in place" + ) from err + + return devices + + coordinator = DataUpdateCoordinator( + hass, + _LOGGER, + # Name of the data. For logging purposes. + name="meater_api", + update_method=async_update_data, + # Polling interval. Will only be polled if there are subscribers. + update_interval=timedelta(seconds=30), + ) + await coordinator.async_config_entry_first_refresh() + + hass.data.setdefault(DOMAIN, {}) + hass.data[DOMAIN].setdefault("known_probes", set()) + + hass.data[DOMAIN][entry.entry_id] = { + "api": meater_api, + "coordinator": 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.""" + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): + hass.data[DOMAIN].pop(entry.entry_id) + + return unload_ok diff --git a/homeassistant/components/meater/config_flow.py b/homeassistant/components/meater/config_flow.py new file mode 100644 index 00000000000..1b1a8a0eca4 --- /dev/null +++ b/homeassistant/components/meater/config_flow.py @@ -0,0 +1,57 @@ +"""Config flow for Meater.""" +from meater import AuthenticationError, MeaterApi, ServiceUnavailableError +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.helpers import aiohttp_client + +from .const import DOMAIN + +FLOW_SCHEMA = vol.Schema( + {vol.Required(CONF_USERNAME): str, vol.Required(CONF_PASSWORD): str} +) + + +class MeaterConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Meater Config Flow.""" + + async def async_step_user(self, user_input=None): + """Define the login user step.""" + if user_input is None: + return self.async_show_form( + step_id="user", + data_schema=FLOW_SCHEMA, + ) + + username: str = user_input[CONF_USERNAME] + await self.async_set_unique_id(username.lower()) + self._abort_if_unique_id_configured() + + username = user_input[CONF_USERNAME] + password = user_input[CONF_PASSWORD] + + session = aiohttp_client.async_get_clientsession(self.hass) + + api = MeaterApi(session) + errors = {} + + try: + await api.authenticate(user_input[CONF_USERNAME], user_input[CONF_PASSWORD]) + except AuthenticationError: + errors["base"] = "invalid_auth" + except ServiceUnavailableError: + errors["base"] = "service_unavailable_error" + except Exception: # pylint: disable=broad-except + errors["base"] = "unknown_auth_error" + else: + return self.async_create_entry( + title="Meater", + data={"username": username, "password": password}, + ) + + return self.async_show_form( + step_id="user", + data_schema=FLOW_SCHEMA, + errors=errors, + ) diff --git a/homeassistant/components/meater/const.py b/homeassistant/components/meater/const.py new file mode 100644 index 00000000000..6b40aa18d59 --- /dev/null +++ b/homeassistant/components/meater/const.py @@ -0,0 +1,3 @@ +"""Constants for the Meater Temperature Probe integration.""" + +DOMAIN = "meater" diff --git a/homeassistant/components/meater/manifest.json b/homeassistant/components/meater/manifest.json new file mode 100644 index 00000000000..192a534cd75 --- /dev/null +++ b/homeassistant/components/meater/manifest.json @@ -0,0 +1,9 @@ +{ + "codeowners": ["@Sotolotl"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/meater", + "domain": "meater", + "iot_class": "cloud_polling", + "name": "Meater", + "requirements": ["meater-python==0.0.8"] +} diff --git a/homeassistant/components/meater/sensor.py b/homeassistant/components/meater/sensor.py new file mode 100644 index 00000000000..9fb5176aa81 --- /dev/null +++ b/homeassistant/components/meater/sensor.py @@ -0,0 +1,112 @@ +"""The Meater Temperature Probe integration.""" +from enum import Enum + +from homeassistant.components.sensor import SensorDeviceClass, SensorEntity +from homeassistant.const import TEMP_CELSIUS +from homeassistant.core import callback +from homeassistant.helpers.update_coordinator import ( + CoordinatorEntity, + DataUpdateCoordinator, +) + +from .const import DOMAIN + + +async def async_setup_entry(hass, entry, async_add_entities): + """Set up the entry.""" + coordinator: DataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id][ + "coordinator" + ] + + @callback + def async_update_data(): + """Handle updated data from the API endpoint.""" + if not coordinator.last_update_success: + return + + devices = coordinator.data + entities = [] + known_probes: set = hass.data[DOMAIN]["known_probes"] + + # Add entities for temperature probes which we've not yet seen + for dev in devices: + if dev.id in known_probes: + continue + + entities.append( + MeaterProbeTemperature( + coordinator, dev.id, TemperatureMeasurement.Internal + ) + ) + entities.append( + MeaterProbeTemperature( + coordinator, dev.id, TemperatureMeasurement.Ambient + ) + ) + known_probes.add(dev.id) + + async_add_entities(entities) + + return devices + + # Add a subscriber to the coordinator to discover new temperature probes + coordinator.async_add_listener(async_update_data) + + +class MeaterProbeTemperature(SensorEntity, CoordinatorEntity): + """Meater Temperature Sensor Entity.""" + + _attr_device_class = SensorDeviceClass.TEMPERATURE + _attr_native_unit_of_measurement = TEMP_CELSIUS + + def __init__(self, coordinator, device_id, temperature_reading_type): + """Initialise the sensor.""" + super().__init__(coordinator) + self._attr_name = f"Meater Probe {temperature_reading_type.name}" + self._attr_device_info = { + "identifiers": { + # Serial numbers are unique identifiers within a specific domain + (DOMAIN, device_id) + }, + "manufacturer": "Apption Labs", + "model": "Meater Probe", + "name": f"Meater Probe {device_id}", + } + self._attr_unique_id = f"{device_id}-{temperature_reading_type}" + + self.device_id = device_id + self.temperature_reading_type = temperature_reading_type + + @property + def native_value(self): + """Return the temperature of the probe.""" + # First find the right probe in the collection + device = None + + for dev in self.coordinator.data: + if dev.id == self.device_id: + device = dev + + if device is None: + return None + + if TemperatureMeasurement.Internal == self.temperature_reading_type: + return device.internal_temperature + + # Not an internal temperature, must be ambient + return device.ambient_temperature + + @property + def available(self): + """Return if entity is available.""" + # See if the device was returned from the API. If not, it's offline + return self.coordinator.last_update_success and any( + self.device_id == device.id for device in self.coordinator.data + ) + + +class TemperatureMeasurement(Enum): + """Enumeration of possible temperature readings from the probe.""" + + Internal = 1 + Ambient = 2 diff --git a/homeassistant/components/meater/strings.json b/homeassistant/components/meater/strings.json new file mode 100644 index 00000000000..772e6afd080 --- /dev/null +++ b/homeassistant/components/meater/strings.json @@ -0,0 +1,18 @@ +{ + "config": { + "step": { + "user": { + "description": "Set up your Meater Cloud account.", + "data": { + "password": "[%key:common::config_flow::data::password%]", + "username": "[%key:common::config_flow::data::username%]" + } + } + }, + "error": { + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "unknown_auth_error": "[%key:common::config_flow::error::unknown%]", + "service_unavailable_error": "The API is currently unavailable, please try again later." + } + } +} diff --git a/homeassistant/components/meater/translations/en.json b/homeassistant/components/meater/translations/en.json new file mode 100644 index 00000000000..3ceb94bcef0 --- /dev/null +++ b/homeassistant/components/meater/translations/en.json @@ -0,0 +1,18 @@ +{ + "config": { + "error": { + "invalid_auth": "Invalid authentication", + "service_unavailable_error": "The API is currently unavailable, please try again later.", + "unknown_auth_error": "Unexpected error" + }, + "step": { + "user": { + "data": { + "password": "Password", + "username": "Username" + }, + "description": "Set up your Meater Cloud account." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 998e88000bf..a7eced345b4 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -196,6 +196,7 @@ FLOWS = { "lyric", "mailgun", "mazda", + "meater", "melcloud", "met", "met_eireann", diff --git a/requirements_all.txt b/requirements_all.txt index 9c984afc53b..aef5fcf0fca 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -982,6 +982,9 @@ mbddns==0.1.2 # homeassistant.components.minecraft_server mcstatus==6.0.0 +# homeassistant.components.meater +meater-python==0.0.8 + # homeassistant.components.message_bird messagebird==1.2.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 28ca4ba5d09..da78199a073 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -660,6 +660,9 @@ mbddns==0.1.2 # homeassistant.components.minecraft_server mcstatus==6.0.0 +# homeassistant.components.meater +meater-python==0.0.8 + # homeassistant.components.meteo_france meteofrance-api==1.0.2 diff --git a/tests/components/meater/__init__.py b/tests/components/meater/__init__.py new file mode 100644 index 00000000000..ef96dafe88c --- /dev/null +++ b/tests/components/meater/__init__.py @@ -0,0 +1 @@ +"""Tests for the Meater integration.""" diff --git a/tests/components/meater/test_config_flow.py b/tests/components/meater/test_config_flow.py new file mode 100644 index 00000000000..597b72c354a --- /dev/null +++ b/tests/components/meater/test_config_flow.py @@ -0,0 +1,108 @@ +"""Define tests for the Meater config flow.""" +from unittest.mock import AsyncMock, patch + +from meater import AuthenticationError, ServiceUnavailableError +import pytest + +from homeassistant import data_entry_flow +from homeassistant.components.meater import DOMAIN +from homeassistant.config_entries import SOURCE_USER +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME + +from tests.common import MockConfigEntry + + +@pytest.fixture +def mock_client(): + """Define a fixture for authentication coroutine.""" + return AsyncMock(return_value=None) + + +@pytest.fixture +def mock_meater(mock_client): + """Mock the meater library.""" + with patch("homeassistant.components.meater.MeaterApi.authenticate") as mock_: + mock_.side_effect = mock_client + yield mock_ + + +async def test_duplicate_error(hass): + """Test that errors are shown when duplicates are added.""" + conf = {CONF_USERNAME: "user@host.com", CONF_PASSWORD: "password123"} + + MockConfigEntry(domain=DOMAIN, unique_id="user@host.com", data=conf).add_to_hass( + hass + ) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER}, data=conf + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "already_configured" + + +@pytest.mark.parametrize("mock_client", [AsyncMock(side_effect=Exception)]) +async def test_unknown_auth_error(hass, mock_meater): + """Test that an invalid API/App Key throws an error.""" + conf = {CONF_USERNAME: "user@host.com", CONF_PASSWORD: "password123"} + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER}, data=conf + ) + assert result["errors"] == {"base": "unknown_auth_error"} + + +@pytest.mark.parametrize("mock_client", [AsyncMock(side_effect=AuthenticationError)]) +async def test_invalid_credentials(hass, mock_meater): + """Test that an invalid API/App Key throws an error.""" + conf = {CONF_USERNAME: "user@host.com", CONF_PASSWORD: "password123"} + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER}, data=conf + ) + assert result["errors"] == {"base": "invalid_auth"} + + +@pytest.mark.parametrize( + "mock_client", [AsyncMock(side_effect=ServiceUnavailableError)] +) +async def test_service_unavailable(hass, mock_meater): + """Test that an invalid API/App Key throws an error.""" + conf = {CONF_USERNAME: "user@host.com", CONF_PASSWORD: "password123"} + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER}, data=conf + ) + assert result["errors"] == {"base": "service_unavailable_error"} + + +async def test_user_flow(hass, mock_meater): + """Test that the user flow works.""" + conf = {CONF_USERNAME: "user@host.com", CONF_PASSWORD: "password123"} + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER}, data=None + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "user" + + with patch( + "homeassistant.components.meater.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result = await hass.config_entries.flow.async_configure(result["flow_id"], conf) + await hass.async_block_till_done() + + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["data"] == { + CONF_USERNAME: "user@host.com", + CONF_PASSWORD: "password123", + } + assert len(mock_setup_entry.mock_calls) == 1 + + config_entry = hass.config_entries.async_entries(DOMAIN)[0] + assert config_entry.data == { + CONF_USERNAME: "user@host.com", + CONF_PASSWORD: "password123", + }