Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 56 additions & 8 deletions homeassistant/components/sonos/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,15 @@
import asyncio
import datetime
from functools import partial
from http import HTTPStatus
from ipaddress import AddressValueError, IPv4Address
import logging
import socket
from typing import Any, cast
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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand All @@ -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:
Expand Down Expand Up @@ -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,
Expand Down
3 changes: 3 additions & 0 deletions homeassistant/components/sonos/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down
4 changes: 4 additions & 0 deletions homeassistant/components/sonos/strings.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
85 changes: 83 additions & 2 deletions tests/components/sonos/test_init.py
Original file line number Diff line number Diff line change
@@ -1,30 +1,34 @@
"""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
from homeassistant.util import dt as dt_util

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(
Expand Down Expand Up @@ -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,
Expand Down
Loading