diff --git a/homeassistant/components/sonos/number.py b/homeassistant/components/sonos/number.py index dc0958f6190..8a9b8e9af70 100644 --- a/homeassistant/components/sonos/number.py +++ b/homeassistant/components/sonos/number.py @@ -19,6 +19,7 @@ from .speaker import SonosSpeaker LEVEL_TYPES = { "audio_delay": (0, 5), "bass": (-10, 10), + "balance": (-100, 100), "treble": (-10, 10), "sub_gain": (-15, 15), "surround_level": (-15, 15), @@ -30,6 +31,40 @@ SocoFeatures = list[tuple[str, tuple[int, int]]] _LOGGER = logging.getLogger(__name__) +def _balance_to_number(state: tuple[int, int]) -> float: + """Represent a balance measure returned by SoCo as a number. + + SoCo returns a pair of volumes, one for the left side and one + for the right side. When the two are equal, sound is centered; + HA will show that as 0. When the left side is louder, HA will + show a negative value, and a positive value means the right + side is louder. Maximum absolute value is 100, which means only + one side produces sound at all. + """ + left, right = state + return (right - left) * 100 // max(right, left) + + +def _balance_from_number(value: float) -> tuple[int, int]: + """Convert a balance value from -100 to 100 into SoCo format. + + 0 becomes (100, 100), fully enabling both sides. Note that + the master volume control is separate, so this does not + turn up the speakers to maximum volume. Negative values + reduce the volume of the right side, and positive values + reduce the volume of the left side. -100 becomes (100, 0), + fully muting the right side, and +100 becomes (0, 100), + muting the left side. + """ + left = min(100, 100 - int(value)) + right = min(100, int(value) + 100) + return left, right + + +LEVEL_TO_NUMBER = {"balance": _balance_to_number} +LEVEL_FROM_NUMBER = {"balance": _balance_from_number} + + async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, @@ -92,9 +127,11 @@ class SonosLevelEntity(SonosEntity, NumberEntity): @soco_error() def set_native_value(self, value: float) -> None: """Set a new value.""" - setattr(self.soco, self.level_type, value) + from_number = LEVEL_FROM_NUMBER.get(self.level_type, int) + setattr(self.soco, self.level_type, from_number(value)) @property def native_value(self) -> float: """Return the current value.""" - return cast(float, getattr(self.speaker, self.level_type)) + to_number = LEVEL_TO_NUMBER.get(self.level_type, int) + return cast(float, to_number(getattr(self.speaker, self.level_type))) diff --git a/homeassistant/components/sonos/speaker.py b/homeassistant/components/sonos/speaker.py index 25ba9c86d1f..e576d3f7908 100644 --- a/homeassistant/components/sonos/speaker.py +++ b/homeassistant/components/sonos/speaker.py @@ -145,6 +145,7 @@ class SonosSpeaker: self.volume: int | None = None self.muted: bool | None = None self.cross_fade: bool | None = None + self.balance: tuple[int, int] | None = None self.bass: int | None = None self.treble: int | None = None self.loudness: bool | None = None @@ -536,7 +537,10 @@ class SonosSpeaker: variables = event.variables if "volume" in variables: - self.volume = int(variables["volume"]["Master"]) + volume = variables["volume"] + self.volume = int(volume["Master"]) + if "LF" in volume and "RF" in volume: + self.balance = (int(volume["LF"]), int(volume["RF"])) if "mute" in variables: self.muted = variables["mute"]["Master"] == "1" diff --git a/tests/components/sonos/conftest.py b/tests/components/sonos/conftest.py index ef420c11ef2..4e01ba02edd 100644 --- a/tests/components/sonos/conftest.py +++ b/tests/components/sonos/conftest.py @@ -112,6 +112,7 @@ def soco_fixture( mock_soco.loudness = True mock_soco.volume = 19 mock_soco.audio_delay = 2 + mock_soco.balance = (61, 100) mock_soco.bass = 1 mock_soco.treble = -1 mock_soco.mic_enabled = False diff --git a/tests/components/sonos/test_number.py b/tests/components/sonos/test_number.py index a393f699a57..d5da2af629e 100644 --- a/tests/components/sonos/test_number.py +++ b/tests/components/sonos/test_number.py @@ -11,6 +11,10 @@ async def test_number_entities( hass: HomeAssistant, async_autosetup_sonos, soco, entity_registry: er.EntityRegistry ) -> None: """Test number entities.""" + balance_number = entity_registry.entities["number.zone_a_balance"] + balance_state = hass.states.get(balance_number.entity_id) + assert balance_state.state == "39" + bass_number = entity_registry.entities["number.zone_a_bass"] bass_state = hass.states.get(bass_number.entity_id) assert bass_state.state == "1"