From 83de1d5606f72ab4e01da122424c7c93fdbe94e7 Mon Sep 17 00:00:00 2001 From: Nathan Spencer Date: Sun, 23 Nov 2025 06:45:01 +0000 Subject: [PATCH] Add additional helpers --- custom_components/oasis_mini/coordinator.py | 8 +---- custom_components/oasis_mini/helpers.py | 27 +++++++++----- custom_components/oasis_mini/image.py | 27 +++++--------- custom_components/oasis_mini/media_player.py | 13 ++----- .../pyoasiscontrol/clients/cloud_client.py | 6 ++-- .../pyoasiscontrol/clients/mqtt_client.py | 6 ++-- .../oasis_mini/pyoasiscontrol/device.py | 35 +++++++++++++------ .../oasis_mini/pyoasiscontrol/utils.py | 4 +-- custom_components/oasis_mini/select.py | 33 +++++++---------- custom_components/oasis_mini/sensor.py | 27 +++++--------- 10 files changed, 82 insertions(+), 104 deletions(-) diff --git a/custom_components/oasis_mini/coordinator.py b/custom_components/oasis_mini/coordinator.py index eee35d2..fdb326f 100644 --- a/custom_components/oasis_mini/coordinator.py +++ b/custom_components/oasis_mini/coordinator.py @@ -66,14 +66,8 @@ class OasisDeviceCoordinator(DataUpdateCoordinator[list[OasisDevice]]): raise Exception( "Could not get mac address for %s", device.serial_number ) - # if not device.software_version: - # await device.async_get_software_version() - # data = await self.device.async_get_status() - # devices = self.cloud_client.mac_address + await self.cloud_client.async_get_playlists() 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 (devices or self.data): raise UpdateFailed( diff --git a/custom_components/oasis_mini/helpers.py b/custom_components/oasis_mini/helpers.py index e5202b4..0cc5f75 100755 --- a/custom_components/oasis_mini/helpers.py +++ b/custom_components/oasis_mini/helpers.py @@ -2,9 +2,12 @@ from __future__ import annotations +import asyncio import logging from typing import Any +import async_timeout + from homeassistant.const import CONF_ACCESS_TOKEN from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession @@ -23,17 +26,23 @@ def create_client(hass: HomeAssistant, data: dict[str, Any]) -> OasisCloudClient async def add_and_play_track(device: OasisDevice, track: int) -> None: """Add and play a track.""" - if track not in device.playlist: - await device.async_add_track_to_playlist(track) + async with async_timeout.timeout(10): + if track not in device.playlist: + await device.async_add_track_to_playlist(track) - # Move track to next item in the playlist and then select it - if (index := device.playlist.index(track)) != device.playlist_index: - if index != (_next := min(device.playlist_index + 1, len(device.playlist) - 1)): - await device.async_move_track(index, _next) - await device.async_change_track(_next) + while track not in device.playlist: + await asyncio.sleep(0.1) - if device.status_code != 4: - await device.async_play() + # Move track to next item in the playlist and then select it + if (index := device.playlist.index(track)) != device.playlist_index: + if index != ( + _next := min(device.playlist_index + 1, len(device.playlist) - 1) + ): + await device.async_move_track(index, _next) + await device.async_change_track(_next) + + if device.status_code != 4: + await device.async_play() def get_track_id(track: str) -> int | None: diff --git a/custom_components/oasis_mini/image.py b/custom_components/oasis_mini/image.py index 286eb1f..c1ab908 100644 --- a/custom_components/oasis_mini/image.py +++ b/custom_components/oasis_mini/image.py @@ -11,8 +11,6 @@ from . import OasisDeviceConfigEntry from .coordinator import OasisDeviceCoordinator from .entity import OasisDeviceEntity from .pyoasiscontrol import OasisDevice -from .pyoasiscontrol.const import TRACKS -from .pyoasiscontrol.utils import draw_svg async def async_setup_entry( @@ -52,33 +50,24 @@ class OasisDeviceImageEntity(OasisDeviceEntity, ImageEntity): def image(self) -> bytes | None: """Return bytes of image.""" if not self._cached_image: - self._cached_image = Image( - self.content_type, draw_svg(self.device.track, self._progress, "1") - ) + self._cached_image = Image(self.content_type, self.device.create_svg()) return self._cached_image.content @callback def _handle_coordinator_update(self) -> None: """Handle updated data from the coordinator.""" + device = self.device if ( - self._track_id != self.device.track_id - or self._progress != self.device.progress - ) and (self.device.status == "playing" or self._cached_image is None): + self._track_id != device.track_id or self._progress != device.progress + ) and (device.status == "playing" or self._cached_image is None): self._attr_image_last_updated = self.coordinator.last_updated - self._track_id = self.device.track_id - self._progress = self.device.progress + self._track_id = device.track_id + self._progress = device.progress self._cached_image = None - if self.device.track and self.device.track.get("svg_content"): + if device.track and device.track.get("svg_content"): self._attr_image_url = UNDEFINED else: - self._attr_image_url = ( - f"https://app.grounded.so/uploads/{track['image']}" - if ( - track := (self.device.track or TRACKS.get(self.device.track_id)) - ) - and "image" in track - else None - ) + self._attr_image_url = device.track_image_url if self.hass: super()._handle_coordinator_update() diff --git a/custom_components/oasis_mini/media_player.py b/custom_components/oasis_mini/media_player.py index 5ad6cf1..6d45acc 100644 --- a/custom_components/oasis_mini/media_player.py +++ b/custom_components/oasis_mini/media_player.py @@ -23,7 +23,6 @@ from .const import DOMAIN from .coordinator import OasisDeviceCoordinator from .entity import OasisDeviceEntity from .helpers import get_track_id -from .pyoasiscontrol.const import TRACKS async def async_setup_entry( @@ -73,11 +72,7 @@ class OasisDeviceMediaPlayerEntity(OasisDeviceEntity, MediaPlayerEntity): @property def media_image_url(self) -> str | None: """Image url of current playing media.""" - if not (track := self.device.track): - track = TRACKS.get(self.device.track_id) - if track and "image" in track: - return f"https://app.grounded.so/uploads/{track['image']}" - return None + return self.device.track_image_url @property def media_position(self) -> int: @@ -92,11 +87,7 @@ class OasisDeviceMediaPlayerEntity(OasisDeviceEntity, MediaPlayerEntity): @property def media_title(self) -> str | None: """Title of current playing media.""" - if not self.device.track_id: - return None - if not (track := self.device.track): - track = TRACKS.get(self.device.track_id, {}) - return track.get("name", f"Unknown Title (#{self.device.track_id})") + return self.device.track_name @property def repeat(self) -> RepeatMode: diff --git a/custom_components/oasis_mini/pyoasiscontrol/clients/cloud_client.py b/custom_components/oasis_mini/pyoasiscontrol/clients/cloud_client.py index 029a3da..cb0396b 100644 --- a/custom_components/oasis_mini/pyoasiscontrol/clients/cloud_client.py +++ b/custom_components/oasis_mini/pyoasiscontrol/clients/cloud_client.py @@ -2,7 +2,7 @@ from __future__ import annotations -from datetime import timedelta +from datetime import datetime, timedelta import logging from typing import Any from urllib.parse import urljoin @@ -37,7 +37,7 @@ class OasisCloudClient: _access_token: str | None # these are "cache" fields for tracks/playlists - _playlists_next_refresh: float + _playlists_next_refresh: datetime playlists: list[dict[str, Any]] _playlist_details: dict[int, dict[str, str]] @@ -52,7 +52,7 @@ class OasisCloudClient: self._access_token = access_token # simple in-memory caches - self._playlists_next_refresh = 0.0 + self._playlists_next_refresh = now() self.playlists = [] self._playlist_details = {} diff --git a/custom_components/oasis_mini/pyoasiscontrol/clients/mqtt_client.py b/custom_components/oasis_mini/pyoasiscontrol/clients/mqtt_client.py index df39c35..d2c8c2a 100644 --- a/custom_components/oasis_mini/pyoasiscontrol/clients/mqtt_client.py +++ b/custom_components/oasis_mini/pyoasiscontrol/clients/mqtt_client.py @@ -143,8 +143,9 @@ class OasisMqttClient(OasisClientProtocol): async def _resubscribe_all(self) -> None: """Resubscribe to all known devices after (re)connect.""" self._subscribed_serials.clear() - for serial in list(self._devices): + for serial, device in self._devices.items(): await self._subscribe_serial(serial) + await self.async_get_all(device) def start(self) -> None: """Start MQTT connection loop.""" @@ -316,9 +317,6 @@ class OasisMqttClient(OasisClientProtocol): payload = f"WRIJOBLIST={track_str}" await self._publish_command(device, payload) - # local state optimistic update - device.update_from_status_dict({"playlist": playlist}) - async def async_send_set_repeat_playlist_command( self, device: OasisDevice, diff --git a/custom_components/oasis_mini/pyoasiscontrol/device.py b/custom_components/oasis_mini/pyoasiscontrol/device.py index d891f4d..3e763a1 100644 --- a/custom_components/oasis_mini/pyoasiscontrol/device.py +++ b/custom_components/oasis_mini/pyoasiscontrol/device.py @@ -13,7 +13,7 @@ from .const import ( STATUS_CODE_SLEEPING, TRACKS, ) -from .utils import _bit_to_bool, _parse_int, decrypt_svg_content +from .utils import _bit_to_bool, _parse_int, create_svg, decrypt_svg_content if TYPE_CHECKING: # avoid runtime circular imports from .clients import OasisCloudClient @@ -255,6 +255,13 @@ class OasisDevice: """Return human-readable status from status_code.""" return STATUS_CODE_MAP.get(self.status_code, f"Unknown ({self.status_code})") + @property + def track(self) -> dict | None: + """Return cached track info if it matches the current `track_id`.""" + if (track := self._track) and track["id"] == self.track_id: + return track + return TRACKS.get(self.track_id) + @property def track_id(self) -> int | None: if not self.playlist: @@ -263,13 +270,17 @@ class OasisDevice: return self.playlist[0] if i >= len(self.playlist) else self.playlist[i] @property - def track(self) -> dict | None: - """Return cached track info if it matches the current `track_id`.""" - if self._track and self._track.get("id") == self.track_id: - return self._track - if track := TRACKS.get(self.track_id): - self._track = track - return self._track + def track_image_url(self) -> str | None: + """Return the track image url, if any.""" + if (track := self.track) and (image := track.get("image")): + return f"https://app.grounded.so/uploads/{image}" + return None + + @property + def track_name(self) -> str | None: + """Return the track name, if any.""" + if track := self.track: + return track.get("name", f"Unknown Title (#{self.track_id})") return None @property @@ -281,19 +292,23 @@ class OasisDevice: paths = svg_content.split("L") total = self.track.get("reduced_svg_content_new", 0) or len(paths) percent = (100 * self.progress) / total - return max(percent, 100) + return min(percent, 100) @property def playlist_details(self) -> dict[int, dict[str, str]]: """Basic playlist details using built-in TRACKS metadata.""" return { - track_id: TRACKS.get( + track_id: {self.track_id: self.track or {}, **TRACKS}.get( track_id, {"name": f"Unknown Title (#{track_id})"}, ) for track_id in self.playlist } + def create_svg(self) -> str | None: + """Create the current svg based on track and progress.""" + return create_svg(self.track, self.progress) + def add_update_listener(self, listener: Callable[[], None]) -> Callable[[], None]: """Register a callback for state changes. diff --git a/custom_components/oasis_mini/pyoasiscontrol/utils.py b/custom_components/oasis_mini/pyoasiscontrol/utils.py index 23bf401..caf47df 100644 --- a/custom_components/oasis_mini/pyoasiscontrol/utils.py +++ b/custom_components/oasis_mini/pyoasiscontrol/utils.py @@ -35,8 +35,8 @@ def _parse_int(val: str) -> int: return 0 -def draw_svg(track: dict, progress: int, model_id: str) -> str | None: - """Draw SVG.""" +def create_svg(track: dict, progress: int) -> str | None: + """Create an SVG from a track based on progress.""" if track and (svg_content := track.get("svg_content")): try: if progress is not None: diff --git a/custom_components/oasis_mini/select.py b/custom_components/oasis_mini/select.py index b741314..6fed382 100644 --- a/custom_components/oasis_mini/select.py +++ b/custom_components/oasis_mini/select.py @@ -27,7 +27,7 @@ def playlists_update_handler(entity: OasisDeviceSelectEntity) -> None: counts = defaultdict(int) options = [] current_option: str | None = None - for playlist in device.playlists: + for playlist in device._cloud.playlists: name = playlist["name"] counts[name] += 1 if counts[name] > 1: @@ -71,18 +71,11 @@ async def async_setup_entry( ) -> None: """Set up Oasis device select using config entry.""" coordinator: OasisDeviceCoordinator = entry.runtime_data - entities = [ + async_add_entities( OasisDeviceSelectEntity(coordinator, device, descriptor) for device in coordinator.data for descriptor in DESCRIPTORS - ] - # if coordinator.device.access_token: - # entities.extend( - # OasisDeviceSelectEntity(coordinator, device, descriptor) - # for device in coordinator.data - # for descriptor in CLOUD_DESCRIPTORS - # ) - async_add_entities(entities) + ) @dataclass(frozen=True, kw_only=True) @@ -104,6 +97,15 @@ DESCRIPTORS = ( device.async_set_autoplay(AUTOPLAY_MAP_LIST[index]) ), ), + OasisDeviceSelectEntityDescription( + key="playlists", + translation_key="playlist", + current_value=lambda device: (device._cloud.playlists, device.playlist.copy()), + select_fn=lambda device, index: device.async_set_playlist( + [pattern["id"] for pattern in device._cloud.playlists[index]["patterns"]] + ), + update_handler=playlists_update_handler, + ), OasisDeviceSelectEntityDescription( key="queue", translation_key="queue", @@ -112,17 +114,6 @@ DESCRIPTORS = ( update_handler=queue_update_handler, ), ) -CLOUD_DESCRIPTORS = ( - OasisDeviceSelectEntityDescription( - key="playlists", - translation_key="playlist", - current_value=lambda device: (device.playlists, device.playlist.copy()), - select_fn=lambda device, index: device.async_set_playlist( - [pattern["id"] for pattern in device.playlists[index]["patterns"]] - ), - update_handler=playlists_update_handler, - ), -) class OasisDeviceSelectEntity(OasisDeviceEntity, SelectEntity): diff --git a/custom_components/oasis_mini/sensor.py b/custom_components/oasis_mini/sensor.py index 3048950..f20eacd 100644 --- a/custom_components/oasis_mini/sensor.py +++ b/custom_components/oasis_mini/sensor.py @@ -23,17 +23,11 @@ async def async_setup_entry( ) -> None: """Set up Oasis device sensors using config entry.""" coordinator: OasisDeviceCoordinator = entry.runtime_data - entities = [ + async_add_entities( OasisDeviceSensorEntity(coordinator, device, descriptor) for device in coordinator.data for descriptor in DESCRIPTORS - ] - entities.extend( - OasisDeviceSensorEntity(coordinator, device, descriptor) - for device in coordinator.data - for descriptor in CLOUD_DESCRIPTORS ) - async_add_entities(entities) DESCRIPTORS = { @@ -45,6 +39,14 @@ DESCRIPTORS = { native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, ), + SensorEntityDescription( + key="drawing_progress", + translation_key="drawing_progress", + entity_category=EntityCategory.DIAGNOSTIC, + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=1, + ), } | { SensorEntityDescription( key=key, @@ -56,17 +58,6 @@ DESCRIPTORS = { # for key in ("error_message", "led_color_id", "status") } -CLOUD_DESCRIPTORS = ( - SensorEntityDescription( - key="drawing_progress", - translation_key="drawing_progress", - entity_category=EntityCategory.DIAGNOSTIC, - native_unit_of_measurement=PERCENTAGE, - state_class=SensorStateClass.MEASUREMENT, - suggested_display_precision=1, - ), -) - class OasisDeviceSensorEntity(OasisDeviceEntity, SensorEntity): """Oasis device sensor entity."""