Migrate emoncms to config flow (#121336)

* Migrate emoncms to config flow

* tests coverage 98%

* use runtime_data

* Remove pyemoncms bump.

* Remove not needed yaml parameters add async_update_data to coordinator

* Reduce snapshot size

* Remove CONF_UNIT_OF_MEASUREMENT

* correct path in emoncms_client mock

* Remove init connexion check
as done by config_entry_first_refresh
since async_update_data catches exceptionand raise UpdateFailed

* Remove CONF_EXCLUDE_FEEDID from config flow

* Update homeassistant/components/emoncms/__init__.py

Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>

* Update homeassistant/components/emoncms/sensor.py

Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>

* Update homeassistant/components/emoncms/strings.json

Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>

* Use options in options flow and common strings

* Extend the ConfigEntry type

* Define the type explicitely

* Add data description in strings.json

* Update tests/components/emoncms/test_config_flow.py

Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>

* Update tests/components/emoncms/test_config_flow.py

Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>

* Add test import same yaml conf + corrections

* Add test user flow

* Use data_description...

* Use snapshot_platform in test_sensor

* Transfer all fixtures in conftest

* Add async_step_choose_feeds to ask flows to user

* Test abortion reason in test_flow_import_failure

* Add issue when value_template is i yaml conf

* make text more expressive in strings.json

* Add issue when no feed imported during migration.

* Update tests/components/emoncms/test_config_flow.py

* Update tests/components/emoncms/test_config_flow.py

---------

Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
This commit is contained in:
Alexandre CUER 2024-09-03 17:21:13 +02:00 committed by GitHub
parent 470335e27a
commit 8255728f53
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 815 additions and 107 deletions

View file

@ -1 +1,40 @@
"""The emoncms component.""" """The emoncms component."""
from pyemoncms import EmoncmsClient
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_API_KEY, CONF_URL, Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .coordinator import EmoncmsCoordinator
PLATFORMS: list[Platform] = [Platform.SENSOR]
type EmonCMSConfigEntry = ConfigEntry[EmoncmsCoordinator]
async def async_setup_entry(hass: HomeAssistant, entry: EmonCMSConfigEntry) -> bool:
"""Load a config entry."""
emoncms_client = EmoncmsClient(
entry.data[CONF_URL],
entry.data[CONF_API_KEY],
session=async_get_clientsession(hass),
)
coordinator = EmoncmsCoordinator(hass, emoncms_client)
await coordinator.async_config_entry_first_refresh()
entry.runtime_data = coordinator
entry.async_on_unload(entry.add_update_listener(update_listener))
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
async def update_listener(hass: HomeAssistant, entry: ConfigEntry):
"""Handle options update."""
await hass.config_entries.async_reload(entry.entry_id)
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)

View file

