Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

ReadYourMeter Pro integration #85986

Merged
merged 4 commits into from
Jan 24, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .coveragerc
Original file line number Diff line number Diff line change
Expand Up @@ -1098,6 +1098,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
Expand Down
2 changes: 2 additions & 0 deletions CODEOWNERS
Validating CODEOWNERS rules …
Original file line number Diff line number Diff line change
Expand Up @@ -997,6 +997,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
Expand Down
82 changes: 82 additions & 0 deletions homeassistant/components/rympro/__init__.py
Original file line number Diff line number Diff line change
@@ -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(
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please only wrap the line that can raise in the try... except block. Move this line down.

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):
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Coordinators should go into their own modules, in this case, move it into coordinator.py.

"""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
100 changes: 100 additions & 0 deletions homeassistant/components/rympro/config_flow.py
Original file line number Diff line number Diff line change
@@ -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],
Copy link
Member

@frenck frenck Jan 24, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This can't change, right? Why is it updated?

As a matter of fact, what in this re-auth process guards that the right account is re-authed?

)
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()
3 changes: 3 additions & 0 deletions homeassistant/components/rympro/const.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
"""Constants for the Read Your Meter Pro integration."""

DOMAIN = "rympro"
9 changes: 9 additions & 0 deletions homeassistant/components/rympro/manifest.json
Original file line number Diff line number Diff line change
@@ -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"
}
70 changes: 70 additions & 0 deletions homeassistant/components/rympro/sensor.py
Original file line number Diff line number Diff line change
@@ -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()
]
Comment on lines +29 to +32
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

async_add_entities accepts a generator, you can remove the list comprehension here.

)


class RymProSensor(CoordinatorEntity[RymProDataUpdateCoordinator], SensorEntity):
"""Sensor for RymPro meters."""

_attr_has_entity_name = True
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This seems to be incorrect, as there are conditions not met for this to be set.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Will change the name to sentence case. Other than that, is there any other condition that's not met?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's about it as it seems.

_attr_name = "Last Read"
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This must be following sentence case styling to be able to set has_entity_name.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Additionally, it is a bit of an odd name, it would suggest a date of when it was last read? But I guess it is water usage? Would that be a better name?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The read here is in present tense, as in the meter read. Common usage, and how the service refers to it, but maybe total consumption? As opposed to some periodic consumption (e.g. daily, monthly), which I plan on adding in future PRs.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We don't describe entities on what they do.. we describe what they are/represent. They represent consumption or usage in this case.

_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
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is unused?

unique_id = f"{entry_id}_{meter_id}"
frenck marked this conversation as resolved.
Show resolved Hide resolved
self._attr_unique_id = f"{unique_id}_last_read"
self._attr_extra_state_attributes = {"meter_id": str(meter_id)}
frenck marked this conversation as resolved.
Show resolved Hide resolved
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()
Comment on lines +66 to +70
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is no need to handle it like this. The coordinator already has the data and there are no calculations involved here. Implement the native_value property method instead.

20 changes: 20 additions & 0 deletions homeassistant/components/rympro/strings.json
Original file line number Diff line number Diff line change
@@ -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%]"
}
}
}
1 change: 1 addition & 0 deletions homeassistant/generated/config_flows.py
Original file line number Diff line number Diff line change
Expand Up @@ -357,6 +357,7 @@
"ruckus_unleashed",
"ruuvi_gateway",
"ruuvitag_ble",
"rympro",
"sabnzbd",
"samsungtv",
"scrape",
Expand Down
6 changes: 6 additions & 0 deletions homeassistant/generated/integrations.json
Original file line number Diff line number Diff line change
Expand Up @@ -4620,6 +4620,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",
Expand Down
3 changes: 3 additions & 0 deletions requirements_all.txt
Original file line number Diff line number Diff line change
Expand Up @@ -1904,6 +1904,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

Expand Down
3 changes: 3 additions & 0 deletions requirements_test_all.txt
Original file line number Diff line number Diff line change
Expand Up @@ -1369,6 +1369,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

Expand Down
1 change: 1 addition & 0 deletions tests/components/rympro/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"""Tests for the Read Your Meter Pro integration."""
Loading