diff --git a/CODEOWNERS b/CODEOWNERS index 2d76eec1511..59c46c916ed 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -278,7 +278,7 @@ homeassistant/components/notion/* @bachya homeassistant/components/nsw_fuel_station/* @nickw444 homeassistant/components/nsw_rural_fire_service_feed/* @exxamalte homeassistant/components/nuheat/* @bdraco -homeassistant/components/nuki/* @pvizeli +homeassistant/components/nuki/* @pschmitt @pvizeli homeassistant/components/numato/* @clssn homeassistant/components/nut/* @bdraco homeassistant/components/nws/* @MatthewFlamm diff --git a/homeassistant/components/androidtv/manifest.json b/homeassistant/components/androidtv/manifest.json index d1747b8cd42..40e7575bbb9 100644 --- a/homeassistant/components/androidtv/manifest.json +++ b/homeassistant/components/androidtv/manifest.json @@ -4,7 +4,7 @@ "documentation": "https://www.home-assistant.io/integrations/androidtv", "requirements": [ "adb-shell[async]==0.2.0", - "androidtv[async]==0.0.45", + "androidtv[async]==0.0.46", "pure-python-adb==0.2.2.dev0" ], "codeowners": ["@JeffLIrion"] diff --git a/homeassistant/components/discord/manifest.json b/homeassistant/components/discord/manifest.json index 7f03e52ca62..1f4ccbdf5f5 100644 --- a/homeassistant/components/discord/manifest.json +++ b/homeassistant/components/discord/manifest.json @@ -2,6 +2,6 @@ "domain": "discord", "name": "Discord", "documentation": "https://www.home-assistant.io/integrations/discord", - "requirements": ["discord.py==1.3.3"], + "requirements": ["discord.py==1.3.4"], "codeowners": [] } diff --git a/homeassistant/components/nuki/manifest.json b/homeassistant/components/nuki/manifest.json index 386b36a3ca9..09cf112d41c 100644 --- a/homeassistant/components/nuki/manifest.json +++ b/homeassistant/components/nuki/manifest.json @@ -2,6 +2,6 @@ "domain": "nuki", "name": "Nuki", "documentation": "https://www.home-assistant.io/integrations/nuki", - "requirements": ["pynuki==1.3.7"], - "codeowners": ["@pvizeli"] + "requirements": ["pynuki==1.3.8"], + "codeowners": ["@pschmitt", "@pvizeli"] } diff --git a/homeassistant/components/rfxtrx/__init__.py b/homeassistant/components/rfxtrx/__init__.py index 1f4655aedc7..576e38316c6 100644 --- a/homeassistant/components/rfxtrx/__init__.py +++ b/homeassistant/components/rfxtrx/__init__.py @@ -194,7 +194,7 @@ def setup_internal(hass, config): "sub_type": event.device.subtype, "type_string": event.device.type_string, "id_string": event.device.id_string, - "data": "".join(f"{x:02x}" for x in event.data), + "data": binascii.hexlify(event.data).decode("ASCII"), "values": getattr(event, "values", None), } @@ -339,7 +339,7 @@ def get_device_id(device, data_bits=None): if data_bits and device.packettype == DEVICE_PACKET_TYPE_LIGHTING4: masked_id = get_pt2262_deviceid(id_string, data_bits) if masked_id: - id_string = str(masked_id) + id_string = masked_id.decode("ASCII") return (f"{device.packettype:x}", f"{device.subtype:x}", id_string) diff --git a/homeassistant/components/rfxtrx/cover.py b/homeassistant/components/rfxtrx/cover.py index af5c48810ee..41df8d022ec 100644 --- a/homeassistant/components/rfxtrx/cover.py +++ b/homeassistant/components/rfxtrx/cover.py @@ -7,6 +7,7 @@ from homeassistant.core import callback from . import ( CONF_AUTOMATIC_ADD, + CONF_DATA_BITS, CONF_SIGNAL_REPETITIONS, DEFAULT_SIGNAL_REPETITIONS, SIGNAL_EVENT, @@ -38,7 +39,9 @@ async def async_setup_entry( if not supported(event): continue - device_id = get_device_id(event.device) + device_id = get_device_id( + event.device, data_bits=entity_info.get(CONF_DATA_BITS) + ) if device_id in device_ids: continue device_ids.add(device_id) diff --git a/homeassistant/components/rfxtrx/light.py b/homeassistant/components/rfxtrx/light.py index 71bf54d3d50..3e3ef95fbf7 100644 --- a/homeassistant/components/rfxtrx/light.py +++ b/homeassistant/components/rfxtrx/light.py @@ -13,6 +13,7 @@ from homeassistant.core import callback from . import ( CONF_AUTOMATIC_ADD, + CONF_DATA_BITS, CONF_SIGNAL_REPETITIONS, DEFAULT_SIGNAL_REPETITIONS, SIGNAL_EVENT, @@ -50,7 +51,9 @@ async def async_setup_entry( if not supported(event): continue - device_id = get_device_id(event.device) + device_id = get_device_id( + event.device, data_bits=entity_info.get(CONF_DATA_BITS) + ) if device_id in device_ids: continue device_ids.add(device_id) diff --git a/homeassistant/components/rfxtrx/sensor.py b/homeassistant/components/rfxtrx/sensor.py index 537fabd7aa7..fb7176d2f91 100644 --- a/homeassistant/components/rfxtrx/sensor.py +++ b/homeassistant/components/rfxtrx/sensor.py @@ -14,6 +14,7 @@ from homeassistant.core import callback from . import ( CONF_AUTOMATIC_ADD, + CONF_DATA_BITS, DATA_TYPES, SIGNAL_EVENT, RfxtrxEntity, @@ -64,7 +65,7 @@ async def async_setup_entry( return isinstance(event, (ControlEvent, SensorEvent)) entities = [] - for packet_id in discovery_info[CONF_DEVICES]: + for packet_id, entity in discovery_info[CONF_DEVICES].items(): event = get_rfx_object(packet_id) if event is None: _LOGGER.error("Invalid device: %s", packet_id) @@ -72,7 +73,7 @@ async def async_setup_entry( if not supported(event): continue - device_id = get_device_id(event.device) + device_id = get_device_id(event.device, data_bits=entity.get(CONF_DATA_BITS)) for data_type in set(event.values) & set(DATA_TYPES): data_id = (*device_id, data_type) if data_id in data_ids: diff --git a/homeassistant/components/rfxtrx/switch.py b/homeassistant/components/rfxtrx/switch.py index e5c96215c83..6cd9a484abd 100644 --- a/homeassistant/components/rfxtrx/switch.py +++ b/homeassistant/components/rfxtrx/switch.py @@ -9,6 +9,7 @@ from homeassistant.core import callback from . import ( CONF_AUTOMATIC_ADD, + CONF_DATA_BITS, CONF_SIGNAL_REPETITIONS, DEFAULT_SIGNAL_REPETITIONS, DOMAIN, @@ -48,7 +49,9 @@ async def async_setup_entry( if not supported(event): continue - device_id = get_device_id(event.device) + device_id = get_device_id( + event.device, data_bits=entity_info.get(CONF_DATA_BITS) + ) if device_id in device_ids: continue device_ids.add(device_id) diff --git a/homeassistant/components/route53/__init__.py b/homeassistant/components/route53/__init__.py index cda4ba9dc86..5355ed15f38 100644 --- a/homeassistant/components/route53/__init__.py +++ b/homeassistant/components/route53/__init__.py @@ -4,7 +4,7 @@ import logging from typing import List import boto3 -from ipify import exceptions, get_ip +import requests import voluptuous as vol from homeassistant.const import CONF_DOMAIN, CONF_TTL, CONF_ZONE, HTTP_OK @@ -84,16 +84,12 @@ def _update_route53( # Get the IP Address and build an array of changes try: - ipaddress = get_ip() + ipaddress = requests.get("https://api.ipify.org/", timeout=5).text - except exceptions.ConnectionError: + except requests.RequestException: _LOGGER.warning("Unable to reach the ipify service") return - except exceptions.ServiceError: - _LOGGER.warning("Unable to complete the ipfy request") - return - changes = [] for record in records: _LOGGER.debug("Processing record: %s", record) diff --git a/homeassistant/components/route53/manifest.json b/homeassistant/components/route53/manifest.json index da2b6dc092c..4879f12a3be 100644 --- a/homeassistant/components/route53/manifest.json +++ b/homeassistant/components/route53/manifest.json @@ -2,6 +2,6 @@ "domain": "route53", "name": "AWS Route53", "documentation": "https://www.home-assistant.io/integrations/route53", - "requirements": ["boto3==1.9.252", "ipify==1.0.0"], + "requirements": ["boto3==1.9.252"], "codeowners": [] } diff --git a/homeassistant/components/simplisafe/__init__.py b/homeassistant/components/simplisafe/__init__.py index 8895244158a..327549eeb62 100644 --- a/homeassistant/components/simplisafe/__init__.py +++ b/homeassistant/components/simplisafe/__init__.py @@ -1,6 +1,6 @@ """Support for SimpliSafe alarm systems.""" import asyncio -import logging +from uuid import UUID from simplipy import API from simplipy.errors import InvalidCredentialsError, SimplipyError @@ -55,11 +55,10 @@ from .const import ( DATA_CLIENT, DEFAULT_SCAN_INTERVAL, DOMAIN, + LOGGER, VOLUMES, ) -_LOGGER = logging.getLogger(__name__) - CONF_ACCOUNTS = "accounts" DATA_LISTENER = "listener" @@ -161,6 +160,13 @@ def _async_save_refresh_token(hass, config_entry, token): ) +async def async_get_client_id(hass): + """Get a client ID (based on the HASS unique ID) for the SimpliSafe API.""" + hass_id = await hass.helpers.instance_id.async_get() + # SimpliSafe requires full, "dashed" versions of UUIDs: + return str(UUID(hass_id)) + + async def async_register_base_station(hass, system, config_entry_id): """Register a new bridge.""" device_registry = await dr.async_get_registry(hass) @@ -220,17 +226,18 @@ async def async_setup_entry(hass, config_entry): _verify_domain_control = verify_domain_control(hass, DOMAIN) + client_id = await async_get_client_id(hass) websession = aiohttp_client.async_get_clientsession(hass) try: api = await API.login_via_token( - config_entry.data[CONF_TOKEN], session=websession + config_entry.data[CONF_TOKEN], client_id=client_id, session=websession ) except InvalidCredentialsError: - _LOGGER.error("Invalid credentials provided") + LOGGER.error("Invalid credentials provided") return False except SimplipyError as err: - _LOGGER.error("Config entry failed: %s", err) + LOGGER.error("Config entry failed: %s", err) raise ConfigEntryNotReady _async_save_refresh_token(hass, config_entry, api.refresh_token) @@ -252,7 +259,7 @@ async def async_setup_entry(hass, config_entry): """Decorate.""" system_id = int(call.data[ATTR_SYSTEM_ID]) if system_id not in simplisafe.systems: - _LOGGER.error("Unknown system ID in service call: %s", system_id) + LOGGER.error("Unknown system ID in service call: %s", system_id) return await coro(call) @@ -266,7 +273,7 @@ async def async_setup_entry(hass, config_entry): """Decorate.""" system = simplisafe.systems[int(call.data[ATTR_SYSTEM_ID])] if system.version != 3: - _LOGGER.error("Service only available on V3 systems") + LOGGER.error("Service only available on V3 systems") return await coro(call) @@ -280,7 +287,7 @@ async def async_setup_entry(hass, config_entry): try: await system.clear_notifications() except SimplipyError as err: - _LOGGER.error("Error during service call: %s", err) + LOGGER.error("Error during service call: %s", err) return @verify_system_exists @@ -291,7 +298,7 @@ async def async_setup_entry(hass, config_entry): try: await system.remove_pin(call.data[ATTR_PIN_LABEL_OR_VALUE]) except SimplipyError as err: - _LOGGER.error("Error during service call: %s", err) + LOGGER.error("Error during service call: %s", err) return @verify_system_exists @@ -302,7 +309,7 @@ async def async_setup_entry(hass, config_entry): try: await system.set_pin(call.data[ATTR_PIN_LABEL], call.data[ATTR_PIN_VALUE]) except SimplipyError as err: - _LOGGER.error("Error during service call: %s", err) + LOGGER.error("Error during service call: %s", err) return @verify_system_exists @@ -320,7 +327,7 @@ async def async_setup_entry(hass, config_entry): } ) except SimplipyError as err: - _LOGGER.error("Error during service call: %s", err) + LOGGER.error("Error during service call: %s", err) return for service, method, schema in [ @@ -373,16 +380,16 @@ class SimpliSafeWebsocket: @staticmethod def _on_connect(): """Define a handler to fire when the websocket is connected.""" - _LOGGER.info("Connected to websocket") + LOGGER.info("Connected to websocket") @staticmethod def _on_disconnect(): """Define a handler to fire when the websocket is disconnected.""" - _LOGGER.info("Disconnected from websocket") + LOGGER.info("Disconnected from websocket") def _on_event(self, event): """Define a handler to fire when a new SimpliSafe event arrives.""" - _LOGGER.debug("New websocket event: %s", event) + LOGGER.debug("New websocket event: %s", event) async_dispatcher_send( self._hass, TOPIC_UPDATE_WEBSOCKET.format(event.system_id), event ) @@ -451,7 +458,7 @@ class SimpliSafe: if not to_add: return - _LOGGER.debug("New system notifications: %s", to_add) + LOGGER.debug("New system notifications: %s", to_add) self._system_notifications[system.system_id].update(to_add) @@ -492,7 +499,7 @@ class SimpliSafe: system.system_id ] = await system.get_latest_event() except SimplipyError as err: - _LOGGER.error("Error while fetching initial event: %s", err) + LOGGER.error("Error while fetching initial event: %s", err) self.initial_event_to_use[system.system_id] = {} async def refresh(event_time): @@ -512,7 +519,7 @@ class SimpliSafe: """Update a system.""" await system.update() self._async_process_new_notifications(system) - _LOGGER.debug('Updated REST API data for "%s"', system.address) + LOGGER.debug('Updated REST API data for "%s"', system.address) async_dispatcher_send( self._hass, TOPIC_UPDATE_REST_API.format(system.system_id) ) @@ -523,27 +530,37 @@ class SimpliSafe: for result in results: if isinstance(result, InvalidCredentialsError): if self._emergency_refresh_token_used: - _LOGGER.error( - "SimpliSafe authentication disconnected. Please restart HASS" + LOGGER.error( + "Token disconnected or invalid. Please re-auth the " + "SimpliSafe integration in HASS" ) - remove_listener = self._hass.data[DOMAIN][DATA_LISTENER].pop( - self._config_entry.entry_id + self._hass.async_create_task( + self._hass.config_entries.flow.async_init( + DOMAIN, + context={"source": "reauth"}, + data=self._config_entry.data, + ) ) - remove_listener() return - _LOGGER.warning("SimpliSafe cloud error; trying stored refresh token") + LOGGER.warning("SimpliSafe cloud error; trying stored refresh token") self._emergency_refresh_token_used = True - return await self._api.refresh_access_token( - self._config_entry.data[CONF_TOKEN] - ) + + try: + await self._api.refresh_access_token( + self._config_entry.data[CONF_TOKEN] + ) + return + except SimplipyError as err: + LOGGER.error("Error while using stored refresh token: %s", err) + return if isinstance(result, SimplipyError): - _LOGGER.error("SimpliSafe error while updating: %s", result) + LOGGER.error("SimpliSafe error while updating: %s", result) return - if isinstance(result, SimplipyError): - _LOGGER.error("Unknown error while updating: %s", result) + if isinstance(result, Exception): # pylint: disable=broad-except + LOGGER.error("Unknown error while updating: %s", result) return if self._api.refresh_token != self._config_entry.data[CONF_TOKEN]: diff --git a/homeassistant/components/simplisafe/alarm_control_panel.py b/homeassistant/components/simplisafe/alarm_control_panel.py index 7998de463f6..acc7e3bb0a8 100644 --- a/homeassistant/components/simplisafe/alarm_control_panel.py +++ b/homeassistant/components/simplisafe/alarm_control_panel.py @@ -1,5 +1,4 @@ """Support for SimpliSafe alarm control panels.""" -import logging import re from simplipy.errors import SimplipyError @@ -50,11 +49,10 @@ from .const import ( ATTR_VOICE_PROMPT_VOLUME, DATA_CLIENT, DOMAIN, + LOGGER, VOLUME_STRING_MAP, ) -_LOGGER = logging.getLogger(__name__) - ATTR_BATTERY_BACKUP_POWER_LEVEL = "battery_backup_power_level" ATTR_GSM_STRENGTH = "gsm_strength" ATTR_PIN_NAME = "pin_name" @@ -146,7 +144,7 @@ class SimpliSafeAlarm(SimpliSafeEntity, AlarmControlPanelEntity): return True if not code or code != self._simplisafe.options[CONF_CODE]: - _LOGGER.warning( + LOGGER.warning( "Incorrect alarm code entered (target state: %s): %s", state, code ) return False @@ -161,7 +159,7 @@ class SimpliSafeAlarm(SimpliSafeEntity, AlarmControlPanelEntity): try: await self._system.set_off() except SimplipyError as err: - _LOGGER.error('Error while disarming "%s": %s', self._system.name, err) + LOGGER.error('Error while disarming "%s": %s', self._system.name, err) return self._state = STATE_ALARM_DISARMED @@ -174,7 +172,7 @@ class SimpliSafeAlarm(SimpliSafeEntity, AlarmControlPanelEntity): try: await self._system.set_home() except SimplipyError as err: - _LOGGER.error('Error while arming "%s" (home): %s', self._system.name, err) + LOGGER.error('Error while arming "%s" (home): %s', self._system.name, err) return self._state = STATE_ALARM_ARMED_HOME @@ -187,7 +185,7 @@ class SimpliSafeAlarm(SimpliSafeEntity, AlarmControlPanelEntity): try: await self._system.set_away() except SimplipyError as err: - _LOGGER.error('Error while arming "%s" (away): %s', self._system.name, err) + LOGGER.error('Error while arming "%s" (away): %s', self._system.name, err) return self._state = STATE_ALARM_ARMING diff --git a/homeassistant/components/simplisafe/config_flow.py b/homeassistant/components/simplisafe/config_flow.py index 1225f6de818..d4a076860a1 100644 --- a/homeassistant/components/simplisafe/config_flow.py +++ b/homeassistant/components/simplisafe/config_flow.py @@ -1,6 +1,10 @@ """Config flow to configure the SimpliSafe component.""" from simplipy import API -from simplipy.errors import SimplipyError +from simplipy.errors import ( + InvalidCredentialsError, + PendingAuthorizationError, + SimplipyError, +) import voluptuous as vol from homeassistant import config_entries @@ -8,7 +12,8 @@ from homeassistant.const import CONF_CODE, CONF_PASSWORD, CONF_TOKEN, CONF_USERN from homeassistant.core import callback from homeassistant.helpers import aiohttp_client -from .const import DOMAIN # pylint: disable=unused-import +from . import async_get_client_id +from .const import DOMAIN, LOGGER # pylint: disable=unused-import class SimpliSafeFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): @@ -19,21 +24,18 @@ class SimpliSafeFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): def __init__(self): """Initialize the config flow.""" - self.data_schema = vol.Schema( + self.full_data_schema = vol.Schema( { vol.Required(CONF_USERNAME): str, vol.Required(CONF_PASSWORD): str, vol.Optional(CONF_CODE): str, } ) + self.password_data_schema = vol.Schema({vol.Required(CONF_PASSWORD): str}) - async def _show_form(self, errors=None): - """Show the form to the user.""" - return self.async_show_form( - step_id="user", - data_schema=self.data_schema, - errors=errors if errors else {}, - ) + self._code = None + self._password = None + self._username = None @staticmethod @callback @@ -41,34 +43,112 @@ class SimpliSafeFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): """Define the config flow to handle options.""" return SimpliSafeOptionsFlowHandler(config_entry) + async def _async_get_simplisafe_api(self): + """Get an authenticated SimpliSafe API client.""" + client_id = await async_get_client_id(self.hass) + websession = aiohttp_client.async_get_clientsession(self.hass) + + return await API.login_via_credentials( + self._username, self._password, client_id=client_id, session=websession, + ) + + async def _async_login_during_step(self, *, step_id, form_schema): + """Attempt to log into the API from within a config flow step.""" + errors = {} + + try: + simplisafe = await self._async_get_simplisafe_api() + except PendingAuthorizationError: + LOGGER.info("Awaiting confirmation of MFA email click") + return await self.async_step_mfa() + except InvalidCredentialsError: + errors = {"base": "invalid_credentials"} + except SimplipyError as err: + LOGGER.error("Unknown error while logging into SimpliSafe: %s", err) + errors = {"base": "unknown"} + + if errors: + return self.async_show_form( + step_id=step_id, data_schema=form_schema, errors=errors, + ) + + return await self.async_step_finish( + { + CONF_USERNAME: self._username, + CONF_TOKEN: simplisafe.refresh_token, + CONF_CODE: self._code, + } + ) + + async def async_step_finish(self, user_input=None): + """Handle finish config entry setup.""" + existing_entry = await self.async_set_unique_id(self._username) + if existing_entry: + self.hass.config_entries.async_update_entry(existing_entry, data=user_input) + return self.async_abort(reason="reauth_successful") + return self.async_create_entry(title=self._username, data=user_input) + async def async_step_import(self, import_config): """Import a config entry from configuration.yaml.""" return await self.async_step_user(import_config) + async def async_step_mfa(self, user_input=None): + """Handle multi-factor auth confirmation.""" + if user_input is None: + return self.async_show_form(step_id="mfa") + + try: + simplisafe = await self._async_get_simplisafe_api() + except PendingAuthorizationError: + LOGGER.error("Still awaiting confirmation of MFA email click") + return self.async_show_form( + step_id="mfa", errors={"base": "still_awaiting_mfa"} + ) + + return await self.async_step_finish( + { + CONF_USERNAME: self._username, + CONF_TOKEN: simplisafe.refresh_token, + CONF_CODE: self._code, + } + ) + + async def async_step_reauth(self, config): + """Handle configuration by re-auth.""" + self._code = config.get(CONF_CODE) + self._username = config[CONF_USERNAME] + + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm(self, user_input=None): + """Handle re-auth completion.""" + if not user_input: + return self.async_show_form( + step_id="reauth_confirm", data_schema=self.password_data_schema + ) + + self._password = user_input[CONF_PASSWORD] + + return await self._async_login_during_step( + step_id="reauth_confirm", form_schema=self.password_data_schema + ) + async def async_step_user(self, user_input=None): """Handle the start of the config flow.""" if not user_input: - return await self._show_form() + return self.async_show_form( + step_id="user", data_schema=self.full_data_schema + ) await self.async_set_unique_id(user_input[CONF_USERNAME]) self._abort_if_unique_id_configured() - websession = aiohttp_client.async_get_clientsession(self.hass) + self._code = user_input.get(CONF_CODE) + self._password = user_input[CONF_PASSWORD] + self._username = user_input[CONF_USERNAME] - try: - simplisafe = await API.login_via_credentials( - user_input[CONF_USERNAME], user_input[CONF_PASSWORD], session=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: user_input[CONF_USERNAME], - CONF_TOKEN: simplisafe.refresh_token, - CONF_CODE: user_input.get(CONF_CODE), - }, + return await self._async_login_during_step( + step_id="user", form_schema=self.full_data_schema ) diff --git a/homeassistant/components/simplisafe/const.py b/homeassistant/components/simplisafe/const.py index 6ca5f8323a7..36d191d0ab8 100644 --- a/homeassistant/components/simplisafe/const.py +++ b/homeassistant/components/simplisafe/const.py @@ -1,8 +1,11 @@ """Define constants for the SimpliSafe component.""" from datetime import timedelta +import logging from simplipy.system.v3 import VOLUME_HIGH, VOLUME_LOW, VOLUME_MEDIUM, VOLUME_OFF +LOGGER = logging.getLogger(__package__) + DOMAIN = "simplisafe" DATA_CLIENT = "client" diff --git a/homeassistant/components/simplisafe/lock.py b/homeassistant/components/simplisafe/lock.py index 78866ce9004..82177fb4387 100644 --- a/homeassistant/components/simplisafe/lock.py +++ b/homeassistant/components/simplisafe/lock.py @@ -1,6 +1,4 @@ """Support for SimpliSafe locks.""" -import logging - from simplipy.errors import SimplipyError from simplipy.lock import LockStates from simplipy.websocket import EVENT_LOCK_LOCKED, EVENT_LOCK_UNLOCKED @@ -9,9 +7,7 @@ from homeassistant.components.lock import LockEntity from homeassistant.core import callback from . import SimpliSafeEntity -from .const import DATA_CLIENT, DOMAIN - -_LOGGER = logging.getLogger(__name__) +from .const import DATA_CLIENT, DOMAIN, LOGGER ATTR_LOCK_LOW_BATTERY = "lock_low_battery" ATTR_JAMMED = "jammed" @@ -52,7 +48,7 @@ class SimpliSafeLock(SimpliSafeEntity, LockEntity): try: await self._lock.lock() except SimplipyError as err: - _LOGGER.error('Error while locking "%s": %s', self._lock.name, err) + LOGGER.error('Error while locking "%s": %s', self._lock.name, err) return self._is_locked = True @@ -62,7 +58,7 @@ class SimpliSafeLock(SimpliSafeEntity, LockEntity): try: await self._lock.unlock() except SimplipyError as err: - _LOGGER.error('Error while unlocking "%s": %s', self._lock.name, err) + LOGGER.error('Error while unlocking "%s": %s', self._lock.name, err) return self._is_locked = False diff --git a/homeassistant/components/simplisafe/manifest.json b/homeassistant/components/simplisafe/manifest.json index 6b271012c8e..c986add4539 100644 --- a/homeassistant/components/simplisafe/manifest.json +++ b/homeassistant/components/simplisafe/manifest.json @@ -3,6 +3,6 @@ "name": "SimpliSafe", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/simplisafe", - "requirements": ["simplisafe-python==9.2.0"], + "requirements": ["simplisafe-python==9.2.1"], "codeowners": ["@bachya"] } diff --git a/homeassistant/components/simplisafe/strings.json b/homeassistant/components/simplisafe/strings.json index 0a097c9fda8..7f724de9db5 100644 --- a/homeassistant/components/simplisafe/strings.json +++ b/homeassistant/components/simplisafe/strings.json @@ -1,6 +1,17 @@ { "config": { "step": { + "mfa": { + "title": "SimpliSafe Multi-Factor Authentication", + "description": "Check your email for a link from SimpliSafe. After verifying the link, return here to complete the installation of the integration." + }, + "reauth_confirm": { + "title": "Re-link SimpliSafe Account", + "description": "Your access token has expired or been revoked. Enter your password to re-link your account.", + "data": { + "password": "[%key:common::config_flow::data::password%]" + } + }, "user": { "title": "Fill in your information.", "data": { @@ -12,10 +23,13 @@ }, "error": { "identifier_exists": "Account already registered", - "invalid_credentials": "Invalid credentials" + "invalid_credentials": "Invalid credentials", + "still_awaiting_mfa": "Still awaiting MFA email click", + "unknown": "[%key:common::config_flow::error::unknown%]" }, "abort": { - "already_configured": "This SimpliSafe account is already in use." + "already_configured": "This SimpliSafe account is already in use.", + "reauth_successful": "SimpliSafe successfully reauthenticated." } }, "options": { @@ -28,4 +42,4 @@ } } } -} \ No newline at end of file +} diff --git a/homeassistant/components/simplisafe/translations/en.json b/homeassistant/components/simplisafe/translations/en.json index 90867a0163f..29ad4ee88ef 100644 --- a/homeassistant/components/simplisafe/translations/en.json +++ b/homeassistant/components/simplisafe/translations/en.json @@ -1,18 +1,32 @@ { "config": { "abort": { - "already_configured": "This SimpliSafe account is already in use." + "already_configured": "This SimpliSafe account is already in use.", + "reauth_successful": "SimpliSafe successfully reauthenticated." }, "error": { "identifier_exists": "Account already registered", - "invalid_credentials": "Invalid credentials" + "invalid_credentials": "Invalid credentials", + "still_awaiting_mfa": "Still awaiting MFA email click", + "unknown": "[%key:common::config_flow::error::unknown%]" }, "step": { + "mfa": { + "description": "Check your email for a link from SimpliSafe. After verifying the link, return here to complete the installation of the integration.", + "title": "SimpliSafe Multi-Factor Authentication" + }, + "reauth_confirm": { + "data": { + "password": "[%key:common::config_flow::data::password%]" + }, + "description": "Your access token has expired or been revoked. Enter your password to re-link your account.", + "title": "Re-link SimpliSafe Account" + }, "user": { "data": { "code": "Code (used in Home Assistant UI)", - "password": "Password", - "username": "Email" + "password": "[%key:common::config_flow::data::password%]", + "username": "[%key:common::config_flow::data::email%]" }, "title": "Fill in your information." } diff --git a/homeassistant/components/smartthings/manifest.json b/homeassistant/components/smartthings/manifest.json index 3f9ee75b173..bf137ae398d 100644 --- a/homeassistant/components/smartthings/manifest.json +++ b/homeassistant/components/smartthings/manifest.json @@ -3,7 +3,7 @@ "name": "SmartThings", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/smartthings", - "requirements": ["pysmartapp==0.3.2", "pysmartthings==0.7.1"], + "requirements": ["pysmartapp==0.3.2", "pysmartthings==0.7.2"], "dependencies": ["webhook"], "after_dependencies": ["cloud"], "codeowners": ["@andrewsayre"] diff --git a/homeassistant/components/toon/const.py b/homeassistant/components/toon/const.py index d7f403f7013..5015d50fa63 100644 --- a/homeassistant/components/toon/const.py +++ b/homeassistant/components/toon/const.py @@ -224,7 +224,7 @@ SENSOR_ENTITIES = { "power_meter_reading_low": { ATTR_NAME: "Electricity Meter Feed IN Tariff 2", ATTR_SECTION: "power_usage", - ATTR_MEASUREMENT: "meter_high", + ATTR_MEASUREMENT: "meter_low", ATTR_UNIT_OF_MEASUREMENT: ENERGY_KILO_WATT_HOUR, ATTR_DEVICE_CLASS: None, ATTR_ICON: "mdi:power-plug", diff --git a/homeassistant/components/xbox_live/manifest.json b/homeassistant/components/xbox_live/manifest.json index f00f49c1589..3ebffc425ad 100644 --- a/homeassistant/components/xbox_live/manifest.json +++ b/homeassistant/components/xbox_live/manifest.json @@ -2,6 +2,6 @@ "domain": "xbox_live", "name": "Xbox Live", "documentation": "https://www.home-assistant.io/integrations/xbox_live", - "requirements": ["xboxapi==0.1.1"], + "requirements": ["xboxapi==2.0.0"], "codeowners": ["@MartinHjelmare"] } diff --git a/homeassistant/components/xbox_live/sensor.py b/homeassistant/components/xbox_live/sensor.py index ed5abe74bb6..1f46267967a 100644 --- a/homeassistant/components/xbox_live/sensor.py +++ b/homeassistant/components/xbox_live/sensor.py @@ -3,7 +3,7 @@ from datetime import timedelta import logging import voluptuous as vol -from xboxapi import xbox_api +from xboxapi import Client from homeassistant.components.sensor import PLATFORM_SCHEMA from homeassistant.const import CONF_API_KEY, CONF_SCAN_INTERVAL @@ -28,17 +28,17 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( def setup_platform(hass, config, add_entities, discovery_info=None): """Set up the Xbox platform.""" - api = xbox_api.XboxApi(config[CONF_API_KEY]) + api = Client(api_key=config[CONF_API_KEY]) entities = [] - # request personal profile to check api connection - profile = api.get_profile() - if profile.get("error_code") is not None: + # request profile info to check api connection + response = api.api_get("profile") + if not response.ok: _LOGGER.error( - "Can't setup XboxAPI connection. Check your account or " - "api key on xboxapi.com. Code: %s Description: %s ", - profile.get("error_code", "unknown"), - profile.get("error_message", "unknown"), + "Can't setup X API connection. Check your account or " + "api key on xapi.us. Code: %s Description: %s ", + response.status_code, + response.reason, ) return @@ -59,7 +59,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None): def get_user_gamercard(api, xuid): """Get profile info.""" - gamercard = api.get_user_gamercard(xuid) + gamercard = api.gamer(gamertag="", xuid=xuid).get("gamercard") _LOGGER.debug("User gamercard: %s", gamercard) if gamercard.get("success", True) and gamercard.get("code") is None: @@ -82,11 +82,11 @@ class XboxSensor(Entity): self._presence = [] self._xuid = xuid self._api = api - self._gamertag = gamercard.get("gamertag") - self._gamerscore = gamercard.get("gamerscore") + self._gamertag = gamercard["gamertag"] + self._gamerscore = gamercard["gamerscore"] self._interval = interval - self._picture = gamercard.get("gamerpicSmallSslImagePath") - self._tier = gamercard.get("tier") + self._picture = gamercard["gamerpicSmallSslImagePath"] + self._tier = gamercard["tier"] @property def name(self): @@ -111,10 +111,8 @@ class XboxSensor(Entity): attributes["tier"] = self._tier for device in self._presence: - for title in device.get("titles"): - attributes[ - f'{device.get("type")} {title.get("placement")}' - ] = title.get("name") + for title in device["titles"]: + attributes[f'{device["type"]} {title["placement"]}'] = title["name"] return attributes @@ -140,7 +138,7 @@ class XboxSensor(Entity): def update(self): """Update state data from Xbox API.""" - presence = self._api.get_user_presence(self._xuid) + presence = self._api.gamer(gamertag="", xuid=self._xuid).get("presence") _LOGGER.debug("User presence: %s", presence) - self._state = presence.get("state") + self._state = presence["state"] self._presence = presence.get("devices", []) diff --git a/homeassistant/components/zeroconf/__init__.py b/homeassistant/components/zeroconf/__init__.py index 7534ad39541..3c448a91010 100644 --- a/homeassistant/components/zeroconf/__init__.py +++ b/homeassistant/components/zeroconf/__init__.py @@ -209,6 +209,11 @@ def setup(hass, config): return info = info_from_service(service_info) + if not info: + # Prevent the browser thread from collapsing + _LOGGER.debug("Failed to get addresses for device %s", name) + return + _LOGGER.debug("Discovered new device %s %s", name, info) # If we can handle it as a HomeKit discovery, we do that here. @@ -310,6 +315,9 @@ def info_from_service(service): except UnicodeDecodeError: pass + if not service.addresses: + return None + address = service.addresses[0] info = { diff --git a/homeassistant/const.py b/homeassistant/const.py index 1a4c1941e63..ddbc6ad91ed 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -1,7 +1,7 @@ """Constants used by Home Assistant components.""" MAJOR_VERSION = 0 MINOR_VERSION = 113 -PATCH_VERSION = "0" +PATCH_VERSION = "1" __short_version__ = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__ = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER = (3, 7, 1) diff --git a/homeassistant/core.py b/homeassistant/core.py index a8613dade59..01bfa402348 100644 --- a/homeassistant/core.py +++ b/homeassistant/core.py @@ -23,6 +23,7 @@ from typing import ( Callable, Coroutine, Dict, + Iterable, List, Mapping, Optional, @@ -98,6 +99,9 @@ CORE_STORAGE_VERSION = 1 DOMAIN = "homeassistant" +# How long to wait to log tasks that are blocking +BLOCK_LOG_TIMEOUT = 60 + # How long we wait for the result of a service call SERVICE_CALL_LIMIT = 10 # seconds @@ -393,10 +397,21 @@ class HomeAssistant: pending = [task for task in self._pending_tasks if not task.done()] self._pending_tasks.clear() if pending: - await asyncio.wait(pending) + await self._await_and_log_pending(pending) else: await asyncio.sleep(0) + async def _await_and_log_pending(self, pending: Iterable[Awaitable[Any]]) -> None: + """Await and log tasks that take a long time.""" + wait_time = 0 + while pending: + _, pending = await asyncio.wait(pending, timeout=BLOCK_LOG_TIMEOUT) + if not pending: + return + wait_time += BLOCK_LOG_TIMEOUT + for task in pending: + _LOGGER.debug("Waited %s seconds for task: %s", wait_time, task) + def stop(self) -> None: """Stop Home Assistant and shuts down all threads.""" if self.state == CoreState.not_running: # just ignore diff --git a/homeassistant/helpers/script.py b/homeassistant/helpers/script.py index 1ca13e22e9f..1eb46a1eded 100644 --- a/homeassistant/helpers/script.py +++ b/homeassistant/helpers/script.py @@ -140,7 +140,7 @@ class _ScriptRun: ) -> None: self._hass = hass self._script = script - self._variables = variables + self._variables = variables or {} self._context = context self._log_exceptions = log_exceptions self._step = -1 @@ -431,22 +431,23 @@ class _ScriptRun: async def _async_repeat_step(self): """Repeat a sequence.""" - description = self._action.get(CONF_ALIAS, "sequence") repeat = self._action[CONF_REPEAT] - async def async_run_sequence(iteration, extra_msg="", extra_vars=None): + saved_repeat_vars = self._variables.get("repeat") + + def set_repeat_var(iteration, count=None): + repeat_vars = {"first": iteration == 1, "index": iteration} + if count: + repeat_vars["last"] = iteration == count + self._variables["repeat"] = repeat_vars + + # pylint: disable=protected-access + script = self._script._get_repeat_script(self._step) + + async def async_run_sequence(iteration, extra_msg=""): self._log("Repeating %s: Iteration %i%s", description, iteration, extra_msg) - repeat_vars = {"repeat": {"first": iteration == 1, "index": iteration}} - if extra_vars: - repeat_vars["repeat"].update(extra_vars) - # pylint: disable=protected-access - await self._async_run_script( - self._script._get_repeat_script(self._step), - # Add repeat to variables. Override if it already exists in case of - # nested calls. - {**(self._variables or {}), **repeat_vars}, - ) + await self._async_run_script(script) if CONF_COUNT in repeat: count = repeat[CONF_COUNT] @@ -461,10 +462,10 @@ class _ScriptRun: level=logging.ERROR, ) raise _StopScript + extra_msg = f" of {count}" for iteration in range(1, count + 1): - await async_run_sequence( - iteration, f" of {count}", {"last": iteration == count} - ) + set_repeat_var(iteration, count) + await async_run_sequence(iteration, extra_msg) if self._stop.is_set(): break @@ -473,6 +474,7 @@ class _ScriptRun: await self._async_get_condition(config) for config in repeat[CONF_WHILE] ] for iteration in itertools.count(1): + set_repeat_var(iteration) if self._stop.is_set() or not all( cond(self._hass, self._variables) for cond in conditions ): @@ -484,12 +486,18 @@ class _ScriptRun: await self._async_get_condition(config) for config in repeat[CONF_UNTIL] ] for iteration in itertools.count(1): + set_repeat_var(iteration) await async_run_sequence(iteration) if self._stop.is_set() or all( cond(self._hass, self._variables) for cond in conditions ): break + if saved_repeat_vars: + self._variables["repeat"] = saved_repeat_vars + else: + del self._variables["repeat"] + async def _async_choose_step(self): """Choose a sequence.""" # pylint: disable=protected-access @@ -503,11 +511,11 @@ class _ScriptRun: if choose_data["default"]: await self._async_run_script(choose_data["default"]) - async def _async_run_script(self, script, variables=None): + async def _async_run_script(self, script): """Execute a script.""" await self._async_run_long_action( self._hass.async_create_task( - script.async_run(variables or self._variables, self._context) + script.async_run(self._variables, self._context) ) ) diff --git a/requirements_all.txt b/requirements_all.txt index 2492a229806..3bcfc2e7596 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -231,7 +231,7 @@ ambiclimate==0.2.1 amcrest==1.7.0 # homeassistant.components.androidtv -androidtv[async]==0.0.45 +androidtv[async]==0.0.46 # homeassistant.components.anel_pwrctrl anel_pwrctrl-homeassistant==0.0.1.dev2 @@ -473,7 +473,7 @@ directv==0.3.0 discogs_client==2.2.2 # homeassistant.components.discord -discord.py==1.3.3 +discord.py==1.3.4 # homeassistant.components.updater distro==1.5.0 @@ -783,9 +783,6 @@ influxdb==5.2.3 # homeassistant.components.iperf3 iperf3==0.1.11 -# homeassistant.components.route53 -ipify==1.0.0 - # homeassistant.components.rest # homeassistant.components.verisure jsonpath==0.82 @@ -1491,7 +1488,7 @@ pynetgear==0.6.1 pynetio==0.1.9.1 # homeassistant.components.nuki -pynuki==1.3.7 +pynuki==1.3.8 # homeassistant.components.nut pynut2==2.1.2 @@ -1608,7 +1605,7 @@ pysmappee==0.1.5 pysmartapp==0.3.2 # homeassistant.components.smartthings -pysmartthings==0.7.1 +pysmartthings==0.7.2 # homeassistant.components.smarty pysmarty==0.8 @@ -1942,7 +1939,7 @@ simplehound==0.3 simplepush==1.1.4 # homeassistant.components.simplisafe -simplisafe-python==9.2.0 +simplisafe-python==9.2.1 # homeassistant.components.sisyphus sisyphus-control==2.2.1 @@ -2207,7 +2204,7 @@ wled==0.4.3 xbee-helper==0.0.7 # homeassistant.components.xbox_live -xboxapi==0.1.1 +xboxapi==2.0.0 # homeassistant.components.xfinity xfinity-gateway==0.0.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 02595d9f708..bc48e7f7e49 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -132,7 +132,7 @@ airly==0.0.2 ambiclimate==0.2.1 # homeassistant.components.androidtv -androidtv[async]==0.0.45 +androidtv[async]==0.0.46 # homeassistant.components.apns apns2==0.3.0 @@ -737,7 +737,7 @@ pysmappee==0.1.5 pysmartapp==0.3.2 # homeassistant.components.smartthings -pysmartthings==0.7.1 +pysmartthings==0.7.2 # homeassistant.components.soma pysoma==0.0.10 @@ -857,7 +857,7 @@ sentry-sdk==0.13.5 simplehound==0.3 # homeassistant.components.simplisafe -simplisafe-python==9.2.0 +simplisafe-python==9.2.1 # homeassistant.components.sleepiq sleepyq==0.7 diff --git a/tests/components/simplisafe/test_config_flow.py b/tests/components/simplisafe/test_config_flow.py index 2448b20b084..d94c431aa14 100644 --- a/tests/components/simplisafe/test_config_flow.py +++ b/tests/components/simplisafe/test_config_flow.py @@ -1,14 +1,16 @@ """Define tests for the SimpliSafe config flow.""" -import json - -from simplipy.errors import SimplipyError +from simplipy.errors import ( + InvalidCredentialsError, + PendingAuthorizationError, + SimplipyError, +) from homeassistant import data_entry_flow 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.async_mock import MagicMock, PropertyMock, mock_open, patch +from tests.async_mock import MagicMock, PropertyMock, patch from tests.common import MockConfigEntry @@ -21,11 +23,17 @@ def mock_api(): async def test_duplicate_error(hass): """Test that errors are shown when duplicates are added.""" - conf = {CONF_USERNAME: "user@email.com", CONF_PASSWORD: "password"} + conf = { + CONF_USERNAME: "user@email.com", + CONF_PASSWORD: "password", + CONF_CODE: "1234", + } - MockConfigEntry(domain=DOMAIN, unique_id="user@email.com", data=conf).add_to_hass( - hass - ) + MockConfigEntry( + domain=DOMAIN, + unique_id="user@email.com", + data={CONF_USERNAME: "user@email.com", CONF_TOKEN: "12345", CONF_CODE: "1234"}, + ).add_to_hass(hass) result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER}, data=conf @@ -40,7 +48,7 @@ async def test_invalid_credentials(hass): conf = {CONF_USERNAME: "user@email.com", CONF_PASSWORD: "password"} with patch( - "simplipy.API.login_via_credentials", side_effect=SimplipyError, + "simplipy.API.login_via_credentials", side_effect=InvalidCredentialsError, ): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER}, data=conf @@ -75,15 +83,12 @@ async def test_options_flow(hass): async def test_show_form(hass): """Test that the form is served with no input.""" - 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 hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_IMPORT} + ) - 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): @@ -94,17 +99,9 @@ async def test_step_import(hass): CONF_CODE: "1234", } - mop = mock_open(read_data=json.dumps({"refresh_token": "12345"})) - with patch( "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" - ): + ), patch("simplipy.API.login_via_credentials", return_value=mock_api()): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER}, data=conf ) @@ -118,25 +115,48 @@ async def test_step_import(hass): } +async def test_step_reauth(hass): + """Test that the reauth step works.""" + MockConfigEntry( + domain=DOMAIN, + unique_id="user@email.com", + data={CONF_USERNAME: "user@email.com", CONF_TOKEN: "12345", CONF_CODE: "1234"}, + ).add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": "reauth"}, + data={CONF_CODE: "1234", CONF_USERNAME: "user@email.com"}, + ) + assert result["step_id"] == "reauth_confirm" + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "reauth_confirm" + + with patch( + "homeassistant.components.simplisafe.async_setup_entry", return_value=True + ), patch("simplipy.API.login_via_credentials", return_value=mock_api()): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_PASSWORD: "password"} + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "reauth_successful" + + assert len(hass.config_entries.async_entries()) == 1 + + async def test_step_user(hass): - """Test that the user step works.""" + """Test that the user step works (without MFA).""" conf = { CONF_USERNAME: "user@email.com", CONF_PASSWORD: "password", CONF_CODE: "1234", } - mop = mock_open(read_data=json.dumps({"refresh_token": "12345"})) - with patch( "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" - ): + ), patch("simplipy.API.login_via_credentials", return_value=mock_api()): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER}, data=conf ) @@ -148,3 +168,58 @@ async def test_step_user(hass): CONF_TOKEN: "12345abc", CONF_CODE: "1234", } + + +async def test_step_user_mfa(hass): + """Test that the user step works when MFA is in the middle.""" + conf = { + CONF_USERNAME: "user@email.com", + CONF_PASSWORD: "password", + CONF_CODE: "1234", + } + + with patch( + "simplipy.API.login_via_credentials", side_effect=PendingAuthorizationError + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER}, data=conf + ) + assert result["step_id"] == "mfa" + + with patch( + "simplipy.API.login_via_credentials", side_effect=PendingAuthorizationError + ): + # Simulate the user pressing the MFA submit button without having clicked + # the link in the MFA email: + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={} + ) + assert result["step_id"] == "mfa" + + with patch( + "homeassistant.components.simplisafe.async_setup_entry", return_value=True + ), patch("simplipy.API.login_via_credentials", return_value=mock_api()): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={} + ) + + 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_unknown_error(hass): + """Test that an unknown error raises the correct error.""" + conf = {CONF_USERNAME: "user@email.com", CONF_PASSWORD: "password"} + + 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": "unknown"} diff --git a/tests/components/smartthings/conftest.py b/tests/components/smartthings/conftest.py index a7ca9a4744c..643c084720a 100644 --- a/tests/components/smartthings/conftest.py +++ b/tests/components/smartthings/conftest.py @@ -249,7 +249,7 @@ def subscription_factory_fixture(): def device_factory_fixture(): """Fixture for creating mock devices.""" api = Mock(Api) - api.post_device_command.return_value = {} + api.post_device_command.return_value = {"results": [{"status": "ACCEPTED"}]} def _factory(label, capabilities, status: dict = None): device_data = { diff --git a/tests/components/zeroconf/test_init.py b/tests/components/zeroconf/test_init.py index 412e1f5f3f5..0e4d9aa904c 100644 --- a/tests/components/zeroconf/test_init.py +++ b/tests/components/zeroconf/test_init.py @@ -49,6 +49,20 @@ def get_service_info_mock(service_type, name): ) +def get_service_info_mock_without_an_address(service_type, name): + """Return service info for get_service_info without any addresses.""" + return ServiceInfo( + service_type, + name, + addresses=[], + port=80, + weight=0, + priority=0, + server="name.local.", + properties=PROPERTIES, + ) + + def get_homekit_info_mock(model, pairing_status): """Return homekit info for get_service_info for an homekit device.""" @@ -286,6 +300,15 @@ async def test_info_from_service_non_utf8(hass): assert raw_info["non-utf8-value"] is NON_UTF8_VALUE +async def test_info_from_service_with_addresses(hass): + """Test info_from_service does not throw when there are no addresses.""" + service_type = "_test._tcp.local." + info = zeroconf.info_from_service( + get_service_info_mock_without_an_address(service_type, f"test.{service_type}") + ) + assert info is None + + async def test_get_instance(hass, mock_zeroconf): """Test we get an instance.""" assert await hass.components.zeroconf.async_get_instance() is mock_zeroconf diff --git a/tests/helpers/test_entity_platform.py b/tests/helpers/test_entity_platform.py index d5b817e0655..ddecd1988ed 100644 --- a/tests/helpers/test_entity_platform.py +++ b/tests/helpers/test_entity_platform.py @@ -188,8 +188,8 @@ async def test_platform_warn_slow_setup(hass): assert mock_call.called # mock_calls[0] is the warning message for component setup - # mock_calls[5] is the warning message for platform setup - timeout, logger_method = mock_call.mock_calls[5][1][:2] + # mock_calls[6] is the warning message for platform setup + timeout, logger_method = mock_call.mock_calls[6][1][:2] assert timeout == entity_platform.SLOW_SETUP_WARNING assert logger_method == _LOGGER.warning diff --git a/tests/helpers/test_script.py b/tests/helpers/test_script.py index 8a27c1c4e7e..7a458c49286 100644 --- a/tests/helpers/test_script.py +++ b/tests/helpers/test_script.py @@ -854,6 +854,122 @@ async def test_repeat_conditional(hass, condition): assert event.data.get("index") == str(index + 1) +@pytest.mark.parametrize("condition", ["while", "until"]) +async def test_repeat_var_in_condition(hass, condition): + """Test repeat action w/ while option.""" + event = "test_event" + events = async_capture_events(hass, event) + + sequence = {"repeat": {"sequence": {"event": event}}} + if condition == "while": + sequence["repeat"]["while"] = { + "condition": "template", + "value_template": "{{ repeat.index <= 2 }}", + } + else: + sequence["repeat"]["until"] = { + "condition": "template", + "value_template": "{{ repeat.index == 2 }}", + } + script_obj = script.Script(hass, cv.SCRIPT_SCHEMA(sequence)) + + with mock.patch( + "homeassistant.helpers.condition._LOGGER.error", + side_effect=AssertionError("Template Error"), + ): + await script_obj.async_run() + + assert len(events) == 2 + + +async def test_repeat_nested(hass): + """Test nested repeats.""" + event = "test_event" + events = async_capture_events(hass, event) + + sequence = cv.SCRIPT_SCHEMA( + [ + { + "event": event, + "event_data_template": { + "repeat": "{{ None if repeat is not defined else repeat }}" + }, + }, + { + "repeat": { + "count": 2, + "sequence": [ + { + "event": event, + "event_data_template": { + "first": "{{ repeat.first }}", + "index": "{{ repeat.index }}", + "last": "{{ repeat.last }}", + }, + }, + { + "repeat": { + "count": 2, + "sequence": { + "event": event, + "event_data_template": { + "first": "{{ repeat.first }}", + "index": "{{ repeat.index }}", + "last": "{{ repeat.last }}", + }, + }, + } + }, + { + "event": event, + "event_data_template": { + "first": "{{ repeat.first }}", + "index": "{{ repeat.index }}", + "last": "{{ repeat.last }}", + }, + }, + ], + } + }, + { + "event": event, + "event_data_template": { + "repeat": "{{ None if repeat is not defined else repeat }}" + }, + }, + ] + ) + script_obj = script.Script(hass, sequence, "test script") + + with mock.patch( + "homeassistant.helpers.condition._LOGGER.error", + side_effect=AssertionError("Template Error"), + ): + await script_obj.async_run() + + assert len(events) == 10 + assert events[0].data == {"repeat": "None"} + assert events[-1].data == {"repeat": "None"} + for index, result in enumerate( + ( + ("True", "1", "False"), + ("True", "1", "False"), + ("False", "2", "True"), + ("True", "1", "False"), + ("False", "2", "True"), + ("True", "1", "False"), + ("False", "2", "True"), + ("False", "2", "True"), + ), + 1, + ): + assert events[index].data == { + "first": result[0], + "index": result[1], + "last": result[2], + } + + @pytest.mark.parametrize("var,result", [(1, "first"), (2, "second"), (3, "default")]) async def test_choose(hass, var, result): """Test choose action.""" diff --git a/tests/test_core.py b/tests/test_core.py index 884c5e98125..3c0a9ee4fb8 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -1393,3 +1393,24 @@ async def test_start_events(hass): EVENT_HOMEASSISTANT_STARTED, ] assert core_states == [ha.CoreState.starting, ha.CoreState.running] + + +async def test_log_blocking_events(hass, caplog): + """Ensure we log which task is blocking startup when debug logging is on.""" + caplog.set_level(logging.DEBUG) + + async def _wait_a_bit_1(): + await asyncio.sleep(0.1) + + async def _wait_a_bit_2(): + await asyncio.sleep(0.1) + + hass.async_create_task(_wait_a_bit_1()) + await hass.async_block_till_done() + + with patch.object(ha, "BLOCK_LOG_TIMEOUT", 0.00001): + hass.async_create_task(_wait_a_bit_2()) + await hass.async_block_till_done() + + assert "_wait_a_bit_2" in caplog.text + assert "_wait_a_bit_1" not in caplog.text