diff --git a/aiorussound/__init__.py b/aiorussound/__init__.py index f74f2fd..10c97de 100644 --- a/aiorussound/__init__.py +++ b/aiorussound/__init__.py @@ -10,9 +10,10 @@ UnsupportedRussoundVersionError, ) from .connection import RussoundTcpConnectionHandler -from .models import Source, RussoundMessage, Zone +from .models import Source, RussoundMessage, Zone, Favorite from .rio import Controller, RussoundClient + __all__ = [ "RussoundError", "CommandError", @@ -23,6 +24,7 @@ "Controller", "Zone", "RussoundTcpConnectionHandler", + "Favorite", "Source", "RussoundMessage", ] diff --git a/aiorussound/const.py b/aiorussound/const.py index a3b705b..15d8c4e 100644 --- a/aiorussound/const.py +++ b/aiorussound/const.py @@ -6,6 +6,7 @@ from enum import Enum import re + MINIMUM_API_SUPPORT = "1.05.00" DEFAULT_PORT = 9621 @@ -16,6 +17,9 @@ KEEP_ALIVE_INTERVAL = 60 MAX_SOURCE = 17 +MAX_SYSTEM_FAVORITES = 32 + +SYSTEM_KEY = "System" MAX_RNET_CONTROLLERS = 6 diff --git a/aiorussound/models.py b/aiorussound/models.py index 331a59a..fcb356d 100644 --- a/aiorussound/models.py +++ b/aiorussound/models.py @@ -8,6 +8,18 @@ from mashumaro.mixins.orjson import DataClassORJSONMixin +@dataclass +class Favorite: + """Russound Favorite.""" + + favorite_id: int + is_system_favorite: bool + name: str + provider_mode: str + album_cover_url: str + source_id: int + + @dataclass class Zone(DataClassORJSONMixin): """Data class representing Russound state.""" diff --git a/aiorussound/rio.py b/aiorussound/rio.py index 4271c89..aa7b50d 100644 --- a/aiorussound/rio.py +++ b/aiorussound/rio.py @@ -8,12 +8,15 @@ from dataclasses import field, dataclass from typing import Any, Coroutine, Optional + from aiorussound.connection import RussoundConnectionHandler from aiorussound.const import ( FLAGS_BY_VERSION, MAX_SOURCE, MINIMUM_API_SUPPORT, FeatureFlag, + SYSTEM_KEY, + MAX_SYSTEM_FAVORITES, MAX_RNET_CONTROLLERS, RESPONSE_REGEX, KEEP_ALIVE_INTERVAL, @@ -30,9 +33,12 @@ Source, Zone, MessageType, + Favorite, ) + from aiorussound.util import ( controller_device_str, + get_max_zones_favorites, is_feature_supported, is_fw_version_higher, source_device_str, @@ -398,6 +404,53 @@ def supported_features(self) -> list[FeatureFlag]: flags.append(flag) return flags + async def enumerate_system_favorites(self) -> list[Favorite]: + """Return a list of Favorite for this system.""" + favorites = [] + + for favorite_id in range(1, MAX_SYSTEM_FAVORITES): + try: + valid = await self._get_system_favorite_variable(favorite_id, "valid") + if valid == "TRUE": + try: + name = await self._get_system_favorite_variable( + favorite_id, "name" + ) + providerMode = await self._get_system_favorite_variable( + favorite_id, "providerMode" + ) + albumCoverURL = await self._get_system_favorite_variable( + favorite_id, "albumCoverURL" + ) + source_id = await self._get_system_favorite_variable( + favorite_id, "source" + ) + + favorites.append( + Favorite( + favorite_id, + True, + name, + providerMode, + albumCoverURL, + source_id, + ) + ) + except CommandError: + break + except CommandError: + continue + return favorites + + async def _get_system_favorite_variable(self, favorite_id, variable) -> str: + """Return a system favorite variable.""" + try: + return await self.get_variable( + SYSTEM_KEY, f"favorite[{favorite_id}].{variable}" + ) + except RussoundError: + return "False" + class AbstractControlSurface: def __init__(self): @@ -469,6 +522,126 @@ async def select_source(self, source: int) -> str: """Select a source.""" return await self.send_event("SelectSource", source) + async def enumerate_favorites(self) -> list[Favorite]: + """Return a list of Favorite for this zone.""" + favorites = [] + max_zone_favorites = get_max_zones_favorites( + self.client.controllers[1].controller_type + ) + + if max_zone_favorites > 0: + for favorite_id in range(1, max_zone_favorites): + try: + valid = await self.client.get_variable( + self.device_str, f"favorite[{favorite_id}].valid" + ) + if valid == "TRUE": + try: + name = await self.client.get_variable( + self.device_str, f"favorite[{favorite_id}].name" + ) + providerMode = await self.client.get_variable( + self.device_str, f"favorite[{favorite_id}].providerMode" + ) + albumCoverURL = await self.client.get_variable( + self.device_str, + f"favorite[{favorite_id}].albumCoverURL", + ) + source_id = await self.client.get_variable( + self.device_str, f"favorite[{favorite_id}].source" + ) + + favorites.append( + Favorite( + favorite_id, + False, + name, + providerMode, + albumCoverURL, + source_id, + ) + ) + except CommandError: + break + except CommandError: + continue + return favorites + + async def save_system_favorite(self, favorite_id: int, favorite_name=None) -> None: + """Save system favorite to controller.""" + if favorite_name is None: + # default to channel name if no name is provided + favorite_name = self.client.sources[int(self.current_source)].channel_name + + if favorite_name is None: + # if no channel name, set a default name + favorite_name = f"F{favorite_id}" + + if favorite_id >= 1 and favorite_id <= MAX_SYSTEM_FAVORITES: + _LOGGER.debug("Saving system favorite %d", favorite_id) + await self.send_event( + "saveSystemFavorite", f'"{favorite_name}"', favorite_id + ) + else: + raise RussoundError + + async def save_zone_favorite(self, favorite_id: int, favorite_name=None) -> None: + """Save zone favorite to contoller.""" + if favorite_name is None: + # default to channel name if no name is provided + favorite_name = self.client.sources[int(self.current_source)].channel_name + + if favorite_name is None: + # if no channel name, set a default name + favorite_name = f"F{favorite_id}" + + if favorite_id >= 1 and favorite_id <= get_max_zones_favorites( + self.client.controllers[1].controller_type + ): + _LOGGER.debug("Saving zone favorite %d", favorite_id) + await self.send_event("saveZoneFavorite", f'"{favorite_name}"', favorite_id) + else: + raise RussoundError + + async def delete_system_favorite(self, favorite_id: int) -> None: + """Delete system favorite from contoller.""" + self.delete_system_favorite(favorite_id) + + if favorite_id >= 1 and favorite_id <= MAX_SYSTEM_FAVORITES: + _LOGGER.debug("Removing system favorite %d", favorite_id) + await self.send_event("deleteSystemFavorite", favorite_id) + else: + raise RussoundError + + async def delete_zone_favorite(self, favorite_id: int) -> None: + """Delete zone favorite from contoller.""" + + if favorite_id >= 1 and favorite_id <= get_max_zones_favorites( + self.client.controllers[1].controller_type + ): + _LOGGER.debug("Removing zone favorite %d", favorite_id) + await self.send_event("deleteZoneFavorite", favorite_id) + else: + raise RussoundError + + async def restore_system_favorite(self, favorite_id: int) -> None: + """Change to system favorite from contoller for this zone.""" + + if favorite_id >= 1 and favorite_id <= MAX_SYSTEM_FAVORITES: + await self.send_event("restoreSystemFavorite", favorite_id) + else: + raise RussoundError + + async def restore_zone_favorite(self, favorite_id: int) -> None: + """Change to zone favorite from contoller for this zone.""" + + if favorite_id >= 1 and favorite_id <= get_max_zones_favorites( + self.client.controllers[1].controller_type + ): + await self.send_event("restoreZoneFavorite", favorite_id) + else: + raise RussoundError + @dataclass class Controller: diff --git a/aiorussound/util.py b/aiorussound/util.py index e950409..66a2062 100644 --- a/aiorussound/util.py +++ b/aiorussound/util.py @@ -71,6 +71,23 @@ def get_max_zones(model: str) -> int: return 1 +def get_max_zones_favorites(model: str) -> int: + """Return a maximum number of zones favorites supported by a specific controller.""" + if model in ( + "MCA-88", + "MCA-88X", + "MCA-C5", + "MCA-66", + "MCA-C3", + "MBX-AMP", + "MBX-PRE", + ): + return 4 + if model in ("XSource", "XZone4", "XZone70V"): + return 2 + return 0 + + def is_rnet_capable(model: str) -> bool: """Return whether a controller is rnet capable.""" return model in ("MCA-88X", "MCA-88", "MCA-66", "MCA-C5", "MCA-C3") diff --git a/tests/test_util.py b/tests/test_util.py index 6cbba08..f986583 100644 --- a/tests/test_util.py +++ b/tests/test_util.py @@ -9,6 +9,7 @@ from aiorussound.util import ( controller_device_str, get_max_zones, + get_max_zones_favorites, is_feature_supported, is_fw_version_higher, raise_unsupported_feature, @@ -84,3 +85,22 @@ def test_source_device_str(source_id: int, result: str) -> None: def test_get_max_zones(model: str, max_zones: int) -> None: """Test if the maximum number of zones is correct.""" assert get_max_zones(model) == max_zones + + +@pytest.mark.parametrize( + "model_favorites,max_zone_favorites", + [ + ("MCA-C5", 4), + ("MCA-88", 4), + ("MCA-66", 4), + ("MCA-C3", 4), + ("MBX-PRE", 4), + ("XSource", 2), + ("XZone4", 2), + ("XZone70V",2), + ("Other", 0), + ], +) +def test_get_max_zones_favorites(model_favorites: str, max_zone_favorites: int) -> None: + """Test if the maximum number of zone favorites is correct.""" + assert get_max_zones_favorites(model_favorites) == max_zone_favorites \ No newline at end of file