1
0
mirror of https://github.com/natekspencer/hacs-oasis_mini.git synced 2025-12-06 18:44:14 -05:00
Files
hacs-oasis_mini/custom_components/oasis_mini/pyoasiscontrol/clients/cloud_client.py
2025-11-23 06:45:01 +00:00

192 lines
6.7 KiB
Python

"""Oasis cloud client."""
from __future__ import annotations
from datetime import datetime, timedelta
import logging
from typing import Any
from urllib.parse import urljoin
from aiohttp import ClientResponseError, ClientSession
from ..exceptions import UnauthenticatedError
from ..utils import now
_LOGGER = logging.getLogger(__name__)
BASE_URL = "https://app.grounded.so"
PLAYLISTS_REFRESH_LIMITER = timedelta(minutes=5)
class OasisCloudClient:
"""Cloud client for Oasis.
Responsibilities:
- Manage aiohttp session (optionally owned)
- Manage access token
- Provide async_* helpers for:
* login/logout
* user info
* devices
* tracks/playlists
* latest software metadata
"""
_session: ClientSession | None
_owns_session: bool
_access_token: str | None
# these are "cache" fields for tracks/playlists
_playlists_next_refresh: datetime
playlists: list[dict[str, Any]]
_playlist_details: dict[int, dict[str, str]]
def __init__(
self,
*,
session: ClientSession | None = None,
access_token: str | None = None,
) -> None:
self._session = session
self._owns_session = session is None
self._access_token = access_token
# simple in-memory caches
self._playlists_next_refresh = now()
self.playlists = []
self._playlist_details = {}
@property
def session(self) -> ClientSession:
"""Return (or lazily create) the aiohttp ClientSession."""
if self._session is None or self._session.closed:
self._session = ClientSession()
self._owns_session = True
return self._session
async def async_close(self) -> None:
"""Close owned session (call from HA unload / cleanup)."""
if self._session and not self._session.closed and self._owns_session:
await self._session.close()
@property
def access_token(self) -> str | None:
return self._access_token
@access_token.setter
def access_token(self, value: str | None) -> None:
self._access_token = value
async def async_login(self, email: str, password: str) -> None:
"""Login via the cloud and store the access token."""
response = await self._async_request(
"POST",
urljoin(BASE_URL, "api/auth/login"),
json={"email": email, "password": password},
)
token = response.get("access_token") if isinstance(response, dict) else None
self.access_token = token
_LOGGER.debug("Cloud login succeeded, token set: %s", bool(token))
async def async_logout(self) -> None:
"""Logout from the cloud."""
await self._async_auth_request("GET", "api/auth/logout")
self.access_token = None
async def async_get_user(self) -> dict:
"""Get current user info."""
return await self._async_auth_request("GET", "api/auth/user")
async def async_get_devices(self) -> list[dict[str, Any]]:
"""Get user devices (raw JSON from API)."""
return await self._async_auth_request("GET", "api/user/devices")
async def async_get_playlists(
self, personal_only: bool = False
) -> list[dict[str, Any]]:
"""Get playlists from the cloud (cached by PLAYLISTS_REFRESH_LIMITER)."""
if self._playlists_next_refresh <= now():
params = {"my_playlists": str(personal_only).lower()}
playlists = await self._async_auth_request(
"GET", "api/playlist", params=params
)
if playlists:
self.playlists = playlists
self._playlists_next_refresh = now() + PLAYLISTS_REFRESH_LIMITER
return self.playlists
async def async_get_track_info(self, track_id: int) -> dict[str, Any] | None:
"""Get single track info from the cloud."""
try:
return await self._async_auth_request("GET", f"api/track/{track_id}")
except ClientResponseError as err:
if err.status == 404:
return {"id": track_id, "name": f"Unknown Title (#{track_id})"}
except Exception as ex: # noqa: BLE001
_LOGGER.exception("Error fetching track %s: %s", track_id, ex)
return None
async def async_get_tracks(
self, tracks: list[int] | None = None
) -> list[dict[str, Any]]:
"""Get multiple tracks info from the cloud (handles pagination)."""
response = await self._async_auth_request(
"GET",
"api/track",
params={"ids[]": tracks or []},
)
if not response:
return []
track_details = response.get("data", [])
while next_page_url := response.get("next_page_url"):
response = await self._async_auth_request("GET", next_page_url)
track_details += response.get("data", [])
return track_details
async def async_get_latest_software_details(self) -> dict[str, int | str]:
"""Get latest software metadata from cloud."""
return await self._async_auth_request("GET", "api/software/last-version")
async def _async_auth_request(self, method: str, url: str, **kwargs: Any) -> Any:
"""Perform an authenticated cloud request."""
if not self.access_token:
raise UnauthenticatedError("Unauthenticated")
headers = kwargs.pop("headers", {}) or {}
headers["Authorization"] = f"Bearer {self.access_token}"
return await self._async_request(
method,
url if url.startswith("http") else urljoin(BASE_URL, url),
headers=headers,
**kwargs,
)
async def _async_request(self, method: str, url: str, **kwargs: Any) -> Any:
"""Low-level HTTP helper for both cloud and (if desired) device HTTP."""
session = self.session
_LOGGER.debug(
"%s %s",
method,
session._build_url(url).update_query( # pylint: disable=protected-access
kwargs.get("params"),
),
)
response = await session.request(method, url, **kwargs)
if response.status == 200:
if response.content_type == "application/json":
return await response.json()
if response.content_type == "text/plain":
return await response.text()
if response.content_type == "text/html" and BASE_URL in url:
text = await response.text()
if "login-page" in text:
raise UnauthenticatedError("Unauthenticated")
return None
if response.status == 401:
raise UnauthenticatedError("Unauthenticated")
response.raise_for_status()