Compare commits

...

3 Commits

Author SHA1 Message Date
Claude
d6a3381081 Add _async_handle_volume_up/down as @final service handlers
Refactor volume up/down to use dedicated @final handler methods
(_async_handle_volume_up, _async_handle_volume_down) that are
registered as service handlers, matching the pattern used by
_async_handle_set_volume_level.

When max_volume is set, the handlers use the fallback volume logic
with rescaled steps, bypassing any custom volume_up/down methods.
When max_volume is not set, they delegate to async_volume_up/down
which preserves integration-specific custom implementations.

The async_volume_up/async_volume_down methods are restored to their
original implementations without max_volume awareness, keeping them
as clean override points for integrations.

https://claude.ai/code/session_01EXD8vTWxzcCJcU4opNe9TW
2026-02-28 01:03:35 +00:00
Claude
2fb6cb15f6 Rescale volume instead of clamping when max_volume is set
When max_volume is set, the user's 0..1 volume range is rescaled to
0..max_volume on the device. For example, with max_volume=0.6, setting
50% results in the device receiving 0.3 (50% of 0.6).

The state_attributes also rescale the volume back, so the user always
sees a 0..1 range: device at 0.3 with max_volume=0.6 reports 0.5.

Volume up/down steps are also rescaled proportionally.

https://claude.ai/code/session_01EXD8vTWxzcCJcU4opNe9TW
2026-02-28 00:57:00 +00:00
Claude
33d0029afb Add max_volume entity registry option for media player entities
Support a max_volume option in the entity registry for media player
entities that support VOLUME_SET. When max_volume is set:
- volume_set service clamps the volume to the max_volume value
- volume_up/volume_down always use the fallback methods which
  respect the max_volume ceiling, even if the entity has custom
  volume_up/volume_down implementations
- When max_volume is not set, existing behavior is preserved
  (custom volume_up/volume_down methods are used if available)

https://claude.ai/code/session_01EXD8vTWxzcCJcU4opNe9TW
2026-02-27 22:08:05 +00:00
4 changed files with 751 additions and 5 deletions

View File

