diff --git a/homeassistant/components/sonos/__init__.py b/homeassistant/components/sonos/__init__.py index 0231fca42dd5eb..33d82e072882c7 100644 --- a/homeassistant/components/sonos/__init__.py +++ b/homeassistant/components/sonos/__init__.py @@ -5,6 +5,7 @@ import asyncio import datetime from functools import partial +from http import HTTPStatus from ipaddress import AddressValueError, IPv4Address import logging import socket @@ -12,7 +13,7 @@ from urllib.parse import urlparse from aiohttp import ClientError -from requests.exceptions import Timeout +from requests.exceptions import HTTPError, Timeout from soco import events_asyncio, zonegroupstate import soco.config as soco_config from soco.core import SoCo @@ -54,6 +55,8 @@ SUB_FAIL_ISSUE_ID, SUB_FAIL_URL, SUBSCRIPTION_TIMEOUT, + UPNP_DOCUMENTATION_URL, + UPNP_ISSUE_ID, UPNP_ST, ) from .exception import SonosUpdateError @@ -184,6 +187,32 @@ def is_device_invisible(self, ip_address: str) -> bool: """Check if device at provided IP is known to be invisible.""" return any(x for x in self._known_invisible if x.ip_address == ip_address) + async def _process_http_connection_error( + self, err: HTTPError, ip_address: str + ) -> None: + """Process HTTP Errors when connecting to a Sonos speaker.""" + response = err.response + # When UPnP is disabled, Sonos returns HTTP 403 Forbidden error. + # Create issue advising user to enable UPnP on Sonos system. + if response is not None and response.status_code == HTTPStatus.FORBIDDEN: + ir.async_create_issue( + self.hass, + DOMAIN, + f"{UPNP_ISSUE_ID}_{ip_address}", + is_fixable=False, + severity=ir.IssueSeverity.ERROR, + translation_key="upnp_disabled", + translation_placeholders={ + "device_ip": ip_address, + "documentation_url": UPNP_DOCUMENTATION_URL, + }, + ) + _LOGGER.error( + "HTTP error connecting to Sonos speaker at %s: %s", + ip_address, + err, + ) + async def async_subscribe_to_zone_updates(self, ip_address: str) -> None: """Test subscriptions and create SonosSpeakers based on results.""" try: @@ -195,13 +224,29 @@ async def async_subscribe_to_zone_updates(self, ip_address: str) -> None: ) return soco = SoCo(ip_address) - # Cache now to avoid household ID lookup during first ZoneGroupState processing - await self.hass.async_add_executor_job( - getattr, - soco, - "household_id", - ) - sub = await soco.zoneGroupTopology.subscribe() + try: + # Cache now to avoid household ID lookup during first ZoneGroupState processing + await self.hass.async_add_executor_job( + getattr, + soco, + "household_id", + ) + sub = await soco.zoneGroupTopology.subscribe() + except HTTPError as err: + await self._process_http_connection_error(err, ip_address) + return + except ( + OSError, + SoCoException, + Timeout, + TimeoutError, + ) as err: + _LOGGER.error( + "Error connecting to discovered Sonos speaker at %s: %s", + ip_address, + err, + ) + return @callback def _async_add_visible_zones(subscription_succeeded: bool = False) -> None: @@ -390,6 +435,9 @@ async def async_poll_manual_hosts( sync_get_visible_zones, soco, ) + except HTTPError as err: + await self._process_http_connection_error(err, ip_addr) + continue except ( OSError, SoCoException, diff --git a/homeassistant/components/sonos/const.py b/homeassistant/components/sonos/const.py index 20e079c901d482..31db15f70cc666 100644 --- a/homeassistant/components/sonos/const.py +++ b/homeassistant/components/sonos/const.py @@ -20,6 +20,9 @@ Platform.SWITCH, ] +UPNP_ISSUE_ID = "upnp_disabled" +UPNP_DOCUMENTATION_URL = "https://www.home-assistant.io/integrations/sonos/#403-error-when-setting-up-the-integration" + SUB_FAIL_ISSUE_ID = "subscriptions_failed" SUB_FAIL_URL = "https://www.home-assistant.io/integrations/sonos/#network-requirements" diff --git a/homeassistant/components/sonos/strings.json b/homeassistant/components/sonos/strings.json index 28f6a7c4d61ae2..71b6ffe6c63fb7 100644 --- a/homeassistant/components/sonos/strings.json +++ b/homeassistant/components/sonos/strings.json @@ -132,6 +132,10 @@ "subscriptions_failed": { "description": "Falling back to polling, functionality may be limited.\n\nSonos device at {device_ip} cannot reach Home Assistant at {listener_address}.\n\nSee our [documentation]({sub_fail_url}) for more information on how to solve this issue.", "title": "Networking error: subscriptions failed" + }, + "upnp_disabled": { + "description": "Unable to connect to Sonos speaker at {device_ip}.\n\nPlease ensure UPnP is enabled on your Sonos system.\n\nOpen the Sonos app on your phone or tablet. Go to Account > Privacy and Security > UPnP. Enable the UPnP setting. Once UPnP is enabled, return to Home Assistant and reload the Sonos integration. The connection should now succeed. See our [documentation]({documentation_url}) for steps to resolve this issue.", + "title": "Networking error: UPnP disabled" } }, "services": { diff --git a/tests/components/sonos/test_init.py b/tests/components/sonos/test_init.py index c1b98b2ec60fc4..0c655de0749283 100644 --- a/tests/components/sonos/test_init.py +++ b/tests/components/sonos/test_init.py @@ -1,22 +1,26 @@ """Tests for the Sonos config flow.""" import asyncio +from http import HTTPStatus import logging from unittest.mock import Mock, PropertyMock, patch from freezegun.api import FrozenDateTimeFactory import pytest +from requests import Response +from requests.exceptions import HTTPError from homeassistant import config_entries from homeassistant.components import sonos from homeassistant.components.sonos.const import ( DISCOVERY_INTERVAL, SONOS_SPEAKER_ACTIVITY, + UPNP_ISSUE_ID, ) from homeassistant.components.sonos.exception import SonosUpdateError from homeassistant.core import HomeAssistant, callback from homeassistant.data_entry_flow import FlowResultType -from homeassistant.helpers import entity_registry as er +from homeassistant.helpers import entity_registry as er, issue_registry as ir from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from homeassistant.setup import async_setup_component @@ -24,7 +28,7 @@ from .conftest import MockSoCo, SoCoMockFactory -from tests.common import async_fire_time_changed +from tests.common import MockConfigEntry, async_fire_time_changed async def test_creating_entry_sets_up_media_player( @@ -85,6 +89,83 @@ async def test_not_configuring_sonos_not_creates_entry(hass: HomeAssistant) -> N assert len(mock_setup.mock_calls) == 0 +async def test_upnp_disabled_discovery( + hass: HomeAssistant, config_entry: MockConfigEntry, soco: MockSoCo +) -> None: + """Test issue creation when discovery processing fails with 403.""" + + resp = Response() + resp.status_code = HTTPStatus.FORBIDDEN + http_error = HTTPError(response=resp) + + with patch( + "tests.components.sonos.conftest.MockSoCo.household_id", + new_callable=PropertyMock, + create=True, + side_effect=http_error, + ): + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done(wait_background_tasks=True) + + issue_registry = ir.async_get(hass) + assert ( + issue_registry.async_get_issue( + sonos.DOMAIN, f"{UPNP_ISSUE_ID}_{soco.ip_address}" + ) + is not None + ) + + +async def test_upnp_disabled_manual_hosts( + hass: HomeAssistant, + soco_factory: SoCoMockFactory, +) -> None: + """Test issue creation when manual host processing fails with 403.""" + + resp = Response() + resp.status_code = HTTPStatus.FORBIDDEN + http_error = HTTPError(response=resp) + soco = soco_factory.cache_mock(MockSoCo(), "10.10.10.1", "Bedroom") + + with patch.object( + type(soco), + "household_id", + new_callable=PropertyMock, + create=True, + side_effect=http_error, + ): + await _setup_hass(hass) + + issue_registry = ir.async_get(hass) + issue = issue_registry.async_get_issue( + sonos.DOMAIN, f"{UPNP_ISSUE_ID}_{soco.ip_address}" + ) + assert issue is not None + assert issue.translation_placeholders.get("device_ip") == "10.10.10.1" + + +async def test_discovery_exception( + hass: HomeAssistant, + config_entry: MockConfigEntry, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test exception handling during discovery processing.""" + + with patch( + "tests.components.sonos.conftest.MockSoCo.household_id", + new_callable=PropertyMock, + create=True, + side_effect=OSError("This is a test"), + ): + caplog.set_level(logging.ERROR) + caplog.clear() + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done(wait_background_tasks=True) + assert "This is a test" in caplog.text + + async def test_async_poll_manual_hosts_warnings( hass: HomeAssistant, caplog: pytest.LogCaptureFixture,