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

2 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
21 changed files with 502 additions and 264 deletions

View File

@@ -3,18 +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]
@@ -29,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)
@@ -148,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,6 +8,7 @@ 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
@@ -46,40 +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
for device in devices:
self.mqtt_client.register_device(device)
await self.mqtt_client.wait_until_ready(device, request_status=True)
if not await device.async_get_mac_address():
raise Exception(
"Could not get mac address for %s", device.serial_number
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,
)
# 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
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:
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 = dt_util.now()
return devices

View File

@@ -37,7 +37,7 @@ class OasisDeviceEntity(CoordinatorEntity[OasisDeviceCoordinator]):
self._attr_device_info = DeviceInfo(
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

@@ -13,7 +13,7 @@ from .const import (
STATUS_CODE_SLEEPING,
TRACKS,
)
from .utils import _bit_to_bool, _parse_int, decrypt_svg_content
from .utils import _bit_to_bool, _parse_int, create_svg, decrypt_svg_content
if TYPE_CHECKING: # avoid runtime circular imports
from .clients import OasisCloudClient
@@ -23,6 +23,7 @@ _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
@@ -62,6 +63,7 @@ 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,
@@ -73,10 +75,11 @@ class OasisDevice:
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
@@ -84,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
@@ -150,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,
@@ -174,7 +178,7 @@ class OasisDevice:
_LOGGER.warning("Unknown field: %s=%s", key, value)
if playlist_or_index_changed:
self._schedule_track_refresh()
self.schedule_track_refresh()
if changed:
self._notify_listeners()
@@ -255,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:
@@ -263,13 +274,17 @@ class OasisDevice:
return self.playlist[0] if i >= len(self.playlist) else self.playlist[i]
@property
def track(self) -> dict | None:
"""Return cached track info if it matches the current `track_id`."""
if self._track and self._track.get("id") == self.track_id:
return self._track
if track := TRACKS.get(self.track_id):
self._track = track
return self._track
def track_image_url(self) -> str | None:
"""Return the track image url, if any."""
if (track := self.track) and (image := track.get("image")):
return f"https://app.grounded.so/uploads/{image}"
return None
@property
def track_name(self) -> str | None:
"""Return the track name, if any."""
if track := self.track:
return track.get("name", f"Unknown Title (#{self.track_id})")
return None
@property
@@ -281,19 +296,23 @@ class OasisDevice:
paths = svg_content.split("L")
total = self.track.get("reduced_svg_content_new", 0) or len(paths)
percent = (100 * self.progress) / total
return max(percent, 100)
return min(percent, 100)
@property
def playlist_details(self) -> dict[int, dict[str, str]]:
"""Basic playlist details using built-in TRACKS metadata."""
return {
track_id: TRACKS.get(
track_id: {self.track_id: self.track or {}, **TRACKS}.get(
track_id,
{"name": f"Unknown Title (#{track_id})"},
)
for track_id in self.playlist
}
def create_svg(self) -> str | None:
"""Create the current svg based on track and progress."""
return create_svg(self.track, self.progress)
def add_update_listener(self, listener: Callable[[], None]) -> Callable[[], None]:
"""Register a callback for state changes.
@@ -328,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")
@@ -423,7 +446,7 @@ class OasisDevice:
client = self._require_client()
await client.async_send_reboot_command(self)
def _schedule_track_refresh(self) -> None:
def schedule_track_refresh(self) -> None:
"""Schedule an async refresh of current track info if track_id changed."""
if not self._cloud:
return

View File

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