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

1 Commits

Author SHA1 Message Date
Nathan Spencer
83de1d5606 Add additional helpers 2025-11-23 06:45:01 +00:00
10 changed files with 82 additions and 104 deletions

View File

@@ -66,14 +66,8 @@ class OasisDeviceCoordinator(DataUpdateCoordinator[list[OasisDevice]]):
raise Exception( raise Exception(
"Could not get mac address for %s", device.serial_number "Could not get mac address for %s", device.serial_number
) )
# if not device.software_version: await self.cloud_client.async_get_playlists()
# await device.async_get_software_version()
# data = await self.device.async_get_status()
# devices = self.cloud_client.mac_address
self.attempt = 0 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 except Exception as ex: # pylint:disable=broad-except
if self.attempt > 2 or not (devices or self.data): if self.attempt > 2 or not (devices or self.data):
raise UpdateFailed( raise UpdateFailed(

View File

@@ -2,9 +2,12 @@
from __future__ import annotations from __future__ import annotations
import asyncio
import logging import logging
from typing import Any from typing import Any
import async_timeout
from homeassistant.const import CONF_ACCESS_TOKEN from homeassistant.const import CONF_ACCESS_TOKEN
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers.aiohttp_client import async_get_clientsession 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: async def add_and_play_track(device: OasisDevice, track: int) -> None:
"""Add and play a track.""" """Add and play a track."""
if track not in device.playlist: async with async_timeout.timeout(10):
await device.async_add_track_to_playlist(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 while track not in device.playlist:
if (index := device.playlist.index(track)) != device.playlist_index: await asyncio.sleep(0.1)
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: # Move track to next item in the playlist and then select it
await device.async_play() 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: def get_track_id(track: str) -> int | None:

View File

@@ -11,8 +11,6 @@ from . import OasisDeviceConfigEntry
from .coordinator import OasisDeviceCoordinator from .coordinator import OasisDeviceCoordinator
from .entity import OasisDeviceEntity from .entity import OasisDeviceEntity
from .pyoasiscontrol import OasisDevice from .pyoasiscontrol import OasisDevice
from .pyoasiscontrol.const import TRACKS
from .pyoasiscontrol.utils import draw_svg
async def async_setup_entry( async def async_setup_entry(
@@ -52,33 +50,24 @@ class OasisDeviceImageEntity(OasisDeviceEntity, ImageEntity):
def image(self) -> bytes | None: def image(self) -> bytes | None:
"""Return bytes of image.""" """Return bytes of image."""
if not self._cached_image: if not self._cached_image:
self._cached_image = Image( self._cached_image = Image(self.content_type, self.device.create_svg())
self.content_type, draw_svg(self.device.track, self._progress, "1")
)
return self._cached_image.content return self._cached_image.content
@callback @callback
def _handle_coordinator_update(self) -> None: def _handle_coordinator_update(self) -> None:
"""Handle updated data from the coordinator.""" """Handle updated data from the coordinator."""
device = self.device
if ( if (
self._track_id != self.device.track_id self._track_id != device.track_id or self._progress != device.progress
or self._progress != self.device.progress ) and (device.status == "playing" or self._cached_image is None):
) and (self.device.status == "playing" or self._cached_image is None):
self._attr_image_last_updated = self.coordinator.last_updated self._attr_image_last_updated = self.coordinator.last_updated
self._track_id = self.device.track_id self._track_id = device.track_id
self._progress = self.device.progress self._progress = device.progress
self._cached_image = None 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 self._attr_image_url = UNDEFINED
else: else:
self._attr_image_url = ( self._attr_image_url = device.track_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
)
if self.hass: if self.hass:
super()._handle_coordinator_update() super()._handle_coordinator_update()

View File

@@ -23,7 +23,6 @@ from .const import DOMAIN
from .coordinator import OasisDeviceCoordinator from .coordinator import OasisDeviceCoordinator
from .entity import OasisDeviceEntity from .entity import OasisDeviceEntity
from .helpers import get_track_id from .helpers import get_track_id
from .pyoasiscontrol.const import TRACKS
async def async_setup_entry( async def async_setup_entry(
@@ -73,11 +72,7 @@ class OasisDeviceMediaPlayerEntity(OasisDeviceEntity, MediaPlayerEntity):
@property @property
def media_image_url(self) -> str | None: def media_image_url(self) -> str | None:
"""Image url of current playing media.""" """Image url of current playing media."""
if not (track := self.device.track): return self.device.track_image_url
track = TRACKS.get(self.device.track_id)
if track and "image" in track:
return f"https://app.grounded.so/uploads/{track['image']}"
return None
@property @property
def media_position(self) -> int: def media_position(self) -> int:
@@ -92,11 +87,7 @@ class OasisDeviceMediaPlayerEntity(OasisDeviceEntity, MediaPlayerEntity):
@property @property
def media_title(self) -> str | None: def media_title(self) -> str | None:
"""Title of current playing media.""" """Title of current playing media."""
if not self.device.track_id: return self.device.track_name
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})")
@property @property
def repeat(self) -> RepeatMode: def repeat(self) -> RepeatMode:

View File

@@ -2,7 +2,7 @@
from __future__ import annotations from __future__ import annotations
from datetime import timedelta from datetime import datetime, timedelta
import logging import logging
from typing import Any from typing import Any
from urllib.parse import urljoin from urllib.parse import urljoin
@@ -37,7 +37,7 @@ class OasisCloudClient:
_access_token: str | None _access_token: str | None
# these are "cache" fields for tracks/playlists # these are "cache" fields for tracks/playlists
_playlists_next_refresh: float _playlists_next_refresh: datetime
playlists: list[dict[str, Any]] playlists: list[dict[str, Any]]
_playlist_details: dict[int, dict[str, str]] _playlist_details: dict[int, dict[str, str]]
@@ -52,7 +52,7 @@ class OasisCloudClient:
self._access_token = access_token self._access_token = access_token
# simple in-memory caches # simple in-memory caches
self._playlists_next_refresh = 0.0 self._playlists_next_refresh = now()
self.playlists = [] self.playlists = []
self._playlist_details = {} self._playlist_details = {}

View File

@@ -143,8 +143,9 @@ class OasisMqttClient(OasisClientProtocol):
async def _resubscribe_all(self) -> None: async def _resubscribe_all(self) -> None:
"""Resubscribe to all known devices after (re)connect.""" """Resubscribe to all known devices after (re)connect."""
self._subscribed_serials.clear() self._subscribed_serials.clear()
for serial in list(self._devices): for serial, device in self._devices.items():
await self._subscribe_serial(serial) await self._subscribe_serial(serial)
await self.async_get_all(device)
def start(self) -> None: def start(self) -> None:
"""Start MQTT connection loop.""" """Start MQTT connection loop."""
@@ -316,9 +317,6 @@ class OasisMqttClient(OasisClientProtocol):
payload = f"WRIJOBLIST={track_str}" payload = f"WRIJOBLIST={track_str}"
await self._publish_command(device, payload) 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( async def async_send_set_repeat_playlist_command(
self, self,
device: OasisDevice, device: OasisDevice,

View File

@@ -13,7 +13,7 @@ from .const import (
STATUS_CODE_SLEEPING, STATUS_CODE_SLEEPING,
TRACKS, 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 if TYPE_CHECKING: # avoid runtime circular imports
from .clients import OasisCloudClient from .clients import OasisCloudClient
@@ -255,6 +255,13 @@ class OasisDevice:
"""Return human-readable status from status_code.""" """Return human-readable status from status_code."""
return STATUS_CODE_MAP.get(self.status_code, f"Unknown ({self.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 @property
def track_id(self) -> int | None: def track_id(self) -> int | None:
if not self.playlist: if not self.playlist:
@@ -263,13 +270,17 @@ class OasisDevice:
return self.playlist[0] if i >= len(self.playlist) else self.playlist[i] return self.playlist[0] if i >= len(self.playlist) else self.playlist[i]
@property @property
def track(self) -> dict | None: def track_image_url(self) -> str | None:
"""Return cached track info if it matches the current `track_id`.""" """Return the track image url, if any."""
if self._track and self._track.get("id") == self.track_id: if (track := self.track) and (image := track.get("image")):
return self._track return f"https://app.grounded.so/uploads/{image}"
if track := TRACKS.get(self.track_id): return None
self._track = track
return self._track @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 return None
@property @property
@@ -281,19 +292,23 @@ class OasisDevice:
paths = svg_content.split("L") paths = svg_content.split("L")
total = self.track.get("reduced_svg_content_new", 0) or len(paths) total = self.track.get("reduced_svg_content_new", 0) or len(paths)
percent = (100 * self.progress) / total percent = (100 * self.progress) / total
return max(percent, 100) return min(percent, 100)
@property @property
def playlist_details(self) -> dict[int, dict[str, str]]: def playlist_details(self) -> dict[int, dict[str, str]]:
"""Basic playlist details using built-in TRACKS metadata.""" """Basic playlist details using built-in TRACKS metadata."""
return { return {
track_id: TRACKS.get( track_id: {self.track_id: self.track or {}, **TRACKS}.get(
track_id, track_id,
{"name": f"Unknown Title (#{track_id})"}, {"name": f"Unknown Title (#{track_id})"},
) )
for track_id in self.playlist 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]: def add_update_listener(self, listener: Callable[[], None]) -> Callable[[], None]:
"""Register a callback for state changes. """Register a callback for state changes.

View File

@@ -35,8 +35,8 @@ def _parse_int(val: str) -> int:
return 0 return 0
def draw_svg(track: dict, progress: int, model_id: str) -> str | None: def create_svg(track: dict, progress: int) -> str | None:
"""Draw SVG.""" """Create an SVG from a track based on progress."""
if track and (svg_content := track.get("svg_content")): if track and (svg_content := track.get("svg_content")):
try: try:
if progress is not None: if progress is not None:

View File

@@ -27,7 +27,7 @@ def playlists_update_handler(entity: OasisDeviceSelectEntity) -> None:
counts = defaultdict(int) counts = defaultdict(int)
options = [] options = []
current_option: str | None = None current_option: str | None = None
for playlist in device.playlists: for playlist in device._cloud.playlists:
name = playlist["name"] name = playlist["name"]
counts[name] += 1 counts[name] += 1
if counts[name] > 1: if counts[name] > 1:
@@ -71,18 +71,11 @@ async def async_setup_entry(
) -> None: ) -> None:
"""Set up Oasis device select using config entry.""" """Set up Oasis device select using config entry."""
coordinator: OasisDeviceCoordinator = entry.runtime_data coordinator: OasisDeviceCoordinator = entry.runtime_data
entities = [ async_add_entities(
OasisDeviceSelectEntity(coordinator, device, descriptor) OasisDeviceSelectEntity(coordinator, device, descriptor)
for device in coordinator.data for device in coordinator.data
for descriptor in DESCRIPTORS 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) @dataclass(frozen=True, kw_only=True)
@@ -104,6 +97,15 @@ DESCRIPTORS = (
device.async_set_autoplay(AUTOPLAY_MAP_LIST[index]) 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( OasisDeviceSelectEntityDescription(
key="queue", key="queue",
translation_key="queue", translation_key="queue",
@@ -112,17 +114,6 @@ DESCRIPTORS = (
update_handler=queue_update_handler, 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): class OasisDeviceSelectEntity(OasisDeviceEntity, SelectEntity):

View File

@@ -23,17 +23,11 @@ async def async_setup_entry(
) -> None: ) -> None:
"""Set up Oasis device sensors using config entry.""" """Set up Oasis device sensors using config entry."""
coordinator: OasisDeviceCoordinator = entry.runtime_data coordinator: OasisDeviceCoordinator = entry.runtime_data
entities = [ async_add_entities(
OasisDeviceSensorEntity(coordinator, device, descriptor) OasisDeviceSensorEntity(coordinator, device, descriptor)
for device in coordinator.data for device in coordinator.data
for descriptor in DESCRIPTORS 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 = { DESCRIPTORS = {
@@ -45,6 +39,14 @@ DESCRIPTORS = {
native_unit_of_measurement=PERCENTAGE, native_unit_of_measurement=PERCENTAGE,
state_class=SensorStateClass.MEASUREMENT, 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( SensorEntityDescription(
key=key, key=key,
@@ -56,17 +58,6 @@ DESCRIPTORS = {
# for key in ("error_message", "led_color_id", "status") # 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): class OasisDeviceSensorEntity(OasisDeviceEntity, SensorEntity):
"""Oasis device sensor entity.""" """Oasis device sensor entity."""