From 3b90603bef49c8007db726001f6b558a09b42628 Mon Sep 17 00:00:00 2001 From: Nathan Spencer Date: Tue, 30 Jul 2024 23:44:43 -0600 Subject: [PATCH] Add additional features --- custom_components/oasis_mini/button.py | 13 +-- custom_components/oasis_mini/coordinator.py | 7 +- custom_components/oasis_mini/helpers.py | 15 ++++ custom_components/oasis_mini/image.py | 41 ++++++--- custom_components/oasis_mini/media_player.py | 32 ++++++- .../oasis_mini/pyoasismini/__init__.py | 30 +++++-- .../oasis_mini/pyoasismini/tracks.json | 85 +++++++++++++++++++ .../oasis_mini/pyoasismini/utils.py | 4 +- custom_components/oasis_mini/select.py | 23 ++++- custom_components/oasis_mini/strings.json | 1 + .../oasis_mini/translations/en.json | 1 + 11 files changed, 217 insertions(+), 35 deletions(-) diff --git a/custom_components/oasis_mini/button.py b/custom_components/oasis_mini/button.py index 493ac21..d801c69 100644 --- a/custom_components/oasis_mini/button.py +++ b/custom_components/oasis_mini/button.py @@ -19,6 +19,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN from .coordinator import OasisMiniCoordinator from .entity import OasisMiniEntity +from .helpers import add_and_play_track from .pyoasismini import OasisMini from .pyoasismini.const import TRACKS @@ -39,17 +40,7 @@ async def async_setup_entry( async def play_random_track(device: OasisMini) -> None: """Play random track.""" track = int(random.choice(list(TRACKS))) - 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_index := device.playlist_index + 1): - await device.async_move_track(index, next_index) - await device.async_change_track(next_index) - - if device.status_code != 4: - await device.async_play() + await add_and_play_track(device, track) @dataclass(frozen=True, kw_only=True) diff --git a/custom_components/oasis_mini/coordinator.py b/custom_components/oasis_mini/coordinator.py index dfb2420..0698a02 100644 --- a/custom_components/oasis_mini/coordinator.py +++ b/custom_components/oasis_mini/coordinator.py @@ -25,7 +25,11 @@ class OasisMiniCoordinator(DataUpdateCoordinator[str]): def __init__(self, hass: HomeAssistant, device: OasisMini) -> None: """Initialize.""" super().__init__( - hass, _LOGGER, name=DOMAIN, update_interval=timedelta(seconds=10) + hass, + _LOGGER, + name=DOMAIN, + update_interval=timedelta(seconds=10), + always_update=False, ) self.device = device @@ -44,6 +48,7 @@ class OasisMiniCoordinator(DataUpdateCoordinator[str]): await self.device.async_get_software_version() data = await self.device.async_get_status() await self.device.async_get_current_track_details() + await self.device.async_get_playlist_details() except Exception as ex: # pylint:disable=broad-except if self.attempt > 2 or not self.data: raise UpdateFailed( diff --git a/custom_components/oasis_mini/helpers.py b/custom_components/oasis_mini/helpers.py index 7d061a7..2c12910 100755 --- a/custom_components/oasis_mini/helpers.py +++ b/custom_components/oasis_mini/helpers.py @@ -12,3 +12,18 @@ from .pyoasismini import OasisMini def create_client(data: dict[str, Any]) -> OasisMini: """Create a Oasis Mini local client.""" return OasisMini(data[CONF_HOST], data.get(CONF_ACCESS_TOKEN)) + + +async def add_and_play_track(device: OasisMini, track: int) -> None: + """Add and play a track.""" + 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))): + await device.async_move_track(index, _next) + await device.async_change_track(_next) + + if device.status_code != 4: + await device.async_play() diff --git a/custom_components/oasis_mini/image.py b/custom_components/oasis_mini/image.py index e5cc36d..0279af6 100644 --- a/custom_components/oasis_mini/image.py +++ b/custom_components/oasis_mini/image.py @@ -2,9 +2,7 @@ from __future__ import annotations -from datetime import datetime - -from homeassistant.components.image import ImageEntity, ImageEntityDescription +from homeassistant.components.image import Image, ImageEntity, ImageEntityDescription from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -12,6 +10,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN from .coordinator import OasisMiniCoordinator from .entity import OasisMiniEntity +from .pyoasismini.const import TRACKS from .pyoasismini.utils import draw_svg IMAGE = ImageEntityDescription(key="image", name=None) @@ -21,6 +20,8 @@ class OasisMiniImageEntity(OasisMiniEntity, ImageEntity): """Oasis Mini image entity.""" _attr_content_type = "image/svg+xml" + _track_id: int | None = None + _progress: int = 0 def __init__( self, @@ -31,15 +32,34 @@ class OasisMiniImageEntity(OasisMiniEntity, ImageEntity): """Initialize the entity.""" super().__init__(coordinator, entry_id, description) ImageEntity.__init__(self, coordinator.hass) - - @property - def image_last_updated(self) -> datetime | None: - """The time when the image was last updated.""" - return self.coordinator.last_updated + self._handle_coordinator_update() def image(self) -> bytes | None: """Return bytes of image.""" - return draw_svg(self.device.track, self.device.progress, "1") + if not self._cached_image: + self._cached_image = Image( + self.content_type, draw_svg(self.device.track, self._progress, "1") + ) + return self._cached_image.content + + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + if self._track_id != self.device.track_id or ( + self._progress != self.device.progress and self.device.access_token + ): + self._attr_image_last_updated = self.coordinator.last_updated + self._track_id = self.device.track_id + self._progress = self.device.progress + self._cached_image = None + if not self.device.access_token: + self._attr_image_url = ( + f"https://app.grounded.so/uploads/{track['image']}" + if (track := TRACKS.get(str(self.device.track_id))) + and "image" in track + else None + ) + if self.hass: + super()._handle_coordinator_update() async def async_setup_entry( @@ -47,5 +67,4 @@ async def async_setup_entry( ) -> None: """Set up Oasis Mini camera using config entry.""" coordinator: OasisMiniCoordinator = hass.data[DOMAIN][entry.entry_id] - if coordinator.device.access_token: - async_add_entities([OasisMiniImageEntity(coordinator, entry, IMAGE)]) + async_add_entities([OasisMiniImageEntity(coordinator, entry, IMAGE)]) diff --git a/custom_components/oasis_mini/media_player.py b/custom_components/oasis_mini/media_player.py index 5630917..9dcbaf5 100644 --- a/custom_components/oasis_mini/media_player.py +++ b/custom_components/oasis_mini/media_player.py @@ -3,7 +3,8 @@ from __future__ import annotations from datetime import datetime -import math +import logging +from typing import Any from homeassistant.components.media_player import ( MediaPlayerEntity, @@ -15,13 +16,17 @@ from homeassistant.components.media_player import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN from .coordinator import OasisMiniCoordinator from .entity import OasisMiniEntity +from .helpers import add_and_play_track from .pyoasismini.const import TRACKS +_LOGGER = logging.getLogger(__name__) + class OasisMiniMediaPlayerEntity(OasisMiniEntity, MediaPlayerEntity): """Oasis Mini media player entity.""" @@ -33,6 +38,7 @@ class OasisMiniMediaPlayerEntity(OasisMiniEntity, MediaPlayerEntity): | MediaPlayerEntityFeature.STOP | MediaPlayerEntityFeature.PREVIOUS_TRACK | MediaPlayerEntityFeature.NEXT_TRACK + | MediaPlayerEntityFeature.PLAY_MEDIA | MediaPlayerEntityFeature.CLEAR_PLAYLIST | MediaPlayerEntityFeature.REPEAT_SET ) @@ -69,8 +75,10 @@ class OasisMiniMediaPlayerEntity(OasisMiniEntity, MediaPlayerEntity): return self.coordinator.last_updated @property - def media_title(self) -> str: + 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(str(self.device.track_id), {}) return track.get("name", f"Unknown Title (#{self.device.track_id})") @@ -135,6 +143,26 @@ class OasisMiniMediaPlayerEntity(OasisMiniEntity, MediaPlayerEntity): await self.device.async_change_track(index) await self.coordinator.async_request_refresh() + async def async_play_media( + self, media_type: MediaType | str, media_id: str, **kwargs: Any + ) -> None: + """Play a piece of media.""" + if media_id not in TRACKS: + media_id = next( + ( + id + for id, info in TRACKS.items() + if info["name"].lower() == media_id.lower() + ), + media_id, + ) + try: + media_id = int(media_id) + except ValueError as err: + raise ServiceValidationError(f"Invalid media: {media_id}") from err + + await add_and_play_track(self.device, media_id) + async def async_clear_playlist(self) -> None: """Clear players playlist.""" await self.device.async_set_playlist([0]) diff --git a/custom_components/oasis_mini/pyoasismini/__init__.py b/custom_components/oasis_mini/pyoasismini/__init__.py index 14ff211..b8490e9 100644 --- a/custom_components/oasis_mini/pyoasismini/__init__.py +++ b/custom_components/oasis_mini/pyoasismini/__init__.py @@ -83,6 +83,7 @@ class OasisMini: _access_token: str | None = None _mac_address: str | None = None _ip_address: str | None = None + _playlist: dict[int, dict[str, str]] = {} _serial_number: str | None = None _software_version: str | None = None _track: dict | None = None @@ -326,11 +327,18 @@ class OasisMini: """Get cloud track info.""" return await self._async_cloud_request("GET", f"api/track/{track_id}") - async def async_cloud_get_tracks(self, tracks: list[int]) -> dict: + async def async_cloud_get_tracks( + self, tracks: list[int] | None = None + ) -> list[dict[str, Any]]: """Get tracks info from the cloud""" - return await self._async_cloud_request( - "GET", "api/track", params={"ids[]": tracks} + response = await self._async_cloud_request( + "GET", "api/track", params={"ids[]": tracks or []} ) + track_details = response.get("data", []) + while next_page_url := response.get("next_page_url"): + response = await self._async_cloud_request("GET", next_page_url) + track_details += response.get("data", []) + return track_details async def async_cloud_get_latest_software_details(self) -> dict[str, int | str]: """Get the latest software details from the cloud.""" @@ -344,9 +352,21 @@ class OasisMini: self._track = await self.async_cloud_get_track_info(self.track_id) return self._track - async def async_get_playlist_details(self) -> dict: + async def async_get_playlist_details(self) -> dict[int, dict[str, str]]: """Get playlist info.""" - return await self.async_cloud_get_tracks(self.playlist) + if set(self.playlist).difference(self._playlist.keys()): + tracks = await self.async_cloud_get_tracks(self.playlist) + self._playlist = { + track["id"]: { + "name": track["name"], + "author": ((track.get("author") or {}).get("person") or {}).get( + "name", "Oasis Mini" + ), + "image": track["image"], + } + for track in tracks + } + return self._playlist async def _async_cloud_request(self, method: str, url: str, **kwargs: Any) -> Any: """Perform a cloud request.""" diff --git a/custom_components/oasis_mini/pyoasismini/tracks.json b/custom_components/oasis_mini/pyoasismini/tracks.json index f8e8fac..5651cdc 100644 --- a/custom_components/oasis_mini/pyoasismini/tracks.json +++ b/custom_components/oasis_mini/pyoasismini/tracks.json @@ -833,5 +833,90 @@ "name": "Horse", "author": "Otávio Bittencourt", "image": "2024/07/9fec8716ce98fdbf0c02db14b47b0d66.svg" + }, + "513": { + "name": "Clover Flower", + "author": "Riley P", + "image": "2024/06/b7de1c0518e5ce9cbdd8f3dd6d995e3a.svg" + }, + "537": { + "name": "Full moon", + "author": "001547.d33e09ec63fb4259a31a494ad194e028.0314", + "image": "2024/07/3b06cb1bd961c01bd2411515549d907e.svg" + }, + "531": { + "name": "Ghost", + "author": "Stephen Murphy", + "image": "2024/07/106d0bed641489cc5b2ee371dcfdebfa.svg" + }, + "509": { + "name": "Heart loop", + "author": "000653.17c9b352828247bd858234a2a114f79b.1358", + "image": "2024/06/985c1c16fe0ce704b17229a8c7e795f5.svg" + }, + "535": { + "name": "Hubcap", + "author": "001547.d33e09ec63fb4259a31a494ad194e028.0314", + "image": "2024/07/565216e030c9fa2a474c4f57366a5cc3.svg" + }, + "538": { + "name": "Noise cell", + "author": "001547.d33e09ec63fb4259a31a494ad194e028.0314", + "image": "2024/07/b60bebf49043ef7969a722d826e88bf5.svg" + }, + "559": { + "name": "Polymath", + "author": "Codie Johnston", + "image": "2024/07/7a5fd9826476071567967fc17ec6cb12.svg" + }, + "1264": { + "name": "Raccoon", + "author": "Otávio Bittencourt", + "image": "2024/07/5ff0bd18649b029e16ac32f3b96f9715.svg" + }, + "551": { + "name": "snowflake", + "author": "christina", + "image": "2024/07/77ae32407d7d2563110b1ee4607f6b7e.svg" + }, + "553": { + "name": "spheres", + "author": "max", + "image": "2024/07/7a292d6cb204fbe4fc4a56ef8e4e9228.svg" + }, + "544": { + "name": "Star round", + "author": "Codie Johnston", + "image": "2024/07/5e05532a764a109b090abc06f217f62e.svg" + }, + "517": { + "name": "Starburst", + "author": "001547.d33e09ec63fb4259a31a494ad194e028.0314", + "image": "2024/06/ec2c03a42db75c33ddf677c6ac52e7b3.svg" + }, + "1137": { + "name": "Tiger", + "author": "Otávio Bittencourt", + "image": "2024/07/02da0d000c200fb8cab3f1d38a90e077.svg" + }, + "528": { + "name": "Tight spiral in to out", + "author": "Codie Johnston", + "image": "2024/06/82360f9b4c9dc169bceb99a1b4a3a13c.svg" + }, + "527": { + "name": "Tight spiral out to in", + "author": "Codie Johnston", + "image": "2024/06/eef9f4aa33ca80e3f09e4c4661c6c80e.svg" + }, + "519": { + "name": "Web", + "author": "000653.17c9b352828247bd858234a2a114f79b.1358", + "image": "2024/06/9c05e1e19cb5ecf6e156e44a8a8829e5.svg" + }, + "536": { + "name": "Yin yang", + "author": "001547.d33e09ec63fb4259a31a494ad194e028.0314", + "image": "2024/07/36fe669628c5e4dfd6d33a263196a750.svg" } } \ No newline at end of file diff --git a/custom_components/oasis_mini/pyoasismini/utils.py b/custom_components/oasis_mini/pyoasismini/utils.py index 26954d7..7bfee11 100644 --- a/custom_components/oasis_mini/pyoasismini/utils.py +++ b/custom_components/oasis_mini/pyoasismini/utils.py @@ -27,8 +27,8 @@ def draw_svg(track: dict, progress: int, model_id: str) -> str | None: if progress is not None: paths = svg_content.split("L") total = track.get("reduced_svg_content", {}).get(model_id, len(paths)) - percent = (100 * progress) / total - progress = math.floor((percent / 100) * len(paths)) + percent = min((100 * progress) / total, 100) + progress = math.floor((percent / 100) * (len(paths) - 1)) svg = Element( "svg", diff --git a/custom_components/oasis_mini/select.py b/custom_components/oasis_mini/select.py index 4b14503..64e4563 100644 --- a/custom_components/oasis_mini/select.py +++ b/custom_components/oasis_mini/select.py @@ -22,6 +22,7 @@ from .pyoasismini.const import TRACKS class OasisMiniSelectEntityDescription(SelectEntityDescription): """Oasis Mini select entity description.""" + current_value: Callable[[OasisMini], Any] select_fn: Callable[[OasisMini, int], Awaitable[None]] update_handler: Callable[[OasisMiniSelectEntity], None] | None = None @@ -30,6 +31,7 @@ class OasisMiniSelectEntity(OasisMiniEntity, SelectEntity): """Oasis Mini select entity.""" entity_description: OasisMiniSelectEntityDescription + _current_value: Any | None = None def __init__( self, @@ -48,6 +50,10 @@ class OasisMiniSelectEntity(OasisMiniEntity, SelectEntity): def _handle_coordinator_update(self) -> None: """Handle updated data from the coordinator.""" + new_value = self.entity_description.current_value(self.device) + if self._current_value == new_value: + return + self._current_value = new_value if update_handler := self.entity_description.update_handler: update_handler(self) else: @@ -61,12 +67,21 @@ class OasisMiniSelectEntity(OasisMiniEntity, SelectEntity): def playlist_update_handler(entity: OasisMiniSelectEntity) -> None: """Handle playlist updates.""" # pylint: disable=protected-access + device = entity.device options = [ - TRACKS.get(str(track), {}).get("name", str(track)) - for track in entity.device.playlist + device._playlist.get(track, {}).get( + "name", + TRACKS.get(str(track), {}).get( + "name", + device.track["name"] + if device.track and device.track["id"] == track + else str(track), + ), + ) + for track in device.playlist ] entity._attr_options = options - index = min(entity.device.playlist_index, len(options) - 1) + index = min(device.playlist_index, len(options) - 1) entity._attr_current_option = options[index] if options else None @@ -74,6 +89,7 @@ DESCRIPTORS = ( OasisMiniSelectEntityDescription( key="playlist", name="Playlist", + current_value=lambda device: (device.playlist, device.playlist_index), select_fn=lambda device, option: device.async_change_track(option), update_handler=playlist_update_handler, ), @@ -81,6 +97,7 @@ DESCRIPTORS = ( key="autoplay", name="Autoplay", options=list(AUTOPLAY_MAP.values()), + current_value=lambda device: device.autoplay, select_fn=lambda device, option: device.async_set_autoplay(option), ), ) diff --git a/custom_components/oasis_mini/strings.json b/custom_components/oasis_mini/strings.json index afdf5e3..b9466f0 100755 --- a/custom_components/oasis_mini/strings.json +++ b/custom_components/oasis_mini/strings.json @@ -26,6 +26,7 @@ "options": { "step": { "init": { + "description": "Add your cloud credentials to get additional information about your Oasis Mini", "data": { "email": "[%key:common::config_flow::data::email%]", "password": "[%key:common::config_flow::data::password%]" diff --git a/custom_components/oasis_mini/translations/en.json b/custom_components/oasis_mini/translations/en.json index e876722..12d5dbd 100755 --- a/custom_components/oasis_mini/translations/en.json +++ b/custom_components/oasis_mini/translations/en.json @@ -26,6 +26,7 @@ "options": { "step": { "init": { + "description": "Add your cloud credentials to get additional information about your Oasis Mini", "data": { "email": "Email", "password": "Password"