diff --git a/custom_components/oasis_mini/__init__.py b/custom_components/oasis_mini/__init__.py index 3356379..204928b 100755 --- a/custom_components/oasis_mini/__init__.py +++ b/custom_components/oasis_mini/__init__.py @@ -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 + ) diff --git a/custom_components/oasis_mini/binary_sensor.py b/custom_components/oasis_mini/binary_sensor.py index 5c5330c..a6f9b15 100644 --- a/custom_components/oasis_mini/binary_sensor.py +++ b/custom_components/oasis_mini/binary_sensor.py @@ -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 = { diff --git a/custom_components/oasis_mini/button.py b/custom_components/oasis_mini/button.py index 0854e4c..8352128 100644 --- a/custom_components/oasis_mini/button.py +++ b/custom_components/oasis_mini/button.py @@ -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) diff --git a/custom_components/oasis_mini/coordinator.py b/custom_components/oasis_mini/coordinator.py index fdb326f..9c4a43b 100644 --- a/custom_components/oasis_mini/coordinator.py +++ b/custom_components/oasis_mini/coordinator.py @@ -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,34 +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, ) - await self.cloud_client.async_get_playlists() - self.attempt = 0 - 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 diff --git a/custom_components/oasis_mini/entity.py b/custom_components/oasis_mini/entity.py index 0dd8032..bdc3bd5 100644 --- a/custom_components/oasis_mini/entity.py +++ b/custom_components/oasis_mini/entity.py @@ -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, diff --git a/custom_components/oasis_mini/image.py b/custom_components/oasis_mini/image.py index c1ab908..7e32f16 100644 --- a/custom_components/oasis_mini/image.py +++ b/custom_components/oasis_mini/image.py @@ -6,8 +6,9 @@ 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 @@ -19,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) @@ -32,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 @@ -50,20 +53,29 @@ 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, 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 @callback def _handle_coordinator_update(self) -> None: """Handle updated data from the coordinator.""" device = self.device - if ( - self._track_id != device.track_id or self._progress != device.progress - ) and (device.status == "playing" or self._cached_image is None): + + 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 = device.track_id self._progress = device.progress self._cached_image = None + if device.track and device.track.get("svg_content"): self._attr_image_url = UNDEFINED else: diff --git a/custom_components/oasis_mini/light.py b/custom_components/oasis_mini/light.py index 1f131d7..5fcc394 100644 --- a/custom_components/oasis_mini/light.py +++ b/custom_components/oasis_mini/light.py @@ -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") diff --git a/custom_components/oasis_mini/media_player.py b/custom_components/oasis_mini/media_player.py index 6d45acc..4d2efab 100644 --- a/custom_components/oasis_mini/media_player.py +++ b/custom_components/oasis_mini/media_player.py @@ -18,11 +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 import OasisDevice async def async_setup_entry( @@ -31,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) diff --git a/custom_components/oasis_mini/number.py b/custom_components/oasis_mini/number.py index a59c2b8..38ba738 100644 --- a/custom_components/oasis_mini/number.py +++ b/custom_components/oasis_mini/number.py @@ -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, diff --git a/custom_components/oasis_mini/pyoasiscontrol/clients/cloud_client.py b/custom_components/oasis_mini/pyoasiscontrol/clients/cloud_client.py index cb0396b..ac9b3e3 100644 --- a/custom_components/oasis_mini/pyoasiscontrol/clients/cloud_client.py +++ b/custom_components/oasis_mini/pyoasiscontrol/clients/cloud_client.py @@ -2,7 +2,8 @@ from __future__ import annotations -from datetime import datetime, timedelta +import asyncio +from datetime import timedelta import logging from typing import Any from urllib.parse import urljoin @@ -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: datetime - 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 + # playlists cache + self.playlists: list[dict[str, Any]] = [] self._playlists_next_refresh = now() - self.playlists = [] - self._playlist_details = {} + 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.""" diff --git a/custom_components/oasis_mini/pyoasiscontrol/clients/mqtt_client.py b/custom_components/oasis_mini/pyoasiscontrol/clients/mqtt_client.py index d2c8c2a..96a1db5 100644 --- a/custom_components/oasis_mini/pyoasiscontrol/clients/mqtt_client.py +++ b/custom_components/oasis_mini/pyoasiscontrol/clients/mqtt_client.py @@ -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: @@ -260,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, diff --git a/custom_components/oasis_mini/pyoasiscontrol/clients/transport.py b/custom_components/oasis_mini/pyoasiscontrol/clients/transport.py index adc0d1d..b7c9e33 100644 --- a/custom_components/oasis_mini/pyoasiscontrol/clients/transport.py +++ b/custom_components/oasis_mini/pyoasiscontrol/clients/transport.py @@ -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, diff --git a/custom_components/oasis_mini/pyoasiscontrol/device.py b/custom_components/oasis_mini/pyoasiscontrol/device.py index 3e763a1..5cfa2f5 100644 --- a/custom_components/oasis_mini/pyoasiscontrol/device.py +++ b/custom_components/oasis_mini/pyoasiscontrol/device.py @@ -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() @@ -343,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") @@ -438,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 diff --git a/custom_components/oasis_mini/select.py b/custom_components/oasis_mini/select.py index 6fed382..8908878 100644 --- a/custom_components/oasis_mini/select.py +++ b/custom_components/oasis_mini/select.py @@ -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 @@ -70,12 +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 - async_add_entities( - OasisDeviceSelectEntity(coordinator, device, descriptor) - for device in coordinator.data - for descriptor in DESCRIPTORS - ) + + 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) @@ -91,6 +95,7 @@ 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: ( diff --git a/custom_components/oasis_mini/sensor.py b/custom_components/oasis_mini/sensor.py index f20eacd..9a1a21a 100644 --- a/custom_components/oasis_mini/sensor.py +++ b/custom_components/oasis_mini/sensor.py @@ -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,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( - OasisDeviceSensorEntity(coordinator, device, descriptor) - for device in coordinator.data - for descriptor in DESCRIPTORS - ) + + 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 = { diff --git a/custom_components/oasis_mini/strings.json b/custom_components/oasis_mini/strings.json index b279980..cb3eb59 100755 --- a/custom_components/oasis_mini/strings.json +++ b/custom_components/oasis_mini/strings.json @@ -144,6 +144,11 @@ "live": "Live drawing" } } + }, + "switch": { + "auto_clean": { + "name": "Auto-clean" + } } }, "exceptions": { diff --git a/custom_components/oasis_mini/switch.py b/custom_components/oasis_mini/switch.py index b94c1db..2c29e4b 100644 --- a/custom_components/oasis_mini/switch.py +++ b/custom_components/oasis_mini/switch.py @@ -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) diff --git a/custom_components/oasis_mini/translations/en.json b/custom_components/oasis_mini/translations/en.json index 7639f55..df16b01 100755 --- a/custom_components/oasis_mini/translations/en.json +++ b/custom_components/oasis_mini/translations/en.json @@ -144,6 +144,11 @@ "live": "Live drawing" } } + }, + "switch": { + "auto_clean": { + "name": "Auto-clean" + } } }, "exceptions": { diff --git a/custom_components/oasis_mini/update.py b/custom_components/oasis_mini/update.py index ade7698..9753574 100644 --- a/custom_components/oasis_mini/update.py +++ b/custom_components/oasis_mini/update.py @@ -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(