Add battery support for Sonos S1 speakers (#50864)
parent
aa18ad2abf
commit
1e86818f85
|
@ -65,3 +65,8 @@ class SonosPowerEntity(SonosEntity, BinarySensorEntity):
|
||||||
return {
|
return {
|
||||||
ATTR_BATTERY_POWER_SOURCE: self.speaker.power_source,
|
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)
|
||||||
|
|
|
@ -58,3 +58,8 @@ class SonosBatteryEntity(SonosEntity, SensorEntity):
|
||||||
def state(self) -> int | None:
|
def state(self) -> int | None:
|
||||||
"""Return the state of the sensor."""
|
"""Return the state of the sensor."""
|
||||||
return self.speaker.battery_info.get("Level")
|
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
|
||||||
|
|
|
@ -187,15 +187,21 @@ class SonosSpeaker:
|
||||||
self.hass, f"{SONOS_SEEN}-{self.soco.uid}", self.async_seen
|
self.hass, f"{SONOS_SEEN}-{self.soco.uid}", self.async_seen
|
||||||
)
|
)
|
||||||
|
|
||||||
if battery_info := fetch_battery_info_or_none(self.soco):
|
if (battery_info := fetch_battery_info_or_none(self.soco)) is None:
|
||||||
# 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:
|
|
||||||
self._platforms_ready.update({BINARY_SENSOR_DOMAIN, SENSOR_DOMAIN})
|
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():
|
if new_alarms := self.update_alarms_for_speaker():
|
||||||
dispatcher_send(self.hass, SONOS_CREATE_ALARM, self, new_alarms)
|
dispatcher_send(self.hass, SONOS_CREATE_ALARM, self, new_alarms)
|
||||||
|
@ -421,6 +427,17 @@ class SonosSpeaker:
|
||||||
self._last_battery_event = dt_util.utcnow()
|
self._last_battery_event = dt_util.utcnow()
|
||||||
|
|
||||||
is_charging = EVENT_CHARGING[battery_dict["BattChg"]]
|
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:
|
if is_charging == self.charging:
|
||||||
self.battery_info.update({"Level": int(battery_dict["BattPct"])})
|
self.battery_info.update({"Level": int(battery_dict["BattPct"])})
|
||||||
else:
|
else:
|
||||||
|
@ -435,17 +452,21 @@ class SonosSpeaker:
|
||||||
return self.coordinator is None
|
return self.coordinator is None
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def power_source(self) -> str:
|
def power_source(self) -> str | None:
|
||||||
"""Return the name of the current power source.
|
"""Return the name of the current power source.
|
||||||
|
|
||||||
Observed to be either BATTERY or SONOS_CHARGING_RING or USB_POWER.
|
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
|
@property
|
||||||
def charging(self) -> bool:
|
def charging(self) -> bool | None:
|
||||||
"""Return the charging status of the speaker."""
|
"""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:
|
async def async_poll_battery(self, now: datetime.datetime | None = None) -> None:
|
||||||
"""Poll the device for the current battery state."""
|
"""Poll the device for the current battery state."""
|
||||||
|
|
|
@ -10,6 +10,18 @@ from homeassistant.const import CONF_HOSTS
|
||||||
from tests.common import MockConfigEntry
|
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")
|
@pytest.fixture(name="config_entry")
|
||||||
def config_entry_fixture():
|
def config_entry_fixture():
|
||||||
"""Create a mock Sonos config entry."""
|
"""Create a mock Sonos config entry."""
|
||||||
|
@ -119,3 +131,13 @@ def battery_info_fixture():
|
||||||
"Temperature": "NORMAL",
|
"Temperature": "NORMAL",
|
||||||
"PowerSource": "SONOS_CHARGING_RING",
|
"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)
|
||||||
|
|
|
@ -1,9 +1,9 @@
|
||||||
"""Tests for the Sonos battery sensor platform."""
|
"""Tests for the Sonos battery sensor platform."""
|
||||||
from pysonos.exceptions import NotSupportedException
|
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.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
|
from homeassistant.setup import async_setup_component
|
||||||
|
|
||||||
|
|
||||||
|
@ -55,3 +55,32 @@ async def test_battery_attributes(hass, config_entry, config, soco):
|
||||||
assert (
|
assert (
|
||||||
power_state.attributes.get(ATTR_BATTERY_POWER_SOURCE) == "SONOS_CHARGING_RING"
|
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"
|
||||||
|
|
Loading…
Reference in New Issue