From 8a72aba2946a0150722898bdd1e37ca893230b03 Mon Sep 17 00:00:00 2001 From: Nathan Spencer Date: Sat, 2 Aug 2025 13:48:58 +0000 Subject: [PATCH] Add cloud playlists --- custom_components/oasis_mini/__init__.py | 34 ++++++++- custom_components/oasis_mini/config_flow.py | 1 + custom_components/oasis_mini/coordinator.py | 1 + .../oasis_mini/pyoasismini/__init__.py | 22 +++++- .../oasis_mini/pyoasismini/utils.py | 5 ++ custom_components/oasis_mini/select.py | 69 +++++++++++++++---- custom_components/oasis_mini/strings.json | 3 + .../oasis_mini/translations/en.json | 3 + 8 files changed, 120 insertions(+), 18 deletions(-) diff --git a/custom_components/oasis_mini/__init__.py b/custom_components/oasis_mini/__init__.py index c2d818e..c96ed32 100755 --- a/custom_components/oasis_mini/__init__.py +++ b/custom_components/oasis_mini/__init__.py @@ -3,12 +3,14 @@ from __future__ import annotations import logging +from typing import Any from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryError, ConfigEntryNotReady import homeassistant.helpers.device_registry as dr +import homeassistant.helpers.entity_registry as er from .const import DOMAIN from .coordinator import OasisMiniCoordinator @@ -89,3 +91,33 @@ async def async_remove_entry(hass: HomeAssistant, entry: OasisMiniConfigEntry) - async def update_listener(hass: HomeAssistant, entry: OasisMiniConfigEntry) -> None: """Handle options update.""" await hass.config_entries.async_reload(entry.entry_id) + + +async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry): + """Migrate old entry.""" + _LOGGER.debug( + "Migrating configuration from version %s.%s", entry.version, entry.minor_version + ) + + if entry.version == 1 and entry.minor_version == 1: + # Need to update previous playlist select entity to queue + @callback + def migrate_unique_id(entity_entry: er.RegistryEntry) -> dict[str, Any] | None: + """Migrate the playlist unique ID to queue.""" + if entity_entry.domain == "select" and entity_entry.unique_id.endswith( + "-playlist" + ): + unique_id = entity_entry.unique_id.replace("-playlist", "-queue") + return {"new_unique_id": unique_id} + return None + + await er.async_migrate_entries(hass, entry.entry_id, migrate_unique_id) + hass.config_entries.async_update_entry(entry, minor_version=2, version=1) + + _LOGGER.debug( + "Migration to configuration version %s.%s successful", + entry.version, + entry.minor_version, + ) + + return True diff --git a/custom_components/oasis_mini/config_flow.py b/custom_components/oasis_mini/config_flow.py index fd6d81b..5fec557 100755 --- a/custom_components/oasis_mini/config_flow.py +++ b/custom_components/oasis_mini/config_flow.py @@ -62,6 +62,7 @@ class OasisMiniConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for Oasis Mini.""" VERSION = 1 + MINOR_VERSION = 2 @staticmethod @callback diff --git a/custom_components/oasis_mini/coordinator.py b/custom_components/oasis_mini/coordinator.py index c54918f..0509225 100644 --- a/custom_components/oasis_mini/coordinator.py +++ b/custom_components/oasis_mini/coordinator.py @@ -50,6 +50,7 @@ class OasisMiniCoordinator(DataUpdateCoordinator[str]): self.attempt = 0 await self.device.async_get_current_track_details() await self.device.async_get_playlist_details() + await self.device.async_cloud_get_playlists() except Exception as ex: # pylint:disable=broad-except if self.attempt > 2 or not (data or self.data): raise UpdateFailed( diff --git a/custom_components/oasis_mini/pyoasismini/__init__.py b/custom_components/oasis_mini/pyoasismini/__init__.py index 01d1052..3c364b2 100644 --- a/custom_components/oasis_mini/pyoasismini/__init__.py +++ b/custom_components/oasis_mini/pyoasismini/__init__.py @@ -3,6 +3,7 @@ from __future__ import annotations import asyncio +from datetime import datetime, timedelta import logging from typing import Any, Awaitable, Final from urllib.parse import urljoin @@ -10,7 +11,7 @@ from urllib.parse import urljoin from aiohttp import ClientResponseError, ClientSession from .const import TRACKS -from .utils import _bit_to_bool, _parse_int, decrypt_svg_content +from .utils import _bit_to_bool, _parse_int, decrypt_svg_content, now _LOGGER = logging.getLogger(__name__) @@ -89,6 +90,8 @@ BALL_SPEED_MIN: Final = 100 LED_SPEED_MAX: Final = 90 LED_SPEED_MIN: Final = -90 +PLAYLISTS_REFRESH_LIMITER = timedelta(minutes=5) + class OasisMini: """Oasis Mini API client class.""" @@ -116,6 +119,9 @@ class OasisMini: repeat_playlist: bool status_code: int + playlists: list[dict[str, Any]] = [] + _playlists_next_refresh: datetime = now() + def __init__( self, host: str, @@ -385,6 +391,18 @@ class OasisMini: """Login via the cloud.""" await self._async_cloud_request("GET", "api/auth/logout") + async def async_cloud_get_playlists( + self, personal_only: bool = False + ) -> list[dict[str, Any]]: + """Get playlists from the cloud.""" + if self._playlists_next_refresh <= now(): + if playlists := await self._async_cloud_request( + "GET", "api/playlist", params={"my_playlists": str(personal_only)} + ): + self.playlists = playlists + self._playlists_next_refresh = now() + PLAYLISTS_REFRESH_LIMITER + return self.playlists + async def async_cloud_get_track_info(self, track_id: int) -> dict[str, Any] | None: """Get cloud track info.""" try: @@ -404,7 +422,7 @@ class OasisMini: "GET", "api/track", params={"ids[]": tracks or []} ) if not response: - return None + return [] track_details = response.get("data", []) while next_page_url := response.get("next_page_url"): response = await self._async_cloud_request("GET", next_page_url) diff --git a/custom_components/oasis_mini/pyoasismini/utils.py b/custom_components/oasis_mini/pyoasismini/utils.py index 56aa855..d75d0c7 100644 --- a/custom_components/oasis_mini/pyoasismini/utils.py +++ b/custom_components/oasis_mini/pyoasismini/utils.py @@ -3,6 +3,7 @@ from __future__ import annotations import base64 +from datetime import UTC, datetime import logging import math from xml.etree.ElementTree import Element, SubElement, tostring @@ -177,3 +178,7 @@ def decrypt_svg_content(svg_content: dict[str, str]): svg_content["decrypted"] = decrypted return decrypted + + +def now() -> datetime: + return datetime.now(UTC) diff --git a/custom_components/oasis_mini/select.py b/custom_components/oasis_mini/select.py index 5ca59e8..038758d 100644 --- a/custom_components/oasis_mini/select.py +++ b/custom_components/oasis_mini/select.py @@ -2,6 +2,7 @@ from __future__ import annotations +from collections import defaultdict from dataclasses import dataclass from typing import Any, Awaitable, Callable @@ -63,12 +64,33 @@ class OasisMiniSelectEntity(OasisMiniEntity, SelectEntity): return super()._handle_coordinator_update() -def playlist_update_handler(entity: OasisMiniSelectEntity) -> None: - """Handle playlist updates.""" +def playlists_update_handler(entity: OasisMiniSelectEntity) -> None: + """Handle playlists updates.""" # pylint: disable=protected-access device = entity.device - options = [ - device._playlist.get(track, {}).get( + counts = defaultdict(int) + options = [] + current_option: str | None = None + for playlist in device.playlists: + name = playlist["name"] + counts[name] += 1 + if counts[name] > 1: + name = f"{name} ({counts[name]})" + options.append(name) + if device.playlist == [pattern["id"] for pattern in playlist["patterns"]]: + current_option = name + entity._attr_options = options + entity._attr_current_option = current_option + + +def queue_update_handler(entity: OasisMiniSelectEntity) -> None: + """Handle queue updates.""" + # pylint: disable=protected-access + device = entity.device + counts = defaultdict(int) + options = [] + for track in device.playlist: + name = device._playlist.get(track, {}).get( "name", TRACKS.get(track, {"id": track, "name": f"Unknown Title (#{track})"}).get( "name", @@ -77,8 +99,10 @@ def playlist_update_handler(entity: OasisMiniSelectEntity) -> None: else str(track), ), ) - for track in device.playlist - ] + counts[name] += 1 + if counts[name] > 1: + name = f"{name} ({counts[name]})" + options.append(name) entity._attr_options = options index = min(device.playlist_index, len(options) - 1) entity._attr_current_option = options[index] if options else None @@ -93,11 +117,22 @@ DESCRIPTORS = ( select_fn=lambda device, option: device.async_set_autoplay(option), ), OasisMiniSelectEntityDescription( - key="playlist", - translation_key="playlist", + key="queue", + translation_key="queue", current_value=lambda device: (device.playlist.copy(), device.playlist_index), select_fn=lambda device, option: device.async_change_track(option), - update_handler=playlist_update_handler, + update_handler=queue_update_handler, + ), +) +CLOUD_DESCRIPTORS = ( + OasisMiniSelectEntityDescription( + key="playlists", + translation_key="playlist", + current_value=lambda device: (device.playlists, device.playlist.copy()), + select_fn=lambda device, option: device.async_set_playlist( + [pattern["id"] for pattern in device.playlists[option]["patterns"]] + ), + update_handler=playlists_update_handler, ), ) @@ -108,9 +143,13 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up Oasis Mini select using config entry.""" - async_add_entities( - [ - OasisMiniSelectEntity(entry.runtime_data, descriptor) - for descriptor in DESCRIPTORS - ] - ) + coordinator: OasisMiniCoordinator = entry.runtime_data + entities = [ + OasisMiniSelectEntity(coordinator, descriptor) for descriptor in DESCRIPTORS + ] + if coordinator.device.access_token: + entities.extend( + OasisMiniSelectEntity(coordinator, descriptor) + for descriptor in CLOUD_DESCRIPTORS + ) + async_add_entities(entities) diff --git a/custom_components/oasis_mini/strings.json b/custom_components/oasis_mini/strings.json index 810efe5..26234dc 100755 --- a/custom_components/oasis_mini/strings.json +++ b/custom_components/oasis_mini/strings.json @@ -73,6 +73,9 @@ }, "playlist": { "name": "Playlist" + }, + "queue": { + "name": "Queue" } }, "sensor": { diff --git a/custom_components/oasis_mini/translations/en.json b/custom_components/oasis_mini/translations/en.json index 7adf027..ea41990 100755 --- a/custom_components/oasis_mini/translations/en.json +++ b/custom_components/oasis_mini/translations/en.json @@ -73,6 +73,9 @@ }, "playlist": { "name": "Playlist" + }, + "queue": { + "name": "Queue" } }, "sensor": {