Add Meater integration (#44929)

Co-authored-by: Alexei Chetroi <lexoid@gmail.com>
Co-authored-by: Brian Rogers <brg468@hotmail.com>
Co-authored-by: Franck Nijhof <frenck@frenck.nl>
Co-authored-by: Erik <erik@montnemery.com>
This commit is contained in:
Billy Stevenson 2022-04-01 14:11:37 +01:00 committed by GitHub
parent c01637130b
commit 2c3d9566cb
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 427 additions and 0 deletions

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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,
)

View File

@ -0,0 +1,3 @@
"""Constants for the Meater Temperature Probe integration."""
DOMAIN = "meater"

View File

@ -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"]
}

View File

@ -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

View File

@ -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."
}
}
}

View File

@ -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."
}
}
}
}

View File

@ -196,6 +196,7 @@ FLOWS = {
"lyric",
"mailgun",
"mazda",
"meater",
"melcloud",
"met",
"met_eireann",

View File

@ -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

View File

@ -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

View File

@ -0,0 +1 @@
"""Tests for the Meater integration."""

View File

@ -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",
}