Add OptionsFlow to Squeezebox to allow setting Browse Limit and Volume Step (#129578)

* Initial

* prettier strings

* Updates

* remove error strings

* prettier again

* Update strings.json

vscode prettier fails check

* update test to remove invalid value

* Remove config_entry __init__

* remove param

* Review updates

* ruff fixes

* Review changes

* Shorten options flow ui string

* Review changes

* Remove errant mock attib

---------

Co-authored-by: Andrew Sayre <6730289+andrewsayre@users.noreply.github.com>
This commit is contained in:
peteS-UK 2025-02-16 21:02:29 +00:00 committed by GitHub
parent 09df6c8706
commit bdeb24cb61
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 206 additions and 35 deletions

View File

@ -129,10 +129,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: SqueezeboxConfigEntry) -
server_coordinator = LMSStatusDataUpdateCoordinator(hass, entry, lms)
entry.runtime_data = SqueezeboxData(
coordinator=server_coordinator,
server=lms,
)
entry.runtime_data = SqueezeboxData(coordinator=server_coordinator, server=lms)
# set up player discovery
known_servers = hass.data.setdefault(DOMAIN, {}).setdefault(KNOWN_SERVERS, {})

View File

@ -81,11 +81,12 @@ CONTENT_TYPE_TO_CHILD_TYPE = {
"New Music": MediaType.ALBUM,
}
BROWSE_LIMIT = 1000
async def build_item_response(
entity: MediaPlayerEntity, player: Player, payload: dict[str, str | None]
entity: MediaPlayerEntity,
player: Player,
payload: dict[str, str | None],
browse_limit: int,
) -> BrowseMedia:
"""Create response payload for search described by payload."""
@ -107,7 +108,7 @@ async def build_item_response(
result = await player.async_browse(
MEDIA_TYPE_TO_SQUEEZEBOX[search_type],
limit=BROWSE_LIMIT,
limit=browse_limit,
browse_id=browse_id,
)
@ -237,7 +238,11 @@ def media_source_content_filter(item: BrowseMedia) -> bool:
return item.media_content_type.startswith("audio/")
async def generate_playlist(player: Player, payload: dict[str, str]) -> list | None:
async def generate_playlist(
player: Player,
payload: dict[str, str],
browse_limit: int,
) -> list | None:
"""Generate playlist from browsing payload."""
media_type = payload["search_type"]
media_id = payload["search_id"]
@ -247,7 +252,7 @@ async def generate_playlist(player: Player, payload: dict[str, str]) -> list | N
browse_id = (SQUEEZEBOX_ID_BY_TYPE[media_type], media_id)
result = await player.async_browse(
"titles", limit=BROWSE_LIMIT, browse_id=browse_id
"titles", limit=browse_limit, browse_id=browse_id
)
if result and "items" in result:
items: list = result["items"]

View File

@ -11,15 +11,34 @@ from pysqueezebox import Server, async_discover
import voluptuous as vol
from homeassistant.components.media_player import DOMAIN as MP_DOMAIN
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.config_entries import (
ConfigEntry,
ConfigFlow,
ConfigFlowResult,
OptionsFlow,
)
from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT, CONF_USERNAME
from homeassistant.core import callback
from homeassistant.data_entry_flow import AbortFlow
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.device_registry import format_mac
from homeassistant.helpers.selector import (
NumberSelector,
NumberSelectorConfig,
NumberSelectorMode,
)
from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo
from .const import CONF_HTTPS, DEFAULT_PORT, DOMAIN
from .const import (
CONF_BROWSE_LIMIT,
CONF_HTTPS,
CONF_VOLUME_STEP,
DEFAULT_BROWSE_LIMIT,
DEFAULT_PORT,
DEFAULT_VOLUME_STEP,
DOMAIN,
)
_LOGGER = logging.getLogger(__name__)
@ -77,6 +96,12 @@ class SqueezeboxConfigFlow(ConfigFlow, domain=DOMAIN):
self.data_schema = _base_schema()
self.discovery_info: dict[str, Any] | None = None
@staticmethod
@callback
def async_get_options_flow(config_entry: ConfigEntry) -> OptionsFlowHandler:
"""Get the options flow for this handler."""
return OptionsFlowHandler()
async def _discover(self, uuid: str | None = None) -> None:
"""Discover an unconfigured LMS server."""
self.discovery_info = None
@ -222,3 +247,48 @@ class SqueezeboxConfigFlow(ConfigFlow, domain=DOMAIN):
# if the player is unknown, then we likely need to configure its server
return await self.async_step_user()
OPTIONS_SCHEMA = vol.Schema(
{
vol.Required(CONF_BROWSE_LIMIT): vol.All(
NumberSelector(
NumberSelectorConfig(min=1, max=65534, mode=NumberSelectorMode.BOX)
),
vol.Coerce(int),
),
vol.Required(CONF_VOLUME_STEP): vol.All(
NumberSelector(
NumberSelectorConfig(min=1, max=20, mode=NumberSelectorMode.SLIDER)
),
vol.Coerce(int),
),
}
)
class OptionsFlowHandler(OptionsFlow):
"""Options Flow Handler."""
async def async_step_init(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Options Flow Steps."""
if user_input is not None:
return self.async_create_entry(title="", data=user_input)
return self.async_show_form(
step_id="init",
data_schema=self.add_suggested_values_to_schema(
OPTIONS_SCHEMA,
{
CONF_BROWSE_LIMIT: self.config_entry.options.get(
CONF_BROWSE_LIMIT, DEFAULT_BROWSE_LIMIT
),
CONF_VOLUME_STEP: self.config_entry.options.get(
CONF_VOLUME_STEP, DEFAULT_VOLUME_STEP
),
},
),
)

View File

@ -32,3 +32,7 @@ SIGNAL_PLAYER_DISCOVERED = "squeezebox_player_discovered"
SIGNAL_PLAYER_REDISCOVERED = "squeezebox_player_rediscovered"
DISCOVERY_INTERVAL = 60
PLAYER_UPDATE_INTERVAL = 5
CONF_BROWSE_LIMIT = "browse_limit"
CONF_VOLUME_STEP = "volume_step"
DEFAULT_BROWSE_LIMIT = 1000
DEFAULT_VOLUME_STEP = 5

View File

@ -52,6 +52,10 @@ from .browse_media import (
media_source_content_filter,
)
from .const import (
CONF_BROWSE_LIMIT,
CONF_VOLUME_STEP,
DEFAULT_BROWSE_LIMIT,
DEFAULT_VOLUME_STEP,
DISCOVERY_TASK,
DOMAIN,
KNOWN_PLAYERS,
@ -166,6 +170,7 @@ class SqueezeBoxMediaPlayerEntity(
| MediaPlayerEntityFeature.PAUSE
| MediaPlayerEntityFeature.VOLUME_SET
| MediaPlayerEntityFeature.VOLUME_MUTE
| MediaPlayerEntityFeature.VOLUME_STEP
| MediaPlayerEntityFeature.PREVIOUS_TRACK
| MediaPlayerEntityFeature.NEXT_TRACK
| MediaPlayerEntityFeature.SEEK
@ -184,10 +189,7 @@ class SqueezeBoxMediaPlayerEntity(
_attr_name = None
_last_update: datetime | None = None
def __init__(
self,
coordinator: SqueezeBoxPlayerUpdateCoordinator,
) -> None:
def __init__(self, coordinator: SqueezeBoxPlayerUpdateCoordinator) -> None:
"""Initialize the SqueezeBox device."""
super().__init__(coordinator)
player = coordinator.player
@ -223,6 +225,23 @@ class SqueezeBoxMediaPlayerEntity(
self._last_update = utcnow()
self.async_write_ha_state()
@property
def volume_step(self) -> float:
"""Return the step to be used for volume up down."""
return float(
self.coordinator.config_entry.options.get(
CONF_VOLUME_STEP, DEFAULT_VOLUME_STEP
)
/ 100
)
@property
def browse_limit(self) -> int:
"""Return the step to be used for volume up down."""
return self.coordinator.config_entry.options.get(
CONF_BROWSE_LIMIT, DEFAULT_BROWSE_LIMIT
)
@property
def available(self) -> bool:
"""Return True if entity is available."""
@ -366,16 +385,6 @@ class SqueezeBoxMediaPlayerEntity(
await self._player.async_set_power(False)
await self.coordinator.async_refresh()
async def async_volume_up(self) -> None:
"""Volume up media player."""
await self._player.async_set_volume("+5")
await self.coordinator.async_refresh()
async def async_volume_down(self) -> None:
"""Volume down media player."""
await self._player.async_set_volume("-5")
await self.coordinator.async_refresh()
async def async_set_volume_level(self, volume: float) -> None:
"""Set volume level, range 0..1."""
volume_percent = str(int(volume * 100))
@ -466,7 +475,11 @@ class SqueezeBoxMediaPlayerEntity(
"search_id": media_id,
"search_type": MediaType.PLAYLIST,
}
playlist = await generate_playlist(self._player, payload)
playlist = await generate_playlist(
self._player,
payload,
self.browse_limit,
)
except BrowseError:
# a list of urls
content = json.loads(media_id)
@ -477,7 +490,11 @@ class SqueezeBoxMediaPlayerEntity(
"search_id": media_id,
"search_type": media_type,
}
playlist = await generate_playlist(self._player, payload)
playlist = await generate_playlist(
self._player,
payload,
self.browse_limit,
)
_LOGGER.debug("Generated playlist: %s", playlist)
@ -587,7 +604,12 @@ class SqueezeBoxMediaPlayerEntity(
"search_id": media_content_id,
}
return await build_item_response(self, self._player, payload)
return await build_item_response(
self,
self._player,
payload,
self.browse_limit,
)
async def async_get_browse_image(
self,

View File

@ -103,5 +103,20 @@
"unit_of_measurement": "[%key:component::squeezebox::entity::sensor::player_count::unit_of_measurement%]"
}
}
},
"options": {
"step": {
"init": {
"title": "LMS Configuration",
"data": {
"browse_limit": "Browse limit",
"volume_step": "Volume step"
},
"data_description": {
"browse_limit": "Maximum number of items when browsing or in a playlist.",
"volume_step": "Amount to adjust the volume when turning volume up or down."
}
}
}
}
}

View File

@ -33,6 +33,9 @@ from homeassistant.helpers.device_registry import format_mac
# from homeassistant.setup import async_setup_component
from tests.common import MockConfigEntry
CONF_VOLUME_STEP = "volume_step"
TEST_VOLUME_STEP = 10
TEST_HOST = "1.2.3.4"
TEST_PORT = "9000"
TEST_USE_HTTPS = False
@ -109,6 +112,9 @@ def config_entry(hass: HomeAssistant) -> MockConfigEntry:
CONF_PORT: TEST_PORT,
const.CONF_HTTPS: TEST_USE_HTTPS,
},
options={
CONF_VOLUME_STEP: TEST_VOLUME_STEP,
},
)
config_entry.add_to_hass(hass)
return config_entry

View File

@ -65,7 +65,7 @@
'original_name': None,
'platform': 'squeezebox',
'previous_unique_id': None,
'supported_features': <MediaPlayerEntityFeature: 3077055>,
'supported_features': <MediaPlayerEntityFeature: 3078079>,
'translation_key': None,
'unique_id': 'aa:bb:cc:dd:ee:ff',
'unit_of_measurement': None,
@ -88,7 +88,7 @@
}),
'repeat': <RepeatMode.OFF: 'off'>,
'shuffle': False,
'supported_features': <MediaPlayerEntityFeature: 3077055>,
'supported_features': <MediaPlayerEntityFeature: 3078079>,
'volume_level': 0.01,
}),
'context': <ANY>,

View File

@ -6,7 +6,12 @@ from unittest.mock import patch
from pysqueezebox import Server
from homeassistant import config_entries
from homeassistant.components.squeezebox.const import CONF_HTTPS, DOMAIN
from homeassistant.components.squeezebox.const import (
CONF_BROWSE_LIMIT,
CONF_HTTPS,
CONF_VOLUME_STEP,
DOMAIN,
)
from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT, CONF_USERNAME
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
@ -19,6 +24,8 @@ HOST2 = "2.2.2.2"
PORT = 9000
UUID = "test-uuid"
UNKNOWN_ERROR = "1234"
BROWSE_LIMIT = 10
VOLUME_STEP = 1
async def mock_discover(_discovery_callback):
@ -87,6 +94,45 @@ async def test_user_form(hass: HomeAssistant) -> None:
assert len(mock_setup_entry.mock_calls) == 1
async def test_options_form(hass: HomeAssistant) -> None:
"""Test we can configure options."""
entry = MockConfigEntry(
data={
CONF_HOST: HOST,
CONF_PORT: PORT,
CONF_USERNAME: "",
CONF_PASSWORD: "",
CONF_HTTPS: False,
},
unique_id=UUID,
domain=DOMAIN,
options={CONF_BROWSE_LIMIT: 1000, CONF_VOLUME_STEP: 5},
)
entry.add_to_hass(hass)
await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
result = await hass.config_entries.options.async_init(entry.entry_id)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "init"
# simulate manual input of options
result = await hass.config_entries.options.async_configure(
result["flow_id"],
user_input={CONF_BROWSE_LIMIT: BROWSE_LIMIT, CONF_VOLUME_STEP: VOLUME_STEP},
)
# put some meaningful asserts here
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["data"] == {
CONF_BROWSE_LIMIT: BROWSE_LIMIT,
CONF_VOLUME_STEP: VOLUME_STEP,
}
async def test_user_form_timeout(hass: HomeAssistant) -> None:
"""Test we handle server search timeout."""
with (

View File

@ -68,7 +68,7 @@ from homeassistant.helpers.device_registry import DeviceRegistry
from homeassistant.helpers.entity_registry import EntityRegistry
from homeassistant.util.dt import utcnow
from .conftest import FAKE_VALID_ITEM_ID, TEST_MAC
from .conftest import FAKE_VALID_ITEM_ID, TEST_MAC, TEST_VOLUME_STEP
from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform
@ -183,26 +183,32 @@ async def test_squeezebox_volume_up(
hass: HomeAssistant, configured_player: MagicMock
) -> None:
"""Test volume up service call."""
configured_player.volume = 50
await hass.services.async_call(
MEDIA_PLAYER_DOMAIN,
SERVICE_VOLUME_UP,
{ATTR_ENTITY_ID: "media_player.test_player"},
blocking=True,
)
configured_player.async_set_volume.assert_called_once_with("+5")
configured_player.async_set_volume.assert_called_once_with(
str(configured_player.volume + TEST_VOLUME_STEP)
)
async def test_squeezebox_volume_down(
hass: HomeAssistant, configured_player: MagicMock
) -> None:
"""Test volume down service call."""
configured_player.volume = 50
await hass.services.async_call(
MEDIA_PLAYER_DOMAIN,
SERVICE_VOLUME_DOWN,
{ATTR_ENTITY_ID: "media_player.test_player"},
blocking=True,
)
configured_player.async_set_volume.assert_called_once_with("-5")
configured_player.async_set_volume.assert_called_once_with(
str(configured_player.volume - TEST_VOLUME_STEP)
)
async def test_squeezebox_volume_set(