1
0
mirror of https://github.com/natekspencer/hacs-oasis_mini.git synced 2025-12-06 18:44:14 -05:00

Dynamically handle devices and other enhancements

This commit is contained in:
Nathan Spencer
2025-11-23 22:49:26 +00:00
parent 83de1d5606
commit cf21a5d995
19 changed files with 430 additions and 170 deletions

View File

@@ -2,7 +2,8 @@
from __future__ import annotations
from datetime import datetime, timedelta
import asyncio
from datetime import timedelta
import logging
from typing import Any
from urllib.parse import urljoin
@@ -16,6 +17,7 @@ _LOGGER = logging.getLogger(__name__)
BASE_URL = "https://app.grounded.so"
PLAYLISTS_REFRESH_LIMITER = timedelta(minutes=5)
SOFTWARE_REFRESH_LIMITER = timedelta(hours=1)
class OasisCloudClient:
@@ -32,15 +34,6 @@ class OasisCloudClient:
* 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,
*,
@@ -51,10 +44,17 @@ class OasisCloudClient:
self._owns_session = session is None
self._access_token = access_token
# simple in-memory caches
# playlists cache
self.playlists: list[dict[str, Any]] = []
self._playlists_next_refresh = now()
self.playlists = []
self._playlist_details = {}
self._playlists_lock = asyncio.Lock()
self._playlist_details: dict[int, dict[str, str]] = {}
# software metadata cache
self._software_details: dict[str, int | str] | None = None
self._software_next_refresh = now()
self._software_lock = asyncio.Lock()
@property
def session(self) -> ClientSession:
@@ -105,15 +105,32 @@ class OasisCloudClient:
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():
now_dt = now()
def _is_cache_valid() -> bool:
return self._playlists_next_refresh > now_dt and bool(self.playlists)
if _is_cache_valid():
return self.playlists
async with self._playlists_lock:
# Double-check in case another task just refreshed it
now_dt = now()
if _is_cache_valid():
return self.playlists
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
if not isinstance(playlists, list):
playlists = []
self.playlists = playlists
self._playlists_next_refresh = now_dt + 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."""
@@ -143,9 +160,37 @@ class OasisCloudClient:
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_get_latest_software_details(
self, *, force_refresh: bool = False
) -> dict[str, int | str] | None:
"""Get latest software metadata from cloud (cached)."""
now_dt = now()
def _is_cache_valid() -> bool:
return (
not force_refresh
and self._software_details is not None
and self._software_next_refresh > now_dt
)
if _is_cache_valid():
return self._software_details
async with self._software_lock:
# Double-check in case another task just refreshed it
now_dt = now()
if _is_cache_valid():
return self._software_details
details = await self._async_auth_request("GET", "api/software/last-version")
if not isinstance(details, dict):
details = {}
self._software_details = details
self._software_next_refresh = now_dt + SOFTWARE_REFRESH_LIMITER
return self._software_details
async def _async_auth_request(self, method: str, url: str, **kwargs: Any) -> Any:
"""Perform an authenticated cloud request."""

View File

@@ -7,7 +7,7 @@ import base64
from datetime import UTC, datetime
import logging
import ssl
from typing import Any, Final
from typing import Any, Final, Iterable
import aiomqtt
@@ -92,6 +92,11 @@ class OasisMqttClient(OasisClientProtocol):
"Could not schedule subscription for %s (no running loop)", serial
)
def register_devices(self, devices: Iterable[OasisDevice]) -> None:
"""Convenience method to register multiple devices."""
for device in devices:
self.register_device(device)
def unregister_device(self, device: OasisDevice) -> None:
serial = device.serial_number
if not serial:
@@ -260,6 +265,13 @@ class OasisMqttClient(OasisClientProtocol):
return device.mac_address
async def async_send_auto_clean_command(
self, device: OasisDevice, auto_clean: bool
) -> None:
"""Send auto clean command."""
payload = f"WRIAUTOCLEAN={1 if auto_clean else 0}"
await self._publish_command(device, payload)
async def async_send_ball_speed_command(
self,
device: OasisDevice,

View File

@@ -16,6 +16,10 @@ class OasisClientProtocol(Protocol):
async def async_get_mac_address(self, device: OasisDevice) -> str | None: ...
async def async_send_auto_clean_command(
self, device: OasisDevice, auto_clean: bool
) -> None: ...
async def async_send_ball_speed_command(
self,
device: OasisDevice,

View File

@@ -23,6 +23,7 @@ _LOGGER = logging.getLogger(__name__)
BALL_SPEED_MAX: Final = 400
BALL_SPEED_MIN: Final = 100
BRIGHTNESS_DEFAULT: Final = 100
LED_SPEED_MAX: Final = 90
LED_SPEED_MIN: Final = -90
@@ -62,6 +63,7 @@ class OasisDevice:
*,
model: str | None = None,
serial_number: str | None = None,
name: str | None = None,
ssid: str | None = None,
ip_address: str | None = None,
cloud: OasisCloudClient | None = None,
@@ -73,10 +75,11 @@ class OasisDevice:
self._listeners: list[Callable[[], None]] = []
# Details
self.model: str | None = model
self.serial_number: str | None = serial_number
self.ssid: str | None = ssid
self.ip_address: str | None = ip_address
self.model = model
self.serial_number = serial_number
self.name = name if name else f"{model} {serial_number}"
self.ssid = ssid
self.ip_address = ip_address
# Status
self.auto_clean: bool = False
@@ -84,7 +87,7 @@ class OasisDevice:
self.ball_speed: int = BALL_SPEED_MIN
self._brightness: int = 0
self.brightness_max: int = 200
self.brightness_on: int = 0
self.brightness_on: int = BRIGHTNESS_DEFAULT
self.busy: bool = False
self.color: str | None = None
self.download_progress: int = 0
@@ -150,7 +153,8 @@ class OasisDevice:
old = getattr(self, name, None)
if old != value:
_LOGGER.debug(
"%s changed: '%s' -> '%s'",
"%s %s changed: '%s' -> '%s'",
self.serial_number,
name.replace("_", " ").capitalize(),
old,
value,
@@ -174,7 +178,7 @@ class OasisDevice:
_LOGGER.warning("Unknown field: %s=%s", key, value)
if playlist_or_index_changed:
self._schedule_track_refresh()
self.schedule_track_refresh()
if changed:
self._notify_listeners()
@@ -343,6 +347,10 @@ class OasisDevice:
self._update_field("mac_address", mac)
return mac
async def async_set_auto_clean(self, auto_clean: bool) -> None:
client = self._require_client()
await client.async_send_auto_clean_command(self, auto_clean)
async def async_set_ball_speed(self, speed: int) -> None:
if not BALL_SPEED_MIN <= speed <= BALL_SPEED_MAX:
raise ValueError("Invalid speed specified")
@@ -438,7 +446,7 @@ class OasisDevice:
client = self._require_client()
await client.async_send_reboot_command(self)
def _schedule_track_refresh(self) -> None:
def schedule_track_refresh(self) -> None:
"""Schedule an async refresh of current track info if track_id changed."""
if not self._cloud:
return