diff --git a/homeassistant/components/alexa/config.py b/homeassistant/components/alexa/config.py index cdbea2ca346..159bfebc624 100644 --- a/homeassistant/components/alexa/config.py +++ b/homeassistant/components/alexa/config.py @@ -3,7 +3,7 @@ from abc import ABC, abstractmethod import asyncio import logging -from homeassistant.core import CALLBACK_TYPE, callback +from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback from homeassistant.helpers.storage import Store from .const import DOMAIN @@ -19,7 +19,7 @@ class AbstractConfig(ABC): _unsub_proactive_report: asyncio.Task[CALLBACK_TYPE] | None = None - def __init__(self, hass): + def __init__(self, hass: HomeAssistant) -> None: """Initialize abstract config.""" self.hass = hass self._store = None diff --git a/homeassistant/components/cloud/alexa_config.py b/homeassistant/components/cloud/alexa_config.py index 4ba32c338b5..53bf44d8aa1 100644 --- a/homeassistant/components/cloud/alexa_config.py +++ b/homeassistant/components/cloud/alexa_config.py @@ -199,14 +199,10 @@ class CloudAlexaConfig(alexa_config.AbstractConfig): # Don't migrate if there's a YAML config return - for state in self.hass.states.async_all(): - async_expose_entity( - self.hass, - CLOUD_ALEXA, - state.entity_id, - self._should_expose_legacy(state.entity_id), - ) - for entity_id in self._prefs.alexa_entity_configs: + for entity_id in { + *self.hass.states.async_entity_ids(), + *self._prefs.alexa_entity_configs, + }: async_expose_entity( self.hass, CLOUD_ALEXA, @@ -220,8 +216,18 @@ class CloudAlexaConfig(alexa_config.AbstractConfig): async def on_hass_started(hass): if self._prefs.alexa_settings_version != ALEXA_SETTINGS_VERSION: - if self._prefs.alexa_settings_version < 2: + if self._prefs.alexa_settings_version < 2 or ( + # Recover from a bug we had in 2023.5.0 where entities didn't get exposed + self._prefs.alexa_settings_version < 3 + and not any( + settings.get("should_expose", False) + for settings in async_get_assistant_settings( + hass, CLOUD_ALEXA + ).values() + ) + ): self._migrate_alexa_entity_settings_v1() + await self._prefs.async_update( alexa_settings_version=ALEXA_SETTINGS_VERSION ) diff --git a/homeassistant/components/cloud/google_config.py b/homeassistant/components/cloud/google_config.py index 16848acc19d..351de5d0e65 100644 --- a/homeassistant/components/cloud/google_config.py +++ b/homeassistant/components/cloud/google_config.py @@ -12,6 +12,7 @@ from homeassistant.components.google_assistant import DOMAIN as GOOGLE_DOMAIN from homeassistant.components.google_assistant.helpers import AbstractConfig from homeassistant.components.homeassistant.exposed_entities import ( async_expose_entity, + async_get_assistant_settings, async_get_entity_settings, async_listen_entity_updates, async_set_assistant_option, @@ -175,23 +176,10 @@ class CloudGoogleConfig(AbstractConfig): # Don't migrate if there's a YAML config return - for state in self.hass.states.async_all(): - entity_id = state.entity_id - async_expose_entity( - self.hass, - CLOUD_GOOGLE, - entity_id, - self._should_expose_legacy(entity_id), - ) - if _2fa_disabled := (self._2fa_disabled_legacy(entity_id) is not None): - async_set_assistant_option( - self.hass, - CLOUD_GOOGLE, - entity_id, - PREF_DISABLE_2FA, - _2fa_disabled, - ) - for entity_id in self._prefs.google_entity_configs: + for entity_id in { + *self.hass.states.async_entity_ids(), + *self._prefs.google_entity_configs, + }: async_expose_entity( self.hass, CLOUD_GOOGLE, @@ -213,8 +201,18 @@ class CloudGoogleConfig(AbstractConfig): async def on_hass_started(hass: HomeAssistant) -> None: if self._prefs.google_settings_version != GOOGLE_SETTINGS_VERSION: - if self._prefs.google_settings_version < 2: + if self._prefs.google_settings_version < 2 or ( + # Recover from a bug we had in 2023.5.0 where entities didn't get exposed + self._prefs.google_settings_version < 3 + and not any( + settings.get("should_expose", False) + for settings in async_get_assistant_settings( + hass, CLOUD_GOOGLE + ).values() + ) + ): self._migrate_google_entity_settings_v1() + await self._prefs.async_update( google_settings_version=GOOGLE_SETTINGS_VERSION ) diff --git a/homeassistant/components/cloud/prefs.py b/homeassistant/components/cloud/prefs.py index 75e1856503c..5ccc007e524 100644 --- a/homeassistant/components/cloud/prefs.py +++ b/homeassistant/components/cloud/prefs.py @@ -41,8 +41,8 @@ STORAGE_KEY = DOMAIN STORAGE_VERSION = 1 STORAGE_VERSION_MINOR = 2 -ALEXA_SETTINGS_VERSION = 2 -GOOGLE_SETTINGS_VERSION = 2 +ALEXA_SETTINGS_VERSION = 3 +GOOGLE_SETTINGS_VERSION = 3 class CloudPreferencesStore(Store): diff --git a/homeassistant/components/elkm1/manifest.json b/homeassistant/components/elkm1/manifest.json index 26fab34f0e1..d7094a2e60b 100644 --- a/homeassistant/components/elkm1/manifest.json +++ b/homeassistant/components/elkm1/manifest.json @@ -15,5 +15,5 @@ "documentation": "https://www.home-assistant.io/integrations/elkm1", "iot_class": "local_push", "loggers": ["elkm1_lib"], - "requirements": ["elkm1-lib==2.2.1"] + "requirements": ["elkm1-lib==2.2.2"] } diff --git a/homeassistant/components/esphome/entry_data.py b/homeassistant/components/esphome/entry_data.py index 61d6262250c..7ce195d68fc 100644 --- a/homeassistant/components/esphome/entry_data.py +++ b/homeassistant/components/esphome/entry_data.py @@ -25,6 +25,7 @@ from aioesphomeapi import ( NumberInfo, SelectInfo, SensorInfo, + SensorState, SwitchInfo, TextSensorInfo, UserService, @@ -240,9 +241,18 @@ class RuntimeEntryData: current_state_by_type = self.state[state_type] current_state = current_state_by_type.get(key, _SENTINEL) subscription_key = (state_type, key) - if current_state == state and subscription_key not in stale_state: + if ( + current_state == state + and subscription_key not in stale_state + and not ( + type(state) is SensorState # pylint: disable=unidiomatic-typecheck + and (platform_info := self.info.get(Platform.SENSOR)) + and (entity_info := platform_info.get(state.key)) + and (cast(SensorInfo, entity_info)).force_update + ) + ): _LOGGER.debug( - "%s: ignoring duplicate update with and key %s: %s", + "%s: ignoring duplicate update with key %s: %s", self.name, key, state, diff --git a/homeassistant/components/esphome/manifest.json b/homeassistant/components/esphome/manifest.json index 3576dadd1c0..ff78996f3aa 100644 --- a/homeassistant/components/esphome/manifest.json +++ b/homeassistant/components/esphome/manifest.json @@ -15,7 +15,7 @@ "iot_class": "local_push", "loggers": ["aioesphomeapi", "noiseprotocol"], "requirements": [ - "aioesphomeapi==13.7.2", + "aioesphomeapi==13.7.3", "bluetooth-data-tools==0.4.0", "esphome-dashboard-api==1.2.3" ], diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index 41b363b6388..4e1e0a74fe9 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -20,5 +20,5 @@ "documentation": "https://www.home-assistant.io/integrations/frontend", "integration_type": "system", "quality_scale": "internal", - "requirements": ["home-assistant-frontend==20230503.2"] + "requirements": ["home-assistant-frontend==20230503.3"] } diff --git a/homeassistant/components/hassio/__init__.py b/homeassistant/components/hassio/__init__.py index 78d974fe9cf..42a51c218b1 100644 --- a/homeassistant/components/hassio/__init__.py +++ b/homeassistant/components/hassio/__init__.py @@ -590,7 +590,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa: await async_setup_addon_panel(hass, hassio) # Setup hardware integration for the detected board type - async def _async_setup_hardware_integration(hass): + async def _async_setup_hardware_integration(_: datetime) -> None: """Set up hardaware integration for the detected board type.""" if (os_info := get_os_info(hass)) is None: # os info not yet fetched from supervisor, retry later @@ -610,7 +610,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa: ) ) - await _async_setup_hardware_integration(hass) + await _async_setup_hardware_integration(datetime.now()) hass.async_create_task( hass.config_entries.flow.async_init(DOMAIN, context={"source": "system"}) diff --git a/homeassistant/components/netatmo/sensor.py b/homeassistant/components/netatmo/sensor.py index 25c42f92cef..949c7336ea4 100644 --- a/homeassistant/components/netatmo/sensor.py +++ b/homeassistant/components/netatmo/sensor.py @@ -266,7 +266,7 @@ SENSOR_TYPES: tuple[NetatmoSensorEntityDescription, ...] = ( netatmo_name="power", entity_registry_enabled_default=True, native_unit_of_measurement=UnitOfPower.WATT, - state_class=SensorStateClass.TOTAL, + state_class=SensorStateClass.MEASUREMENT, device_class=SensorDeviceClass.POWER, ), ) diff --git a/homeassistant/components/onvif/button.py b/homeassistant/components/onvif/button.py index cacf317f7bd..f263821a460 100644 --- a/homeassistant/components/onvif/button.py +++ b/homeassistant/components/onvif/button.py @@ -34,7 +34,7 @@ class RebootButton(ONVIFBaseEntity, ButtonEntity): async def async_press(self) -> None: """Send out a SystemReboot command.""" - device_mgmt = self.device.device.create_devicemgmt_service() + device_mgmt = await self.device.device.create_devicemgmt_service() await device_mgmt.SystemReboot() diff --git a/homeassistant/components/onvif/config_flow.py b/homeassistant/components/onvif/config_flow.py index 68a4ce52511..27f279266dd 100644 --- a/homeassistant/components/onvif/config_flow.py +++ b/homeassistant/components/onvif/config_flow.py @@ -275,7 +275,7 @@ class OnvifFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): try: await device.update_xaddrs() - device_mgmt = device.create_devicemgmt_service() + device_mgmt = await device.create_devicemgmt_service() # Get the MAC address to use as the unique ID for the config flow if not self.device_id: try: @@ -314,7 +314,7 @@ class OnvifFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): } ) # Verify there is an H264 profile - media_service = device.create_media_service() + media_service = await device.create_media_service() profiles = await media_service.GetProfiles() except AttributeError: # Likely an empty document or 404 from the wrong port LOGGER.debug( diff --git a/homeassistant/components/onvif/device.py b/homeassistant/components/onvif/device.py index f93529ea612..ea2325f271c 100644 --- a/homeassistant/components/onvif/device.py +++ b/homeassistant/components/onvif/device.py @@ -136,7 +136,7 @@ class ONVIFDevice: if self.capabilities.ptz: LOGGER.debug("%s: creating PTZ service", self.name) - self.device.create_ptz_service() + await self.device.create_ptz_service() # Determine max resolution from profiles self.max_resolution = max( @@ -159,7 +159,7 @@ class ONVIFDevice: async def async_manually_set_date_and_time(self) -> None: """Set Date and Time Manually using SetSystemDateAndTime command.""" - device_mgmt = self.device.create_devicemgmt_service() + device_mgmt = await self.device.create_devicemgmt_service() # Retrieve DateTime object from camera to use as template for Set operation device_time = await device_mgmt.GetSystemDateAndTime() @@ -202,7 +202,7 @@ class ONVIFDevice: async def async_check_date_and_time(self) -> None: """Warns if device and system date not synced.""" LOGGER.debug("%s: Setting up the ONVIF device management service", self.name) - device_mgmt = self.device.create_devicemgmt_service() + device_mgmt = await self.device.create_devicemgmt_service() system_date = dt_util.utcnow() LOGGER.debug("%s: Retrieving current device date/time", self.name) @@ -285,7 +285,7 @@ class ONVIFDevice: async def async_get_device_info(self) -> DeviceInfo: """Obtain information about this device.""" - device_mgmt = self.device.create_devicemgmt_service() + device_mgmt = await self.device.create_devicemgmt_service() manufacturer = None model = None firmware_version = None @@ -331,7 +331,7 @@ class ONVIFDevice: """Obtain information about the available services on the device.""" snapshot = False with suppress(*GET_CAPABILITIES_EXCEPTIONS): - media_service = self.device.create_media_service() + media_service = await self.device.create_media_service() media_capabilities = await media_service.GetServiceCapabilities() snapshot = media_capabilities and media_capabilities.SnapshotUri @@ -342,7 +342,7 @@ class ONVIFDevice: imaging = False with suppress(*GET_CAPABILITIES_EXCEPTIONS): - self.device.create_imaging_service() + await self.device.create_imaging_service() imaging = True return Capabilities(snapshot=snapshot, ptz=ptz, imaging=imaging) @@ -361,7 +361,7 @@ class ONVIFDevice: async def async_get_profiles(self) -> list[Profile]: """Obtain media profiles for this device.""" - media_service = self.device.create_media_service() + media_service = await self.device.create_media_service() LOGGER.debug("%s: xaddr for media_service: %s", self.name, media_service.xaddr) try: result = await media_service.GetProfiles() @@ -408,7 +408,7 @@ class ONVIFDevice: ) try: - ptz_service = self.device.create_ptz_service() + ptz_service = await self.device.create_ptz_service() presets = await ptz_service.GetPresets(profile.token) profile.ptz.presets = [preset.token for preset in presets if preset] except GET_CAPABILITIES_EXCEPTIONS: @@ -427,7 +427,7 @@ class ONVIFDevice: async def async_get_stream_uri(self, profile: Profile) -> str: """Get the stream URI for a specified profile.""" - media_service = self.device.create_media_service() + media_service = await self.device.create_media_service() req = media_service.create_type("GetStreamUri") req.ProfileToken = profile.token req.StreamSetup = { @@ -454,7 +454,7 @@ class ONVIFDevice: LOGGER.warning("PTZ actions are not supported on device '%s'", self.name) return - ptz_service = self.device.create_ptz_service() + ptz_service = await self.device.create_ptz_service() pan_val = distance * PAN_FACTOR.get(pan, 0) tilt_val = distance * TILT_FACTOR.get(tilt, 0) @@ -576,7 +576,7 @@ class ONVIFDevice: LOGGER.warning("PTZ actions are not supported on device '%s'", self.name) return - ptz_service = self.device.create_ptz_service() + ptz_service = await self.device.create_ptz_service() LOGGER.debug( "Running Aux Command | Cmd = %s", @@ -607,7 +607,7 @@ class ONVIFDevice: ) return - imaging_service = self.device.create_imaging_service() + imaging_service = await self.device.create_imaging_service() LOGGER.debug("Setting Imaging Setting | Settings = %s", settings) try: diff --git a/homeassistant/components/onvif/event.py b/homeassistant/components/onvif/event.py index 851b0f26d1b..507eda60097 100644 --- a/homeassistant/components/onvif/event.py +++ b/homeassistant/components/onvif/event.py @@ -9,7 +9,7 @@ import datetime as dt from aiohttp.web import Request from httpx import RemoteProtocolError, RequestError, TransportError from onvif import ONVIFCamera, ONVIFService -from onvif.client import NotificationManager +from onvif.client import NotificationManager, retry_connection_error from onvif.exceptions import ONVIFError from zeep.exceptions import Fault, ValidationError, XMLParseError @@ -40,8 +40,8 @@ SET_SYNCHRONIZATION_POINT_ERRORS = (*SUBSCRIPTION_ERRORS, TypeError) UNSUBSCRIBE_ERRORS = (XMLParseError, *SUBSCRIPTION_ERRORS) RENEW_ERRORS = (ONVIFError, RequestError, XMLParseError, *SUBSCRIPTION_ERRORS) # -# We only keep the subscription alive for 3 minutes, and will keep -# renewing it every 1.5 minutes. This is to avoid the camera +# We only keep the subscription alive for 10 minutes, and will keep +# renewing it every 8 minutes. This is to avoid the camera # accumulating subscriptions which will be impossible to clean up # since ONVIF does not provide a way to list existing subscriptions. # @@ -49,12 +49,25 @@ RENEW_ERRORS = (ONVIFError, RequestError, XMLParseError, *SUBSCRIPTION_ERRORS) # sending events to us, and we will not be able to recover until # the subscriptions expire or the camera is rebooted. # -SUBSCRIPTION_TIME = dt.timedelta(minutes=3) -SUBSCRIPTION_RELATIVE_TIME = ( - "PT3M" # use relative time since the time on the camera is not reliable -) -SUBSCRIPTION_RENEW_INTERVAL = SUBSCRIPTION_TIME.total_seconds() / 2 -SUBSCRIPTION_RENEW_INTERVAL_ON_ERROR = 60.0 +SUBSCRIPTION_TIME = dt.timedelta(minutes=10) + +# SUBSCRIPTION_RELATIVE_TIME uses a relative time since the time on the camera +# is not reliable. We use 600 seconds (10 minutes) since some cameras cannot +# parse time in the format "PT10M" (10 minutes). +SUBSCRIPTION_RELATIVE_TIME = "PT600S" + +# SUBSCRIPTION_RENEW_INTERVAL Must be less than the +# overall timeout of 90 * (SUBSCRIPTION_ATTEMPTS) 2 = 180 seconds +# +# We use 8 minutes between renewals to make sure we never hit the +# 10 minute limit even if the first renewal attempt fails +SUBSCRIPTION_RENEW_INTERVAL = 8 * 60 + +# The number of attempts to make when creating or renewing a subscription +SUBSCRIPTION_ATTEMPTS = 2 + +# The time to wait before trying to restart the subscription if it fails +SUBSCRIPTION_RESTART_INTERVAL_ON_ERROR = 60 PULLPOINT_POLL_TIME = dt.timedelta(seconds=60) PULLPOINT_MESSAGE_LIMIT = 100 @@ -276,7 +289,13 @@ class PullPointManager: """Pause pullpoint subscription.""" LOGGER.debug("%s: Pausing PullPoint manager", self._name) self.state = PullPointManagerState.PAUSED - self._hass.async_create_task(self._async_cancel_and_unsubscribe()) + # Cancel the renew job so we don't renew the subscription + # and stop pulling messages. + self._async_cancel_pullpoint_renew() + self.async_cancel_pull_messages() + # We do not unsubscribe from the pullpoint subscription and instead + # let the subscription expire since some cameras will terminate all + # subscriptions if we unsubscribe which will break the webhook. @callback def async_resume(self) -> None: @@ -327,20 +346,7 @@ class PullPointManager: async def _async_start_pullpoint(self) -> bool: """Start pullpoint subscription.""" try: - try: - started = await self._async_create_pullpoint_subscription() - except RequestError: - # - # We should only need to retry on RemoteProtocolError but some cameras - # are flaky and sometimes do not respond to the Renew request so we - # retry on RequestError as well. - # - # For RemoteProtocolError: - # http://datatracker.ietf.org/doc/html/rfc2616#section-8.1.4 allows the server - # to close the connection at any time, we treat this as a normal and try again - # once since we do not want to declare the camera as not supporting PullPoint - # if it just happened to close the connection at the wrong time. - started = await self._async_create_pullpoint_subscription() + started = await self._async_create_pullpoint_subscription() except CREATE_ERRORS as err: LOGGER.debug( "%s: Device does not support PullPoint service or has too many subscriptions: %s", @@ -372,16 +378,16 @@ class PullPointManager: # scheduled when the current one is done if needed. return async with self._renew_lock: - next_attempt = SUBSCRIPTION_RENEW_INTERVAL_ON_ERROR + next_attempt = SUBSCRIPTION_RESTART_INTERVAL_ON_ERROR try: - if ( - await self._async_renew_pullpoint() - or await self._async_restart_pullpoint() - ): + if await self._async_renew_pullpoint(): next_attempt = SUBSCRIPTION_RENEW_INTERVAL + else: + await self._async_restart_pullpoint() finally: self.async_schedule_pullpoint_renew(next_attempt) + @retry_connection_error(SUBSCRIPTION_ATTEMPTS) async def _async_create_pullpoint_subscription(self) -> bool: """Create pullpoint subscription.""" @@ -392,12 +398,12 @@ class PullPointManager: return False # Create subscription manager - self._pullpoint_subscription = self._device.create_subscription_service( + self._pullpoint_subscription = await self._device.create_subscription_service( "PullPointSubscription" ) # Create the service that will be used to pull messages from the device. - self._pullpoint_service = self._device.create_pullpoint_service() + self._pullpoint_service = await self._device.create_pullpoint_service() # Initialize events with suppress(*SET_SYNCHRONIZATION_POINT_ERRORS): @@ -447,6 +453,11 @@ class PullPointManager: ) self._pullpoint_subscription = None + @retry_connection_error(SUBSCRIPTION_ATTEMPTS) + async def _async_call_pullpoint_subscription_renew(self) -> None: + """Call PullPoint subscription Renew.""" + await self._pullpoint_subscription.Renew(SUBSCRIPTION_RELATIVE_TIME) + async def _async_renew_pullpoint(self) -> bool: """Renew the PullPoint subscription.""" if ( @@ -458,20 +469,7 @@ class PullPointManager: # The first time we renew, we may get a Fault error so we # suppress it. The subscription will be restarted in # async_restart later. - try: - await self._pullpoint_subscription.Renew(SUBSCRIPTION_RELATIVE_TIME) - except RequestError: - # - # We should only need to retry on RemoteProtocolError but some cameras - # are flaky and sometimes do not respond to the Renew request so we - # retry on RequestError as well. - # - # For RemoteProtocolError: - # http://datatracker.ietf.org/doc/html/rfc2616#section-8.1.4 allows the server - # to close the connection at any time, we treat this as a normal and try again - # once since we do not want to mark events as stale - # if it just happened to close the connection at the wrong time. - await self._pullpoint_subscription.Renew(SUBSCRIPTION_RELATIVE_TIME) + await self._async_call_pullpoint_subscription_renew() LOGGER.debug("%s: Renewed PullPoint subscription", self._name) return True except RENEW_ERRORS as err: @@ -521,7 +519,7 @@ class PullPointManager: stringify_onvif_error(err), ) return True - except (XMLParseError, *SUBSCRIPTION_ERRORS) as err: + except Fault as err: # Device may not support subscriptions so log at debug level # when we get an XMLParseError LOGGER.debug( @@ -532,6 +530,16 @@ class PullPointManager: # Treat errors as if the camera restarted. Assume that the pullpoint # subscription is no longer valid. return False + except (XMLParseError, RequestError, TimeoutError, TransportError) as err: + LOGGER.debug( + "%s: PullPoint subscription encountered an unexpected error and will be retried " + "(this is normal for some cameras): %s", + self._name, + stringify_onvif_error(err), + ) + # Avoid renewing the subscription too often since it causes problems + # for some cameras, mainly the Tapo ones. + return True if self.state != PullPointManagerState.STARTED: # If the webhook became started working during the long poll, @@ -655,6 +663,7 @@ class WebHookManager: self._renew_or_restart_job, ) + @retry_connection_error(SUBSCRIPTION_ATTEMPTS) async def _async_create_webhook_subscription(self) -> None: """Create webhook subscription.""" LOGGER.debug( @@ -689,20 +698,7 @@ class WebHookManager: async def _async_start_webhook(self) -> bool: """Start webhook.""" try: - try: - await self._async_create_webhook_subscription() - except RequestError: - # - # We should only need to retry on RemoteProtocolError but some cameras - # are flaky and sometimes do not respond to the Renew request so we - # retry on RequestError as well. - # - # For RemoteProtocolError: - # http://datatracker.ietf.org/doc/html/rfc2616#section-8.1.4 allows the server - # to close the connection at any time, we treat this as a normal and try again - # once since we do not want to declare the camera as not supporting webhooks - # if it just happened to close the connection at the wrong time. - await self._async_create_webhook_subscription() + await self._async_create_webhook_subscription() except CREATE_ERRORS as err: self._event_manager.async_webhook_failed() LOGGER.debug( @@ -720,6 +716,12 @@ class WebHookManager: await self._async_unsubscribe_webhook() return await self._async_start_webhook() + @retry_connection_error(SUBSCRIPTION_ATTEMPTS) + async def _async_call_webhook_subscription_renew(self) -> None: + """Call PullPoint subscription Renew.""" + assert self._webhook_subscription is not None + await self._webhook_subscription.Renew(SUBSCRIPTION_RELATIVE_TIME) + async def _async_renew_webhook(self) -> bool: """Renew webhook subscription.""" if ( @@ -728,20 +730,7 @@ class WebHookManager: ): return False try: - try: - await self._webhook_subscription.Renew(SUBSCRIPTION_RELATIVE_TIME) - except RequestError: - # - # We should only need to retry on RemoteProtocolError but some cameras - # are flaky and sometimes do not respond to the Renew request so we - # retry on RequestError as well. - # - # For RemoteProtocolError: - # http://datatracker.ietf.org/doc/html/rfc2616#section-8.1.4 allows the server - # to close the connection at any time, we treat this as a normal and try again - # once since we do not want to mark events as stale - # if it just happened to close the connection at the wrong time. - await self._webhook_subscription.Renew(SUBSCRIPTION_RELATIVE_TIME) + await self._async_call_webhook_subscription_renew() LOGGER.debug("%s: Renewed Webhook subscription", self._name) return True except RENEW_ERRORS as err: @@ -765,13 +754,12 @@ class WebHookManager: # scheduled when the current one is done if needed. return async with self._renew_lock: - next_attempt = SUBSCRIPTION_RENEW_INTERVAL_ON_ERROR + next_attempt = SUBSCRIPTION_RESTART_INTERVAL_ON_ERROR try: - if ( - await self._async_renew_webhook() - or await self._async_restart_webhook() - ): + if await self._async_renew_webhook(): next_attempt = SUBSCRIPTION_RENEW_INTERVAL + else: + await self._async_restart_webhook() finally: self._async_schedule_webhook_renew(next_attempt) diff --git a/homeassistant/components/onvif/manifest.json b/homeassistant/components/onvif/manifest.json index 17e7f1f0f29..f29fd562104 100644 --- a/homeassistant/components/onvif/manifest.json +++ b/homeassistant/components/onvif/manifest.json @@ -8,5 +8,5 @@ "documentation": "https://www.home-assistant.io/integrations/onvif", "iot_class": "local_push", "loggers": ["onvif", "wsdiscovery", "zeep"], - "requirements": ["onvif-zeep-async==1.3.1", "WSDiscovery==2.0.0"] + "requirements": ["onvif-zeep-async==2.1.1", "WSDiscovery==2.0.0"] } diff --git a/homeassistant/components/onvif/parsers.py b/homeassistant/components/onvif/parsers.py index 443254e125a..8e6e3e25861 100644 --- a/homeassistant/components/onvif/parsers.py +++ b/homeassistant/components/onvif/parsers.py @@ -15,6 +15,19 @@ PARSERS: Registry[ str, Callable[[str, Any], Coroutine[Any, Any, Event | None]] ] = Registry() +VIDEO_SOURCE_MAPPING = { + "vsconf": "VideoSourceToken", +} + + +def _normalize_video_source(source: str) -> str: + """Normalize video source. + + Some cameras do not set the VideoSourceToken correctly so we get duplicate + sensors, so we need to normalize it to the correct value. + """ + return VIDEO_SOURCE_MAPPING.get(source, source) + def local_datetime_or_none(value: str) -> datetime.datetime | None: """Convert strings to datetimes, if invalid, return None.""" @@ -188,7 +201,7 @@ async def async_parse_field_detector(uid: str, msg) -> Event | None: rule = "" for source in msg.Message._value_1.Source.SimpleItem: if source.Name == "VideoSourceConfigurationToken": - video_source = source.Value + video_source = _normalize_video_source(source.Value) if source.Name == "VideoAnalyticsConfigurationToken": video_analytics = source.Value if source.Name == "Rule": @@ -220,7 +233,7 @@ async def async_parse_cell_motion_detector(uid: str, msg) -> Event | None: rule = "" for source in msg.Message._value_1.Source.SimpleItem: if source.Name == "VideoSourceConfigurationToken": - video_source = source.Value + video_source = _normalize_video_source(source.Value) if source.Name == "VideoAnalyticsConfigurationToken": video_analytics = source.Value if source.Name == "Rule": @@ -251,7 +264,7 @@ async def async_parse_motion_region_detector(uid: str, msg) -> Event | None: rule = "" for source in msg.Message._value_1.Source.SimpleItem: if source.Name == "VideoSourceConfigurationToken": - video_source = source.Value + video_source = _normalize_video_source(source.Value) if source.Name == "VideoAnalyticsConfigurationToken": video_analytics = source.Value if source.Name == "Rule": @@ -282,7 +295,7 @@ async def async_parse_tamper_detector(uid: str, msg) -> Event | None: rule = "" for source in msg.Message._value_1.Source.SimpleItem: if source.Name == "VideoSourceConfigurationToken": - video_source = source.Value + video_source = _normalize_video_source(source.Value) if source.Name == "VideoAnalyticsConfigurationToken": video_analytics = source.Value if source.Name == "Rule": @@ -312,7 +325,7 @@ async def async_parse_dog_cat_detector(uid: str, msg) -> Event | None: video_source = "" for source in msg.Message._value_1.Source.SimpleItem: if source.Name == "Source": - video_source = source.Value + video_source = _normalize_video_source(source.Value) return Event( f"{uid}_{msg.Topic._value_1}_{video_source}", @@ -337,7 +350,7 @@ async def async_parse_vehicle_detector(uid: str, msg) -> Event | None: video_source = "" for source in msg.Message._value_1.Source.SimpleItem: if source.Name == "Source": - video_source = source.Value + video_source = _normalize_video_source(source.Value) return Event( f"{uid}_{msg.Topic._value_1}_{video_source}", @@ -362,7 +375,7 @@ async def async_parse_person_detector(uid: str, msg) -> Event | None: video_source = "" for source in msg.Message._value_1.Source.SimpleItem: if source.Name == "Source": - video_source = source.Value + video_source = _normalize_video_source(source.Value) return Event( f"{uid}_{msg.Topic._value_1}_{video_source}", @@ -387,7 +400,7 @@ async def async_parse_face_detector(uid: str, msg) -> Event | None: video_source = "" for source in msg.Message._value_1.Source.SimpleItem: if source.Name == "Source": - video_source = source.Value + video_source = _normalize_video_source(source.Value) return Event( f"{uid}_{msg.Topic._value_1}_{video_source}", @@ -401,6 +414,31 @@ async def async_parse_face_detector(uid: str, msg) -> Event | None: return None +@PARSERS.register("tns1:RuleEngine/MyRuleDetector/Visitor") +# pylint: disable=protected-access +async def async_parse_visitor_detector(uid: str, msg) -> Event | None: + """Handle parsing event message. + + Topic: tns1:RuleEngine/MyRuleDetector/Visitor + """ + try: + video_source = "" + for source in msg.Message._value_1.Source.SimpleItem: + if source.Name == "Source": + video_source = _normalize_video_source(source.Value) + + return Event( + f"{uid}_{msg.Topic._value_1}_{video_source}", + "Visitor Detection", + "binary_sensor", + "occupancy", + None, + msg.Message._value_1.Data.SimpleItem[0].Value == "true", + ) + except (AttributeError, KeyError): + return None + + @PARSERS.register("tns1:Device/Trigger/DigitalInput") # pylint: disable=protected-access async def async_parse_digital_input(uid: str, msg) -> Event | None: @@ -658,7 +696,7 @@ async def async_parse_count_aggregation_counter(uid: str, msg) -> Event | None: rule = "" for source in msg.Message._value_1.Source.SimpleItem: if source.Name == "VideoSourceConfigurationToken": - video_source = source.Value + video_source = _normalize_video_source(source.Value) if source.Name == "VideoAnalyticsConfigurationToken": video_analytics = source.Value if source.Name == "Rule": diff --git a/homeassistant/components/onvif/util.py b/homeassistant/components/onvif/util.py index 978473caa24..a88a37f5d20 100644 --- a/homeassistant/components/onvif/util.py +++ b/homeassistant/components/onvif/util.py @@ -34,7 +34,7 @@ def stringify_onvif_error(error: Exception) -> str: message += f" (actor:{error.actor})" else: message = str(error) - return message or "Device sent empty error" + return message or f"Device sent empty error with type {type(error)}" def is_auth_error(error: Exception) -> bool: diff --git a/homeassistant/components/opensky/sensor.py b/homeassistant/components/opensky/sensor.py index 4c96f2575f0..03e242f40b2 100644 --- a/homeassistant/components/opensky/sensor.py +++ b/homeassistant/components/opensky/sensor.py @@ -38,7 +38,8 @@ DEFAULT_ALTITUDE = 0 EVENT_OPENSKY_ENTRY = f"{DOMAIN}_entry" EVENT_OPENSKY_EXIT = f"{DOMAIN}_exit" -SCAN_INTERVAL = timedelta(seconds=12) # opensky public limit is 10 seconds +# OpenSky free user has 400 credits, with 4 credits per API call. 100/24 = ~4 requests per hour +SCAN_INTERVAL = timedelta(minutes=15) OPENSKY_API_URL = "https://opensky-network.org/api/states/all" OPENSKY_API_FIELDS = [ diff --git a/homeassistant/components/scene/services.yaml b/homeassistant/components/scene/services.yaml index cbe5e70f688..202b4a98aa9 100644 --- a/homeassistant/components/scene/services.yaml +++ b/homeassistant/components/scene/services.yaml @@ -29,7 +29,7 @@ apply: name: Entities state description: The entities and the state that they need to be. required: true - example: + example: | light.kitchen: "on" light.ceiling: state: "on" @@ -60,7 +60,7 @@ create: entities: name: Entities state description: The entities to control with the scene. - example: + example: | light.tv_back_light: "on" light.ceiling: state: "on" @@ -70,7 +70,7 @@ create: snapshot_entities: name: Snapshot entities description: The entities of which a snapshot is to be taken - example: + example: | - light.ceiling - light.kitchen selector: diff --git a/homeassistant/components/sia/alarm_control_panel.py b/homeassistant/components/sia/alarm_control_panel.py index 6a86ce81445..ef2ecc7aa23 100644 --- a/homeassistant/components/sia/alarm_control_panel.py +++ b/homeassistant/components/sia/alarm_control_panel.py @@ -123,7 +123,7 @@ class SIAAlarmControlPanel(SIABaseEntity, AlarmControlPanelEntity): """ new_state = None if sia_event.code: - new_state = self.entity_description.code_consequences[sia_event.code] + new_state = self.entity_description.code_consequences.get(sia_event.code) if new_state is None: return False _LOGGER.debug("New state will be %s", new_state) diff --git a/homeassistant/components/sia/binary_sensor.py b/homeassistant/components/sia/binary_sensor.py index 715fa26eee9..db0845473fd 100644 --- a/homeassistant/components/sia/binary_sensor.py +++ b/homeassistant/components/sia/binary_sensor.py @@ -132,7 +132,7 @@ class SIABinarySensor(SIABaseEntity, BinarySensorEntity): """ new_state = None if sia_event.code: - new_state = self.entity_description.code_consequences[sia_event.code] + new_state = self.entity_description.code_consequences.get(sia_event.code) if new_state is None: return False _LOGGER.debug("New state will be %s", new_state) diff --git a/homeassistant/components/tasmota/manifest.json b/homeassistant/components/tasmota/manifest.json index c9c135fcccb..a5a8ed2f0d2 100644 --- a/homeassistant/components/tasmota/manifest.json +++ b/homeassistant/components/tasmota/manifest.json @@ -8,5 +8,5 @@ "iot_class": "local_push", "loggers": ["hatasmota"], "mqtt": ["tasmota/discovery/#"], - "requirements": ["hatasmota==0.6.4"] + "requirements": ["hatasmota==0.6.5"] } diff --git a/homeassistant/components/transmission/__init__.py b/homeassistant/components/transmission/__init__.py index 765755d1248..d8623e7bbe5 100644 --- a/homeassistant/components/transmission/__init__.py +++ b/homeassistant/components/transmission/__init__.py @@ -7,7 +7,11 @@ import logging from typing import Any import transmission_rpc -from transmission_rpc.error import TransmissionError +from transmission_rpc.error import ( + TransmissionAuthError, + TransmissionConnectError, + TransmissionError, +) import voluptuous as vol from homeassistant.config_entries import ConfigEntry, ConfigEntryState @@ -137,14 +141,13 @@ async def get_api(hass, entry): _LOGGER.debug("Successfully connected to %s", host) return api + except TransmissionAuthError as error: + _LOGGER.error("Credentials for Transmission client are not valid") + raise AuthenticationError from error + except TransmissionConnectError as error: + _LOGGER.error("Connecting to the Transmission client %s failed", host) + raise CannotConnect from error except TransmissionError as error: - if "401: Unauthorized" in str(error): - _LOGGER.error("Credentials for Transmission client are not valid") - raise AuthenticationError from error - if "111: Connection refused" in str(error): - _LOGGER.error("Connecting to the Transmission client %s failed", host) - raise CannotConnect from error - _LOGGER.error(error) raise UnknownError from error diff --git a/homeassistant/components/zha/core/endpoint.py b/homeassistant/components/zha/core/endpoint.py index d134c033ed7..53a3fb883ef 100644 --- a/homeassistant/components/zha/core/endpoint.py +++ b/homeassistant/components/zha/core/endpoint.py @@ -137,7 +137,19 @@ class Endpoint: ): cluster_handler_class = MultistateInput # end of ugly hack - cluster_handler = cluster_handler_class(cluster, self) + + try: + cluster_handler = cluster_handler_class(cluster, self) + except KeyError as err: + _LOGGER.warning( + "Cluster handler %s for cluster %s on endpoint %s is invalid: %s", + cluster_handler_class, + cluster, + self, + err, + ) + continue + if cluster_handler.name == const.CLUSTER_HANDLER_POWER_CONFIGURATION: self._device.power_configuration_ch = cluster_handler elif cluster_handler.name == const.CLUSTER_HANDLER_IDENTIFY: diff --git a/homeassistant/const.py b/homeassistant/const.py index badec5be56f..7c9681ff2b4 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -8,7 +8,7 @@ from .backports.enum import StrEnum APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2023 MINOR_VERSION: Final = 5 -PATCH_VERSION: Final = "1" +PATCH_VERSION: Final = "2" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 10, 0) diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 63b89bbe5de..a30652fac8d 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -25,7 +25,7 @@ ha-av==10.0.0 hass-nabucasa==0.66.2 hassil==1.0.6 home-assistant-bluetooth==1.10.0 -home-assistant-frontend==20230503.2 +home-assistant-frontend==20230503.3 home-assistant-intents==2023.4.26 httpx==0.24.0 ifaddr==0.1.7 diff --git a/pyproject.toml b/pyproject.toml index d3c150305bd..20a02528aff 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2023.5.1" +version = "2023.5.2" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" diff --git a/requirements_all.txt b/requirements_all.txt index 2cdf860f642..5e3d4d52d5c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -156,7 +156,7 @@ aioecowitt==2023.01.0 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==13.7.2 +aioesphomeapi==13.7.3 # homeassistant.components.flo aioflo==2021.11.0 @@ -644,7 +644,7 @@ elgato==4.0.1 eliqonline==1.2.2 # homeassistant.components.elkm1 -elkm1-lib==2.2.1 +elkm1-lib==2.2.2 # homeassistant.components.elmax elmax_api==0.0.4 @@ -881,7 +881,7 @@ hass_splunk==0.1.1 hassil==1.0.6 # homeassistant.components.tasmota -hatasmota==0.6.4 +hatasmota==0.6.5 # homeassistant.components.jewish_calendar hdate==0.10.4 @@ -911,7 +911,7 @@ hole==0.8.0 holidays==0.21.13 # homeassistant.components.frontend -home-assistant-frontend==20230503.2 +home-assistant-frontend==20230503.3 # homeassistant.components.conversation home-assistant-intents==2023.4.26 @@ -1264,7 +1264,7 @@ ondilo==0.2.0 onkyo-eiscp==1.2.7 # homeassistant.components.onvif -onvif-zeep-async==1.3.1 +onvif-zeep-async==2.1.1 # homeassistant.components.opengarage open-garage==0.2.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 048f57d1f3d..6762e481d02 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -146,7 +146,7 @@ aioecowitt==2023.01.0 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==13.7.2 +aioesphomeapi==13.7.3 # homeassistant.components.flo aioflo==2021.11.0 @@ -506,7 +506,7 @@ easyenergy==0.3.0 elgato==4.0.1 # homeassistant.components.elkm1 -elkm1-lib==2.2.1 +elkm1-lib==2.2.2 # homeassistant.components.elmax elmax_api==0.0.4 @@ -679,7 +679,7 @@ hass-nabucasa==0.66.2 hassil==1.0.6 # homeassistant.components.tasmota -hatasmota==0.6.4 +hatasmota==0.6.5 # homeassistant.components.jewish_calendar hdate==0.10.4 @@ -700,7 +700,7 @@ hole==0.8.0 holidays==0.21.13 # homeassistant.components.frontend -home-assistant-frontend==20230503.2 +home-assistant-frontend==20230503.3 # homeassistant.components.conversation home-assistant-intents==2023.4.26 @@ -945,7 +945,7 @@ omnilogic==0.4.5 ondilo==0.2.0 # homeassistant.components.onvif -onvif-zeep-async==1.3.1 +onvif-zeep-async==2.1.1 # homeassistant.components.opengarage open-garage==0.2.0 diff --git a/tests/components/cloud/test_alexa_config.py b/tests/components/cloud/test_alexa_config.py index 3a7e5a0874e..2be2a8eb2bb 100644 --- a/tests/components/cloud/test_alexa_config.py +++ b/tests/components/cloud/test_alexa_config.py @@ -542,11 +542,13 @@ async def test_alexa_handle_logout( assert len(mock_enable.return_value.mock_calls) == 1 +@pytest.mark.parametrize("alexa_settings_version", [1, 2]) async def test_alexa_config_migrate_expose_entity_prefs( hass: HomeAssistant, cloud_prefs: CloudPreferences, cloud_stub, entity_registry: er.EntityRegistry, + alexa_settings_version: int, ) -> None: """Test migrating Alexa entity config.""" hass.state = CoreState.starting @@ -593,7 +595,7 @@ async def test_alexa_config_migrate_expose_entity_prefs( await cloud_prefs.async_update( alexa_enabled=True, alexa_report_state=False, - alexa_settings_version=1, + alexa_settings_version=alexa_settings_version, ) expose_entity(hass, entity_migrated.entity_id, False) @@ -641,6 +643,100 @@ async def test_alexa_config_migrate_expose_entity_prefs( } +async def test_alexa_config_migrate_expose_entity_prefs_v2_no_exposed( + hass: HomeAssistant, + cloud_prefs: CloudPreferences, + entity_registry: er.EntityRegistry, +) -> None: + """Test migrating Alexa entity config from v2 to v3 when no entity is exposed.""" + hass.state = CoreState.starting + + assert await async_setup_component(hass, "homeassistant", {}) + hass.states.async_set("light.state_only", "on") + entity_migrated = entity_registry.async_get_or_create( + "light", + "test", + "light_migrated", + suggested_object_id="migrated", + ) + await cloud_prefs.async_update( + alexa_enabled=True, + alexa_report_state=False, + alexa_settings_version=2, + ) + expose_entity(hass, "light.state_only", False) + expose_entity(hass, entity_migrated.entity_id, False) + + cloud_prefs._prefs[PREF_ALEXA_ENTITY_CONFIGS]["light.state_only"] = { + PREF_SHOULD_EXPOSE: True + } + cloud_prefs._prefs[PREF_ALEXA_ENTITY_CONFIGS][entity_migrated.entity_id] = { + PREF_SHOULD_EXPOSE: True + } + conf = alexa_config.CloudAlexaConfig( + hass, ALEXA_SCHEMA({}), "mock-user-id", cloud_prefs, Mock(is_logged_in=False) + ) + await conf.async_initialize() + hass.bus.async_fire(EVENT_HOMEASSISTANT_START) + await hass.async_block_till_done() + hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) + await hass.async_block_till_done() + + assert async_get_entity_settings(hass, "light.state_only") == { + "cloud.alexa": {"should_expose": True} + } + assert async_get_entity_settings(hass, entity_migrated.entity_id) == { + "cloud.alexa": {"should_expose": True} + } + + +async def test_alexa_config_migrate_expose_entity_prefs_v2_exposed( + hass: HomeAssistant, + cloud_prefs: CloudPreferences, + entity_registry: er.EntityRegistry, +) -> None: + """Test migrating Alexa entity config from v2 to v3 when an entity is exposed.""" + hass.state = CoreState.starting + + assert await async_setup_component(hass, "homeassistant", {}) + hass.states.async_set("light.state_only", "on") + entity_migrated = entity_registry.async_get_or_create( + "light", + "test", + "light_migrated", + suggested_object_id="migrated", + ) + await cloud_prefs.async_update( + alexa_enabled=True, + alexa_report_state=False, + alexa_settings_version=2, + ) + expose_entity(hass, "light.state_only", False) + expose_entity(hass, entity_migrated.entity_id, True) + + cloud_prefs._prefs[PREF_ALEXA_ENTITY_CONFIGS]["light.state_only"] = { + PREF_SHOULD_EXPOSE: True + } + cloud_prefs._prefs[PREF_ALEXA_ENTITY_CONFIGS][entity_migrated.entity_id] = { + PREF_SHOULD_EXPOSE: True + } + conf = alexa_config.CloudAlexaConfig( + hass, ALEXA_SCHEMA({}), "mock-user-id", cloud_prefs, Mock(is_logged_in=False) + ) + await conf.async_initialize() + hass.bus.async_fire(EVENT_HOMEASSISTANT_START) + await hass.async_block_till_done() + hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) + await hass.async_block_till_done() + + assert async_get_entity_settings(hass, "light.state_only") == { + "cloud.alexa": {"should_expose": False} + } + assert async_get_entity_settings(hass, entity_migrated.entity_id) == { + "cloud.alexa": {"should_expose": True} + } + + async def test_alexa_config_migrate_expose_entity_prefs_default_none( hass: HomeAssistant, cloud_prefs: CloudPreferences, diff --git a/tests/components/cloud/test_google_config.py b/tests/components/cloud/test_google_config.py index 45bc56a1700..fe60ca971a1 100644 --- a/tests/components/cloud/test_google_config.py +++ b/tests/components/cloud/test_google_config.py @@ -483,10 +483,12 @@ async def test_google_handle_logout( assert len(mock_enable.return_value.mock_calls) == 1 +@pytest.mark.parametrize("google_settings_version", [1, 2]) async def test_google_config_migrate_expose_entity_prefs( hass: HomeAssistant, cloud_prefs: CloudPreferences, entity_registry: er.EntityRegistry, + google_settings_version: int, ) -> None: """Test migrating Google entity config.""" hass.state = CoreState.starting @@ -540,7 +542,7 @@ async def test_google_config_migrate_expose_entity_prefs( await cloud_prefs.async_update( google_enabled=True, google_report_state=False, - google_settings_version=1, + google_settings_version=google_settings_version, ) expose_entity(hass, entity_migrated.entity_id, False) @@ -596,6 +598,100 @@ async def test_google_config_migrate_expose_entity_prefs( } +async def test_google_config_migrate_expose_entity_prefs_v2_no_exposed( + hass: HomeAssistant, + cloud_prefs: CloudPreferences, + entity_registry: er.EntityRegistry, +) -> None: + """Test migrating Google entity config from v2 to v3 when no entity is exposed.""" + hass.state = CoreState.starting + + assert await async_setup_component(hass, "homeassistant", {}) + hass.states.async_set("light.state_only", "on") + entity_migrated = entity_registry.async_get_or_create( + "light", + "test", + "light_migrated", + suggested_object_id="migrated", + ) + await cloud_prefs.async_update( + google_enabled=True, + google_report_state=False, + google_settings_version=2, + ) + expose_entity(hass, "light.state_only", False) + expose_entity(hass, entity_migrated.entity_id, False) + + cloud_prefs._prefs[PREF_GOOGLE_ENTITY_CONFIGS]["light.state_only"] = { + PREF_SHOULD_EXPOSE: True + } + cloud_prefs._prefs[PREF_GOOGLE_ENTITY_CONFIGS][entity_migrated.entity_id] = { + PREF_SHOULD_EXPOSE: True + } + conf = CloudGoogleConfig( + hass, GACTIONS_SCHEMA({}), "mock-user-id", cloud_prefs, Mock(is_logged_in=False) + ) + await conf.async_initialize() + hass.bus.async_fire(EVENT_HOMEASSISTANT_START) + await hass.async_block_till_done() + hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) + await hass.async_block_till_done() + + assert async_get_entity_settings(hass, "light.state_only") == { + "cloud.google_assistant": {"should_expose": True} + } + assert async_get_entity_settings(hass, entity_migrated.entity_id) == { + "cloud.google_assistant": {"should_expose": True} + } + + +async def test_google_config_migrate_expose_entity_prefs_v2_exposed( + hass: HomeAssistant, + cloud_prefs: CloudPreferences, + entity_registry: er.EntityRegistry, +) -> None: + """Test migrating Google entity config from v2 to v3 when an entity is exposed.""" + hass.state = CoreState.starting + + assert await async_setup_component(hass, "homeassistant", {}) + hass.states.async_set("light.state_only", "on") + entity_migrated = entity_registry.async_get_or_create( + "light", + "test", + "light_migrated", + suggested_object_id="migrated", + ) + await cloud_prefs.async_update( + google_enabled=True, + google_report_state=False, + google_settings_version=2, + ) + expose_entity(hass, "light.state_only", False) + expose_entity(hass, entity_migrated.entity_id, True) + + cloud_prefs._prefs[PREF_GOOGLE_ENTITY_CONFIGS]["light.state_only"] = { + PREF_SHOULD_EXPOSE: True + } + cloud_prefs._prefs[PREF_GOOGLE_ENTITY_CONFIGS][entity_migrated.entity_id] = { + PREF_SHOULD_EXPOSE: True + } + conf = CloudGoogleConfig( + hass, GACTIONS_SCHEMA({}), "mock-user-id", cloud_prefs, Mock(is_logged_in=False) + ) + await conf.async_initialize() + hass.bus.async_fire(EVENT_HOMEASSISTANT_START) + await hass.async_block_till_done() + hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) + await hass.async_block_till_done() + + assert async_get_entity_settings(hass, "light.state_only") == { + "cloud.google_assistant": {"should_expose": False} + } + assert async_get_entity_settings(hass, entity_migrated.entity_id) == { + "cloud.google_assistant": {"should_expose": True} + } + + async def test_google_config_migrate_expose_entity_prefs_default_none( hass: HomeAssistant, cloud_prefs: CloudPreferences, diff --git a/tests/components/onvif/__init__.py b/tests/components/onvif/__init__.py index 18de9839e1b..a56e0a477e7 100644 --- a/tests/components/onvif/__init__.py +++ b/tests/components/onvif/__init__.py @@ -98,8 +98,8 @@ def setup_mock_onvif_camera( ) else: mock_onvif_camera.update_xaddrs = AsyncMock(return_value=True) - mock_onvif_camera.create_devicemgmt_service = MagicMock(return_value=devicemgmt) - mock_onvif_camera.create_media_service = MagicMock(return_value=media_service) + mock_onvif_camera.create_devicemgmt_service = AsyncMock(return_value=devicemgmt) + mock_onvif_camera.create_media_service = AsyncMock(return_value=media_service) mock_onvif_camera.close = AsyncMock(return_value=None) def mock_constructor( diff --git a/tests/components/onvif/test_button.py b/tests/components/onvif/test_button.py index 4c2dda760e4..4b30bc7bdd1 100644 --- a/tests/components/onvif/test_button.py +++ b/tests/components/onvif/test_button.py @@ -27,7 +27,7 @@ async def test_reboot_button(hass: HomeAssistant) -> None: async def test_reboot_button_press(hass: HomeAssistant) -> None: """Test Reboot button press.""" _, camera, _ = await setup_onvif_integration(hass) - devicemgmt = camera.create_devicemgmt_service() + devicemgmt = await camera.create_devicemgmt_service() devicemgmt.SystemReboot = AsyncMock(return_value=True) await hass.services.async_call( diff --git a/tests/components/tasmota/test_sensor.py b/tests/components/tasmota/test_sensor.py index 7eee8fcbe7c..1d9334a2657 100644 --- a/tests/components/tasmota/test_sensor.py +++ b/tests/components/tasmota/test_sensor.py @@ -102,7 +102,7 @@ INDEXED_SENSOR_CONFIG_2 = { } -NESTED_SENSOR_CONFIG = { +NESTED_SENSOR_CONFIG_1 = { "sn": { "Time": "2020-03-03T00:00:00+00:00", "TX23": { @@ -119,6 +119,17 @@ NESTED_SENSOR_CONFIG = { } } +NESTED_SENSOR_CONFIG_2 = { + "sn": { + "Time": "2023-01-27T11:04:56", + "DS18B20": { + "Id": "01191ED79190", + "Temperature": 2.4, + }, + "TempUnit": "C", + } +} + async def test_controlling_state_via_mqtt( hass: HomeAssistant, mqtt_mock: MqttMockHAClient, setup_tasmota @@ -174,12 +185,59 @@ async def test_controlling_state_via_mqtt( assert state.state == "20.0" +@pytest.mark.parametrize( + ("sensor_config", "entity_ids", "messages", "states"), + [ + ( + NESTED_SENSOR_CONFIG_1, + ["sensor.tasmota_tx23_speed_act", "sensor.tasmota_tx23_dir_card"], + ( + '{"TX23":{"Speed":{"Act":"12.3"},"Dir": {"Card": "WSW"}}}', + '{"StatusSNS":{"TX23":{"Speed":{"Act":"23.4"},"Dir": {"Card": "ESE"}}}}', + ), + ( + { + "sensor.tasmota_tx23_speed_act": "12.3", + "sensor.tasmota_tx23_dir_card": "WSW", + }, + { + "sensor.tasmota_tx23_speed_act": "23.4", + "sensor.tasmota_tx23_dir_card": "ESE", + }, + ), + ), + ( + NESTED_SENSOR_CONFIG_2, + ["sensor.tasmota_ds18b20_temperature", "sensor.tasmota_ds18b20_id"], + ( + '{"DS18B20":{"Id": "01191ED79190","Temperature": 12.3}}', + '{"StatusSNS":{"DS18B20":{"Id": "meep","Temperature": 23.4}}}', + ), + ( + { + "sensor.tasmota_ds18b20_temperature": "12.3", + "sensor.tasmota_ds18b20_id": "01191ED79190", + }, + { + "sensor.tasmota_ds18b20_temperature": "23.4", + "sensor.tasmota_ds18b20_id": "meep", + }, + ), + ), + ], +) async def test_nested_sensor_state_via_mqtt( - hass: HomeAssistant, mqtt_mock: MqttMockHAClient, setup_tasmota + hass: HomeAssistant, + mqtt_mock: MqttMockHAClient, + setup_tasmota, + sensor_config, + entity_ids, + messages, + states, ) -> None: """Test state update via MQTT.""" config = copy.deepcopy(DEFAULT_CONFIG) - sensor_config = copy.deepcopy(NESTED_SENSOR_CONFIG) + sensor_config = copy.deepcopy(sensor_config) mac = config["mac"] async_fire_mqtt_message( @@ -195,31 +253,29 @@ async def test_nested_sensor_state_via_mqtt( ) await hass.async_block_till_done() - state = hass.states.get("sensor.tasmota_tx23_speed_act") - assert state.state == "unavailable" - assert not state.attributes.get(ATTR_ASSUMED_STATE) + for entity_id in entity_ids: + state = hass.states.get(entity_id) + assert state.state == "unavailable" + assert not state.attributes.get(ATTR_ASSUMED_STATE) async_fire_mqtt_message(hass, "tasmota_49A3BC/tele/LWT", "Online") await hass.async_block_till_done() - state = hass.states.get("sensor.tasmota_tx23_speed_act") - assert state.state == STATE_UNKNOWN - assert not state.attributes.get(ATTR_ASSUMED_STATE) + for entity_id in entity_ids: + state = hass.states.get(entity_id) + assert state.state == STATE_UNKNOWN + assert not state.attributes.get(ATTR_ASSUMED_STATE) # Test periodic state update - async_fire_mqtt_message( - hass, "tasmota_49A3BC/tele/SENSOR", '{"TX23":{"Speed":{"Act":"12.3"}}}' - ) - state = hass.states.get("sensor.tasmota_tx23_speed_act") - assert state.state == "12.3" + async_fire_mqtt_message(hass, "tasmota_49A3BC/tele/SENSOR", messages[0]) + for entity_id in entity_ids: + state = hass.states.get(entity_id) + assert state.state == states[0][entity_id] # Test polled state update - async_fire_mqtt_message( - hass, - "tasmota_49A3BC/stat/STATUS10", - '{"StatusSNS":{"TX23":{"Speed":{"Act":"23.4"}}}}', - ) - state = hass.states.get("sensor.tasmota_tx23_speed_act") - assert state.state == "23.4" + async_fire_mqtt_message(hass, "tasmota_49A3BC/stat/STATUS10", messages[1]) + for entity_id in entity_ids: + state = hass.states.get(entity_id) + assert state.state == states[1][entity_id] async def test_indexed_sensor_state_via_mqtt( @@ -728,7 +784,7 @@ async def test_nested_sensor_attributes( ) -> None: """Test correct attributes for sensors.""" config = copy.deepcopy(DEFAULT_CONFIG) - sensor_config = copy.deepcopy(NESTED_SENSOR_CONFIG) + sensor_config = copy.deepcopy(NESTED_SENSOR_CONFIG_1) mac = config["mac"] async_fire_mqtt_message( @@ -754,7 +810,7 @@ async def test_nested_sensor_attributes( assert state.attributes.get("device_class") is None assert state.attributes.get("friendly_name") == "Tasmota TX23 Dir Avg" assert state.attributes.get("icon") is None - assert state.attributes.get("unit_of_measurement") == " " + assert state.attributes.get("unit_of_measurement") is None async def test_indexed_sensor_attributes( diff --git a/tests/components/transmission/test_config_flow.py b/tests/components/transmission/test_config_flow.py index d163708ce28..b4fae8e6f3d 100644 --- a/tests/components/transmission/test_config_flow.py +++ b/tests/components/transmission/test_config_flow.py @@ -2,7 +2,11 @@ from unittest.mock import MagicMock, patch import pytest -from transmission_rpc.error import TransmissionError +from transmission_rpc.error import ( + TransmissionAuthError, + TransmissionConnectError, + TransmissionError, +) from homeassistant import config_entries from homeassistant.components import transmission @@ -125,7 +129,7 @@ async def test_error_on_wrong_credentials( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - mock_api.side_effect = TransmissionError("401: Unauthorized") + mock_api.side_effect = TransmissionAuthError() result2 = await hass.config_entries.flow.async_configure( result["flow_id"], MOCK_CONFIG_DATA, @@ -137,6 +141,21 @@ async def test_error_on_wrong_credentials( } +async def test_unexpected_error(hass: HomeAssistant, mock_api: MagicMock) -> None: + """Test we handle unexpected error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + mock_api.side_effect = TransmissionError() + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + MOCK_CONFIG_DATA, + ) + assert result2["type"] == FlowResultType.FORM + assert result2["errors"] == {"base": "cannot_connect"} + + async def test_error_on_connection_failure( hass: HomeAssistant, mock_api: MagicMock ) -> None: @@ -145,7 +164,7 @@ async def test_error_on_connection_failure( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - mock_api.side_effect = TransmissionError("111: Connection refused") + mock_api.side_effect = TransmissionConnectError() result2 = await hass.config_entries.flow.async_configure( result["flow_id"], MOCK_CONFIG_DATA, @@ -213,7 +232,7 @@ async def test_reauth_failed(hass: HomeAssistant, mock_api: MagicMock) -> None: assert result["step_id"] == "reauth_confirm" assert result["description_placeholders"] == {"username": "user"} - mock_api.side_effect = TransmissionError("401: Unauthorized") + mock_api.side_effect = TransmissionAuthError() result2 = await hass.config_entries.flow.async_configure( result["flow_id"], { @@ -248,7 +267,7 @@ async def test_reauth_failed_connection_error( assert result["step_id"] == "reauth_confirm" assert result["description_placeholders"] == {"username": "user"} - mock_api.side_effect = TransmissionError("111: Connection refused") + mock_api.side_effect = TransmissionConnectError() result2 = await hass.config_entries.flow.async_configure( result["flow_id"], { diff --git a/tests/components/transmission/test_init.py b/tests/components/transmission/test_init.py index da5e6859544..89ad0dd2410 100644 --- a/tests/components/transmission/test_init.py +++ b/tests/components/transmission/test_init.py @@ -3,7 +3,11 @@ from unittest.mock import MagicMock, patch import pytest -from transmission_rpc.error import TransmissionError +from transmission_rpc.error import ( + TransmissionAuthError, + TransmissionConnectError, + TransmissionError, +) from homeassistant.components.transmission.const import DOMAIN from homeassistant.config_entries import ConfigEntryState @@ -40,7 +44,7 @@ async def test_setup_failed_connection_error( entry = MockConfigEntry(domain=DOMAIN, data=MOCK_CONFIG_DATA) entry.add_to_hass(hass) - mock_api.side_effect = TransmissionError("111: Connection refused") + mock_api.side_effect = TransmissionConnectError() await hass.config_entries.async_setup(entry.entry_id) assert entry.state == ConfigEntryState.SETUP_RETRY @@ -54,7 +58,21 @@ async def test_setup_failed_auth_error( entry = MockConfigEntry(domain=DOMAIN, data=MOCK_CONFIG_DATA) entry.add_to_hass(hass) - mock_api.side_effect = TransmissionError("401: Unauthorized") + mock_api.side_effect = TransmissionAuthError() + + await hass.config_entries.async_setup(entry.entry_id) + assert entry.state == ConfigEntryState.SETUP_ERROR + + +async def test_setup_failed_unexpected_error( + hass: HomeAssistant, mock_api: MagicMock +) -> None: + """Test integration failed due to unexpected error.""" + + entry = MockConfigEntry(domain=DOMAIN, data=MOCK_CONFIG_DATA) + entry.add_to_hass(hass) + + mock_api.side_effect = TransmissionError() await hass.config_entries.async_setup(entry.entry_id) assert entry.state == ConfigEntryState.SETUP_ERROR diff --git a/tests/components/zha/test_cluster_handlers.py b/tests/components/zha/test_cluster_handlers.py index c0c455542d3..1897383b6c4 100644 --- a/tests/components/zha/test_cluster_handlers.py +++ b/tests/components/zha/test_cluster_handlers.py @@ -1,11 +1,13 @@ """Test ZHA Core cluster handlers.""" import asyncio from collections.abc import Callable +import logging import math from unittest import mock from unittest.mock import AsyncMock, patch import pytest +import zigpy.device import zigpy.endpoint from zigpy.endpoint import Endpoint as ZigpyEndpoint import zigpy.profiles.zha @@ -791,3 +793,41 @@ async def test_configure_reporting(hass: HomeAssistant, endpoint) -> None: } ), ] + + +async def test_invalid_cluster_handler(hass: HomeAssistant, caplog) -> None: + """Test setting up a cluster handler that fails to match properly.""" + + class TestZigbeeClusterHandler(cluster_handlers.ClusterHandler): + REPORT_CONFIG = ( + cluster_handlers.AttrReportConfig(attr="missing_attr", config=(1, 60, 1)), + ) + + mock_device = mock.AsyncMock(spec_set=zigpy.device.Device) + zigpy_ep = zigpy.endpoint.Endpoint(mock_device, endpoint_id=1) + + cluster = zigpy_ep.add_input_cluster(zigpy.zcl.clusters.lighting.Color.cluster_id) + cluster.configure_reporting_multiple = AsyncMock( + spec_set=cluster.configure_reporting_multiple, + return_value=[ + foundation.ConfigureReportingResponseRecord( + status=foundation.Status.SUCCESS + ) + ], + ) + + mock_zha_device = mock.AsyncMock(spec_set=ZHADevice) + zha_endpoint = Endpoint(zigpy_ep, mock_zha_device) + + # The cluster handler throws an error when matching this cluster + with pytest.raises(KeyError): + TestZigbeeClusterHandler(cluster, zha_endpoint) + + # And one is also logged at runtime + with patch.dict( + registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY, + {cluster.cluster_id: TestZigbeeClusterHandler}, + ), caplog.at_level(logging.WARNING): + zha_endpoint.add_all_cluster_handlers() + + assert "missing_attr" in caplog.text