diff --git a/.coveragerc b/.coveragerc index 982db1eeade..2f93792a3f6 100644 --- a/.coveragerc +++ b/.coveragerc @@ -899,6 +899,7 @@ omit = homeassistant/components/slack/notify.py homeassistant/components/sinch/* homeassistant/components/slide/* + homeassistant/components/sma/__init__.py homeassistant/components/sma/sensor.py homeassistant/components/smappee/__init__.py homeassistant/components/smappee/api.py diff --git a/CODEOWNERS b/CODEOWNERS index 862df7b687a..a2ab0082cac 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -428,7 +428,7 @@ homeassistant/components/sisyphus/* @jkeljo homeassistant/components/sky_hub/* @rogerselwyn homeassistant/components/slack/* @bachya homeassistant/components/slide/* @ualex73 -homeassistant/components/sma/* @kellerza +homeassistant/components/sma/* @kellerza @rklomp homeassistant/components/smappee/* @bsmappee homeassistant/components/smart_meter_texas/* @grahamwetzler homeassistant/components/smarthab/* @outadoc diff --git a/homeassistant/components/sma/__init__.py b/homeassistant/components/sma/__init__.py index 97d7147596c..5a4123ec10b 100644 --- a/homeassistant/components/sma/__init__.py +++ b/homeassistant/components/sma/__init__.py @@ -1 +1,199 @@ -"""The sma component.""" +"""The sma integration.""" +import asyncio +from datetime import timedelta +import logging + +import pysma + +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry, ConfigEntryNotReady +from homeassistant.const import ( + CONF_HOST, + CONF_PASSWORD, + CONF_PATH, + CONF_SCAN_INTERVAL, + CONF_SENSORS, + CONF_SSL, + CONF_VERIFY_SSL, + EVENT_HOMEASSISTANT_STOP, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import ( + CONF_CUSTOM, + CONF_FACTOR, + CONF_GROUP, + CONF_KEY, + CONF_UNIT, + DEFAULT_SCAN_INTERVAL, + DOMAIN, + PLATFORMS, + PYSMA_COORDINATOR, + PYSMA_OBJECT, + PYSMA_REMOVE_LISTENER, + PYSMA_SENSORS, +) + +_LOGGER = logging.getLogger(__name__) + + +async def _parse_legacy_options(entry: ConfigEntry, sensor_def: pysma.Sensors) -> None: + """Parse legacy configuration options. + + This will parse the legacy CONF_SENSORS and CONF_CUSTOM configuration options + to support deprecated yaml config from platform setup. + """ + + # Add sensors from the custom config + sensor_def.add( + [ + pysma.Sensor(o[CONF_KEY], n, o[CONF_UNIT], o[CONF_FACTOR], o.get(CONF_PATH)) + for n, o in entry.data.get(CONF_CUSTOM).items() + ] + ) + + # Parsing of sensors configuration + config_sensors = entry.data.get(CONF_SENSORS) + if not config_sensors: + return + + # Find and replace sensors removed from pysma + # This only alters the config, the actual sensor migration takes place in _migrate_old_unique_ids + for sensor in config_sensors.copy(): + if sensor in pysma.LEGACY_MAP: + config_sensors.remove(sensor) + config_sensors.append(pysma.LEGACY_MAP[sensor]["new_sensor"]) + + # Only sensors from config should be enabled + for sensor in sensor_def: + sensor.enabled = sensor.name in config_sensors + + +async def _migrate_old_unique_ids( + hass: HomeAssistant, entry: ConfigEntry, sensor_def: pysma.Sensors +) -> None: + """Migrate legacy sensor entity_id format to new format.""" + entity_registry = er.async_get(hass) + + # Create list of all possible sensor names + possible_sensors = list( + set( + entry.data.get(CONF_SENSORS) + + [s.name for s in sensor_def] + + list(pysma.LEGACY_MAP) + ) + ) + + for sensor in possible_sensors: + if sensor in sensor_def: + pysma_sensor = sensor_def[sensor] + original_key = pysma_sensor.key + elif sensor in pysma.LEGACY_MAP: + # If sensor was removed from pysma we will remap it to the new sensor + legacy_sensor = pysma.LEGACY_MAP[sensor] + pysma_sensor = sensor_def[legacy_sensor["new_sensor"]] + original_key = legacy_sensor["old_key"] + else: + _LOGGER.error("%s does not exist", sensor) + continue + + # Find entity_id using previous format of unique ID + entity_id = entity_registry.async_get_entity_id( + "sensor", "sma", f"sma-{original_key}-{sensor}" + ) + + if not entity_id: + continue + + # Change entity_id to new format using the device serial in entry.unique_id + new_unique_id = f"{entry.unique_id}-{pysma_sensor.key}_{pysma_sensor.key_idx}" + entity_registry.async_update_entity(entity_id, new_unique_id=new_unique_id) + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up sma from a config entry.""" + # Init all default sensors + sensor_def = pysma.Sensors() + + if entry.source == SOURCE_IMPORT: + await _parse_legacy_options(entry, sensor_def) + await _migrate_old_unique_ids(hass, entry, sensor_def) + + # Init the SMA interface + protocol = "https" if entry.data.get(CONF_SSL) else "http" + url = f"{protocol}://{entry.data.get(CONF_HOST)}" + verify_ssl = entry.data.get(CONF_VERIFY_SSL) + group = entry.data.get(CONF_GROUP) + password = entry.data.get(CONF_PASSWORD) + + session = async_get_clientsession(hass, verify_ssl=verify_ssl) + sma = pysma.SMA(session, url, password, group) + + # Define the coordinator + async def async_update_data(): + """Update the used SMA sensors.""" + values = await sma.read(sensor_def) + if not values: + raise UpdateFailed + + interval = timedelta( + seconds=entry.options.get(CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL) + ) + + coordinator = DataUpdateCoordinator( + hass, + _LOGGER, + name="sma", + update_method=async_update_data, + update_interval=interval, + ) + + try: + await coordinator.async_config_entry_first_refresh() + except ConfigEntryNotReady: + await sma.close_session() + raise + + # Ensure we logout on shutdown + async def async_close_session(event): + """Close the session.""" + await sma.close_session() + + remove_stop_listener = hass.bus.async_listen_once( + EVENT_HOMEASSISTANT_STOP, async_close_session + ) + + hass.data.setdefault(DOMAIN, {}) + hass.data[DOMAIN][entry.entry_id] = { + PYSMA_OBJECT: sma, + PYSMA_COORDINATOR: coordinator, + PYSMA_SENSORS: sensor_def, + PYSMA_REMOVE_LISTENER: remove_stop_listener, + } + + for component in PLATFORMS: + hass.async_create_task( + hass.config_entries.async_forward_entry_setup(entry, component) + ) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + unload_ok = all( + await asyncio.gather( + *[ + hass.config_entries.async_forward_entry_unload(entry, component) + for component in PLATFORMS + ] + ) + ) + if unload_ok: + data = hass.data[DOMAIN].pop(entry.entry_id) + await data[PYSMA_OBJECT].close_session() + data[PYSMA_REMOVE_LISTENER]() + + return unload_ok diff --git a/homeassistant/components/sma/config_flow.py b/homeassistant/components/sma/config_flow.py new file mode 100644 index 00000000000..08c1aed2e7b --- /dev/null +++ b/homeassistant/components/sma/config_flow.py @@ -0,0 +1,141 @@ +"""Config flow for the sma integration.""" +from __future__ import annotations + +import logging +from typing import Any + +import aiohttp +import pysma +import voluptuous as vol + +from homeassistant import config_entries, core, exceptions +from homeassistant.const import ( + CONF_HOST, + CONF_PASSWORD, + CONF_SENSORS, + CONF_SSL, + CONF_VERIFY_SSL, +) +from homeassistant.helpers.aiohttp_client import async_get_clientsession +import homeassistant.helpers.config_validation as cv + +from .const import CONF_CUSTOM, CONF_GROUP, DEVICE_INFO, GROUPS +from .const import DOMAIN # pylint: disable=unused-import + +_LOGGER = logging.getLogger(__name__) + + +async def validate_input( + hass: core.HomeAssistant, data: dict[str, Any] +) -> dict[str, Any]: + """Validate the user input allows us to connect.""" + session = async_get_clientsession(hass, verify_ssl=data[CONF_VERIFY_SSL]) + + protocol = "https" if data[CONF_SSL] else "http" + url = f"{protocol}://{data[CONF_HOST]}" + + sma = pysma.SMA(session, url, data[CONF_PASSWORD], group=data[CONF_GROUP]) + + if await sma.new_session() is False: + raise InvalidAuth + + device_info = await sma.device_info() + await sma.close_session() + + if not device_info: + raise CannotRetrieveDeviceInfo + + return device_info + + +class SmaConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for SMA.""" + + VERSION = 1 + CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_POLL + + def __init__(self) -> None: + """Initialize.""" + self._data = { + CONF_HOST: vol.UNDEFINED, + CONF_SSL: False, + CONF_VERIFY_SSL: True, + CONF_GROUP: GROUPS[0], + CONF_PASSWORD: vol.UNDEFINED, + CONF_SENSORS: [], + CONF_CUSTOM: {}, + DEVICE_INFO: {}, + } + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> dict[str, Any]: + """First step in config flow.""" + errors = {} + if user_input is not None: + self._data[CONF_HOST] = user_input[CONF_HOST] + self._data[CONF_SSL] = user_input[CONF_SSL] + self._data[CONF_VERIFY_SSL] = user_input[CONF_VERIFY_SSL] + self._data[CONF_GROUP] = user_input[CONF_GROUP] + self._data[CONF_PASSWORD] = user_input[CONF_PASSWORD] + + try: + self._data[DEVICE_INFO] = await validate_input(self.hass, user_input) + except aiohttp.ClientError: + errors["base"] = "cannot_connect" + except InvalidAuth: + errors["base"] = "invalid_auth" + except CannotRetrieveDeviceInfo: + errors["base"] = "cannot_retrieve_device_info" + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + + if not errors: + await self.async_set_unique_id(self._data[DEVICE_INFO]["serial"]) + self._abort_if_unique_id_configured() + return self.async_create_entry( + title=self._data[CONF_HOST], data=self._data + ) + + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + { + vol.Required(CONF_HOST, default=self._data[CONF_HOST]): cv.string, + vol.Optional(CONF_SSL, default=self._data[CONF_SSL]): cv.boolean, + vol.Optional( + CONF_VERIFY_SSL, default=self._data[CONF_VERIFY_SSL] + ): cv.boolean, + vol.Optional(CONF_GROUP, default=self._data[CONF_GROUP]): vol.In( + GROUPS + ), + vol.Required(CONF_PASSWORD): cv.string, + } + ), + errors=errors, + ) + + async def async_step_import( + self, import_config: dict[str, Any] | None + ) -> dict[str, Any]: + """Import a config flow from configuration.""" + device_info = await validate_input(self.hass, import_config) + import_config[DEVICE_INFO] = device_info + + # If unique is configured import was already run + # This means remap was already done, so we can abort + await self.async_set_unique_id(device_info["serial"]) + self._abort_if_unique_id_configured(import_config) + + return self.async_create_entry( + title=import_config[CONF_HOST], data=import_config + ) + + +class InvalidAuth(exceptions.HomeAssistantError): + """Error to indicate there is invalid auth.""" + + +class CannotRetrieveDeviceInfo(exceptions.HomeAssistantError): + """Error to indicate we cannot retrieve the device information.""" diff --git a/homeassistant/components/sma/const.py b/homeassistant/components/sma/const.py new file mode 100644 index 00000000000..2e1086e48a2 --- /dev/null +++ b/homeassistant/components/sma/const.py @@ -0,0 +1,21 @@ +"""Constants for the sma integration.""" + +DOMAIN = "sma" + +PYSMA_COORDINATOR = "coordinator" +PYSMA_OBJECT = "pysma" +PYSMA_REMOVE_LISTENER = "remove_listener" +PYSMA_SENSORS = "pysma_sensors" + +PLATFORMS = ["sensor"] + +CONF_CUSTOM = "custom" +CONF_FACTOR = "factor" +CONF_GROUP = "group" +CONF_KEY = "key" +CONF_UNIT = "unit" +DEVICE_INFO = "device_info" + +DEFAULT_SCAN_INTERVAL = 5 + +GROUPS = ["user", "installer"] diff --git a/homeassistant/components/sma/manifest.json b/homeassistant/components/sma/manifest.json index 9cadec377a2..f38038d8eb1 100644 --- a/homeassistant/components/sma/manifest.json +++ b/homeassistant/components/sma/manifest.json @@ -1,7 +1,8 @@ { "domain": "sma", "name": "SMA Solar", + "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/sma", - "requirements": ["pysma==0.3.5"], - "codeowners": ["@kellerza"] + "requirements": ["pysma==0.4.3"], + "codeowners": ["@kellerza", "@rklomp"] } diff --git a/homeassistant/components/sma/sensor.py b/homeassistant/components/sma/sensor.py index 2290f3a330f..ea5b5666408 100644 --- a/homeassistant/components/sma/sensor.py +++ b/homeassistant/components/sma/sensor.py @@ -1,41 +1,51 @@ """SMA Solar Webconnect interface.""" -from datetime import timedelta +from __future__ import annotations + import logging +from typing import Any, Callable, Coroutine import pysma import voluptuous as vol from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import ( CONF_HOST, CONF_PASSWORD, CONF_PATH, - CONF_SCAN_INTERVAL, CONF_SENSORS, CONF_SSL, CONF_VERIFY_SSL, - EVENT_HOMEASSISTANT_STOP, ) -from homeassistant.core import callback -from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.event import async_track_time_interval +from homeassistant.helpers.typing import StateType +from homeassistant.helpers.update_coordinator import ( + CoordinatorEntity, + DataUpdateCoordinator, +) + +from .const import ( + CONF_CUSTOM, + CONF_FACTOR, + CONF_GROUP, + CONF_KEY, + CONF_UNIT, + DEVICE_INFO, + DOMAIN, + GROUPS, + PYSMA_COORDINATOR, + PYSMA_SENSORS, +) _LOGGER = logging.getLogger(__name__) -CONF_CUSTOM = "custom" -CONF_FACTOR = "factor" -CONF_GROUP = "group" -CONF_KEY = "key" -CONF_UNIT = "unit" -GROUPS = ["user", "installer"] - - -def _check_sensor_schema(conf): +def _check_sensor_schema(conf: dict[str, Any]) -> dict[str, Any]: """Check sensors and attributes are valid.""" try: valid = [s.name for s in pysma.Sensors()] + valid += pysma.LEGACY_MAP.keys() except (ImportError, AttributeError): return conf @@ -83,146 +93,114 @@ PLATFORM_SCHEMA = vol.All( ) -async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): - """Set up SMA WebConnect sensor.""" - # Check config again during load - dependency available - config = _check_sensor_schema(config) - - # Init all default sensors - sensor_def = pysma.Sensors() - - # Sensor from the custom config - sensor_def.add( - [ - pysma.Sensor(o[CONF_KEY], n, o[CONF_UNIT], o[CONF_FACTOR], o.get(CONF_PATH)) - for n, o in config[CONF_CUSTOM].items() - ] +async def async_setup_platform( + hass: HomeAssistant, + config: ConfigEntry, + async_add_entities: Callable[[], Coroutine], + discovery_info=None, +) -> None: + """Import the platform into a config entry.""" + _LOGGER.warning( + "Loading SMA via platform setup is deprecated. " + "Please remove it from your configuration" ) - # Use all sensors by default - config_sensors = config[CONF_SENSORS] - hass_sensors = [] - used_sensors = [] - - if isinstance(config_sensors, dict): # will be remove from 0.99 - if not config_sensors: # Use all sensors by default - config_sensors = {s.name: [] for s in sensor_def} - - # Prepare all Home Assistant sensor entities - for name, attr in config_sensors.items(): - sub_sensors = [sensor_def[s] for s in attr] - hass_sensors.append(SMAsensor(sensor_def[name], sub_sensors)) - used_sensors.append(name) - used_sensors.extend(attr) - - if isinstance(config_sensors, list): - if not config_sensors: # Use all sensors by default - config_sensors = [s.name for s in sensor_def] - used_sensors = list(set(config_sensors + list(config[CONF_CUSTOM]))) - for sensor in used_sensors: - hass_sensors.append(SMAsensor(sensor_def[sensor], [])) - - used_sensors = [sensor_def[s] for s in set(used_sensors)] - async_add_entities(hass_sensors) - - # Init the SMA interface - session = async_get_clientsession(hass, verify_ssl=config[CONF_VERIFY_SSL]) - grp = config[CONF_GROUP] - - protocol = "https" if config[CONF_SSL] else "http" - url = f"{protocol}://{config[CONF_HOST]}" - - sma = pysma.SMA(session, url, config[CONF_PASSWORD], group=grp) - - # Ensure we logout on shutdown - async def async_close_session(event): - """Close the session.""" - await sma.close_session() - - hass.bus.async_listen(EVENT_HOMEASSISTANT_STOP, async_close_session) - - backoff = 0 - backoff_step = 0 - - async def async_sma(event): - """Update all the SMA sensors.""" - nonlocal backoff, backoff_step - if backoff > 1: - backoff -= 1 - return - - values = await sma.read(used_sensors) - if not values: - try: - backoff = [1, 1, 1, 6, 30][backoff_step] - backoff_step += 1 - except IndexError: - backoff = 60 - return - backoff_step = 0 - - for sensor in hass_sensors: - sensor.async_update_values() - - interval = config.get(CONF_SCAN_INTERVAL) or timedelta(seconds=5) - async_track_time_interval(hass, async_sma, interval) + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_IMPORT}, data=config + ) + ) -class SMAsensor(SensorEntity): +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: Callable[[], Coroutine], +) -> None: + """Set up SMA sensors.""" + sma_data = hass.data[DOMAIN][config_entry.entry_id] + + coordinator = sma_data[PYSMA_COORDINATOR] + used_sensors = sma_data[PYSMA_SENSORS] + + entities = [] + for sensor in used_sensors: + entities.append( + SMAsensor( + coordinator, + config_entry.unique_id, + config_entry.data[DEVICE_INFO], + sensor, + ) + ) + + async_add_entities(entities) + + +class SMAsensor(CoordinatorEntity, SensorEntity): """Representation of a SMA sensor.""" - def __init__(self, pysma_sensor, sub_sensors): + def __init__( + self, + coordinator: DataUpdateCoordinator, + config_entry_unique_id: str, + device_info: dict[str, Any], + pysma_sensor: pysma.Sensor, + ) -> None: """Initialize the sensor.""" + super().__init__(coordinator) self._sensor = pysma_sensor - self._sub_sensors = sub_sensors # Can be remove from 0.99 + self._enabled_default = self._sensor.enabled + self._config_entry_unique_id = config_entry_unique_id + self._device_info = device_info - self._attr = {s.name: "" for s in sub_sensors} - self._state = self._sensor.value + # Set sensor enabled to False. + # Will be enabled by async_added_to_hass if actually used. + self._sensor.enabled = False @property - def name(self): + def name(self) -> str: """Return the name of the sensor.""" return self._sensor.name @property - def state(self): + def state(self) -> StateType: """Return the state of the sensor.""" - return self._state + return self._sensor.value @property - def unit_of_measurement(self): + def unit_of_measurement(self) -> str | None: """Return the unit the value is expressed in.""" return self._sensor.unit @property - def extra_state_attributes(self): # Can be remove from 0.99 - """Return the state attributes of the sensor.""" - return self._attr - - @property - def poll(self): - """SMA sensors are updated & don't poll.""" - return False - - @callback - def async_update_values(self): - """Update this sensor.""" - update = False - - for sens in self._sub_sensors: # Can be remove from 0.99 - newval = f"{sens.value} {sens.unit}" - if self._attr[sens.name] != newval: - update = True - self._attr[sens.name] = newval - - if self._sensor.value != self._state: - update = True - self._state = self._sensor.value - - if update: - self.async_write_ha_state() - - @property - def unique_id(self): + def unique_id(self) -> str: """Return a unique identifier for this sensor.""" - return f"sma-{self._sensor.key}-{self._sensor.name}" + return ( + f"{self._config_entry_unique_id}-{self._sensor.key}_{self._sensor.key_idx}" + ) + + @property + def device_info(self) -> dict[str, Any]: + """Return the device information.""" + return { + "identifiers": {(DOMAIN, self._config_entry_unique_id)}, + "name": self._device_info["name"], + "manufacturer": self._device_info["manufacturer"], + "model": self._device_info["type"], + } + + @property + def entity_registry_enabled_default(self) -> bool: + """Return if the entity should be enabled when first added to the entity registry.""" + return self._enabled_default + + async def async_added_to_hass(self) -> None: + """Run when entity about to be added to hass.""" + await super().async_added_to_hass() + self._sensor.enabled = True + + async def async_will_remove_from_hass(self) -> None: + """Run when entity will be removed from hass.""" + await super().async_will_remove_from_hass() + self._sensor.enabled = False diff --git a/homeassistant/components/sma/strings.json b/homeassistant/components/sma/strings.json new file mode 100644 index 00000000000..f5dc6c16c88 --- /dev/null +++ b/homeassistant/components/sma/strings.json @@ -0,0 +1,27 @@ +{ + "config": { + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]" + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "cannot_retrieve_device_info": "Successfully connected, but unable to retrieve the device information", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "step": { + "user": { + "data": { + "group": "Group", + "host": "[%key:common::config_flow::data::host%]", + "password": "[%key:common::config_flow::data::password%]", + "ssl": "[%key:common::config_flow::data::ssl%]", + "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]" + }, + "description": "Enter your SMA device information.", + "title": "Set up SMA Solar" + } + } + } +} diff --git a/homeassistant/components/sma/translations/en.json b/homeassistant/components/sma/translations/en.json new file mode 100644 index 00000000000..71b8ce55bd5 --- /dev/null +++ b/homeassistant/components/sma/translations/en.json @@ -0,0 +1,27 @@ +{ + "config": { + "abort": { + "already_configured": "Device is already configured", + "already_in_progress": "Configuration flow is already in progress" + }, + "error": { + "cannot_connect": "Failed to connect", + "cannot_retrieve_device_info": "Successfully connected, but unable to retrieve the device information", + "invalid_auth": "Invalid authentication", + "unknown": "Unexpected error" + }, + "step": { + "user": { + "data": { + "group": "Group", + "host": "Host", + "password": "Password", + "ssl": "Uses an SSL certificate", + "verify_ssl": "Verify SSL certificate" + }, + "description": "Enter your SMA device information.", + "title": "Set up SMA Solar" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 25429296d8e..151b95a8f20 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -210,6 +210,7 @@ FLOWS = [ "shelly", "shopping_list", "simplisafe", + "sma", "smappee", "smart_meter_texas", "smarthab", diff --git a/requirements_all.txt b/requirements_all.txt index 66324962193..95c521ec872 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1708,7 +1708,7 @@ pysignalclirestapi==0.3.4 pyskyqhub==0.1.3 # homeassistant.components.sma -pysma==0.3.5 +pysma==0.4.3 # homeassistant.components.smappee pysmappee==0.2.17 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 26c97639e21..9d5da4349ad 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -935,7 +935,7 @@ pyserial==3.5 pysignalclirestapi==0.3.4 # homeassistant.components.sma -pysma==0.3.5 +pysma==0.4.3 # homeassistant.components.smappee pysmappee==0.2.17 diff --git a/tests/components/sma/__init__.py b/tests/components/sma/__init__.py index 124f481135e..05e9dc9f4cf 100644 --- a/tests/components/sma/__init__.py +++ b/tests/components/sma/__init__.py @@ -1 +1,126 @@ -"""SMA tests.""" +"""Tests for the sma integration.""" +from unittest.mock import patch + +from homeassistant.components.sma.const import DOMAIN +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry + +MOCK_DEVICE = { + "manufacturer": "SMA", + "name": "SMA Device Name", + "type": "Sunny Boy 3.6", + "serial": "123456789", +} + +MOCK_USER_INPUT = { + "host": "1.1.1.1", + "ssl": True, + "verify_ssl": False, + "group": "user", + "password": "password", +} + +MOCK_IMPORT = { + "platform": "sma", + "host": "1.1.1.1", + "ssl": True, + "verify_ssl": False, + "group": "user", + "password": "password", + "sensors": ["pv_power", "daily_yield", "total_yield", "not_existing_sensors"], + "custom": { + "yesterday_consumption": { + "factor": 1000.0, + "key": "6400_00543A01", + "unit": "kWh", + } + }, +} + +MOCK_CUSTOM_SENSOR = { + "name": "yesterday_consumption", + "key": "6400_00543A01", + "unit": "kWh", + "factor": 1000, +} + +MOCK_CUSTOM_SENSOR2 = { + "name": "device_type_id", + "key": "6800_08822000", + "unit": "", + "path": '"1"[0].val[0].tag', +} + +MOCK_SETUP_DATA = dict( + { + "custom": {}, + "device_info": MOCK_DEVICE, + "sensors": [], + }, + **MOCK_USER_INPUT, +) + +MOCK_CUSTOM_SETUP_DATA = dict( + { + "custom": { + MOCK_CUSTOM_SENSOR["name"]: { + "factor": MOCK_CUSTOM_SENSOR["factor"], + "key": MOCK_CUSTOM_SENSOR["key"], + "path": None, + "unit": MOCK_CUSTOM_SENSOR["unit"], + }, + MOCK_CUSTOM_SENSOR2["name"]: { + "factor": 1.0, + "key": MOCK_CUSTOM_SENSOR2["key"], + "path": MOCK_CUSTOM_SENSOR2["path"], + "unit": MOCK_CUSTOM_SENSOR2["unit"], + }, + }, + "device_info": MOCK_DEVICE, + "sensors": [], + }, + **MOCK_USER_INPUT, +) + +MOCK_LEGACY_ENTRY = er.RegistryEntry( + entity_id="sensor.pv_power", + unique_id="sma-6100_0046C200-pv_power", + platform="sma", + unit_of_measurement="W", + original_name="pv_power", +) + + +async def init_integration(hass): + """Create a fake SMA Config Entry.""" + entry = MockConfigEntry( + domain=DOMAIN, + title=MOCK_DEVICE["name"], + unique_id=MOCK_DEVICE["serial"], + data=MOCK_CUSTOM_SETUP_DATA, + source="import", + ) + entry.add_to_hass(hass) + + with patch( + "homeassistant.helpers.update_coordinator.DataUpdateCoordinator.async_config_entry_first_refresh" + ): + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + return entry + + +def _patch_validate_input(return_value=MOCK_DEVICE, side_effect=None): + return patch( + "homeassistant.components.sma.config_flow.validate_input", + return_value=return_value, + side_effect=side_effect, + ) + + +def _patch_async_setup_entry(return_value=True): + return patch( + "homeassistant.components.sma.async_setup_entry", + return_value=return_value, + ) diff --git a/tests/components/sma/test_config_flow.py b/tests/components/sma/test_config_flow.py new file mode 100644 index 00000000000..d248b2206da --- /dev/null +++ b/tests/components/sma/test_config_flow.py @@ -0,0 +1,170 @@ +"""Test the sma config flow.""" +from unittest.mock import patch + +import aiohttp + +from homeassistant import setup +from homeassistant.components.sma.const import DOMAIN +from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_USER +from homeassistant.data_entry_flow import ( + RESULT_TYPE_ABORT, + RESULT_TYPE_CREATE_ENTRY, + RESULT_TYPE_FORM, +) +from homeassistant.helpers import entity_registry as er + +from . import ( + MOCK_DEVICE, + MOCK_IMPORT, + MOCK_LEGACY_ENTRY, + MOCK_SETUP_DATA, + MOCK_USER_INPUT, + _patch_async_setup_entry, + _patch_validate_input, +) + + +async def test_form(hass, aioclient_mock): + """Test we get the form.""" + await setup.async_setup_component(hass, "persistent_notification", {}) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] == RESULT_TYPE_FORM + assert result["errors"] == {} + + with patch("pysma.SMA.new_session", return_value=True), patch( + "pysma.SMA.device_info", return_value=MOCK_DEVICE + ), _patch_async_setup_entry() as mock_setup_entry: + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + MOCK_USER_INPUT, + ) + await hass.async_block_till_done() + + assert result["type"] == RESULT_TYPE_CREATE_ENTRY + assert result["title"] == MOCK_USER_INPUT["host"] + assert result["data"] == MOCK_SETUP_DATA + + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_form_cannot_connect(hass, aioclient_mock): + """Test we handle cannot connect error.""" + aioclient_mock.get("https://1.1.1.1/data/l10n/en-US.json", exc=aiohttp.ClientError) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + MOCK_USER_INPUT, + ) + + assert result["type"] == RESULT_TYPE_FORM + assert result["errors"] == {"base": "cannot_connect"} + + +async def test_form_invalid_auth(hass, aioclient_mock): + """Test we handle invalid auth error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + with patch("pysma.SMA.new_session", return_value=False): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + MOCK_USER_INPUT, + ) + + assert result["type"] == RESULT_TYPE_FORM + assert result["errors"] == {"base": "invalid_auth"} + + +async def test_form_cannot_retrieve_device_info(hass, aioclient_mock): + """Test we handle cannot retrieve device info error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + with patch("pysma.SMA.new_session", return_value=True), patch( + "pysma.SMA.read", return_value=False + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + MOCK_USER_INPUT, + ) + + assert result["type"] == RESULT_TYPE_FORM + assert result["errors"] == {"base": "cannot_retrieve_device_info"} + + +async def test_form_unexpected_exception(hass): + """Test we handle unexpected exception.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + with _patch_validate_input(side_effect=Exception): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + MOCK_USER_INPUT, + ) + + assert result["type"] == RESULT_TYPE_FORM + assert result["errors"] == {"base": "unknown"} + + +async def test_form_already_configured(hass): + """Test starting a flow by user when already configured.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + with _patch_validate_input(): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + MOCK_USER_INPUT, + ) + + assert result["type"] == RESULT_TYPE_CREATE_ENTRY + assert result["result"].unique_id == MOCK_DEVICE["serial"] + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + with _patch_validate_input(): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + MOCK_USER_INPUT, + ) + + assert result["type"] == RESULT_TYPE_ABORT + assert result["reason"] == "already_configured" + + +async def test_import(hass): + """Test we can import.""" + entity_registry = er.async_get(hass) + entity_registry._register_entry(MOCK_LEGACY_ENTRY) + + await setup.async_setup_component(hass, "persistent_notification", {}) + + with _patch_validate_input(): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data=MOCK_IMPORT, + ) + + assert result["type"] == RESULT_TYPE_CREATE_ENTRY + assert result["title"] == MOCK_USER_INPUT["host"] + assert result["data"] == MOCK_IMPORT + + assert MOCK_LEGACY_ENTRY.original_name not in result["data"]["sensors"] + assert "pv_power_a" in result["data"]["sensors"] + + entity = entity_registry.async_get(MOCK_LEGACY_ENTRY.entity_id) + assert entity.unique_id == f"{MOCK_DEVICE['serial']}-6380_40251E00_0" diff --git a/tests/components/sma/test_sensor.py b/tests/components/sma/test_sensor.py index 8aa8c3e5b4c..7d5be09222c 100644 --- a/tests/components/sma/test_sensor.py +++ b/tests/components/sma/test_sensor.py @@ -1,32 +1,21 @@ -"""SMA sensor tests.""" -from homeassistant.components.sensor import DOMAIN -from homeassistant.const import ATTR_UNIT_OF_MEASUREMENT, VOLT -from homeassistant.setup import async_setup_component +"""Test the sma sensor platform.""" +from homeassistant.const import ( + ATTR_UNIT_OF_MEASUREMENT, + ENERGY_KILO_WATT_HOUR, + POWER_WATT, +) -from tests.common import assert_setup_component - -BASE_CFG = { - "platform": "sma", - "host": "1.1.1.1", - "password": "", - "custom": {"my_sensor": {"key": "1234567890123", "unit": VOLT}}, -} +from . import MOCK_CUSTOM_SENSOR, init_integration -async def test_sma_config(hass): - """Test new config.""" - sensors = ["current_consumption"] - - with assert_setup_component(1): - assert await async_setup_component( - hass, DOMAIN, {DOMAIN: dict(BASE_CFG, sensors=sensors)} - ) - await hass.async_block_till_done() +async def test_sensors(hass): + """Test states of the sensors.""" + await init_integration(hass) state = hass.states.get("sensor.current_consumption") assert state - assert ATTR_UNIT_OF_MEASUREMENT in state.attributes - assert "current_consumption" not in state.attributes + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == POWER_WATT - state = hass.states.get("sensor.my_sensor") + state = hass.states.get(f"sensor.{MOCK_CUSTOM_SENSOR['name']}") assert state + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == ENERGY_KILO_WATT_HOUR