Skip to content
Merged
Show file tree
Hide file tree
Changes from 15 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
63 changes: 55 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,31 @@ 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_conection_error(
Comment thread
PeteRager marked this conversation as resolved.
Outdated
self, err: HTTPError, ip_address: str
) -> None:
"""Process HTTP Errors when connecting to a Sonos speaker."""
# When UPnP is disabled, Sonos returns HTTP 403 Forbidden error.
# Create issue advising user to enable UPnP on Sonos system.
if err.response.status_code == HTTPStatus.FORBIDDEN:
Comment thread
PeteRager marked this conversation as resolved.
Outdated
ir.async_create_issue(
self.hass,
DOMAIN,
UPNP_ISSUE_ID,
Comment thread
PeteRager marked this conversation as resolved.
Outdated
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 +223,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_conection_error(err, ip_address)
Comment thread
PeteRager marked this conversation as resolved.
Outdated
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 +434,9 @@ async def async_poll_manual_hosts(
sync_get_visible_zones,
soco,
)
except HTTPError as err:
await self._process_http_conection_error(err, ip_addr)
Comment thread
PeteRager marked this conversation as resolved.
Outdated
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\nSee our [documentation]({documentation_url}) for steps to resolve this issue.",
Comment thread
PeteRager marked this conversation as resolved.
Outdated
"title": "Networking error: UPnP disabled"
}
},
"services": {
Expand Down
78 changes: 76 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,76 @@ 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
) -> 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, UPNP_ISSUE_ID) 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, UPNP_ISSUE_ID)
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