Add enum sensor for Sonos Power Source (#147449)

* feat: add power source sensor

* fix: translations

* fix:cleanup

* fix: simpify

* fix: improve coverage

* fix: improve coverage

* fix: add missing test

* fix: call it charging_base

* fix: disable entity by default

* update snapshots

* Update homeassistant/components/sonos/strings.json

Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>

* fix: update test

---------

Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
This commit is contained in:
Pete Sage 2025-06-25 17:49:06 -04:00 committed by GitHub
parent 1286b5d9d8
commit 345ec97dd5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 158 additions and 6 deletions

View File

@ -24,6 +24,20 @@ from .speaker import SonosSpeaker
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
SONOS_POWER_SOURCE_BATTERY = "BATTERY"
SONOS_POWER_SOURCE_CHARGING_RING = "SONOS_CHARGING_RING"
SONOS_POWER_SOURCE_USB = "USB_POWER"
HA_POWER_SOURCE_BATTERY = "battery"
HA_POWER_SOURCE_CHARGING_BASE = "charging_base"
HA_POWER_SOURCE_USB = "usb"
power_source_map = {
SONOS_POWER_SOURCE_BATTERY: HA_POWER_SOURCE_BATTERY,
SONOS_POWER_SOURCE_CHARGING_RING: HA_POWER_SOURCE_CHARGING_BASE,
SONOS_POWER_SOURCE_USB: HA_POWER_SOURCE_USB,
}
async def async_setup_entry( async def async_setup_entry(
hass: HomeAssistant, hass: HomeAssistant,
@ -42,9 +56,15 @@ async def async_setup_entry(
@callback @callback
def _async_create_battery_sensor(speaker: SonosSpeaker) -> None: def _async_create_battery_sensor(speaker: SonosSpeaker) -> None:
_LOGGER.debug("Creating battery level sensor on %s", speaker.zone_name) _LOGGER.debug(
entity = SonosBatteryEntity(speaker, config_entry) "Creating battery level and power source sensor on %s", speaker.zone_name
async_add_entities([entity]) )
async_add_entities(
[
SonosBatteryEntity(speaker, config_entry),
SonosPowerSourceEntity(speaker, config_entry),
]
)
@callback @callback
def _async_create_favorites_sensor(favorites: SonosFavorites) -> None: def _async_create_favorites_sensor(favorites: SonosFavorites) -> None:
@ -101,6 +121,48 @@ class SonosBatteryEntity(SonosEntity, SensorEntity):
return self.speaker.available and self.speaker.power_source is not None return self.speaker.available and self.speaker.power_source is not None
class SonosPowerSourceEntity(SonosEntity, SensorEntity):
"""Representation of a Sonos Power Source entity."""
_attr_device_class = SensorDeviceClass.ENUM
_attr_entity_category = EntityCategory.DIAGNOSTIC
_attr_entity_registry_enabled_default = False
_attr_options = [
HA_POWER_SOURCE_BATTERY,
HA_POWER_SOURCE_CHARGING_BASE,
HA_POWER_SOURCE_USB,
]
_attr_translation_key = "power_source"
def __init__(self, speaker: SonosSpeaker, config_entry: SonosConfigEntry) -> None:
"""Initialize the power source sensor."""
super().__init__(speaker, config_entry)
self._attr_unique_id = f"{self.soco.uid}-power_source"
async def _async_fallback_poll(self) -> None:
"""Poll the device for the current state."""
await self.speaker.async_poll_battery()
@property
def native_value(self) -> str | None:
"""Return the state of the sensor."""
if not (power_source := self.speaker.power_source):
return None
if not (value := power_source_map.get(power_source)):
_LOGGER.warning(
"Unknown power source '%s' for speaker %s",
power_source,
self.speaker.zone_name,
)
return None
return value
@property
def available(self) -> bool:
"""Return whether this entity is available."""
return self.speaker.available and self.speaker.power_source is not None
class SonosAudioInputFormatSensorEntity(SonosPollingEntity, SensorEntity): class SonosAudioInputFormatSensorEntity(SonosPollingEntity, SensorEntity):
"""Representation of a Sonos audio import format sensor entity.""" """Representation of a Sonos audio import format sensor entity."""

View File

@ -53,6 +53,14 @@
"sensor": { "sensor": {
"audio_input_format": { "audio_input_format": {
"name": "Audio input format" "name": "Audio input format"
},
"power_source": {
"name": "Power source",
"state": {
"battery": "Battery",
"charging_base": "Charging base",
"usb": "USB"
}
} }
}, },
"switch": { "switch": {

View File

@ -1,20 +1,35 @@
"""Tests for the Sonos battery sensor platform.""" """Tests for the Sonos battery sensor platform."""
from collections.abc import Callable, Coroutine
from datetime import timedelta from datetime import timedelta
from typing import Any
from unittest.mock import PropertyMock, patch from unittest.mock import PropertyMock, patch
import pytest import pytest
from soco.exceptions import NotSupportedException from soco.exceptions import NotSupportedException
from homeassistant.components.sensor import SCAN_INTERVAL from homeassistant.components.sensor import SCAN_INTERVAL
from homeassistant.components.sonos import 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.components.sonos.sensor import (
HA_POWER_SOURCE_BATTERY,
HA_POWER_SOURCE_CHARGING_BASE,
HA_POWER_SOURCE_USB,
SensorDeviceClass,
)
from homeassistant.config_entries import RELOAD_AFTER_UPDATE_DELAY from homeassistant.config_entries import RELOAD_AFTER_UPDATE_DELAY
from homeassistant.const import STATE_OFF, STATE_ON from homeassistant.const import (
STATE_OFF,
STATE_ON,
STATE_UNAVAILABLE,
STATE_UNKNOWN,
Platform,
)
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er from homeassistant.helpers import entity_registry as er, translation
from homeassistant.util import dt as dt_util from homeassistant.util import dt as dt_util
from .conftest import SonosMockEvent from .conftest import MockSoCo, SonosMockEvent
from tests.common import async_fire_time_changed from tests.common import async_fire_time_changed
@ -42,8 +57,10 @@ async def test_entity_registry_supported(
assert "media_player.zone_a" in entity_registry.entities assert "media_player.zone_a" in entity_registry.entities
assert "sensor.zone_a_battery" in entity_registry.entities assert "sensor.zone_a_battery" in entity_registry.entities
assert "binary_sensor.zone_a_charging" in entity_registry.entities assert "binary_sensor.zone_a_charging" in entity_registry.entities
assert "sensor.zone_a_power_source" in entity_registry.entities
@pytest.mark.usefixtures("entity_registry_enabled_by_default")
async def test_battery_attributes( async def test_battery_attributes(
hass: HomeAssistant, async_autosetup_sonos, soco, entity_registry: er.EntityRegistry hass: HomeAssistant, async_autosetup_sonos, soco, entity_registry: er.EntityRegistry
) -> None: ) -> None:
@ -60,6 +77,71 @@ async def test_battery_attributes(
power_state.attributes.get(ATTR_BATTERY_POWER_SOURCE) == "SONOS_CHARGING_RING" power_state.attributes.get(ATTR_BATTERY_POWER_SOURCE) == "SONOS_CHARGING_RING"
) )
power_source = entity_registry.entities["sensor.zone_a_power_source"]
power_source_state = hass.states.get(power_source.entity_id)
assert power_source_state.state == HA_POWER_SOURCE_CHARGING_BASE
assert power_source_state.attributes.get("device_class") == SensorDeviceClass.ENUM
assert power_source_state.attributes.get("options") == [
HA_POWER_SOURCE_BATTERY,
HA_POWER_SOURCE_CHARGING_BASE,
HA_POWER_SOURCE_USB,
]
result = translation.async_translate_state(
hass,
power_source_state.state,
Platform.SENSOR,
DOMAIN,
power_source.translation_key,
None,
)
assert result == "Charging base"
@pytest.mark.usefixtures("entity_registry_enabled_by_default")
async def test_power_source_unknown_state(
hass: HomeAssistant,
async_setup_sonos: Callable[[], Coroutine[Any, Any, None]],
soco: MockSoCo,
entity_registry: er.EntityRegistry,
caplog: pytest.LogCaptureFixture,
) -> None:
"""Test bad value for power source."""
soco.get_battery_info.return_value = {
"Level": 100,
"PowerSource": "BAD_POWER_SOURCE",
}
with caplog.at_level("WARNING"):
await async_setup_sonos()
assert "Unknown power source" in caplog.text
assert "BAD_POWER_SOURCE" in caplog.text
assert "Zone A" in caplog.text
power_source = entity_registry.entities["sensor.zone_a_power_source"]
power_source_state = hass.states.get(power_source.entity_id)
assert power_source_state.state == STATE_UNKNOWN
@pytest.mark.usefixtures("entity_registry_enabled_by_default")
async def test_power_source_none(
hass: HomeAssistant,
async_setup_sonos: Callable[[], Coroutine[Any, Any, None]],
soco: MockSoCo,
entity_registry: er.EntityRegistry,
caplog: pytest.LogCaptureFixture,
) -> None:
"""Test none value for power source."""
soco.get_battery_info.return_value = {
"Level": 100,
"PowerSource": None,
}
await async_setup_sonos()
power_source = entity_registry.entities["sensor.zone_a_power_source"]
power_source_state = hass.states.get(power_source.entity_id)
assert power_source_state.state == STATE_UNAVAILABLE
async def test_battery_on_s1( async def test_battery_on_s1(
hass: HomeAssistant, hass: HomeAssistant,