Add new integration Discovergy (#54280)

* Add discovergy integration

* Capitalize measurement type as it is in uppercase

* Some logging and typing

* Add all-time total production power and check if meter has value before adding it

* Add tests for Discovergy and changing therefor library import

* Disable phase-specific sensor per default, set user_input as default for schema and implement some other suggestions form code review

* Removing translation, fixing import and some more review implementation

* Fixing CI issues

* Check if acces token keys are in dict the correct way

* Implement suggestions after code review

* Correcting property function

* Change state class to STATE_CLASS_TOTAL_INCREASING

* Add reauth workflow for Discovergy

* Bump pydiscovergy

* Implement code review

* Remove _meter from __init__

* Bump pydiscovergy & minor changes

* Add gas meter support

* bump pydiscovergy & error handling

* Add myself to CODEOWNERS for test directory

* Resorting CODEOWNERS

* Implement diagnostics and reduce API use

* Make homeassistant imports absolute

* Exclude diagnostics.py from coverage report

* Add sensors with different keys

* Reformatting files

* Use new naming style

* Refactoring and moving to basic auth for API authentication

* Remove device name form entity name

* Add integration type to discovergy and implement new unit of measurement

* Add system health to discovergy integration

* Use right array key when using an alternative_key & using UnitOfElectricPotential.VOLT

* Add options for precision and update interval to Discovergy

* Remove precision config option and let it handle HA

* Rename precision attribute and remove translation file

* Some formatting tweaks

* Some more tests

* Move sensor names to strings.json

* Redacting title and unique_id as it contains user email address
This commit is contained in:
Jan-Philipp Benecke 2023-06-06 19:44:00 +02:00 committed by GitHub
parent 5ab4bf218e
commit d8ceb6463e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
19 changed files with 1067 additions and 0 deletions

View File

@ -202,6 +202,8 @@ omit =
homeassistant/components/discogs/sensor.py
homeassistant/components/discord/__init__.py
homeassistant/components/discord/notify.py
homeassistant/components/discovergy/__init__.py
homeassistant/components/discovergy/sensor.py
homeassistant/components/dlib_face_detect/image_processing.py
homeassistant/components/dlib_face_identify/image_processing.py
homeassistant/components/dlink/data.py

View File

@ -275,6 +275,8 @@ build.json @home-assistant/supervisor
/homeassistant/components/discogs/ @thibmaek
/homeassistant/components/discord/ @tkdrob
/tests/components/discord/ @tkdrob
/homeassistant/components/discovergy/ @jpbede
/tests/components/discovergy/ @jpbede
/homeassistant/components/discovery/ @home-assistant/core
/tests/components/discovery/ @home-assistant/core
/homeassistant/components/dlink/ @tkdrob

View File

@ -0,0 +1,84 @@
"""The Discovergy integration."""
from __future__ import annotations
from dataclasses import dataclass, field
import logging
import pydiscovergy
from pydiscovergy.authentication import BasicAuth
import pydiscovergy.error as discovergyError
from pydiscovergy.models import Meter
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_EMAIL, CONF_PASSWORD, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.helpers.httpx_client import get_async_client
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
from .const import APP_NAME, DOMAIN
PLATFORMS = [Platform.SENSOR]
_LOGGER = logging.getLogger(__name__)
@dataclass
class DiscovergyData:
"""Discovergy data class to share meters and api client."""
api_client: pydiscovergy.Discovergy = field(default_factory=lambda: None)
meters: list[Meter] = field(default_factory=lambda: [])
coordinators: dict[str, DataUpdateCoordinator] = field(default_factory=lambda: {})
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up Discovergy from a config entry."""
hass.data.setdefault(DOMAIN, {})
# init discovergy data class
discovergy_data = DiscovergyData(
api_client=pydiscovergy.Discovergy(
email=entry.data[CONF_EMAIL],
password=entry.data[CONF_PASSWORD],
app_name=APP_NAME,
httpx_client=get_async_client(hass),
authentication=BasicAuth(),
),
meters=[],
coordinators={},
)
try:
# try to get meters from api to check if access token is still valid and later use
# if no exception is raised everything is fine to go
discovergy_data.meters = await discovergy_data.api_client.get_meters()
except discovergyError.InvalidLogin as err:
_LOGGER.debug("Invalid email or password: %s", err)
raise ConfigEntryAuthFailed("Invalid email or password") from err
except Exception as err: # pylint: disable=broad-except
_LOGGER.error("Unexpected error while communicating with API: %s", err)
raise ConfigEntryNotReady(
"Unexpected error while communicating with API"
) from err
hass.data[DOMAIN][entry.entry_id] = discovergy_data
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
entry.async_on_unload(entry.add_update_listener(async_reload_entry))
return True
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
if unload_ok:
hass.data[DOMAIN].pop(entry.entry_id)
return unload_ok
async def async_reload_entry(hass: HomeAssistant, entry: ConfigEntry) -> None:
"""Handle an options update."""
await hass.config_entries.async_reload(entry.entry_id)

View File

@ -0,0 +1,167 @@
"""Config flow for Discovergy integration."""
from __future__ import annotations
from collections.abc import Mapping
import logging
from typing import Any
import pydiscovergy
from pydiscovergy.authentication import BasicAuth
import pydiscovergy.error as discovergyError
import voluptuous as vol
from homeassistant import config_entries
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_EMAIL, CONF_PASSWORD
from homeassistant.core import callback
from homeassistant.data_entry_flow import FlowResult
from homeassistant.helpers.httpx_client import get_async_client
from .const import (
APP_NAME,
CONF_TIME_BETWEEN_UPDATE,
DEFAULT_TIME_BETWEEN_UPDATE,
DOMAIN,
)
_LOGGER = logging.getLogger(__name__)
def make_schema(email: str = "", password: str = "") -> vol.Schema:
"""Create schema for config flow."""
return vol.Schema(
{
vol.Required(
CONF_EMAIL,
default=email,
): str,
vol.Required(
CONF_PASSWORD,
default=password,
): str,
}
)
class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Discovergy."""
VERSION = 1
existing_entry: 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=make_schema(),
)
return await self._validate_and_save(user_input)
async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult:
"""Handle the initial step."""
self.existing_entry = await self.async_set_unique_id(self.context["unique_id"])
if entry_data is None:
return self.async_show_form(
step_id="reauth",
data_schema=make_schema(
self.existing_entry.data[CONF_EMAIL] or "",
self.existing_entry.data[CONF_PASSWORD] or "",
),
)
return await self._validate_and_save(dict(entry_data), step_id="reauth")
async def _validate_and_save(
self, user_input: dict[str, Any] | None = None, step_id: str = "user"
) -> FlowResult:
"""Validate user input and create config entry."""
errors = {}
if user_input:
try:
await pydiscovergy.Discovergy(
email=user_input[CONF_EMAIL],
password=user_input[CONF_PASSWORD],
app_name=APP_NAME,
httpx_client=get_async_client(self.hass),
authentication=BasicAuth(),
).get_meters()
result = {"title": user_input[CONF_EMAIL], "data": user_input}
except discovergyError.HTTPError:
errors["base"] = "cannot_connect"
except discovergyError.InvalidLogin:
errors["base"] = "invalid_auth"
except Exception: # pylint: disable=broad-except
_LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
else:
if self.existing_entry:
self.hass.config_entries.async_update_entry(
self.existing_entry,
data={
CONF_EMAIL: user_input[CONF_EMAIL],
CONF_PASSWORD: user_input[CONF_PASSWORD],
},
)
await self.hass.config_entries.async_reload(
self.existing_entry.entry_id
)
return self.async_abort(reason="reauth_successful")
# set unique id to title which is the account email
await self.async_set_unique_id(result["title"].lower())
self._abort_if_unique_id_configured()
return self.async_create_entry(
title=result["title"], data=result["data"]
)
return self.async_show_form(
step_id=step_id,
data_schema=make_schema(),
errors=errors,
)
@staticmethod
@callback
def async_get_options_flow(
config_entry: config_entries.ConfigEntry,
) -> config_entries.OptionsFlow:
"""Create the options flow."""
return DiscovergyOptionsFlowHandler(config_entry)
class DiscovergyOptionsFlowHandler(config_entries.OptionsFlow):
"""Handle Discovergy options."""
def __init__(self, config_entry: config_entries.ConfigEntry) -> None:
"""Initialize options flow."""
self.config_entry = config_entry
async def async_step_init(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
"""Manage the options."""
if user_input is not None:
return self.async_create_entry(title="", data=user_input)
return self.async_show_form(
step_id="init",
data_schema=vol.Schema(
{
vol.Optional(
CONF_TIME_BETWEEN_UPDATE,
default=self.config_entry.options.get(
CONF_TIME_BETWEEN_UPDATE, DEFAULT_TIME_BETWEEN_UPDATE
),
): vol.All(vol.Coerce(int), vol.Range(min=1)),
}
),
)

View File

@ -0,0 +1,8 @@
"""Constants for the Discovergy integration."""
from __future__ import annotations
DOMAIN = "discovergy"
MANUFACTURER = "Discovergy"
APP_NAME = "homeassistant"
CONF_TIME_BETWEEN_UPDATE = "time_between_update"
DEFAULT_TIME_BETWEEN_UPDATE = 30

View File

@ -0,0 +1,50 @@
"""Diagnostics support for discovergy."""
from __future__ import annotations
from typing import Any
from pydiscovergy.models import Meter
from homeassistant.components.diagnostics import async_redact_data
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_EMAIL, CONF_PASSWORD, CONF_UNIQUE_ID
from homeassistant.core import HomeAssistant
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
from . import DiscovergyData
from .const import DOMAIN
TO_REDACT_CONFIG_ENTRY = {CONF_EMAIL, CONF_PASSWORD, CONF_UNIQUE_ID, "title"}
TO_REDACT_METER = {
"serial_number",
"full_serial_number",
"location",
"fullSerialNumber",
"printedFullSerialNumber",
"administrationNumber",
}
async def async_get_config_entry_diagnostics(
hass: HomeAssistant, entry: ConfigEntry
) -> dict[str, Any]:
"""Return diagnostics for a config entry."""
flattened_meter: list[dict] = []
last_readings: dict[str, dict] = {}
data: DiscovergyData = hass.data[DOMAIN][entry.entry_id]
meters: list[Meter] = data.meters # always returns a list
for meter in meters:
# make a dict of meter data and redact some data
flattened_meter.append(async_redact_data(meter.__dict__, TO_REDACT_METER))
# get last reading for meter and make a dict of it
coordinator: DataUpdateCoordinator = data.coordinators[meter.get_meter_id()]
last_readings[meter.get_meter_id()] = coordinator.data.__dict__
return {
"entry": async_redact_data(entry.as_dict(), TO_REDACT_CONFIG_ENTRY),
"meters": flattened_meter,
"readings": last_readings,
}

View File

@ -0,0 +1,10 @@
{
"domain": "discovergy",
"name": "Discovergy",
"codeowners": ["@jpbede"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/discovergy",
"integration_type": "hub",
"iot_class": "cloud_polling",
"requirements": ["pydiscovergy==1.2.1"]
}

View File

@ -0,0 +1,274 @@
"""Discovergy sensor entity."""
from dataclasses import dataclass, field
from datetime import timedelta
import logging
from pydiscovergy import Discovergy
from pydiscovergy.error import AccessTokenExpired, HTTPError
from pydiscovergy.models import Meter
from homeassistant.components.sensor import (
SensorDeviceClass,
SensorEntity,
SensorEntityDescription,
SensorStateClass,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
ATTR_IDENTIFIERS,
ATTR_MANUFACTURER,
ATTR_MODEL,
ATTR_NAME,
UnitOfElectricPotential,
UnitOfEnergy,
UnitOfPower,
UnitOfVolume,
)
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import StateType
from homeassistant.helpers.update_coordinator import (
CoordinatorEntity,
DataUpdateCoordinator,
UpdateFailed,
)
from . import DiscovergyData
from .const import (
CONF_TIME_BETWEEN_UPDATE,
DEFAULT_TIME_BETWEEN_UPDATE,
DOMAIN,
MANUFACTURER,
)
PARALLEL_UPDATES = 1
_LOGGER = logging.getLogger(__name__)
@dataclass
class DiscovergyMixin:
"""Mixin for alternative keys."""
alternative_keys: list = field(default_factory=lambda: [])
scale: int = field(default_factory=lambda: 1000)
@dataclass
class DiscovergySensorEntityDescription(DiscovergyMixin, SensorEntityDescription):
"""Define Sensor entity description class."""
GAS_SENSORS: tuple[DiscovergySensorEntityDescription, ...] = (
DiscovergySensorEntityDescription(
key="volume",
translation_key="total_gas_consumption",
suggested_display_precision=4,
native_unit_of_measurement=UnitOfVolume.CUBIC_METERS,
device_class=SensorDeviceClass.GAS,
state_class=SensorStateClass.TOTAL_INCREASING,
),
)
ELECTRICITY_SENSORS: tuple[DiscovergySensorEntityDescription, ...] = (
# power sensors
DiscovergySensorEntityDescription(
key="power",
translation_key="total_power",
native_unit_of_measurement=UnitOfPower.WATT,
suggested_display_precision=3,
device_class=SensorDeviceClass.POWER,
state_class=SensorStateClass.MEASUREMENT,
),
DiscovergySensorEntityDescription(
key="power1",
translation_key="phase_1_power",
native_unit_of_measurement=UnitOfPower.WATT,
suggested_display_precision=3,
device_class=SensorDeviceClass.POWER,
state_class=SensorStateClass.MEASUREMENT,
entity_registry_enabled_default=False,
alternative_keys=["phase1Power"],
),
DiscovergySensorEntityDescription(
key="power2",
translation_key="phase_2_power",
native_unit_of_measurement=UnitOfPower.WATT,
suggested_display_precision=3,
device_class=SensorDeviceClass.POWER,
state_class=SensorStateClass.MEASUREMENT,
entity_registry_enabled_default=False,
alternative_keys=["phase2Power"],
),
DiscovergySensorEntityDescription(
key="power3",
translation_key="phase_3_power",
native_unit_of_measurement=UnitOfPower.WATT,
suggested_display_precision=3,
device_class=SensorDeviceClass.POWER,
state_class=SensorStateClass.MEASUREMENT,
entity_registry_enabled_default=False,
alternative_keys=["phase3Power"],
),
# voltage sensors
DiscovergySensorEntityDescription(
key="phase1Voltage",
translation_key="phase_1_voltage",
native_unit_of_measurement=UnitOfElectricPotential.VOLT,
suggested_display_precision=1,
device_class=SensorDeviceClass.VOLTAGE,
state_class=SensorStateClass.MEASUREMENT,
entity_registry_enabled_default=False,
),
DiscovergySensorEntityDescription(
key="phase2Voltage",
translation_key="phase_2_voltage",
native_unit_of_measurement=UnitOfElectricPotential.VOLT,
suggested_display_precision=1,
device_class=SensorDeviceClass.VOLTAGE,
state_class=SensorStateClass.MEASUREMENT,
entity_registry_enabled_default=False,
),
DiscovergySensorEntityDescription(
key="phase3Voltage",
translation_key="phase_3_voltage",
native_unit_of_measurement=UnitOfElectricPotential.VOLT,
suggested_display_precision=1,
device_class=SensorDeviceClass.VOLTAGE,
state_class=SensorStateClass.MEASUREMENT,
entity_registry_enabled_default=False,
),
# energy sensors
DiscovergySensorEntityDescription(
key="energy",
translation_key="total_consumption",
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
suggested_display_precision=4,
device_class=SensorDeviceClass.ENERGY,
state_class=SensorStateClass.TOTAL_INCREASING,
scale=10000000000,
),
DiscovergySensorEntityDescription(
key="energyOut",
translation_key="total_production",
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
suggested_display_precision=4,
device_class=SensorDeviceClass.ENERGY,
state_class=SensorStateClass.TOTAL_INCREASING,
scale=10000000000,
),
)
def get_coordinator_for_meter(
hass: HomeAssistant,
meter: Meter,
discovergy_instance: Discovergy,
update_interval: timedelta,
) -> DataUpdateCoordinator:
"""Create a new DataUpdateCoordinator for given meter."""
async def async_update_data():
"""Fetch data from API endpoint."""
try:
return await discovergy_instance.get_last_reading(meter.get_meter_id())
except AccessTokenExpired as err:
raise ConfigEntryAuthFailed(
"Got token expired while communicating with API"
) from err
except HTTPError as err:
raise UpdateFailed(f"Error communicating with API: {err}") from err
except Exception as err: # pylint: disable=broad-except
raise UpdateFailed(
f"Unexpected error while communicating with API: {err}"
) from err
coordinator = DataUpdateCoordinator(
hass,
_LOGGER,
name="sensor",
update_method=async_update_data,
update_interval=update_interval,
)
return coordinator
async def async_setup_entry(
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
) -> None:
"""Set up the Discovergy sensors."""
data: DiscovergyData = hass.data[DOMAIN][entry.entry_id]
discovergy_instance: Discovergy = data.api_client
meters: list[Meter] = data.meters # always returns a list
min_time_between_updates = timedelta(
seconds=entry.options.get(CONF_TIME_BETWEEN_UPDATE, DEFAULT_TIME_BETWEEN_UPDATE)
)
entities = []
for meter in meters:
# Get coordinator for meter, set config entry and fetch initial data
# so we have data when entities are added
coordinator = get_coordinator_for_meter(
hass, meter, discovergy_instance, min_time_between_updates
)
coordinator.config_entry = entry
await coordinator.async_config_entry_first_refresh()
# add coordinator to data for diagnostics
data.coordinators[meter.get_meter_id()] = coordinator
sensors = None
if meter.measurement_type == "ELECTRICITY":
sensors = ELECTRICITY_SENSORS
elif meter.measurement_type == "GAS":
sensors = GAS_SENSORS
if sensors is not None:
for description in sensors:
keys = [description.key] + description.alternative_keys
# check if this meter has this data, then add this sensor
for key in keys:
if key in coordinator.data.values:
entities.append(
DiscovergySensor(key, description, meter, coordinator)
)
async_add_entities(entities, False)
class DiscovergySensor(CoordinatorEntity, SensorEntity):
"""Represents a discovergy smart meter sensor."""
entity_description: DiscovergySensorEntityDescription
data_key: str
_attr_has_entity_name = True
def __init__(
self,
data_key: str,
description: DiscovergySensorEntityDescription,
meter: Meter,
coordinator: DataUpdateCoordinator,
) -> None:
"""Initialize the sensor."""
super().__init__(coordinator)
self.data_key = data_key
self.entity_description = description
self._attr_unique_id = f"{meter.full_serial_number}-{description.key}"
self._attr_device_info = {
ATTR_IDENTIFIERS: {(DOMAIN, meter.get_meter_id())},
ATTR_NAME: f"{meter.measurement_type.capitalize()} {meter.location.street} {meter.location.street_number}",
ATTR_MODEL: f"{meter.type} {meter.full_serial_number}",
ATTR_MANUFACTURER: MANUFACTURER,
}
@property
def native_value(self) -> StateType:
"""Return the sensor state."""
return float(
self.coordinator.data.values[self.data_key] / self.entity_description.scale
)

View File

@ -0,0 +1,75 @@
{
"config": {
"step": {
"user": {
"data": {
"email": "[%key:common::config_flow::data::email%]",
"password": "[%key:common::config_flow::data::password%]"
}
},
"reauth": {
"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_account%]",
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]"
}
},
"system_health": {
"info": {
"api_endpoint_reachable": "Discovergy API endpoint reachable"
}
},
"options": {
"step": {
"init": {
"data": {
"time_between_update": "Minimum time between entity updates [s]"
}
}
}
},
"entity": {
"sensor": {
"total_gas_consumption": {
"name": "Total gas consumption"
},
"total_power": {
"name": "Total power"
},
"total_consumption": {
"name": "Total consumption"
},
"total_production": {
"name": "Total production"
},
"phase_1_voltage": {
"name": "Phase 1 voltage"
},
"phase_2_voltage": {
"name": "Phase 2 voltage"
},
"phase_3_voltage": {
"name": "Phase 3 voltage"
},
"phase_1_power": {
"name": "Phase 1 power"
},
"phase_2_power": {
"name": "Phase 2 power"
},
"phase_3_power": {
"name": "Phase 3 power"
}
}
}
}

View File

@ -0,0 +1,22 @@
"""Provide info to system health."""
from pydiscovergy.const import API_BASE
from homeassistant.components import system_health
from homeassistant.core import HomeAssistant, callback
@callback
def async_register(
hass: HomeAssistant, register: system_health.SystemHealthRegistration
) -> None:
"""Register system health callbacks."""
register.async_register_info(system_health_info)
async def system_health_info(hass):
"""Get info for the info page."""
return {
"api_endpoint_reachable": system_health.async_check_can_reach_url(
hass, API_BASE
)
}

View File

@ -95,6 +95,7 @@ FLOWS = {
"dialogflow",
"directv",
"discord",
"discovergy",
"dlink",
"dlna_dmr",
"dlna_dms",

View File

@ -1091,6 +1091,12 @@
"config_flow": true,
"iot_class": "cloud_push"
},
"discovergy": {
"name": "Discovergy",
"integration_type": "hub",
"config_flow": true,
"iot_class": "cloud_polling"
},
"dlib_face_detect": {
"name": "Dlib Face Detect",
"integration_type": "hub",

View File

@ -1633,6 +1633,9 @@ pydelijn==1.0.0
# homeassistant.components.dexcom
pydexcom==0.2.3
# homeassistant.components.discovergy
pydiscovergy==1.2.1
# homeassistant.components.doods
pydoods==1.0.2

View File

@ -1203,6 +1203,9 @@ pydeconz==113
# homeassistant.components.dexcom
pydexcom==0.2.3
# homeassistant.components.discovergy
pydiscovergy==1.2.1
# homeassistant.components.android_ip_webcam
pydroid-ipcam==2.0.0

View File

@ -0,0 +1,75 @@
"""Tests for the Discovergy integration."""
import datetime
from unittest.mock import patch
from pydiscovergy.models import Meter, Reading
from homeassistant.components.discovergy import DOMAIN
from homeassistant.const import CONF_EMAIL, CONF_PASSWORD
from homeassistant.core import HomeAssistant
from tests.common import MockConfigEntry
GET_METERS = [
Meter(
meterId="f8d610b7a8cc4e73939fa33b990ded54",
serialNumber="abc123",
fullSerialNumber="abc123",
type="TST",
measurementType="ELECTRICITY",
loadProfileType="SLP",
location={
"city": "Testhause",
"street": "Teststraße",
"streetNumber": "1",
"country": "Germany",
},
manufacturerId="TST",
printedFullSerialNumber="abc123",
administrationNumber="12345",
scalingFactor=1,
currentScalingFactor=1,
voltageScalingFactor=1,
internalMeters=1,
firstMeasurementTime=1517569090926,
lastMeasurementTime=1678430543742,
),
]
LAST_READING = Reading(
time=datetime.datetime(2023, 3, 10, 7, 32, 6, 702000),
values={
"energy": 119348699715000.0,
"energy1": 2254180000.0,
"energy2": 119346445534000.0,
"energyOut": 55048723044000.0,
"energyOut1": 0.0,
"energyOut2": 0.0,
"power": 531750.0,
"power1": 142680.0,
"power2": 138010.0,
"power3": 251060.0,
"voltage1": 239800.0,
"voltage2": 239700.0,
"voltage3": 239000.0,
},
)
async def init_integration(hass: HomeAssistant) -> MockConfigEntry:
"""Set up the Discovergy integration in Home Assistant."""
entry = MockConfigEntry(
domain=DOMAIN,
title="user@example.org",
unique_id="user@example.org",
data={CONF_EMAIL: "user@example.org", CONF_PASSWORD: "supersecretpassword"},
)
with patch("pydiscovergy.Discovergy.get_meters", return_value=GET_METERS), patch(
"pydiscovergy.Discovergy.get_last_reading", return_value=LAST_READING
):
entry.add_to_hass(hass)
await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
return entry

View File

@ -0,0 +1,14 @@
"""Fixtures for Discovergy integration tests."""
from unittest.mock import AsyncMock, Mock, patch
import pytest
from tests.components.discovergy import GET_METERS
@pytest.fixture
def mock_meters() -> Mock:
"""Patch libraries."""
with patch("pydiscovergy.Discovergy.get_meters") as discovergy:
discovergy.side_effect = AsyncMock(return_value=GET_METERS)
yield discovergy

View File

@ -0,0 +1,145 @@
"""Test the Discovergy config flow."""
from unittest.mock import patch
from pydiscovergy.error import HTTPError, InvalidLogin
from homeassistant import data_entry_flow, setup
from homeassistant.components.discovergy.const import DOMAIN
from homeassistant.config_entries import SOURCE_REAUTH, SOURCE_USER
from homeassistant.const import CONF_EMAIL, CONF_PASSWORD
from homeassistant.core import HomeAssistant
from tests.components.discovergy import init_integration
async def test_form(hass: HomeAssistant, mock_meters) -> None:
"""Test we get the form."""
await setup.async_setup_component(hass, "persistent_notification", {})
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
assert result["type"] == data_entry_flow.FlowResultType.FORM
assert result["errors"] is None
with patch(
"homeassistant.components.discovergy.async_setup_entry",
return_value=True,
) as mock_setup_entry:
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
CONF_EMAIL: "test@example.com",
CONF_PASSWORD: "test-password",
},
)
await hass.async_block_till_done()
assert result2["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY
assert result2["title"] == "test@example.com"
assert result2["data"] == {
CONF_EMAIL: "test@example.com",
CONF_PASSWORD: "test-password",
}
assert len(mock_setup_entry.mock_calls) == 1
async def test_reauth(hass: HomeAssistant, mock_meters) -> None:
"""Test reauth flow."""
entry = await init_integration(hass)
init_result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_REAUTH, "unique_id": entry.unique_id},
data=None,
)
assert init_result["type"] == data_entry_flow.FlowResultType.FORM
assert init_result["step_id"] == "reauth"
configure_result = await hass.config_entries.flow.async_configure(
init_result["flow_id"],
{
CONF_EMAIL: "test@example.com",
CONF_PASSWORD: "test-password",
},
)
assert configure_result["type"] == data_entry_flow.FlowResultType.ABORT
assert configure_result["reason"] == "reauth_successful"
async def test_form_invalid_auth(hass: HomeAssistant) -> None:
"""Test we handle invalid auth."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
with patch(
"pydiscovergy.Discovergy.get_meters",
side_effect=InvalidLogin,
):
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
CONF_EMAIL: "test@example.com",
CONF_PASSWORD: "test-password",
},
)
assert result2["type"] == data_entry_flow.FlowResultType.FORM
assert result2["errors"] == {"base": "invalid_auth"}
async def test_form_cannot_connect(hass: HomeAssistant) -> None:
"""Test we handle cannot connect error."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
with patch("pydiscovergy.Discovergy.get_meters", side_effect=HTTPError):
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
CONF_EMAIL: "test@example.com",
CONF_PASSWORD: "test-password",
},
)
assert result2["type"] == data_entry_flow.FlowResultType.FORM
assert result2["errors"] == {"base": "cannot_connect"}
async def test_form_unknown_exception(hass: HomeAssistant) -> None:
"""Test we handle cannot connect error."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
with patch("pydiscovergy.Discovergy.get_meters", side_effect=Exception):
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
CONF_EMAIL: "test@example.com",
CONF_PASSWORD: "test-password",
},
)
assert result2["type"] == data_entry_flow.FlowResultType.FORM
assert result2["errors"] == {"base": "unknown"}
async def test_options_flow_init(hass: HomeAssistant) -> None:
"""Test the options flow."""
entry = await init_integration(hass)
result = await hass.config_entries.options.async_init(entry.entry_id)
assert result["type"] == data_entry_flow.FlowResultType.FORM
assert result["step_id"] == "init"
create_result = await hass.config_entries.options.async_configure(
result["flow_id"], {"time_between_update": 2}
)
assert create_result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY
assert create_result["data"] == {"time_between_update": 2}

View File

@ -0,0 +1,78 @@
"""Test Discovergy diagnostics."""
from homeassistant.components.diagnostics import REDACTED
from homeassistant.core import HomeAssistant
from . import init_integration
from tests.components.diagnostics import get_diagnostics_for_config_entry
from tests.typing import ClientSessionGenerator
async def test_entry_diagnostics(
hass: HomeAssistant, hass_client: ClientSessionGenerator
) -> None:
"""Test config entry diagnostics."""
entry = await init_integration(hass)
result = await get_diagnostics_for_config_entry(hass, hass_client, entry)
assert result["entry"] == {
"entry_id": entry.entry_id,
"version": 1,
"domain": "discovergy",
"title": REDACTED,
"data": {"email": REDACTED, "password": REDACTED},
"options": {},
"pref_disable_new_entities": False,
"pref_disable_polling": False,
"source": "user",
"unique_id": REDACTED,
"disabled_by": None,
}
assert result["meters"] == [
{
"additional": {
"administrationNumber": REDACTED,
"currentScalingFactor": 1,
"firstMeasurementTime": 1517569090926,
"fullSerialNumber": REDACTED,
"internalMeters": 1,
"lastMeasurementTime": 1678430543742,
"loadProfileType": "SLP",
"manufacturerId": "TST",
"printedFullSerialNumber": REDACTED,
"scalingFactor": 1,
"type": "TST",
"voltageScalingFactor": 1,
},
"full_serial_number": REDACTED,
"load_profile_type": "SLP",
"location": REDACTED,
"measurement_type": "ELECTRICITY",
"meter_id": "f8d610b7a8cc4e73939fa33b990ded54",
"serial_number": REDACTED,
"type": "TST",
}
]
assert result["readings"] == {
"f8d610b7a8cc4e73939fa33b990ded54": {
"time": "2023-03-10T07:32:06.702000",
"values": {
"energy": 119348699715000.0,
"energy1": 2254180000.0,
"energy2": 119346445534000.0,
"energyOut": 55048723044000.0,
"energyOut1": 0.0,
"energyOut2": 0.0,
"power": 531750.0,
"power1": 142680.0,
"power2": 138010.0,
"power3": 251060.0,
"voltage1": 239800.0,
"voltage2": 239700.0,
"voltage3": 239000.0,
},
}
}

View File

@ -0,0 +1,48 @@
"""Test Discovergy system health."""
import asyncio
from aiohttp import ClientError
from pydiscovergy.const import API_BASE
from homeassistant.components.discovergy.const import DOMAIN
from homeassistant.core import HomeAssistant
from homeassistant.setup import async_setup_component
from tests.common import get_system_health_info
from tests.test_util.aiohttp import AiohttpClientMocker
async def test_discovergy_system_health(
hass: HomeAssistant, aioclient_mock: AiohttpClientMocker
) -> None:
"""Test Discovergy system health."""
aioclient_mock.get(API_BASE, text="")
hass.config.components.add(DOMAIN)
assert await async_setup_component(hass, "system_health", {})
info = await get_system_health_info(hass, DOMAIN)
for key, val in info.items():
if asyncio.iscoroutine(val):
info[key] = await val
assert info == {"api_endpoint_reachable": "ok"}
async def test_discovergy_system_health_fail(
hass: HomeAssistant, aioclient_mock: AiohttpClientMocker
) -> None:
"""Test Discovergy system health."""
aioclient_mock.get(API_BASE, exc=ClientError)
hass.config.components.add(DOMAIN)
assert await async_setup_component(hass, "system_health", {})
info = await get_system_health_info(hass, DOMAIN)
for key, val in info.items():
if asyncio.iscoroutine(val):
info[key] = await val
assert info == {
"api_endpoint_reachable": {"type": "failed", "error": "unreachable"}
}