Create repair issue if Sonos subscriptions fail (#87437)

Co-authored-by: Franck Nijhof <frenck@frenck.nl>
This commit is contained in:
jjlawren 2023-04-22 12:28:04 -05:00 committed by GitHub
parent 93a87d3c82
commit d5a6840588
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 87 additions and 29 deletions

View File

@ -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

View File

@ -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"

View File

@ -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 <a href="{SUB_FAIL_URL}">Sonos documentation</a>'
" 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:

View File

@ -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."
}
}
}

View File

@ -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)

View File

@ -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