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__)
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(
hass: HomeAssistant,
@ -42,9 +56,15 @@ async def async_setup_entry(
@callback
def _async_create_battery_sensor(speaker: SonosSpeaker) -> None:
_LOGGER.debug("Creating battery level sensor on %s", speaker.zone_name)
entity = SonosBatteryEntity(speaker, config_entry)
async_add_entities([entity])
_LOGGER.debug(
"Creating battery level and power source sensor on %s", speaker.zone_name
)
async_add_entities(
[
SonosBatteryEntity(speaker, config_entry),
SonosPowerSourceEntity(speaker, config_entry),
]
)
@callback
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
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):
"""Representation of a Sonos audio import format sensor entity."""

View File

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

View File

@ -1,20 +1,35 @@
"""Tests for the Sonos battery sensor platform."""
from collections.abc import Callable, Coroutine
from datetime import timedelta
from typing import Any
from unittest.mock import PropertyMock, patch
import pytest
from soco.exceptions import NotSupportedException
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.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.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.helpers import entity_registry as er
from homeassistant.helpers import entity_registry as er, translation
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
@ -42,8 +57,10 @@ async def test_entity_registry_supported(
assert "media_player.zone_a" in entity_registry.entities
assert "sensor.zone_a_battery" 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(
hass: HomeAssistant, async_autosetup_sonos, soco, entity_registry: er.EntityRegistry
) -> None:
@ -60,6 +77,71 @@ async def test_battery_attributes(
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(
hass: HomeAssistant,