From 2c9e8ad475e9f8da3538750f462a5a7c64add6b9 Mon Sep 17 00:00:00 2001 From: On Freund Date: Tue, 24 Jan 2023 22:41:33 +0200 Subject: [PATCH] ReadYourMeter Pro integration (#85986) * ReadYourMeter Pro integration * Add __init__.py to .coveragerc * Address code review comments * More code review comments --- .coveragerc | 2 + CODEOWNERS | 2 + homeassistant/components/rympro/__init__.py | 82 +++++++ .../components/rympro/config_flow.py | 100 +++++++++ homeassistant/components/rympro/const.py | 3 + homeassistant/components/rympro/manifest.json | 9 + homeassistant/components/rympro/sensor.py | 70 ++++++ homeassistant/components/rympro/strings.json | 20 ++ homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 6 + requirements_all.txt | 3 + requirements_test_all.txt | 3 + tests/components/rympro/__init__.py | 1 + tests/components/rympro/test_config_flow.py | 202 ++++++++++++++++++ 14 files changed, 504 insertions(+) create mode 100644 homeassistant/components/rympro/__init__.py create mode 100644 homeassistant/components/rympro/config_flow.py create mode 100644 homeassistant/components/rympro/const.py create mode 100644 homeassistant/components/rympro/manifest.json create mode 100644 homeassistant/components/rympro/sensor.py create mode 100644 homeassistant/components/rympro/strings.json create mode 100644 tests/components/rympro/__init__.py create mode 100644 tests/components/rympro/test_config_flow.py diff --git a/.coveragerc b/.coveragerc index ddda64f3888..3ca7d31502b 100644 --- a/.coveragerc +++ b/.coveragerc @@ -1020,6 +1020,8 @@ omit = homeassistant/components/ruuvi_gateway/coordinator.py homeassistant/components/russound_rio/media_player.py homeassistant/components/russound_rnet/media_player.py + homeassistant/components/rympro/__init__.py + homeassistant/components/rympro/sensor.py homeassistant/components/sabnzbd/__init__.py homeassistant/components/sabnzbd/sensor.py homeassistant/components/saj/sensor.py diff --git a/CODEOWNERS b/CODEOWNERS index 8223c1a1f1f..367e93a6681 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1001,6 +1001,8 @@ build.json @home-assistant/supervisor /tests/components/ruuvi_gateway/ @akx /homeassistant/components/ruuvitag_ble/ @akx /tests/components/ruuvitag_ble/ @akx +/homeassistant/components/rympro/ @OnFreund +/tests/components/rympro/ @OnFreund /homeassistant/components/sabnzbd/ @shaiu /tests/components/sabnzbd/ @shaiu /homeassistant/components/safe_mode/ @home-assistant/core diff --git a/homeassistant/components/rympro/__init__.py b/homeassistant/components/rympro/__init__.py new file mode 100644 index 00000000000..2e474be6a6a --- /dev/null +++ b/homeassistant/components/rympro/__init__.py @@ -0,0 +1,82 @@ +"""The Read Your Meter Pro integration.""" +from __future__ import annotations + +from datetime import timedelta +import logging + +from pyrympro import CannotConnectError, OperationError, RymPro, UnauthorizedError + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_EMAIL, CONF_PASSWORD, CONF_TOKEN, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DOMAIN + +SCAN_INTERVAL = 60 * 60 +PLATFORMS: list[Platform] = [Platform.SENSOR] +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Read Your Meter Pro from a config entry.""" + data = entry.data + rympro = RymPro(async_get_clientsession(hass)) + rympro.set_token(data[CONF_TOKEN]) + try: + await rympro.account_info() + except CannotConnectError as error: + raise ConfigEntryNotReady from error + except UnauthorizedError: + try: + token = await rympro.login(data[CONF_EMAIL], data[CONF_PASSWORD], "ha") + hass.config_entries.async_update_entry( + entry, + data={**data, CONF_TOKEN: token}, + ) + except UnauthorizedError as error: + raise ConfigEntryAuthFailed from error + + coordinator = RymProDataUpdateCoordinator(hass, rympro, SCAN_INTERVAL) + await coordinator.async_config_entry_first_refresh() + + hass.data.setdefault(DOMAIN, {}) + hass.data[DOMAIN][entry.entry_id] = coordinator + + await hass.config_entries.async_forward_entry_setups(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 + + +class RymProDataUpdateCoordinator(DataUpdateCoordinator): + """Class to manage fetching RYM Pro data.""" + + def __init__(self, hass: HomeAssistant, rympro: RymPro, scan_interval: int) -> None: + """Initialize global RymPro data updater.""" + self.rympro = rympro + interval = timedelta(seconds=scan_interval) + super().__init__( + hass, + _LOGGER, + name=DOMAIN, + update_interval=interval, + ) + + async def _async_update_data(self): + """Fetch data from Rym Pro.""" + try: + return await self.rympro.last_read() + except UnauthorizedError: + await self.hass.config_entries.async_reload(self.config_entry.entry_id) + except (CannotConnectError, OperationError) as error: + raise UpdateFailed(error) from error diff --git a/homeassistant/components/rympro/config_flow.py b/homeassistant/components/rympro/config_flow.py new file mode 100644 index 00000000000..b954bb10c57 --- /dev/null +++ b/homeassistant/components/rympro/config_flow.py @@ -0,0 +1,100 @@ +"""Config flow for Read Your Meter Pro integration.""" +from __future__ import annotations + +from collections.abc import Mapping +import logging +from typing import Any + +from pyrympro import CannotConnectError, RymPro, UnauthorizedError +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_EMAIL, CONF_PASSWORD, CONF_TOKEN, CONF_UNIQUE_ID +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + +STEP_USER_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_EMAIL): str, + vol.Required(CONF_PASSWORD): str, + } +) + + +async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, Any]: + """Validate the user input allows us to connect. + + Data has the keys from STEP_USER_DATA_SCHEMA with values provided by the user. + """ + + rympro = RymPro(async_get_clientsession(hass)) + + token = await rympro.login(data[CONF_EMAIL], data[CONF_PASSWORD], "ha") + + info = await rympro.account_info() + + return {CONF_TOKEN: token, CONF_UNIQUE_ID: info["accountNumber"]} + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for Read Your Meter Pro.""" + + VERSION = 1 + + def __init__(self) -> None: + """Init the config flow.""" + self._reauth_entry: config_entries.ConfigEntry | None = None + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle the initial step.""" + if user_input is None: + return self.async_show_form( + step_id="user", data_schema=STEP_USER_DATA_SCHEMA + ) + + errors = {} + + try: + info = await validate_input(self.hass, user_input) + except CannotConnectError: + errors["base"] = "cannot_connect" + except UnauthorizedError: + errors["base"] = "invalid_auth" + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + title = user_input[CONF_EMAIL] + data = {**user_input, **info} + + if not self._reauth_entry: + await self.async_set_unique_id(info[CONF_UNIQUE_ID]) + self._abort_if_unique_id_configured() + return self.async_create_entry(title=title, data=data) + + self.hass.config_entries.async_update_entry( + self._reauth_entry, + title=title, + data=data, + unique_id=info[CONF_UNIQUE_ID], + ) + await self.hass.config_entries.async_reload(self._reauth_entry.entry_id) + return self.async_abort(reason="reauth_successful") + + return self.async_show_form( + step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors + ) + + async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: + """Handle configuration by re-auth.""" + self._reauth_entry = self.hass.config_entries.async_get_entry( + self.context["entry_id"] + ) + return await self.async_step_user() diff --git a/homeassistant/components/rympro/const.py b/homeassistant/components/rympro/const.py new file mode 100644 index 00000000000..ed7e2801a1b --- /dev/null +++ b/homeassistant/components/rympro/const.py @@ -0,0 +1,3 @@ +"""Constants for the Read Your Meter Pro integration.""" + +DOMAIN = "rympro" diff --git a/homeassistant/components/rympro/manifest.json b/homeassistant/components/rympro/manifest.json new file mode 100644 index 00000000000..9079a781e51 --- /dev/null +++ b/homeassistant/components/rympro/manifest.json @@ -0,0 +1,9 @@ +{ + "domain": "rympro", + "name": "Read Your Meter Pro", + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/rympro", + "requirements": ["pyrympro==0.0.4"], + "codeowners": ["@OnFreund"], + "iot_class": "cloud_polling" +} diff --git a/homeassistant/components/rympro/sensor.py b/homeassistant/components/rympro/sensor.py new file mode 100644 index 00000000000..7360f930d30 --- /dev/null +++ b/homeassistant/components/rympro/sensor.py @@ -0,0 +1,70 @@ +"""Sensor for RymPro meters.""" +from __future__ import annotations + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorStateClass, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import UnitOfVolume +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from . import RymProDataUpdateCoordinator +from .const import DOMAIN + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up sensors for device.""" + coordinator: RymProDataUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id] + async_add_entities( + [ + RymProSensor(coordinator, meter_id, meter["read"], config_entry.entry_id) + for meter_id, meter in coordinator.data.items() + ] + ) + + +class RymProSensor(CoordinatorEntity[RymProDataUpdateCoordinator], SensorEntity): + """Sensor for RymPro meters.""" + + _attr_has_entity_name = True + _attr_name = "Last Read" + _attr_device_class = SensorDeviceClass.WATER + _attr_native_unit_of_measurement = UnitOfVolume.CUBIC_METERS + _attr_state_class = SensorStateClass.TOTAL_INCREASING + + def __init__( + self, + coordinator: RymProDataUpdateCoordinator, + meter_id: int, + last_read: int, + entry_id: str, + ) -> None: + """Initialize sensor.""" + super().__init__(coordinator) + self._meter_id = meter_id + self._entity_registry: er.EntityRegistry | None = None + unique_id = f"{entry_id}_{meter_id}" + self._attr_unique_id = f"{unique_id}_last_read" + self._attr_extra_state_attributes = {"meter_id": str(meter_id)} + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, unique_id)}, + manufacturer="Read Your Meter Pro", + name=f"Meter {meter_id}", + ) + self._attr_native_value = last_read + + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + self._attr_native_value = self.coordinator.data[self._meter_id]["read"] + self.async_write_ha_state() diff --git a/homeassistant/components/rympro/strings.json b/homeassistant/components/rympro/strings.json new file mode 100644 index 00000000000..b6e7adc9631 --- /dev/null +++ b/homeassistant/components/rympro/strings.json @@ -0,0 +1,20 @@ +{ + "config": { + "step": { + "user": { + "data": { + "email": "[%key:common::config_flow::data::email%]", + "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%]" + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 199fe2a3cf3..28cf9d96cfd 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -359,6 +359,7 @@ FLOWS = { "ruckus_unleashed", "ruuvi_gateway", "ruuvitag_ble", + "rympro", "sabnzbd", "samsungtv", "scrape", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 0d79ab16dab..13acd8cc30e 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -4637,6 +4637,12 @@ "config_flow": true, "iot_class": "local_push" }, + "rympro": { + "name": "Read Your Meter Pro", + "integration_type": "hub", + "config_flow": true, + "iot_class": "cloud_polling" + }, "sabnzbd": { "name": "SABnzbd", "integration_type": "hub", diff --git a/requirements_all.txt b/requirements_all.txt index 92af36cfef6..5ebf0779d19 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1910,6 +1910,9 @@ pyrituals==0.0.6 # homeassistant.components.ruckus_unleashed pyruckus==0.16 +# homeassistant.components.rympro +pyrympro==0.0.4 + # homeassistant.components.sabnzbd pysabnzbd==1.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f205f113f32..794a7225212 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1375,6 +1375,9 @@ pyrituals==0.0.6 # homeassistant.components.ruckus_unleashed pyruckus==0.16 +# homeassistant.components.rympro +pyrympro==0.0.4 + # homeassistant.components.sabnzbd pysabnzbd==1.1.1 diff --git a/tests/components/rympro/__init__.py b/tests/components/rympro/__init__.py new file mode 100644 index 00000000000..6f06d9bdb20 --- /dev/null +++ b/tests/components/rympro/__init__.py @@ -0,0 +1 @@ +"""Tests for the Read Your Meter Pro integration.""" diff --git a/tests/components/rympro/test_config_flow.py b/tests/components/rympro/test_config_flow.py new file mode 100644 index 00000000000..ec0651fb881 --- /dev/null +++ b/tests/components/rympro/test_config_flow.py @@ -0,0 +1,202 @@ +"""Test the Read Your Meter Pro config flow.""" +from unittest.mock import patch + +import pytest + +from homeassistant import config_entries +from homeassistant.components.rympro.config_flow import ( + CannotConnectError, + UnauthorizedError, +) +from homeassistant.components.rympro.const import DOMAIN +from homeassistant.const import CONF_EMAIL, CONF_PASSWORD, CONF_TOKEN, CONF_UNIQUE_ID +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from tests.common import MockConfigEntry + +TEST_DATA = { + CONF_EMAIL: "test-email", + CONF_PASSWORD: "test-password", + CONF_TOKEN: "test-token", + CONF_UNIQUE_ID: "test-account-number", +} + + +@pytest.fixture +def _config_entry(hass): + config_entry = MockConfigEntry( + domain=DOMAIN, + data=TEST_DATA, + unique_id=TEST_DATA[CONF_UNIQUE_ID], + ) + config_entry.add_to_hass(hass) + return config_entry + + +async def test_form(hass: HomeAssistant) -> None: + """Test we get the form.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == FlowResultType.FORM + assert result["errors"] is None + + with patch( + "homeassistant.components.rympro.config_flow.RymPro.login", + return_value="test-token", + ), patch( + "homeassistant.components.rympro.config_flow.RymPro.account_info", + return_value={"accountNumber": TEST_DATA[CONF_UNIQUE_ID]}, + ), patch( + "homeassistant.components.rympro.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_EMAIL: TEST_DATA[CONF_EMAIL], + CONF_PASSWORD: TEST_DATA[CONF_PASSWORD], + }, + ) + await hass.async_block_till_done() + + assert result2["type"] == FlowResultType.CREATE_ENTRY + assert result2["title"] == TEST_DATA[CONF_EMAIL] + assert result2["data"] == TEST_DATA + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.parametrize( + "exception, error", + [ + (UnauthorizedError, "invalid_auth"), + (CannotConnectError, "cannot_connect"), + (Exception, "unknown"), + ], +) +async def test_login_error(hass, exception, error): + """Test we handle config flow errors.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + with patch( + "homeassistant.components.rympro.config_flow.RymPro.login", + side_effect=exception, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_EMAIL: TEST_DATA[CONF_EMAIL], + CONF_PASSWORD: TEST_DATA[CONF_PASSWORD], + }, + ) + await hass.async_block_till_done() + + assert result2["type"] == FlowResultType.FORM + assert result2["errors"] == {"base": error} + + +async def test_form_already_exists(hass, _config_entry): + """Test that a flow with an existing account aborts.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "homeassistant.components.rympro.config_flow.RymPro.login", + return_value="test-token", + ), patch( + "homeassistant.components.rympro.config_flow.RymPro.account_info", + return_value={"accountNumber": TEST_DATA[CONF_UNIQUE_ID]}, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_EMAIL: TEST_DATA[CONF_EMAIL], + CONF_PASSWORD: TEST_DATA[CONF_PASSWORD], + }, + ) + await hass.async_block_till_done() + + assert result2["type"] == FlowResultType.ABORT + assert result2["reason"] == "already_configured" + + +async def test_form_reauth(hass, _config_entry): + """Test reauthentication.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": config_entries.SOURCE_REAUTH, + "entry_id": _config_entry.entry_id, + }, + data=_config_entry.data, + ) + assert result["type"] == "form" + assert result["errors"] is None + + with patch( + "homeassistant.components.rympro.config_flow.RymPro.login", + return_value="test-token", + ), patch( + "homeassistant.components.rympro.config_flow.RymPro.account_info", + return_value={"accountNumber": TEST_DATA[CONF_UNIQUE_ID]}, + ), patch( + "homeassistant.components.rympro.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_EMAIL: TEST_DATA[CONF_EMAIL], + CONF_PASSWORD: "new_password", + }, + ) + await hass.async_block_till_done() + + assert result2["type"] == "abort" + assert result2["reason"] == "reauth_successful" + assert _config_entry.data[CONF_PASSWORD] == "new_password" + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_form_reauth_with_new_account(hass, _config_entry): + """Test reauthentication with new account.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": config_entries.SOURCE_REAUTH, + "entry_id": _config_entry.entry_id, + }, + data=_config_entry.data, + ) + assert result["type"] == "form" + assert result["errors"] is None + + with patch( + "homeassistant.components.rympro.config_flow.RymPro.login", + return_value="test-token", + ), patch( + "homeassistant.components.rympro.config_flow.RymPro.account_info", + return_value={"accountNumber": "new-account-number"}, + ), patch( + "homeassistant.components.rympro.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_EMAIL: TEST_DATA[CONF_EMAIL], + CONF_PASSWORD: TEST_DATA[CONF_PASSWORD], + }, + ) + await hass.async_block_till_done() + + assert result2["type"] == "abort" + assert result2["reason"] == "reauth_successful" + assert _config_entry.data[CONF_UNIQUE_ID] == "new-account-number" + assert _config_entry.unique_id == "new-account-number" + assert len(mock_setup_entry.mock_calls) == 1