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

3 Commits

Author SHA1 Message Date
Nathan Spencer
cf21a5d995 Dynamically handle devices and other enhancements 2025-11-23 22:49:26 +00:00
Nathan Spencer
83de1d5606 Add additional helpers 2025-11-23 06:45:01 +00:00
Nathan Spencer
2a92212aad Get track info from the cloud when playlist or index changes 2025-11-23 00:13:45 +00:00
22 changed files with 576 additions and 276 deletions

View File

@@ -10,9 +10,9 @@
<img alt="Oasis Mini logo" src="https://brands.home-assistant.io/oasis_mini/logo.png">
</picture>
# Oasis Mini for Home Assistant
# Oasis Control for Home Assistant
Home Assistant integration for Oasis Mini kinetic sand art devices.
Home Assistant integration for Oasis kinetic sand art devices.
# Installation
@@ -43,13 +43,13 @@ While the manual installation above seems like less steps, it's important to not
[![Open your Home Assistant instance and start setting up a new integration.](https://my.home-assistant.io/badges/config_flow_start.svg)](https://my.home-assistant.io/redirect/config_flow_start/?domain=oasis_mini)
There is a config flow for this Oasis Mini integration. After installing the custom component, use the convenient My Home Assistant link above.
There is a config flow for this Oasis Control integration. After installing the custom component, use the convenient My Home Assistant link above.
Alternatively:
1. Go to **Configuration**->**Integrations**
2. Click **+ ADD INTEGRATION** to setup a new integration
3. Search for **Oasis Mini** and click on it
3. Search for **Oasis Control** and click on it
4. You will be guided through the rest of the setup process via the config flow
# Options
@@ -76,6 +76,6 @@ data:
I'm not employed by Kinetic Oasis, and provide this custom component purely for your own enjoyment and home automation needs.
If you already own an Oasis Mini, found this integration useful and want to donate, consider [sponsoring me on GitHub](https://github.com/sponsors/natekspencer) or buying me a coffee ☕ (or beer 🍺) instead by using the link below:
If you already own an Oasis device, found this integration useful and want to donate, consider [sponsoring me on GitHub](https://github.com/sponsors/natekspencer) or buying me a coffee ☕ (or beer 🍺) instead by using the link below:
<a href='https://ko-fi.com/Y8Y57F59S' target='_blank'><img height='36' style='border:0px;height:36px;' src='https://storage.ko-fi.com/cdn/kofi1.png?v=3' border='0' alt='Buy Me a Coffee at ko-fi.com' /></a>

View File

@@ -3,17 +3,22 @@
from __future__ import annotations
import logging
from typing import Any
from typing import Any, Callable, Iterable
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_EMAIL, Platform
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.helpers.device_registry import DeviceEntry
from homeassistant.helpers.entity_platform import AddEntitiesCallback
import homeassistant.helpers.entity_registry as er
import homeassistant.util.dt as dt_util
from .const import DOMAIN
from .coordinator import OasisDeviceCoordinator
from .entity import OasisDeviceEntity
from .helpers import create_client
from .pyoasiscontrol import OasisMqttClient, UnauthenticatedError
from .pyoasiscontrol import OasisDevice, OasisMqttClient, UnauthenticatedError
type OasisDeviceConfigEntry = ConfigEntry[OasisDeviceCoordinator]
@@ -28,10 +33,47 @@ PLATFORMS = [
Platform.NUMBER,
Platform.SELECT,
Platform.SENSOR,
Platform.SWITCH,
Platform.UPDATE,
]
def setup_platform_from_coordinator(
entry: OasisDeviceConfigEntry,
async_add_entities: AddEntitiesCallback,
make_entities: Callable[[OasisDevice], Iterable[OasisDeviceEntity]],
update_before_add: bool = False,
) -> None:
"""Generic pattern: add entities per device, including newly discovered ones."""
coordinator = entry.runtime_data
known_serials: set[str] = set()
@callback
def _check_devices() -> None:
devices = coordinator.data or []
new_devices: list[OasisDevice] = []
for device in devices:
serial = device.serial_number
if not serial or serial in known_serials:
continue
known_serials.add(serial)
new_devices.append(device)
if not new_devices:
return
if entities := make_entities(new_devices):
async_add_entities(entities, update_before_add)
# Initial population
_check_devices()
# Future updates (new devices discovered)
entry.async_on_unload(coordinator.async_add_listener(_check_devices))
async def async_setup_entry(hass: HomeAssistant, entry: OasisDeviceConfigEntry) -> bool:
"""Set up Oasis devices from a config entry."""
cloud_client = create_client(hass, entry.data)
@@ -59,6 +101,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: OasisDeviceConfigEntry)
entry.runtime_data = coordinator
def _on_oasis_update() -> None:
coordinator.last_updated = dt_util.now()
coordinator.async_update_listeners()
for device in coordinator.data:
@@ -146,3 +189,15 @@ async def async_migrate_entry(hass: HomeAssistant, entry: OasisDeviceConfigEntry
)
return True
async def async_remove_config_entry_device(
hass: HomeAssistant, config_entry: OasisDeviceConfigEntry, device_entry: DeviceEntry
) -> bool:
"""Remove a config entry from a device."""
current_serials = {d.serial_number for d in (config_entry.runtime_data.data or [])}
return not any(
identifier
for identifier in device_entry.identifiers
if identifier[0] == DOMAIN and identifier[1] in current_serials
)

View File

@@ -11,9 +11,9 @@ from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from . import OasisDeviceConfigEntry
from .coordinator import OasisDeviceCoordinator
from . import OasisDeviceConfigEntry, setup_platform_from_coordinator
from .entity import OasisDeviceEntity
from .pyoasiscontrol import OasisDevice
async def async_setup_entry(
@@ -22,12 +22,15 @@ async def async_setup_entry(
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up Oasis device sensors using config entry."""
coordinator: OasisDeviceCoordinator = entry.runtime_data
async_add_entities(
OasisDeviceBinarySensorEntity(coordinator, device, descriptor)
for device in coordinator.data
for descriptor in DESCRIPTORS
)
def make_entities(new_devices: list[OasisDevice]):
return [
OasisDeviceBinarySensorEntity(entry.runtime_data, device, descriptor)
for device in new_devices
for descriptor in DESCRIPTORS
]
setup_platform_from_coordinator(entry, async_add_entities, make_entities)
DESCRIPTORS = {

View File

@@ -13,10 +13,10 @@ from homeassistant.components.button import (
)
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from . import OasisDeviceConfigEntry
from .coordinator import OasisDeviceCoordinator
from . import OasisDeviceConfigEntry, setup_platform_from_coordinator
from .entity import OasisDeviceEntity
from .helpers import add_and_play_track
from .pyoasiscontrol import OasisDevice
@@ -29,18 +29,24 @@ async def async_setup_entry(
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up Oasis device button using config entry."""
coordinator: OasisDeviceCoordinator = entry.runtime_data
async_add_entities(
OasisDeviceButtonEntity(coordinator, device, descriptor)
for device in coordinator.data
for descriptor in DESCRIPTORS
)
def make_entities(new_devices: list[OasisDevice]):
return [
OasisDeviceButtonEntity(entry.runtime_data, device, descriptor)
for device in new_devices
for descriptor in DESCRIPTORS
]
setup_platform_from_coordinator(entry, async_add_entities, make_entities)
async def play_random_track(device: OasisDevice) -> None:
"""Play random track."""
track = random.choice(list(TRACKS))
await add_and_play_track(device, track)
try:
await add_and_play_track(device, track)
except TimeoutError as err:
raise HomeAssistantError("Timeout adding track to queue") from err
@dataclass(frozen=True, kw_only=True)

View File

@@ -8,7 +8,9 @@ import logging
import async_timeout
from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
import homeassistant.util.dt as dt_util
from .const import DOMAIN
from .pyoasiscontrol import OasisCloudClient, OasisDevice, OasisMqttClient
@@ -45,37 +47,114 @@ class OasisDeviceCoordinator(DataUpdateCoordinator[list[OasisDevice]]):
self.attempt += 1
try:
async with async_timeout.timeout(10):
if not self.data:
raw_devices = await self.cloud_client.async_get_devices()
devices = [
OasisDevice(
model=raw_device.get("model", {}).get("name"),
serial_number=raw_device.get("serial_number"),
async with async_timeout.timeout(30):
raw_devices = await self.cloud_client.async_get_devices()
existing_by_serial = {
d.serial_number: d for d in (self.data or []) if d.serial_number
}
for raw in raw_devices:
if not (serial := raw.get("serial_number")):
continue
if device := existing_by_serial.get(serial):
if name := raw.get("name"):
device.name = name
else:
device = OasisDevice(
model=(raw.get("model") or {}).get("name"),
serial_number=serial,
name=raw.get("name"),
cloud=self.cloud_client,
)
for raw_device in raw_devices
]
else:
devices = self.data
devices.append(device)
new_serials = {d.serial_number for d in devices if d.serial_number}
removed_serials = set(existing_by_serial) - new_serials
if removed_serials:
device_registry = dr.async_get(self.hass)
for serial in removed_serials:
_LOGGER.info(
"Oasis device %s removed from account; cleaning up in HA",
serial,
)
device_entry = device_registry.async_get_device(
identifiers={(DOMAIN, serial)}
)
if device_entry:
device_registry.async_update_device(
device_id=device_entry.id,
remove_config_entry_id=self.config_entry.entry_id,
)
# ✅ Valid state: logged in but no devices on account
if not devices:
_LOGGER.debug("No Oasis devices found for account")
self.attempt = 0
if devices != self.data:
self.last_updated = dt_util.now()
return []
self.mqtt_client.register_devices(devices)
# Best-effort playlists
try:
await self.cloud_client.async_get_playlists()
except Exception: # noqa: BLE001
_LOGGER.exception("Error fetching playlists from cloud")
any_success = False
for device in devices:
self.mqtt_client.register_device(device)
await self.mqtt_client.wait_until_ready(device, request_status=True)
if not device.mac_address:
await device.async_get_mac_address()
# if not device.software_version:
# await device.async_get_software_version()
# data = await self.device.async_get_status()
# devices = self.cloud_client.mac_address
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
try:
ready = await self.mqtt_client.wait_until_ready(
device, timeout=3, request_status=True
)
if not ready:
_LOGGER.warning(
"Timeout waiting for Oasis device %s to be ready",
device.serial_number,
)
continue
mac = await device.async_get_mac_address()
if not mac:
_LOGGER.warning(
"Could not get MAC address for Oasis device %s",
device.serial_number,
)
continue
any_success = True
device.schedule_track_refresh()
except Exception: # noqa: BLE001
_LOGGER.exception(
"Error preparing Oasis device %s", device.serial_number
)
if any_success:
self.attempt = 0
else:
if self.attempt > 2 or not self.data:
raise UpdateFailed(
"Couldn't read from any Oasis device "
f"after {self.attempt} attempts"
)
except UpdateFailed:
raise
except Exception as ex: # noqa: BLE001
if self.attempt > 2 or not (devices or self.data):
raise UpdateFailed(
f"Couldn't read from the Oasis device after {self.attempt} attempts"
"Unexpected error talking to Oasis devices "
f"after {self.attempt} attempts"
) from ex
if devices != self.data:
self.last_updated = datetime.now()
self.last_updated = dt_util.now()
return devices

View File

@@ -30,10 +30,14 @@ class OasisDeviceEntity(CoordinatorEntity[OasisDeviceCoordinator]):
serial_number = device.serial_number
self._attr_unique_id = f"{serial_number}-{description.key}"
connections = set()
if mac_address := device.mac_address:
connections.add((CONNECTION_NETWORK_MAC, format_mac(mac_address)))
self._attr_device_info = DeviceInfo(
connections={(CONNECTION_NETWORK_MAC, format_mac(device.mac_address))},
connections=connections,
identifiers={(DOMAIN, serial_number)},
name=f"{device.model} {serial_number}",
name=device.name,
manufacturer=device.manufacturer,
model=device.model,
serial_number=serial_number,

View File

@@ -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:

View File

@@ -6,13 +6,12 @@ from homeassistant.components.image import Image, ImageEntity, ImageEntityDescri
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import UNDEFINED
from homeassistant.util import dt as dt_util
from . import OasisDeviceConfigEntry
from . import OasisDeviceConfigEntry, setup_platform_from_coordinator
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(
@@ -21,11 +20,14 @@ async def async_setup_entry(
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up Oasis device image using config entry."""
coordinator: OasisDeviceCoordinator = entry.runtime_data
async_add_entities(
OasisDeviceImageEntity(coordinator, device, IMAGE)
for device in coordinator.data
)
def make_entities(new_devices: list[OasisDevice]):
return [
OasisDeviceImageEntity(entry.runtime_data, device, IMAGE)
for device in new_devices
]
setup_platform_from_coordinator(entry, async_add_entities, make_entities)
IMAGE = ImageEntityDescription(key="image", name=None)
@@ -34,7 +36,6 @@ IMAGE = ImageEntityDescription(key="image", name=None)
class OasisDeviceImageEntity(OasisDeviceEntity, ImageEntity):
"""Oasis device image entity."""
_attr_content_type = "image/svg+xml"
_track_id: int | None = None
_progress: int = 0
@@ -52,33 +53,33 @@ 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")
)
if (svg := self.device.create_svg()) is None:
self._attr_image_url = self.device.track_image_url
self._attr_image_last_updated = dt_util.now()
return None
self._attr_content_type = "image/svg+xml"
self._cached_image = Image(self.content_type, svg)
return self._cached_image.content
@callback
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.status == "playing" or self._cached_image is None):
device = self.device
track_changed = self._track_id != device.track_id
progress_changed = self._progress != device.progress
allow_update = device.status == "playing" or self._cached_image is None
if (track_changed or progress_changed) and allow_update:
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()

View File

@@ -23,9 +23,9 @@ from homeassistant.util.color import (
value_to_brightness,
)
from . import OasisDeviceConfigEntry
from .coordinator import OasisDeviceCoordinator
from . import OasisDeviceConfigEntry, setup_platform_from_coordinator
from .entity import OasisDeviceEntity
from .pyoasiscontrol import OasisDevice
from .pyoasiscontrol.const import LED_EFFECTS
@@ -35,11 +35,14 @@ async def async_setup_entry(
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up Oasis device lights using config entry."""
coordinator: OasisDeviceCoordinator = entry.runtime_data
async_add_entities(
OasisDeviceLightEntity(coordinator, device, DESCRIPTOR)
for device in coordinator.data
)
def make_entities(new_devices: list[OasisDevice]):
return [
OasisDeviceLightEntity(entry.runtime_data, device, DESCRIPTOR)
for device in new_devices
]
setup_platform_from_coordinator(entry, async_add_entities, make_entities)
DESCRIPTOR = LightEntityDescription(key="led", translation_key="led")

View File

@@ -18,12 +18,11 @@ from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ServiceValidationError
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from . import OasisDeviceConfigEntry
from . import OasisDeviceConfigEntry, setup_platform_from_coordinator
from .const import DOMAIN
from .coordinator import OasisDeviceCoordinator
from .entity import OasisDeviceEntity
from .helpers import get_track_id
from .pyoasiscontrol.const import TRACKS
from .pyoasiscontrol import OasisDevice
async def async_setup_entry(
@@ -32,11 +31,14 @@ async def async_setup_entry(
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up Oasis device media_players using config entry."""
coordinator: OasisDeviceCoordinator = entry.runtime_data
async_add_entities(
OasisDeviceMediaPlayerEntity(coordinator, device, DESCRIPTOR)
for device in coordinator.data
)
def make_entities(new_devices: list[OasisDevice]):
return [
OasisDeviceMediaPlayerEntity(entry.runtime_data, device, DESCRIPTOR)
for device in new_devices
]
setup_platform_from_coordinator(entry, async_add_entities, make_entities)
DESCRIPTOR = MediaPlayerEntityDescription(key="oasis_mini", name=None)
@@ -73,11 +75,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 +90,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:

View File

@@ -7,12 +7,13 @@ from homeassistant.components.number import (
NumberEntityDescription,
NumberMode,
)
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from . import OasisDeviceConfigEntry
from .coordinator import OasisDeviceCoordinator
from . import OasisDeviceConfigEntry, setup_platform_from_coordinator
from .entity import OasisDeviceEntity
from .pyoasiscontrol import OasisDevice
from .pyoasiscontrol.device import (
BALL_SPEED_MAX,
BALL_SPEED_MIN,
@@ -27,18 +28,22 @@ async def async_setup_entry(
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up Oasis device numbers using config entry."""
coordinator: OasisDeviceCoordinator = entry.runtime_data
async_add_entities(
OasisDeviceNumberEntity(coordinator, device, descriptor)
for device in coordinator.data
for descriptor in DESCRIPTORS
)
def make_entities(new_devices: list[OasisDevice]):
return [
OasisDeviceNumberEntity(entry.runtime_data, device, descriptor)
for device in new_devices
for descriptor in DESCRIPTORS
]
setup_platform_from_coordinator(entry, async_add_entities, make_entities)
DESCRIPTORS = {
NumberEntityDescription(
key="ball_speed",
translation_key="ball_speed",
entity_category=EntityCategory.CONFIG,
mode=NumberMode.SLIDER,
native_max_value=BALL_SPEED_MAX,
native_min_value=BALL_SPEED_MIN,
@@ -46,6 +51,7 @@ DESCRIPTORS = {
NumberEntityDescription(
key="led_speed",
translation_key="led_speed",
entity_category=EntityCategory.CONFIG,
mode=NumberMode.SLIDER,
native_max_value=LED_SPEED_MAX,
native_min_value=LED_SPEED_MIN,

View File

@@ -2,6 +2,7 @@
from __future__ import annotations
import asyncio
from datetime import timedelta
import logging
from typing import Any
@@ -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: float
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
self._playlists_next_refresh = 0.0
self.playlists = []
self._playlist_details = {}
# playlists cache
self.playlists: list[dict[str, Any]] = []
self._playlists_next_refresh = now()
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:
@@ -143,8 +148,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."""
@@ -259,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,
@@ -316,9 +329,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,

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

@@ -2,6 +2,7 @@
from __future__ import annotations
import asyncio
import logging
from typing import TYPE_CHECKING, Any, Callable, Final, Iterable
@@ -12,15 +13,17 @@ from .const import (
STATUS_CODE_SLEEPING,
TRACKS,
)
from .utils import _bit_to_bool, _parse_int
from .utils import _bit_to_bool, _parse_int, create_svg, decrypt_svg_content
if TYPE_CHECKING: # avoid runtime circular imports
from .clients import OasisCloudClient
from .clients.transport import OasisClientProtocol
_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
@@ -60,19 +63,23 @@ 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,
client: OasisClientProtocol | None = None,
) -> None:
# Transport
self._client: OasisClientProtocol | None = client
self._cloud = cloud
self._client = client
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
@@ -80,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
@@ -105,8 +112,9 @@ class OasisDevice:
self.environment: str | None = None
self.schedule: Any | None = None
# Track metadata cache (used if you hydrate from cloud)
# Track metadata cache
self._track: dict | None = None
self._track_task: asyncio.Task | None = None
@property
def brightness(self) -> int:
@@ -145,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,
@@ -157,13 +166,20 @@ class OasisDevice:
def update_from_status_dict(self, data: dict[str, Any]) -> None:
"""Update device fields from a status payload (from any transport)."""
changed = False
playlist_or_index_changed = False
for key, value in data.items():
if hasattr(self, key):
if self._update_field(key, value):
changed = True
if key in ("playlist", "playlist_index"):
playlist_or_index_changed = True
else:
_LOGGER.warning("Unknown field: %s=%s", key, value)
if playlist_or_index_changed:
self.schedule_track_refresh()
if changed:
self._notify_listeners()
@@ -243,6 +259,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:
@@ -251,37 +274,45 @@ 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
def drawing_progress(self) -> float | None:
"""Return drawing progress percentage for the current track."""
# if not (self.track and (svg_content := self.track.get("svg_content"))):
# return None
# svg_content = decrypt_svg_content(svg_content)
# paths = svg_content.split("L")
total = self.track.get("reduced_svg_content_new", 0) # or len(paths)
if not (self.track and (svg_content := self.track.get("svg_content"))):
return None
svg_content = decrypt_svg_content(svg_content)
paths = svg_content.split("L")
total = self.track.get("reduced_svg_content_new", 0) or len(paths)
percent = (100 * self.progress) / total
return percent
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.
@@ -316,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")
@@ -330,7 +365,7 @@ class OasisDevice:
led_speed: int | None = None,
brightness: int | None = None,
) -> None:
"""Set the Oasis Mini LED (shared validation & attribute updates)."""
"""Set the Oasis device LED (shared validation & attribute updates)."""
if led_effect is None:
led_effect = self.led_effect
if color is None:
@@ -410,3 +445,43 @@ class OasisDevice:
async def async_reboot(self) -> None:
client = self._require_client()
await client.async_send_reboot_command(self)
def schedule_track_refresh(self) -> None:
"""Schedule an async refresh of current track info if track_id changed."""
if not self._cloud:
return
try:
loop = asyncio.get_running_loop()
except RuntimeError:
_LOGGER.debug("No running loop; cannot schedule track refresh")
return
if self._track_task and not self._track_task.done():
self._track_task.cancel()
self._track_task = loop.create_task(self._async_refresh_current_track())
async def _async_refresh_current_track(self) -> None:
"""Refresh the current track info."""
if not self._cloud:
return
if (track_id := self.track_id) is None:
self._track = None
return
if self._track and self._track.get("id") == track_id:
return
try:
track = await self._cloud.async_get_track_info(track_id)
except Exception: # noqa: BLE001
_LOGGER.exception("Error fetching track info for %s", track_id)
return
if not track:
return
self._track = track
self._notify_listeners()

View File

@@ -1,4 +1,4 @@
"""Oasis Mini utils."""
"""Oasis control utils."""
from __future__ import annotations
@@ -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:

View File

@@ -7,11 +7,12 @@ from dataclasses import dataclass
from typing import Any, Awaitable, Callable
from homeassistant.components.select import SelectEntity, SelectEntityDescription
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity import EntityDescription
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from . import OasisDeviceConfigEntry
from . import OasisDeviceConfigEntry, setup_platform_from_coordinator
from .coordinator import OasisDeviceCoordinator
from .entity import OasisDeviceEntity
from .pyoasiscontrol import OasisDevice
@@ -27,7 +28,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:
@@ -70,19 +71,15 @@ async def async_setup_entry(
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up Oasis device select using config entry."""
coordinator: OasisDeviceCoordinator = entry.runtime_data
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)
def make_entities(new_devices: list[OasisDevice]):
return [
OasisDeviceSelectEntity(entry.runtime_data, device, descriptor)
for device in new_devices
for descriptor in DESCRIPTORS
]
setup_platform_from_coordinator(entry, async_add_entities, make_entities)
@dataclass(frozen=True, kw_only=True)
@@ -98,12 +95,22 @@ DESCRIPTORS = (
OasisDeviceSelectEntityDescription(
key="autoplay",
translation_key="autoplay",
entity_category=EntityCategory.CONFIG,
options=AUTOPLAY_MAP_LIST,
current_value=lambda device: str(device.autoplay),
select_fn=lambda device, 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(
key="queue",
translation_key="queue",
@@ -112,17 +119,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):

View File

@@ -11,9 +11,9 @@ from homeassistant.const import PERCENTAGE, EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from . import OasisDeviceConfigEntry
from .coordinator import OasisDeviceCoordinator
from . import OasisDeviceConfigEntry, setup_platform_from_coordinator
from .entity import OasisDeviceEntity
from .pyoasiscontrol import OasisDevice
async def async_setup_entry(
@@ -22,18 +22,15 @@ async def async_setup_entry(
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up Oasis device sensors using config entry."""
coordinator: OasisDeviceCoordinator = entry.runtime_data
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)
def make_entities(new_devices: list[OasisDevice]):
return [
OasisDeviceSensorEntity(entry.runtime_data, device, descriptor)
for device in new_devices
for descriptor in DESCRIPTORS
]
setup_platform_from_coordinator(entry, async_add_entities, make_entities)
DESCRIPTORS = {
@@ -45,6 +42,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 +61,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."""

View File

@@ -144,6 +144,11 @@
"live": "Live drawing"
}
}
},
"switch": {
"auto_clean": {
"name": "Auto-clean"
}
}
},
"exceptions": {

View File

@@ -1,53 +1,57 @@
# """Oasis Mini switch entity."""
"""Oasis device switch entity."""
# from __future__ import annotations
from __future__ import annotations
# from typing import Any
from typing import Any
# from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription
# from homeassistant.core import HomeAssistant
# from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
# from . import OasisMiniConfigEntry
# from .entity import OasisMiniEntity
from . import OasisDeviceConfigEntry, setup_platform_from_coordinator
from .entity import OasisDeviceEntity
from .pyoasiscontrol import OasisDevice
# async def async_setup_entry(
# hass: HomeAssistant,
# entry: OasisMiniConfigEntry,
# async_add_entities: AddEntitiesCallback,
# ) -> None:
# """Set up Oasis Mini switchs using config entry."""
# async_add_entities(
# [
# OasisMiniSwitchEntity(entry.runtime_data, descriptor)
# for descriptor in DESCRIPTORS
# ]
# )
async def async_setup_entry(
hass: HomeAssistant,
entry: OasisDeviceConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up Oasis device switchs using config entry."""
def make_entities(new_devices: list[OasisDevice]):
return [
OasisDeviceSwitchEntity(entry.runtime_data, device, descriptor)
for device in new_devices
for descriptor in DESCRIPTORS
]
setup_platform_from_coordinator(entry, async_add_entities, make_entities)
# class OasisMiniSwitchEntity(OasisMiniEntity, SwitchEntity):
# """Oasis Mini switch entity."""
# @property
# def is_on(self) -> bool:
# """Return True if entity is on."""
# return int(getattr(self.device, self.entity_description.key))
# async def async_turn_off(self, **kwargs: Any) -> None:
# """Turn the entity off."""
# await self.device.async_set_repeat_playlist(False)
# await self.coordinator.async_request_refresh()
# async def async_turn_on(self, **kwargs: Any) -> None:
# """Turn the entity on."""
# await self.device.async_set_repeat_playlist(True)
# await self.coordinator.async_request_refresh()
DESCRIPTORS = {
SwitchEntityDescription(
key="auto_clean",
translation_key="auto_clean",
entity_category=EntityCategory.CONFIG,
),
}
# DESCRIPTORS = {
# SwitchEntityDescription(
# key="repeat_playlist",
# name="Repeat playlist",
# ),
# }
class OasisDeviceSwitchEntity(OasisDeviceEntity, SwitchEntity):
"""Oasis device switch entity."""
@property
def is_on(self) -> bool:
"""Return True if entity is on."""
return bool(getattr(self.device, self.entity_description.key))
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn the entity off."""
await self.device.async_set_auto_clean(False)
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn the entity on."""
await self.device.async_set_auto_clean(True)

View File

@@ -144,6 +144,11 @@
"live": "Live drawing"
}
}
},
"switch": {
"auto_clean": {
"name": "Auto-clean"
}
}
},
"exceptions": {

View File

@@ -15,9 +15,9 @@ from homeassistant.components.update import (
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from . import OasisDeviceConfigEntry
from .coordinator import OasisDeviceCoordinator
from . import OasisDeviceConfigEntry, setup_platform_from_coordinator
from .entity import OasisDeviceEntity
from .pyoasiscontrol import OasisDevice
_LOGGER = logging.getLogger(__name__)
@@ -30,12 +30,14 @@ async def async_setup_entry(
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up Oasis device updates using config entry."""
coordinator: OasisDeviceCoordinator = entry.runtime_data
entities = (
OasisDeviceUpdateEntity(coordinator, device, DESCRIPTOR)
for device in coordinator.data
)
async_add_entities(entities, True)
def make_entities(new_devices: list[OasisDevice]):
return [
OasisDeviceUpdateEntity(entry.runtime_data, device, DESCRIPTOR)
for device in new_devices
]
setup_platform_from_coordinator(entry, async_add_entities, make_entities, True)
DESCRIPTOR = UpdateEntityDescription(