From 8255728f530df6345b8e043ec291d538a5259553 Mon Sep 17 00:00:00 2001 From: Alexandre CUER Date: Tue, 3 Sep 2024 17:21:13 +0200 Subject: [PATCH] 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 * Update homeassistant/components/emoncms/sensor.py Co-authored-by: Joost Lekkerkerker * Update homeassistant/components/emoncms/strings.json Co-authored-by: Joost Lekkerkerker * 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 * Update tests/components/emoncms/test_config_flow.py Co-authored-by: Joost Lekkerkerker * 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 --- homeassistant/components/emoncms/__init__.py | 39 ++++ .../components/emoncms/config_flow.py | 210 ++++++++++++++++++ homeassistant/components/emoncms/const.py | 3 + .../components/emoncms/coordinator.py | 3 +- .../components/emoncms/manifest.json | 1 + homeassistant/components/emoncms/sensor.py | 150 +++++++------ homeassistant/components/emoncms/strings.json | 40 ++++ homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 2 +- tests/components/emoncms/__init__.py | 11 + tests/components/emoncms/conftest.py | 105 ++++++++- .../emoncms/snapshots/test_sensor.ambr | 41 +++- tests/components/emoncms/test_config_flow.py | 143 ++++++++++++ tests/components/emoncms/test_init.py | 40 ++++ tests/components/emoncms/test_sensor.py | 133 ++++++++--- 15 files changed, 815 insertions(+), 107 deletions(-) create mode 100644 homeassistant/components/emoncms/config_flow.py create mode 100644 homeassistant/components/emoncms/strings.json create mode 100644 tests/components/emoncms/test_config_flow.py create mode 100644 tests/components/emoncms/test_init.py diff --git a/homeassistant/components/emoncms/__init__.py b/homeassistant/components/emoncms/__init__.py index 5e7adbcd6e7..98ed6328578 100644 --- a/homeassistant/components/emoncms/__init__.py +++ b/homeassistant/components/emoncms/__init__.py @@ -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) diff --git a/homeassistant/components/emoncms/config_flow.py b/homeassistant/components/emoncms/config_flow.py new file mode 100644 index 00000000000..fdd5d29788e --- /dev/null +++ b/homeassistant/components/emoncms/config_flow.py @@ -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, + ) diff --git a/homeassistant/components/emoncms/const.py b/homeassistant/components/emoncms/const.py index 96269218316..256db5726bb 100644 --- a/homeassistant/components/emoncms/const.py +++ b/homeassistant/components/emoncms/const.py @@ -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__) diff --git a/homeassistant/components/emoncms/coordinator.py b/homeassistant/components/emoncms/coordinator.py index d1f6a2858c7..c6fda5ed7c8 100644 --- a/homeassistant/components/emoncms/coordinator.py +++ b/homeassistant/components/emoncms/coordinator.py @@ -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 diff --git a/homeassistant/components/emoncms/manifest.json b/homeassistant/components/emoncms/manifest.json index 09229d0419a..f8f0f2edb95 100644 --- a/homeassistant/components/emoncms/manifest.json +++ b/homeassistant/components/emoncms/manifest.json @@ -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"] diff --git a/homeassistant/components/emoncms/sensor.py b/homeassistant/components/emoncms/sensor.py index 3c448391974..4add7c9625d 100644 --- a/homeassistant/components/emoncms/sensor.py +++ b/homeassistant/components/emoncms/sensor.py @@ -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 diff --git a/homeassistant/components/emoncms/strings.json b/homeassistant/components/emoncms/strings.json new file mode 100644 index 00000000000..4a700cc8981 --- /dev/null +++ b/homeassistant/components/emoncms/strings.json @@ -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." + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 5f46cb1013e..e78df5ab045 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -157,6 +157,7 @@ FLOWS = { "elkm1", "elmax", "elvia", + "emoncms", "emonitor", "emulated_roku", "energenie_power_sockets", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index e379851b37f..879012ae54b 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -1569,7 +1569,7 @@ "integrations": { "emoncms": { "integration_type": "hub", - "config_flow": false, + "config_flow": true, "iot_class": "local_polling", "name": "Emoncms" }, diff --git a/tests/components/emoncms/__init__.py b/tests/components/emoncms/__init__.py index ecf3c54e9ed..59dc4fa08e1 100644 --- a/tests/components/emoncms/__init__.py +++ b/tests/components/emoncms/__init__.py @@ -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() diff --git a/tests/components/emoncms/conftest.py b/tests/components/emoncms/conftest.py index 500fff228e9..29e86f3c59d 100644 --- a/tests/components/emoncms/conftest.py +++ b/tests/components/emoncms/conftest.py @@ -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, ), ): diff --git a/tests/components/emoncms/snapshots/test_sensor.ambr b/tests/components/emoncms/snapshots/test_sensor.ambr index 62c85aaba01..5e718c1d8e8 100644 --- a/tests/components/emoncms/snapshots/test_sensor.ambr +++ b/tests/components/emoncms/snapshots/test_sensor.ambr @@ -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': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + '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': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + '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': , + }) +# --- +# 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': , 'unit_of_measurement': , }), 'context': , - 'entity_id': 'sensor.emoncms_parameter_1', + 'entity_id': 'sensor.emoncms_1_1_1_1_parameter_1', 'last_changed': , 'last_reported': , 'last_updated': , diff --git a/tests/components/emoncms/test_config_flow.py b/tests/components/emoncms/test_config_flow.py new file mode 100644 index 00000000000..17ec32a9008 --- /dev/null +++ b/tests/components/emoncms/test_config_flow.py @@ -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" diff --git a/tests/components/emoncms/test_init.py b/tests/components/emoncms/test_init.py new file mode 100644 index 00000000000..b89b6e65a66 --- /dev/null +++ b/tests/components/emoncms/test_init.py @@ -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) diff --git a/tests/components/emoncms/test_sensor.py b/tests/components/emoncms/test_sensor.py index a039239077e..a7bc8059287 100644 --- a/tests/components/emoncms/test_sensor.py +++ b/tests/components/emoncms/test_sensor.py @@ -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"