From 1e86818f854ad9452ef919dee67101e5196f08d1 Mon Sep 17 00:00:00 2001 From: jjlawren Date: Tue, 25 May 2021 11:39:31 -0500 Subject: [PATCH] Add battery support for Sonos S1 speakers (#50864) --- .../components/sonos/binary_sensor.py | 5 +++ homeassistant/components/sonos/sensor.py | 5 +++ homeassistant/components/sonos/speaker.py | 45 ++++++++++++++----- tests/components/sonos/conftest.py | 22 +++++++++ tests/components/sonos/test_sensor.py | 33 +++++++++++++- 5 files changed, 96 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/sonos/binary_sensor.py b/homeassistant/components/sonos/binary_sensor.py index 9fd81a1f006..21e0c077136 100644 --- a/homeassistant/components/sonos/binary_sensor.py +++ b/homeassistant/components/sonos/binary_sensor.py @@ -65,3 +65,8 @@ class SonosPowerEntity(SonosEntity, BinarySensorEntity): return { ATTR_BATTERY_POWER_SOURCE: self.speaker.power_source, } + + @property + def available(self) -> bool: + """Return whether this device is available.""" + return self.speaker.available and (self.speaker.charging is not None) diff --git a/homeassistant/components/sonos/sensor.py b/homeassistant/components/sonos/sensor.py index fcb856e1c06..d9ff19af581 100644 --- a/homeassistant/components/sonos/sensor.py +++ b/homeassistant/components/sonos/sensor.py @@ -58,3 +58,8 @@ class SonosBatteryEntity(SonosEntity, SensorEntity): def state(self) -> int | None: """Return the state of the sensor.""" return self.speaker.battery_info.get("Level") + + @property + def available(self) -> bool: + """Return whether this device is available.""" + return self.speaker.available and self.speaker.power_source diff --git a/homeassistant/components/sonos/speaker.py b/homeassistant/components/sonos/speaker.py index 708b29d5c55..7ce51176a88 100644 --- a/homeassistant/components/sonos/speaker.py +++ b/homeassistant/components/sonos/speaker.py @@ -187,15 +187,21 @@ class SonosSpeaker: self.hass, f"{SONOS_SEEN}-{self.soco.uid}", self.async_seen ) - if battery_info := fetch_battery_info_or_none(self.soco): - # Battery events can be infrequent, polling is still necessary - self.battery_info = battery_info - self._battery_poll_timer = self.hass.helpers.event.track_time_interval( - self.async_poll_battery, BATTERY_SCAN_INTERVAL - ) - dispatcher_send(self.hass, SONOS_CREATE_BATTERY, self) - else: + if (battery_info := fetch_battery_info_or_none(self.soco)) is None: self._platforms_ready.update({BINARY_SENSOR_DOMAIN, SENSOR_DOMAIN}) + else: + self.battery_info = battery_info + # Only create a polling task if successful, may fail on S1 firmware + if battery_info: + # Battery events can be infrequent, polling is still necessary + self._battery_poll_timer = self.hass.helpers.event.track_time_interval( + self.async_poll_battery, BATTERY_SCAN_INTERVAL + ) + else: + _LOGGER.warning( + "S1 firmware detected, battery sensor may update infrequently" + ) + dispatcher_send(self.hass, SONOS_CREATE_BATTERY, self) if new_alarms := self.update_alarms_for_speaker(): dispatcher_send(self.hass, SONOS_CREATE_ALARM, self, new_alarms) @@ -421,6 +427,17 @@ class SonosSpeaker: self._last_battery_event = dt_util.utcnow() is_charging = EVENT_CHARGING[battery_dict["BattChg"]] + + if not self._battery_poll_timer: + # Battery info received for an S1 speaker + self.battery_info.update( + { + "Level": int(battery_dict["BattPct"]), + "PowerSource": "EXTERNAL" if is_charging else "BATTERY", + } + ) + return + if is_charging == self.charging: self.battery_info.update({"Level": int(battery_dict["BattPct"])}) else: @@ -435,17 +452,21 @@ class SonosSpeaker: return self.coordinator is None @property - def power_source(self) -> str: + def power_source(self) -> str | None: """Return the name of the current power source. Observed to be either BATTERY or SONOS_CHARGING_RING or USB_POWER. + + May be an empty dict if used with an S1 Move. """ - return self.battery_info["PowerSource"] + return self.battery_info.get("PowerSource") @property - def charging(self) -> bool: + def charging(self) -> bool | None: """Return the charging status of the speaker.""" - return self.power_source != "BATTERY" + if self.power_source: + return self.power_source != "BATTERY" + return None async def async_poll_battery(self, now: datetime.datetime | None = None) -> None: """Poll the device for the current battery state.""" diff --git a/tests/components/sonos/conftest.py b/tests/components/sonos/conftest.py index e7e4c42d64c..2feb2b54896 100644 --- a/tests/components/sonos/conftest.py +++ b/tests/components/sonos/conftest.py @@ -10,6 +10,18 @@ from homeassistant.const import CONF_HOSTS from tests.common import MockConfigEntry +class SonosMockEvent: + """Mock a sonos Event used in callbacks.""" + + def __init__(self, soco, variables): + """Initialize the instance.""" + self.sid = f"{soco.uid}_sub0000000001" + self.seq = "0" + self.timestamp = 1621000000.0 + self.service = dummy_soco_service_fixture + self.variables = variables + + @pytest.fixture(name="config_entry") def config_entry_fixture(): """Create a mock Sonos config entry.""" @@ -119,3 +131,13 @@ def battery_info_fixture(): "Temperature": "NORMAL", "PowerSource": "SONOS_CHARGING_RING", } + + +@pytest.fixture(name="battery_event") +def battery_event_fixture(soco): + """Create battery_event fixture.""" + variables = { + "zone_name": "Zone A", + "more_info": "BattChg:NOT_CHARGING,RawBattPct:100,BattPct:100,BattTmp:25", + } + return SonosMockEvent(soco, variables) diff --git a/tests/components/sonos/test_sensor.py b/tests/components/sonos/test_sensor.py index 42bf6eedb9c..bd667b6cf3b 100644 --- a/tests/components/sonos/test_sensor.py +++ b/tests/components/sonos/test_sensor.py @@ -1,9 +1,9 @@ """Tests for the Sonos battery sensor platform.""" from pysonos.exceptions import NotSupportedException -from homeassistant.components.sonos import DOMAIN +from homeassistant.components.sonos import DATA_SONOS, DOMAIN from homeassistant.components.sonos.binary_sensor import ATTR_BATTERY_POWER_SOURCE -from homeassistant.const import STATE_ON +from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNAVAILABLE from homeassistant.setup import async_setup_component @@ -55,3 +55,32 @@ async def test_battery_attributes(hass, config_entry, config, soco): assert ( power_state.attributes.get(ATTR_BATTERY_POWER_SOURCE) == "SONOS_CHARGING_RING" ) + + +async def test_battery_on_S1(hass, config_entry, config, soco, battery_event): + """Test battery state updates on a Sonos S1 device.""" + soco.get_battery_info.return_value = {} + + await setup_platform(hass, config_entry, config) + + entity_registry = await hass.helpers.entity_registry.async_get_registry() + + battery = entity_registry.entities["sensor.zone_a_battery"] + battery_state = hass.states.get(battery.entity_id) + assert battery_state.state == STATE_UNAVAILABLE + + power = entity_registry.entities["binary_sensor.zone_a_power"] + power_state = hass.states.get(power.entity_id) + assert power_state.state == STATE_UNAVAILABLE + + # Update the speaker with a callback event + speaker = hass.data[DATA_SONOS].discovered[soco.uid] + speaker.async_dispatch_properties(battery_event) + await hass.async_block_till_done() + + battery_state = hass.states.get(battery.entity_id) + assert battery_state.state == "100" + + power_state = hass.states.get(power.entity_id) + assert power_state.state == STATE_OFF + assert power_state.attributes.get(ATTR_BATTERY_POWER_SOURCE) == "BATTERY"