From 3e702c8ca4a2f5bc97d34c3cc193743ec274ddf1 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 25 Feb 2020 13:37:41 -1000 Subject: [PATCH] Add Config Flow for sense (#32160) * Config Flow for sense * Fix unique ids so they are actually unique (and migrate the old ones) * Fix missing solar production * Do not mark sensors available until they have data * Address review items * Address review round #2 --- .../components/sense/.translations/en.json | 22 ++++ homeassistant/components/sense/__init__.py | 105 ++++++++++++++---- .../components/sense/binary_sensor.py | 50 +++++++-- homeassistant/components/sense/config_flow.py | 75 +++++++++++++ homeassistant/components/sense/const.py | 7 ++ homeassistant/components/sense/manifest.json | 11 +- homeassistant/components/sense/sensor.py | 39 +++++-- homeassistant/components/sense/strings.json | 22 ++++ homeassistant/generated/config_flows.py | 1 + requirements_test_all.txt | 3 + tests/components/sense/__init__.py | 1 + tests/components/sense/test_config_flow.py | 75 +++++++++++++ 12 files changed, 367 insertions(+), 44 deletions(-) create mode 100644 homeassistant/components/sense/.translations/en.json create mode 100644 homeassistant/components/sense/config_flow.py create mode 100644 homeassistant/components/sense/const.py create mode 100644 homeassistant/components/sense/strings.json create mode 100644 tests/components/sense/__init__.py create mode 100644 tests/components/sense/test_config_flow.py diff --git a/homeassistant/components/sense/.translations/en.json b/homeassistant/components/sense/.translations/en.json new file mode 100644 index 00000000000..d3af47b5378 --- /dev/null +++ b/homeassistant/components/sense/.translations/en.json @@ -0,0 +1,22 @@ +{ + "config": { + "title": "Sense", + "step": { + "user": { + "title": "Connect to your Sense Energy Monitor", + "data": { + "email": "Email Address", + "password": "Password" + } + } + }, + "error": { + "cannot_connect": "Failed to connect, please try again", + "invalid_auth": "Invalid authentication", + "unknown": "Unexpected error" + }, + "abort": { + "already_configured": "Device is already configured" + } + } +} diff --git a/homeassistant/components/sense/__init__.py b/homeassistant/components/sense/__init__.py index ce0d3bce5dc..f54e4092178 100644 --- a/homeassistant/components/sense/__init__.py +++ b/homeassistant/components/sense/__init__.py @@ -1,4 +1,5 @@ """Support for monitoring a Sense energy sensor.""" +import asyncio from datetime import timedelta import logging @@ -9,21 +10,25 @@ from sense_energy import ( ) import voluptuous as vol +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import CONF_EMAIL, CONF_PASSWORD, CONF_TIMEOUT +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.discovery import async_load_platform from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.event import async_track_time_interval +from .const import ( + ACTIVE_UPDATE_RATE, + DEFAULT_TIMEOUT, + DOMAIN, + SENSE_DATA, + SENSE_DEVICE_UPDATE, +) + _LOGGER = logging.getLogger(__name__) -ACTIVE_UPDATE_RATE = 60 - -DEFAULT_TIMEOUT = 5 -DOMAIN = "sense" - -SENSE_DATA = "sense_data" -SENSE_DEVICE_UPDATE = "sense_devices_update" +PLATFORMS = ["sensor", "binary_sensor"] CONFIG_SCHEMA = vol.Schema( { @@ -39,34 +44,88 @@ CONFIG_SCHEMA = vol.Schema( ) -async def async_setup(hass, config): - """Set up the Sense sensor.""" +async def async_setup(hass: HomeAssistant, config: dict): + """Set up the Sense component.""" + hass.data.setdefault(DOMAIN, {}) + conf = config.get(DOMAIN) + if not conf: + return True - username = config[DOMAIN][CONF_EMAIL] - password = config[DOMAIN][CONF_PASSWORD] + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data={ + CONF_EMAIL: conf[CONF_EMAIL], + CONF_PASSWORD: conf[CONF_PASSWORD], + CONF_TIMEOUT: conf.get[CONF_TIMEOUT], + }, + ) + ) + return True + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): + """Set up Sense from a config entry.""" + + entry_data = entry.data + email = entry_data[CONF_EMAIL] + password = entry_data[CONF_PASSWORD] + timeout = entry_data[CONF_TIMEOUT] + + gateway = ASyncSenseable(api_timeout=timeout, wss_timeout=timeout) + gateway.rate_limit = ACTIVE_UPDATE_RATE - timeout = config[DOMAIN][CONF_TIMEOUT] try: - hass.data[SENSE_DATA] = ASyncSenseable(api_timeout=timeout, wss_timeout=timeout) - hass.data[SENSE_DATA].rate_limit = ACTIVE_UPDATE_RATE - await hass.data[SENSE_DATA].authenticate(username, password) + await gateway.authenticate(email, password) except SenseAuthenticationException: _LOGGER.error("Could not authenticate with sense server") return False - hass.async_create_task(async_load_platform(hass, "sensor", DOMAIN, {}, config)) - hass.async_create_task( - async_load_platform(hass, "binary_sensor", DOMAIN, {}, config) - ) + except SenseAPITimeoutException: + raise ConfigEntryNotReady + + hass.data[DOMAIN][entry.entry_id] = {SENSE_DATA: gateway} + + for component in PLATFORMS: + hass.async_create_task( + hass.config_entries.async_forward_entry_setup(entry, component) + ) async def async_sense_update(now): """Retrieve latest state.""" try: - await hass.data[SENSE_DATA].update_realtime() - async_dispatcher_send(hass, SENSE_DEVICE_UPDATE) + gateway = hass.data[DOMAIN][entry.entry_id][SENSE_DATA] + await gateway.update_realtime() + async_dispatcher_send( + hass, f"{SENSE_DEVICE_UPDATE}-{gateway.sense_monitor_id}" + ) except SenseAPITimeoutException: _LOGGER.error("Timeout retrieving data") - async_track_time_interval( + hass.data[DOMAIN][entry.entry_id][ + "track_time_remove_callback" + ] = async_track_time_interval( hass, async_sense_update, timedelta(seconds=ACTIVE_UPDATE_RATE) ) return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry): + """Unload a config entry.""" + unload_ok = all( + await asyncio.gather( + *[ + hass.config_entries.async_forward_entry_unload(entry, component) + for component in PLATFORMS + ] + ) + ) + track_time_remove_callback = hass.data[DOMAIN][entry.entry_id][ + "track_time_remove_callback" + ] + track_time_remove_callback() + + if unload_ok: + hass.data[DOMAIN].pop(entry.entry_id) + + return unload_ok diff --git a/homeassistant/components/sense/binary_sensor.py b/homeassistant/components/sense/binary_sensor.py index 81f1b64c864..2ae79d71e5a 100644 --- a/homeassistant/components/sense/binary_sensor.py +++ b/homeassistant/components/sense/binary_sensor.py @@ -4,11 +4,14 @@ import logging from homeassistant.components.binary_sensor import BinarySensorDevice from homeassistant.core import callback from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity_registry import async_get_registry -from . import SENSE_DATA, SENSE_DEVICE_UPDATE +from .const import DOMAIN, SENSE_DATA, SENSE_DEVICE_UPDATE _LOGGER = logging.getLogger(__name__) +ATTR_WATTS = "watts" +DEVICE_ID_SOLAR = "solar" BIN_SENSOR_CLASS = "power" MDI_ICONS = { "ac": "air-conditioner", @@ -41,6 +44,7 @@ MDI_ICONS = { "skillet": "pot", "smartcamera": "webcam", "socket": "power-plug", + "solar_alt": "solar-power", "sound": "speaker", "stove": "stove", "trash": "trash-can", @@ -50,21 +54,40 @@ MDI_ICONS = { } -async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): +async def async_setup_entry(hass, config_entry, async_add_entities): """Set up the Sense binary sensor.""" - if discovery_info is None: - return - data = hass.data[SENSE_DATA] + data = hass.data[DOMAIN][config_entry.entry_id][SENSE_DATA] + sense_monitor_id = data.sense_monitor_id sense_devices = await data.get_discovered_device_data() devices = [ - SenseDevice(data, device) + SenseDevice(data, device, sense_monitor_id) for device in sense_devices - if device["tags"]["DeviceListAllowed"] == "true" + if device["id"] == DEVICE_ID_SOLAR + or device["tags"]["DeviceListAllowed"] == "true" ] + + await _migrate_old_unique_ids(hass, devices) + async_add_entities(devices) +async def _migrate_old_unique_ids(hass, devices): + registry = await async_get_registry(hass) + for device in devices: + # Migration of old not so unique ids + old_entity_id = registry.async_get_entity_id( + "binary_sensor", DOMAIN, device.old_unique_id + ) + if old_entity_id is not None: + _LOGGER.debug( + "Migrating unique_id from [%s] to [%s]", + device.old_unique_id, + device.unique_id, + ) + registry.async_update_entity(old_entity_id, new_unique_id=device.unique_id) + + def sense_to_mdi(sense_icon): """Convert sense icon to mdi icon.""" return "mdi:{}".format(MDI_ICONS.get(sense_icon, "power-plug")) @@ -73,10 +96,12 @@ def sense_to_mdi(sense_icon): class SenseDevice(BinarySensorDevice): """Implementation of a Sense energy device binary sensor.""" - def __init__(self, data, device): + def __init__(self, data, device, sense_monitor_id): """Initialize the Sense binary sensor.""" self._name = device["name"] self._id = device["id"] + self._sense_monitor_id = sense_monitor_id + self._unique_id = f"{sense_monitor_id}-{self._id}" self._icon = sense_to_mdi(device["icon"]) self._data = data self._undo_dispatch_subscription = None @@ -93,7 +118,12 @@ class SenseDevice(BinarySensorDevice): @property def unique_id(self): - """Return the id of the binary sensor.""" + """Return the unique id of the binary sensor.""" + return self._unique_id + + @property + def old_unique_id(self): + """Return the old not so unique id of the binary sensor.""" return self._id @property @@ -120,7 +150,7 @@ class SenseDevice(BinarySensorDevice): self.async_schedule_update_ha_state(True) self._undo_dispatch_subscription = async_dispatcher_connect( - self.hass, SENSE_DEVICE_UPDATE, update + self.hass, f"{SENSE_DEVICE_UPDATE}-{self._sense_monitor_id}", update ) async def async_will_remove_from_hass(self): diff --git a/homeassistant/components/sense/config_flow.py b/homeassistant/components/sense/config_flow.py new file mode 100644 index 00000000000..68bbb9ed932 --- /dev/null +++ b/homeassistant/components/sense/config_flow.py @@ -0,0 +1,75 @@ +"""Config flow for Sense integration.""" +import logging + +from sense_energy import ( + ASyncSenseable, + SenseAPITimeoutException, + SenseAuthenticationException, +) +import voluptuous as vol + +from homeassistant import config_entries, core +from homeassistant.const import CONF_EMAIL, CONF_PASSWORD, CONF_TIMEOUT + +from .const import ACTIVE_UPDATE_RATE, DEFAULT_TIMEOUT + +from .const import DOMAIN # pylint:disable=unused-import; pylint:disable=unused-import + +_LOGGER = logging.getLogger(__name__) + +DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_EMAIL): str, + vol.Required(CONF_PASSWORD): str, + vol.Optional(CONF_TIMEOUT, default=DEFAULT_TIMEOUT): vol.Coerce(int), + } +) + + +async def validate_input(hass: core.HomeAssistant, data): + """Validate the user input allows us to connect. + + Data has the keys from DATA_SCHEMA with values provided by the user. + """ + timeout = data[CONF_TIMEOUT] + + gateway = ASyncSenseable(api_timeout=timeout, wss_timeout=timeout) + gateway.rate_limit = ACTIVE_UPDATE_RATE + await gateway.authenticate(data[CONF_EMAIL], data[CONF_PASSWORD]) + + # Return info that you want to store in the config entry. + return {"title": data[CONF_EMAIL]} + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for Sense.""" + + VERSION = 1 + CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL + + async def async_step_user(self, user_input=None): + """Handle the initial step.""" + errors = {} + if user_input is not None: + try: + info = await validate_input(self.hass, user_input) + await self.async_set_unique_id(user_input[CONF_EMAIL]) + return self.async_create_entry(title=info["title"], data=user_input) + except SenseAPITimeoutException: + errors["base"] = "cannot_connect" + except SenseAuthenticationException: + errors["base"] = "invalid_auth" + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + + return self.async_show_form( + step_id="user", data_schema=DATA_SCHEMA, errors=errors + ) + + async def async_step_import(self, user_input): + """Handle import.""" + await self.async_set_unique_id(user_input[CONF_EMAIL]) + self._abort_if_unique_id_configured() + + return await self.async_step_user(user_input) diff --git a/homeassistant/components/sense/const.py b/homeassistant/components/sense/const.py new file mode 100644 index 00000000000..cc30591e02a --- /dev/null +++ b/homeassistant/components/sense/const.py @@ -0,0 +1,7 @@ +"""Constants for monitoring a Sense energy sensor.""" +DOMAIN = "sense" +DEFAULT_TIMEOUT = 10 +ACTIVE_UPDATE_RATE = 60 +DEFAULT_NAME = "Sense" +SENSE_DATA = "sense_data" +SENSE_DEVICE_UPDATE = "sense_devices_update" diff --git a/homeassistant/components/sense/manifest.json b/homeassistant/components/sense/manifest.json index e27d4bb72f6..61f09fb444b 100644 --- a/homeassistant/components/sense/manifest.json +++ b/homeassistant/components/sense/manifest.json @@ -2,7 +2,12 @@ "domain": "sense", "name": "Sense", "documentation": "https://www.home-assistant.io/integrations/sense", - "requirements": ["sense_energy==0.7.0"], + "requirements": [ + "sense_energy==0.7.0" + ], "dependencies": [], - "codeowners": ["@kbickar"] -} + "codeowners": [ + "@kbickar" + ], + "config_flow": true +} \ No newline at end of file diff --git a/homeassistant/components/sense/sensor.py b/homeassistant/components/sense/sensor.py index d177a480ddf..8d3c8f9e171 100644 --- a/homeassistant/components/sense/sensor.py +++ b/homeassistant/components/sense/sensor.py @@ -8,7 +8,7 @@ from homeassistant.const import ENERGY_KILO_WATT_HOUR, POWER_WATT from homeassistant.helpers.entity import Entity from homeassistant.util import Throttle -from . import SENSE_DATA +from .const import DOMAIN, SENSE_DATA _LOGGER = logging.getLogger(__name__) @@ -46,11 +46,9 @@ SENSOR_TYPES = { SENSOR_VARIANTS = [PRODUCTION_NAME.lower(), CONSUMPTION_NAME.lower()] -async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): +async def async_setup_entry(hass, config_entry, async_add_entities): """Set up the Sense sensor.""" - if discovery_info is None: - return - data = hass.data[SENSE_DATA] + data = hass.data[DOMAIN][config_entry.entry_id][SENSE_DATA] @Throttle(MIN_TIME_BETWEEN_DAILY_UPDATES) async def update_trends(): @@ -61,8 +59,11 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= """Update the active power usage.""" await data.update_realtime() + sense_monitor_id = data.sense_monitor_id + devices = [] - for typ in SENSOR_TYPES.values(): + for type_id in SENSOR_TYPES: + typ = SENSOR_TYPES[type_id] for var in SENSOR_VARIANTS: name = typ.name sensor_type = typ.sensor_type @@ -71,7 +72,13 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= update_call = update_active else: update_call = update_trends - devices.append(Sense(data, name, sensor_type, is_production, update_call)) + + unique_id = f"{sense_monitor_id}-{type_id}-{var}".lower() + devices.append( + Sense( + data, name, sensor_type, is_production, update_call, var, unique_id + ) + ) async_add_entities(devices) @@ -79,10 +86,14 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= class Sense(Entity): """Implementation of a Sense energy sensor.""" - def __init__(self, data, name, sensor_type, is_production, update_call): + def __init__( + self, data, name, sensor_type, is_production, update_call, sensor_id, unique_id + ): """Initialize the Sense sensor.""" name_type = PRODUCTION_NAME if is_production else CONSUMPTION_NAME self._name = f"{name} {name_type}" + self._unique_id = unique_id + self._available = False self._data = data self._sensor_type = sensor_type self.update_sensor = update_call @@ -104,6 +115,11 @@ class Sense(Entity): """Return the state of the sensor.""" return self._state + @property + def available(self): + """Return the availability of the sensor.""" + return self._available + @property def unit_of_measurement(self): """Return the unit of measurement of this entity, if any.""" @@ -114,6 +130,11 @@ class Sense(Entity): """Icon to use in the frontend, if any.""" return ICON + @property + def unique_id(self): + """Return the unique id.""" + return self._unique_id + async def async_update(self): """Get the latest data, update state.""" @@ -131,3 +152,5 @@ class Sense(Entity): else: state = self._data.get_trend(self._sensor_type, self._is_production) self._state = round(state, 1) + + self._available = True diff --git a/homeassistant/components/sense/strings.json b/homeassistant/components/sense/strings.json new file mode 100644 index 00000000000..d3af47b5378 --- /dev/null +++ b/homeassistant/components/sense/strings.json @@ -0,0 +1,22 @@ +{ + "config": { + "title": "Sense", + "step": { + "user": { + "title": "Connect to your Sense Energy Monitor", + "data": { + "email": "Email Address", + "password": "Password" + } + } + }, + "error": { + "cannot_connect": "Failed to connect, please try again", + "invalid_auth": "Invalid authentication", + "unknown": "Unexpected error" + }, + "abort": { + "already_configured": "Device is already configured" + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index cb12b13afed..d0162f84737 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -78,6 +78,7 @@ FLOWS = [ "rainmachine", "ring", "samsungtv", + "sense", "sentry", "simplisafe", "smartthings", diff --git a/requirements_test_all.txt b/requirements_test_all.txt index a3717f9d7d7..2b531deeca8 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -619,6 +619,9 @@ rxv==0.6.0 # homeassistant.components.samsungtv samsungctl[websocket]==0.7.1 +# homeassistant.components.sense +sense_energy==0.7.0 + # homeassistant.components.sentry sentry-sdk==0.13.5 diff --git a/tests/components/sense/__init__.py b/tests/components/sense/__init__.py new file mode 100644 index 00000000000..bf0a87737b9 --- /dev/null +++ b/tests/components/sense/__init__.py @@ -0,0 +1 @@ +"""Tests for the Sense integration.""" diff --git a/tests/components/sense/test_config_flow.py b/tests/components/sense/test_config_flow.py new file mode 100644 index 00000000000..fdce335b7cf --- /dev/null +++ b/tests/components/sense/test_config_flow.py @@ -0,0 +1,75 @@ +"""Test the Sense config flow.""" +from asynctest import patch +from sense_energy import SenseAPITimeoutException, SenseAuthenticationException + +from homeassistant import config_entries, setup +from homeassistant.components.sense.const import DOMAIN + + +async def test_form(hass): + """Test we get the form.""" + await setup.async_setup_component(hass, "persistent_notification", {}) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == "form" + assert result["errors"] == {} + + with patch("sense_energy.ASyncSenseable.authenticate", return_value=True,), patch( + "homeassistant.components.sense.async_setup", return_value=True + ) as mock_setup, patch( + "homeassistant.components.sense.async_setup_entry", return_value=True, + ) as mock_setup_entry: + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"timeout": "6", "email": "test-email", "password": "test-password"}, + ) + + assert result2["type"] == "create_entry" + assert result2["title"] == "test-email" + assert result2["data"] == { + "timeout": 6, + "email": "test-email", + "password": "test-password", + } + await hass.async_block_till_done() + assert len(mock_setup.mock_calls) == 1 + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_form_invalid_auth(hass): + """Test we handle invalid auth.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "sense_energy.ASyncSenseable.authenticate", + side_effect=SenseAuthenticationException, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"timeout": "6", "email": "test-email", "password": "test-password"}, + ) + + assert result2["type"] == "form" + assert result2["errors"] == {"base": "invalid_auth"} + + +async def test_form_cannot_connect(hass): + """Test we handle cannot connect error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "sense_energy.ASyncSenseable.authenticate", + side_effect=SenseAPITimeoutException, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"timeout": "6", "email": "test-email", "password": "test-password"}, + ) + + assert result2["type"] == "form" + assert result2["errors"] == {"base": "cannot_connect"}