@@ -52,7 +52,7 @@ from homeassistant.const import ( # noqa: F401
STATE_PLAYING,
STATE_STANDBY,
)
from homeassistant.core import HomeAssistant, SupportsResponse
from homeassistant.core import HomeAssistant, SupportsResponse, callback
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.entity import Entity, EntityDescription
@@ -102,6 +102,7 @@ from .const import ( # noqa: F401
ATTR_MEDIA_VOLUME_MUTED,
ATTR_SOUND_MODE,
ATTR_SOUND_MODE_LIST,
CONF_MAX_VOLUME,
CONTENT_AUTH_EXPIRY_TIME,
DOMAIN,
INTENT_MEDIA_SEARCH_AND_PLAY,
@@ -303,13 +304,13 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
component.async_register_entity_service(
SERVICE_VOLUME_UP,
None,
"async_volume_up",
"_async_handle_volume_up",
[MediaPlayerEntityFeature.VOLUME_SET, MediaPlayerEntityFeature.VOLUME_STEP],
)
component.async_register_entity_service(
SERVICE_VOLUME_DOWN,
None,
"async_volume_down",
"_async_handle_volume_down",
[MediaPlayerEntityFeature.VOLUME_SET, MediaPlayerEntityFeature.VOLUME_STEP],
)
component.async_register_entity_service(
@@ -353,7 +354,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
),
_rename_keys(volume=ATTR_MEDIA_VOLUME_LEVEL),
),
"async_set_volume_level",
"_async_handle_set_volume_level",
[MediaPlayerEntityFeature.VOLUME_SET],
)
component.async_register_entity_service(
@@ -552,6 +553,7 @@ class MediaPlayerEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
entity_description: MediaPlayerEntityDescription
_access_token: str | None = None
_media_player_option_max_volume: float | None = None
_attr_app_id: str | None = None
_attr_app_name: str | None = None
@@ -799,6 +801,33 @@ class MediaPlayerEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
"""Flag media player features that are supported."""
return self._attr_supported_features
async def async_internal_added_to_hass(self) -> None:
"""Call when the media player entity is added to hass."""
await super().async_internal_added_to_hass()
if not self.registry_entry:
return
self._async_read_entity_options()
@callback
def async_registry_entry_updated(self) -> None:
"""Run when the entity registry entry has been updated."""
self._async_read_entity_options()
@callback
def _async_read_entity_options(self) -> None:
"""Read entity options from entity registry.
Called when the entity registry entry has been updated and before the
media player is added to the state machine.
"""
assert self.registry_entry
if (
media_player_options := self.registry_entry.options.get(DOMAIN)
) and (max_volume := media_player_options.get(CONF_MAX_VOLUME)) is not None:
self._media_player_option_max_volume = max_volume
return
self._media_player_option_max_volume = None
def turn_on(self) -> None:
"""Turn the media player on."""
raise NotImplementedError
@@ -831,6 +860,51 @@ class MediaPlayerEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
"""Set volume level, range 0..1."""
await self.hass.async_add_executor_job(self.set_volume_level, volume)
@final
async def _async_handle_set_volume_level(self, volume: float) -> None:
"""Set volume level, rescaling to max volume if set."""
if self._media_player_option_max_volume is not None:
volume = volume * self._media_player_option_max_volume
await self.async_set_volume_level(volume)
@final
async def _async_handle_volume_up(self) -> None:
"""Handle volume up, respecting max volume setting."""
if self._media_player_option_max_volume is not None:
max_volume = self._media_player_option_max_volume
if (
self.volume_level is not None
and self.volume_level < max_volume
and MediaPlayerEntityFeature.VOLUME_SET in self.supported_features
):
await self.async_set_volume_level(
min(
max_volume,
self.volume_level + self.volume_step * max_volume,
)
)
return
await self.async_volume_up()
@final
async def _async_handle_volume_down(self) -> None:
"""Handle volume down, respecting max volume setting."""
if self._media_player_option_max_volume is not None:
max_volume = self._media_player_option_max_volume
if (
self.volume_level is not None
and self.volume_level > 0
and MediaPlayerEntityFeature.VOLUME_SET in self.supported_features
):
await self.async_set_volume_level(
max(
0,
self.volume_level - self.volume_step * max_volume,
)
)
return
await self.async_volume_down()
def media_play(self) -> None:
"""Send play command."""
raise NotImplementedError
@@ -1136,6 +1210,16 @@ class MediaPlayerEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
if (value := getattr(self, attr)) is not None:
state_attr[attr] = value
if (
self._media_player_option_max_volume is not None
and ATTR_MEDIA_VOLUME_LEVEL in state_attr
):
state_attr[ATTR_MEDIA_VOLUME_LEVEL] = min(
1.0,
state_attr[ATTR_MEDIA_VOLUME_LEVEL]
/ self._media_player_option_max_volume,
)
if self.media_image_remotely_accessible:
state_attr[ATTR_ENTITY_PICTURE_LOCAL] = self.media_image_local

View File

@@ -41,6 +41,8 @@ ATTR_MEDIA_VOLUME_MUTED = "is_volume_muted"
ATTR_SOUND_MODE = "sound_mode"
ATTR_SOUND_MODE_LIST = "sound_mode_list"
CONF_MAX_VOLUME = "max_volume"
DOMAIN = "media_player"
INTENT_MEDIA_PAUSE = "HassMediaPause"

View File

@@ -0,0 +1,207 @@
"""Fixtures for the media player entity platform tests."""
from collections.abc import Generator
from typing import Any
from unittest.mock import MagicMock
import pytest
from homeassistant.components.media_player import (
DOMAIN,
MediaPlayerEntity,
MediaPlayerEntityFeature,
)
from homeassistant.config_entries import ConfigEntry, ConfigFlow
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from tests.common import (
MockConfigEntry,
MockModule,
MockPlatform,
mock_config_flow,
mock_integration,
mock_platform,
)
TEST_DOMAIN = "test"
class MockMediaPlayer(MediaPlayerEntity):
"""Mocked media player entity."""
def __init__(
self,
supported_features: MediaPlayerEntityFeature = MediaPlayerEntityFeature(0),
) -> None:
"""Initialize the media player."""
self._volume = 0.0
self.calls_set_volume = MagicMock()
self._attr_supported_features = supported_features
self._attr_has_entity_name = True
self._attr_name = "test_media_player"
self._attr_unique_id = "very_unique_media_player_id"
super().__init__()
@property
def volume_level(self) -> float:
"""Volume level of the media player (0..1)."""
return self._volume
def set_volume_level(self, volume: float) -> None:
"""Set volume level, range 0..1."""
self._volume = volume
self.calls_set_volume(volume=volume)
class MockMediaPlayerVolumeUpDown(MockMediaPlayer):
"""Mocked media player entity with custom volume_up/volume_down."""
def __init__(
self,
supported_features: MediaPlayerEntityFeature = MediaPlayerEntityFeature(0),
) -> None:
"""Initialize the media player."""
super().__init__(supported_features)
self.calls_volume_up = MagicMock()
self.calls_volume_down = MagicMock()
def volume_up(self) -> None:
"""Turn volume up for media player."""
self.calls_volume_up()
if self.volume_level < 1:
self.set_volume_level(min(1, self.volume_level + 0.1))
def volume_down(self) -> None:
"""Turn volume down for media player."""
self.calls_volume_down()
if self.volume_level > 0:
self.set_volume_level(max(0, self.volume_level - 0.1))
class MockFlow(ConfigFlow):
"""Test flow."""
@pytest.fixture(autouse=True)
def config_flow_fixture(hass: HomeAssistant) -> Generator[None]:
"""Mock config flow."""
mock_platform(hass, f"{TEST_DOMAIN}.config_flow")
with mock_config_flow(TEST_DOMAIN, MockFlow):
yield
@pytest.fixture(name="supported_features")
async def media_player_supported_features() -> MediaPlayerEntityFeature:
"""Return the supported features for the test media player entity."""
return (
MediaPlayerEntityFeature.VOLUME_SET
| MediaPlayerEntityFeature.VOLUME_STEP
)
@pytest.fixture(name="mock_media_player_entity")
async def setup_media_player_platform_test_entity(
hass: HomeAssistant,
entity_registry: er.EntityRegistry,
supported_features: MediaPlayerEntityFeature,
) -> MockMediaPlayer:
"""Set up media player entity using an entity platform."""
async def async_setup_entry_init(
hass: HomeAssistant, config_entry: ConfigEntry
) -> bool:
"""Set up test config entry."""
await hass.config_entries.async_forward_entry_setups(
config_entry, [Platform.MEDIA_PLAYER]
)
return True
mock_integration(
hass,
MockModule(
TEST_DOMAIN,
async_setup_entry=async_setup_entry_init,
),
)
entity = MockMediaPlayer(supported_features=supported_features)
async def async_setup_entry_platform(
hass: HomeAssistant,
config_entry: ConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up test media player platform via config entry."""
async_add_entities([entity])
mock_platform(
hass,
f"{TEST_DOMAIN}.{DOMAIN}",
MockPlatform(async_setup_entry=async_setup_entry_platform),
)
config_entry = MockConfigEntry(domain=TEST_DOMAIN)
config_entry.add_to_hass(hass)
assert await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
state = hass.states.get(entity.entity_id)
assert state is not None
return entity
@pytest.fixture(name="mock_media_player_custom_vol_entity")
async def setup_media_player_custom_vol_platform_test_entity(
hass: HomeAssistant,
entity_registry: er.EntityRegistry,
supported_features: MediaPlayerEntityFeature,
) -> MockMediaPlayerVolumeUpDown:
"""Set up media player entity with custom volume methods."""
async def async_setup_entry_init(
hass: HomeAssistant, config_entry: ConfigEntry
) -> bool:
"""Set up test config entry."""
await hass.config_entries.async_forward_entry_setups(
config_entry, [Platform.MEDIA_PLAYER]
)
return True
mock_integration(
hass,
MockModule(
TEST_DOMAIN,
async_setup_entry=async_setup_entry_init,
),
)
entity = MockMediaPlayerVolumeUpDown(supported_features=supported_features)
async def async_setup_entry_platform(
hass: HomeAssistant,
config_entry: ConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up test media player platform via config entry."""
async_add_entities([entity])
mock_platform(
hass,
f"{TEST_DOMAIN}.{DOMAIN}",
MockPlatform(async_setup_entry=async_setup_entry_platform),
)
config_entry = MockConfigEntry(domain=TEST_DOMAIN)
config_entry.add_to_hass(hass)
assert await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
state = hass.states.get(entity.entity_id)
assert state is not None
return entity

View File

@@ -12,6 +12,7 @@ from homeassistant.components.media_player import (
ATTR_MEDIA_CONTENT_TYPE,
ATTR_MEDIA_FILTER_CLASSES,
ATTR_MEDIA_SEARCH_QUERY,
ATTR_MEDIA_VOLUME_LEVEL,
BrowseMedia,
MediaClass,
MediaPlayerEnqueue,
@@ -20,14 +21,25 @@ from homeassistant.components.media_player import (
SearchMediaQuery,
)
from homeassistant.components.media_player.const import (
CONF_MAX_VOLUME,
DOMAIN,
SERVICE_BROWSE_MEDIA,
SERVICE_SEARCH_MEDIA,
)
from homeassistant.components.websocket_api import TYPE_RESULT
from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF
from homeassistant.const import (
ATTR_ENTITY_ID,
SERVICE_VOLUME_DOWN,
SERVICE_VOLUME_SET,
SERVICE_VOLUME_UP,
STATE_OFF,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from homeassistant.setup import async_setup_component
from .conftest import MockMediaPlayer, MockMediaPlayerVolumeUpDown
from tests.common import MockEntityPlatform
from tests.test_util.aiohttp import AiohttpClientMocker
from tests.typing import ClientSessionGenerator, WebSocketGenerator
@@ -634,3 +646,444 @@ async def test_play_media_via_selector(hass: HomeAssistant) -> None:
},
blocking=True,
)
async def test_max_volume_option_default(
hass: HomeAssistant,
mock_media_player_entity: MockMediaPlayer,
) -> None:
"""Test that max_volume option is None by default."""
assert mock_media_player_entity._media_player_option_max_volume is None
async def test_max_volume_option_set(
hass: HomeAssistant,
entity_registry: er.EntityRegistry,
mock_media_player_entity: MockMediaPlayer,
) -> None:
"""Test that max_volume option is read from the entity registry."""
entity_registry.async_update_entity_options(
mock_media_player_entity.entity_id,
DOMAIN,
{CONF_MAX_VOLUME: 0.5},
)
await hass.async_block_till_done()
assert mock_media_player_entity._media_player_option_max_volume == 0.5
async def test_max_volume_option_cleared(
hass: HomeAssistant,
entity_registry: er.EntityRegistry,
mock_media_player_entity: MockMediaPlayer,
) -> None:
"""Test that max_volume option can be cleared."""
entity_registry.async_update_entity_options(
mock_media_player_entity.entity_id,
DOMAIN,
{CONF_MAX_VOLUME: 0.5},
)
await hass.async_block_till_done()
assert mock_media_player_entity._media_player_option_max_volume == 0.5
entity_registry.async_update_entity_options(
mock_media_player_entity.entity_id,
DOMAIN,
None,
)
await hass.async_block_till_done()
assert mock_media_player_entity._media_player_option_max_volume is None
async def test_volume_set_rescaled_by_max_volume(
hass: HomeAssistant,
entity_registry: er.EntityRegistry,
mock_media_player_entity: MockMediaPlayer,
) -> None:
"""Test that volume_set service rescales to max_volume.
Setting 80% with max_volume=0.5 should send 0.4 to the device (80% of 0.5).
"""
entity_registry.async_update_entity_options(
mock_media_player_entity.entity_id,
DOMAIN,
{CONF_MAX_VOLUME: 0.5},
)
await hass.async_block_till_done()
await hass.services.async_call(
DOMAIN,
SERVICE_VOLUME_SET,
{
ATTR_ENTITY_ID: mock_media_player_entity.entity_id,
ATTR_MEDIA_VOLUME_LEVEL: 0.8,
},
blocking=True,
)
# Device receives 0.8 * 0.5 = 0.4
assert mock_media_player_entity.volume_level == pytest.approx(0.4)
async def test_volume_set_half_with_max_volume(
hass: HomeAssistant,
entity_registry: er.EntityRegistry,
mock_media_player_entity: MockMediaPlayer,
) -> None:
"""Test that setting 50% with max_volume=0.6 sends 0.3 to the device."""
entity_registry.async_update_entity_options(
mock_media_player_entity.entity_id,
DOMAIN,
{CONF_MAX_VOLUME: 0.6},
)
await hass.async_block_till_done()
await hass.services.async_call(
DOMAIN,
SERVICE_VOLUME_SET,
{
ATTR_ENTITY_ID: mock_media_player_entity.entity_id,
ATTR_MEDIA_VOLUME_LEVEL: 0.5,
},
blocking=True,
)
# Device receives 0.5 * 0.6 = 0.3
assert mock_media_player_entity.volume_level == pytest.approx(0.3)
async def test_volume_set_full_with_max_volume(
hass: HomeAssistant,
entity_registry: er.EntityRegistry,
mock_media_player_entity: MockMediaPlayer,
) -> None:
"""Test that setting 100% with max_volume sends max_volume to device."""
entity_registry.async_update_entity_options(
mock_media_player_entity.entity_id,
DOMAIN,
{CONF_MAX_VOLUME: 0.6},
)
await hass.async_block_till_done()
await hass.services.async_call(
DOMAIN,
SERVICE_VOLUME_SET,
{
ATTR_ENTITY_ID: mock_media_player_entity.entity_id,
ATTR_MEDIA_VOLUME_LEVEL: 1.0,
},
blocking=True,
)
# Device receives 1.0 * 0.6 = 0.6
assert mock_media_player_entity.volume_level == pytest.approx(0.6)
async def test_volume_set_without_max_volume(
hass: HomeAssistant,
mock_media_player_entity: MockMediaPlayer,
) -> None:
"""Test that volume_set without max_volume passes through unchanged."""
await hass.services.async_call(
DOMAIN,
SERVICE_VOLUME_SET,
{
ATTR_ENTITY_ID: mock_media_player_entity.entity_id,
ATTR_MEDIA_VOLUME_LEVEL: 0.9,
},
blocking=True,
)
assert mock_media_player_entity.volume_level == 0.9
async def test_volume_state_rescaled_by_max_volume(
hass: HomeAssistant,
entity_registry: er.EntityRegistry,
mock_media_player_entity: MockMediaPlayer,
) -> None:
"""Test that volume_level in state is rescaled back to 0..1 range.
Device at 0.3 with max_volume=0.6 should report 0.5 (50%) in the state.
"""
entity_registry.async_update_entity_options(
mock_media_player_entity.entity_id,
DOMAIN,
{CONF_MAX_VOLUME: 0.6},
)
await hass.async_block_till_done()
# Set 50% → device gets 0.3
await hass.services.async_call(
DOMAIN,
SERVICE_VOLUME_SET,
{
ATTR_ENTITY_ID: mock_media_player_entity.entity_id,
ATTR_MEDIA_VOLUME_LEVEL: 0.5,
},
blocking=True,
)
# Raw device value is 0.3
assert mock_media_player_entity.volume_level == pytest.approx(0.3)
# Trigger a state write so the state attributes are updated
mock_media_player_entity.async_write_ha_state()
state = hass.states.get(mock_media_player_entity.entity_id)
assert state is not None
# State should report rescaled value: 0.3 / 0.6 = 0.5
assert state.attributes[ATTR_MEDIA_VOLUME_LEVEL] == pytest.approx(0.5)
async def test_volume_up_rescaled_with_max_volume(
hass: HomeAssistant,
entity_registry: er.EntityRegistry,
mock_media_player_entity: MockMediaPlayer,
) -> None:
"""Test that volume_up uses rescaled step with max_volume.
With max_volume=0.5 and step=0.1, a volume_up should add
0.1 * 0.5 = 0.05 to the device volume.
"""
entity_registry.async_update_entity_options(
mock_media_player_entity.entity_id,
DOMAIN,
{CONF_MAX_VOLUME: 0.5},
)
await hass.async_block_till_done()
# Set volume to 80% → device gets 0.8 * 0.5 = 0.4
await hass.services.async_call(
DOMAIN,
SERVICE_VOLUME_SET,
{
ATTR_ENTITY_ID: mock_media_player_entity.entity_id,
ATTR_MEDIA_VOLUME_LEVEL: 0.8,
},
blocking=True,
)
assert mock_media_player_entity.volume_level == pytest.approx(0.4)
# Volume up: step in device space = 0.1 * 0.5 = 0.05
# New device volume = 0.4 + 0.05 = 0.45
await hass.services.async_call(
DOMAIN,
SERVICE_VOLUME_UP,
{ATTR_ENTITY_ID: mock_media_player_entity.entity_id},
blocking=True,
)
assert mock_media_player_entity.volume_level == pytest.approx(0.45)
async def test_volume_up_does_not_exceed_max_volume(
hass: HomeAssistant,
entity_registry: er.EntityRegistry,
mock_media_player_entity: MockMediaPlayer,
) -> None:
"""Test that volume_up does nothing when already at max_volume."""
entity_registry.async_update_entity_options(
mock_media_player_entity.entity_id,
DOMAIN,
{CONF_MAX_VOLUME: 0.5},
)
await hass.async_block_till_done()
# Set volume to 100% → device gets 1.0 * 0.5 = 0.5
await hass.services.async_call(
DOMAIN,
SERVICE_VOLUME_SET,
{
ATTR_ENTITY_ID: mock_media_player_entity.entity_id,
ATTR_MEDIA_VOLUME_LEVEL: 1.0,
},
blocking=True,
)
assert mock_media_player_entity.volume_level == pytest.approx(0.5)
# Volume up should not go above max_volume
await hass.services.async_call(
DOMAIN,
SERVICE_VOLUME_UP,
{ATTR_ENTITY_ID: mock_media_player_entity.entity_id},
blocking=True,
)
assert mock_media_player_entity.volume_level == pytest.approx(0.5)
async def test_volume_down_rescaled_with_max_volume(
hass: HomeAssistant,
entity_registry: er.EntityRegistry,
mock_media_player_entity: MockMediaPlayer,
) -> None:
"""Test that volume_down uses rescaled step with max_volume."""
entity_registry.async_update_entity_options(
mock_media_player_entity.entity_id,
DOMAIN,
{CONF_MAX_VOLUME: 0.5},
)
await hass.async_block_till_done()
# Set volume to 100% → device gets 0.5
await hass.services.async_call(
DOMAIN,
SERVICE_VOLUME_SET,
{
ATTR_ENTITY_ID: mock_media_player_entity.entity_id,
ATTR_MEDIA_VOLUME_LEVEL: 1.0,
},
blocking=True,
)
assert mock_media_player_entity.volume_level == pytest.approx(0.5)
# Volume down: step in device space = 0.1 * 0.5 = 0.05
# New device volume = 0.5 - 0.05 = 0.45
await hass.services.async_call(
DOMAIN,
SERVICE_VOLUME_DOWN,
{ATTR_ENTITY_ID: mock_media_player_entity.entity_id},
blocking=True,
)
assert mock_media_player_entity.volume_level == pytest.approx(0.45)
async def test_max_volume_overrides_custom_volume_up(
hass: HomeAssistant,
entity_registry: er.EntityRegistry,
mock_media_player_custom_vol_entity: MockMediaPlayerVolumeUpDown,
) -> None:
"""Test that max_volume forces fallback and skips custom volume_up."""
entity = mock_media_player_custom_vol_entity
entity_registry.async_update_entity_options(
entity.entity_id,
DOMAIN,
{CONF_MAX_VOLUME: 0.5},
)
await hass.async_block_till_done()
# Set volume to 80% → device gets 0.8 * 0.5 = 0.4
await hass.services.async_call(
DOMAIN,
SERVICE_VOLUME_SET,
{
ATTR_ENTITY_ID: entity.entity_id,
ATTR_MEDIA_VOLUME_LEVEL: 0.8,
},
blocking=True,
)
# Volume up should use fallback (not custom)
await hass.services.async_call(
DOMAIN,
SERVICE_VOLUME_UP,
{ATTR_ENTITY_ID: entity.entity_id},
blocking=True,
)
# Custom volume_up should NOT have been called
entity.calls_volume_up.assert_not_called()
# Device: 0.4 + 0.1 * 0.5 = 0.45
assert entity.volume_level == pytest.approx(0.45)
async def test_max_volume_overrides_custom_volume_down(
hass: HomeAssistant,
entity_registry: er.EntityRegistry,
mock_media_player_custom_vol_entity: MockMediaPlayerVolumeUpDown,
) -> None:
"""Test that max_volume forces fallback and skips custom volume_down."""
entity = mock_media_player_custom_vol_entity
entity_registry.async_update_entity_options(
entity.entity_id,
DOMAIN,
{CONF_MAX_VOLUME: 0.5},
)
await hass.async_block_till_done()
# Set volume to 80% → device gets 0.8 * 0.5 = 0.4
await hass.services.async_call(
DOMAIN,
SERVICE_VOLUME_SET,
{
ATTR_ENTITY_ID: entity.entity_id,
ATTR_MEDIA_VOLUME_LEVEL: 0.8,
},
blocking=True,
)
# Volume down should use fallback (not custom)
await hass.services.async_call(
DOMAIN,
SERVICE_VOLUME_DOWN,
{ATTR_ENTITY_ID: entity.entity_id},
blocking=True,
)
# Custom volume_down should NOT have been called
entity.calls_volume_down.assert_not_called()
# Device: 0.4 - 0.1 * 0.5 = 0.35
assert entity.volume_level == pytest.approx(0.35)
async def test_no_max_volume_uses_custom_volume_up(
hass: HomeAssistant,
mock_media_player_custom_vol_entity: MockMediaPlayerVolumeUpDown,
) -> None:
"""Test that without max_volume, custom volume_up is used."""
entity = mock_media_player_custom_vol_entity
# Set volume to 0.5 (no max_volume, so no rescaling)
await hass.services.async_call(
DOMAIN,
SERVICE_VOLUME_SET,
{
ATTR_ENTITY_ID: entity.entity_id,
ATTR_MEDIA_VOLUME_LEVEL: 0.5,
},
blocking=True,
)
# Volume up should use the custom method
await hass.services.async_call(
DOMAIN,
SERVICE_VOLUME_UP,
{ATTR_ENTITY_ID: entity.entity_id},
blocking=True,
)
entity.calls_volume_up.assert_called_once()
assert entity.volume_level == pytest.approx(0.6)
async def test_no_max_volume_uses_custom_volume_down(
hass: HomeAssistant,
mock_media_player_custom_vol_entity: MockMediaPlayerVolumeUpDown,
) -> None:
"""Test that without max_volume, custom volume_down is used."""
entity = mock_media_player_custom_vol_entity
# Set volume to 0.5 (no max_volume, so no rescaling)
await hass.services.async_call(
DOMAIN,
SERVICE_VOLUME_SET,
{
ATTR_ENTITY_ID: entity.entity_id,
ATTR_MEDIA_VOLUME_LEVEL: 0.5,
},
blocking=True,
)
# Volume down should use the custom method
await hass.services.async_call(
DOMAIN,
SERVICE_VOLUME_DOWN,
{ATTR_ENTITY_ID: entity.entity_id},
blocking=True,
)
entity.calls_volume_down.assert_called_once()
assert entity.volume_level == pytest.approx(0.4)