From 532b3d780f58df7178cb3278513c451f7450ada1 Mon Sep 17 00:00:00 2001 From: jjlawren Date: Sat, 14 May 2022 13:40:26 -0500 Subject: [PATCH] Rework Sonos battery and ping activity tracking (#70942) --- homeassistant/components/sonos/__init__.py | 13 +--- homeassistant/components/sonos/exception.py | 4 ++ homeassistant/components/sonos/speaker.py | 73 ++++++++++++--------- 3 files changed, 48 insertions(+), 42 deletions(-) diff --git a/homeassistant/components/sonos/__init__.py b/homeassistant/components/sonos/__init__.py index 114c2815a56..c775b475dc4 100644 --- a/homeassistant/components/sonos/__init__.py +++ b/homeassistant/components/sonos/__init__.py @@ -40,6 +40,7 @@ from .const import ( SONOS_VANISHED, UPNP_ST, ) +from .exception import SonosUpdateError from .favorites import SonosFavorites from .speaker import SonosSpeaker @@ -264,19 +265,11 @@ class SonosDiscoveryManager: self._create_visible_speakers(ip_addr) elif not known_speaker.available: try: - known_speaker.soco.renderingControl.GetVolume( - [("InstanceID", 0), ("Channel", "Master")], timeout=1 - ) - except OSError: + known_speaker.ping() + except SonosUpdateError: _LOGGER.debug( "Manual poll to %s failed, keeping unavailable", ip_addr ) - else: - dispatcher_send( - self.hass, - f"{SONOS_SPEAKER_ACTIVITY}-{known_speaker.uid}", - "manual rediscovery", - ) self.data.hosts_heartbeat = call_later( self.hass, DISCOVERY_INTERVAL.total_seconds(), self._poll_manual_hosts diff --git a/homeassistant/components/sonos/exception.py b/homeassistant/components/sonos/exception.py index bce1e3233c1..dd2d30796cc 100644 --- a/homeassistant/components/sonos/exception.py +++ b/homeassistant/components/sonos/exception.py @@ -9,3 +9,7 @@ class UnknownMediaType(BrowseError): class SonosUpdateError(HomeAssistantError): """Update failed.""" + + +class S1BatteryMissing(SonosUpdateError): + """Battery update failed on S1 firmware.""" diff --git a/homeassistant/components/sonos/speaker.py b/homeassistant/components/sonos/speaker.py index a57bb4d3206..5d4199ec905 100644 --- a/homeassistant/components/sonos/speaker.py +++ b/homeassistant/components/sonos/speaker.py @@ -57,6 +57,7 @@ from .const import ( SONOS_VANISHED, SUBSCRIPTION_TIMEOUT, ) +from .exception import S1BatteryMissing, SonosUpdateError from .favorites import SonosFavorites from .helpers import soco_error from .media import SonosMedia @@ -83,16 +84,6 @@ UNUSED_DEVICE_KEYS = ["SPID", "TargetRoomName"] _LOGGER = logging.getLogger(__name__) -def fetch_battery_info_or_none(soco: SoCo) -> dict[str, Any] | None: - """Fetch battery_info from the given SoCo object. - - Returns None if the device doesn't support battery info - or if the device is offline. - """ - with contextlib.suppress(ConnectionError, TimeoutError, SoCoException): - return soco.get_battery_info() - - class SonosSpeaker: """Representation of a Sonos speaker.""" @@ -207,8 +198,11 @@ class SonosSpeaker: self.hass, SONOS_CREATE_AUDIO_FORMAT_SENSOR, self, audio_format ) - if battery_info := fetch_battery_info_or_none(self.soco): - self.battery_info = battery_info + try: + self.battery_info = self.fetch_battery_info() + except SonosUpdateError: + _LOGGER.debug("No battery available for %s", self.zone_name) + else: # Battery events can be infrequent, polling is still necessary self._battery_poll_timer = track_time_interval( self.hass, self.async_poll_battery, BATTERY_SCAN_INTERVAL @@ -530,6 +524,13 @@ class SonosSpeaker: # # Speaker availability methods # + @soco_error() + def ping(self) -> None: + """Test device availability. Failure will raise SonosUpdateError.""" + self.soco.renderingControl.GetVolume( + [("InstanceID", 0), ("Channel", "Master")], timeout=1 + ) + @callback def speaker_activity(self, source): """Track the last activity on this speaker, set availability and resubscribe.""" @@ -560,23 +561,13 @@ class SonosSpeaker: return try: - # Make a short-timeout call as a final check - # before marking this speaker as unavailable - await self.hass.async_add_executor_job( - partial( - self.soco.renderingControl.GetVolume, - [("InstanceID", 0), ("Channel", "Master")], - timeout=1, - ) - ) - except OSError: + await self.hass.async_add_executor_job(self.ping) + except SonosUpdateError: _LOGGER.warning( "No recent activity and cannot reach %s, marking unavailable", self.zone_name, ) await self.async_offline() - else: - self.speaker_activity("timeout poll") async def async_offline(self) -> None: """Handle removal of speaker when unavailable.""" @@ -619,6 +610,15 @@ class SonosSpeaker: # # Battery management # + @soco_error() + def fetch_battery_info(self) -> dict[str, Any]: + """Fetch battery_info for the speaker.""" + battery_info = self.soco.get_battery_info() + if not battery_info: + # S1 firmware returns an empty payload + raise S1BatteryMissing + return battery_info + async def async_update_battery_info(self, more_info: str) -> None: """Update battery info using a SonosEvent payload value.""" battery_dict = dict(x.split(":") for x in more_info.split(",")) @@ -658,11 +658,17 @@ class SonosSpeaker: if is_charging == self.charging: self.battery_info.update({"Level": int(battery_dict["BattPct"])}) + elif not is_charging: + # Avoid polling the speaker if possible + self.battery_info["PowerSource"] = "BATTERY" else: - if battery_info := await self.hass.async_add_executor_job( - fetch_battery_info_or_none, self.soco - ): - self.battery_info = battery_info + # Poll to obtain current power source not provided by event + try: + self.battery_info = await self.hass.async_add_executor_job( + self.fetch_battery_info + ) + except SonosUpdateError as err: + _LOGGER.debug("Could not request current power source: %s", err) @property def power_source(self) -> str | None: @@ -692,10 +698,13 @@ class SonosSpeaker: ): return - if battery_info := await self.hass.async_add_executor_job( - fetch_battery_info_or_none, self.soco - ): - self.battery_info = battery_info + try: + self.battery_info = await self.hass.async_add_executor_job( + self.fetch_battery_info + ) + except SonosUpdateError as err: + _LOGGER.debug("Could not poll battery info: %s", err) + else: self.async_write_entity_states() #