diff --git a/homeassistant/components/sonos/binary_sensor.py b/homeassistant/components/sonos/binary_sensor.py new file mode 100644 index 00000000000..b7d515a8f11 --- /dev/null +++ b/homeassistant/components/sonos/binary_sensor.py @@ -0,0 +1,67 @@ +"""Entity representing a Sonos power sensor.""" +from __future__ import annotations + +import datetime +import logging +from typing import Any + +from homeassistant.components.binary_sensor import ( + DEVICE_CLASS_BATTERY_CHARGING, + BinarySensorEntity, +) +from homeassistant.helpers.dispatcher import async_dispatcher_connect + +from .const import DATA_SONOS, SONOS_CREATE_BATTERY +from .entity import SonosSensorEntity +from .speaker import SonosSpeaker + +_LOGGER = logging.getLogger(__name__) + +ATTR_BATTERY_POWER_SOURCE = "power_source" + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up Sonos from a config entry.""" + + async def _async_create_entity(speaker: SonosSpeaker) -> None: + entity = SonosPowerEntity(speaker, hass.data[DATA_SONOS]) + async_add_entities([entity]) + + config_entry.async_on_unload( + async_dispatcher_connect(hass, SONOS_CREATE_BATTERY, _async_create_entity) + ) + + +class SonosPowerEntity(SonosSensorEntity, BinarySensorEntity): + """Representation of a Sonos power entity.""" + + @property + def unique_id(self) -> str: + """Return the unique ID of the sensor.""" + return f"{self.soco.uid}-power" + + @property + def name(self) -> str: + """Return the name of the sensor.""" + return f"{self.speaker.zone_name} Power" + + @property + def device_class(self) -> str: + """Return the entity's device class.""" + return DEVICE_CLASS_BATTERY_CHARGING + + async def async_update(self, now: datetime.datetime | None = None) -> None: + """Poll the device for the current state.""" + await self.speaker.async_poll_battery() + + @property + def is_on(self) -> bool | None: + """Return the state of the binary sensor.""" + return self.speaker.charging + + @property + def extra_state_attributes(self) -> dict[str, Any]: + """Return entity specific state attributes.""" + return { + ATTR_BATTERY_POWER_SOURCE: self.speaker.power_source, + } diff --git a/homeassistant/components/sonos/const.py b/homeassistant/components/sonos/const.py index b841347ce27..133bf773991 100644 --- a/homeassistant/components/sonos/const.py +++ b/homeassistant/components/sonos/const.py @@ -1,6 +1,7 @@ """Const for Sonos.""" import datetime +from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN from homeassistant.components.media_player import DOMAIN as MP_DOMAIN from homeassistant.components.media_player.const import ( MEDIA_CLASS_ALBUM, @@ -22,7 +23,7 @@ from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN DOMAIN = "sonos" DATA_SONOS = "sonos_media_player" -PLATFORMS = {MP_DOMAIN, SENSOR_DOMAIN} +PLATFORMS = {BINARY_SENSOR_DOMAIN, MP_DOMAIN, SENSOR_DOMAIN} SONOS_ARTIST = "artists" SONOS_ALBUM = "albums" @@ -128,12 +129,12 @@ PLAYABLE_MEDIA_TYPES = [ ] SONOS_CONTENT_UPDATE = "sonos_content_update" -SONOS_DISCOVERY_UPDATE = "sonos_discovery_update" +SONOS_CREATE_BATTERY = "sonos_create_battery" +SONOS_CREATE_MEDIA_PLAYER = "sonos_create_media_player" SONOS_ENTITY_CREATED = "sonos_entity_created" SONOS_ENTITY_UPDATE = "sonos_entity_update" SONOS_GROUP_UPDATE = "sonos_group_update" SONOS_MEDIA_UPDATE = "sonos_media_update" -SONOS_PROPERTIES_UPDATE = "sonos_properties_update" SONOS_PLAYER_RECONNECTED = "sonos_player_reconnected" SONOS_STATE_UPDATED = "sonos_state_updated" SONOS_VOLUME_UPDATE = "sonos_properties_update" diff --git a/homeassistant/components/sonos/entity.py b/homeassistant/components/sonos/entity.py index 159b3fb348a..a6cbadae014 100644 --- a/homeassistant/components/sonos/entity.py +++ b/homeassistant/components/sonos/entity.py @@ -7,11 +7,19 @@ from typing import Any from pysonos.core import SoCo import homeassistant.helpers.device_registry as dr -from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.dispatcher import ( + async_dispatcher_connect, + async_dispatcher_send, +) from homeassistant.helpers.entity import Entity from . import SonosData -from .const import DOMAIN, SONOS_ENTITY_UPDATE, SONOS_STATE_UPDATED +from .const import ( + DOMAIN, + SONOS_ENTITY_CREATED, + SONOS_ENTITY_UPDATE, + SONOS_STATE_UPDATED, +) from .speaker import SonosSpeaker _LOGGER = logging.getLogger(__name__) @@ -71,3 +79,14 @@ class SonosEntity(Entity): def should_poll(self) -> bool: """Return that we should not be polled (we handle that internally).""" return False + + +class SonosSensorEntity(SonosEntity): + """Representation of a Sonos sensor entity.""" + + async def async_added_to_hass(self) -> None: + """Handle common setup when added to hass.""" + await super().async_added_to_hass() + async_dispatcher_send( + self.hass, f"{SONOS_ENTITY_CREATED}-{self.soco.uid}", self.platform.domain + ) diff --git a/homeassistant/components/sonos/media_player.py b/homeassistant/components/sonos/media_player.py index 57ce1f8a8ae..73d144f6b0c 100644 --- a/homeassistant/components/sonos/media_player.py +++ b/homeassistant/components/sonos/media_player.py @@ -30,7 +30,6 @@ import voluptuous as vol from homeassistant.components.media_player import MediaPlayerEntity from homeassistant.components.media_player.const import ( ATTR_MEDIA_ENQUEUE, - DOMAIN as MP_DOMAIN, MEDIA_TYPE_ALBUM, MEDIA_TYPE_ARTIST, MEDIA_TYPE_MUSIC, @@ -75,7 +74,7 @@ from .const import ( MEDIA_TYPES_TO_SONOS, PLAYABLE_MEDIA_TYPES, SONOS_CONTENT_UPDATE, - SONOS_DISCOVERY_UPDATE, + SONOS_CREATE_MEDIA_PLAYER, SONOS_ENTITY_CREATED, SONOS_GROUP_UPDATE, SONOS_MEDIA_UPDATE, @@ -189,7 +188,9 @@ async def async_setup_entry( hass, entities, service_call.data[ATTR_WITH_GROUP] # type: ignore[arg-type] ) - async_dispatcher_connect(hass, SONOS_DISCOVERY_UPDATE, async_create_entities) + config_entry.async_on_unload( + async_dispatcher_connect(hass, SONOS_CREATE_MEDIA_PLAYER, async_create_entities) + ) hass.services.async_register( SONOS_DOMAIN, @@ -390,7 +391,7 @@ class SonosMediaPlayerEntity(SonosEntity, MediaPlayerEntity): async_dispatcher_send(self.hass, SONOS_GROUP_UPDATE) async_dispatcher_send( - self.hass, f"{SONOS_ENTITY_CREATED}-{self.soco.uid}", MP_DOMAIN + self.hass, f"{SONOS_ENTITY_CREATED}-{self.soco.uid}", self.platform.domain ) @property diff --git a/homeassistant/components/sonos/sensor.py b/homeassistant/components/sonos/sensor.py index 2ca5e0979dc..a18c143fe61 100644 --- a/homeassistant/components/sonos/sensor.py +++ b/homeassistant/components/sonos/sensor.py @@ -1,133 +1,35 @@ """Entity representing a Sonos battery level.""" from __future__ import annotations -import contextlib import datetime import logging -from typing import Any -from pysonos.core import SoCo -from pysonos.events_base import Event as SonosEvent -from pysonos.exceptions import SoCoException +from homeassistant.components.sensor import SensorEntity +from homeassistant.const import DEVICE_CLASS_BATTERY, PERCENTAGE +from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN, SensorEntity -from homeassistant.const import DEVICE_CLASS_BATTERY, PERCENTAGE, STATE_UNKNOWN -from homeassistant.helpers.dispatcher import ( - async_dispatcher_connect, - async_dispatcher_send, -) -from homeassistant.util import dt as dt_util - -from . import SonosData -from .const import ( - BATTERY_SCAN_INTERVAL, - DATA_SONOS, - SONOS_DISCOVERY_UPDATE, - SONOS_ENTITY_CREATED, - SONOS_PROPERTIES_UPDATE, -) -from .entity import SonosEntity +from .const import DATA_SONOS, SONOS_CREATE_BATTERY +from .entity import SonosSensorEntity from .speaker import SonosSpeaker _LOGGER = logging.getLogger(__name__) -ATTR_BATTERY_LEVEL = "battery_level" -ATTR_BATTERY_CHARGING = "charging" -ATTR_BATTERY_POWERSOURCE = "power_source" - -EVENT_CHARGING = { - "CHARGING": True, - "NOT_CHARGING": False, -} - - -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() - return None - async def async_setup_entry(hass, config_entry, async_add_entities): """Set up Sonos from a config entry.""" - sonos_data = hass.data[DATA_SONOS] + async def _async_create_entity(speaker: SonosSpeaker) -> None: + entity = SonosBatteryEntity(speaker, hass.data[DATA_SONOS]) + async_add_entities([entity]) - async def _async_create_entity(speaker: SonosSpeaker) -> SonosBatteryEntity | None: - if battery_info := await hass.async_add_executor_job( - fetch_battery_info_or_none, speaker.soco - ): - return SonosBatteryEntity(speaker, sonos_data, battery_info) - return None - - async def _async_create_entities(speaker: SonosSpeaker): - if entity := await _async_create_entity(speaker): - async_add_entities([entity]) - else: - async_dispatcher_send( - hass, f"{SONOS_ENTITY_CREATED}-{speaker.soco.uid}", SENSOR_DOMAIN - ) - - async_dispatcher_connect(hass, SONOS_DISCOVERY_UPDATE, _async_create_entities) + config_entry.async_on_unload( + async_dispatcher_connect(hass, SONOS_CREATE_BATTERY, _async_create_entity) + ) -class SonosBatteryEntity(SonosEntity, SensorEntity): +class SonosBatteryEntity(SonosSensorEntity, SensorEntity): """Representation of a Sonos Battery entity.""" - def __init__( - self, speaker: SonosSpeaker, sonos_data: SonosData, battery_info: dict[str, Any] - ) -> None: - """Initialize a SonosBatteryEntity.""" - super().__init__(speaker, sonos_data) - self._battery_info: dict[str, Any] = battery_info - self._last_event: datetime.datetime | None = None - - async def async_added_to_hass(self) -> None: - """Register polling callback when added to hass.""" - await super().async_added_to_hass() - - self.async_on_remove( - self.hass.helpers.event.async_track_time_interval( - self.async_update, BATTERY_SCAN_INTERVAL - ) - ) - self.async_on_remove( - async_dispatcher_connect( - self.hass, - f"{SONOS_PROPERTIES_UPDATE}-{self.soco.uid}", - self.async_update_battery_info, - ) - ) - async_dispatcher_send( - self.hass, f"{SONOS_ENTITY_CREATED}-{self.soco.uid}", SENSOR_DOMAIN - ) - - async def async_update_battery_info(self, event: SonosEvent = None) -> None: - """Update battery info using the provided SonosEvent.""" - if event is None: - return - - if (more_info := event.variables.get("more_info")) is None: - return - - more_info_dict = dict(x.split(":") for x in more_info.split(",")) - self._last_event = dt_util.utcnow() - - is_charging = EVENT_CHARGING[more_info_dict["BattChg"]] - if is_charging == self.charging: - self._battery_info.update({"Level": int(more_info_dict["BattPct"])}) - else: - if battery_info := await self.hass.async_add_executor_job( - fetch_battery_info_or_none, self.soco - ): - self._battery_info = battery_info - - self.async_write_ha_state() - @property def unique_id(self) -> str: """Return the unique ID of the sensor.""" @@ -148,51 +50,11 @@ class SonosBatteryEntity(SonosEntity, SensorEntity): """Get the unit of measurement.""" return PERCENTAGE - async def async_update(self, event=None) -> None: + async def async_update(self, now: datetime.datetime | None = None) -> None: """Poll the device for the current state.""" - if not self.available: - # wait for the Sonos device to come back online - return - - if ( - self._last_event - and dt_util.utcnow() - self._last_event < BATTERY_SCAN_INTERVAL - ): - return - - if battery_info := await self.hass.async_add_executor_job( - fetch_battery_info_or_none, self.soco - ): - self._battery_info = battery_info - self.async_write_ha_state() - - @property - def battery_level(self) -> int: - """Return the battery level.""" - return self._battery_info.get("Level", 0) - - @property - def power_source(self) -> str: - """Return the name of the power source. - - Observed to be either BATTERY or SONOS_CHARGING_RING or USB_POWER. - """ - return self._battery_info.get("PowerSource", STATE_UNKNOWN) - - @property - def charging(self) -> bool: - """Return the charging status of this battery.""" - return self.power_source not in ("BATTERY", STATE_UNKNOWN) + await self.speaker.async_poll_battery() @property def state(self) -> int | None: """Return the state of the sensor.""" - return self._battery_info.get("Level") - - @property - def device_state_attributes(self) -> dict[str, Any]: - """Return entity specific state attributes.""" - return { - ATTR_BATTERY_CHARGING: self.charging, - ATTR_BATTERY_POWERSOURCE: self.power_source, - } + return self.speaker.battery_info.get("Level") diff --git a/homeassistant/components/sonos/speaker.py b/homeassistant/components/sonos/speaker.py index 2d67cf8041f..73704c61364 100644 --- a/homeassistant/components/sonos/speaker.py +++ b/homeassistant/components/sonos/speaker.py @@ -2,6 +2,7 @@ from __future__ import annotations from asyncio import gather +import contextlib import datetime import logging from typing import Any, Callable @@ -10,33 +11,52 @@ from pysonos.core import SoCo from pysonos.events_base import Event as SonosEvent, SubscriptionBase from pysonos.exceptions import SoCoException +from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN +from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import ( async_dispatcher_send, dispatcher_connect, dispatcher_send, ) +from homeassistant.util import dt as dt_util from .const import ( + BATTERY_SCAN_INTERVAL, PLATFORMS, SCAN_INTERVAL, SEEN_EXPIRE_TIME, SONOS_CONTENT_UPDATE, - SONOS_DISCOVERY_UPDATE, + SONOS_CREATE_BATTERY, + SONOS_CREATE_MEDIA_PLAYER, SONOS_ENTITY_CREATED, SONOS_ENTITY_UPDATE, SONOS_GROUP_UPDATE, SONOS_MEDIA_UPDATE, SONOS_PLAYER_RECONNECTED, - SONOS_PROPERTIES_UPDATE, SONOS_SEEN, SONOS_STATE_UPDATED, SONOS_VOLUME_UPDATE, ) +EVENT_CHARGING = { + "CHARGING": True, + "NOT_CHARGING": False, +} + _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.""" @@ -60,6 +80,10 @@ class SonosSpeaker: self.version = speaker_info["software_version"] self.zone_name = speaker_info["zone_name"] + self.battery_info: dict[str, Any] | None = None + self._last_battery_event: datetime.datetime | None = None + self._battery_poll_timer: Callable | None = None + def setup(self) -> None: """Run initial setup of the speaker.""" self._entity_creation_dispatcher = dispatcher_connect( @@ -70,7 +94,18 @@ class SonosSpeaker: self._seen_dispatcher = dispatcher_connect( self.hass, f"{SONOS_SEEN}-{self.soco.uid}", self.async_seen ) - dispatcher_send(self.hass, SONOS_DISCOVERY_UPDATE, self) + + if (battery_info := fetch_battery_info_or_none(self.soco)) is not 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}) + + dispatcher_send(self.hass, SONOS_CREATE_MEDIA_PLAYER, self) async def async_handle_new_entity(self, entity_type: str) -> None: """Listen to new entities to trigger first subscription.""" @@ -149,9 +184,7 @@ class SonosSpeaker: @callback def async_dispatch_properties(self, event: SonosEvent | None = None) -> None: """Update properties from event.""" - async_dispatcher_send( - self.hass, f"{SONOS_PROPERTIES_UPDATE}-{self.soco.uid}", event - ) + self.hass.async_create_task(self.async_update_device_properties(event)) @callback def async_dispatch_groups(self, event: SonosEvent | None = None) -> None: @@ -217,3 +250,57 @@ class SonosSpeaker: await subscription.unsubscribe() self._subscriptions = [] + + async def async_update_device_properties(self, event: SonosEvent = None) -> None: + """Update device properties using the provided SonosEvent.""" + if event is None: + return + + if (more_info := event.variables.get("more_info")) is not None: + battery_dict = dict(x.split(":") for x in more_info.split(",")) + await self.async_update_battery_info(battery_dict) + + self.async_write_entity_states() + + async def async_update_battery_info(self, battery_dict: dict[str, Any]) -> None: + """Update battery info using the decoded SonosEvent.""" + self._last_battery_event = dt_util.utcnow() + + is_charging = EVENT_CHARGING[battery_dict["BattChg"]] + if is_charging == self.charging: + self.battery_info.update({"Level": int(battery_dict["BattPct"])}) + else: + if battery_info := await self.hass.async_add_executor_job( + fetch_battery_info_or_none, self.soco + ): + self.battery_info = battery_info + + @property + def power_source(self) -> str: + """Return the name of the current power source. + + Observed to be either BATTERY or SONOS_CHARGING_RING or USB_POWER. + """ + return self.battery_info["PowerSource"] + + @property + def charging(self) -> bool: + """Return the charging status of the speaker.""" + return self.power_source != "BATTERY" + + async def async_poll_battery(self, now: datetime.datetime | None = None) -> None: + """Poll the device for the current battery state.""" + if not self.available: + return + + if ( + self._last_battery_event + and dt_util.utcnow() - self._last_battery_event < BATTERY_SCAN_INTERVAL + ): + return + + if battery_info := await self.hass.async_add_executor_job( + fetch_battery_info_or_none, self.soco + ): + self.battery_info = battery_info + self.async_write_entity_states() diff --git a/tests/components/sonos/test_sensor.py b/tests/components/sonos/test_sensor.py index a1fc1d7efd8..42bf6eedb9c 100644 --- a/tests/components/sonos/test_sensor.py +++ b/tests/components/sonos/test_sensor.py @@ -2,6 +2,8 @@ from pysonos.exceptions import NotSupportedException from homeassistant.components.sonos import DOMAIN +from homeassistant.components.sonos.binary_sensor import ATTR_BATTERY_POWER_SOURCE +from homeassistant.const import STATE_ON from homeassistant.setup import async_setup_component @@ -22,6 +24,7 @@ async def test_entity_registry_unsupported(hass, config_entry, config, soco): assert "media_player.zone_a" in entity_registry.entities assert "sensor.zone_a_battery" not in entity_registry.entities + assert "binary_sensor.zone_a_power" not in entity_registry.entities async def test_entity_registry_supported(hass, config_entry, config, soco): @@ -32,17 +35,7 @@ async def test_entity_registry_supported(hass, config_entry, config, soco): assert "media_player.zone_a" in entity_registry.entities assert "sensor.zone_a_battery" in entity_registry.entities - - -async def test_battery_missing_attributes(hass, config_entry, config, soco): - """Test sonos device with unknown battery state.""" - soco.get_battery_info.return_value = {} - - await setup_platform(hass, config_entry, config) - - entity_registry = await hass.helpers.entity_registry.async_get_registry() - - assert entity_registry.entities.get("sensor.zone_a_battery") is None + assert "binary_sensor.zone_a_power" in entity_registry.entities async def test_battery_attributes(hass, config_entry, config, soco): @@ -53,9 +46,12 @@ async def test_battery_attributes(hass, config_entry, config, soco): battery = entity_registry.entities["sensor.zone_a_battery"] battery_state = hass.states.get(battery.entity_id) - - # confirm initial state from conftest assert battery_state.state == "100" assert battery_state.attributes.get("unit_of_measurement") == "%" - assert battery_state.attributes.get("charging") - assert battery_state.attributes.get("power_source") == "SONOS_CHARGING_RING" + + power = entity_registry.entities["binary_sensor.zone_a_power"] + power_state = hass.states.get(power.entity_id) + assert power_state.state == STATE_ON + assert ( + power_state.attributes.get(ATTR_BATTERY_POWER_SOURCE) == "SONOS_CHARGING_RING" + )