Add power binary_sensor support to Sonos (#49730)

* Add power binary_sensor support to Sonos

* Prepare for future unloading of config entries

* Remove unnecessary calls to super() inits

* Add binary_sensor to tests, remove invalid test for empty battery payload

* Move sensor added_to_hass to common sensor class

* Avoid dispatching sensors if no battery

* Use proper attributes property

* Remove power source fallback

* Update homeassistant/components/sonos/speaker.py

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
This commit is contained in:
jjlawren 2021-04-27 09:52:05 -05:00 committed by GitHub
parent 9742bfdf46
commit d4ed65e0f5
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 216 additions and 183 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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