From 4f0997f6e963e09d72624c783f3d8e8297bb221d Mon Sep 17 00:00:00 2001 From: Aaron Bach Date: Thu, 12 Mar 2020 23:00:00 -0600 Subject: [PATCH] Add options flow for SimpliSafe (#32631) * Fix bug where SimpliSafe ignored code from UI * Fix tests * Add options flow * Fix tests * Code review * Code review * Code review --- .../simplisafe/.translations/en.json | 11 +- .../components/simplisafe/__init__.py | 26 +++- .../simplisafe/alarm_control_panel.py | 39 ++--- .../components/simplisafe/config_flow.py | 40 +++++- .../components/simplisafe/strings.json | 13 +- .../components/simplisafe/test_config_flow.py | 135 +++++++++++------- 6 files changed, 186 insertions(+), 78 deletions(-) diff --git a/homeassistant/components/simplisafe/.translations/en.json b/homeassistant/components/simplisafe/.translations/en.json index 7e9c26291f7..60c3784ee9d 100644 --- a/homeassistant/components/simplisafe/.translations/en.json +++ b/homeassistant/components/simplisafe/.translations/en.json @@ -10,7 +10,6 @@ "step": { "user": { "data": { - "code": "Code (for Home Assistant)", "password": "Password", "username": "Email Address" }, @@ -18,5 +17,15 @@ } }, "title": "SimpliSafe" + }, + "options": { + "step": { + "init": { + "data": { + "code": "Code (used in Home Assistant UI)" + }, + "title": "Configure SimpliSafe" + } + } } } \ No newline at end of file diff --git a/homeassistant/components/simplisafe/__init__.py b/homeassistant/components/simplisafe/__init__.py index 8c75ed5d9f5..9f014a44a23 100644 --- a/homeassistant/components/simplisafe/__init__.py +++ b/homeassistant/components/simplisafe/__init__.py @@ -201,10 +201,21 @@ async def async_setup(hass, config): async def async_setup_entry(hass, config_entry): """Set up SimpliSafe as config entry.""" + entry_updates = {} if not config_entry.unique_id: - hass.config_entries.async_update_entry( - config_entry, unique_id=config_entry.data[CONF_USERNAME] - ) + # If the config entry doesn't already have a unique ID, set one: + entry_updates["unique_id"] = config_entry.data[CONF_USERNAME] + if CONF_CODE in config_entry.data: + # If an alarm code was provided as part of configuration.yaml, pop it out of + # the config entry's data and move it to options: + data = {**config_entry.data} + entry_updates["data"] = data + entry_updates["options"] = { + **config_entry.options, + CONF_CODE: data.pop(CONF_CODE), + } + if entry_updates: + hass.config_entries.async_update_entry(config_entry, **entry_updates) _verify_domain_control = verify_domain_control(hass, DOMAIN) @@ -309,6 +320,8 @@ async def async_setup_entry(hass, config_entry): ]: async_register_admin_service(hass, DOMAIN, service, method, schema=schema) + config_entry.add_update_listener(async_update_options) + return True @@ -328,6 +341,12 @@ async def async_unload_entry(hass, entry): return True +async def async_update_options(hass, config_entry): + """Handle an options update.""" + simplisafe = hass.data[DOMAIN][DATA_CLIENT][config_entry.entry_id] + simplisafe.options = config_entry.options + + class SimpliSafeWebsocket: """Define a SimpliSafe websocket "manager" object.""" @@ -394,6 +413,7 @@ class SimpliSafe: self._emergency_refresh_token_used = False self._hass = hass self._system_notifications = {} + self.options = config_entry.options or {} self.initial_event_to_use = {} self.systems = {} self.websocket = SimpliSafeWebsocket(hass, api.websocket) diff --git a/homeassistant/components/simplisafe/alarm_control_panel.py b/homeassistant/components/simplisafe/alarm_control_panel.py index 9166c59bec0..4e2393bd238 100644 --- a/homeassistant/components/simplisafe/alarm_control_panel.py +++ b/homeassistant/components/simplisafe/alarm_control_panel.py @@ -67,10 +67,7 @@ async def async_setup_entry(hass, entry, async_add_entities): """Set up a SimpliSafe alarm control panel based on a config entry.""" simplisafe = hass.data[DOMAIN][DATA_CLIENT][entry.entry_id] async_add_entities( - [ - SimpliSafeAlarm(simplisafe, system, entry.data.get(CONF_CODE)) - for system in simplisafe.systems.values() - ], + [SimpliSafeAlarm(simplisafe, system) for system in simplisafe.systems.values()], True, ) @@ -78,11 +75,10 @@ async def async_setup_entry(hass, entry, async_add_entities): class SimpliSafeAlarm(SimpliSafeEntity, AlarmControlPanel): """Representation of a SimpliSafe alarm.""" - def __init__(self, simplisafe, system, code): + def __init__(self, simplisafe, system): """Initialize the SimpliSafe alarm.""" super().__init__(simplisafe, system, "Alarm Control Panel") self._changed_by = None - self._code = code self._last_event = None if system.alarm_going_off: @@ -125,9 +121,11 @@ class SimpliSafeAlarm(SimpliSafeEntity, AlarmControlPanel): @property def code_format(self): """Return one or more digits/characters.""" - if not self._code: + if not self._simplisafe.options.get(CONF_CODE): return None - if isinstance(self._code, str) and re.search("^\\d+$", self._code): + if isinstance(self._simplisafe.options[CONF_CODE], str) and re.search( + "^\\d+$", self._simplisafe.options[CONF_CODE] + ): return FORMAT_NUMBER return FORMAT_TEXT @@ -141,16 +139,23 @@ class SimpliSafeAlarm(SimpliSafeEntity, AlarmControlPanel): """Return the list of supported features.""" return SUPPORT_ALARM_ARM_HOME | SUPPORT_ALARM_ARM_AWAY - def _validate_code(self, code, state): - """Validate given code.""" - check = self._code is None or code == self._code - if not check: - _LOGGER.warning("Wrong code entered for %s", state) - return check + @callback + def _is_code_valid(self, code, state): + """Validate that a code matches the required one.""" + if not self._simplisafe.options.get(CONF_CODE): + return True + + if not code or code != self._simplisafe.options[CONF_CODE]: + _LOGGER.warning( + "Incorrect alarm code entered (target state: %s): %s", state, code + ) + return False + + return True async def async_alarm_disarm(self, code=None): """Send disarm command.""" - if not self._validate_code(code, "disarming"): + if not self._is_code_valid(code, STATE_ALARM_DISARMED): return try: @@ -163,7 +168,7 @@ class SimpliSafeAlarm(SimpliSafeEntity, AlarmControlPanel): async def async_alarm_arm_home(self, code=None): """Send arm home command.""" - if not self._validate_code(code, "arming home"): + if not self._is_code_valid(code, STATE_ALARM_ARMED_HOME): return try: @@ -176,7 +181,7 @@ class SimpliSafeAlarm(SimpliSafeEntity, AlarmControlPanel): async def async_alarm_arm_away(self, code=None): """Send arm away command.""" - if not self._validate_code(code, "arming away"): + if not self._is_code_valid(code, STATE_ALARM_ARMED_AWAY): return try: diff --git a/homeassistant/components/simplisafe/config_flow.py b/homeassistant/components/simplisafe/config_flow.py index 4963f9d2de1..031d5496f9d 100644 --- a/homeassistant/components/simplisafe/config_flow.py +++ b/homeassistant/components/simplisafe/config_flow.py @@ -5,6 +5,7 @@ import voluptuous as vol from homeassistant import config_entries from homeassistant.const import CONF_CODE, CONF_PASSWORD, CONF_TOKEN, CONF_USERNAME +from homeassistant.core import callback from homeassistant.helpers import aiohttp_client from .const import DOMAIN # pylint: disable=unused-import @@ -34,6 +35,12 @@ class SimpliSafeFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): errors=errors if errors else {}, ) + @staticmethod + @callback + def async_get_options_flow(config_entry): + """Define the config flow to handle options.""" + return SimpliSafeOptionsFlowHandler(config_entry) + async def async_step_import(self, import_config): """Import a config entry from configuration.yaml.""" return await self.async_step_user(import_config) @@ -46,17 +53,44 @@ class SimpliSafeFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): await self.async_set_unique_id(user_input[CONF_USERNAME]) self._abort_if_unique_id_configured() - username = user_input[CONF_USERNAME] websession = aiohttp_client.async_get_clientsession(self.hass) try: simplisafe = await API.login_via_credentials( - username, user_input[CONF_PASSWORD], websession + user_input[CONF_USERNAME], user_input[CONF_PASSWORD], websession ) except SimplipyError: return await self._show_form(errors={"base": "invalid_credentials"}) return self.async_create_entry( title=user_input[CONF_USERNAME], - data={CONF_USERNAME: username, CONF_TOKEN: simplisafe.refresh_token}, + data={ + CONF_USERNAME: user_input[CONF_USERNAME], + CONF_TOKEN: simplisafe.refresh_token, + CONF_CODE: user_input.get(CONF_CODE), + }, + ) + + +class SimpliSafeOptionsFlowHandler(config_entries.OptionsFlow): + """Handle a SimpliSafe options flow.""" + + def __init__(self, config_entry): + """Initialize.""" + self.config_entry = config_entry + + async def async_step_init(self, user_input=None): + """Manage the options.""" + if user_input is not None: + return self.async_create_entry(title="", data=user_input) + + return self.async_show_form( + step_id="init", + data_schema=vol.Schema( + { + vol.Optional( + CONF_CODE, default=self.config_entry.options.get(CONF_CODE), + ): str + } + ), ) diff --git a/homeassistant/components/simplisafe/strings.json b/homeassistant/components/simplisafe/strings.json index 3043bd79104..1c8aadc2192 100644 --- a/homeassistant/components/simplisafe/strings.json +++ b/homeassistant/components/simplisafe/strings.json @@ -6,8 +6,7 @@ "title": "Fill in your information", "data": { "username": "Email Address", - "password": "Password", - "code": "Code (for Home Assistant)" + "password": "Password" } } }, @@ -18,5 +17,15 @@ "abort": { "already_configured": "This SimpliSafe account is already in use." } + }, + "options": { + "step": { + "init": { + "title": "Configure SimpliSafe", + "data": { + "code": "Code (used in Home Assistant UI)" + } + } + } } } diff --git a/tests/components/simplisafe/test_config_flow.py b/tests/components/simplisafe/test_config_flow.py index 496c6d88954..f53636fc440 100644 --- a/tests/components/simplisafe/test_config_flow.py +++ b/tests/components/simplisafe/test_config_flow.py @@ -1,15 +1,16 @@ """Define tests for the SimpliSafe config flow.""" import json -from unittest.mock import MagicMock, PropertyMock, mock_open, patch +from unittest.mock import MagicMock, PropertyMock, mock_open +from asynctest import patch from simplipy.errors import SimplipyError from homeassistant import data_entry_flow -from homeassistant.components.simplisafe import DOMAIN, config_flow -from homeassistant.config_entries import SOURCE_USER -from homeassistant.const import CONF_PASSWORD, CONF_TOKEN, CONF_USERNAME +from homeassistant.components.simplisafe import DOMAIN +from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_USER +from homeassistant.const import CONF_CODE, CONF_PASSWORD, CONF_TOKEN, CONF_USERNAME -from tests.common import MockConfigEntry, mock_coro +from tests.common import MockConfigEntry def mock_api(): @@ -39,55 +40,83 @@ async def test_invalid_credentials(hass): """Test that invalid credentials throws an error.""" conf = {CONF_USERNAME: "user@email.com", CONF_PASSWORD: "password"} - flow = config_flow.SimpliSafeFlowHandler() - flow.hass = hass - flow.context = {"source": SOURCE_USER} + with patch( + "simplipy.API.login_via_credentials", side_effect=SimplipyError, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER}, data=conf + ) + assert result["errors"] == {"base": "invalid_credentials"} + + +async def test_options_flow(hass): + """Test config flow options.""" + conf = {CONF_USERNAME: "user@email.com", CONF_PASSWORD: "password"} + + config_entry = MockConfigEntry( + domain=DOMAIN, unique_id="abcde12345", data=conf, options={CONF_CODE: "1234"}, + ) + config_entry.add_to_hass(hass) with patch( - "simplipy.API.login_via_credentials", - return_value=mock_coro(exception=SimplipyError), + "homeassistant.components.simplisafe.async_setup_entry", return_value=True ): - result = await flow.async_step_user(user_input=conf) - assert result["errors"] == {"base": "invalid_credentials"} + result = await hass.config_entries.options.async_init(config_entry.entry_id) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "init" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], user_input={CONF_CODE: "4321"} + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert config_entry.options == {CONF_CODE: "4321"} async def test_show_form(hass): """Test that the form is served with no input.""" - flow = config_flow.SimpliSafeFlowHandler() - flow.hass = hass - flow.context = {"source": SOURCE_USER} + with patch( + "homeassistant.components.simplisafe.async_setup_entry", return_value=True + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_IMPORT} + ) - result = await flow.async_step_user(user_input=None) - - assert result["type"] == data_entry_flow.RESULT_TYPE_FORM - assert result["step_id"] == "user" + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "user" async def test_step_import(hass): """Test that the import step works.""" - conf = {CONF_USERNAME: "user@email.com", CONF_PASSWORD: "password"} - - flow = config_flow.SimpliSafeFlowHandler() - flow.hass = hass - flow.context = {"source": SOURCE_USER} + conf = { + CONF_USERNAME: "user@email.com", + CONF_PASSWORD: "password", + CONF_CODE: "1234", + } mop = mock_open(read_data=json.dumps({"refresh_token": "12345"})) with patch( - "simplipy.API.login_via_credentials", - return_value=mock_coro(return_value=mock_api()), + "homeassistant.components.simplisafe.async_setup_entry", return_value=True + ), patch("simplipy.API.login_via_credentials", return_value=mock_api()), patch( + "homeassistant.util.json.open", mop, create=True + ), patch( + "homeassistant.util.json.os.open", return_value=0 + ), patch( + "homeassistant.util.json.os.replace" ): - with patch("homeassistant.util.json.open", mop, create=True): - with patch("homeassistant.util.json.os.open", return_value=0): - with patch("homeassistant.util.json.os.replace"): - result = await flow.async_step_import(import_config=conf) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER}, data=conf + ) - assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY - assert result["title"] == "user@email.com" - assert result["data"] == { - CONF_USERNAME: "user@email.com", - CONF_TOKEN: "12345abc", - } + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["title"] == "user@email.com" + assert result["data"] == { + CONF_USERNAME: "user@email.com", + CONF_TOKEN: "12345abc", + CONF_CODE: "1234", + } async def test_step_user(hass): @@ -95,26 +124,28 @@ async def test_step_user(hass): conf = { CONF_USERNAME: "user@email.com", CONF_PASSWORD: "password", + CONF_CODE: "1234", } - flow = config_flow.SimpliSafeFlowHandler() - flow.hass = hass - flow.context = {"source": SOURCE_USER} - mop = mock_open(read_data=json.dumps({"refresh_token": "12345"})) with patch( - "simplipy.API.login_via_credentials", - return_value=mock_coro(return_value=mock_api()), + "homeassistant.components.simplisafe.async_setup_entry", return_value=True + ), patch("simplipy.API.login_via_credentials", return_value=mock_api()), patch( + "homeassistant.util.json.open", mop, create=True + ), patch( + "homeassistant.util.json.os.open", return_value=0 + ), patch( + "homeassistant.util.json.os.replace" ): - with patch("homeassistant.util.json.open", mop, create=True): - with patch("homeassistant.util.json.os.open", return_value=0): - with patch("homeassistant.util.json.os.replace"): - result = await flow.async_step_user(user_input=conf) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER}, data=conf + ) - assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY - assert result["title"] == "user@email.com" - assert result["data"] == { - CONF_USERNAME: "user@email.com", - CONF_TOKEN: "12345abc", - } + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["title"] == "user@email.com" + assert result["data"] == { + CONF_USERNAME: "user@email.com", + CONF_TOKEN: "12345abc", + CONF_CODE: "1234", + }