1
0
mirror of https://github.com/natekspencer/hacs-oasis_mini.git synced 2025-11-08 05:03:52 -05:00

Merge pull request #85 from natekspencer/cloud-playlists

Add cloud playlists
This commit is contained in:
Nathan Spencer
2025-08-02 07:52:24 -06:00
committed by GitHub
8 changed files with 120 additions and 18 deletions

View File

@@ -3,12 +3,14 @@
from __future__ import annotations from __future__ import annotations
import logging import logging
from typing import Any
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform from homeassistant.const import Platform
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import ConfigEntryError, ConfigEntryNotReady from homeassistant.exceptions import ConfigEntryError, ConfigEntryNotReady
import homeassistant.helpers.device_registry as dr import homeassistant.helpers.device_registry as dr
import homeassistant.helpers.entity_registry as er
from .const import DOMAIN from .const import DOMAIN
from .coordinator import OasisMiniCoordinator 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: async def update_listener(hass: HomeAssistant, entry: OasisMiniConfigEntry) -> None:
"""Handle options update.""" """Handle options update."""
await hass.config_entries.async_reload(entry.entry_id) 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

View File

@@ -62,6 +62,7 @@ class OasisMiniConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Oasis Mini.""" """Handle a config flow for Oasis Mini."""
VERSION = 1 VERSION = 1
MINOR_VERSION = 2
@staticmethod @staticmethod
@callback @callback

View File

@@ -50,6 +50,7 @@ class OasisMiniCoordinator(DataUpdateCoordinator[str]):
self.attempt = 0 self.attempt = 0
await self.device.async_get_current_track_details() await self.device.async_get_current_track_details()
await self.device.async_get_playlist_details() await self.device.async_get_playlist_details()
await self.device.async_cloud_get_playlists()
except Exception as ex: # pylint:disable=broad-except except Exception as ex: # pylint:disable=broad-except
if self.attempt > 2 or not (data or self.data): if self.attempt > 2 or not (data or self.data):
raise UpdateFailed( raise UpdateFailed(

View File

@@ -3,6 +3,7 @@
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
from datetime import datetime, timedelta
import logging import logging
from typing import Any, Awaitable, Final from typing import Any, Awaitable, Final
from urllib.parse import urljoin from urllib.parse import urljoin
@@ -10,7 +11,7 @@ from urllib.parse import urljoin
from aiohttp import ClientResponseError, ClientSession from aiohttp import ClientResponseError, ClientSession
from .const import TRACKS 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__) _LOGGER = logging.getLogger(__name__)
@@ -89,6 +90,8 @@ BALL_SPEED_MIN: Final = 100
LED_SPEED_MAX: Final = 90 LED_SPEED_MAX: Final = 90
LED_SPEED_MIN: Final = -90 LED_SPEED_MIN: Final = -90
PLAYLISTS_REFRESH_LIMITER = timedelta(minutes=5)
class OasisMini: class OasisMini:
"""Oasis Mini API client class.""" """Oasis Mini API client class."""
@@ -116,6 +119,9 @@ class OasisMini:
repeat_playlist: bool repeat_playlist: bool
status_code: int status_code: int
playlists: list[dict[str, Any]] = []
_playlists_next_refresh: datetime = now()
def __init__( def __init__(
self, self,
host: str, host: str,
@@ -385,6 +391,18 @@ class OasisMini:
"""Login via the cloud.""" """Login via the cloud."""
await self._async_cloud_request("GET", "api/auth/logout") 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: async def async_cloud_get_track_info(self, track_id: int) -> dict[str, Any] | None:
"""Get cloud track info.""" """Get cloud track info."""
try: try:
@@ -404,7 +422,7 @@ class OasisMini:
"GET", "api/track", params={"ids[]": tracks or []} "GET", "api/track", params={"ids[]": tracks or []}
) )
if not response: if not response:
return None return []
track_details = response.get("data", []) track_details = response.get("data", [])
while next_page_url := response.get("next_page_url"): while next_page_url := response.get("next_page_url"):
response = await self._async_cloud_request("GET", next_page_url) response = await self._async_cloud_request("GET", next_page_url)

View File

@@ -3,6 +3,7 @@
from __future__ import annotations from __future__ import annotations
import base64 import base64
from datetime import UTC, datetime
import logging import logging
import math import math
from xml.etree.ElementTree import Element, SubElement, tostring 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 svg_content["decrypted"] = decrypted
return decrypted return decrypted
def now() -> datetime:
return datetime.now(UTC)

View File

@@ -2,6 +2,7 @@
from __future__ import annotations from __future__ import annotations
from collections import defaultdict
from dataclasses import dataclass from dataclasses import dataclass
from typing import Any, Awaitable, Callable from typing import Any, Awaitable, Callable
@@ -63,12 +64,33 @@ class OasisMiniSelectEntity(OasisMiniEntity, SelectEntity):
return super()._handle_coordinator_update() return super()._handle_coordinator_update()
def playlist_update_handler(entity: OasisMiniSelectEntity) -> None: def playlists_update_handler(entity: OasisMiniSelectEntity) -> None:
"""Handle playlist updates.""" """Handle playlists updates."""
# pylint: disable=protected-access # pylint: disable=protected-access
device = entity.device device = entity.device
options = [ counts = defaultdict(int)
device._playlist.get(track, {}).get( 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", "name",
TRACKS.get(track, {"id": track, "name": f"Unknown Title (#{track})"}).get( TRACKS.get(track, {"id": track, "name": f"Unknown Title (#{track})"}).get(
"name", "name",
@@ -77,8 +99,10 @@ def playlist_update_handler(entity: OasisMiniSelectEntity) -> None:
else str(track), 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 entity._attr_options = options
index = min(device.playlist_index, len(options) - 1) index = min(device.playlist_index, len(options) - 1)
entity._attr_current_option = options[index] if options else None 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), select_fn=lambda device, option: device.async_set_autoplay(option),
), ),
OasisMiniSelectEntityDescription( OasisMiniSelectEntityDescription(
key="playlist", key="queue",
translation_key="playlist", translation_key="queue",
current_value=lambda device: (device.playlist.copy(), device.playlist_index), current_value=lambda device: (device.playlist.copy(), device.playlist_index),
select_fn=lambda device, option: device.async_change_track(option), 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, async_add_entities: AddEntitiesCallback,
) -> None: ) -> None:
"""Set up Oasis Mini select using config entry.""" """Set up Oasis Mini select using config entry."""
async_add_entities( coordinator: OasisMiniCoordinator = entry.runtime_data
[ entities = [
OasisMiniSelectEntity(entry.runtime_data, descriptor) OasisMiniSelectEntity(coordinator, descriptor) for descriptor in DESCRIPTORS
for descriptor in DESCRIPTORS ]
] if coordinator.device.access_token:
) entities.extend(
OasisMiniSelectEntity(coordinator, descriptor)
for descriptor in CLOUD_DESCRIPTORS
)
async_add_entities(entities)

View File

@@ -73,6 +73,9 @@
}, },
"playlist": { "playlist": {
"name": "Playlist" "name": "Playlist"
},
"queue": {
"name": "Queue"
} }
}, },
"sensor": { "sensor": {

View File

@@ -73,6 +73,9 @@
}, },
"playlist": { "playlist": {
"name": "Playlist" "name": "Playlist"
},
"queue": {
"name": "Queue"
} }
}, },
"sensor": { "sensor": {