diff --git a/homeassistant/components/tibber/__init__.py b/homeassistant/components/tibber/__init__.py index 53c02a1461a..657be67c7fc 100644 --- a/homeassistant/components/tibber/__init__.py +++ b/homeassistant/components/tibber/__init__.py @@ -6,16 +6,19 @@ import aiohttp import tibber import voluptuous as vol +from homeassistant import config_entries from homeassistant.const import CONF_ACCESS_TOKEN, CONF_NAME, EVENT_HOMEASSISTANT_STOP +from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import discovery from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.event import async_call_later from homeassistant.util import dt as dt_util -DOMAIN = "tibber" +from .const import DATA_HASS_CONFIG, DOMAIN -FIRST_RETRY_TIME = 60 +PLATFORMS = [ + "sensor", +] CONFIG_SCHEMA = vol.Schema( {DOMAIN: vol.Schema({vol.Required(CONF_ACCESS_TOKEN): cv.string})}, @@ -25,12 +28,30 @@ CONFIG_SCHEMA = vol.Schema( _LOGGER = logging.getLogger(__name__) -async def async_setup(hass, config, retry_delay=FIRST_RETRY_TIME): +async def async_setup(hass, config): """Set up the Tibber component.""" - conf = config.get(DOMAIN) + + hass.data[DATA_HASS_CONFIG] = config + + if DOMAIN not in config: + return True + + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data=config[DOMAIN], + ) + ) + + return True + + +async def async_setup_entry(hass, entry): + """Set up a config entry.""" tibber_connection = tibber.Tibber( - conf[CONF_ACCESS_TOKEN], + access_token=entry.data[CONF_ACCESS_TOKEN], websession=async_get_clientsession(hass), time_zone=dt_util.DEFAULT_TIME_ZONE, ) @@ -44,15 +65,7 @@ async def async_setup(hass, config, retry_delay=FIRST_RETRY_TIME): try: await tibber_connection.update_info() except asyncio.TimeoutError: - _LOGGER.warning("Timeout connecting to Tibber. Will retry in %ss", retry_delay) - - async def retry_setup(now): - """Retry setup if a timeout happens on Tibber API.""" - await async_setup(hass, config, retry_delay=min(2 * retry_delay, 900)) - - async_call_later(hass, retry_delay, retry_setup) - - return True + raise ConfigEntryNotReady except aiohttp.ClientError as err: _LOGGER.error("Error connecting to Tibber: %s ", err) return False @@ -60,7 +73,34 @@ async def async_setup(hass, config, retry_delay=FIRST_RETRY_TIME): _LOGGER.error("Failed to login. %s", exp) return False - for component in ["sensor", "notify"]: - discovery.load_platform(hass, component, DOMAIN, {CONF_NAME: DOMAIN}, config) + for component in PLATFORMS: + hass.async_create_task( + hass.config_entries.async_forward_entry_setup(entry, component) + ) + # set up notify platform, no entry support for notify component yet, + # have to use discovery to load platform. + hass.async_create_task( + discovery.async_load_platform( + hass, "notify", DOMAIN, {CONF_NAME: DOMAIN}, hass.data[DATA_HASS_CONFIG] + ) + ) return True + + +async def async_unload_entry(hass, config_entry): + """Unload a config entry.""" + unload_ok = all( + await asyncio.gather( + *[ + hass.config_entries.async_forward_entry_unload(config_entry, component) + for component in PLATFORMS + ] + ) + ) + + if unload_ok: + tibber_connection = hass.data.get(DOMAIN) + await tibber_connection.rt_disconnect() + + return unload_ok diff --git a/homeassistant/components/tibber/config_flow.py b/homeassistant/components/tibber/config_flow.py new file mode 100644 index 00000000000..b0115d84e2c --- /dev/null +++ b/homeassistant/components/tibber/config_flow.py @@ -0,0 +1,68 @@ +"""Adds config flow for Tibber integration.""" +import asyncio +import logging + +import aiohttp +import tibber +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_ACCESS_TOKEN +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import DOMAIN # pylint:disable=unused-import + +_LOGGER = logging.getLogger(__name__) + +DATA_SCHEMA = vol.Schema({vol.Required(CONF_ACCESS_TOKEN): str}) + + +class TibberConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for Tibber integration.""" + + VERSION = 1 + CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL + + async def async_step_import(self, import_info): + """Set the config entry up from yaml.""" + return await self.async_step_user(import_info) + + async def async_step_user(self, user_input=None): + """Handle the initial step.""" + + if self._async_current_entries(): + return self.async_abort(reason="already_configured") + + if user_input is not None: + access_token = user_input[CONF_ACCESS_TOKEN].replace(" ", "") + + tibber_connection = tibber.Tibber( + access_token=access_token, + websession=async_get_clientsession(self.hass), + ) + + errors = {} + + try: + await tibber_connection.update_info() + except asyncio.TimeoutError: + errors[CONF_ACCESS_TOKEN] = "timeout" + except aiohttp.ClientError: + errors[CONF_ACCESS_TOKEN] = "connection_error" + except tibber.InvalidLogin: + errors[CONF_ACCESS_TOKEN] = "invalid_access_token" + + if errors: + return self.async_show_form( + step_id="user", data_schema=DATA_SCHEMA, errors=errors, + ) + + unique_id = tibber_connection.user_id + await self.async_set_unique_id(unique_id) + self._abort_if_unique_id_configured() + + return self.async_create_entry( + title=tibber_connection.name, data={CONF_ACCESS_TOKEN: access_token}, + ) + + return self.async_show_form(step_id="user", data_schema=DATA_SCHEMA, errors={},) diff --git a/homeassistant/components/tibber/const.py b/homeassistant/components/tibber/const.py new file mode 100644 index 00000000000..a35fa89c40f --- /dev/null +++ b/homeassistant/components/tibber/const.py @@ -0,0 +1,5 @@ +"""Constants for Tibber integration.""" + +DATA_HASS_CONFIG = "tibber_hass_config" +DOMAIN = "tibber" +MANUFACTURER = "Tibber" diff --git a/homeassistant/components/tibber/manifest.json b/homeassistant/components/tibber/manifest.json index 78249a96291..36f4002949b 100644 --- a/homeassistant/components/tibber/manifest.json +++ b/homeassistant/components/tibber/manifest.json @@ -2,7 +2,8 @@ "domain": "tibber", "name": "Tibber", "documentation": "https://www.home-assistant.io/integrations/tibber", - "requirements": ["pyTibber==0.13.8"], + "requirements": ["pyTibber==0.14.0"], "codeowners": ["@danielhiversen"], - "quality_scale": "silver" + "quality_scale": "silver", + "config_flow": true } diff --git a/homeassistant/components/tibber/sensor.py b/homeassistant/components/tibber/sensor.py index 36f1a65222c..7fc8820e92d 100644 --- a/homeassistant/components/tibber/sensor.py +++ b/homeassistant/components/tibber/sensor.py @@ -10,7 +10,7 @@ from homeassistant.exceptions import PlatformNotReady from homeassistant.helpers.entity import Entity from homeassistant.util import Throttle, dt as dt_util -from . import DOMAIN as TIBBER_DOMAIN +from .const import DOMAIN as TIBBER_DOMAIN, MANUFACTURER _LOGGER = logging.getLogger(__name__) @@ -20,10 +20,8 @@ SCAN_INTERVAL = timedelta(minutes=1) MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=5) -async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): +async def async_setup_entry(hass, entry, async_add_entities): """Set up the Tibber sensor.""" - if discovery_info is None: - return tibber_connection = hass.data.get(TIBBER_DOMAIN) @@ -66,11 +64,34 @@ class TibberSensor(Entity): """Return the state attributes.""" return self._device_state_attributes + @property + def model(self): + """Return the model of the sensor.""" + return None + @property def state(self): """Return the state of the device.""" return self._state + @property + def device_id(self): + """Return the ID of the physical device this sensor is part of.""" + home = self._tibber_home.info["viewer"]["home"] + return home["meteringPointData"]["consumptionEan"] + + @property + def device_info(self): + """Return the device_info of the device.""" + device_info = { + "identifiers": {(TIBBER_DOMAIN, self.device_id)}, + "name": self.name, + "manufacturer": MANUFACTURER, + } + if self.model is not None: + device_info["model"] = self.model + return device_info + class TibberSensorElPrice(TibberSensor): """Representation of a Tibber sensor for el price.""" @@ -112,6 +133,11 @@ class TibberSensorElPrice(TibberSensor): """Return the name of the sensor.""" return f"Electricity price {self._name}" + @property + def model(self): + """Return the model of the sensor.""" + return "Price Sensor" + @property def icon(self): """Return the icon to use in the frontend.""" @@ -125,8 +151,7 @@ class TibberSensorElPrice(TibberSensor): @property def unique_id(self): """Return a unique ID.""" - home = self._tibber_home.info["viewer"]["home"] - return home["meteringPointData"]["consumptionEan"] + return self.device_id @Throttle(MIN_TIME_BETWEEN_UPDATES) async def _fetch_data(self): @@ -149,7 +174,7 @@ class TibberSensorRT(TibberSensor): """Representation of a Tibber sensor for real time consumption.""" async def async_added_to_hass(self): - """Start unavailability tracking.""" + """Start listen for real time data.""" await self._tibber_home.rt_subscribe(self.hass.loop, self._async_callback) async def _async_callback(self, payload): @@ -177,6 +202,11 @@ class TibberSensorRT(TibberSensor): """Return True if entity is available.""" return self._tibber_home.rt_subscription_running + @property + def model(self): + """Return the model of the sensor.""" + return "Tibber Pulse" + @property def name(self): """Return the name of the sensor.""" @@ -200,6 +230,4 @@ class TibberSensorRT(TibberSensor): @property def unique_id(self): """Return a unique ID.""" - home = self._tibber_home.info["viewer"]["home"] - _id = home["meteringPointData"]["consumptionEan"] - return f"{_id}_rt_consumption" + return f"{self.device_id}_rt_consumption" diff --git a/homeassistant/components/tibber/strings.json b/homeassistant/components/tibber/strings.json new file mode 100644 index 00000000000..a856c96e2f9 --- /dev/null +++ b/homeassistant/components/tibber/strings.json @@ -0,0 +1,22 @@ +{ + "title": "Tibber", + "config": { + "abort": { + "already_configured": "A Tibber account is already configured." + }, + "error": { + "timeout": "Timeout connecting to Tibber", + "connection_error": "Error connecting to Tibber", + "invalid_access_token": "Invalid access token" + }, + "step": { + "user": { + "data": { + "access_token": "Access token" + }, + "description": "Enter your access token from https://developer.tibber.com/settings/accesstoken", + "title": "Tibber" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 0fde3dc9676..472f722538d 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -125,6 +125,7 @@ FLOWS = [ "tado", "tellduslive", "tesla", + "tibber", "toon", "totalconnect", "tplink", diff --git a/requirements_all.txt b/requirements_all.txt index bbdb9165bc7..0a50553bdd2 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1158,7 +1158,7 @@ pyRFXtrx==0.25 # pySwitchmate==0.4.6 # homeassistant.components.tibber -pyTibber==0.13.8 +pyTibber==0.14.0 # homeassistant.components.dlink pyW215==0.7.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index bfeaad74448..02dd55bb2d2 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -475,6 +475,9 @@ pyMetno==0.4.6 # homeassistant.components.rfxtrx pyRFXtrx==0.25 +# homeassistant.components.tibber +pyTibber==0.14.0 + # homeassistant.components.nextbus py_nextbusnext==0.1.4 diff --git a/tests/components/tibber/__init__.py b/tests/components/tibber/__init__.py new file mode 100644 index 00000000000..0633a3da06e --- /dev/null +++ b/tests/components/tibber/__init__.py @@ -0,0 +1 @@ +"""Tests for Tibber.""" diff --git a/tests/components/tibber/test_config_flow.py b/tests/components/tibber/test_config_flow.py new file mode 100644 index 00000000000..6b2428fcff9 --- /dev/null +++ b/tests/components/tibber/test_config_flow.py @@ -0,0 +1,69 @@ +"""Tests for Tibber config flow.""" +import pytest + +from homeassistant.components.tibber.const import DOMAIN +from homeassistant.const import CONF_ACCESS_TOKEN + +from tests.async_mock import AsyncMock, MagicMock, PropertyMock, patch +from tests.common import MockConfigEntry + + +@pytest.fixture(name="tibber_setup", autouse=True) +def tibber_setup_fixture(): + """Patch tibber setup entry.""" + with patch("homeassistant.components.tibber.async_setup_entry", return_value=True): + yield + + +async def test_show_config_form(hass): + """Test show configuration form.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": "user"} + ) + + assert result["type"] == "form" + assert result["step_id"] == "user" + + +async def test_create_entry(hass): + """Test create entry from user input.""" + test_data = { + CONF_ACCESS_TOKEN: "valid", + } + + unique_user_id = "unique_user_id" + title = "title" + + tibber_mock = MagicMock() + type(tibber_mock).update_info = AsyncMock(return_value=True) + type(tibber_mock).user_id = PropertyMock(return_value=unique_user_id) + type(tibber_mock).name = PropertyMock(return_value=title) + + with patch("tibber.Tibber", return_value=tibber_mock): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": "user"}, data=test_data + ) + + assert result["type"] == "create_entry" + assert result["title"] == title + assert result["data"] == test_data + + +async def test_flow_entry_already_exists(hass): + """Test user input for config_entry that already exists.""" + first_entry = MockConfigEntry( + domain="tibber", data={CONF_ACCESS_TOKEN: "valid"}, unique_id="tibber", + ) + first_entry.add_to_hass(hass) + + test_data = { + CONF_ACCESS_TOKEN: "valid", + } + + with patch("tibber.Tibber.update_info", return_value=None): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": "user"}, data=test_data + ) + + assert result["type"] == "abort" + assert result["reason"] == "already_configured"