From f0d5ae2fec475cc28d102efab2834965bc8ecb44 Mon Sep 17 00:00:00 2001 From: G Johansson <62932417+gjohansson-ST@users.noreply.github.com> Date: Sat, 24 Jul 2021 19:55:43 +0200 Subject: [PATCH] Add yale_smart_alarm config flow and coordinator (#50850) * config flow and coordinator * comply with pylint * Remove pylint errors * Update test coverage yale smart alarm * Update test config_flow * Fix test already configured * Second try test already configured * Fixes config flow and tests * Conform pylint errors coordinator * Fix various review remarks * Correct entity unique id * Fix unique id and migrate entries * Remove lock code * Remove code from test * Expand code coverage config flow test * Add more constants * Add test new requirements * Minor corrections * Resolve conflict alarm schema * Change logger * Changed from review * Fix isort error * Fix flake error * Ignore mypy errors * Corrections from PR review no 2 * Corrections from PR review no 3 * Added tests and fix pylint error * Corrections from PR review no 4 * Corrections from PR review no 5 * Corrections from PR review no 6 * Corrections from PR review no 6_2 * Corrections from PR review no 7 * Corrections from PR review no 8 * Minor last changes for PR * Update homeassistant/components/yale_smart_alarm/coordinator.py Co-authored-by: Raman Gupta <7243222+raman325@users.noreply.github.com> --- .coveragerc | 3 + .../components/yale_smart_alarm/__init__.py | 45 ++++ .../yale_smart_alarm/alarm_control_panel.py | 138 ++++++----- .../yale_smart_alarm/config_flow.py | 129 ++++++++++ .../components/yale_smart_alarm/const.py | 39 +++ .../yale_smart_alarm/coordinator.py | 139 +++++++++++ .../components/yale_smart_alarm/manifest.json | 1 + .../components/yale_smart_alarm/strings.json | 28 +++ .../yale_smart_alarm/translations/en.json | 28 +++ homeassistant/generated/config_flows.py | 1 + requirements_test_all.txt | 3 + tests/components/yale_smart_alarm/__init__.py | 1 + .../yale_smart_alarm/test_config_flow.py | 224 ++++++++++++++++++ 13 files changed, 722 insertions(+), 57 deletions(-) create mode 100644 homeassistant/components/yale_smart_alarm/config_flow.py create mode 100644 homeassistant/components/yale_smart_alarm/const.py create mode 100644 homeassistant/components/yale_smart_alarm/coordinator.py create mode 100644 homeassistant/components/yale_smart_alarm/strings.json create mode 100644 homeassistant/components/yale_smart_alarm/translations/en.json create mode 100644 tests/components/yale_smart_alarm/__init__.py create mode 100644 tests/components/yale_smart_alarm/test_config_flow.py diff --git a/.coveragerc b/.coveragerc index 4be3d2e5f01..759e2251988 100644 --- a/.coveragerc +++ b/.coveragerc @@ -1214,7 +1214,10 @@ omit = homeassistant/components/xiaomi_tv/media_player.py homeassistant/components/xmpp/notify.py homeassistant/components/xs1/* + homeassistant/components/yale_smart_alarm/__init__.py homeassistant/components/yale_smart_alarm/alarm_control_panel.py + homeassistant/components/yale_smart_alarm/const.py + homeassistant/components/yale_smart_alarm/coordinator.py homeassistant/components/yamaha_musiccast/__init__.py homeassistant/components/yamaha_musiccast/media_player.py homeassistant/components/yandex_transport/* diff --git a/homeassistant/components/yale_smart_alarm/__init__.py b/homeassistant/components/yale_smart_alarm/__init__.py index 2ce2fb13495..87bfb2b86d7 100644 --- a/homeassistant/components/yale_smart_alarm/__init__.py +++ b/homeassistant/components/yale_smart_alarm/__init__.py @@ -1 +1,46 @@ """The yale_smart_alarm component.""" +from __future__ import annotations + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed + +from .const import DOMAIN, LOGGER, PLATFORMS +from .coordinator import YaleDataUpdateCoordinator + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Yale from a config entry.""" + hass.data.setdefault(DOMAIN, {}) + title = entry.title + + coordinator = YaleDataUpdateCoordinator(hass, entry=entry) + + if not await hass.async_add_executor_job(coordinator.get_updates): + raise ConfigEntryAuthFailed + + await coordinator.async_config_entry_first_refresh() + + hass.data[DOMAIN][entry.entry_id] = { + "coordinator": coordinator, + } + + hass.config_entries.async_setup_platforms(entry, PLATFORMS) + + LOGGER.debug("Loaded entry for %s", title) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + + unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + + title = entry.title + if unload_ok: + hass.data[DOMAIN].pop(entry.entry_id) + LOGGER.debug("Unloaded entry for %s", title) + return unload_ok + + return False diff --git a/homeassistant/components/yale_smart_alarm/alarm_control_panel.py b/homeassistant/components/yale_smart_alarm/alarm_control_panel.py index 13433086879..f450895f5c3 100644 --- a/homeassistant/components/yale_smart_alarm/alarm_control_panel.py +++ b/homeassistant/components/yale_smart_alarm/alarm_control_panel.py @@ -1,14 +1,7 @@ -"""Component for interacting with the Yale Smart Alarm System API.""" -import logging +"""Support for Yale Alarm.""" +from __future__ import annotations import voluptuous as vol -from yalesmartalarmclient.client import ( - YALE_STATE_ARM_FULL, - YALE_STATE_ARM_PARTIAL, - YALE_STATE_DISARM, - AuthenticationError, - YaleSmartAlarmClient, -) from homeassistant.components.alarm_control_panel import ( PLATFORM_SCHEMA as PARENT_PLATFORM_SCHEMA, @@ -18,23 +11,38 @@ from homeassistant.components.alarm_control_panel.const import ( SUPPORT_ALARM_ARM_AWAY, SUPPORT_ALARM_ARM_HOME, ) +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import ( + ATTR_IDENTIFIERS, + ATTR_MANUFACTURER, + ATTR_MODEL, + ATTR_NAME, CONF_NAME, CONF_PASSWORD, CONF_USERNAME, - STATE_ALARM_ARMED_AWAY, - STATE_ALARM_ARMED_HOME, - STATE_ALARM_DISARMED, ) +from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.entity_platform import ( + AddEntitiesCallback, + ConfigType, + DiscoveryInfoType, +) +from homeassistant.helpers.update_coordinator import CoordinatorEntity -CONF_AREA_ID = "area_id" - -DEFAULT_NAME = "Yale Smart Alarm" - -DEFAULT_AREA_ID = "1" - -_LOGGER = logging.getLogger(__name__) +from .const import ( + CONF_AREA_ID, + COORDINATOR, + DEFAULT_AREA_ID, + DEFAULT_NAME, + DOMAIN, + LOGGER, + MANUFACTURER, + MODEL, + STATE_MAP, +) +from .coordinator import YaleDataUpdateCoordinator PLATFORM_SCHEMA = PARENT_PLATFORM_SCHEMA.extend( { @@ -46,66 +54,82 @@ PLATFORM_SCHEMA = PARENT_PLATFORM_SCHEMA.extend( ) -def setup_platform(hass, config, add_entities, discovery_info=None): - """Set up the alarm platform.""" - name = config[CONF_NAME] - username = config[CONF_USERNAME] - password = config[CONF_PASSWORD] - area_id = config[CONF_AREA_ID] - - try: - client = YaleSmartAlarmClient(username, password, area_id) - except AuthenticationError: - _LOGGER.error("Authentication failed. Check credentials") - return - - add_entities([YaleAlarmDevice(name, client)], True) +async def async_setup_platform( + hass: HomeAssistant, + config: ConfigType, + async_add_entities: AddEntitiesCallback, + discovery_info: DiscoveryInfoType | None = None, +) -> None: + """Import Yale configuration from YAML.""" + LOGGER.warning( + "Loading Yale Alarm via platform setup is deprecated; Please remove it from your configuration" + ) + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data=config, + ) + ) -class YaleAlarmDevice(AlarmControlPanelEntity): +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up the alarm entry.""" + + async_add_entities( + [YaleAlarmDevice(coordinator=hass.data[DOMAIN][entry.entry_id][COORDINATOR])] + ) + + +class YaleAlarmDevice(CoordinatorEntity, AlarmControlPanelEntity): """Represent a Yale Smart Alarm.""" - def __init__(self, name, client): - """Initialize the Yale Alarm Device.""" - self._name = name - self._client = client - self._state = None + coordinator: YaleDataUpdateCoordinator - self._state_map = { - YALE_STATE_DISARM: STATE_ALARM_DISARMED, - YALE_STATE_ARM_PARTIAL: STATE_ALARM_ARMED_HOME, - YALE_STATE_ARM_FULL: STATE_ALARM_ARMED_AWAY, - } + _attr_name: str = coordinator.entry.data[CONF_NAME] + _attr_unique_id: str = coordinator.entry.entry_id + _identifier: str = coordinator.entry.data[CONF_USERNAME] @property - def name(self): - """Return the name of the device.""" - return self._name + def device_info(self) -> DeviceInfo: + """Return device information about this entity.""" + return { + ATTR_NAME: str(self.name), + ATTR_MANUFACTURER: MANUFACTURER, + ATTR_MODEL: MODEL, + ATTR_IDENTIFIERS: {(DOMAIN, self._identifier)}, + } @property def state(self): """Return the state of the device.""" - return self._state + return STATE_MAP.get(self.coordinator.data["alarm"]) + + @property + def available(self): + """Return if entity is available.""" + return STATE_MAP.get(self.coordinator.data["alarm"]) is not None + + @property + def code_arm_required(self): + """Whether the code is required for arm actions.""" + return False @property def supported_features(self) -> int: """Return the list of supported features.""" return SUPPORT_ALARM_ARM_HOME | SUPPORT_ALARM_ARM_AWAY - def update(self): - """Return the state of the device.""" - armed_status = self._client.get_armed_status() - - self._state = self._state_map.get(armed_status) - def alarm_disarm(self, code=None): """Send disarm command.""" - self._client.disarm() + self.coordinator.yale.disarm() def alarm_arm_home(self, code=None): """Send arm home command.""" - self._client.arm_partial() + self.coordinator.yale.arm_partial() def alarm_arm_away(self, code=None): """Send arm away command.""" - self._client.arm_full() + self.coordinator.yale.arm_full() diff --git a/homeassistant/components/yale_smart_alarm/config_flow.py b/homeassistant/components/yale_smart_alarm/config_flow.py new file mode 100644 index 00000000000..828d308b0a0 --- /dev/null +++ b/homeassistant/components/yale_smart_alarm/config_flow.py @@ -0,0 +1,129 @@ +"""Adds config flow for Yale Smart Alarm integration.""" +from __future__ import annotations + +import voluptuous as vol +from yalesmartalarmclient.client import AuthenticationError, YaleSmartAlarmClient + +from homeassistant import config_entries +from homeassistant.const import CONF_NAME, CONF_PASSWORD, CONF_USERNAME +import homeassistant.helpers.config_validation as cv + +from .const import CONF_AREA_ID, DEFAULT_AREA_ID, DEFAULT_NAME, DOMAIN, LOGGER + +DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_USERNAME): cv.string, + vol.Required(CONF_PASSWORD): cv.string, + vol.Required(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Required(CONF_AREA_ID, default=DEFAULT_AREA_ID): cv.string, + } +) + +DATA_SCHEMA_AUTH = vol.Schema( + { + vol.Required(CONF_USERNAME): cv.string, + vol.Required(CONF_PASSWORD): cv.string, + } +) + + +class YaleConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for Yale integration.""" + + VERSION = 1 + CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL + + entry: config_entries.ConfigEntry + + async def async_step_import(self, config: dict): + """Import a configuration from config.yaml.""" + + self.context.update( + {"title_placeholders": {CONF_NAME: f"YAML import {DOMAIN}"}} + ) + return await self.async_step_user(user_input=config) + + async def async_step_reauth(self, user_input=None): + """Handle initiation of re-authentication with Yale.""" + self.entry = self.hass.config_entries.async_get_entry(self.context["entry_id"]) + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm(self, user_input=None): + """Dialog that informs the user that reauth is required.""" + errors = {} + + if user_input is not None: + username = user_input[CONF_USERNAME] + password = user_input[CONF_PASSWORD] + + try: + await self.hass.async_add_executor_job( + YaleSmartAlarmClient, username, password + ) + except AuthenticationError as error: + LOGGER.error("Authentication failed. Check credentials %s", error) + return self.async_show_form( + step_id="reauth_confirm", + data_schema=DATA_SCHEMA, + errors={"base": "invalid_auth"}, + ) + + existing_entry = await self.async_set_unique_id(username) + if existing_entry: + self.hass.config_entries.async_update_entry( + existing_entry, + data={ + **self.entry.data, + CONF_USERNAME: username, + CONF_PASSWORD: password, + }, + ) + await self.hass.config_entries.async_reload(existing_entry.entry_id) + return self.async_abort(reason="reauth_successful") + + return self.async_show_form( + step_id="reauth_confirm", + data_schema=DATA_SCHEMA_AUTH, + errors=errors, + ) + + async def async_step_user(self, user_input=None): + """Handle the initial step.""" + errors = {} + + if user_input is not None: + username = user_input[CONF_USERNAME] + password = user_input[CONF_PASSWORD] + name = user_input.get(CONF_NAME, DEFAULT_NAME) + area = user_input.get(CONF_AREA_ID, DEFAULT_AREA_ID) + + try: + await self.hass.async_add_executor_job( + YaleSmartAlarmClient, username, password + ) + except AuthenticationError as error: + LOGGER.error("Authentication failed. Check credentials %s", error) + return self.async_show_form( + step_id="user", + data_schema=DATA_SCHEMA, + errors={"base": "invalid_auth"}, + ) + + await self.async_set_unique_id(username) + self._abort_if_unique_id_configured() + + return self.async_create_entry( + title=username, + data={ + CONF_USERNAME: username, + CONF_PASSWORD: password, + CONF_NAME: name, + CONF_AREA_ID: area, + }, + ) + + return self.async_show_form( + step_id="user", + data_schema=DATA_SCHEMA, + errors=errors, + ) diff --git a/homeassistant/components/yale_smart_alarm/const.py b/homeassistant/components/yale_smart_alarm/const.py new file mode 100644 index 00000000000..618f9ad073a --- /dev/null +++ b/homeassistant/components/yale_smart_alarm/const.py @@ -0,0 +1,39 @@ +"""Yale integration constants.""" +import logging + +from yalesmartalarmclient.client import ( + YALE_STATE_ARM_FULL, + YALE_STATE_ARM_PARTIAL, + YALE_STATE_DISARM, +) + +from homeassistant.const import ( + STATE_ALARM_ARMED_AWAY, + STATE_ALARM_ARMED_HOME, + STATE_ALARM_DISARMED, +) + +CONF_AREA_ID = "area_id" +DEFAULT_NAME = "Yale Smart Alarm" +DEFAULT_AREA_ID = "1" + +MANUFACTURER = "Yale" +MODEL = "main" + +DOMAIN = "yale_smart_alarm" +COORDINATOR = "coordinator" + +DEFAULT_SCAN_INTERVAL = 15 + +LOGGER = logging.getLogger(__name__) + +ATTR_ONLINE = "online" +ATTR_STATUS = "status" + +PLATFORMS = ["alarm_control_panel"] + +STATE_MAP = { + YALE_STATE_DISARM: STATE_ALARM_DISARMED, + YALE_STATE_ARM_PARTIAL: STATE_ALARM_ARMED_HOME, + YALE_STATE_ARM_FULL: STATE_ALARM_ARMED_AWAY, +} diff --git a/homeassistant/components/yale_smart_alarm/coordinator.py b/homeassistant/components/yale_smart_alarm/coordinator.py new file mode 100644 index 00000000000..1016f8f4d9d --- /dev/null +++ b/homeassistant/components/yale_smart_alarm/coordinator.py @@ -0,0 +1,139 @@ +"""DataUpdateCoordinator for the Yale integration.""" +from __future__ import annotations + +from datetime import timedelta + +from yalesmartalarmclient.client import AuthenticationError, YaleSmartAlarmClient + +from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntry +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DEFAULT_SCAN_INTERVAL, DOMAIN, LOGGER + + +class YaleDataUpdateCoordinator(DataUpdateCoordinator): + """A Yale Data Update Coordinator.""" + + def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: + """Initialize the Yale hub.""" + self.entry = entry + self.yale: YaleSmartAlarmClient | None = None + super().__init__( + hass, + LOGGER, + name=DOMAIN, + update_interval=timedelta(seconds=DEFAULT_SCAN_INTERVAL), + ) + + async def _async_update_data(self) -> dict: + """Fetch data from Yale.""" + + updates = await self.hass.async_add_executor_job(self.get_updates) + + locks = [] + door_windows = [] + + for device in updates["cycle"]["data"]["device_status"]: + state = device["status1"] + if device["type"] == "device_type.door_lock": + lock_status_str = device["minigw_lock_status"] + lock_status = int(str(lock_status_str or 0), 16) + closed = (lock_status & 16) == 16 + locked = (lock_status & 1) == 1 + if not lock_status and "device_status.lock" in state: + device["_state"] = "locked" + locks.append(device) + continue + if not lock_status and "device_status.unlock" in state: + device["_state"] = "unlocked" + locks.append(device) + continue + if ( + lock_status + and ( + "device_status.lock" in state or "device_status.unlock" in state + ) + and closed + and locked + ): + device["_state"] = "locked" + locks.append(device) + continue + if ( + lock_status + and ( + "device_status.lock" in state or "device_status.unlock" in state + ) + and closed + and not locked + ): + device["_state"] = "unlocked" + locks.append(device) + continue + if ( + lock_status + and ( + "device_status.lock" in state or "device_status.unlock" in state + ) + and not closed + ): + device["_state"] = "unlocked" + locks.append(device) + continue + device["_state"] = "unavailable" + locks.append(device) + continue + if device["type"] == "device_type.door_contact": + if "device_status.dc_close" in state: + device["_state"] = "closed" + door_windows.append(device) + continue + if "device_status.dc_open" in state: + device["_state"] = "open" + door_windows.append(device) + continue + device["_state"] = "unavailable" + door_windows.append(device) + continue + + return { + "alarm": updates["arm_status"], + "locks": locks, + "door_windows": door_windows, + "status": updates["status"], + "online": updates["online"], + } + + def get_updates(self) -> dict: + """Fetch data from Yale.""" + + if self.yale is None: + self.yale = YaleSmartAlarmClient( + self.entry.data[CONF_USERNAME], self.entry.data[CONF_PASSWORD] + ) + + try: + arm_status = self.yale.get_armed_status() + cycle = self.yale.get_cycle() + status = self.yale.get_status() + online = self.yale.get_online() + + except AuthenticationError as error: + LOGGER.error("Authentication failed. Check credentials %s", error) + self.hass.async_create_task( + self.hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_REAUTH, "entry_id": self.entry.entry_id}, + data=self.entry.data, + ) + ) + raise UpdateFailed from error + + return { + "arm_status": arm_status, + "cycle": cycle, + "status": status, + "online": online, + } diff --git a/homeassistant/components/yale_smart_alarm/manifest.json b/homeassistant/components/yale_smart_alarm/manifest.json index e900f4e0373..83c380881ef 100644 --- a/homeassistant/components/yale_smart_alarm/manifest.json +++ b/homeassistant/components/yale_smart_alarm/manifest.json @@ -4,5 +4,6 @@ "documentation": "https://www.home-assistant.io/integrations/yale_smart_alarm", "requirements": ["yalesmartalarmclient==0.3.3"], "codeowners": ["@gjohansson-ST"], + "config_flow": true, "iot_class": "cloud_polling" } diff --git a/homeassistant/components/yale_smart_alarm/strings.json b/homeassistant/components/yale_smart_alarm/strings.json new file mode 100644 index 00000000000..14bb48f8176 --- /dev/null +++ b/homeassistant/components/yale_smart_alarm/strings.json @@ -0,0 +1,28 @@ +{ + "config": { + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_account%]" + }, + "error": { + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]" + }, + "step": { + "user": { + "data": { + "username": "[%key:common::config_flow::data::username%]", + "password": "[%key:common::config_flow::data::password%]", + "name": "[%key:component::yale_smart_alarm::config::step::user::data::name%]", + "area_id": "[%key:component::yale_smart_alarm::config::step::user::data::area_id%]" + } + }, + "reauth_confirm": { + "data": { + "username": "[%key:common::config_flow::data::username%]", + "password": "[%key:common::config_flow::data::password%]", + "name": "[%key:component::yale_smart_alarm::config::step::user::data::name%]", + "area_id": "[%key:component::yale_smart_alarm::config::step::user::data::area_id%]" + } + } + } + } +} diff --git a/homeassistant/components/yale_smart_alarm/translations/en.json b/homeassistant/components/yale_smart_alarm/translations/en.json new file mode 100644 index 00000000000..0c653f84e7d --- /dev/null +++ b/homeassistant/components/yale_smart_alarm/translations/en.json @@ -0,0 +1,28 @@ +{ + "config": { + "abort": { + "already_configured": "Connection already configured for this account" + }, + "error": { + "invalid_auth": "Authentication error" + }, + "step": { + "user": { + "data": { + "username": "Username", + "password": "Password", + "name": "Name of alarm", + "area_id": "Area ID" + } + }, + "reauth_confirm": { + "data": { + "username": "Usernamn", + "password": "Password", + "name": "Name of alarm", + "area_id": "Area ID" + } + } + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 0e7b6c52cc2..270304cb623 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -300,6 +300,7 @@ FLOWS = [ "xbox", "xiaomi_aqara", "xiaomi_miio", + "yale_smart_alarm", "yamaha_musiccast", "yeelight", "zerproc", diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 84b9e458b92..516e9910ae2 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1324,6 +1324,9 @@ xknx==0.18.8 # homeassistant.components.zestimate xmltodict==0.12.0 +# homeassistant.components.yale_smart_alarm +yalesmartalarmclient==0.3.3 + # homeassistant.components.august yalexs==1.1.12 diff --git a/tests/components/yale_smart_alarm/__init__.py b/tests/components/yale_smart_alarm/__init__.py new file mode 100644 index 00000000000..472ef33a083 --- /dev/null +++ b/tests/components/yale_smart_alarm/__init__.py @@ -0,0 +1 @@ +"""Tests for the Yale Smart Living integration.""" diff --git a/tests/components/yale_smart_alarm/test_config_flow.py b/tests/components/yale_smart_alarm/test_config_flow.py new file mode 100644 index 00000000000..142e1ac5b5d --- /dev/null +++ b/tests/components/yale_smart_alarm/test_config_flow.py @@ -0,0 +1,224 @@ +"""Test the Yale Smart Living config flow.""" +from __future__ import annotations + +from unittest.mock import patch + +import pytest +from yalesmartalarmclient.client import AuthenticationError + +from homeassistant import config_entries, setup +from homeassistant.components.yale_smart_alarm.const import DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import RESULT_TYPE_ABORT, RESULT_TYPE_FORM + +from tests.common import MockConfigEntry + + +async def test_form(hass: HomeAssistant) -> None: + """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( + "homeassistant.components.yale_smart_alarm.config_flow.YaleSmartAlarmClient", + ), patch( + "homeassistant.components.yale_smart_alarm.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "username": "test-username", + "password": "test-password", + "name": "Yale Smart Alarm", + "area_id": "1", + }, + ) + await hass.async_block_till_done() + + assert result2["type"] == "create_entry" + assert result2["title"] == "test-username" + assert result2["data"] == { + "username": "test-username", + "password": "test-password", + "name": "Yale Smart Alarm", + "area_id": "1", + } + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_form_invalid_auth(hass: HomeAssistant) -> None: + """Test we handle invalid auth.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "homeassistant.components.yale_smart_alarm.config_flow.YaleSmartAlarmClient", + side_effect=AuthenticationError, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "username": "test-username", + "password": "test-password", + "name": "Yale Smart Alarm", + "area_id": "1", + }, + ) + await hass.async_block_till_done() + + assert result2["type"] == "form" + assert result2["errors"] == {"base": "invalid_auth"} + + +@pytest.mark.parametrize( + "input,output", + [ + ( + { + "username": "test-username", + "password": "test-password", + "name": "Yale Smart Alarm", + "area_id": "1", + }, + { + "username": "test-username", + "password": "test-password", + "name": "Yale Smart Alarm", + "area_id": "1", + }, + ), + ( + { + "username": "test-username", + "password": "test-password", + }, + { + "username": "test-username", + "password": "test-password", + "name": "Yale Smart Alarm", + "area_id": "1", + }, + ), + ], +) +async def test_import_flow_success(hass, input: dict[str, str], output: dict[str, str]): + """Test a successful import of yaml.""" + + with patch( + "homeassistant.components.yale_smart_alarm.config_flow.YaleSmartAlarmClient", + ), patch( + "homeassistant.components.yale_smart_alarm.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result2 = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data=input, + ) + await hass.async_block_till_done() + + assert result2["type"] == "create_entry" + assert result2["title"] == "test-username" + assert result2["data"] == output + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_reauth_flow(hass: HomeAssistant) -> None: + """Test a reauthentication flow.""" + entry = MockConfigEntry( + domain=DOMAIN, + unique_id="test-username", + data={ + "username": "test-username", + "password": "test-password", + "name": "Yale Smart Alarm", + "area_id": "1", + }, + ) + entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": config_entries.SOURCE_REAUTH, + "unique_id": entry.unique_id, + "entry_id": entry.entry_id, + }, + data=entry.data, + ) + assert result["step_id"] == "reauth_confirm" + assert result["type"] == RESULT_TYPE_FORM + assert result["errors"] == {} + + with patch( + "homeassistant.components.yale_smart_alarm.config_flow.YaleSmartAlarmClient", + ) as mock_yale, patch( + "homeassistant.components.yale_smart_alarm.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "username": "test-username", + "password": "new-test-password", + }, + ) + await hass.async_block_till_done() + + assert result2["type"] == RESULT_TYPE_ABORT + assert result2["reason"] == "reauth_successful" + assert entry.data == { + "username": "test-username", + "password": "new-test-password", + "name": "Yale Smart Alarm", + "area_id": "1", + } + + assert len(mock_yale.mock_calls) == 1 + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_reauth_flow_invalid_login(hass: HomeAssistant) -> None: + """Test a reauthentication flow.""" + entry = MockConfigEntry( + domain=DOMAIN, + unique_id="test-username", + data={ + "username": "test-username", + "password": "test-password", + }, + ) + entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": config_entries.SOURCE_REAUTH, + "unique_id": entry.unique_id, + "entry_id": entry.entry_id, + }, + data=entry.data, + ) + + with patch( + "homeassistant.components.yale_smart_alarm.config_flow.YaleSmartAlarmClient", + side_effect=AuthenticationError, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "username": "test-username", + "password": "wrong-password", + }, + ) + await hass.async_block_till_done() + + assert result2["step_id"] == "reauth_confirm" + assert result2["type"] == "form" + assert result2["errors"] == {"base": "invalid_auth"}