@ -0,0 +1,210 @@
"""Configflow for the emoncms integration."""
from typing import Any
from pyemoncms import EmoncmsClient
import voluptuous as vol
from homeassistant.config_entries import (
ConfigEntry,
ConfigFlow,
ConfigFlowResult,
OptionsFlowWithConfigEntry,
)
from homeassistant.const import CONF_API_KEY, CONF_URL
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.selector import selector
from homeassistant.helpers.typing import ConfigType
from .const import (
CONF_MESSAGE,
CONF_ONLY_INCLUDE_FEEDID,
CONF_SUCCESS,
DOMAIN,
FEED_ID,
FEED_NAME,
FEED_TAG,
LOGGER,
)
def get_options(feeds: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""Build the selector options with the feed list."""
return [
{
"value": feed[FEED_ID],
"label": f"{feed[FEED_ID]}|{feed[FEED_TAG]}|{feed[FEED_NAME]}",
}
for feed in feeds
]
def sensor_name(url: str) -> str:
"""Return sensor name."""
sensorip = url.rsplit("//", maxsplit=1)[-1]
return f"emoncms@{sensorip}"
async def get_feed_list(hass: HomeAssistant, url: str, api_key: str) -> dict[str, Any]:
"""Check connection to emoncms and return feed list if successful."""
emoncms_client = EmoncmsClient(
url,
api_key,
session=async_get_clientsession(hass),
)
return await emoncms_client.async_request("/feed/list.json")
class EmoncmsConfigFlow(ConfigFlow, domain=DOMAIN):
"""emoncms integration UI config flow."""
url: str
api_key: str
include_only_feeds: list | None = None
dropdown: dict = {}
@staticmethod
@callback
def async_get_options_flow(
config_entry: ConfigEntry,
) -> OptionsFlowWithConfigEntry:
"""Get the options flow for this handler."""
return EmoncmsOptionsFlow(config_entry)
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Initiate a flow via the UI."""
errors: dict[str, str] = {}
if user_input is not None:
self._async_abort_entries_match(
{
CONF_API_KEY: user_input[CONF_API_KEY],
CONF_URL: user_input[CONF_URL],
}
)
result = await get_feed_list(
self.hass, user_input[CONF_URL], user_input[CONF_API_KEY]
)
if not result[CONF_SUCCESS]:
errors["base"] = result[CONF_MESSAGE]
else:
self.include_only_feeds = user_input.get(CONF_ONLY_INCLUDE_FEEDID)
self.url = user_input[CONF_URL]
self.api_key = user_input[CONF_API_KEY]
options = get_options(result[CONF_MESSAGE])
self.dropdown = {
"options": options,
"mode": "dropdown",
"multiple": True,
}
return await self.async_step_choose_feeds()
return self.async_show_form(
step_id="user",
data_schema=self.add_suggested_values_to_schema(
vol.Schema(
{
vol.Required(CONF_URL): str,
vol.Required(CONF_API_KEY): str,
}
),
user_input,
),
errors=errors,
)
async def async_step_choose_feeds(
self,
user_input: dict[str, Any] | None = None,
) -> ConfigFlowResult:
"""Choose feeds to import."""
errors: dict[str, str] = {}
include_only_feeds: list = []
if user_input or self.include_only_feeds is not None:
if self.include_only_feeds is not None:
include_only_feeds = self.include_only_feeds
elif user_input:
include_only_feeds = user_input[CONF_ONLY_INCLUDE_FEEDID]
return self.async_create_entry(
title=sensor_name(self.url),
data={
CONF_URL: self.url,
CONF_API_KEY: self.api_key,
CONF_ONLY_INCLUDE_FEEDID: include_only_feeds,
},
)
return self.async_show_form(
step_id="choose_feeds",
data_schema=vol.Schema(
{
vol.Required(
CONF_ONLY_INCLUDE_FEEDID,
default=include_only_feeds,
): selector({"select": self.dropdown}),
}
),
errors=errors,
)
async def async_step_import(self, import_info: ConfigType) -> ConfigFlowResult:
"""Import config from yaml."""
url = import_info[CONF_URL]
api_key = import_info[CONF_API_KEY]
include_only_feeds = None
if import_info.get(CONF_ONLY_INCLUDE_FEEDID) is not None:
include_only_feeds = list(map(str, import_info[CONF_ONLY_INCLUDE_FEEDID]))
config = {
CONF_API_KEY: api_key,
CONF_ONLY_INCLUDE_FEEDID: include_only_feeds,
CONF_URL: url,
}
LOGGER.debug(config)
result = await self.async_step_user(config)
if errors := result.get("errors"):
return self.async_abort(reason=errors["base"])
return result
class EmoncmsOptionsFlow(OptionsFlowWithConfigEntry):
"""Emoncms Options flow handler."""
async def async_step_init(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Manage the options."""
errors: dict[str, str] = {}
data = self.options if self.options else self._config_entry.data
url = data[CONF_URL]
api_key = data[CONF_API_KEY]
include_only_feeds = data.get(CONF_ONLY_INCLUDE_FEEDID, [])
options: list = include_only_feeds
result = await get_feed_list(self.hass, url, api_key)
if not result[CONF_SUCCESS]:
errors["base"] = result[CONF_MESSAGE]
else:
options = get_options(result[CONF_MESSAGE])
dropdown = {"options": options, "mode": "dropdown", "multiple": True}
if user_input:
include_only_feeds = user_input[CONF_ONLY_INCLUDE_FEEDID]
return self.async_create_entry(
title=sensor_name(url),
data={
CONF_URL: url,
CONF_API_KEY: api_key,
CONF_ONLY_INCLUDE_FEEDID: include_only_feeds,
},
)
return self.async_show_form(
step_id="init",
data_schema=vol.Schema(
{
vol.Required(
CONF_ONLY_INCLUDE_FEEDID, default=include_only_feeds
): selector({"select": dropdown}),
}
),
errors=errors,
)

View file

@ -7,6 +7,9 @@ CONF_ONLY_INCLUDE_FEEDID = "include_only_feed_id"
CONF_MESSAGE = "message" CONF_MESSAGE = "message"
CONF_SUCCESS = "success" CONF_SUCCESS = "success"
DOMAIN = "emoncms" DOMAIN = "emoncms"
FEED_ID = "id"
FEED_NAME = "name"
FEED_TAG = "tag"
LOGGER = logging.getLogger(__package__) LOGGER = logging.getLogger(__package__)

View file

@ -18,14 +18,13 @@ class EmoncmsCoordinator(DataUpdateCoordinator[list[dict[str, Any]] | None]):
self, self,
hass: HomeAssistant, hass: HomeAssistant,
emoncms_client: EmoncmsClient, emoncms_client: EmoncmsClient,
scan_interval: timedelta,
) -> None: ) -> None:
"""Initialize the emoncms data coordinator.""" """Initialize the emoncms data coordinator."""
super().__init__( super().__init__(
hass, hass,
LOGGER, LOGGER,
name="emoncms_coordinator", name="emoncms_coordinator",
update_interval=scan_interval, update_interval=timedelta(seconds=60),
) )
self.emoncms_client = emoncms_client self.emoncms_client = emoncms_client

View file

@ -2,6 +2,7 @@
"domain": "emoncms", "domain": "emoncms",
"name": "Emoncms", "name": "Emoncms",
"codeowners": ["@borpin", "@alexandrecuer"], "codeowners": ["@borpin", "@alexandrecuer"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/emoncms", "documentation": "https://www.home-assistant.io/integrations/emoncms",
"iot_class": "local_polling", "iot_class": "local_polling",
"requirements": ["pyemoncms==0.0.7"] "requirements": ["pyemoncms==0.0.7"]

View file

@ -2,10 +2,8 @@
from __future__ import annotations from __future__ import annotations
from datetime import timedelta
from typing import Any from typing import Any
from pyemoncms import EmoncmsClient
import voluptuous as vol import voluptuous as vol
from homeassistant.components.sensor import ( from homeassistant.components.sensor import (
@ -14,25 +12,33 @@ from homeassistant.components.sensor import (
SensorEntity, SensorEntity,
SensorStateClass, SensorStateClass,
) )
from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry
from homeassistant.const import ( from homeassistant.const import (
CONF_API_KEY, CONF_API_KEY,
CONF_ID, CONF_ID,
CONF_SCAN_INTERVAL,
CONF_UNIT_OF_MEASUREMENT, CONF_UNIT_OF_MEASUREMENT,
CONF_URL, CONF_URL,
CONF_VALUE_TEMPLATE, CONF_VALUE_TEMPLATE,
STATE_UNKNOWN,
UnitOfPower, UnitOfPower,
) )
from homeassistant.core import HomeAssistant, callback from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant, callback
from homeassistant.data_entry_flow import FlowResultType
from homeassistant.helpers import template from homeassistant.helpers import template
from homeassistant.helpers.aiohttp_client import async_get_clientsession
import homeassistant.helpers.config_validation as cv import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import CONF_EXCLUDE_FEEDID, CONF_ONLY_INCLUDE_FEEDID from .config_flow import sensor_name
from .const import (
CONF_EXCLUDE_FEEDID,
CONF_ONLY_INCLUDE_FEEDID,
DOMAIN,
FEED_ID,
FEED_NAME,
FEED_TAG,
)
from .coordinator import EmoncmsCoordinator from .coordinator import EmoncmsCoordinator
ATTR_FEEDID = "FeedId" ATTR_FEEDID = "FeedId"
@ -42,9 +48,7 @@ ATTR_LASTUPDATETIMESTR = "LastUpdatedStr"
ATTR_SIZE = "Size" ATTR_SIZE = "Size"
ATTR_TAG = "Tag" ATTR_TAG = "Tag"
ATTR_USERID = "UserId" ATTR_USERID = "UserId"
CONF_SENSOR_NAMES = "sensor_names" CONF_SENSOR_NAMES = "sensor_names"
DECIMALS = 2 DECIMALS = 2
DEFAULT_UNIT = UnitOfPower.WATT DEFAULT_UNIT = UnitOfPower.WATT
@ -76,20 +80,73 @@ async def async_setup_platform(
async_add_entities: AddEntitiesCallback, async_add_entities: AddEntitiesCallback,
discovery_info: DiscoveryInfoType | None = None, discovery_info: DiscoveryInfoType | None = None,
) -> None: ) -> None:
"""Set up the Emoncms sensor.""" """Import config from yaml."""
apikey = config[CONF_API_KEY] if CONF_VALUE_TEMPLATE in config:
url = config[CONF_URL] async_create_issue(
sensorid = config[CONF_ID] hass,
value_template = config.get(CONF_VALUE_TEMPLATE) DOMAIN,
config_unit = config.get(CONF_UNIT_OF_MEASUREMENT) f"remove_{CONF_VALUE_TEMPLATE}_{DOMAIN}",
is_fixable=False,
issue_domain=DOMAIN,
severity=IssueSeverity.ERROR,
translation_key=f"remove_{CONF_VALUE_TEMPLATE}",
translation_placeholders={
"domain": DOMAIN,
"parameter": CONF_VALUE_TEMPLATE,
},
)
return
if CONF_ONLY_INCLUDE_FEEDID not in config:
async_create_issue(
hass,
DOMAIN,
f"missing_{CONF_ONLY_INCLUDE_FEEDID}_{DOMAIN}",
is_fixable=False,
issue_domain=DOMAIN,
severity=IssueSeverity.WARNING,
translation_key=f"missing_{CONF_ONLY_INCLUDE_FEEDID}",
translation_placeholders={
"domain": DOMAIN,
},
)
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_IMPORT}, data=config
)
if (
result.get("type") == FlowResultType.CREATE_ENTRY
or result.get("reason") == "already_configured"
):
async_create_issue(
hass,
HOMEASSISTANT_DOMAIN,
f"deprecated_yaml_{DOMAIN}",
is_fixable=False,
issue_domain=DOMAIN,
breaks_in_ha_version="2025.3.0",
severity=IssueSeverity.WARNING,
translation_key="deprecated_yaml",
translation_placeholders={
"domain": DOMAIN,
"integration_title": "emoncms",
},
)
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the emoncms sensors."""
config = entry.options if entry.options else entry.data
name = sensor_name(config[CONF_URL])
exclude_feeds = config.get(CONF_EXCLUDE_FEEDID) exclude_feeds = config.get(CONF_EXCLUDE_FEEDID)
include_only_feeds = config.get(CONF_ONLY_INCLUDE_FEEDID) include_only_feeds = config.get(CONF_ONLY_INCLUDE_FEEDID)
sensor_names = config.get(CONF_SENSOR_NAMES)
scan_interval = config.get(CONF_SCAN_INTERVAL, timedelta(seconds=30))
emoncms_client = EmoncmsClient(url, apikey, session=async_get_clientsession(hass)) if exclude_feeds is None and include_only_feeds is None:
coordinator = EmoncmsCoordinator(hass, emoncms_client, scan_interval) return
await coordinator.async_refresh()
coordinator = entry.runtime_data
elems = coordinator.data elems = coordinator.data
if not elems: if not elems:
return return
@ -97,28 +154,15 @@ async def async_setup_platform(
sensors: list[EmonCmsSensor] = [] sensors: list[EmonCmsSensor] = []
for idx, elem in enumerate(elems): for idx, elem in enumerate(elems):
if exclude_feeds is not None and int(elem["id"]) in exclude_feeds: if include_only_feeds is not None and elem[FEED_ID] not in include_only_feeds:
continue continue
if include_only_feeds is not None and int(elem["id"]) not in include_only_feeds:
continue
name = None
if sensor_names is not None:
name = sensor_names.get(int(elem["id"]), None)
if unit := elem.get("unit"):
unit_of_measurement = unit
else:
unit_of_measurement = config_unit
sensors.append( sensors.append(
EmonCmsSensor( EmonCmsSensor(
coordinator, coordinator,
entry.entry_id,
elem["unit"],
name, name,
value_template,
unit_of_measurement,
str(sensorid),
idx, idx,
) )
) )
@ -131,10 +175,9 @@ class EmonCmsSensor(CoordinatorEntity[EmoncmsCoordinator], SensorEntity):
def __init__( def __init__(
self, self,
coordinator: EmoncmsCoordinator, coordinator: EmoncmsCoordinator,
name: str | None, entry_id: str,
value_template: template.Template | None,
unit_of_measurement: str | None, unit_of_measurement: str | None,
sensorid: str, name: str,
idx: int, idx: int,
) -> None: ) -> None:
"""Initialize the sensor.""" """Initialize the sensor."""
@ -143,20 +186,9 @@ class EmonCmsSensor(CoordinatorEntity[EmoncmsCoordinator], SensorEntity):
elem = {} elem = {}
if self.coordinator.data: if self.coordinator.data:
elem = self.coordinator.data[self.idx] elem = self.coordinator.data[self.idx]
if name is None: self._attr_name = f"{name} {elem[FEED_NAME]}"
# Suppress ID in sensor name if it's 1, since most people won't
# have more than one EmonCMS source and it's redundant to show the
# ID if there's only one.
id_for_name = "" if str(sensorid) == "1" else sensorid
# Use the feed name assigned in EmonCMS or fall back to the feed ID
feed_name = elem.get("name", f"Feed {elem.get('id')}")
self._attr_name = f"EmonCMS{id_for_name} {feed_name}"
else:
self._attr_name = name
self._value_template = value_template
self._attr_native_unit_of_measurement = unit_of_measurement self._attr_native_unit_of_measurement = unit_of_measurement
self._sensorid = sensorid self._attr_unique_id = f"{entry_id}-{elem[FEED_ID]}"
if unit_of_measurement in ("kWh", "Wh"): if unit_of_measurement in ("kWh", "Wh"):
self._attr_device_class = SensorDeviceClass.ENERGY self._attr_device_class = SensorDeviceClass.ENERGY
self._attr_state_class = SensorStateClass.TOTAL_INCREASING self._attr_state_class = SensorStateClass.TOTAL_INCREASING
@ -186,9 +218,9 @@ class EmonCmsSensor(CoordinatorEntity[EmoncmsCoordinator], SensorEntity):
def _update_attributes(self, elem: dict[str, Any]) -> None: def _update_attributes(self, elem: dict[str, Any]) -> None:
"""Update entity attributes.""" """Update entity attributes."""
self._attr_extra_state_attributes = { self._attr_extra_state_attributes = {
ATTR_FEEDID: elem["id"], ATTR_FEEDID: elem[FEED_ID],
ATTR_TAG: elem["tag"], ATTR_TAG: elem[FEED_TAG],
ATTR_FEEDNAME: elem["name"], ATTR_FEEDNAME: elem[FEED_NAME],
} }
if elem["value"] is not None: if elem["value"] is not None:
self._attr_extra_state_attributes[ATTR_SIZE] = elem["size"] self._attr_extra_state_attributes[ATTR_SIZE] = elem["size"]
@ -199,13 +231,7 @@ class EmonCmsSensor(CoordinatorEntity[EmoncmsCoordinator], SensorEntity):
) )
self._attr_native_value = None self._attr_native_value = None
if self._value_template is not None: if elem["value"] is not None:
self._attr_native_value = (
self._value_template.async_render_with_possible_json_value(
elem["value"], STATE_UNKNOWN
)
)
elif elem["value"] is not None:
self._attr_native_value = round(float(elem["value"]), DECIMALS) self._attr_native_value = round(float(elem["value"]), DECIMALS)
@callback @callback

View file

@ -0,0 +1,40 @@
{
"config": {
"step": {
"user": {
"data": {
"url": "[%key:common::config_flow::data::url%]",
"api_key": "[%key:common::config_flow::data::api_key%]"
},
"data_description": {
"url": "Server url starting with the protocol (http or https)",
"api_key": "Your 32 bits api key"
}
},
"choose_feeds": {
"data": {
"include_only_feed_id": "Choose feeds to include"
}
}
}
},
"options": {
"step": {
"init": {
"data": {
"include_only_feed_id": "[%key:component::emoncms::config::step::choose_feeds::data::include_only_feed_id%]"
}
}
}
},
"issues": {
"remove_value_template": {
"title": "The {domain} integration cannot start",
"description": "Configuring {domain} using YAML is being removed and the `{parameter}` parameter cannot be imported.\n\nPlease remove `{parameter}` from your `{domain}` yaml configuration and restart Home Assistant\n\nAlternatively, you may entirely remove the `{domain}` configuration from your configuration.yaml, restart Home Assistant, and add the {domain} integration manually."
},
"missing_include_only_feed_id": {
"title": "No feed synchronized with the {domain} sensor",
"description": "Configuring {domain} using YAML is being removed.\n\nPlease add manually the feeds you want to synchronize with the `configure` button of the integration."
}
}
}

View file

@ -157,6 +157,7 @@ FLOWS = {
"elkm1", "elkm1",
"elmax", "elmax",
"elvia", "elvia",
"emoncms",
"emonitor", "emonitor",
"emulated_roku", "emulated_roku",
"energenie_power_sockets", "energenie_power_sockets",

View file

@ -1569,7 +1569,7 @@
"integrations": { "integrations": {
"emoncms": { "emoncms": {
"integration_type": "hub", "integration_type": "hub",
"config_flow": false, "config_flow": true,
"iot_class": "local_polling", "iot_class": "local_polling",
"name": "Emoncms" "name": "Emoncms"
}, },

View file

@ -1 +1,12 @@
"""Tests for the emoncms component.""" """Tests for the emoncms component."""
from homeassistant.core import HomeAssistant
from tests.common import MockConfigEntry
async def setup_integration(hass: HomeAssistant, entry: MockConfigEntry) -> None:
"""Set up the integration."""
entry.add_to_hass(hass)
assert await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()

View file

@ -1,10 +1,23 @@
"""Fixtures for emoncms integration tests.""" """Fixtures for emoncms integration tests."""
from collections.abc import AsyncGenerator from collections.abc import AsyncGenerator, Generator
import copy
from unittest.mock import AsyncMock, patch from unittest.mock import AsyncMock, patch
import pytest import pytest
from homeassistant.components.emoncms.const import CONF_ONLY_INCLUDE_FEEDID, DOMAIN
from homeassistant.const import (
CONF_API_KEY,
CONF_ID,
CONF_PLATFORM,
CONF_URL,
CONF_VALUE_TEMPLATE,
)
from homeassistant.helpers.typing import ConfigType
from tests.common import MockConfigEntry
UNITS = ["kWh", "Wh", "W", "V", "A", "VA", "°C", "°F", "K", "Hz", "hPa", ""] UNITS = ["kWh", "Wh", "W", "V", "A", "VA", "°C", "°F", "K", "Hz", "hPa", ""]
@ -29,16 +42,102 @@ FEEDS = [get_feed(i + 1, unit=unit) for i, unit in enumerate(UNITS)]
EMONCMS_FAILURE = {"success": False, "message": "failure"} EMONCMS_FAILURE = {"success": False, "message": "failure"}
FLOW_RESULT = {
CONF_API_KEY: "my_api_key",
CONF_ONLY_INCLUDE_FEEDID: [str(i + 1) for i in range(len(UNITS))],
CONF_URL: "http://1.1.1.1",
}
SENSOR_NAME = "emoncms@1.1.1.1"
YAML_BASE = {
CONF_PLATFORM: "emoncms",
CONF_API_KEY: "my_api_key",
CONF_ID: 1,
CONF_URL: "http://1.1.1.1",
}
YAML = {
**YAML_BASE,
CONF_ONLY_INCLUDE_FEEDID: [1],
}
@pytest.fixture
def emoncms_yaml_config() -> ConfigType:
"""Mock emoncms yaml configuration."""
return {"sensor": YAML}
@pytest.fixture
def emoncms_yaml_config_with_template() -> ConfigType:
"""Mock emoncms yaml conf with template parameter."""
return {"sensor": {**YAML, CONF_VALUE_TEMPLATE: "{{ value | float + 1500 }}"}}
@pytest.fixture
def emoncms_yaml_config_no_include_only_feed_id() -> ConfigType:
"""Mock emoncms yaml configuration without include_only_feed_id parameter."""
return {"sensor": YAML_BASE}
@pytest.fixture
def config_entry() -> MockConfigEntry:
"""Mock emoncms config entry."""
return MockConfigEntry(
domain=DOMAIN,
title=SENSOR_NAME,
data=FLOW_RESULT,
)
FLOW_RESULT_NO_FEED = copy.deepcopy(FLOW_RESULT)
FLOW_RESULT_NO_FEED[CONF_ONLY_INCLUDE_FEEDID] = None
@pytest.fixture
def config_no_feed() -> MockConfigEntry:
"""Mock emoncms config entry with no feed selected."""
return MockConfigEntry(
domain=DOMAIN,
title=SENSOR_NAME,
data=FLOW_RESULT_NO_FEED,
)
FLOW_RESULT_SINGLE_FEED = copy.deepcopy(FLOW_RESULT)
FLOW_RESULT_SINGLE_FEED[CONF_ONLY_INCLUDE_FEEDID] = ["1"]
@pytest.fixture
def config_single_feed() -> MockConfigEntry:
"""Mock emoncms config entry with a single feed exposed."""
return MockConfigEntry(
domain=DOMAIN,
title=SENSOR_NAME,
data=FLOW_RESULT_SINGLE_FEED,
entry_id="XXXXXXXX",
)
@pytest.fixture
def mock_setup_entry() -> Generator[AsyncMock]:
"""Override async_setup_entry."""
with patch(
"homeassistant.components.emoncms.async_setup_entry", return_value=True
) as mock_setup_entry:
yield mock_setup_entry
@pytest.fixture @pytest.fixture
async def emoncms_client() -> AsyncGenerator[AsyncMock]: async def emoncms_client() -> AsyncGenerator[AsyncMock]:
"""Mock pyemoncms success response.""" """Mock pyemoncms success response."""
with ( with (
patch( patch(
"homeassistant.components.emoncms.sensor.EmoncmsClient", autospec=True "homeassistant.components.emoncms.EmoncmsClient", autospec=True
) as mock_client, ) as mock_client,
patch( patch(
"homeassistant.components.emoncms.coordinator.EmoncmsClient", "homeassistant.components.emoncms.config_flow.EmoncmsClient",
new=mock_client, new=mock_client,
), ),
): ):

View file

@ -1,5 +1,40 @@
# serializer version: 1 # serializer version: 1
# name: test_coordinator_update[sensor.emoncms_parameter_1] # name: test_coordinator_update[sensor.emoncms_1_1_1_1_parameter_1-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
}),
'config_entry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.emoncms_1_1_1_1_parameter_1',
'has_entity_name': False,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': <SensorDeviceClass.TEMPERATURE: 'temperature'>,
'original_icon': None,
'original_name': 'emoncms@1.1.1.1 parameter 1',
'platform': 'emoncms',
'previous_unique_id': None,
'supported_features': 0,
'translation_key': None,
'unique_id': 'XXXXXXXX-1',
'unit_of_measurement': <UnitOfTemperature.CELSIUS: '°C'>,
})
# ---
# name: test_coordinator_update[sensor.emoncms_1_1_1_1_parameter_1-state]
StateSnapshot({ StateSnapshot({
'attributes': ReadOnlyDict({ 'attributes': ReadOnlyDict({
'FeedId': '1', 'FeedId': '1',
@ -10,12 +45,12 @@
'Tag': 'tag', 'Tag': 'tag',
'UserId': '1', 'UserId': '1',
'device_class': 'temperature', 'device_class': 'temperature',
'friendly_name': 'EmonCMS parameter 1', 'friendly_name': 'emoncms@1.1.1.1 parameter 1',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>, 'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': <UnitOfTemperature.CELSIUS: '°C'>, 'unit_of_measurement': <UnitOfTemperature.CELSIUS: '°C'>,
}), }),
'context': <ANY>, 'context': <ANY>,
'entity_id': 'sensor.emoncms_parameter_1', 'entity_id': 'sensor.emoncms_1_1_1_1_parameter_1',
'last_changed': <ANY>, 'last_changed': <ANY>,
'last_reported': <ANY>, 'last_reported': <ANY>,
'last_updated': <ANY>, 'last_updated': <ANY>,

View file

@ -0,0 +1,143 @@
"""Test emoncms config flow."""
from unittest.mock import AsyncMock
from homeassistant.components.emoncms.const import CONF_ONLY_INCLUDE_FEEDID, DOMAIN
from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_USER
from homeassistant.const import CONF_API_KEY, CONF_URL
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
from . import setup_integration
from .conftest import EMONCMS_FAILURE, FLOW_RESULT_SINGLE_FEED, SENSOR_NAME, YAML
from tests.common import MockConfigEntry
async def test_flow_import_include_feeds(
hass: HomeAssistant,
mock_setup_entry: AsyncMock,
emoncms_client: AsyncMock,
) -> None:
"""YAML import with included feed - success test."""
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_IMPORT},
data=YAML,
)
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == SENSOR_NAME
assert result["data"] == FLOW_RESULT_SINGLE_FEED
async def test_flow_import_failure(
hass: HomeAssistant,
emoncms_client: AsyncMock,
) -> None:
"""YAML import - failure test."""
emoncms_client.async_request.return_value = EMONCMS_FAILURE
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_IMPORT},
data=YAML,
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == EMONCMS_FAILURE["message"]
async def test_flow_import_already_configured(
hass: HomeAssistant,
config_entry: MockConfigEntry,
emoncms_client: AsyncMock,
) -> None:
"""Test we abort import data set when entry is already configured."""
config_entry.add_to_hass(hass)
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_IMPORT},
data=YAML,
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "already_configured"
USER_INPUT = {
CONF_URL: "http://1.1.1.1",
CONF_API_KEY: "my_api_key",
}
async def test_user_flow(
hass: HomeAssistant,
mock_setup_entry: AsyncMock,
emoncms_client: AsyncMock,
) -> None:
"""Test we get the user form."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
assert result["type"] is FlowResultType.FORM
assert result["errors"] == {}
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
USER_INPUT,
)
assert result["type"] is FlowResultType.FORM
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{CONF_ONLY_INCLUDE_FEEDID: ["1"]},
)
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == SENSOR_NAME
assert result["data"] == {**USER_INPUT, CONF_ONLY_INCLUDE_FEEDID: ["1"]}
assert len(mock_setup_entry.mock_calls) == 1
USER_OPTIONS = {
CONF_ONLY_INCLUDE_FEEDID: ["1"],
}
CONFIG_ENTRY = {
CONF_API_KEY: "my_api_key",
CONF_ONLY_INCLUDE_FEEDID: ["1"],
CONF_URL: "http://1.1.1.1",
}
async def test_options_flow(
hass: HomeAssistant,
mock_setup_entry: AsyncMock,
emoncms_client: AsyncMock,
config_entry: MockConfigEntry,
) -> None:
"""Options flow - success test."""
await setup_integration(hass, config_entry)
result = await hass.config_entries.options.async_init(config_entry.entry_id)
await hass.async_block_till_done()
result = await hass.config_entries.options.async_configure(
result["flow_id"],
user_input=USER_OPTIONS,
)
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["data"] == CONFIG_ENTRY
assert config_entry.options == CONFIG_ENTRY
async def test_options_flow_failure(
hass: HomeAssistant,
mock_setup_entry: AsyncMock,
emoncms_client: AsyncMock,
config_entry: MockConfigEntry,
) -> None:
"""Options flow - test failure."""
emoncms_client.async_request.return_value = EMONCMS_FAILURE
await setup_integration(hass, config_entry)
result = await hass.config_entries.options.async_init(config_entry.entry_id)
await hass.async_block_till_done()
assert result["errors"]["base"] == "failure"
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "init"

View file

@ -0,0 +1,40 @@
"""Test Emoncms component setup process."""
from __future__ import annotations
from unittest.mock import AsyncMock
from homeassistant.config_entries import ConfigEntryState
from homeassistant.core import HomeAssistant
from . import setup_integration
from .conftest import EMONCMS_FAILURE
from tests.common import MockConfigEntry
async def test_load_unload_entry(
hass: HomeAssistant,
config_entry: MockConfigEntry,
emoncms_client: AsyncMock,
) -> None:
"""Test load and unload entry."""
await setup_integration(hass, config_entry)
assert config_entry.state is ConfigEntryState.LOADED
await hass.config_entries.async_unload(config_entry.entry_id)
await hass.async_block_till_done()
assert config_entry.state is ConfigEntryState.NOT_LOADED
async def test_failure(
hass: HomeAssistant,
config_entry: MockConfigEntry,
emoncms_client: AsyncMock,
) -> None:
"""Test load failure."""
emoncms_client.async_request.return_value = EMONCMS_FAILURE
config_entry.add_to_hass(hass)
assert not await hass.config_entries.async_setup(config_entry.entry_id)

View file

@ -1,54 +1,112 @@
"""Test emoncms sensor.""" """Test emoncms sensor."""
from typing import Any
from unittest.mock import AsyncMock from unittest.mock import AsyncMock
from freezegun.api import FrozenDateTimeFactory from freezegun.api import FrozenDateTimeFactory
import pytest import pytest
from syrupy.assertion import SnapshotAssertion from syrupy.assertion import SnapshotAssertion
from homeassistant.components.emoncms.const import CONF_ONLY_INCLUDE_FEEDID, DOMAIN from homeassistant.components.emoncms.const import DOMAIN
from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN
from homeassistant.const import CONF_API_KEY, CONF_ID, CONF_PLATFORM, CONF_URL from homeassistant.config_entries import ConfigEntryState
from homeassistant.core import HomeAssistant from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant
from homeassistant.helpers import entity_registry as er, issue_registry as ir
from homeassistant.helpers.typing import ConfigType from homeassistant.helpers.typing import ConfigType
from homeassistant.setup import async_setup_component from homeassistant.setup import async_setup_component
from .conftest import EMONCMS_FAILURE, FEEDS, get_feed from . import setup_integration
from .conftest import EMONCMS_FAILURE, get_feed
from tests.common import async_fire_time_changed from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform
YAML = {
CONF_PLATFORM: "emoncms",
CONF_API_KEY: "my_api_key",
CONF_ID: 1,
CONF_URL: "http://1.1.1.1",
CONF_ONLY_INCLUDE_FEEDID: [1, 2],
"scan_interval": 30,
}
@pytest.fixture async def test_deprecated_yaml(
def emoncms_yaml_config() -> ConfigType: hass: HomeAssistant,
"""Mock emoncms configuration from yaml.""" issue_registry: ir.IssueRegistry,
return {"sensor": YAML} emoncms_yaml_config: ConfigType,
emoncms_client: AsyncMock,
) -> None:
"""Test an issue is created when we import from yaml config."""
await async_setup_component(hass, SENSOR_DOMAIN, emoncms_yaml_config)
await hass.async_block_till_done()
assert issue_registry.async_get_issue(
domain=HOMEASSISTANT_DOMAIN, issue_id=f"deprecated_yaml_{DOMAIN}"
)
def get_entity_ids(feeds: list[dict[str, Any]]) -> list[str]: async def test_yaml_with_template(
"""Get emoncms entity ids.""" hass: HomeAssistant,
return [ issue_registry: ir.IssueRegistry,
f"{SENSOR_DOMAIN}.{DOMAIN}_{feed["name"].replace(' ', '_')}" for feed in feeds emoncms_yaml_config_with_template: ConfigType,
] emoncms_client: AsyncMock,
) -> None:
"""Test an issue is created when we import a yaml config with a value_template parameter."""
await async_setup_component(hass, SENSOR_DOMAIN, emoncms_yaml_config_with_template)
await hass.async_block_till_done()
assert issue_registry.async_get_issue(
domain=DOMAIN, issue_id=f"remove_value_template_{DOMAIN}"
)
def get_feeds(nbs: list[int]) -> list[dict[str, Any]]: async def test_yaml_no_include_only_feed_id(
"""Get feeds.""" hass: HomeAssistant,
return [feed for feed in FEEDS if feed["id"] in str(nbs)] issue_registry: ir.IssueRegistry,
emoncms_yaml_config_no_include_only_feed_id: ConfigType,
emoncms_client: AsyncMock,
) -> None:
"""Test an issue is created when we import a yaml config without a include_only_feed_id parameter."""
await async_setup_component(
hass, SENSOR_DOMAIN, emoncms_yaml_config_no_include_only_feed_id
)
await hass.async_block_till_done()
assert issue_registry.async_get_issue(
domain=DOMAIN, issue_id=f"missing_include_only_feed_id_{DOMAIN}"
)
async def test_no_feed_selected(
hass: HomeAssistant,
config_no_feed: MockConfigEntry,
entity_registry: er.EntityRegistry,
emoncms_client: AsyncMock,
) -> None:
"""Test with no feed selected."""
await setup_integration(hass, config_no_feed)
assert config_no_feed.state is ConfigEntryState.LOADED
entity_entries = er.async_entries_for_config_entry(
entity_registry, config_no_feed.entry_id
)
assert entity_entries == []
async def test_no_feed_broadcast(
hass: HomeAssistant,
config_entry: MockConfigEntry,
entity_registry: er.EntityRegistry,
emoncms_client: AsyncMock,
) -> None:
"""Test with no feed broadcasted."""
emoncms_client.async_request.return_value = {"success": True, "message": []}
await setup_integration(hass, config_entry)
assert config_entry.state is ConfigEntryState.LOADED
entity_entries = er.async_entries_for_config_entry(
entity_registry, config_entry.entry_id
)
assert entity_entries == []
async def test_coordinator_update( async def test_coordinator_update(
hass: HomeAssistant, hass: HomeAssistant,
emoncms_yaml_config: ConfigType, config_single_feed: MockConfigEntry,
entity_registry: er.EntityRegistry,
snapshot: SnapshotAssertion, snapshot: SnapshotAssertion,
emoncms_client: AsyncMock, emoncms_client: AsyncMock,
caplog: pytest.LogCaptureFixture, caplog: pytest.LogCaptureFixture,
@ -59,12 +117,11 @@ async def test_coordinator_update(
"success": True, "success": True,
"message": [get_feed(1, unit="°C")], "message": [get_feed(1, unit="°C")],
} }
await async_setup_component(hass, SENSOR_DOMAIN, emoncms_yaml_config) await setup_integration(hass, config_single_feed)
await hass.async_block_till_done()
feeds = get_feeds([1]) await snapshot_platform(
for entity_id in get_entity_ids(feeds): hass, entity_registry, snapshot, config_single_feed.entry_id
state = hass.states.get(entity_id) )
assert state == snapshot(name=entity_id)
async def skip_time() -> None: async def skip_time() -> None:
freezer.tick(60) freezer.tick(60)
@ -78,8 +135,12 @@ async def test_coordinator_update(
await skip_time() await skip_time()
for entity_id in get_entity_ids(feeds): entity_entries = er.async_entries_for_config_entry(
state = hass.states.get(entity_id) entity_registry, config_single_feed.entry_id
)
for entity_entry in entity_entries:
state = hass.states.get(entity_entry.entity_id)
assert state.attributes["LastUpdated"] == 1665509670 assert state.attributes["LastUpdated"] == 1665509670
assert state.state == "24.04" assert state.state == "24.04"