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

Dynamically handle devices and other enhancements

This commit is contained in:
Nathan Spencer
2025-11-23 22:49:26 +00:00
parent 83de1d5606
commit cf21a5d995
19 changed files with 430 additions and 170 deletions

View File

@@ -3,18 +3,22 @@
from __future__ import annotations from __future__ import annotations
import logging import logging
from typing import Any from typing import Any, Callable, Iterable
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_EMAIL, Platform from homeassistant.const import CONF_EMAIL, Platform
from homeassistant.core import HomeAssistant, callback from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import ConfigEntryAuthFailed 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.helpers.entity_registry as er
import homeassistant.util.dt as dt_util import homeassistant.util.dt as dt_util
from .const import DOMAIN
from .coordinator import OasisDeviceCoordinator from .coordinator import OasisDeviceCoordinator
from .entity import OasisDeviceEntity
from .helpers import create_client from .helpers import create_client
from .pyoasiscontrol import OasisMqttClient, UnauthenticatedError from .pyoasiscontrol import OasisDevice, OasisMqttClient, UnauthenticatedError
type OasisDeviceConfigEntry = ConfigEntry[OasisDeviceCoordinator] type OasisDeviceConfigEntry = ConfigEntry[OasisDeviceCoordinator]
@@ -29,10 +33,47 @@ PLATFORMS = [
Platform.NUMBER, Platform.NUMBER,
Platform.SELECT, Platform.SELECT,
Platform.SENSOR, Platform.SENSOR,
Platform.SWITCH,
Platform.UPDATE, 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: async def async_setup_entry(hass: HomeAssistant, entry: OasisDeviceConfigEntry) -> bool:
"""Set up Oasis devices from a config entry.""" """Set up Oasis devices from a config entry."""
cloud_client = create_client(hass, entry.data) cloud_client = create_client(hass, entry.data)
@@ -148,3 +189,15 @@ async def async_migrate_entry(hass: HomeAssistant, entry: OasisDeviceConfigEntry
) )
return True 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.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback 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 .entity import OasisDeviceEntity
from .pyoasiscontrol import OasisDevice
async def async_setup_entry( async def async_setup_entry(
@@ -22,12 +22,15 @@ async def async_setup_entry(
async_add_entities: AddEntitiesCallback, async_add_entities: AddEntitiesCallback,
) -> None: ) -> None:
"""Set up Oasis device sensors using config entry.""" """Set up Oasis device sensors using config entry."""
coordinator: OasisDeviceCoordinator = entry.runtime_data
async_add_entities( def make_entities(new_devices: list[OasisDevice]):
OasisDeviceBinarySensorEntity(coordinator, device, descriptor) return [
for device in coordinator.data OasisDeviceBinarySensorEntity(entry.runtime_data, device, descriptor)
for descriptor in DESCRIPTORS for device in new_devices
) for descriptor in DESCRIPTORS
]
setup_platform_from_coordinator(entry, async_add_entities, make_entities)
DESCRIPTORS = { DESCRIPTORS = {

View File

@@ -13,10 +13,10 @@ from homeassistant.components.button import (
) )
from homeassistant.const import EntityCategory from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddEntitiesCallback 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 .entity import OasisDeviceEntity
from .helpers import add_and_play_track from .helpers import add_and_play_track
from .pyoasiscontrol import OasisDevice from .pyoasiscontrol import OasisDevice
@@ -29,18 +29,24 @@ async def async_setup_entry(
async_add_entities: AddEntitiesCallback, async_add_entities: AddEntitiesCallback,
) -> None: ) -> None:
"""Set up Oasis device button using config entry.""" """Set up Oasis device button using config entry."""
coordinator: OasisDeviceCoordinator = entry.runtime_data
async_add_entities( def make_entities(new_devices: list[OasisDevice]):
OasisDeviceButtonEntity(coordinator, device, descriptor) return [
for device in coordinator.data OasisDeviceButtonEntity(entry.runtime_data, device, descriptor)
for descriptor in DESCRIPTORS 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: async def play_random_track(device: OasisDevice) -> None:
"""Play random track.""" """Play random track."""
track = random.choice(list(TRACKS)) 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) @dataclass(frozen=True, kw_only=True)

View File

@@ -8,6 +8,7 @@ import logging
import async_timeout import async_timeout
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
import homeassistant.util.dt as dt_util import homeassistant.util.dt as dt_util
@@ -46,34 +47,114 @@ class OasisDeviceCoordinator(DataUpdateCoordinator[list[OasisDevice]]):
self.attempt += 1 self.attempt += 1
try: try:
async with async_timeout.timeout(10): async with async_timeout.timeout(30):
if not self.data: raw_devices = await self.cloud_client.async_get_devices()
raw_devices = await self.cloud_client.async_get_devices()
devices = [ existing_by_serial = {
OasisDevice( d.serial_number: d for d in (self.data or []) if d.serial_number
model=raw_device.get("model", {}).get("name"), }
serial_number=raw_device.get("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, cloud=self.cloud_client,
) )
for raw_device in raw_devices
] devices.append(device)
else:
devices = self.data new_serials = {d.serial_number for d in devices if d.serial_number}
for device in devices: removed_serials = set(existing_by_serial) - new_serials
self.mqtt_client.register_device(device)
await self.mqtt_client.wait_until_ready(device, request_status=True) if removed_serials:
if not await device.async_get_mac_address(): device_registry = dr.async_get(self.hass)
raise Exception( for serial in removed_serials:
"Could not get mac address for %s", device.serial_number _LOGGER.info(
"Oasis device %s removed from account; cleaning up in HA",
serial,
) )
await self.cloud_client.async_get_playlists() device_entry = device_registry.async_get_device(
self.attempt = 0 identifiers={(DOMAIN, serial)}
except Exception as ex: # pylint:disable=broad-except )
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): if self.attempt > 2 or not (devices or self.data):
raise UpdateFailed( 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 ) from ex
if devices != self.data: if devices != self.data:
self.last_updated = dt_util.now() self.last_updated = dt_util.now()
return devices return devices

View File

@@ -37,7 +37,7 @@ class OasisDeviceEntity(CoordinatorEntity[OasisDeviceCoordinator]):
self._attr_device_info = DeviceInfo( self._attr_device_info = DeviceInfo(
connections=connections, connections=connections,
identifiers={(DOMAIN, serial_number)}, identifiers={(DOMAIN, serial_number)},
name=f"{device.model} {serial_number}", name=device.name,
manufacturer=device.manufacturer, manufacturer=device.manufacturer,
model=device.model, model=device.model,
serial_number=serial_number, serial_number=serial_number,

View File

@@ -6,8 +6,9 @@ from homeassistant.components.image import Image, ImageEntity, ImageEntityDescri
from homeassistant.core import HomeAssistant, callback from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import UNDEFINED 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 .coordinator import OasisDeviceCoordinator
from .entity import OasisDeviceEntity from .entity import OasisDeviceEntity
from .pyoasiscontrol import OasisDevice from .pyoasiscontrol import OasisDevice
@@ -19,11 +20,14 @@ async def async_setup_entry(
async_add_entities: AddEntitiesCallback, async_add_entities: AddEntitiesCallback,
) -> None: ) -> None:
"""Set up Oasis device image using config entry.""" """Set up Oasis device image using config entry."""
coordinator: OasisDeviceCoordinator = entry.runtime_data
async_add_entities( def make_entities(new_devices: list[OasisDevice]):
OasisDeviceImageEntity(coordinator, device, IMAGE) return [
for device in coordinator.data 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) IMAGE = ImageEntityDescription(key="image", name=None)
@@ -32,7 +36,6 @@ IMAGE = ImageEntityDescription(key="image", name=None)
class OasisDeviceImageEntity(OasisDeviceEntity, ImageEntity): class OasisDeviceImageEntity(OasisDeviceEntity, ImageEntity):
"""Oasis device image entity.""" """Oasis device image entity."""
_attr_content_type = "image/svg+xml"
_track_id: int | None = None _track_id: int | None = None
_progress: int = 0 _progress: int = 0
@@ -50,20 +53,29 @@ 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.content_type, self.device.create_svg()) 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 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 device = self.device
if (
self._track_id != device.track_id or self._progress != device.progress track_changed = self._track_id != device.track_id
) and (device.status == "playing" or self._cached_image is None): 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._attr_image_last_updated = self.coordinator.last_updated
self._track_id = device.track_id self._track_id = device.track_id
self._progress = device.progress self._progress = device.progress
self._cached_image = None self._cached_image = None
if device.track and 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:

View File

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

View File

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

View File

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

View File

@@ -2,7 +2,8 @@
from __future__ import annotations from __future__ import annotations
from datetime import datetime, timedelta import asyncio
from datetime import timedelta
import logging import logging
from typing import Any from typing import Any
from urllib.parse import urljoin from urllib.parse import urljoin
@@ -16,6 +17,7 @@ _LOGGER = logging.getLogger(__name__)
BASE_URL = "https://app.grounded.so" BASE_URL = "https://app.grounded.so"
PLAYLISTS_REFRESH_LIMITER = timedelta(minutes=5) PLAYLISTS_REFRESH_LIMITER = timedelta(minutes=5)
SOFTWARE_REFRESH_LIMITER = timedelta(hours=1)
class OasisCloudClient: class OasisCloudClient:
@@ -32,15 +34,6 @@ class OasisCloudClient:
* latest software metadata * latest software metadata
""" """
_session: ClientSession | None
_owns_session: bool
_access_token: str | None
# these are "cache" fields for tracks/playlists
_playlists_next_refresh: datetime
playlists: list[dict[str, Any]]
_playlist_details: dict[int, dict[str, str]]
def __init__( def __init__(
self, self,
*, *,
@@ -51,10 +44,17 @@ class OasisCloudClient:
self._owns_session = session is None self._owns_session = session is None
self._access_token = access_token self._access_token = access_token
# simple in-memory caches # playlists cache
self.playlists: list[dict[str, Any]] = []
self._playlists_next_refresh = now() self._playlists_next_refresh = now()
self.playlists = [] self._playlists_lock = asyncio.Lock()
self._playlist_details = {}
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 @property
def session(self) -> ClientSession: def session(self) -> ClientSession:
@@ -105,15 +105,32 @@ class OasisCloudClient:
self, personal_only: bool = False self, personal_only: bool = False
) -> list[dict[str, Any]]: ) -> list[dict[str, Any]]:
"""Get playlists from the cloud (cached by PLAYLISTS_REFRESH_LIMITER).""" """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()} params = {"my_playlists": str(personal_only).lower()}
playlists = await self._async_auth_request( playlists = await self._async_auth_request(
"GET", "api/playlist", params=params "GET", "api/playlist", params=params
) )
if playlists:
self.playlists = playlists if not isinstance(playlists, list):
self._playlists_next_refresh = now() + PLAYLISTS_REFRESH_LIMITER playlists = []
return self.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: async def async_get_track_info(self, track_id: int) -> dict[str, Any] | None:
"""Get single track info from the cloud.""" """Get single track info from the cloud."""
@@ -143,9 +160,37 @@ class OasisCloudClient:
track_details += response.get("data", []) track_details += response.get("data", [])
return track_details return track_details
async def async_get_latest_software_details(self) -> dict[str, int | str]: async def async_get_latest_software_details(
"""Get latest software metadata from cloud.""" self, *, force_refresh: bool = False
return await self._async_auth_request("GET", "api/software/last-version") ) -> 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: async def _async_auth_request(self, method: str, url: str, **kwargs: Any) -> Any:
"""Perform an authenticated cloud request.""" """Perform an authenticated cloud request."""

View File

@@ -7,7 +7,7 @@ import base64
from datetime import UTC, datetime from datetime import UTC, datetime
import logging import logging
import ssl import ssl
from typing import Any, Final from typing import Any, Final, Iterable
import aiomqtt import aiomqtt
@@ -92,6 +92,11 @@ class OasisMqttClient(OasisClientProtocol):
"Could not schedule subscription for %s (no running loop)", serial "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: def unregister_device(self, device: OasisDevice) -> None:
serial = device.serial_number serial = device.serial_number
if not serial: if not serial:
@@ -260,6 +265,13 @@ class OasisMqttClient(OasisClientProtocol):
return device.mac_address 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( async def async_send_ball_speed_command(
self, self,
device: OasisDevice, 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_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( async def async_send_ball_speed_command(
self, self,
device: OasisDevice, device: OasisDevice,

View File

@@ -23,6 +23,7 @@ _LOGGER = logging.getLogger(__name__)
BALL_SPEED_MAX: Final = 400 BALL_SPEED_MAX: Final = 400
BALL_SPEED_MIN: Final = 100 BALL_SPEED_MIN: Final = 100
BRIGHTNESS_DEFAULT: Final = 100
LED_SPEED_MAX: Final = 90 LED_SPEED_MAX: Final = 90
LED_SPEED_MIN: Final = -90 LED_SPEED_MIN: Final = -90
@@ -62,6 +63,7 @@ class OasisDevice:
*, *,
model: str | None = None, model: str | None = None,
serial_number: str | None = None, serial_number: str | None = None,
name: str | None = None,
ssid: str | None = None, ssid: str | None = None,
ip_address: str | None = None, ip_address: str | None = None,
cloud: OasisCloudClient | None = None, cloud: OasisCloudClient | None = None,
@@ -73,10 +75,11 @@ class OasisDevice:
self._listeners: list[Callable[[], None]] = [] self._listeners: list[Callable[[], None]] = []
# Details # Details
self.model: str | None = model self.model = model
self.serial_number: str | None = serial_number self.serial_number = serial_number
self.ssid: str | None = ssid self.name = name if name else f"{model} {serial_number}"
self.ip_address: str | None = ip_address self.ssid = ssid
self.ip_address = ip_address
# Status # Status
self.auto_clean: bool = False self.auto_clean: bool = False
@@ -84,7 +87,7 @@ class OasisDevice:
self.ball_speed: int = BALL_SPEED_MIN self.ball_speed: int = BALL_SPEED_MIN
self._brightness: int = 0 self._brightness: int = 0
self.brightness_max: int = 200 self.brightness_max: int = 200
self.brightness_on: int = 0 self.brightness_on: int = BRIGHTNESS_DEFAULT
self.busy: bool = False self.busy: bool = False
self.color: str | None = None self.color: str | None = None
self.download_progress: int = 0 self.download_progress: int = 0
@@ -150,7 +153,8 @@ class OasisDevice:
old = getattr(self, name, None) old = getattr(self, name, None)
if old != value: if old != value:
_LOGGER.debug( _LOGGER.debug(
"%s changed: '%s' -> '%s'", "%s %s changed: '%s' -> '%s'",
self.serial_number,
name.replace("_", " ").capitalize(), name.replace("_", " ").capitalize(),
old, old,
value, value,
@@ -174,7 +178,7 @@ class OasisDevice:
_LOGGER.warning("Unknown field: %s=%s", key, value) _LOGGER.warning("Unknown field: %s=%s", key, value)
if playlist_or_index_changed: if playlist_or_index_changed:
self._schedule_track_refresh() self.schedule_track_refresh()
if changed: if changed:
self._notify_listeners() self._notify_listeners()
@@ -343,6 +347,10 @@ class OasisDevice:
self._update_field("mac_address", mac) self._update_field("mac_address", mac)
return 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: async def async_set_ball_speed(self, speed: int) -> None:
if not BALL_SPEED_MIN <= speed <= BALL_SPEED_MAX: if not BALL_SPEED_MIN <= speed <= BALL_SPEED_MAX:
raise ValueError("Invalid speed specified") raise ValueError("Invalid speed specified")
@@ -438,7 +446,7 @@ class OasisDevice:
client = self._require_client() client = self._require_client()
await client.async_send_reboot_command(self) 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.""" """Schedule an async refresh of current track info if track_id changed."""
if not self._cloud: if not self._cloud:
return return

View File

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

View File

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

View File

@@ -144,6 +144,11 @@
"live": "Live drawing" "live": "Live drawing"
} }
} }
},
"switch": {
"auto_clean": {
"name": "Auto-clean"
}
} }
}, },
"exceptions": { "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.components.switch import SwitchEntity, SwitchEntityDescription
# from homeassistant.core import HomeAssistant from homeassistant.const import EntityCategory
# from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
# from . import OasisMiniConfigEntry from . import OasisDeviceConfigEntry, setup_platform_from_coordinator
# from .entity import OasisMiniEntity from .entity import OasisDeviceEntity
from .pyoasiscontrol import OasisDevice
# async def async_setup_entry( async def async_setup_entry(
# hass: HomeAssistant, hass: HomeAssistant,
# entry: OasisMiniConfigEntry, entry: OasisDeviceConfigEntry,
# async_add_entities: AddEntitiesCallback, async_add_entities: AddEntitiesCallback,
# ) -> None: ) -> None:
# """Set up Oasis Mini switchs using config entry.""" """Set up Oasis device switchs using config entry."""
# async_add_entities(
# [ def make_entities(new_devices: list[OasisDevice]):
# OasisMiniSwitchEntity(entry.runtime_data, descriptor) return [
# for descriptor in DESCRIPTORS 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): DESCRIPTORS = {
# """Oasis Mini switch entity.""" SwitchEntityDescription(
key="auto_clean",
# @property translation_key="auto_clean",
# def is_on(self) -> bool: entity_category=EntityCategory.CONFIG,
# """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 = { class OasisDeviceSwitchEntity(OasisDeviceEntity, SwitchEntity):
# SwitchEntityDescription( """Oasis device switch entity."""
# key="repeat_playlist",
# name="Repeat playlist", @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" "live": "Live drawing"
} }
} }
},
"switch": {
"auto_clean": {
"name": "Auto-clean"
}
} }
}, },
"exceptions": { "exceptions": {

View File

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