From c7ab5de07c60e3bfc19ffb19b1e87ef254f5510c Mon Sep 17 00:00:00 2001 From: Austin Mroczek Date: Sun, 12 Apr 2020 19:29:57 -0700 Subject: [PATCH] Add Totalconnect config flow (#32126) * Bump skybellpy to 0.4.0 * Bump skybellpy to 0.4.0 in requirements_all.txt * Added extra states for STATE_ALARM_TRIGGERED to allow users to know if it is a burglar or fire or carbon monoxide so automations can take appropriate actions. Updated TotalConnect component to handle these new states. * Fix const import * Fix const import * Fix const imports * Bump total-connect-client to 0.26. * Catch details of alarm trigger in state attributes. Also bumps total_connect_client to 0.27. * Change state_attributes() to device_state_attributes() * Move totalconnect component toward being a multi-platform integration. Bump total_connect_client to 0.28. * add missing total-connect alarm state mappings * Made recommended changes of MartinHjelmare at https://github.com/home-assistant/home-assistant/pull/24427 * Update __init__.py * Updates per MartinHjelmare comments * flake8/pydocstyle fixes * removed . at end of log message * added blank line between logging and voluptuous * more fixes * Adding totalconnect zones as HA binary_sensors * fix manifest.json * flake8/pydocstyle fixes. Added codeowner. * Update formatting per @springstan guidance. * Fixed pylint * Add zone ID to log message for easier troubleshooting * Account for bypassed zones in update() * More status handling fixes. * Fixed flake8 error * Another attempt at black/isort fixes. * Bump total-connect-client to 0.50. Simplify code using new functions in total-connect-client package instead of importing constants. Run black and isort. * Fix manifest file * Another manifest fix * one more manifest fix * more manifest changes. * sync up * fix indent * one more pylint fix * Hopefully the last pylint fix * make variable names understandable * create and fill dict in one step * Fix name and attributes * rename to logical variable in alarm_control_panel * Remove location_name from alarm_control_panel attributes since it is already the name of the alarm. * Multiple fixes to improve code per @springstan suggestions * Update homeassistant/components/totalconnect/binary_sensor.py Co-Authored-By: springstan <46536646+springstan@users.noreply.github.com> * Multiple changes per @MartinHjelmare review * simplify alarm adding * Fix binary_sensor.py is_on * Move DOMAIN to .const in line with examples. * Move to async_setup * Simplify code using new features of total-connect-client 0.51 * First crack at config flow for totalconnect * bump totalconnect to 0.52 * use client.is_logged_in() to avoid total-connect-client details. * updated generated/config_flow.py * use is_logged_in() * Hopefully final touches for config flow * Add tests for config flow * Updated requirements for test * Fixes to test_config_flow * Removed leftover comments and code * fix const.py flake8 error * Simplify text per @Kane610 https://github.com/home-assistant/home-assistant/pull/32126#pullrequestreview-364652949 * Remove .get() to speed things up since the required items should always be available. * Move CONF_USERNAME and CONF_PASSWORD into .const to eliminate extra I/O to import from homeassistant.const * Fix I/O async issues * Fix flake8 and black errors * Mock the I/O in tests. * Fix isort error * Empty commit to re-start azure pipelines (per discord) * bump total-connect-client to 0.53 * Update homeassistant/components/totalconnect/__init__.py Co-Authored-By: Paulus Schoutsen * Update homeassistant/components/totalconnect/config_flow.py Co-Authored-By: Paulus Schoutsen * Fixes per @balloob comments * Fix imports * fix isort error * Fix async_unload_entry It still referenced CONF_USERNAME instead of entry.entity_id * Added async_setup so not breaking change. Fixed imports. * Update tests/components/totalconnect/test_config_flow.py Co-Authored-By: Martin Hjelmare * Remove TotalConnectSystem() per @MartinHjelmare suggestion * Moved from is_logged_in() to is_valid_credentials() The second is more accurate for what we are checking for, because is_logged_in() could return False due to connection error. * Fix import in test * remove commented code and decorator * Update tests/components/totalconnect/test_config_flow.py Co-Authored-By: Martin Hjelmare * fix test_config_flow.py * bump total-connect-client to 0.54 * remove un-needed import of mock_coro * bump to total-connect-client 0.54.1 * re-add CONFIG_SCHEMA * disable pylint on line 10 to avoid pylint bug Co-authored-by: springstan <46536646+springstan@users.noreply.github.com> Co-authored-by: Paulus Schoutsen Co-authored-by: Martin Hjelmare --- .../totalconnect/.translations/en.json | 20 ++++ .../components/totalconnect/__init__.py | 84 +++++++++----- .../totalconnect/alarm_control_panel.py | 12 +- .../components/totalconnect/binary_sensor.py | 14 +-- .../components/totalconnect/config_flow.py | 60 ++++++++++ .../components/totalconnect/manifest.json | 4 +- .../components/totalconnect/strings.json | 20 ++++ homeassistant/generated/config_flows.py | 1 + requirements_test_all.txt | 3 + tests/components/totalconnect/__init__.py | 1 + .../totalconnect/test_config_flow.py | 104 ++++++++++++++++++ 11 files changed, 278 insertions(+), 45 deletions(-) create mode 100644 homeassistant/components/totalconnect/.translations/en.json create mode 100644 homeassistant/components/totalconnect/config_flow.py create mode 100644 homeassistant/components/totalconnect/strings.json create mode 100644 tests/components/totalconnect/__init__.py create mode 100644 tests/components/totalconnect/test_config_flow.py diff --git a/homeassistant/components/totalconnect/.translations/en.json b/homeassistant/components/totalconnect/.translations/en.json new file mode 100644 index 00000000000..5aca06df513 --- /dev/null +++ b/homeassistant/components/totalconnect/.translations/en.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "already_configured": "Account already configured" + }, + "error": { + "login": "Login error: please check your username & password" + }, + "step": { + "user": { + "data": { + "password": "Password", + "username": "Username" + }, + "title": "Total Connect" + } + }, + "title": "Total Connect" + } +} diff --git a/homeassistant/components/totalconnect/__init__.py b/homeassistant/components/totalconnect/__init__.py index e6cfbbc629a..fce67f71b24 100644 --- a/homeassistant/components/totalconnect/__init__.py +++ b/homeassistant/components/totalconnect/__init__.py @@ -1,16 +1,20 @@ """The totalconnect component.""" +import asyncio import logging from total_connect_client import TotalConnectClient import voluptuous as vol +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import CONF_PASSWORD, CONF_USERNAME -from homeassistant.helpers import discovery +from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv +from .const import DOMAIN + _LOGGER = logging.getLogger(__name__) -DOMAIN = "totalconnect" +PLATFORMS = ["alarm_control_panel", "binary_sensor"] CONFIG_SCHEMA = vol.Schema( { @@ -20,39 +24,61 @@ CONFIG_SCHEMA = vol.Schema( vol.Required(CONF_PASSWORD): cv.string, } ) - }, - extra=vol.ALLOW_EXTRA, + } ) -TOTALCONNECT_PLATFORMS = ["alarm_control_panel", "binary_sensor"] +async def async_setup(hass: HomeAssistant, config: dict): + """Set up by configuration file.""" + if DOMAIN not in config: + return True -def setup(hass, config): - """Set up TotalConnect component.""" - conf = config[DOMAIN] - - username = conf[CONF_USERNAME] - password = conf[CONF_PASSWORD] - - client = TotalConnectClient.TotalConnectClient(username, password) - - if client.token is False: - _LOGGER.error("TotalConnect authentication failed") - return False - - hass.data[DOMAIN] = TotalConnectSystem(username, password, client) - - for platform in TOTALCONNECT_PLATFORMS: - discovery.load_platform(hass, platform, DOMAIN, {}, config) + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_IMPORT}, data=config[DOMAIN], + ) + ) return True -class TotalConnectSystem: - """TotalConnect System class.""" +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): + """Set up upon config entry in user interface.""" + hass.data.setdefault(DOMAIN, {}) - def __init__(self, username, password, client): - """Initialize the TotalConnect system.""" - self._username = username - self._password = password - self.client = client + conf = entry.data + username = conf[CONF_USERNAME] + password = conf[CONF_PASSWORD] + + client = await hass.async_add_executor_job( + TotalConnectClient.TotalConnectClient, username, password + ) + + if not client.is_valid_credentials(): + _LOGGER.error("TotalConnect authentication failed") + return False + + hass.data[DOMAIN][entry.entry_id] = client + + 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, entry: ConfigEntry): + """Unload a config entry.""" + unload_ok = all( + await asyncio.gather( + *[ + hass.config_entries.async_forward_entry_unload(entry, platform) + for platform in PLATFORMS + ] + ) + ) + if unload_ok: + hass.data[DOMAIN].pop(entry.entry_id) + + return unload_ok diff --git a/homeassistant/components/totalconnect/alarm_control_panel.py b/homeassistant/components/totalconnect/alarm_control_panel.py index 2ab06e2f6bd..2a32ae89b4a 100644 --- a/homeassistant/components/totalconnect/alarm_control_panel.py +++ b/homeassistant/components/totalconnect/alarm_control_panel.py @@ -23,19 +23,17 @@ from .const import DOMAIN _LOGGER = logging.getLogger(__name__) -def setup_platform(hass, config, add_entities, discovery_info=None): - """Set up an alarm control panel for a TotalConnect device.""" - if discovery_info is None: - return - +async def async_setup_entry(hass, entry, async_add_entities) -> None: + """Set up TotalConnect alarm panels based on a config entry.""" alarms = [] - client = hass.data[DOMAIN].client + client = hass.data[DOMAIN][entry.entry_id] for location_id, location in client.locations.items(): location_name = location.location_name alarms.append(TotalConnectAlarm(location_name, location_id, client)) - add_entities(alarms) + + async_add_entities(alarms, True) class TotalConnectAlarm(alarm.AlarmControlPanel): diff --git a/homeassistant/components/totalconnect/binary_sensor.py b/homeassistant/components/totalconnect/binary_sensor.py index 28bd58cfff8..48d9a96a483 100644 --- a/homeassistant/components/totalconnect/binary_sensor.py +++ b/homeassistant/components/totalconnect/binary_sensor.py @@ -8,24 +8,22 @@ from homeassistant.components.binary_sensor import ( BinarySensorDevice, ) -from . import DOMAIN as TOTALCONNECT_DOMAIN +from .const import DOMAIN _LOGGER = logging.getLogger(__name__) -def setup_platform(hass, config, add_entities, discovery_info=None): - """Set up a sensor for a TotalConnect device.""" - if discovery_info is None: - return - +async def async_setup_entry(hass, entry, async_add_entities) -> None: + """Set up TotalConnect device sensors based on a config entry.""" sensors = [] - client_locations = hass.data[TOTALCONNECT_DOMAIN].client.locations + client_locations = hass.data[DOMAIN][entry.entry_id].locations for location_id, location in client_locations.items(): for zone_id, zone in location.zones.items(): sensors.append(TotalConnectBinarySensor(zone_id, location_id, zone)) - add_entities(sensors, True) + + async_add_entities(sensors, True) class TotalConnectBinarySensor(BinarySensorDevice): diff --git a/homeassistant/components/totalconnect/config_flow.py b/homeassistant/components/totalconnect/config_flow.py new file mode 100644 index 00000000000..03ddd0a432a --- /dev/null +++ b/homeassistant/components/totalconnect/config_flow.py @@ -0,0 +1,60 @@ +"""Config flow for the Total Connect component.""" +import logging + +from total_connect_client import TotalConnectClient +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME + +from .const import DOMAIN # pylint: disable=unused-import + +_LOGGER = logging.getLogger(__name__) + + +class TotalConnectConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Total Connect config flow.""" + + VERSION = 1 + + async def async_step_user(self, user_input=None): + """Handle a flow initiated by the user.""" + errors = {} + + if user_input is not None: + # Validate user input + username = user_input[CONF_USERNAME] + password = user_input[CONF_PASSWORD] + + await self.async_set_unique_id(username) + self._abort_if_unique_id_configured() + + valid = await self.is_valid(username, password) + + if valid: + # authentication success / valid + return self.async_create_entry( + title="Total Connect", + data={CONF_USERNAME: username, CONF_PASSWORD: password}, + ) + # authentication failed / invalid + errors["base"] = "login" + + data_schema = vol.Schema( + {vol.Required(CONF_USERNAME): str, vol.Required(CONF_PASSWORD): str} + ) + + return self.async_show_form( + step_id="user", data_schema=data_schema, errors=errors + ) + + async def async_step_import(self, user_input): + """Import a config entry.""" + return await self.async_step_user(user_input) + + async def is_valid(self, username="", password=""): + """Return true if the given username and password are valid.""" + client = await self.hass.async_add_executor_job( + TotalConnectClient.TotalConnectClient, username, password + ) + return client.is_valid_credentials() diff --git a/homeassistant/components/totalconnect/manifest.json b/homeassistant/components/totalconnect/manifest.json index bd60e1331f4..fc19c889d8b 100644 --- a/homeassistant/components/totalconnect/manifest.json +++ b/homeassistant/components/totalconnect/manifest.json @@ -3,5 +3,7 @@ "name": "Honeywell Total Connect Alarm", "documentation": "https://www.home-assistant.io/integrations/totalconnect", "requirements": ["total_connect_client==0.54.1"], - "codeowners": ["@austinmroczek"] + "dependencies": [], + "codeowners": ["@austinmroczek"], + "config_flow": true } diff --git a/homeassistant/components/totalconnect/strings.json b/homeassistant/components/totalconnect/strings.json new file mode 100644 index 00000000000..893aba77368 --- /dev/null +++ b/homeassistant/components/totalconnect/strings.json @@ -0,0 +1,20 @@ +{ + "config": { + "title": "Total Connect", + "step": { + "user": { + "title": "Total Connect", + "data": { + "username": "Username", + "password": "Password" + } + } + }, + "error": { + "login": "Login error: please check your username & password" + }, + "abort": { + "already_configured": "Account already configured" + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index a7dbd486089..569457d291c 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -117,6 +117,7 @@ FLOWS = [ "tellduslive", "tesla", "toon", + "totalconnect", "tplink", "traccar", "tradfri", diff --git a/requirements_test_all.txt b/requirements_test_all.txt index e93f9ea8b4c..5c9c69c3efb 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -761,6 +761,9 @@ teslajsonpy==0.6.0 # homeassistant.components.toon toonapilib==3.2.4 +# homeassistant.components.totalconnect +total_connect_client==0.54.1 + # homeassistant.components.transmission transmissionrpc==0.11 diff --git a/tests/components/totalconnect/__init__.py b/tests/components/totalconnect/__init__.py new file mode 100644 index 00000000000..180a00188cd --- /dev/null +++ b/tests/components/totalconnect/__init__.py @@ -0,0 +1 @@ +"""Tests for the totalconnect component.""" diff --git a/tests/components/totalconnect/test_config_flow.py b/tests/components/totalconnect/test_config_flow.py new file mode 100644 index 00000000000..b77198fa9b2 --- /dev/null +++ b/tests/components/totalconnect/test_config_flow.py @@ -0,0 +1,104 @@ +"""Tests for the iCloud config flow.""" +from unittest.mock import patch + +from homeassistant import data_entry_flow +from homeassistant.components.totalconnect.const import DOMAIN +from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_USER +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME + +from tests.common import MockConfigEntry + +USERNAME = "username@me.com" +PASSWORD = "password" + + +async def test_user(hass): + """Test user config.""" + # no data provided so show the form + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "user" + + # now data is provided, so check if login is correct and create the entry + with patch( + "homeassistant.components.totalconnect.config_flow.TotalConnectClient.TotalConnectClient" + ) as client_mock: + client_mock.return_value.is_valid_credentials.return_value = True + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data={CONF_USERNAME: USERNAME, CONF_PASSWORD: PASSWORD}, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + + +async def test_import(hass): + """Test import step with good username and password.""" + with patch( + "homeassistant.components.totalconnect.config_flow.TotalConnectClient.TotalConnectClient" + ) as client_mock: + client_mock.return_value.is_valid_credentials.return_value = True + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data={CONF_USERNAME: USERNAME, CONF_PASSWORD: PASSWORD}, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + + +async def test_abort_if_already_setup(hass): + """Test abort if the account is already setup.""" + MockConfigEntry( + domain=DOMAIN, + data={CONF_USERNAME: USERNAME, CONF_PASSWORD: PASSWORD}, + unique_id=USERNAME, + ).add_to_hass(hass) + + # Should fail, same USERNAME (import) + with patch( + "homeassistant.components.totalconnect.config_flow.TotalConnectClient.TotalConnectClient" + ) as client_mock: + client_mock.return_value.is_valid_credentials.return_value = True + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data={CONF_USERNAME: USERNAME, CONF_PASSWORD: PASSWORD}, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "already_configured" + + # Should fail, same USERNAME (flow) + with patch( + "homeassistant.components.totalconnect.config_flow.TotalConnectClient.TotalConnectClient" + ) as client_mock: + client_mock.return_value.is_valid_credentials.return_value = True + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data={CONF_USERNAME: USERNAME, CONF_PASSWORD: PASSWORD}, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "already_configured" + + +async def test_login_failed(hass): + """Test when we have errors during login.""" + with patch( + "homeassistant.components.totalconnect.config_flow.TotalConnectClient.TotalConnectClient" + ) as client_mock: + client_mock.return_value.is_valid_credentials.return_value = False + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data={CONF_USERNAME: USERNAME, CONF_PASSWORD: PASSWORD}, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["errors"] == {"base": "login"}