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."""
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_SUCCESS = "success"
DOMAIN = "emoncms"
FEED_ID = "id"
FEED_NAME = "name"
FEED_TAG = "tag"
LOGGER = logging.getLogger(__package__)

View file

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

View file

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

View file

@ -2,10 +2,8 @@
from __future__ import annotations
from datetime import timedelta
from typing import Any
from pyemoncms import EmoncmsClient
import voluptuous as vol
from homeassistant.components.sensor import (
@ -14,25 +12,33 @@ from homeassistant.components.sensor import (
SensorEntity,
SensorStateClass,
)
from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry
from homeassistant.const import (
CONF_API_KEY,
CONF_ID,
CONF_SCAN_INTERVAL,
CONF_UNIT_OF_MEASUREMENT,
CONF_URL,
CONF_VALUE_TEMPLATE,
STATE_UNKNOWN,
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.aiohttp_client import async_get_clientsession
import homeassistant.helpers.config_validation as cv
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.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
ATTR_FEEDID = "FeedId"
@ -42,9 +48,7 @@ ATTR_LASTUPDATETIMESTR = "LastUpdatedStr"
ATTR_SIZE = "Size"
ATTR_TAG = "Tag"
ATTR_USERID = "UserId"
CONF_SENSOR_NAMES = "sensor_names"
DECIMALS = 2
DEFAULT_UNIT = UnitOfPower.WATT
@ -76,20 +80,73 @@ async def async_setup_platform(
async_add_entities: AddEntitiesCallback,
discovery_info: DiscoveryInfoType | None = None,
) -> None:
"""Set up the Emoncms sensor."""
apikey = config[CONF_API_KEY]
url = config[CONF_URL]
sensorid = config[CONF_ID]
value_template = config.get(CONF_VALUE_TEMPLATE)
config_unit = config.get(CONF_UNIT_OF_MEASUREMENT)
"""Import config from yaml."""
if CONF_VALUE_TEMPLATE in config:
async_create_issue(
hass,
DOMAIN,
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)
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))
coordinator = EmoncmsCoordinator(hass, emoncms_client, scan_interval)
await coordinator.async_refresh()
if exclude_feeds is None and include_only_feeds is None:
return
coordinator = entry.runtime_data
elems = coordinator.data
if not elems:
return
@ -97,28 +154,15 @@ async def async_setup_platform(
sensors: list[EmonCmsSensor] = []
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
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(
EmonCmsSensor(
coordinator,
entry.entry_id,
elem["unit"],
name,
value_template,
unit_of_measurement,
str(sensorid),
idx,
)
)
@ -131,10 +175,9 @@ class EmonCmsSensor(CoordinatorEntity[EmoncmsCoordinator], SensorEntity):
def __init__(
self,
coordinator: EmoncmsCoordinator,
name: str | None,
value_template: template.Template | None,
entry_id: str,
unit_of_measurement: str | None,
sensorid: str,
name: str,
idx: int,
) -> None:
"""Initialize the sensor."""
@ -143,20 +186,9 @@ class EmonCmsSensor(CoordinatorEntity[EmoncmsCoordinator], SensorEntity):
elem = {}
if self.coordinator.data:
elem = self.coordinator.data[self.idx]
if name is None:
# 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_name = f"{name} {elem[FEED_NAME]}"
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"):
self._attr_device_class = SensorDeviceClass.ENERGY
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:
"""Update entity attributes."""
self._attr_extra_state_attributes = {
ATTR_FEEDID: elem["id"],
ATTR_TAG: elem["tag"],
ATTR_FEEDNAME: elem["name"],
ATTR_FEEDID: elem[FEED_ID],
ATTR_TAG: elem[FEED_TAG],
ATTR_FEEDNAME: elem[FEED_NAME],
}
if elem["value"] is not None:
self._attr_extra_state_attributes[ATTR_SIZE] = elem["size"]
@ -199,13 +231,7 @@ class EmonCmsSensor(CoordinatorEntity[EmoncmsCoordinator], SensorEntity):
)
self._attr_native_value = None
if self._value_template 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:
if elem["value"] is not None:
self._attr_native_value = round(float(elem["value"]), DECIMALS)
@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",
"elmax",
"elvia",
"emoncms",
"emonitor",
"emulated_roku",
"energenie_power_sockets",

View file

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

View file

@ -1 +1,12 @@
"""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."""
from collections.abc import AsyncGenerator
from collections.abc import AsyncGenerator, Generator
import copy
from unittest.mock import AsyncMock, patch
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", ""]
@ -29,16 +42,102 @@ FEEDS = [get_feed(i + 1, unit=unit) for i, unit in enumerate(UNITS)]
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
async def emoncms_client() -> AsyncGenerator[AsyncMock]:
"""Mock pyemoncms success response."""
with (
patch(
"homeassistant.components.emoncms.sensor.EmoncmsClient", autospec=True
"homeassistant.components.emoncms.EmoncmsClient", autospec=True
) as mock_client,
patch(
"homeassistant.components.emoncms.coordinator.EmoncmsClient",
"homeassistant.components.emoncms.config_flow.EmoncmsClient",
new=mock_client,
),
):

View file

@ -1,5 +1,40 @@
# 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({
'attributes': ReadOnlyDict({
'FeedId': '1',
@ -10,12 +45,12 @@
'Tag': 'tag',
'UserId': '1',
'device_class': 'temperature',
'friendly_name': 'EmonCMS parameter 1',
'friendly_name': 'emoncms@1.1.1.1 parameter 1',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': <UnitOfTemperature.CELSIUS: '°C'>,
}),
'context': <ANY>,
'entity_id': 'sensor.emoncms_parameter_1',
'entity_id': 'sensor.emoncms_1_1_1_1_parameter_1',
'last_changed': <ANY>,
'last_reported': <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."""
from typing import Any
from unittest.mock import AsyncMock
from freezegun.api import FrozenDateTimeFactory
import pytest
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.const import CONF_API_KEY, CONF_ID, CONF_PLATFORM, CONF_URL
from homeassistant.core import HomeAssistant
from homeassistant.config_entries import ConfigEntryState
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.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
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,
}
from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform
@pytest.fixture
def emoncms_yaml_config() -> ConfigType:
"""Mock emoncms configuration from yaml."""
return {"sensor": YAML}
async def test_deprecated_yaml(
hass: HomeAssistant,
issue_registry: ir.IssueRegistry,
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]:
"""Get emoncms entity ids."""
return [
f"{SENSOR_DOMAIN}.{DOMAIN}_{feed["name"].replace(' ', '_')}" for feed in feeds
]
async def test_yaml_with_template(
hass: HomeAssistant,
issue_registry: ir.IssueRegistry,
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]]:
"""Get feeds."""
return [feed for feed in FEEDS if feed["id"] in str(nbs)]
async def test_yaml_no_include_only_feed_id(
hass: HomeAssistant,
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(
hass: HomeAssistant,
emoncms_yaml_config: ConfigType,
config_single_feed: MockConfigEntry,
entity_registry: er.EntityRegistry,
snapshot: SnapshotAssertion,
emoncms_client: AsyncMock,
caplog: pytest.LogCaptureFixture,
@ -59,12 +117,11 @@ async def test_coordinator_update(
"success": True,
"message": [get_feed(1, unit="°C")],
}
await async_setup_component(hass, SENSOR_DOMAIN, emoncms_yaml_config)
await hass.async_block_till_done()
feeds = get_feeds([1])
for entity_id in get_entity_ids(feeds):
state = hass.states.get(entity_id)
assert state == snapshot(name=entity_id)
await setup_integration(hass, config_single_feed)
await snapshot_platform(
hass, entity_registry, snapshot, config_single_feed.entry_id
)
async def skip_time() -> None:
freezer.tick(60)
@ -78,8 +135,12 @@ async def test_coordinator_update(
await skip_time()
for entity_id in get_entity_ids(feeds):
state = hass.states.get(entity_id)
entity_entries = er.async_entries_for_config_entry(
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.state == "24.04"