From 4e24e2038debfb9dbc3e030275ccf56186f12b3a Mon Sep 17 00:00:00 2001 From: Raman Gupta <7243222+raman325@users.noreply.github.com> Date: Mon, 24 Aug 2026 15:55:51 -0400 Subject: [PATCH] Report Vizio volume and mute when absent from the audio settings collection (#179433) --- homeassistant/components/vizio/coordinator.py | 26 +++++- .../components/vizio/media_player.py | 32 ++++--- tests/components/vizio/conftest.py | 16 ++++ tests/components/vizio/test_media_player.py | 91 +++++++++++++++++-- 4 files changed, 145 insertions(+), 20 deletions(-) diff --git a/homeassistant/components/vizio/coordinator.py b/homeassistant/components/vizio/coordinator.py index 5b90e95b2c13..a9b088d383f9 100644 --- a/homeassistant/components/vizio/coordinator.py +++ b/homeassistant/components/vizio/coordinator.py @@ -32,7 +32,13 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.storage import Store from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from .const import DOMAIN, VIZIO_AUDIO_SETTINGS, VIZIO_SOUND_MODE +from .const import ( + DOMAIN, + VIZIO_AUDIO_SETTINGS, + VIZIO_MUTE, + VIZIO_SOUND_MODE, + VIZIO_VOLUME, +) type VizioConfigEntry = ConfigEntry[VizioRuntimeData] @@ -96,6 +102,11 @@ class VizioDeviceData: # Audio settings from get_settings("audio") audio_settings: dict[str, SettingInfo] | None = None + # Volume and mute, read individually when the audio settings + # collection does not carry them. + volume: int | None = None + is_muted: bool | None = None + # Sound mode options from get_setting("audio", "eq") sound_mode_list: list[str] | None = None @@ -193,6 +204,17 @@ class VizioDeviceCoordinator(DataUpdateCoordinator[VizioDeviceData]): # Device is on - fetch all data audio_settings = await _optional(self.device.get_settings(VIZIO_AUDIO_SETTINGS)) + # Some firmware omits volume and mute from the audio settings + # collection even though the individual settings still work, and + # there is no way to tell in advance. Read them directly only when + # they are missing, so unaffected devices cost nothing extra. + volume: int | None = None + is_muted: bool | None = None + if audio_settings is None or VIZIO_VOLUME not in audio_settings: + volume = await _optional(self.device.get_volume()) + if audio_settings is None or VIZIO_MUTE not in audio_settings: + is_muted = await _optional(self.device.is_muted()) + sound_mode_list = None if audio_settings and VIZIO_SOUND_MODE in audio_settings: sound_mode = await _optional( @@ -231,6 +253,8 @@ class VizioDeviceCoordinator(DataUpdateCoordinator[VizioDeviceData]): return VizioDeviceData( is_on=True, audio_settings=audio_settings, + volume=volume, + is_muted=is_muted, sound_mode_list=sound_mode_list, current_input=current_input, input_list=input_list, diff --git a/homeassistant/components/vizio/media_player.py b/homeassistant/components/vizio/media_player.py index 0ca90b994e0d..db0282b1d1d3 100644 --- a/homeassistant/components/vizio/media_player.py +++ b/homeassistant/components/vizio/media_player.py @@ -152,20 +152,26 @@ class VizioDevice(VizioEntity, MediaPlayerEntity): # Device is on - apply coordinator data self._attr_state = MediaPlayerState.ON - # Audio settings + # Audio settings. Volume and mute come from the collection when it + # carries them, and from the coordinator's individual reads when it + # does not - some firmware omits them from the collection. + volume: int | None + if data.audio_settings and VIZIO_VOLUME in data.audio_settings: + volume = int(data.audio_settings[VIZIO_VOLUME].value) + else: + volume = data.volume + self._attr_volume_level = ( + None if volume is None else float(volume) / self._max_volume + ) + + if data.audio_settings and VIZIO_MUTE in data.audio_settings: + self._attr_is_volume_muted = ( + str(data.audio_settings[VIZIO_MUTE].value).lower() == VIZIO_MUTE_ON + ) + else: + self._attr_is_volume_muted = data.is_muted + if data.audio_settings: - if VIZIO_VOLUME in data.audio_settings: - self._attr_volume_level = ( - float(data.audio_settings[VIZIO_VOLUME].value) / self._max_volume - ) - else: - self._attr_volume_level = None - if VIZIO_MUTE in data.audio_settings: - self._attr_is_volume_muted = ( - str(data.audio_settings[VIZIO_MUTE].value).lower() == VIZIO_MUTE_ON - ) - else: - self._attr_is_volume_muted = None if VIZIO_SOUND_MODE in data.audio_settings: self._attr_supported_features |= ( MediaPlayerEntityFeature.SELECT_SOUND_MODE diff --git a/tests/components/vizio/conftest.py b/tests/components/vizio/conftest.py index e071a9966d02..dc39ba585a53 100644 --- a/tests/components/vizio/conftest.py +++ b/tests/components/vizio/conftest.py @@ -254,6 +254,14 @@ def vizio_bypass_update_fixture() -> Generator[None]: "homeassistant.components.vizio.Vizio.get_settings", return_value=None, ), + patch( + "homeassistant.components.vizio.Vizio.get_volume", + return_value=None, + ), + patch( + "homeassistant.components.vizio.Vizio.is_muted", + return_value=None, + ), patch( "homeassistant.components.vizio.Vizio.get_current_input", return_value=None, @@ -397,6 +405,14 @@ def vizio_update_fixture() -> Generator[None]: options=tuple(EQ_LIST), ), ), + patch( + "homeassistant.components.vizio.Vizio.get_volume", + return_value=int(SOUNDBAR_PROFILE.max_volume / 2), + ), + patch( + "homeassistant.components.vizio.Vizio.is_muted", + return_value=False, + ), patch( "homeassistant.components.vizio.Vizio.get_current_input", return_value=CURRENT_INPUT, diff --git a/tests/components/vizio/test_media_player.py b/tests/components/vizio/test_media_player.py index 27684fde14a5..0385be4c6348 100644 --- a/tests/components/vizio/test_media_player.py +++ b/tests/components/vizio/test_media_player.py @@ -9,7 +9,7 @@ from unittest.mock import call, patch from freezegun.api import FrozenDateTimeFactory import pytest from syrupy.assertion import SnapshotAssertion -from vizaio import AppConfig, RemoteKey, VizioConnectionError +from vizaio import AppConfig, RemoteKey, VizioConnectionError, VizioNotFoundError from vizaio.apps import BUNDLED_APPS, UNKNOWN_APP, is_app_input from homeassistant.components.media_player import ( @@ -170,6 +170,18 @@ async def _cm_for_test_setup_without_apps( "homeassistant.components.vizio.Vizio.get_power_state", return_value=vizio_power_state, ), + # The coordinator falls back to these when the audio settings + # collection omits volume or mute. Default them to unsupported so + # a test opting out of a setting really gets no value for it; + # tests exercising the fallback patch over these. + patch( + "homeassistant.components.vizio.Vizio.get_volume", + side_effect=VizioNotFoundError("not supported"), + ), + patch( + "homeassistant.components.vizio.Vizio.is_muted", + side_effect=VizioNotFoundError("not supported"), + ), ): yield @@ -293,12 +305,12 @@ async def _test_service( async def test_tv_without_volume_in_audio_settings( hass: HomeAssistant, mock_tv_config_entry: MockConfigEntry ) -> None: - """Test a TV whose audio settings omit volume. + """Test a TV that exposes volume and mute nowhere at all. - Some firmware does not list `volume` (or `mute`) in the `audio` - settings collection even though the individual settings still work. - The entity must still load, with the unavailable attributes reported - as None instead of raising on every coordinator update. + Some firmware does not list them in the `audio` settings collection; + when the individual settings are unavailable too, there is nothing + left to read. The entity must still load with both attributes unset, + rather than raising on every coordinator update. """ async with _cm_for_test_setup_without_apps({"eq": CURRENT_EQ}, True): await setup_integration(hass, mock_tv_config_entry) @@ -310,6 +322,73 @@ async def test_tv_without_volume_in_audio_settings( assert attr[ATTR_SOUND_MODE] == CURRENT_EQ +@pytest.mark.usefixtures("vizio_connect", "vizio_update") +async def test_tv_volume_and_mute_read_individually( + hass: HomeAssistant, mock_tv_config_entry: MockConfigEntry +) -> None: + """Test a TV whose audio settings omit volume and mute. + + Some firmware does not list them in the `audio` collection even + though the individual settings still work, so the coordinator reads + them directly and the entity still reports both. + """ + volume = int(MAX_VOLUME[MediaPlayerDeviceClass.TV] / 2) + async with _cm_for_test_setup_without_apps({"eq": CURRENT_EQ}, True): + with ( + patch( + "homeassistant.components.vizio.Vizio.get_volume", + return_value=volume, + ) as get_volume, + patch( + "homeassistant.components.vizio.Vizio.is_muted", + return_value=True, + ) as is_muted, + ): + await setup_integration(hass, mock_tv_config_entry) + + attr = _get_attr_and_assert_base_attr( + hass, MediaPlayerDeviceClass.TV, STATE_ON + ) + assert ( + attr["volume_level"] + == float(volume) / MAX_VOLUME[MediaPlayerDeviceClass.TV] + ) + assert attr["is_volume_muted"] is True + assert get_volume.called + assert is_muted.called + + +@pytest.mark.usefixtures("vizio_connect", "vizio_update") +async def test_tv_volume_and_mute_not_read_when_present( + hass: HomeAssistant, mock_tv_config_entry: MockConfigEntry +) -> None: + """Test that the individual reads are skipped when not needed. + + Devices that do list volume and mute in the collection must not pay + an extra round trip for each on every coordinator update. + """ + volume = int(MAX_VOLUME[MediaPlayerDeviceClass.TV] / 2) + async with _cm_for_test_setup_without_apps( + {"volume": volume, "mute": "Off", "eq": CURRENT_EQ}, True + ): + with ( + patch("homeassistant.components.vizio.Vizio.get_volume") as get_volume, + patch("homeassistant.components.vizio.Vizio.is_muted") as is_muted, + ): + await setup_integration(hass, mock_tv_config_entry) + + attr = _get_attr_and_assert_base_attr( + hass, MediaPlayerDeviceClass.TV, STATE_ON + ) + assert ( + attr["volume_level"] + == float(volume) / MAX_VOLUME[MediaPlayerDeviceClass.TV] + ) + assert attr["is_volume_muted"] is False + assert not get_volume.called + assert not is_muted.called + + @pytest.mark.usefixtures("vizio_connect", "vizio_update") async def test_speaker_on( hass: HomeAssistant, mock_speaker_config_entry: MockConfigEntry