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

2 Commits
0.6.0 ... 0.7.0

Author SHA1 Message Date
Nathan Spencer
cee752b6ce Merge pull request #11 from natekspencer/dev
Add additional features
2024-07-30 23:50:08 -06:00
Nathan Spencer
3b90603bef Add additional features 2024-07-30 23:47:14 -06:00
11 changed files with 217 additions and 35 deletions

View File

@@ -19,6 +19,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .const import DOMAIN from .const import DOMAIN
from .coordinator import OasisMiniCoordinator from .coordinator import OasisMiniCoordinator
from .entity import OasisMiniEntity from .entity import OasisMiniEntity
from .helpers import add_and_play_track
from .pyoasismini import OasisMini from .pyoasismini import OasisMini
from .pyoasismini.const import TRACKS from .pyoasismini.const import TRACKS
@@ -39,17 +40,7 @@ async def async_setup_entry(
async def play_random_track(device: OasisMini) -> None: async def play_random_track(device: OasisMini) -> None:
"""Play random track.""" """Play random track."""
track = int(random.choice(list(TRACKS))) track = int(random.choice(list(TRACKS)))
if track not in device.playlist: await add_and_play_track(device, track)
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()
@dataclass(frozen=True, kw_only=True) @dataclass(frozen=True, kw_only=True)

View File

@@ -25,7 +25,11 @@ class OasisMiniCoordinator(DataUpdateCoordinator[str]):
def __init__(self, hass: HomeAssistant, device: OasisMini) -> None: def __init__(self, hass: HomeAssistant, device: OasisMini) -> None:
"""Initialize.""" """Initialize."""
super().__init__( 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 self.device = device
@@ -44,6 +48,7 @@ class OasisMiniCoordinator(DataUpdateCoordinator[str]):
await self.device.async_get_software_version() await self.device.async_get_software_version()
data = await self.device.async_get_status() data = await self.device.async_get_status()
await self.device.async_get_current_track_details() await self.device.async_get_current_track_details()
await self.device.async_get_playlist_details()
except Exception as ex: # pylint:disable=broad-except except Exception as ex: # pylint:disable=broad-except
if self.attempt > 2 or not self.data: if self.attempt > 2 or not self.data:
raise UpdateFailed( raise UpdateFailed(

View File

@@ -12,3 +12,18 @@ from .pyoasismini import OasisMini
def create_client(data: dict[str, Any]) -> OasisMini: def create_client(data: dict[str, Any]) -> OasisMini:
"""Create a Oasis Mini local client.""" """Create a Oasis Mini local client."""
return OasisMini(data[CONF_HOST], data.get(CONF_ACCESS_TOKEN)) 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()

View File

@@ -2,9 +2,7 @@
from __future__ import annotations from __future__ import annotations
from datetime import datetime from homeassistant.components.image import Image, ImageEntity, ImageEntityDescription
from homeassistant.components.image import ImageEntity, ImageEntityDescription
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
@@ -12,6 +10,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .const import DOMAIN from .const import DOMAIN
from .coordinator import OasisMiniCoordinator from .coordinator import OasisMiniCoordinator
from .entity import OasisMiniEntity from .entity import OasisMiniEntity
from .pyoasismini.const import TRACKS
from .pyoasismini.utils import draw_svg from .pyoasismini.utils import draw_svg
IMAGE = ImageEntityDescription(key="image", name=None) IMAGE = ImageEntityDescription(key="image", name=None)
@@ -21,6 +20,8 @@ class OasisMiniImageEntity(OasisMiniEntity, ImageEntity):
"""Oasis Mini image entity.""" """Oasis Mini image entity."""
_attr_content_type = "image/svg+xml" _attr_content_type = "image/svg+xml"
_track_id: int | None = None
_progress: int = 0
def __init__( def __init__(
self, self,
@@ -31,15 +32,34 @@ class OasisMiniImageEntity(OasisMiniEntity, ImageEntity):
"""Initialize the entity.""" """Initialize the entity."""
super().__init__(coordinator, entry_id, description) super().__init__(coordinator, entry_id, description)
ImageEntity.__init__(self, coordinator.hass) ImageEntity.__init__(self, coordinator.hass)
self._handle_coordinator_update()
@property
def image_last_updated(self) -> datetime | None:
"""The time when the image was last updated."""
return self.coordinator.last_updated
def image(self) -> bytes | None: def image(self) -> bytes | None:
"""Return bytes of image.""" """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( async def async_setup_entry(
@@ -47,5 +67,4 @@ async def async_setup_entry(
) -> None: ) -> None:
"""Set up Oasis Mini camera using config entry.""" """Set up Oasis Mini camera using config entry."""
coordinator: OasisMiniCoordinator = hass.data[DOMAIN][entry.entry_id] 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)])

View File

@@ -3,7 +3,8 @@
from __future__ import annotations from __future__ import annotations
from datetime import datetime from datetime import datetime
import math import logging
from typing import Any
from homeassistant.components.media_player import ( from homeassistant.components.media_player import (
MediaPlayerEntity, MediaPlayerEntity,
@@ -15,13 +16,17 @@ from homeassistant.components.media_player import (
) )
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ServiceValidationError
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .const import DOMAIN from .const import DOMAIN
from .coordinator import OasisMiniCoordinator from .coordinator import OasisMiniCoordinator
from .entity import OasisMiniEntity from .entity import OasisMiniEntity
from .helpers import add_and_play_track
from .pyoasismini.const import TRACKS from .pyoasismini.const import TRACKS
_LOGGER = logging.getLogger(__name__)
class OasisMiniMediaPlayerEntity(OasisMiniEntity, MediaPlayerEntity): class OasisMiniMediaPlayerEntity(OasisMiniEntity, MediaPlayerEntity):
"""Oasis Mini media player entity.""" """Oasis Mini media player entity."""
@@ -33,6 +38,7 @@ class OasisMiniMediaPlayerEntity(OasisMiniEntity, MediaPlayerEntity):
| MediaPlayerEntityFeature.STOP | MediaPlayerEntityFeature.STOP
| MediaPlayerEntityFeature.PREVIOUS_TRACK | MediaPlayerEntityFeature.PREVIOUS_TRACK
| MediaPlayerEntityFeature.NEXT_TRACK | MediaPlayerEntityFeature.NEXT_TRACK
| MediaPlayerEntityFeature.PLAY_MEDIA
| MediaPlayerEntityFeature.CLEAR_PLAYLIST | MediaPlayerEntityFeature.CLEAR_PLAYLIST
| MediaPlayerEntityFeature.REPEAT_SET | MediaPlayerEntityFeature.REPEAT_SET
) )
@@ -69,8 +75,10 @@ class OasisMiniMediaPlayerEntity(OasisMiniEntity, MediaPlayerEntity):
return self.coordinator.last_updated return self.coordinator.last_updated
@property @property
def media_title(self) -> str: def media_title(self) -> str | None:
"""Title of current playing media.""" """Title of current playing media."""
if not self.device.track_id:
return None
if not (track := self.device.track): if not (track := self.device.track):
track = TRACKS.get(str(self.device.track_id), {}) track = TRACKS.get(str(self.device.track_id), {})
return track.get("name", f"Unknown Title (#{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.device.async_change_track(index)
await self.coordinator.async_request_refresh() 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: async def async_clear_playlist(self) -> None:
"""Clear players playlist.""" """Clear players playlist."""
await self.device.async_set_playlist([0]) await self.device.async_set_playlist([0])

View File

@@ -83,6 +83,7 @@ class OasisMini:
_access_token: str | None = None _access_token: str | None = None
_mac_address: str | None = None _mac_address: str | None = None
_ip_address: str | None = None _ip_address: str | None = None
_playlist: dict[int, dict[str, str]] = {}
_serial_number: str | None = None _serial_number: str | None = None
_software_version: str | None = None _software_version: str | None = None
_track: dict | None = None _track: dict | None = None
@@ -326,11 +327,18 @@ class OasisMini:
"""Get cloud track info.""" """Get cloud track info."""
return await self._async_cloud_request("GET", f"api/track/{track_id}") 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""" """Get tracks info from the cloud"""
return await self._async_cloud_request( response = await self._async_cloud_request(
"GET", "api/track", params={"ids[]": tracks} "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]: async def async_cloud_get_latest_software_details(self) -> dict[str, int | str]:
"""Get the latest software details from the cloud.""" """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) self._track = await self.async_cloud_get_track_info(self.track_id)
return self._track 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.""" """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: async def _async_cloud_request(self, method: str, url: str, **kwargs: Any) -> Any:
"""Perform a cloud request.""" """Perform a cloud request."""

View File

@@ -833,5 +833,90 @@
"name": "Horse", "name": "Horse",
"author": "Otávio Bittencourt", "author": "Otávio Bittencourt",
"image": "2024/07/9fec8716ce98fdbf0c02db14b47b0d66.svg" "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"
} }
} }

View File

@@ -27,8 +27,8 @@ def draw_svg(track: dict, progress: int, model_id: str) -> str | None:
if progress is not None: if progress is not None:
paths = svg_content.split("L") paths = svg_content.split("L")
total = track.get("reduced_svg_content", {}).get(model_id, len(paths)) total = track.get("reduced_svg_content", {}).get(model_id, len(paths))
percent = (100 * progress) / total percent = min((100 * progress) / total, 100)
progress = math.floor((percent / 100) * len(paths)) progress = math.floor((percent / 100) * (len(paths) - 1))
svg = Element( svg = Element(
"svg", "svg",

View File

@@ -22,6 +22,7 @@ from .pyoasismini.const import TRACKS
class OasisMiniSelectEntityDescription(SelectEntityDescription): class OasisMiniSelectEntityDescription(SelectEntityDescription):
"""Oasis Mini select entity description.""" """Oasis Mini select entity description."""
current_value: Callable[[OasisMini], Any]
select_fn: Callable[[OasisMini, int], Awaitable[None]] select_fn: Callable[[OasisMini, int], Awaitable[None]]
update_handler: Callable[[OasisMiniSelectEntity], None] | None = None update_handler: Callable[[OasisMiniSelectEntity], None] | None = None
@@ -30,6 +31,7 @@ class OasisMiniSelectEntity(OasisMiniEntity, SelectEntity):
"""Oasis Mini select entity.""" """Oasis Mini select entity."""
entity_description: OasisMiniSelectEntityDescription entity_description: OasisMiniSelectEntityDescription
_current_value: Any | None = None
def __init__( def __init__(
self, self,
@@ -48,6 +50,10 @@ class OasisMiniSelectEntity(OasisMiniEntity, SelectEntity):
def _handle_coordinator_update(self) -> None: def _handle_coordinator_update(self) -> None:
"""Handle updated data from the coordinator.""" """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: if update_handler := self.entity_description.update_handler:
update_handler(self) update_handler(self)
else: else:
@@ -61,12 +67,21 @@ class OasisMiniSelectEntity(OasisMiniEntity, SelectEntity):
def playlist_update_handler(entity: OasisMiniSelectEntity) -> None: def playlist_update_handler(entity: OasisMiniSelectEntity) -> None:
"""Handle playlist updates.""" """Handle playlist updates."""
# pylint: disable=protected-access # pylint: disable=protected-access
device = entity.device
options = [ options = [
TRACKS.get(str(track), {}).get("name", str(track)) device._playlist.get(track, {}).get(
for track in entity.device.playlist "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 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 entity._attr_current_option = options[index] if options else None
@@ -74,6 +89,7 @@ DESCRIPTORS = (
OasisMiniSelectEntityDescription( OasisMiniSelectEntityDescription(
key="playlist", key="playlist",
name="Playlist", name="Playlist",
current_value=lambda device: (device.playlist, 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=playlist_update_handler,
), ),
@@ -81,6 +97,7 @@ DESCRIPTORS = (
key="autoplay", key="autoplay",
name="Autoplay", name="Autoplay",
options=list(AUTOPLAY_MAP.values()), options=list(AUTOPLAY_MAP.values()),
current_value=lambda device: device.autoplay,
select_fn=lambda device, option: device.async_set_autoplay(option), select_fn=lambda device, option: device.async_set_autoplay(option),
), ),
) )

View File

@@ -26,6 +26,7 @@
"options": { "options": {
"step": { "step": {
"init": { "init": {
"description": "Add your cloud credentials to get additional information about your Oasis Mini",
"data": { "data": {
"email": "[%key:common::config_flow::data::email%]", "email": "[%key:common::config_flow::data::email%]",
"password": "[%key:common::config_flow::data::password%]" "password": "[%key:common::config_flow::data::password%]"

View File

@@ -26,6 +26,7 @@
"options": { "options": {
"step": { "step": {
"init": { "init": {
"description": "Add your cloud credentials to get additional information about your Oasis Mini",
"data": { "data": {
"email": "Email", "email": "Email",
"password": "Password" "password": "Password"