diff --git a/homeassistant/components/sonos/__init__.py b/homeassistant/components/sonos/__init__.py index c17e4b00829..ea0a16229c1 100644 --- a/homeassistant/components/sonos/__init__.py +++ b/homeassistant/components/sonos/__init__.py @@ -26,7 +26,11 @@ from homeassistant.components.media_player import DOMAIN as MP_DOMAIN from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOSTS, EVENT_HOMEASSISTANT_STOP from homeassistant.core import CALLBACK_TYPE, Event, HomeAssistant, callback -from homeassistant.helpers import config_validation as cv, device_registry as dr +from homeassistant.helpers import ( + config_validation as cv, + device_registry as dr, + issue_registry as ir, +) from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.event import async_call_later, async_track_time_interval from homeassistant.helpers.typing import ConfigType @@ -43,6 +47,8 @@ from .const import ( SONOS_REBOOTED, SONOS_SPEAKER_ACTIVITY, SONOS_VANISHED, + SUB_FAIL_ISSUE_ID, + SUB_FAIL_URL, SUBSCRIPTION_TIMEOUT, UPNP_ST, ) @@ -227,6 +233,24 @@ class SonosDiscoveryManager: async def async_subscription_failed(now: datetime.datetime) -> None: """Fallback logic if the subscription callback never arrives.""" + addr, port = sub.event_listener.address + listener_address = f"{addr}:{port}" + if advertise_ip := soco_config.EVENT_ADVERTISE_IP: + listener_address += f" (advertising as {advertise_ip})" + ir.async_create_issue( + self.hass, + DOMAIN, + SUB_FAIL_ISSUE_ID, + is_fixable=False, + severity=ir.IssueSeverity.ERROR, + translation_key="subscriptions_failed", + translation_placeholders={ + "device_ip": ip_address, + "listener_address": listener_address, + "sub_fail_url": SUB_FAIL_URL, + }, + ) + _LOGGER.warning( "Subscription to %s failed, attempting to poll directly", ip_address ) @@ -256,6 +280,11 @@ class SonosDiscoveryManager: """Create SonosSpeakers when subscription callbacks successfully arrive.""" _LOGGER.debug("Subscription to %s succeeded", ip_address) cancel_failure_callback() + ir.async_delete_issue( + self.hass, + DOMAIN, + SUB_FAIL_ISSUE_ID, + ) _async_add_visible_zones(subscription_succeeded=True) sub.callback = _async_subscription_succeeded diff --git a/homeassistant/components/sonos/const.py b/homeassistant/components/sonos/const.py index 9476b361ae7..e42fb7d67c7 100644 --- a/homeassistant/components/sonos/const.py +++ b/homeassistant/components/sonos/const.py @@ -19,6 +19,9 @@ PLATFORMS = [ Platform.SWITCH, ] +SUB_FAIL_ISSUE_ID = "subscriptions_failed" +SUB_FAIL_URL = "https://www.home-assistant.io/integrations/sonos/#network-requirements" + SONOS_ARTIST = "artists" SONOS_ALBUM = "albums" SONOS_PLAYLISTS = "playlists" diff --git a/homeassistant/components/sonos/entity.py b/homeassistant/components/sonos/entity.py index ac7b96ec965..0b51687a465 100644 --- a/homeassistant/components/sonos/entity.py +++ b/homeassistant/components/sonos/entity.py @@ -5,10 +5,8 @@ from abc import abstractmethod import datetime import logging -import soco.config as soco_config from soco.core import SoCo -from homeassistant.components import persistent_notification import homeassistant.helpers.device_registry as dr from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity import DeviceInfo, Entity @@ -17,8 +15,6 @@ from .const import DATA_SONOS, DOMAIN, SONOS_FALLBACK_POLL, SONOS_STATE_UPDATED from .exception import SonosUpdateError from .speaker import SonosSpeaker -SUB_FAIL_URL = "https://www.home-assistant.io/integrations/sonos/#network-requirements" - _LOGGER = logging.getLogger(__name__) @@ -57,29 +53,6 @@ class SonosEntity(Entity): async def async_fallback_poll(self, now: datetime.datetime) -> None: """Poll the entity if subscriptions fail.""" if not self.speaker.subscriptions_failed: - if soco_config.EVENT_ADVERTISE_IP: - listener_msg = ( - f"{self.speaker.subscription_address}" - f" (advertising as {soco_config.EVENT_ADVERTISE_IP})" - ) - else: - listener_msg = self.speaker.subscription_address - message = ( - f"{self.speaker.zone_name} cannot reach {listener_msg}," - " falling back to polling, functionality may be limited" - ) - log_link_msg = f", see {SUB_FAIL_URL} for more details" - notification_link_msg = ( - f'.\n\nSee Sonos documentation' - " for more details." - ) - _LOGGER.warning(message + log_link_msg) - persistent_notification.async_create( - self.hass, - message + notification_link_msg, - "Sonos networking issue", - "sonos_subscriptions_failed", - ) self.speaker.subscriptions_failed = True await self.speaker.async_unsubscribe() try: diff --git a/homeassistant/components/sonos/strings.json b/homeassistant/components/sonos/strings.json index fb73e30421f..75c1b850146 100644 --- a/homeassistant/components/sonos/strings.json +++ b/homeassistant/components/sonos/strings.json @@ -10,5 +10,11 @@ "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]", "no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]" } + }, + "issues": { + "subscriptions_failed": { + "title": "Networking error: 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." + } } } diff --git a/tests/components/sonos/test_repairs.py b/tests/components/sonos/test_repairs.py new file mode 100644 index 00000000000..b99704559ec --- /dev/null +++ b/tests/components/sonos/test_repairs.py @@ -0,0 +1,47 @@ +"""Test repairs handling for Sonos.""" +from unittest.mock import Mock + +from homeassistant.components.sonos.const import ( + DOMAIN, + SCAN_INTERVAL, + SUB_FAIL_ISSUE_ID, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.issue_registry import async_get as async_get_issue_registry +from homeassistant.util import dt as dt_util + +from .conftest import SonosMockEvent + +from tests.common import MockConfigEntry, async_fire_time_changed + + +async def test_subscription_repair_issues( + hass: HomeAssistant, config_entry: MockConfigEntry, soco, zgs_discovery +): + """Test repair issues handling for failed subscriptions.""" + issue_registry = async_get_issue_registry(hass) + + subscription = soco.zoneGroupTopology.subscribe.return_value + subscription.event_listener = Mock(address=("192.168.4.2", 1400)) + + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + # Ensure an issue is registered on subscription failure + async_fire_time_changed(hass, dt_util.utcnow() + SCAN_INTERVAL) + await hass.async_block_till_done() + assert issue_registry.async_get_issue(DOMAIN, SUB_FAIL_ISSUE_ID) + + # Ensure the issue still exists after reload + assert await hass.config_entries.async_reload(config_entry.entry_id) + await hass.async_block_till_done() + assert issue_registry.async_get_issue(DOMAIN, SUB_FAIL_ISSUE_ID) + + # Ensure the issue has been removed after a successful subscription callback + variables = {"ZoneGroupState": zgs_discovery} + event = SonosMockEvent(soco, soco.zoneGroupTopology, variables) + sub_callback = subscription.callback + sub_callback(event) + await hass.async_block_till_done() + assert not issue_registry.async_get_issue(DOMAIN, SUB_FAIL_ISSUE_ID) diff --git a/tests/components/sonos/test_speaker.py b/tests/components/sonos/test_speaker.py index 9b497708a7a..e9b85c22eb3 100644 --- a/tests/components/sonos/test_speaker.py +++ b/tests/components/sonos/test_speaker.py @@ -17,6 +17,7 @@ async def test_fallback_to_polling( speaker = list(hass.data[DATA_SONOS].discovered.values())[0] assert speaker.soco is soco assert speaker._subscriptions + assert not speaker.subscriptions_failed caplog.clear() @@ -29,7 +30,6 @@ async def test_fallback_to_polling( assert not speaker._subscriptions assert speaker.subscriptions_failed - assert "falling back to polling" in caplog.text assert "Activity on Zone A from SonosSpeaker.update_volume" in caplog.text